This commit is contained in:
eric
2026-03-08 03:40:53 -05:00
parent f589b71b19
commit 51eeea3be3
8 changed files with 747 additions and 138 deletions

View File

@@ -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
View 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, "&lt;")
.replace(/>/g, "&gt;");
}
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/redirectmapi.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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const orderId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 }
);
}
}

View 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 });
}
}

View File

@@ -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
View 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" }
]
}
]
}

View File

@@ -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 跳转 payurlH5/微信 用 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
View File

@@ -0,0 +1,19 @@
/**
* 环境配置:区分本地调试与生产部署
* 生产https://api.hackrobot.cnpayjsapi
*/
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
View 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";
}