'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: true });
|
||||
return NextResponse.json({ ok: true, user_id: userId });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(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 = [
|
||||
{
|
||||
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",
|
||||
},
|
||||
];
|
||||
import toolsData from "../data/tools.json";
|
||||
|
||||
const toolStats = [
|
||||
{ value: "50+", label: "精选工具" },
|
||||
{ value: "8", label: "分类" },
|
||||
{ value: "15", label: "开发工具" },
|
||||
{ value: "12", label: "协作工具" },
|
||||
];
|
||||
const categories = toolsData.categories as Array<{
|
||||
icon: string;
|
||||
title: string;
|
||||
color: string;
|
||||
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() {
|
||||
return (
|
||||
@@ -132,15 +36,15 @@ export default function Tools() {
|
||||
<span className="gradient-text">工具推荐</span>
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
||||
数字游民必备工具精选,覆盖远程协作、开发、设计、财务等 8 大分类。
|
||||
数字游民出海必备工具精选,含游民社区、VPS、金融收款、出海掘金等,每个链接可点击跳转。
|
||||
</p>
|
||||
</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) => (
|
||||
<div
|
||||
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="p-5">
|
||||
@@ -150,20 +54,28 @@ export default function Tools() {
|
||||
<h3 className="font-bold text-slate-900">{cat.title}</h3>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cat.bg} ${cat.text}`}>
|
||||
{cat.count}
|
||||
{cat.tools.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{cat.tools.map((tool) => (
|
||||
<div
|
||||
<a
|
||||
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">
|
||||
{tool.name}
|
||||
<span className="font-medium text-slate-700">{tool.name}</span>
|
||||
<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 className="text-slate-400">{tool.desc}</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</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";
|
||||
|
||||
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 singleOptions = ["单身", "恋爱中", "已婚"];
|
||||
@@ -43,6 +44,22 @@ export default function JoinPage() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
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) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -61,7 +78,41 @@ export default function JoinPage() {
|
||||
if (!res.ok || !data?.ok) {
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
||||
} finally {
|
||||
@@ -395,6 +446,41 @@ export default function JoinPage() {
|
||||
)}
|
||||
</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 ── */}
|
||||
<div className="mt-8 sm:mt-10">
|
||||
{error && (
|
||||
@@ -404,15 +490,12 @@ export default function JoinPage() {
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{uploading ? "媒体上传中…" : submitting ? "提交中…" : "提交申请"}
|
||||
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 whitespace-pre-line text-center text-sm leading-relaxed text-amber-500">
|
||||
{"🔍 提交后将进行审核\n💰 需支付基本场地/管理费"}
|
||||
</p>
|
||||
</div>
|
||||
</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