This commit is contained in:
eric
2026-03-10 04:41:20 -05:00
parent 82e79110fc
commit 0093294472
31 changed files with 5541 additions and 73 deletions

227
app/api/pay/route.ts Normal file
View File

@@ -0,0 +1,227 @@
/**
* nomadvip 支付 API - 参考 digital
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
*/
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config";
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;
}
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>`;
}
function setPayOrderCookie(res: NextResponse, orderId: string) {
res.cookies.set("nomadvip_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
}
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()) as Record<string, string | number>;
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record<string, string>;
}
const payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
const envApiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL;
const apiUrl = (envApiUrl ? payConfig.apiUrl : getApiUrlFromRequest(hostHeader)).replace(/\/$/, "");
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || 18800;
const type = String(body?.type || "vip");
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc");
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") || "http://localhost:3000";
const finalReturnUrl = return_url || `${origin}/paid`;
const wantHtml = String(body?.html) === "1";
if (!wantHtml) {
return NextResponse.json({ ok: false, error: "请传 html=1" }, { status: 400 });
}
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
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(`${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 res = NextResponse.redirect(location);
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim();
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.use_form) {
// 微信内:直接用 payh5 获取 params,构建收银台表单(不调用 redirect 避免重复创建订单)
const createRes = await fetch(`${apiUrl}/nomadvip/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id, total_fee, type, channel }),
});
const createData = await createRes.json().catch(() => ({}));
const params = createData?.params || {};
const orderId = String(params?.out_trade_no || "").trim();
if (!params || Object.keys(params).length === 0) {
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
}
const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record<string, unknown>);
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
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;}.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>`;
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) setPayOrderCookie(res, orderId);
return res;
}
if (redirectRes.status === 400 || redirectRes.status === 500) {
return NextResponse.json(
{ ok: false, error: resData?.detail || resData?.msg || "支付跳转失败" },
{ status: redirectRes.status }
);
}
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 hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? "请确认 payjsapi 已启动"
: err.name === "AbortError"
? "请求超时"
: msg;
console.error("Pay API error:", err);
return NextResponse.json({ ok: false, error: `支付请求失败: ${hint}` }, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config";
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 });
}
const payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
const envApiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL;
const apiUrl = (envApiUrl ? payConfig.apiUrl : getApiUrlFromRequest(hostHeader)).replace(/\/$/, "");
try {
const res = await fetch(
`${apiUrl}/nomadvip/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 });
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

28
app/globals.css Normal file
View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
@import "github-markdown-css/github-markdown.css";
@import "animate.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

50
app/index-content.ts Normal file
View File

@@ -0,0 +1,50 @@
export const indexMd = `
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/image_20251231_015321_20586.jpg" alt="image_20251231_015321_20586.jpg" width="271">
🌟你将收获:
🚀 业务方向:无人直播、RPA 自动化、web开发
⚙️ 技术杠杆:云手机、PVE 虚拟化、服务器集群
🌐 海外平台:YouTube、TikTok、SaaS 运营实战
<br/>
📖 解锁=>电子书+视频教程
1️⃣[《数字游民》:地理套利 + 自动化杠杆](https://hackrobot.cn)
2️⃣ [《云手机》:低成本工作室搭建指南](https://android.hackrobot.cn)
<img src="https://minioweb.hackrobot.cn/hackrobot/20260101205143_20260101_205202_1552.png" title="" alt="20260101205143_20260101_205202_1552.png" width="180">
📱设备自助下单(开箱即用)
<img title="" src="https://minioweb.hackrobot.cn/hackrobot/wqdq_20260101_212749_8560.jpg" alt="wqdq_20260101_212749_8560.jpg" width="282">
📚 即时问答 (技术咨询)
<img src="https://minioweb.hackrobot.cn/hackrobot/20260101203904_20260101_204741_699.png" title="" alt="20260101203904_20260101_204741_699.png" width="284">
㊙️私密社群
<img src="https://minioweb.hackrobot.cn/hackrobot/20260101204456_20260101_204802_768.png" title="" alt="20260101204456_20260101_204802_768.png" width="186">
<br/>
🚫 内容不适合想躺赚、现金流紧张的人
💰 价格随参与人数阶梯式调整
(维护与答疑成本随规模增长)
📌 本内容为虚拟数字交付
不支持退款
👉 **限时特惠,3月12日恢复**
`;

231
app/index.module.css Normal file
View File

@@ -0,0 +1,231 @@
/* markdown-body from github-markdown-css */
.markVip {
padding-left: 50px;
padding-right: 50px;
}
.markVip img {
display: block;
margin: 10px auto;
max-width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.markVip p,
.markVip li,
.markVip h1,
.markVip h2,
.markVip h3 {
font-size: 15px;
}
.index {
height: 100vh;
}
.indexBook {
min-height: 150px;
display: flex;
flex-direction: column-reverse;
align-items: center;
justify-content: space-between;
}
.indexBookLeft {
min-width: 300px;
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
}
.leftTitle {
font-size: 25px;
font-weight: 700;
color: #69b1ff;
margin-top: 5px;
}
.card {
margin-top: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.cardItem {
padding: 10px;
width: 250px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
background-color: #f0f5ff;
border-radius: 10px;
}
.cardItemLeft {
display: flex;
flex-direction: column;
margin-right: 20px;
}
.cardItemLeftImg {
width: 40px;
height: 40px;
}
.cardItemRight {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.cardItemRightUp {
width: 100%;
height: 40px;
display: flex;
flex-direction: row;
font-weight: 700;
font-size: 17px;
}
.cardItemRightDown {
width: 100%;
flex: 1;
display: flex;
flex-direction: row;
font-size: 15px;
flex-wrap: wrap;
color: #262626;
}
.leftBtn {
margin-top: 15px;
margin-bottom: 15px;
}
.startRead {
width: 250px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: white;
background: linear-gradient(135deg, #69b1ff 0%, #4096ff 100%);
border: none;
border-radius: 9999px;
cursor: pointer;
}
.startRead:hover {
opacity: 0.9;
}
.indexBookImg {
margin-top: 15px;
width: 200px;
height: 200px;
}
.bookImg {
width: 100%;
height: auto;
}
.svgItems {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
margin-top: 20px;
}
/* H5 下隐藏「解锁vip」按钮下方模块(无人直播、RPA自动化、聊天机器人) */
@media (max-width: 768px) {
.svgItems {
display: none;
}
}
.svgItem {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
}
.bookImgItem {
width: 350px;
height: 350px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.bookImgText {
margin-top: 20px;
font-size: 30px;
}
.vipcode {
display: flex;
flex-direction: column;
align-items: center;
font-size: 15px;
}
.vipcode button {
margin: 8px 0;
padding: 8px 16px;
cursor: pointer;
}
@media (min-width: 1536px) {
.index {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.indexBook {
width: 100%;
max-width: 1400px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: #f0f5ff;
border-radius: 20px;
}
.indexBookLeft .card {
display: none;
}
.indexBookLeft .leftBtn .startRead {
width: 100px;
}
.indexBookImg .bookImg {
margin-bottom: 40px;
}
.svgItems {
width: 100%;
max-width: 1400px;
margin-top: 80px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}

34
app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "异度星球",
description: "数字游民社群",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

38
app/lib/payEnv.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* 支付环境检测:PC / H5 / 微信浏览器
* 参考 digital app/lib/payEnv.ts
*/
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 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";
}
export function getRecommendedChannel(env: PayEnv): PayChannel {
return "wxpay";
}
export function mustUseWxPay(env: PayEnv): boolean {
return env === "wechat";
}
export function getDeviceFromEnv(env: PayEnv): "pc" | "h5" | "wechat" {
if (env === "wechat") return "wechat";
return env === "pc" ? "pc" : "h5";
}

View File

@@ -0,0 +1,75 @@
/**
* 微信 H5 支付完成后可能不跳转,通过 cookie 轮询检测支付状态
* 参考 digital usePayStatusPoll
*/
import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "nomadvip_pay_order";
const POLL_INTERVAL = 2000;
const MAX_POLLS = 150;
const MAX_AGE_MS = 15 * 60 * 1000;
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 clearCookie(name: string): void {
if (typeof document === "undefined") return;
document.cookie = `${name}=; path=/; max-age=0`;
}
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
if (!raw?.trim()) return [null, false];
const parts = raw.split("|");
const orderId = parts[0]?.trim() || raw.trim();
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
if (!orderId) return [null, false];
if (Number.isNaN(ts)) return [orderId, false];
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
return [orderId, true];
}
export function usePayStatusPoll(onPaid: () => void): boolean {
const [polling, setPolling] = useState(false);
const onPaidRef = useRef(onPaid);
onPaidRef.current = onPaid;
useEffect(() => {
const raw = getCookie(COOKIE_NAME);
const [orderId, isValid] = parsePayOrderCookie(raw);
if (!orderId || !isValid) {
if (raw) clearCookie(COOKIE_NAME);
return;
}
setPolling(true);
let count = 0;
const timer = setInterval(async () => {
count++;
if (count > MAX_POLLS) {
clearInterval(timer);
clearCookie(COOKIE_NAME);
setPolling(false);
return;
}
try {
const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`);
const data = await res.json();
if (data?.ok && data?.paid) {
clearInterval(timer);
clearCookie(COOKIE_NAME);
setPolling(false);
onPaidRef.current();
}
} catch {
// 继续轮询
}
}, POLL_INTERVAL);
return () => clearInterval(timer);
}, []);
return polling;
}

