This commit is contained in:
eric
2026-03-13 21:38:04 -05:00
parent 0885e00b25
commit 04995f91dd
22 changed files with 856 additions and 482 deletions

View File

@@ -13,6 +13,10 @@ PAYMENT_API_URL=http://127.0.0.1:8007
NEXT_PUBLIC_API_URL=http://127.0.0.1:8007
# 生产同机部署时取消注释Next.js 与 payjsapi 同服务器)
# PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007
# 内网/慢网络可适当提高超时毫秒check_vip 默认 15spayh5/redirect 默认 60s
# PAYJSAPI_CHECK_TIMEOUT=15000
# PAYJSAPI_PAYH5_TIMEOUT=60000
# PAYJSAPI_REDIRECT_TIMEOUT=60000
PAYMENT_PROVIDER=zpay
PAYMENT_DEFAULT_AMOUNT=100
NEXT_PUBLIC_SITE_ID=vip

View File

@@ -90,9 +90,10 @@ async function createPayOrder(
...(provider && { provider }),
};
const payh5Url = `${apiUrl}/nomadvip/payh5`;
console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url);
const payh5Timeout = Number(process.env.PAYJSAPI_PAYH5_TIMEOUT) || 60000;
console.log("[pay] createPayOrder 请求 payjsapi url=%s timeout=%ds", payh5Url, payh5Timeout / 1000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const timeout = setTimeout(() => controller.abort(), payh5Timeout);
const res = await fetch(payh5Url, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -100,14 +101,18 @@ async function createPayOrder(
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
console.log("[pay] createPayOrder 响应 status=%s ok=%s", res.status, data?.status);
if (!res.ok || data?.status !== "ok") {
throw new Error(data?.detail || data?.msg || "支付创建失败");
const statusOk = data?.status === "ok" || data?.status === "success" || (res.ok && (data?.xorpay_params || data?.params));
console.log("[pay] createPayOrder 响应 http=%s status=%s hasParams=%s", res.status, data?.status, !!(data?.xorpay_params || data?.params));
if (!res.ok || !statusOk) {
const errMsg = data?.detail || data?.msg || data?.error || "支付创建失败";
console.error("[pay] createPayOrder 失败:", errMsg, "body=", JSON.stringify(data).slice(0, 300));
throw new Error(errMsg);
}
// xorpay: xorpay_params + xorpay_url | zpay: params + pay_url
const params = (data.xorpay_params ?? data.params) || {};
const payUrl = data.xorpay_url || data.pay_url || "";
if (!params || typeof params !== "object") {
if (!params || typeof params !== "object" || Object.keys(params).length === 0) {
console.error("[pay] createPayOrder 参数为空 data.keys=", Object.keys(data || {}));
throw new Error("payjsapi 未返回支付参数");
}
return {
@@ -149,7 +154,9 @@ export async function POST(request: NextRequest) {
const type = String(body?.type || "vip");
const channel = String(body?.channel || "wxpay");
const device = String(body?.device || "pc");
const reqProvider = String(body?.provider || "").trim().toLowerCase() || undefined;
// 渠道规则pc/手机浏览器 -> zpay仅微信内 -> xorpay
const effectiveProvider =
device === "wechat" ? "xorpay" : "zpay";
const accept = request.headers.get("accept") || "";
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
const preferJson = String(body?.prefer_json) === "1";
@@ -175,139 +182,94 @@ export async function POST(request: NextRequest) {
request.headers.get("x-real-ip") ||
"";
const { params, payUrl, provider } = await createPayOrder(
apiUrl,
user_id,
finalReturnUrl,
total_fee,
type,
channel,
clientIp,
reqProvider
);
// zpay直接走 redirect不先调 payh5避免 payh5 创建 zpay 订单时阻塞超时)
// payjsapi 的 payh5/redirect 可独立调用内部自建订单。digital/cnomadcna 仍用 payh5+redirect 流程,路由隔离互不影响。
if (effectiveProvider === "zpay") {
const redirectPayload = {
user_id,
site_id: "vip",
total_fee,
type,
channel,
return_url: finalReturnUrl,
device,
provider: "zpay",
...(clientIp && { client_ip: clientIp }),
};
const redirectUrl = `${apiUrl}/nomadvip/payh5/redirect`;
const redirectTimeout = Number(process.env.PAYJSAPI_REDIRECT_TIMEOUT) || 60000;
console.log("[pay] zpay 直接 redirect url=%s timeout=%ds", redirectUrl, redirectTimeout / 1000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), redirectTimeout);
const redirectRes = await fetch(redirectUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(redirectPayload),
redirect: "manual",
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
if (!params || Object.keys(params).length === 0) {
if (wantHtml) {
return new NextResponse(buildErrorHtml("支付参数为空"), {
status: 500,
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim() || "";
if (!wantHtml || (preferJson && orderId)) {
const res = NextResponse.json({
ok: true,
order_id: orderId,
redirect_url: location,
provider: "zpay",
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
const res = NextResponse.redirect(location);
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.use_form) {
const params = (resData.params ?? resData) as Record<string, string>;
const orderId = String(params?.out_trade_no ?? resData?.order_id ?? "").trim();
const submitUrl = resData?.pay_url || payConfig.zpaySubmitUrl;
const html = buildPayHtml(submitUrl, params || {}, {
directToCashier: device === "wechat",
});
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
}
// wantHtml=0返回 JSON供微信 fetch 后直接提交表单(无中转页)
if (!wantHtml) {
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
const res = NextResponse.json({
ok: true,
params,
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
provider,
order_id: orderId,
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
// xorpay直接 payh5 返回收银台参数,无需 redirect 中转
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
if (provider === "xorpay") {
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
directToCashier: device === "wechat" || device === "h5",
});
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
// zpay需要 redirect 获取 device 相关跳转
const redirectPayload = {
user_id,
site_id: "vip",
total_fee,
type,
channel,
return_url: finalReturnUrl,
device,
provider: "zpay",
...(clientIp && { client_ip: clientIp }),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
const redirectRes = await fetch(`${apiUrl}/nomadvip/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) {
const orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
String(params?.order_id || params?.out_trade_no || "").trim();
if (preferJson && orderId) {
return NextResponse.json({
ok: true,
order_id: orderId,
redirect_url: location,
provider: "zpay",
});
}
const res = NextResponse.redirect(location);
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.use_form) {
const orderId = String(params?.out_trade_no || "").trim();
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
directToCashier: device === "wechat",
});
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
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 || "wxpay").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>
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
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 || "wxpay").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>
@@ -433,6 +395,80 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
})();
</script>
</body></html>`;
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
if (redirectRes.status === 400 || redirectRes.status === 500) {
const errMsg = resData?.detail || resData?.msg || "zpay redirect 失败";
if (wantHtml) {
return new NextResponse(buildErrorHtml(errMsg), {
status: redirectRes.status,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
}
const errMsg = resData?.detail || resData?.msg || "zpay 未返回有效响应";
if (wantHtml) {
return new NextResponse(buildErrorHtml(errMsg), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
}
// xorpay调用 payh5 获取收银台参数
const { params, payUrl, provider } = await createPayOrder(
apiUrl,
user_id,
finalReturnUrl,
total_fee,
type,
channel,
clientIp,
effectiveProvider
);
if (!params || Object.keys(params).length === 0) {
if (wantHtml) {
return new NextResponse(buildErrorHtml("支付参数为空"), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
}
// wantHtml=0返回 JSON供微信 fetch 后直接提交表单(无中转页)
if (!wantHtml) {
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
const res = NextResponse.json({
ok: true,
params,
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
provider,
order_id: orderId,
});
if (orderId) setPayOrderCookie(res, orderId);
setAnonymousUserCookie(res, request, user_id);
return res;
}
// xorpay直接 payh5 返回收银台参数,无需 redirect 中转
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
if (provider === "xorpay") {
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
directToCashier: device === "wechat" || device === "h5",
});
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -442,34 +478,17 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
return res;
}
if (redirectRes.status === 400 || redirectRes.status === 500) {
const errMsg = resData?.detail || resData?.msg || "支付跳转失败";
if (wantHtml) {
return new NextResponse(buildErrorHtml(errMsg), {
status: redirectRes.status,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
}
const createFailMsg = "支付创建失败";
if (wantHtml) {
return new NextResponse(buildErrorHtml(createFailMsg), {
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return NextResponse.json({ ok: false, error: createFailMsg }, { status: 500 });
return NextResponse.json({ ok: false, error: "支付渠道异常" }, { status: 500 });
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const msg = err.message || "";
const payjsapiHint = "请确认 payjsapi 已启动 (npm run dev 或 python run.py端口 8007)";
const payjsapiHint =
"请确认 payjsapi 已启动且 nomadvip 能访问。同机部署可设 PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? payjsapiHint
: err.name === "AbortError"
? `请求超时,${payjsapiHint}`
? `请求超时(30s)${payjsapiHint}`
: msg;
console.error("Pay API error:", err);
const errorMsg = `支付请求失败: ${hint}`;

View File

@@ -34,9 +34,10 @@ async function checkVipFromPayjsapi(
userId
)}`;
console.log("[check-by-user] payjsapi request user_id=%s url=%s", userId, url);
const checkTimeout = Number(process.env.PAYJSAPI_CHECK_TIMEOUT) || 15000;
const res = await fetch(
url,
{ cache: "no-store", signal: AbortSignal.timeout(5000) }
{ cache: "no-store", signal: AbortSignal.timeout(checkTimeout) }
);
if (!res.ok) {
console.log(

View File

@@ -1,20 +1,54 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { isPaid } from "../lib/payment";
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
export function ChatFloatButton() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
const baseClass =
"animate__animated animate__bounceIn fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110";
const paidClass = "bg-[var(--accent)] text-[var(--accent-foreground)] shadow-[var(--accent)]/30 hover:shadow-xl hover:shadow-[var(--accent)]/40";
const lockedClass = "bg-[var(--accent)] text-[var(--accent-foreground)] shadow-[var(--accent)]/30 hover:shadow-xl hover:shadow-[var(--accent)]/40";
if (paid) {
return (
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className={`${baseClass} ${paidClass}`}
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
aria-label="即时问答"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
);
}
return (
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="animate__animated animate__bounceIn fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent)] text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/30 transition-all hover:scale-110 hover:shadow-xl hover:shadow-[var(--accent)]/40"
<Link
href="/"
className={`${baseClass} ${lockedClass}`}
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
aria-label="即时问答"
aria-label="即时问答开通VIP解锁"
title="开通VIP解锁即时问答"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</a>
</Link>
);
}

View File

@@ -1,11 +1,22 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { SITE_CONFIG } from "../config/course";
import { isPaid } from "../lib/payment";
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
export function Footer() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<footer className="animate__animated animate__fadeInUp border-t border-[var(--border)] py-6 sm:py-8" style={{ animationDelay: "0.1s", animationFillMode: "both" }}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
@@ -16,17 +27,30 @@ export function Footer() {
<Link href="/cloudphone" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
</Link>
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
{paid ? (
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
) : (
<Link
href="/"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
title="开通VIP解锁即时问答"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</Link>
)}
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
{SITE_CONFIG.footer}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { SITE_CONFIG } from "../config/course";
import { isPaid } from "../lib/payment";
import { triggerPay } from "@/app/lib/triggerPay";
import { ThemeToggle } from "@/app/components/ThemeToggle";
export function Header() {
@@ -23,35 +24,71 @@ export function Header() {
</Link>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
<ThemeToggle />
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
{paid ? (
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
) : (
<Link
href="/"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
title="开通VIP解锁即时问答"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</Link>
)}
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
</Link>
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
aria-label={paid ? "进入课程" : "立即报名"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Link>
{paid ? (
<Link
href="/cloudphone/course/0/0"
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
</Link>
) : (
<button
type="button"
onClick={() => triggerPay()}
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
</button>
)}
{paid ? (
<Link
href="/cloudphone/course/0/0"
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
aria-label="进入课程"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Link>
) : (
<button
type="button"
onClick={() => triggerPay()}
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
aria-label="立即报名"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</header>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { CTA_CONFIG } from "../../config/course";
import { isPaid } from "../../lib/payment";
import { triggerPay } from "@/app/lib/triggerPay";
export function CTASection() {
const [paid, setPaid] = useState(false);
@@ -34,12 +35,22 @@ export function CTASection() {
</span>
))}
</div>
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
{paid ? "开始学习 →" : "立即加入 →"}
</Link>
{paid ? (
<Link
href="/cloudphone/course/0/0"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
</Link>
) : (
<button
type="button"
onClick={() => triggerPay()}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
</button>
)}
</div>
</section>
);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "../../config/course";
import { isPaid } from "../../lib/payment";
import { triggerPay } from "@/app/lib/triggerPay";
import { getLastWatched, getProgress } from "../../lib/learning";
import { ParticleBackground } from "../ParticleBackground";
@@ -74,13 +75,21 @@ export function Hero() {
>
</Link>
) : (
) : paid ? (
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
href="/cloudphone/course/0/0"
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
) : (
<button
type="button"
onClick={() => triggerPay()}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
</button>
)}
</div>
{paid && progress.completed > 0 && (

View File

@@ -4,7 +4,7 @@
export const SITE_CONFIG = {
brand: "🌍 云手机",
ctaUrl: "/cloudphone/pay",
ctaUrl: "/",
footer: "🌍 云手机 · 低成本工作室搭建指南",
} as const;

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { triggerPay } from "@/app/lib/triggerPay";
import { Header } from "../../../components";
import { VideoPlayer } from "../../../components/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
@@ -77,7 +78,7 @@ export default function LessonPage() {
prevLesson={prevLesson}
nextLesson={nextLesson}
courseHomeHref="/cloudphone"
payHref="/cloudphone/pay"
payHref="#"
>
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
{unlocked ? (
@@ -93,12 +94,13 @@ export default function LessonPage() {
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<p className="text-lg text-white/90"></p>
<Link
href="/cloudphone/pay"
<button
type="button"
onClick={() => triggerPay()}
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</button>
</div>
</div>
)}

View File

@@ -1,5 +1,7 @@
/**
* 支付状态 - 适配 nomadvip paidType克隆自 nomadlms
*
* CTA 校验逻辑:有本地 paidType=vip 时优先使用;无则点击「立即加入」直接进入支付流程
*/
/** nomadvip 使用 paidType=vip 表示已支付 */

View File

@@ -57,8 +57,8 @@ export const TOPICS_DISPLAY = [
/** 电子书展示 - 首页展示用 */
export const EBOOKS_DISPLAY = [
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook" },
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone" },
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook", free: true },
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone", locked: true },
] as const;
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
@@ -70,8 +70,8 @@ export const VIDEO_COURSES_DISPLAY = [
] as const;
export const COMPARE_ITEMS = [
{ feature: "电子书", single: "单买", member: "全解锁" },
{ feature: "视频教程", single: "单买", member: "全解锁" },
{ feature: "电子书", single: "按需开通", member: "全解锁" },
{ feature: "视频教程", single: "按需开通", member: "全解锁" },
{ feature: "私密社群", single: "—", member: "✓" },
{ feature: "即时问答", single: "—", member: "✓" },
{ feature: "持续更新", single: "—", member: "✓" },

278
app/lib/triggerPay.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* 共享支付触发逻辑 - 与首页「立即加入」效果一致
* 供首页、课程页、云手机页等所有「立即报名」按钮使用,直接发起支付,不跳转首页
*
* 校验逻辑:
* - 页面有本地存储(登录资料 + paidType=vip优先使用本地存储并立即展示后台悄悄校验若 user 被删或 VIP 被撤则更新本地状态
* - 无身份标识和 VIP 权限等存储时,点击按钮直接进入支付流程,不校验本地 userId
*/
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
import { getPaymentConfig } from "@/config/services.config";
const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid";
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
const PAY_ORDER_MAX_AGE = 60 * 15;
function getStorage(key: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
}
function setStorage(key: string, value: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(key, value);
}
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function setAnonymousUserCookie(userId: string): void {
if (typeof document === "undefined") return;
if (!/^user\d+$/.test(userId)) return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${ANONYMOUS_USER_COOKIE}=${encodeURIComponent(
userId
)}; Path=/; Max-Age=${ANONYMOUS_USER_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
}
function setPayOrderCookie(orderId: string): void {
if (typeof document === "undefined") return;
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId) return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${PAY_ORDER_COOKIE}=${encodeURIComponent(
`${normalizedOrderId}|${Date.now()}`
)}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`;
}
function savePendingOrder(orderId: string): void {
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId) return;
setPayOrderCookie(normalizedOrderId);
setStorage(PAY_ORDER_COOKIE, `${normalizedOrderId}|${Date.now()}`);
}
function getStoredAnonymousUserId(): string | null {
const memosAccount = getStorage("memosAccount");
if (memosAccount && /^user\d+$/.test(memosAccount)) return memosAccount;
const userId = getStorage("userId");
if (userId && /^user\d+$/.test(userId)) return userId;
const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE);
if (cookieUserId && /^user\d+$/.test(cookieUserId)) {
setStorage("memosAccount", cookieUserId);
return cookieUserId;
}
return null;
}
/** 同步获取 userId用于微信内立即表单提交避免异步导致手势失效 */
function getOrCreateUserIdSync(): string {
const anonymousUserId = getStoredAnonymousUserId();
if (anonymousUserId) {
setStorage("userId", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
return anonymousUserId;
}
let userId = getStorage("userId");
if (userId?.startsWith("{") && userId.includes("data")) {
try {
const parsed = JSON.parse(userId) as { data?: string };
if (parsed?.data) {
userId = parsed.data;
setStorage("userId", userId);
}
} catch {
userId = null;
}
}
if (!userId) {
userId = `user${Date.now()}`;
setStorage("userId", userId);
}
return userId;
}
async function getOrCreateUserId(): Promise<string> {
const syncId = getOrCreateUserIdSync();
if (syncId && !syncId.startsWith("user")) {
return syncId;
}
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const meData = await meRes.json();
if (meData?.user?.id) {
setStorage("userId", meData.user.id);
return meData.user.id;
}
} catch {
/* ignore */
}
return syncId;
}
function redirectToPayZpay(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
device: "pc" | "h5" | "wechat";
}) {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/pay";
form.target = "_self";
form.style.display = "none";
const fields: [string, string][] = [
["user_id", params.user_id],
["return_url", params.return_url],
["total_fee", String(params.total_fee)],
["type", params.type],
["channel", params.channel],
["device", params.device],
["provider", "zpay"],
["html", "1"],
];
fields.forEach(([k, v]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = v;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
async function redirectToPayZpayH5(
params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
},
onPendingOrder?: (orderId: string) => void
): Promise<string | null> {
const res = await fetch("/api/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...params,
device: "h5",
provider: "zpay",
html: "0",
}),
});
const data = await res.json().catch(() => ({}));
if (!data?.ok) {
alert(data?.error || "支付发起失败");
return null;
}
const hasRedirect = !!data?.redirect_url;
const hasParams = !!data?.params && !!data?.payUrl;
if (!hasRedirect && !hasParams) {
alert(data?.error || "支付发起失败");
return null;
}
const orderId = data?.order_id?.trim() || null;
if (orderId) {
savePendingOrder(orderId);
onPendingOrder?.(orderId);
}
if (data?.redirect_url) {
window.location.href = data.redirect_url;
return orderId;
}
const form = document.createElement("form");
form.method = "POST";
form.action = data.payUrl;
form.target = "_self";
form.style.display = "none";
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
if (v == null) continue;
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
return orderId;
}
/** 微信内:同步表单 POST 到 /api/pay避免异步 fetch 后 form.submit 被微信拦截 */
function payWechatXorpaySync(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
}) {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/pay";
form.target = "_self";
form.style.display = "none";
const fields: [string, string][] = [
["user_id", params.user_id],
["return_url", params.return_url],
["total_fee", String(params.total_fee)],
["type", params.type],
["channel", params.channel],
["device", "wechat"],
["provider", "xorpay"],
["html", "1"],
];
fields.forEach(([k, v]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = v;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
/**
* 触发支付 - 与首页「立即加入」效果一致,直接发起支付流程
* @param onPendingOrder 可选H5 支付时收到 order_id 的回调(用于轮询)
*/
export async function triggerPay(
onPendingOrder?: (orderId: string) => void
): Promise<void> {
const userId =
typeof navigator !== "undefined" && /MicroMessenger/i.test(navigator.userAgent)
? getOrCreateUserIdSync()
: await getOrCreateUserId();
if (/^user\d+$/.test(userId)) {
setStorage("memosAccount", userId);
setStorage("userId", userId);
setAnonymousUserCookie(userId);
}
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
const base = {
user_id: userId,
return_url: returnUrl,
total_fee: totalFee,
type: "vip",
channel: "wxpay" as const,
};
if (device === "wechat") {
payWechatXorpaySync(base);
} else if (device === "h5") {
await redirectToPayZpayH5(base, onPendingOrder);
} else {
redirectToPayZpay({ ...base, device });
}
}

View File

@@ -1,150 +1,21 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { triggerPay } from "@/app/lib/triggerPay";
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
import { getPaymentConfig } from "@/config/services.config";
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
import { VipLayout } from "@/app/vip/components/VipLayout";
import { LandingPage } from "@/app/vip/components/LandingPage";
import { DashboardPage } from "@/app/vip/components/DashboardPage";
import styles from "@/app/vip/components/vip.module.css";
/** 微信内支付渠道zpay | xorpay */
const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay";
// 渠道规则pc/手机浏览器 -> zpay仅微信内 -> xorpay
const DEFAULT_EMAIL_PASSWORD = "12345678";
const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid";
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
const PAY_ORDER_MAX_AGE = 60 * 15;
function redirectToPayZpay(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
device: "pc" | "h5" | "wechat";
}) {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/pay";
form.target = "_self";
form.style.display = "none";
const fields: [string, string][] = [
["user_id", params.user_id],
["return_url", params.return_url],
["total_fee", String(params.total_fee)],
["type", params.type],
["channel", params.channel],
["device", params.device],
["provider", "zpay"],
["html", "1"],
];
fields.forEach(([k, v]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = v;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
async function redirectToPayZpayH5(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
}, onPendingOrder?: (orderId: string) => void): Promise<string | null> {
const res = await fetch("/api/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...params,
device: "h5",
provider: "zpay",
html: "0",
}),
});
const data = await res.json().catch(() => ({}));
if (!data?.ok || !data?.params || !data?.payUrl) {
alert(data?.error || "支付发起失败");
return null;
}
const orderId = data?.order_id?.trim() || null;
if (orderId) {
savePendingOrder(orderId);
onPendingOrder?.(orderId);
}
if (data?.redirect_url) {
window.location.href = data.redirect_url;
return orderId;
}
const form = document.createElement("form");
form.method = "POST";
form.action = data.payUrl;
form.target = "_self";
form.style.display = "none";
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
if (v == null) continue;
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
return orderId;
}
async function payWechatXorpay(params: {
user_id: string;
return_url: string;
total_fee: number;
type: string;
channel: "alipay" | "wxpay";
}) {
const res = await fetch("/api/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...params,
total_fee: params.total_fee,
device: "wechat",
provider: "xorpay",
html: "0",
}),
});
const data = await res.json().catch(() => ({}));
if (!data?.ok || !data?.params || !data?.payUrl) {
alert(data?.error || "支付发起失败");
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = data.payUrl;
const orderId = data?.order_id?.trim();
if (orderId) {
savePendingOrder(orderId);
}
form.target = "_self";
form.style.display = "none";
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
if (v == null) continue;
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
function getStorage(key: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
@@ -213,11 +84,11 @@ function getPendingOrderId(): string | null {
return orderId || null;
}
/** 首页永远不显示 loading所有接口在后台静默执行 */
export default function Home() {
const [paidType, setPaidType] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
const savePaidType = useCallback((type: string | null) => {
@@ -490,7 +361,6 @@ export default function Home() {
clearPendingOrder();
setPaidType(null);
setUserName(null);
setLoading(false);
const path = window.location.pathname || "/";
window.location.replace(path);
} catch (error) {
@@ -498,37 +368,9 @@ export default function Home() {
}
}, []);
const doPay = useCallback(async () => {
const userId = await getOrCreateUserId();
if (/^user\d+$/.test(userId)) {
setStorage("memosAccount", userId);
setStorage("userId", userId);
setAnonymousUserCookie(userId);
}
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
const base = {
user_id: userId,
return_url: returnUrl,
total_fee: totalFee,
type: "vip",
channel: "wxpay" as const,
};
if (device === "wechat" && WECHAT_PAY_PROVIDER === "xorpay") {
payWechatXorpay(base);
} else if (device === "h5") {
const orderId = await redirectToPayZpayH5(base, setPayPendingOrderId);
if (orderId) setPayPendingOrderId(orderId);
} else {
redirectToPayZpay({ ...base, device });
}
}, [getOrCreateUserId]);
const handlePay = useCallback(() => {
doPay();
}, [doPay]);
triggerPay(setPayPendingOrderId);
}, []);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
@@ -593,46 +435,56 @@ export default function Home() {
const pendingOrderId = getPendingOrderId();
if (pendingOrderId) {
setPayPendingOrderId(pendingOrderId);
setLoading(false);
return;
}
// 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖
// 本地已有 VIP 标识时优先使用,立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
const localUserEmail = getStorage("userEmail");
const localUserId = getStorage("userId");
if (localType === "vip" && (localUserName || localUserEmail) && localUserId) {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
if (isAnonymous) {
verified = await checkPaymentFromPB(localUserId);
} else {
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
verified = !!vipData?.vip;
// 立即展示 VIP 状态,不阻塞首屏
setPaidType(localType);
setUserName(localUserEmail || localUserName || "");
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
if (!localUserEmail && !localUserName?.includes("@")) {
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const meData = await meRes.json();
if (meData?.user?.email) setUserEmail(meData.user.email);
} catch { /* ignore */ }
}
if (verified) {
setPaidType(localType);
setUserName(localUserEmail || localUserName || "");
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
if (!localUserEmail && !localUserName?.includes("@")) {
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const meData = await meRes.json();
if (meData?.user?.email) setUserEmail(meData.user.email);
} catch { /* ignore */ }
// 后台悄悄校验user 被删或 VIP 被撤则清除本地存储并更新状态
const verifyInBackground = async () => {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
try {
if (isAnonymous) {
verified = await checkPaymentFromPB(localUserId);
} else {
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
verified = !!vipData?.vip;
}
} catch { /* ignore */ }
if (!verified) {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
savePaidType(null);
setUserName(null);
setUserEmail(null);
}
setLoading(false);
return;
} else {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
}
};
void verifyInBackground();
return;
}
// 校验逻辑:有本地存储(登录资料+VIP时优先使用并校验无身份/VIP 时点击按钮直接进入支付,不校验
const anonymousCandidate = getStoredAnonymousUserId();
const hadStoredUserId = !!anonymousCandidate;
if (anonymousCandidate) {
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
if (recovered) {
@@ -642,7 +494,6 @@ export default function Home() {
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
setUserName(name);
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
setLoading(false);
return;
}
}
@@ -656,7 +507,6 @@ export default function Home() {
setPaidType(afterLocalType);
setUserName(afterLocalUserEmail || afterLocalUserName || "");
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
setLoading(false);
return;
}
@@ -688,7 +538,6 @@ export default function Home() {
}
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
setLoading(false);
return;
}
}
@@ -696,15 +545,17 @@ export default function Home() {
/* ignore */
}
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
if (userIdForCheck) {
const found = await checkPaymentFromPB(userIdForCheck);
if (found) {
const name = getStorage("userEmail") || getStorage("userName");
if (name) setUserName(name);
// 仅当有本地存储的 userId 时才校验(无身份/VIP 时跳过,点击按钮直接进入支付流程)
if (hadStoredUserId) {
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
if (userIdForCheck) {
const found = await checkPaymentFromPB(userIdForCheck);
if (found) {
const name = getStorage("userEmail") || getStorage("userName");
if (name) setUserName(name);
}
}
}
setLoading(false);
};
init();
@@ -778,15 +629,6 @@ export default function Home() {
const isLoggedIn = paidType === "vip";
if (loading) {
return (
<div className={styles.loading}>
<div className={styles.loadingSpinner} />
<p>...</p>
</div>
);
}
return (
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>

View File

@@ -1,14 +1,26 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getTopicConfig } from "../config";
import { isPaid } from "@/app/cloudphone/lib/payment";
type TopicFooterProps = {
topicId: string;
};
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
export function TopicFooter({ topicId }: TopicFooterProps) {
const config = getTopicConfig(topicId);
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<footer className="border-t border-[var(--border)] py-6 sm:py-8">
@@ -23,17 +35,30 @@ export function TopicFooter({ topicId }: TopicFooterProps) {
>
</Link>
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
{paid ? (
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
) : (
<Link
href="/"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
title="开通VIP解锁即时问答"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</Link>
)}
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
{config ? `${config.icon} ${config.title} · 异度星球` : "异度星球 · 数字游民社群"}

View File

@@ -2,10 +2,10 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import { getTopicConfig } from "../config";
import { isPaid } from "@/app/cloudphone/lib/payment";
import { triggerPay } from "@/app/lib/triggerPay";
type TopicHeaderProps = {
topicId: string;
@@ -22,11 +22,7 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
const ctaHref = config?.externalUrl
? config.externalUrl
: paid
? "/cloudphone/course/0/0"
: config?.payUrl ?? "/";
const isEnrollCta = !config?.externalUrl && !paid;
return (
<header
@@ -48,25 +44,55 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
>
</Link>
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
<Link
href={ctaHref}
target={config?.externalUrl ? "_blank" : undefined}
rel={config?.externalUrl ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
>
{config?.externalUrl ? "前往了解 →" : paid ? "进入课程 →" : "立即报名 →"}
</Link>
{paid ? (
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
) : (
<Link
href="/"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
title="开通VIP解锁即时问答"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</Link>
)}
{config?.externalUrl ? (
<Link
href={config.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
>
</Link>
) : isEnrollCta ? (
<button
type="button"
onClick={() => triggerPay()}
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
>
</button>
) : (
<Link
href="/cloudphone/course/0/0"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-4 sm:py-2 sm:text-sm"
>
</Link>
)}
</nav>
</div>
</header>

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { getTopicConfig, getTopicCurriculum } from "../config";
import { getLastWatched, getProgress } from "@/app/cloudphone/lib/learning";
import { ParticleBackground } from "@/app/cloudphone/components/ParticleBackground";
import { triggerPay } from "@/app/lib/triggerPay";
type TopicHeroProps = {
topicId: string;
@@ -33,11 +34,7 @@ export function TopicHero({ topicId }: TopicHeroProps) {
if (!config) return null;
const ctaHref = config.externalUrl
? config.externalUrl
: last
? `/cloudphone/course/${last.moduleIndex}/${last.lessonIndex}`
: config.payUrl;
const isEnrollCta = !config.externalUrl && !last;
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
@@ -82,14 +79,31 @@ export function TopicHero({ topicId }: TopicHeroProps) {
className="animate__animated animate__fadeInDown flex flex-wrap justify-center gap-3 sm:gap-4"
style={{ animationDelay: "0.6s", animationFillMode: "both" }}
>
<Link
href={ctaHref}
target={config.externalUrl ? "_blank" : undefined}
rel={config.externalUrl ? "noopener noreferrer" : undefined}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
{config.externalUrl ? "前往了解 →" : last ? "继续学习 →" : "立即报名 →"}
</Link>
{config.externalUrl ? (
<Link
href={config.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
</Link>
) : isEnrollCta ? (
<button
type="button"
onClick={() => triggerPay()}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
</button>
) : (
<Link
href={`/cloudphone/course/${last!.moduleIndex}/${last!.lessonIndex}`}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
</Link>
)}
</div>
)}
{curriculum?.hasContent && progress.completed > 0 && (

View File

@@ -31,7 +31,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
{ value: "170+", label: "分钟视频" },
{ value: "5", label: "大模块" },
],
payUrl: "/cloudphone/pay",
payUrl: "/",
},
livestream: {
id: "livestream",
@@ -45,7 +45,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
{ value: "2", label: "大模块" },
],
moduleIndices: [1, 2],
payUrl: "/cloudphone/pay",
payUrl: "/",
},
indie: {
id: "indie",

View File

@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { formatRestoreDateShort } from "@/config/promo.config";
import styles from "../vip.module.css";
@@ -9,6 +10,11 @@ type LandingCTAProps = {
};
export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
const [restoreDate, setRestoreDate] = useState<string>("");
useEffect(() => {
setRestoreDate(formatRestoreDateShort());
}, []);
return (
<section className={styles.section} style={{ paddingBottom: "2rem" }}>
<div
@@ -23,7 +29,7 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
}}
>
<h2 style={{ fontSize: "1.35rem", margin: "0 0 0.5rem", color: "var(--foreground)" }}>
· {formatRestoreDateShort()}
· {restoreDate || "—"}
</h2>
<p style={{ fontSize: "0.9rem", color: "var(--muted-foreground)", margin: "0 0 1rem" }}>
· · 退

View File

@@ -108,18 +108,49 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
>
{EBOOKS_DISPLAY.map((e) => {
const { id, href } = e;
const isFree = "free" in e && e.free;
const isLocked = "locked" in e && e.locked && onLockedClick;
const cardContent = (
<>
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem", display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
{e.title}
{isFree && (
<span className={styles.freeBadge}>
</span>
)}
{isLocked && (
<span title="VIP专享" style={{ fontSize: "1rem" }}>🔒</span>
)}
</div>
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--muted-foreground)" }}>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: isLocked ? "var(--muted-foreground)" : "var(--muted-foreground)" }}>
{isLocked ? "开通VIP解锁 →" : "阅读 →"}
</div>
</>
);
if (isLocked) {
return (
<button
key={id}
type="button"
onClick={onLockedClick}
className={styles.card}
style={{
display: "block",
padding: "1.25rem",
cursor: "pointer",
border: "none",
font: "inherit",
textAlign: "left",
width: "100%",
}}
>
{cardContent}
</button>
);
}
return href ? (
<Link
key={id}

View File

@@ -178,6 +178,15 @@
border-color: rgba(245, 158, 11, 0.3);
}
.freeBadge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
background: var(--muted);
color: var(--accent);
font-weight: 500;
}
/* 星球登录信息卡片 */
.loginCard {
margin-top: 1.25rem;

View File

@@ -23,7 +23,7 @@ export const CLOUDPHONE_EBOOK_CONFIG: EbookConfig = {
export const CLOUDPHONE_COURSE_CONFIG: CourseConfig = {
courseId: "cloudphone",
courseHomeHref: "/cloudphone",
payHref: "/cloudphone/pay",
payHref: "/",
freeTrial: (m, l) => m === 0 && l < 2,
videoStoragePrefix: "lms-cloudphone-video-",
learningStoragePrefix: "lms-cloudphone-",