Files
gitlab-instance-0a899031_no…/app/page.tsx
2026-03-12 04:11:29 -05:00

469 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import PocketBase from "pocketbase";
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 { PayAuthModal } from "@/app/vip/components/landing/PayAuthModal";
import styles from "@/app/vip/components/vip.module.css";
const PB_URL =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
/** 微信内支付渠道zpay | xorpay */
const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay";
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";
}): 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 (data?.redirect_url) {
window.open(data.redirect_url, "_blank");
return orderId;
}
const form = document.createElement("form");
form.method = "POST";
form.action = data.payUrl;
form.target = "_blank";
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;
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);
}
function setStorage(key: string, value: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(key, value);
}
function removeStorage(key: string): void {
if (typeof window === "undefined") return;
localStorage.removeItem(key);
}
export default function Home() {
const [paidType, setPaidType] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
const savePaidType = useCallback((type: string | null) => {
if (type) setStorage("paidType", type);
else removeStorage("paidType");
setPaidType(type);
}, []);
const saveUserName = useCallback((name: string) => {
setStorage("userName", name);
setUserName(name);
}, []);
const getOrCreateUserId = useCallback(async () => {
// 优先使用 PocketBase 用户 ID已登录时
try {
const meRes = await fetch("/api/auth/me");
const meData = await meRes.json();
if (meData?.user?.id) {
const pbUserId = meData.user.id;
setStorage("userId", pbUserId);
return pbUserId;
}
} catch {
/* ignore */
}
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;
}, []);
const checkPaymentFromPB = useCallback(
async (userId: string): Promise<boolean> => {
try {
const pb = new PocketBase(PB_URL);
const records = await pb.collection("payments").getList(1, 1, {
filter: `user_id = "${userId}"`,
sort: "-created",
});
if (records.items.length > 0) {
const latestType = records.items[0].type as string;
const dbUserId = records.items[0].user_id as string;
setStorage("userId", dbUserId);
savePaidType(latestType);
saveUserName(dbUserId);
return latestType === "vip";
}
savePaidType(null);
return false;
} catch (err) {
console.error("查询 PocketBase 失败:", err);
return false;
}
},
[savePaidType, saveUserName]
);
/** 会员账号恢复:通过邮箱+密码验证,关联 users 后检查 site_vip / payments */
const checkVerifyByEmail = useCallback(
async (email: string, password: string): Promise<boolean> => {
try {
const { pbLogin, pbSaveAuth } = await import("@/app/lib/pocketbase");
const result = await pbLogin(email, password);
pbSaveAuth(result.token, result.record);
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),
});
const userId = result.record.id;
const vipRes = await fetch("/api/vip/check");
const vipData = await vipRes.json();
if (vipData?.vip) {
setStorage("userId", userId);
savePaidType("vip");
saveUserName(email);
return true;
}
const found = await checkPaymentFromPB(userId);
if (found) return true;
return false;
} catch {
return false;
}
},
[checkPaymentFromPB, savePaidType, saveUserName]
);
const clearAllStorage = useCallback(() => {
try {
if (typeof window === "undefined") return;
const keys = ["userId", "paidType", "userName"];
keys.forEach((k) => {
try {
localStorage.removeItem(k);
} catch {
/* ignore */
}
});
setPaidType(null);
setUserName(null);
setLoading(false);
const path = window.location.pathname || "/";
window.location.replace(path);
} catch (error) {
console.error("清空存储失败:", error);
}
}, []);
const [authForPayOpen, setAuthForPayOpen] = useState(false);
const doPay = useCallback(async () => {
const userId = await getOrCreateUserId();
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);
if (orderId) setPayPendingOrderId(orderId);
} else {
redirectToPayZpay({ ...base, device });
}
}, [getOrCreateUserId]);
const handlePay = useCallback(async () => {
try {
const meRes = await fetch("/api/auth/me");
const meData = await meRes.json();
if (meData?.user?.id) {
doPay();
return;
}
} catch {
/* ignore */
}
setAuthForPayOpen(true);
}, [doPay]);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
}, []);
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const isH5 = device === "h5";
usePayStatusPoll(
(orderId) => {
let uid = getStorage("userId");
if (!uid && orderId?.includes("_order_")) {
const prefix = orderId.split("_order_")[0];
const parts = prefix.split("_");
if (parts.length >= 2) uid = parts.slice(0, -1).join("_");
}
if (uid) saveUserName(uid);
savePaidType("vip");
if (isH5) setPayPendingOrderId(null);
window.location.reload();
},
() => {
if (isH5) setPayPendingOrderId(null);
},
isH5 ? payPendingOrderId : undefined
);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("type")?.trim() === "clean") {
clearAllStorage();
return;
}
if (urlParams.get("paid") === "1") {
savePaidType("vip");
saveUserName(getStorage("userId") || "");
}
const init = async () => {
await getOrCreateUserId();
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
if (localType && localUserName) {
setPaidType(localType);
setUserName(localUserName);
setLoading(false);
return;
}
// SSO: 优先检查根域 Cookie跨站登录态
try {
const meRes = await fetch("/api/auth/me");
const meData = await meRes.json();
if (meData?.user && meData?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
const vipRes = await fetch("/api/vip/check");
const vipData = await vipRes.json();
if (vipData?.vip) {
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
setLoading(false);
return;
}
}
} catch {
/* ignore */
}
const userId = getStorage("userId");
if (userId) {
const found = await checkPaymentFromPB(userId);
if (found) {
setUserName(getStorage("userName"));
}
}
setLoading(false);
};
init();
}, [clearAllStorage, getOrCreateUserId, checkPaymentFromPB, savePaidType, saveUserName]);
useEffect(() => {
const syncFromStorage = () => {
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
if (localType && localUserName && !paidType) {
setPaidType(localType);
setUserName(localUserName);
}
};
const onVisible = () => syncFromStorage();
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) syncFromStorage();
};
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", onPageShow);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", onPageShow);
};
}
}, [paidType]);
const handleLogout = useCallback(async () => {
const { pbLogout } = await import("@/app/lib/pocketbase");
await pbLogout();
clearAllStorage();
}, [clearAllStorage]);
const isLoggedIn = paidType === "vip";
if (loading) {
return (
<div className={styles.loading}>
<div className={styles.loadingSpinner} />
<p>...</p>
</div>
);
}
return (
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} onLogout={handleLogout}>
{isLoggedIn ? (
<DashboardPage userName={userName} />
) : (
<LandingPage
onJoin={handlePay}
onVerify={checkPaymentFromPB}
onVerifyByEmail={checkVerifyByEmail}
onVerifySuccess={handleVerifySuccess}
/>
)}
</VipLayout>
<PayAuthModal
open={authForPayOpen}
onClose={() => setAuthForPayOpen(false)}
onSuccess={() => {
setAuthForPayOpen(false);
doPay();
}}
/>
</>
);
}