310
app/page.tsx Normal file
View File

@@ -0,0 +1,310 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { marked } from "marked";
import PocketBase from "pocketbase";
import { indexMd } from "./index-content";
import styles from "./index.module.css";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
getDeviceFromEnv,
} from "@/app/lib/payEnv";
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
const PB_URL =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
function redirectToPay(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],
["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();
}
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 contentRef = useRef<HTMLDivElement>(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(() => {
let userId = getStorage("userId");
if (!userId) {
userId = `user${Date.now()}`;
setStorage("userId", userId);
}
return userId;
}, []);
const checkPaymentFromPB = useCallback(async (userId: string) => {
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;
savePaidType(latestType);
saveUserName(dbUserId);
return latestType;
} else {
savePaidType(null);
return null;
}
} catch (err) {
console.error("查询 PocketBase 失败:", err);
return getStorage("paidType");
}
}, [savePaidType, saveUserName]);
const clearAllStorage = useCallback(() => {
try {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.clear();
}
removeStorage("userId");
removeStorage("paidType");
removeStorage("userName");
setPaidType(null);
setUserName(null);
setLoading(false);
alert("本地存储已清空");
// 移除 URL 中的 ?type=clean,避免刷新时重复触发
if (typeof window !== "undefined" && window.history.replaceState) {
window.history.replaceState({}, "", window.location.pathname);
}
} catch (error) {
console.error("清空存储失败:", error);
alert("清空存储失败");
}
}, []);
const handlePay = useCallback(() => {
const userId = getOrCreateUserId();
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
const device = getDeviceFromEnv(env);
redirectToPay({
user_id: userId,
return_url: returnUrl,
total_fee: 18800,
type: "vip",
channel,
device,
});
}, [getOrCreateUserId]);
const handleCopyUsername = useCallback(() => {
const name = userName || "";
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(name);
alert("账号已复制");
}
}, [userName]);
// 微信 H5 支付完成后可能不跳转,轮询检测到已支付则刷新
usePayStatusPoll(() => {
savePaidType("vip");
saveUserName(getStorage("userId") || "");
});
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("type") === "clean") {
clearAllStorage();
return;
}
if (urlParams.get("paid") === "1") {
savePaidType("vip");
saveUserName(getStorage("userId") || "");
}
const init = async () => {
getOrCreateUserId();
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
if (localType && localUserName) {
setPaidType(localType);
setUserName(localUserName);
setLoading(false);
return;
}
const userId = getStorage("userId");
if (userId) {
await checkPaymentFromPB(userId);
}
setLoading(false);
};
init();
}, [clearAllStorage, getOrCreateUserId, checkPaymentFromPB, savePaidType, saveUserName]);
marked.setOptions({ breaks: true, gfm: true });
return (
<div className={styles.index}>
<div className={`${styles.indexBook} animate__animated animate__pulse`}>
<div className={styles.indexBookLeft}>
<div className={styles.leftTitle}>异度星球</div>
<div className={styles.card}>
<div className={styles.cardItem}>
<div className={styles.cardItemLeft}>
<Image
className={styles.cardItemLeftImg}
src="/images/group.svg"
alt="group"
width={40}
height={40}
/>
</div>
<div className={styles.cardItemRight}>
<div className={styles.cardItemRightUp}>数字游民社群</div>
<div className={styles.cardItemRightDown}>还在独自摸索吗?</div>
</div>
</div>
</div>
<div
ref={contentRef}
className={`markdown-body ${styles.markVip}`}
dangerouslySetInnerHTML={{ __html: marked(indexMd) as string }}
/>
<div className={styles.leftBtn}>
{paidType === "vip" ? (
<div className={styles.vipcode}>
<div
onClick={() => {
window.location.href = "https://qun.hackrobot.cn";
}}
style={{ cursor: "pointer" }}
>
👉星球地址: qun.hackrobot.cn
</div>
<div>账号:{getStorage("userName")}</div>
<button onClick={handleCopyUsername}>复制账号</button>
<div>密码:123456</div>
<div>打开网页后,请点击&quot;左上角头像&quot;,打开侧面栏</div>
</div>
) : (
<button
onClick={() => {
if (paidType) return;
handlePay();
}}
className={styles.startRead}
type="button"
>
🔒 解锁vip会员
</button>
)}
</div>
</div>
<div className={styles.indexBookImg}>
<Image
className={styles.bookImg}
src="/images/touxiang.png"
alt="avatar"
width={200}
height={200}
/>
</div>
</div>
<div className={styles.svgItems}>
<div className={styles.svgItem}>
<div className={styles.bookImgItem}>
<Image
src="/images/laptop.svg"
alt="laptop"
width={80}
height={80}
/>
<div className={styles.bookImgText}>无人直播</div>
</div>
<div className={styles.bookImgItem}>
<Image
src="/images/travel.svg"
alt="travel"
width={80}
height={80}
/>
<div className={styles.bookImgText}>RPA自动化</div>
</div>
<div className={styles.bookImgItem}>
<Image
src="/images/code.svg"
alt="code"
width={80}
height={80}
/>
<div className={styles.bookImgText}>聊天机器人</div>
</div>
</div>
</div>
</div>
);
}

