's'
This commit is contained in:
@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
|
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true, user_id: userId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Join API error:", e);
|
console.error("Join API error:", e);
|
||||||
|
|||||||
301
app/api/pay/route.ts
Normal file
301
app/api/pay/route.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { PAYMENT_API_URL, SITE_URL } from "../../lib/env";
|
||||||
|
|
||||||
|
const ZPAY_SUBMIT = "https://zpayz.cn/submit.php";
|
||||||
|
|
||||||
|
async function createPayOrder(
|
||||||
|
user_id: string,
|
||||||
|
return_url: string,
|
||||||
|
total_fee = 80,
|
||||||
|
type = "meetup",
|
||||||
|
channel = "alipay"
|
||||||
|
) {
|
||||||
|
if (!PAYMENT_API_URL) throw new Error("未配置 PAYMENT_API_URL");
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
user_id,
|
||||||
|
total_fee: Number(total_fee) || 80,
|
||||||
|
type,
|
||||||
|
channel,
|
||||||
|
return_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const res = await fetch(`${PAYMENT_API_URL}/payh5`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => clearTimeout(timeout));
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data?.status !== "ok") {
|
||||||
|
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = data.params || data.xorpay_params || {};
|
||||||
|
const payUrl = data.pay_url || data.xorpay_url || ZPAY_SUBMIT;
|
||||||
|
if (!params || typeof params !== "object") {
|
||||||
|
throw new Error("payjsapi 未返回支付参数");
|
||||||
|
}
|
||||||
|
params = JSON.parse(JSON.stringify(params));
|
||||||
|
if (data.provider === "xorpay") {
|
||||||
|
throw new Error("当前 payjsapi 使用 xorpay,请设置 PAYMENT_PROVIDER=zpay 后重启 payjsapi");
|
||||||
|
}
|
||||||
|
return { params, payUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
|
||||||
|
const flat: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v == null) continue;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (s === "") continue;
|
||||||
|
flat[k] = s;
|
||||||
|
}
|
||||||
|
for (const key of ZPAY_REQUIRED) {
|
||||||
|
if (!flat[key]) {
|
||||||
|
console.error(`ZPAY 缺少必填参数: ${key}`, flat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inputs = Object.entries(flat)
|
||||||
|
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>跳转支付...</title>
|
||||||
|
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>正在跳转到支付页面...</p>
|
||||||
|
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
|
||||||
|
${inputs}
|
||||||
|
</form>
|
||||||
|
<script>document.getElementById("zpayForm").submit();</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
let body: Record<string, string | number>;
|
||||||
|
const contentType = request.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
body = await request.json();
|
||||||
|
} else {
|
||||||
|
const form = await request.formData();
|
||||||
|
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user_id = String(body?.user_id || "").trim();
|
||||||
|
const return_url = String(body?.return_url || "").trim();
|
||||||
|
const total_fee = Number(body?.total_fee) || 80;
|
||||||
|
const type = String(body?.type || "meetup");
|
||||||
|
const channel = String(body?.channel || "alipay");
|
||||||
|
const device = String(body?.device || "pc"); // pc | h5,前端根据 UA 传入
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin =
|
||||||
|
request.headers.get("x-forwarded-host")
|
||||||
|
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
||||||
|
: request.headers.get("origin") || SITE_URL;
|
||||||
|
const finalReturnUrl = return_url || `${origin}/join?paid=1`;
|
||||||
|
|
||||||
|
const { params, payUrl } = await createPayOrder(
|
||||||
|
user_id,
|
||||||
|
finalReturnUrl,
|
||||||
|
total_fee,
|
||||||
|
type,
|
||||||
|
channel
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!params || Object.keys(params).length === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = request.headers.get("accept") || "";
|
||||||
|
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
|
||||||
|
if (wantHtml) {
|
||||||
|
const clientIp =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"";
|
||||||
|
// 使用 payjsapi /payh5/redirect:mapi.php API接口支付,按 device 返回 payurl/payurl2
|
||||||
|
const redirectPayload = {
|
||||||
|
user_id,
|
||||||
|
total_fee,
|
||||||
|
type,
|
||||||
|
channel,
|
||||||
|
return_url: finalReturnUrl,
|
||||||
|
device,
|
||||||
|
...(clientIp && { client_ip: clientIp }),
|
||||||
|
};
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||||
|
const redirectRes = await fetch(`${PAYMENT_API_URL}/payh5/redirect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(redirectPayload),
|
||||||
|
redirect: "manual",
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => clearTimeout(timeout));
|
||||||
|
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||||
|
const location = redirectRes.headers.get("location");
|
||||||
|
if (location) return NextResponse.redirect(location);
|
||||||
|
}
|
||||||
|
const resData = await redirectRes.json().catch(() => ({}));
|
||||||
|
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||||
|
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||||
|
);
|
||||||
|
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||||
|
);
|
||||||
|
const orderId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||||
|
);
|
||||||
|
const ch = String(resData.channel || "alipay").toLowerCase();
|
||||||
|
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||||
|
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||||
|
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||||
|
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${esc(title)}</title>
|
||||||
|
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}a{color:#0ea5e9;margin-top:16px;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
|
||||||
|
<body>
|
||||||
|
<h2>${esc(title)}</h2>
|
||||||
|
<p>${esc(hint)}</p>
|
||||||
|
<img src="${imgSrc}" alt="支付二维码" />
|
||||||
|
<p class="tip" id="tip">支付成功后将自动跳转...</p>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var orderId="${orderId}";
|
||||||
|
var returnUrl="${returnUrl}";
|
||||||
|
if(!orderId){return;}
|
||||||
|
var tip=document.getElementById("tip");
|
||||||
|
var t=0;
|
||||||
|
var maxT=300;
|
||||||
|
function check(){
|
||||||
|
t++;
|
||||||
|
if(t>maxT){tip.textContent="轮询超时";return;}
|
||||||
|
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(d){
|
||||||
|
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
|
||||||
|
setTimeout(check,2000);
|
||||||
|
})
|
||||||
|
.catch(function(){setTimeout(check,2000);});
|
||||||
|
}
|
||||||
|
setTimeout(check,2000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
return new NextResponse(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const errData = resData;
|
||||||
|
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: errData?.detail || errData?.msg || "支付跳转失败" },
|
||||||
|
{ status: redirectRes.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 降级:payjsapi 未提供 redirect 或非 zpay,使用原有表单提交逻辑
|
||||||
|
const formParams = new URLSearchParams();
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const key of ZPAY_REQUIRED) {
|
||||||
|
const v = params[key];
|
||||||
|
if (v == null || String(v).trim() === "") {
|
||||||
|
missing.push(key);
|
||||||
|
} else {
|
||||||
|
formParams.append(key, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (!ZPAY_REQUIRED.includes(k) && v != null && String(v).trim() !== "") {
|
||||||
|
formParams.append(k, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: `payjsapi 返回参数缺少: ${missing.join(", ")}。请确认 payjsapi 已设置 PAYMENT_PROVIDER=zpay`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const zpayRes = await fetch(ZPAY_SUBMIT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formParams.toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const location = zpayRes.headers.get("location");
|
||||||
|
if ((zpayRes.status === 301 || zpayRes.status === 302) && location) {
|
||||||
|
return NextResponse.redirect(location);
|
||||||
|
}
|
||||||
|
const text = await zpayRes.text();
|
||||||
|
try {
|
||||||
|
const errJson = JSON.parse(text);
|
||||||
|
if (errJson?.code === "error") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: errJson.msg || "ZPAY 返回错误" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const html = buildPayHtml(payUrl, params as Record<string, unknown>);
|
||||||
|
return new NextResponse(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
pay_url: payUrl,
|
||||||
|
params,
|
||||||
|
provider: "zpay",
|
||||||
|
submit_method: "POST",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
const msg = err.message || "";
|
||||||
|
const hint =
|
||||||
|
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||||
|
? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py"
|
||||||
|
: err.name === "AbortError"
|
||||||
|
? "请求超时,请检查 payjsapi 是否正常运行"
|
||||||
|
: msg;
|
||||||
|
console.error("Pay API error:", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `支付请求失败: ${hint}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/api/pay/status/route.ts
Normal file
22
app/api/pay/status/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { PAYMENT_API_URL } from "../../../lib/env";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const orderId = request.nextUrl.searchParams.get("order_id");
|
||||||
|
if (!orderId) {
|
||||||
|
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!PAYMENT_API_URL) {
|
||||||
|
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${PAYMENT_API_URL}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json({ ok: true, paid: !!data?.paid });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,124 +1,28 @@
|
|||||||
const categories = [
|
import toolsData from "../data/tools.json";
|
||||||
{
|
|
||||||
icon: "💬",
|
|
||||||
title: "远程协作",
|
|
||||||
count: 12,
|
|
||||||
tools: [
|
|
||||||
{ name: "Slack", desc: "团队沟通" },
|
|
||||||
{ name: "Notion", desc: "知识管理" },
|
|
||||||
{ name: "Linear", desc: "项目追踪" },
|
|
||||||
{ name: "Figma", desc: "协同设计" },
|
|
||||||
],
|
|
||||||
color: "from-sky-500 to-cyan-500",
|
|
||||||
bg: "bg-sky-50",
|
|
||||||
text: "text-sky-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "🎥",
|
|
||||||
title: "视频会议",
|
|
||||||
count: 8,
|
|
||||||
tools: [
|
|
||||||
{ name: "Zoom", desc: "视频会议" },
|
|
||||||
{ name: "Google Meet", desc: "在线会议" },
|
|
||||||
{ name: "Around", desc: "轻量会议" },
|
|
||||||
{ name: "Loom", desc: "异步视频" },
|
|
||||||
],
|
|
||||||
color: "from-violet-500 to-purple-500",
|
|
||||||
bg: "bg-violet-50",
|
|
||||||
text: "text-violet-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "📋",
|
|
||||||
title: "项目管理",
|
|
||||||
count: 10,
|
|
||||||
tools: [
|
|
||||||
{ name: "Trello", desc: "看板管理" },
|
|
||||||
{ name: "Asana", desc: "任务管理" },
|
|
||||||
{ name: "Monday", desc: "工作流" },
|
|
||||||
{ name: "ClickUp", desc: "全能协作" },
|
|
||||||
],
|
|
||||||
color: "from-emerald-500 to-teal-500",
|
|
||||||
bg: "bg-emerald-50",
|
|
||||||
text: "text-emerald-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "💻",
|
|
||||||
title: "开发工具",
|
|
||||||
count: 15,
|
|
||||||
tools: [
|
|
||||||
{ name: "VS Code", desc: "代码编辑" },
|
|
||||||
{ name: "GitHub", desc: "代码托管" },
|
|
||||||
{ name: "Vercel", desc: "部署平台" },
|
|
||||||
{ name: "Cursor", desc: "AI 编程" },
|
|
||||||
],
|
|
||||||
color: "from-blue-500 to-indigo-500",
|
|
||||||
bg: "bg-blue-50",
|
|
||||||
text: "text-blue-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "🎨",
|
|
||||||
title: "设计工具",
|
|
||||||
count: 9,
|
|
||||||
tools: [
|
|
||||||
{ name: "Figma", desc: "UI 设计" },
|
|
||||||
{ name: "Canva", desc: "图形设计" },
|
|
||||||
{ name: "Framer", desc: "网页设计" },
|
|
||||||
{ name: "Midjourney", desc: "AI 作图" },
|
|
||||||
],
|
|
||||||
color: "from-pink-500 to-rose-500",
|
|
||||||
bg: "bg-pink-50",
|
|
||||||
text: "text-pink-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "💳",
|
|
||||||
title: "财务工具",
|
|
||||||
count: 7,
|
|
||||||
tools: [
|
|
||||||
{ name: "Wise", desc: "跨境转账" },
|
|
||||||
{ name: "PayPal", desc: "在线支付" },
|
|
||||||
{ name: "Stripe", desc: "收款平台" },
|
|
||||||
{ name: "Deel", desc: "薪酬合规" },
|
|
||||||
],
|
|
||||||
color: "from-amber-500 to-orange-500",
|
|
||||||
bg: "bg-amber-50",
|
|
||||||
text: "text-amber-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "🔒",
|
|
||||||
title: "安全与 VPN",
|
|
||||||
count: 6,
|
|
||||||
tools: [
|
|
||||||
{ name: "NordVPN", desc: "网络安全" },
|
|
||||||
{ name: "1Password", desc: "密码管理" },
|
|
||||||
{ name: "Authy", desc: "双因素认证" },
|
|
||||||
{ name: "ProtonMail", desc: "加密邮件" },
|
|
||||||
],
|
|
||||||
color: "from-slate-500 to-gray-600",
|
|
||||||
bg: "bg-slate-50",
|
|
||||||
text: "text-slate-700",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "📝",
|
|
||||||
title: "生产力",
|
|
||||||
count: 11,
|
|
||||||
tools: [
|
|
||||||
{ name: "Obsidian", desc: "笔记系统" },
|
|
||||||
{ name: "Todoist", desc: "待办清单" },
|
|
||||||
{ name: "RescueTime", desc: "时间追踪" },
|
|
||||||
{ name: "Arc", desc: "浏览器" },
|
|
||||||
],
|
|
||||||
color: "from-teal-500 to-cyan-500",
|
|
||||||
bg: "bg-teal-50",
|
|
||||||
text: "text-teal-700",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const toolStats = [
|
const categories = toolsData.categories as Array<{
|
||||||
{ value: "50+", label: "精选工具" },
|
icon: string;
|
||||||
{ value: "8", label: "分类" },
|
title: string;
|
||||||
{ value: "15", label: "开发工具" },
|
color: string;
|
||||||
{ value: "12", label: "协作工具" },
|
bg: string;
|
||||||
];
|
text: string;
|
||||||
|
tools: Array<{ name: string; desc: string; href: string }>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function truncateDesc(desc: string, maxLen = 5) {
|
||||||
|
if (!desc || desc.length <= maxLen) return desc;
|
||||||
|
return desc.slice(0, maxLen) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolStats = (() => {
|
||||||
|
const total = categories.reduce((s, c) => s + c.tools.length, 0);
|
||||||
|
return [
|
||||||
|
{ value: `${total}+`, label: "精选工具" },
|
||||||
|
{ value: String(categories.length), label: "分类" },
|
||||||
|
{ value: "数字游民", label: "出海" },
|
||||||
|
{ value: "佣金", label: "推荐" },
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
export default function Tools() {
|
export default function Tools() {
|
||||||
return (
|
return (
|
||||||
@@ -132,15 +36,15 @@ export default function Tools() {
|
|||||||
<span className="gradient-text">工具推荐</span>
|
<span className="gradient-text">工具推荐</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
||||||
数字游民必备工具精选,覆盖远程协作、开发、设计、财务等 8 大分类。
|
数字游民出海必备工具精选,含游民社区、VPS、金融收款、出海掘金等,每个链接可点击跳转。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-14 grid gap-5 overflow-visible sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div
|
<div
|
||||||
key={cat.title}
|
key={cat.title}
|
||||||
className="card-hover group overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm"
|
className="card-hover group overflow-visible rounded-2xl border border-slate-100 bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<div className={`h-1.5 bg-gradient-to-r ${cat.color}`} />
|
<div className={`h-1.5 bg-gradient-to-r ${cat.color}`} />
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
@@ -150,20 +54,28 @@ export default function Tools() {
|
|||||||
<h3 className="font-bold text-slate-900">{cat.title}</h3>
|
<h3 className="font-bold text-slate-900">{cat.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cat.bg} ${cat.text}`}>
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cat.bg} ${cat.text}`}>
|
||||||
{cat.count}
|
{cat.tools.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{cat.tools.map((tool) => (
|
{cat.tools.map((tool) => (
|
||||||
<div
|
<a
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors group-hover:bg-slate-100/80"
|
href={tool.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group/item flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors hover:bg-slate-100/80 hover:text-sky-600"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-slate-700">
|
<span className="font-medium text-slate-700">{tool.name}</span>
|
||||||
{tool.name}
|
<span className="relative text-slate-400">
|
||||||
|
{truncateDesc(tool.desc)}
|
||||||
|
{tool.desc && tool.desc.length > 5 && (
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 w-max max-w-[90vw] -translate-x-1/2 whitespace-normal break-words rounded-lg bg-slate-800 px-3 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/item:opacity-100">
|
||||||
|
{tool.desc}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-400">{tool.desc}</span>
|
</a>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
219
app/data/tools.json
Normal file
219
app/data/tools.json
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "nomad-community",
|
||||||
|
"icon": "👥",
|
||||||
|
"title": "游民社区",
|
||||||
|
"color": "from-sky-500 to-cyan-500",
|
||||||
|
"bg": "bg-sky-50",
|
||||||
|
"text": "text-sky-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "nomadro", "desc": "出海导航", "href": "https://nomadro.com" },
|
||||||
|
{ "name": "nomadlist", "desc": "海外最大的数字游民社区", "href": "https://nomads.com?ref=https://nomadro.com" },
|
||||||
|
{ "name": "nomadcna", "desc": "国内数字游民社区", "href": "https://meetup.hackrobot.cn" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "toolbox",
|
||||||
|
"icon": "🔧",
|
||||||
|
"title": "工具箱",
|
||||||
|
"color": "from-violet-500 to-purple-500",
|
||||||
|
"bg": "bg-violet-50",
|
||||||
|
"text": "text-violet-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "ChatGPT", "desc": "OpenAI开发的人工智能聊天机器人程序", "href": "https://chat.openai.com/" },
|
||||||
|
{ "name": "Grok", "desc": "xAI 免费 AI 助手", "href": "https://grok.com/" },
|
||||||
|
{ "name": "沉浸式翻译", "desc": "双语翻译插件", "href": "https://immersivetranslate.com/?via=nomad-vps" },
|
||||||
|
{ "name": "奈飞小铺", "desc": "代充值,外网账号合租平台", "href": "https://ihezu.art/CZhn6c" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "us-phone",
|
||||||
|
"icon": "📱",
|
||||||
|
"title": "美国手机号",
|
||||||
|
"color": "from-emerald-500 to-teal-500",
|
||||||
|
"bg": "bg-emerald-50",
|
||||||
|
"text": "text-emerald-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "1psim", "desc": "低成本:2.5$/m,支持esim和实体卡(邮寄中国)", "href": "https://1psim.com/7GARRL" },
|
||||||
|
{ "name": "yesim", "desc": "美国手机号,3$/m", "href": "https://yesim.app/?partner_id=1948" },
|
||||||
|
{ "name": "tello", "desc": "最低5$/m,支持esim", "href": "https://tello.com/" },
|
||||||
|
{ "name": "9esim", "desc": "让任何手机支持esim,可写数据的SIM实体卡", "href": "https://www.9esim.com/?coupon=XIAOSHUANGERIC2" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "finance",
|
||||||
|
"icon": "💳",
|
||||||
|
"title": "金融收款",
|
||||||
|
"color": "from-amber-500 to-orange-500",
|
||||||
|
"bg": "bg-amber-50",
|
||||||
|
"text": "text-amber-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "wise", "desc": "线上开通境外银行账户(身份证即可)", "href": "https://wise.com/invite/amc/xiaoshuangh" },
|
||||||
|
{ "name": "OKX交易所", "desc": "加密货币交易所", "href": "https://okx.com/join/84999861" },
|
||||||
|
{ "name": "safepal (U卡)", "desc": "出海神卡,邀请码:104809", "href": "https://www.safepal.com/zh-cn/" },
|
||||||
|
{ "name": "paypal", "desc": "出海收款必备,tiktok提现", "href": "https://py.pl/1tyAwY" },
|
||||||
|
{ "name": "buy me coffee", "desc": "赞助收款平台", "href": "https://www.buymeacoffee.com/invite/xiaoshuangi" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vps",
|
||||||
|
"icon": "🖥️",
|
||||||
|
"title": "VPS服务器",
|
||||||
|
"color": "from-blue-500 to-indigo-500",
|
||||||
|
"bg": "bg-blue-50",
|
||||||
|
"text": "text-blue-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "vultr", "desc": "可按分钟计费的服务器 (可支付宝)", "href": "https://www.vultr.com/?ref=8985003" },
|
||||||
|
{ "name": "bandwagonhost", "desc": "针对中国线路优化的高质量vps", "href": "https://bandwagonhost.com/aff.php?aff=25463" },
|
||||||
|
{ "name": "RackNerd", "desc": "最便宜的美国vps服务器", "href": "https://my.racknerd.com/aff.php?aff=15630" },
|
||||||
|
{ "name": "腾讯云", "desc": "腾讯服务器,国内速度快", "href": "https://curl.qcloud.com/BZZ2MBTN" },
|
||||||
|
{ "name": "阿里云", "desc": "阿里巴巴旗下,vps服务器", "href": "https://www.aliyun.com/minisite/goods?userCode=dii0ofzg" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "residential-ip",
|
||||||
|
"icon": "🌐",
|
||||||
|
"title": "静态住宅IP",
|
||||||
|
"color": "from-pink-500 to-rose-500",
|
||||||
|
"bg": "bg-pink-50",
|
||||||
|
"text": "text-pink-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "iproyal", "desc": "静态住宅ip,出海必备", "href": "https://iproyal.cn/?r=nomadro" },
|
||||||
|
{ "name": "proxy-cheap", "desc": "便宜的住宅ip", "href": "https://app.proxy-cheap.com/r/bmcMoY" },
|
||||||
|
{ "name": "smartproxy", "desc": "静态住宅ip服务商", "href": "https://www.smartproxy.org/register/?invitation_code=7RXPLM" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "residential-vps",
|
||||||
|
"icon": "🏠",
|
||||||
|
"title": "住宅VPS服务器",
|
||||||
|
"color": "from-rose-500 to-pink-500",
|
||||||
|
"bg": "bg-rose-50",
|
||||||
|
"text": "text-rose-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "lisa", "desc": "住宅ip服务器,tiktok必备", "href": "https://lisahost.com/aff.php?aff=7566" },
|
||||||
|
{ "name": "voyracloud", "desc": "静态住宅ip服务器", "href": "https://www.voyracloud.com/?ref_code=2LJPRTE5" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hardware",
|
||||||
|
"icon": "🖲️",
|
||||||
|
"title": "硬件",
|
||||||
|
"color": "from-slate-500 to-gray-600",
|
||||||
|
"bg": "bg-slate-50",
|
||||||
|
"text": "text-slate-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "树莓派", "desc": "无与伦比的ARM开发板", "href": "https://uland.taobao.com/coupon/edetail?e=5hkWkiThOmClhHvvyUNXZfh8CuWt5YH5OVuOuRD5gLJMmdsrkidbOWBzzpT26idJPSbzO0ArF68ertbZl9wqRUbeJevxT02sHgNIxyFsmaMOsi2OxhyQb%2BhYUTTNN7GRRSHvQe2jOLZ9pbNCYX0I%2BPP%2BWUTgK%2F%2B0I%2BtaUgbudUxA%2B536asYsLWVfKa%2BhVnND4NyB1fwjO3bJLf667Yy8Q5jB6TX2HR3Qrq8LCw4Dujjqyaqc1rGLUwdQHHM8iY%2BUDMxLRSiIntldeBz90au3REAmOgZ0RtdnfeSBK3KXa0%2FA9snq37kKL6zL4bZ3EAS%2F2oYsLHTsIOxroXBFP6oz%2BA%3D%3D&traceId=213e032e17650447230113327e0c4f&union_lens=lensId%3APUB%401765044691%400b521400_0da4_19af4dca231_da77%400290EZjuZ2xbgOlH3NTYAyI%40eyJmbG9vcklkIjoyMDg3MTcxMDEwLCJzcG1CIjoiiX3BvcnRhbF92Ml90b29sX3NlbGVjdGlvbmxpc3RfZGV0YWlsX2luZGV4X2h0bSIsInNyY0Zsb29ySWQiiOiiI4MDY2NCJ9" },
|
||||||
|
{ "name": "迷你主机", "desc": "具有性价比的X86小主机", "href": "https://s.click.taobao.com/t?e=m%3D2%26s%3DCb1Quo%2F0Wb5w4vFB6t2Z2ueEDrYVVa64YUrQeSeIhnK53hKxp7mNFuhCZFSw9yq2woqx0EHqItb0JlhLk0Jl4eVWmNyHytKdTKJ80yOOA0Mtd0e5Ku7CIDk5ffgV5fRI1qrpxiwMoCNxc1AtbZGVS936JshS%2F%2FIdwgQgG5hIzyLNEPXytV9ALtCLThlbPuuZLb93Df8fOziGg7JVKZos%2BjLJJgRsfWlWtajntMnDLEHhuApO%2F8klJ62RdK%2B6%2FP7Z6k6s3rDgsC6jO9AJYjY8CXJ%2BwEVkOqHFPSJJXArlDuzso5nyPLY4NPiLqets6lIJ" },
|
||||||
|
{ "name": "GPU显卡", "desc": "AI时代的算力基础设施", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATUJK1olXwEDUFZVCkkRC18IGloWWwALV19aAUonRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoUBWkBGFoSVAcdDRsBVXsQB29pbiZRKGMHKlclCEgVWghcGV91UQoyVW5dCUoWAmYPHl4RbTYCU24fZp-esLulkIK0yd6n5V1eOEonA2kPH1oQWQUFV19VCnsQA2Y4El0dWQ4LU1hBC00SCmcJK2slXjYFVFdJDjlWUXsOaWslXTYBZF5cCEoQAGoLH1kRQQYHV1lbAFcXBWgMGl4RXgUKUllYOEkWAmsBK2vL05J3IB9Uajl8UStqSz4dFQ9jitDJGTlnA2sPH0olImF5ECFaSU0VVAdNQDxuW09KFiw8SyljbW1jcih3JWNxNlwaS0hqWBhoX2sQbQEEVW4" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "software",
|
||||||
|
"icon": "⚙️",
|
||||||
|
"title": "软件",
|
||||||
|
"color": "from-indigo-500 to-blue-500",
|
||||||
|
"bg": "bg-indigo-50",
|
||||||
|
"text": "text-indigo-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "3X-UI", "desc": "科学上网的正确姿势,仪表盘", "href": "https://github.com/MHSanaei/3x-ui" },
|
||||||
|
{ "name": "虚空终端mihomo", "desc": "代理软件以及配置wiki", "href": "https://wiki.metacubex.one/config/" },
|
||||||
|
{ "name": "frp", "desc": "高性能内网穿透", "href": "https://gofrp.org/zh-cn/docs/" },
|
||||||
|
{ "name": "wireguard", "desc": "P2P打洞组网", "href": "https://github.com/WireGuard/wireguard-go" },
|
||||||
|
{ "name": "lineageos (pi5)", "desc": "树莓派的安卓:linageos", "href": "https://konstakang.com/devices/rpi5/" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "digital-gear",
|
||||||
|
"icon": "📷",
|
||||||
|
"title": "数码装备",
|
||||||
|
"color": "from-cyan-500 to-teal-500",
|
||||||
|
"bg": "bg-cyan-50",
|
||||||
|
"text": "text-cyan-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "AI眼镜", "desc": "第一视角拍摄", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATkJK1olXw8AXVxfCU8UA18IGloVWwcKXFlZDUgnRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoXBW4AE1wRWAUdDRsBVXtwXw1pZzNKAmZgFFwlYUh8UzVBZyZlUQoyVW5dCUoWAmYPHl4RbTYCU24fZpO9hbeBtYKjxt-jwInikZ-fjV8JK1sTWgIDUVtVAEkVA2w4HFscbQ8EXFpVAUwRH2wOHlIdXDYyZF1tD0seF2l6WgkBW3QyZF5tC3sXAm8AGFsVXAEBXEJdDUMVB24UG10SWQcHUVZbAE8fCl8KGloRVDYyitPtY0JrQAYIYj0dXGZKMVc5T5Was35jYlMVXQUTZAQOch9SZTMNbC9gIGdQJi5VCzkRSCpfWDUXVAJlDQoCbw1gcG9qRwYdHnYyUW5aDEgn" },
|
||||||
|
{ "name": "运动相机", "desc": "骑行记录仪", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eCE0UB28ME1slGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWXQABUF5ZAEsIWipURmt3CFhXIQkpbChuRy8JGDpRJUJQDwkbBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVpaDEsUM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmsNHl8UXQQeVF1ZDE0UH28OHF8UWAIDUVddCkgnAW4JH1IlbdiMwDxcaAJfBTlNTRpCBHpHLhyDht8GcR8BHFIUTDZ6NSMpYRJweWtBfAUXC2JyXBkpfTNRWi1mGV4UAlgENFpeDA13cRRcTAJLbQMyU1peOA" },
|
||||||
|
{ "name": "无人机", "desc": "天空视角", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eAUIeB2YMH14lGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWVA8LUFdZDE4IWipURmtCHHVEABsFVChPYRJAbVhlGRhbFRgLBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVdbC04VM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmgLHlgRXgIeVFpfDU0UH28OHF8UWAIDUlteAUwnAW4JH1IlbdiMwCQvcipMBxJhfV9HGgF3FVaDht8GfxgBGVkRTDZUCjsVbzBTYRJbeRkWGG9WAx02D0JcUy5mGRNRFEJiUwgldStzfx1wTj1GbQMyU1peOA" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tiktok",
|
||||||
|
"icon": "🎵",
|
||||||
|
"title": "Tiktok",
|
||||||
|
"color": "from-fuchsia-500 to-pink-500",
|
||||||
|
"bg": "bg-fuchsia-50",
|
||||||
|
"text": "text-fuchsia-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "Tiktok", "desc": "全球直播平台", "href": "https://www.tiktok.com/" },
|
||||||
|
{ "name": "云手机", "desc": "用开源硬件打造云手机", "href": "https://www.hackrobot.cn/" },
|
||||||
|
{ "name": "vcam虚拟摄像头", "desc": "xposed模块,替换摄像头视频流", "href": "https://pan.baidu.com/s/1NW4GMGA3cxfUQNWZPD9vOg?pwd=live" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "youtube",
|
||||||
|
"icon": "▶️",
|
||||||
|
"title": "Youtube",
|
||||||
|
"color": "from-red-500 to-rose-500",
|
||||||
|
"bg": "bg-red-50",
|
||||||
|
"text": "text-red-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "Youtube", "desc": "全球最大的视频网站", "href": "https://www.youtube.com/" },
|
||||||
|
{ "name": "smmfollows", "desc": "刷粉刷量平台", "href": "https://smmfollows.com/ref/dfugq" },
|
||||||
|
{ "name": "YouTube Studio Auto Dismiss", "desc": "油猴脚本,无人直播辅助", "href": "https://greasyfork.org/zh-CN/scripts/557378-youtube-studio-auto-dismiss" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "web",
|
||||||
|
"icon": "🌍",
|
||||||
|
"title": "Web网站",
|
||||||
|
"color": "from-green-500 to-emerald-500",
|
||||||
|
"bg": "bg-green-50",
|
||||||
|
"text": "text-green-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "namesilo", "desc": "注册域名网址", "href": "https://www.namesilo.com/?rid=3bd4047mc" },
|
||||||
|
{ "name": "aapanel", "desc": "宝塔面板", "href": "https://www.aapanel.com?referral_code=wcn04vez" },
|
||||||
|
{ "name": "cloudflare", "desc": "互联网基础节点", "href": "https://www.cloudflare.com/" },
|
||||||
|
{ "name": "Google Analytics", "desc": "网站流量统计服务", "href": "https://analytics.google.com/" },
|
||||||
|
{ "name": "Google AdSense", "desc": "Google广告服务", "href": "https://www.google.com/adsense/" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "live-stream",
|
||||||
|
"icon": "📺",
|
||||||
|
"title": "无人直播",
|
||||||
|
"color": "from-orange-500 to-amber-500",
|
||||||
|
"bg": "bg-orange-50",
|
||||||
|
"text": "text-orange-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "OBS", "desc": "免费开源的电脑客户端:录屏/推流直播", "href": "https://obsproject.com/download" },
|
||||||
|
{ "name": "ffmpeg", "desc": "命令行视频处理/推流工具", "href": "https://www.ffmpeg.org/download.html" },
|
||||||
|
{ "name": "SRS", "desc": "开源直播服务器,拉流推流中转", "href": "https://ossrs.io/" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "payment",
|
||||||
|
"icon": "💲",
|
||||||
|
"title": "支付网关",
|
||||||
|
"color": "from-lime-500 to-green-500",
|
||||||
|
"bg": "bg-lime-50",
|
||||||
|
"text": "text-lime-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "z-pay", "desc": "支持独角数卡-易支付", "href": "https://z-pay.cn/?uid=22119" },
|
||||||
|
{ "name": "xorpay", "desc": "支持收银台支付", "href": "https://xorpay.com?r=nomadro" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "forum",
|
||||||
|
"icon": "📝",
|
||||||
|
"title": "论坛社区",
|
||||||
|
"color": "from-sky-500 to-blue-500",
|
||||||
|
"bg": "bg-sky-50",
|
||||||
|
"text": "text-sky-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "异度世界", "desc": "独立博客,hugo搭建", "href": "https://blog.hackrobot.cn" },
|
||||||
|
{ "name": "大师兄", "desc": "AI实践", "href": "https://dsx2016.com" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, type FormEvent, type ChangeEvent } from "react";
|
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
|
||||||
|
import { getPayEnv, getRecommendedChannel, mustUseWxPay, type PayChannel, type PayEnv } from "../lib/payEnv";
|
||||||
|
|
||||||
const genderOptions = ["男", "女"];
|
const genderOptions = ["男", "女"];
|
||||||
const singleOptions = ["单身", "恋爱中", "已婚"];
|
const singleOptions = ["单身", "恋爱中", "已婚"];
|
||||||
@@ -43,6 +44,22 @@ export default function JoinPage() {
|
|||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [payRedirecting, setPayRedirecting] = useState(false);
|
||||||
|
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
|
||||||
|
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
|
||||||
|
setSubmitted(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const env = getPayEnv();
|
||||||
|
setPayEnv(env);
|
||||||
|
setPayChannel(getRecommendedChannel(env));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const set = (key: keyof FormData, value: string) =>
|
const set = (key: keyof FormData, value: string) =>
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -61,7 +78,41 @@ export default function JoinPage() {
|
|||||||
if (!res.ok || !data?.ok) {
|
if (!res.ok || !data?.ok) {
|
||||||
throw new Error(data?.error || "提交失败,请稍后重试");
|
throw new Error(data?.error || "提交失败,请稍后重试");
|
||||||
}
|
}
|
||||||
setSubmitted(true);
|
const userId = data.user_id;
|
||||||
|
if (!userId) {
|
||||||
|
setSubmitted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
setPayRedirecting(true);
|
||||||
|
const returnUrl = `${window.location.origin}/join?paid=1`;
|
||||||
|
const payForm = document.createElement("form");
|
||||||
|
payForm.method = "POST";
|
||||||
|
payForm.action = "/api/pay";
|
||||||
|
payForm.target = "_self";
|
||||||
|
payForm.style.display = "none";
|
||||||
|
const env = getPayEnv();
|
||||||
|
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
|
||||||
|
const device = env === "pc" ? "pc" : "h5"; // PC 跳转 payurl,H5/微信 用 payurl/payurl2
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
["user_id", userId],
|
||||||
|
["total_fee", "80"],
|
||||||
|
["type", "meetup"],
|
||||||
|
["channel", channel],
|
||||||
|
["device", device],
|
||||||
|
["return_url", returnUrl],
|
||||||
|
["html", "1"],
|
||||||
|
];
|
||||||
|
fields.forEach(([k, v]) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = k;
|
||||||
|
input.value = v;
|
||||||
|
payForm.appendChild(input);
|
||||||
|
});
|
||||||
|
document.body.appendChild(payForm);
|
||||||
|
payForm.submit();
|
||||||
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -395,6 +446,41 @@ export default function JoinPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── 支付通道选择(PC/H5 可选,微信内强制微信支付)── */}
|
||||||
|
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50/50 px-4 py-4 sm:px-5">
|
||||||
|
<p className="mb-3 text-sm font-bold text-slate-700">💳 支付方式</p>
|
||||||
|
{payEnv === "wechat" ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
当前在微信内打开,将使用 <span className="font-medium text-emerald-600">微信支付</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="payChannel"
|
||||||
|
value="alipay"
|
||||||
|
checked={payChannel === "alipay"}
|
||||||
|
onChange={() => setPayChannel("alipay")}
|
||||||
|
className="h-4 w-4 border-slate-300 text-sky-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-slate-700">支付宝</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-emerald-500 has-[:checked]:bg-emerald-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="payChannel"
|
||||||
|
value="wxpay"
|
||||||
|
checked={payChannel === "wxpay"}
|
||||||
|
onChange={() => setPayChannel("wxpay")}
|
||||||
|
className="h-4 w-4 border-slate-300 text-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-slate-700">微信支付</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Submit ── */}
|
{/* ── Submit ── */}
|
||||||
<div className="mt-8 sm:mt-10">
|
<div className="mt-8 sm:mt-10">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -404,15 +490,12 @@ export default function JoinPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || uploading}
|
disabled={submitting || uploading || payRedirecting}
|
||||||
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
|
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
|
||||||
>
|
>
|
||||||
{uploading ? "媒体上传中…" : submitting ? "提交中…" : "提交申请"}
|
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-4 whitespace-pre-line text-center text-sm leading-relaxed text-amber-500">
|
|
||||||
{"🔍 提交后将进行审核\n💰 需支付基本场地/管理费"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
19
app/lib/env.ts
Normal file
19
app/lib/env.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 环境配置:区分本地调试与生产部署
|
||||||
|
* 生产:https://api.hackrobot.cn(payjsapi)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
/** 支付 API 地址(payjsapi) */
|
||||||
|
export const PAYMENT_API_URL =
|
||||||
|
process.env.PAYMENT_API_URL ||
|
||||||
|
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn");
|
||||||
|
|
||||||
|
/** 站点根地址(用于 return_url 等回跳,生产需配置) */
|
||||||
|
export const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||||
|
(isDev ? "http://localhost:3001" : "https://hackrobot.cn");
|
||||||
|
|
||||||
|
/** 是否为本地开发 */
|
||||||
|
export const IS_DEV = isDev;
|
||||||
53
app/lib/payEnv.ts
Normal file
53
app/lib/payEnv.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 支付环境检测:PC / H5 / 微信浏览器
|
||||||
|
* 用于 ZPAY 等聚合支付,需区分环境以选择正确的支付通道
|
||||||
|
* 参考:https://juejin.cn/post/7122720360683798542
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PayEnv = "pc" | "h5" | "wechat";
|
||||||
|
|
||||||
|
export type PayChannel = "alipay" | "wxpay";
|
||||||
|
|
||||||
|
/** 是否在微信内置浏览器 */
|
||||||
|
export function isWeChat(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否在支付宝内置浏览器 */
|
||||||
|
export function isAlipay(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /AlipayClient|Alipay/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否移动设备(含平板) */
|
||||||
|
export function isMobile(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /AppleWebKit.*Mobile|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前支付环境 */
|
||||||
|
export function getPayEnv(): PayEnv {
|
||||||
|
if (isWeChat()) return "wechat";
|
||||||
|
if (isMobile()) return "h5";
|
||||||
|
return "pc";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据环境推荐/强制支付通道
|
||||||
|
* - 微信浏览器:只能用 wxpay(支付宝在微信内无法唤起)
|
||||||
|
* - 支付宝浏览器:优先 alipay
|
||||||
|
* - PC/H5:用户可选
|
||||||
|
*/
|
||||||
|
export function getRecommendedChannel(env: PayEnv): PayChannel {
|
||||||
|
if (env === "wechat") return "wxpay";
|
||||||
|
if (env === "pc" || env === "h5") return "alipay"; // 默认支付宝
|
||||||
|
return "alipay";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 微信内是否只能选微信支付 */
|
||||||
|
export function mustUseWxPay(env: PayEnv): boolean {
|
||||||
|
return env === "wechat";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user