76
app/paid/page.tsx Normal file
View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
export default function PaidPage() {
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
const [fromUrl, setFromUrl] = useState<string | null>(null);
usePayStatusPoll(() => setStatus("success"));
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const paid = params.get("paid");
const fail = params.get("fail");
const from = params.get("from");
if (paid === "1") setStatus("success");
else if (fail === "1") setStatus("fail");
if (from) setFromUrl(decodeURIComponent(from));
}, []);
if (status === "pending") {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
<div className="mx-auto mb-5 flex h-16 w-16 animate-spin items-center justify-center rounded-full border-4 border-sky-200 border-t-sky-500" />
<h2 className="text-xl font-bold text-slate-800">支付结果确认中</h2>
<p className="mt-3 text-sm text-slate-500">
正在查询支付状态,请稍候…
<br />
(支付完成后将自动跳转)
</p>
</div>
</div>
);
}
if (status === "success") {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
✅
</div>
<h2 className="text-2xl font-bold text-slate-800">支付成功</h2>
<p className="mt-3 text-slate-500">感谢您的支持,VIP 权益已开通</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
>
← 返回首页
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-amber-50 text-5xl">
⚠️
</div>
<h2 className="text-2xl font-bold text-slate-800">支付未完成</h2>
<p className="mt-3 text-slate-500">支付已取消或未成功,请重试</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full border-2 border-slate-300 px-8 py-3 font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:bg-slate-50"
>
← 返回首页
</Link>
</div>
</div>
);
}