This commit is contained in:
eric
2026-03-27 18:20:36 -05:00
parent a8976b2eee
commit 326bad4336
34 changed files with 2787 additions and 996 deletions

716
app/HomeClient.tsx Normal file
View File

@@ -0,0 +1,716 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { HomeBootOverlay } from "@/app/components/HomeBootOverlay";
import { triggerPay } from "@/app/lib/triggerPay";
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
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";
// 渠道规则: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;
const EMAIL_VERIFY_TS_KEY = "emailVerifyAt";
const EMAIL_VERIFY_COOLDOWN_MS = 60 * 1000;
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);
}
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 clearCookie(name: string): void {
if (typeof document === "undefined") return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
}
function clearAnonymousUserCookie(): void {
clearCookie(ANONYMOUS_USER_COOKIE);
}
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 clearPendingOrder(): void {
clearCookie(PAY_ORDER_COOKIE);
removeStorage(PAY_ORDER_COOKIE);
}
function getPendingOrderId(): string | null {
const raw = getCookie(PAY_ORDER_COOKIE) || getStorage(PAY_ORDER_COOKIE);
const orderId = raw?.split("|")[0]?.trim() || "";
return orderId || null;
}
/** 仅在本组件内使用(由 page.tsx dynamic ssr:false 仅客户端挂载,首帧可读 localStorage) */
function readVipBootstrapState(): {
paidType: string | null;
userName: string | null;
userEmail: string | null;
} {
if (typeof window === "undefined") {
return { paidType: null, userName: null, userEmail: null };
}
const localType = localStorage.getItem("paidType");
const localUserName = localStorage.getItem("userName");
const localUserEmail = localStorage.getItem("userEmail");
const localUserId = localStorage.getItem("userId");
if (
localType === "vip" &&
(localUserName || localUserEmail) &&
localUserId
) {
return {
paidType: localType,
userName: localUserEmail || localUserName || "",
userEmail:
localUserEmail ||
(localUserName?.includes("@") ? localUserName : null),
};
}
return { paidType: null, userName: null, userEmail: null };
}
/** 首页:init 完成前若尚未从本地恢复 VIP,不渲染落地页(避免 H5 reload 后先闪未解锁) */
export default function HomeClient() {
const [paidType, setPaidType] = useState<string | null>(
() => readVipBootstrapState().paidType
);
const [userName, setUserName] = useState<string | null>(
() => readVipBootstrapState().userName
);
const [userEmail, setUserEmail] = useState<string | null>(
() => readVipBootstrapState().userEmail
);
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
const [initDone, setInitDone] = useState(false);
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 getStoredAnonymousUserId = useCallback((): 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;
}, []);
const applyRecoveredEmailSession = useCallback(
async (
data: {
token?: string;
record?: { id?: string; email?: string };
anonymousUserId?: string | null;
memosAccount?: string | null;
},
normalizedEmail: string,
fallbackAnonymousUserId?: string
) => {
if (!data?.token || !data?.record?.id) {
return false;
}
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? normalizedEmail });
const anonymousUserId =
(typeof data?.anonymousUserId === "string" &&
/^user\d+$/.test(data.anonymousUserId)
? data.anonymousUserId
: typeof data?.memosAccount === "string" &&
/^user\d+$/.test(data.memosAccount)
? data.memosAccount
: /^user\d+$/.test(fallbackAnonymousUserId?.trim() || "")
? fallbackAnonymousUserId?.trim()
: "") || "";
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: data.token,
record: data.record,
anonymousUserId: anonymousUserId || undefined,
}),
credentials: "include",
});
setStorage("userId", data.record.id);
setStorage("userEmail", normalizedEmail);
setStorage("userName", normalizedEmail);
setStorage("emailLinked", "1");
if (anonymousUserId) {
setStorage("memosAccount", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
}
savePaidType("vip");
setUserEmail(normalizedEmail);
saveUserName(normalizedEmail);
setStorage(EMAIL_VERIFY_TS_KEY, String(Date.now()));
return true;
},
[savePaidType, saveUserName]
);
const recoverLinkedVipSession = useCallback(
async (
email: string,
password: string,
user_id?: string
): Promise<{ ok: boolean; error?: string }> => {
try {
const normalizedEmail = email.trim().toLowerCase();
const res = await fetch("/api/vip/check-by-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: normalizedEmail,
password,
...(user_id?.trim() && { user_id: user_id.trim() }),
}),
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!data?.vip || !data?.token || !data?.record) {
return {
ok: false,
error:
data?.error ||
"未找到关联的会员记录,请确认已支付并绑定邮箱",
};
}
const ok = await applyRecoveredEmailSession(
data,
normalizedEmail,
user_id
);
return ok ? { ok: true } : { ok: false, error: "登录恢复失败" };
} catch (error) {
const msg = error instanceof Error ? error.message : "验证失败";
return { ok: false, error: msg };
}
},
[applyRecoveredEmailSession]
);
const getOrCreateUserId = useCallback(async () => {
const anonymousUserId = getStoredAnonymousUserId();
if (anonymousUserId) {
setStorage("userId", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
return anonymousUserId;
}
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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;
}, [getStoredAnonymousUserId]);
/** user_id 验证:调用后端 API(优先 payjsapi,回退 PocketBase) */
const checkVerifyByUserId = useCallback(
async (userId: string): Promise<boolean> => {
try {
const normalizedUserId = userId.trim();
const res = await fetch("/api/vip/check-by-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: normalizedUserId }),
});
const data = await res.json().catch(() => ({}));
if (data?.vip) {
setStorage("userId", normalizedUserId);
if (/^user\d+$/.test(normalizedUserId)) {
setStorage("memosAccount", normalizedUserId);
setAnonymousUserCookie(normalizedUserId);
}
savePaidType("vip");
if (data?.alreadyLinked && data?.email) {
const linkedEmail = String(data.email).trim().toLowerCase();
setStorage("emailLinked", "1");
setStorage("userEmail", linkedEmail);
setStorage("userName", linkedEmail);
setUserEmail(linkedEmail);
saveUserName(linkedEmail);
await recoverLinkedVipSession(
linkedEmail,
DEFAULT_EMAIL_PASSWORD,
normalizedUserId
).catch(() => {});
} else {
const linked = getStorage("emailLinked") === "1";
const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@");
if (!linked && !hasEmail) saveUserName(normalizedUserId);
}
return true;
}
savePaidType(null);
return false;
} catch (err) {
console.error("验证失败:", err);
return false;
}
},
[recoverLinkedVipSession, savePaidType, saveUserName]
);
const checkPaymentFromPB = checkVerifyByUserId;
const tryRecoverByAnonymousUserId = useCallback(
async (anonymousUserId: string): Promise<boolean> => {
if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) {
return false;
}
const firstHit = await checkPaymentFromPB(anonymousUserId);
if (firstHit) {
return true;
}
const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat";
if (!isWechat) {
return false;
}
// WeChat can close page immediately after pay success; notify write may lag.
for (let attempt = 0; attempt < 4; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const recovered = await checkPaymentFromPB(anonymousUserId);
if (recovered) {
return true;
}
}
return false;
},
[checkPaymentFromPB]
);
/** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP,可选 user_id 用于自动关联 */
const checkVerifyByEmail = useCallback(
async (
email: string,
password: string,
user_id?: string
): Promise<{ ok: boolean; error?: string }> => {
return recoverLinkedVipSession(email, password, user_id);
},
[recoverLinkedVipSession]
);
const clearAllStorage = useCallback(() => {
try {
if (typeof window === "undefined") return;
try {
localStorage.clear();
} catch {
/* ignore */
}
clearAnonymousUserCookie();
clearPendingOrder();
setPaidType(null);
setUserName(null);
const path = window.location.pathname || "/";
window.location.replace(path);
} catch (error) {
console.error("清空存储失败:", error);
}
}, []);
const handlePay = useCallback(() => {
triggerPay(setPayPendingOrderId);
}, []);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
}, []);
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const isH5 = device === "h5";
usePayStatusPoll(
async (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) {
let confirmed = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const confirmRes = await fetch("/api/pay/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId, user_id: uid }),
});
const confirmData = await confirmRes.json().catch(() => ({}));
if (confirmData?.ok) {
confirmed = true;
break;
}
} catch (e) {
console.error("pay/confirm request error:", e);
}
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
}
if (confirmed) {
saveUserName(uid);
savePaidType("vip");
}
/* confirm 失败时不设置 paidType,避免显示 VIP 但 PB/Memos 无记录 */
}
clearPendingOrder();
if (isH5) setPayPendingOrderId(null);
window.location.reload();
},
() => {
clearPendingOrder();
if (isH5) setPayPendingOrderId(null);
},
isH5 ? payPendingOrderId ?? undefined : undefined
);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("type")?.trim() === "clean") {
clearAllStorage();
setInitDone(true);
return;
}
/* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */
const init = async () => {
const pendingOrderId = getPendingOrderId();
// 已有 VIP 时忽略陈旧支付轮询标记,否则会 early return,init 从不恢复 VIP,表现为「登录后仍显示未解锁」
if (pendingOrderId) {
const alreadyVip = getStorage("paidType") === "vip";
if (alreadyVip) {
clearPendingOrder();
} else {
setPayPendingOrderId(pendingOrderId);
return;
}
}
// 本地已有 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) {
// 立即展示 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 */ }
}
// 后台悄悄校验:user 被删或 VIP 被撤则清除本地存储并更新状态
const verifyInBackground = async () => {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
let skipClear = 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();
if (vipData?.adminUnavailable) {
skipClear = true;
} else {
verified = !!vipData?.vip;
}
}
} catch { /* ignore */ }
if (!verified && !skipClear) {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
savePaidType(null);
setUserName(null);
setUserEmail(null);
}
};
void verifyInBackground();
return;
}
// 校验逻辑:有本地存储(登录资料+VIP)时优先使用并校验;无身份/VIP 时点击按钮直接进入支付,不校验
const anonymousCandidate = getStoredAnonymousUserId();
const hadStoredUserId = !!anonymousCandidate;
const emailVerifiedAt = Number(getStorage(EMAIL_VERIFY_TS_KEY) || "0");
const skipAnonymousRecover =
!!(getStorage("emailLinked") === "1" && Date.now() - emailVerifiedAt < EMAIL_VERIFY_COOLDOWN_MS);
if (anonymousCandidate && !skipAnonymousRecover) {
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
if (recovered) {
setStorage("userId", anonymousCandidate);
setStorage("memosAccount", anonymousCandidate);
setAnonymousUserCookie(anonymousCandidate);
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
setUserName(name);
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
return;
}
}
await getOrCreateUserId();
const afterLocalType = getStorage("paidType");
const afterLocalUserName = getStorage("userName");
const afterLocalUserEmail = getStorage("userEmail");
if (afterLocalType && (afterLocalUserName || afterLocalUserEmail)) {
setPaidType(afterLocalType);
setUserName(afterLocalUserEmail || afterLocalUserName || "");
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
return;
}
// 检查本站 Cookie 登录态
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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 });
setUserEmail(meData.user.email || null);
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
if (vipData?.vip) {
// 匿名 id 不覆盖;仅当当前无匿名 id 时才写入(可能是纯邮箱登录)
const cur = getStorage("userId");
if (cur && /^user\d+$/.test(cur)) {
setStorage("memosAccount", cur);
} else if (
typeof meData.user.anonymousUserId === "string" &&
/^user\d+$/.test(meData.user.anonymousUserId)
) {
setStorage("memosAccount", meData.user.anonymousUserId);
}
setStorage("userId", meData.user.id);
setStorage("emailLinked", "1");
if (meData.user.email) {
setStorage("userEmail", meData.user.email);
}
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
return;
}
}
} catch {
/* ignore */
}
// 仅当有本地存储的 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);
}
}
}
};
void (async () => {
try {
await init();
} finally {
setInitDone(true);
}
})();
}, [
clearAllStorage,
getOrCreateUserId,
getStoredAnonymousUserId,
checkPaymentFromPB,
savePaidType,
saveUserName,
tryRecoverByAnonymousUserId,
]);
useEffect(() => {
if (!isH5) return;
const syncPendingOrder = () => {
const pendingOrderId = getPendingOrderId();
if (!pendingOrderId) return;
setPayPendingOrderId((current) =>
current === pendingOrderId ? current : pendingOrderId
);
};
const onVisible = () => {
if (document.visibilityState === "visible") {
syncPendingOrder();
}
};
syncPendingOrder();
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", syncPendingOrder);
window.addEventListener("focus", syncPendingOrder);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", syncPendingOrder);
window.removeEventListener("focus", syncPendingOrder);
};
}, [isH5]);
useEffect(() => {
const syncFromStorage = () => {
const localType = getStorage("paidType");
const localName = getStorage("userEmail") || getStorage("userName");
if (localType && localName && !paidType) {
setPaidType(localType);
setUserName(localName);
}
};
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();
setUserEmail(null);
clearAllStorage();
}, [clearAllStorage]);
const isLoggedIn = paidType === "vip";
const showGuestLanding = initDone && !isLoggedIn;
return (
<>
{!initDone && !isLoggedIn ? (
<HomeBootOverlay visible subtitle="正在同步会员状态…" />
) : null}
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
{isLoggedIn ? (
<DashboardPage userName={userName} userEmail={userEmail} />
) : showGuestLanding ? (
<>
<LandingPage
onJoin={handlePay}
onVerify={checkPaymentFromPB}
onVerifyByEmail={checkVerifyByEmail}
onVerifySuccess={handleVerifySuccess}
/>
</>
) : (
<div className="min-h-[50vh]" aria-hidden />
)}
</VipLayout>
</>
);
}

17
app/HomeClientLoader.tsx Normal file
View File

@@ -0,0 +1,17 @@
"use client";
import dynamic from "next/dynamic";
import { HomeBootOverlay } from "@/app/components/HomeBootOverlay";
const HomeClient = dynamic(() => import("./HomeClient"), {
ssr: false,
loading: () => (
<div className="min-h-screen bg-[var(--background)]">
<HomeBootOverlay visible subtitle="加载中…" />
</div>
),
});
export function HomeClientLoader() {
return <HomeClient />;
}

View File

@@ -42,7 +42,12 @@ export async function GET(request: NextRequest) {
try {
const adminToken = await getAdminToken();
if (!adminToken) {
return NextResponse.json({ ok: true, vip: false });
// 未配置 POCKETBASE_EMAIL/PASSWORD 等时无法校验;前端勿据此清空本地已登录态
return NextResponse.json({
ok: true,
vip: false,
adminUnavailable: true,
});
}
const userRecord = await getActiveSiteVipRecord(adminToken, userId);

View File

@@ -24,23 +24,25 @@ export function ChatFloatButton() {
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>
<span className="hidden">
<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>
</span>
);
}
return (
<>
<span className="hidden">
<button
type="button"
onClick={() => setModalOpen(true)}
@@ -54,6 +56,6 @@ export function ChatFloatButton() {
</svg>
</button>
<InstantQaVipModal open={modalOpen} onClose={() => setModalOpen(false)} />
</>
</span>
);
}

View File

@@ -30,28 +30,32 @@ export function Footer() {
课程首页
</Link>
{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>
<span className="hidden">
<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>
</span>
) : (
<button
type="button"
onClick={() => setModalOpen(true)}
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="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>
即时问答
</button>
<span className="hidden">
<button
type="button"
onClick={() => setModalOpen(true)}
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="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>
即时问答
</button>
</span>
)}
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">

View File

@@ -28,28 +28,32 @@ export function Header() {
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
<ThemeToggle />
{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>
<span className="hidden">
<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 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: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>
</span>
) : (
<button
type="button"
onClick={() => setQaModalOpen(true)}
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="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>
即时问答
</button>
<span className="hidden">
<button
type="button"
onClick={() => setQaModalOpen(true)}
className="inline-flex 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: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="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>
即时问答
</button>
</span>
)}
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
首页

View File

@@ -0,0 +1,47 @@
"use client";
type HomeBootOverlayProps = {
visible: boolean;
/** 默认:正在同步会员状态… */
subtitle?: string;
};
/**
* 全屏加载(登录验证、首屏等按需使用)
*/
export function HomeBootOverlay({ visible, subtitle }: HomeBootOverlayProps) {
if (!visible) return null;
const line = subtitle ?? "正在同步会员状态…";
return (
<div
className="fixed inset-0 z-[200] flex flex-col items-center justify-center gap-6 bg-[var(--background)]/92 backdrop-blur-md"
aria-busy="true"
aria-live="polite"
>
<div className="relative flex h-20 w-20 items-center justify-center">
<div
className="absolute inset-0 animate-spin rounded-full border-2 border-amber-500/20 border-t-amber-500"
style={{ animationDuration: "1.05s" }}
/>
<div
className="absolute inset-2 animate-spin rounded-full border-2 border-transparent border-b-amber-400/50"
style={{ animationDuration: "0.75s", animationDirection: "reverse" }}
/>
<span
className="animate__animated animate__pulse animate__infinite text-3xl drop-shadow-sm"
aria-hidden
>
🌍
</span>
</div>
<div className="flex flex-col items-center gap-1 px-6 text-center">
<p className="text-base font-semibold tracking-wide text-[var(--foreground)]">
异度星球
</p>
<p className="text-sm text-[var(--muted-foreground)]">{line}</p>
</div>
</div>
);
}

View File

@@ -52,11 +52,11 @@ export const NETDISK_RESOURCES: NetdiskResource[] = [
{
provider: "quark",
label: "夸克网盘",
url: "https://pan.quark.cn/s/f9f489da5777?pwd=qD61",
url: "https://pan.quark.cn/s/f46a37ac3937?pwd=dLwD",
codeLabel: "提取码",
code: "qD61",
code: "dLwD",
copyText:
"我用夸克网盘给你分享了「直播助手.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。\n/~05213Xseol~:/\n链接:https://pan.quark.cn/s/f9f489da5777?pwd=qD61\n提取码:qD61",
"我用夸克网盘给你分享了「无人直播助手 1.0.1.exe.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。\n/b43c3XtQ8y:/\n链接:https://pan.quark.cn/s/f46a37ac3937?pwd=dLwD\n提取码:dLwD",
},
],
},

View File

@@ -61,7 +61,7 @@ export const EBOOKS_DISPLAY = [
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone", locked: true },
] as const;
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
/** 视频教程展示(首页),至少 4 个 */
export const VIDEO_COURSES_DISPLAY = [
{ id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机,TikTok/YouTube 无人直播实战指南", href: "/cloudphone" },
{ id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
@@ -93,8 +93,8 @@ export const FAQ_ITEMS = [
a: "点击「立即加入」完成支付即可,支付成功后自动开通,无需人工审核。",
},
{
q: "支持退款吗?",
a: "本内容为虚拟数字交付,不支持退款。购买前请确认适合您的需求。",
q: "为什么越早加入越划算?",
a: "会员采用阶梯定价:越早锁定席位,单价越低;在恢复原价前完成支付,可长期享受全部内容与后续更新,整体投入更省、权益不打折。",
},
] as const;

View File

@@ -1,656 +1,5 @@
"use client";
import { HomeClientLoader } from "./HomeClientLoader";
import { useCallback, useEffect, useState } from "react";
import { triggerPay } from "@/app/lib/triggerPay";
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
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";
// 渠道规则: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;
const EMAIL_VERIFY_TS_KEY = "emailVerifyAt";
const EMAIL_VERIFY_COOLDOWN_MS = 60 * 1000;
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);
}
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 clearCookie(name: string): void {
if (typeof document === "undefined") return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
}
function clearAnonymousUserCookie(): void {
clearCookie(ANONYMOUS_USER_COOKIE);
}
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 clearPendingOrder(): void {
clearCookie(PAY_ORDER_COOKIE);
removeStorage(PAY_ORDER_COOKIE);
}
function getPendingOrderId(): string | null {
const raw = getCookie(PAY_ORDER_COOKIE) || getStorage(PAY_ORDER_COOKIE);
const orderId = raw?.split("|")[0]?.trim() || "";
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 [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 getStoredAnonymousUserId = useCallback((): 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;
}, []);
const applyRecoveredEmailSession = useCallback(
async (
data: {
token?: string;
record?: { id?: string; email?: string };
anonymousUserId?: string | null;
memosAccount?: string | null;
},
normalizedEmail: string,
fallbackAnonymousUserId?: string
) => {
if (!data?.token || !data?.record?.id) {
return false;
}
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? normalizedEmail });
const anonymousUserId =
(typeof data?.anonymousUserId === "string" &&
/^user\d+$/.test(data.anonymousUserId)
? data.anonymousUserId
: typeof data?.memosAccount === "string" &&
/^user\d+$/.test(data.memosAccount)
? data.memosAccount
: /^user\d+$/.test(fallbackAnonymousUserId?.trim() || "")
? fallbackAnonymousUserId?.trim()
: "") || "";
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: data.token,
record: data.record,
anonymousUserId: anonymousUserId || undefined,
}),
credentials: "include",
});
setStorage("userId", data.record.id);
setStorage("userEmail", normalizedEmail);
setStorage("userName", normalizedEmail);
setStorage("emailLinked", "1");
if (anonymousUserId) {
setStorage("memosAccount", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
}
savePaidType("vip");
setUserEmail(normalizedEmail);
saveUserName(normalizedEmail);
setStorage(EMAIL_VERIFY_TS_KEY, String(Date.now()));
return true;
},
[savePaidType, saveUserName]
);
const recoverLinkedVipSession = useCallback(
async (
email: string,
password: string,
user_id?: string
): Promise<{ ok: boolean; error?: string }> => {
try {
const normalizedEmail = email.trim().toLowerCase();
const res = await fetch("/api/vip/check-by-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: normalizedEmail,
password,
...(user_id?.trim() && { user_id: user_id.trim() }),
}),
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!data?.vip || !data?.token || !data?.record) {
return {
ok: false,
error:
data?.error ||
"未找到关联的会员记录,请确认已支付并绑定邮箱",
};
}
const ok = await applyRecoveredEmailSession(
data,
normalizedEmail,
user_id
);
return ok ? { ok: true } : { ok: false, error: "登录恢复失败" };
} catch (error) {
const msg = error instanceof Error ? error.message : "验证失败";
return { ok: false, error: msg };
}
},
[applyRecoveredEmailSession]
);
const getOrCreateUserId = useCallback(async () => {
const anonymousUserId = getStoredAnonymousUserId();
if (anonymousUserId) {
setStorage("userId", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
return anonymousUserId;
}
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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;
}, [getStoredAnonymousUserId]);
/** user_id 验证:调用后端 API(优先 payjsapi,回退 PocketBase) */
const checkVerifyByUserId = useCallback(
async (userId: string): Promise<boolean> => {
try {
const normalizedUserId = userId.trim();
const res = await fetch("/api/vip/check-by-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: normalizedUserId }),
});
const data = await res.json().catch(() => ({}));
if (data?.vip) {
setStorage("userId", normalizedUserId);
if (/^user\d+$/.test(normalizedUserId)) {
setStorage("memosAccount", normalizedUserId);
setAnonymousUserCookie(normalizedUserId);
}
savePaidType("vip");
if (data?.alreadyLinked && data?.email) {
const linkedEmail = String(data.email).trim().toLowerCase();
setStorage("emailLinked", "1");
setStorage("userEmail", linkedEmail);
setStorage("userName", linkedEmail);
setUserEmail(linkedEmail);
saveUserName(linkedEmail);
await recoverLinkedVipSession(
linkedEmail,
DEFAULT_EMAIL_PASSWORD,
normalizedUserId
).catch(() => {});
} else {
const linked = getStorage("emailLinked") === "1";
const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@");
if (!linked && !hasEmail) saveUserName(normalizedUserId);
}
return true;
}
savePaidType(null);
return false;
} catch (err) {
console.error("验证失败:", err);
return false;
}
},
[recoverLinkedVipSession, savePaidType, saveUserName]
);
const checkPaymentFromPB = checkVerifyByUserId;
const tryRecoverByAnonymousUserId = useCallback(
async (anonymousUserId: string): Promise<boolean> => {
if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) {
return false;
}
const firstHit = await checkPaymentFromPB(anonymousUserId);
if (firstHit) {
return true;
}
const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat";
if (!isWechat) {
return false;
}
// WeChat can close page immediately after pay success; notify write may lag.
for (let attempt = 0; attempt < 4; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const recovered = await checkPaymentFromPB(anonymousUserId);
if (recovered) {
return true;
}
}
return false;
},
[checkPaymentFromPB]
);
/** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP,可选 user_id 用于自动关联 */
const checkVerifyByEmail = useCallback(
async (
email: string,
password: string,
user_id?: string
): Promise<{ ok: boolean; error?: string }> => {
return recoverLinkedVipSession(email, password, user_id);
},
[recoverLinkedVipSession]
);
const clearAllStorage = useCallback(() => {
try {
if (typeof window === "undefined") return;
try {
localStorage.clear();
} catch {
/* ignore */
}
clearAnonymousUserCookie();
clearPendingOrder();
setPaidType(null);
setUserName(null);
const path = window.location.pathname || "/";
window.location.replace(path);
} catch (error) {
console.error("清空存储失败:", error);
}
}, []);
const handlePay = useCallback(() => {
triggerPay(setPayPendingOrderId);
}, []);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
}, []);
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const isH5 = device === "h5";
usePayStatusPoll(
async (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) {
let confirmed = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const confirmRes = await fetch("/api/pay/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId, user_id: uid }),
});
const confirmData = await confirmRes.json().catch(() => ({}));
if (confirmData?.ok) {
confirmed = true;
break;
}
} catch (e) {
console.error("pay/confirm request error:", e);
}
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
}
if (confirmed) {
saveUserName(uid);
savePaidType("vip");
}
/* confirm 失败时不设置 paidType,避免显示 VIP 但 PB/Memos 无记录 */
}
clearPendingOrder();
if (isH5) setPayPendingOrderId(null);
window.location.reload();
},
() => {
clearPendingOrder();
if (isH5) setPayPendingOrderId(null);
},
isH5 ? payPendingOrderId ?? undefined : undefined
);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("type")?.trim() === "clean") {
clearAllStorage();
return;
}
/* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */
const init = async () => {
const pendingOrderId = getPendingOrderId();
if (pendingOrderId) {
setPayPendingOrderId(pendingOrderId);
return;
}
// 本地已有 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) {
// 立即展示 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 */ }
}
// 后台悄悄校验: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);
}
};
void verifyInBackground();
return;
}
// 校验逻辑:有本地存储(登录资料+VIP)时优先使用并校验;无身份/VIP 时点击按钮直接进入支付,不校验
const anonymousCandidate = getStoredAnonymousUserId();
const hadStoredUserId = !!anonymousCandidate;
const emailVerifiedAt = Number(getStorage(EMAIL_VERIFY_TS_KEY) || "0");
const skipAnonymousRecover =
!!(getStorage("emailLinked") === "1" && Date.now() - emailVerifiedAt < EMAIL_VERIFY_COOLDOWN_MS);
if (anonymousCandidate && !skipAnonymousRecover) {
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
if (recovered) {
setStorage("userId", anonymousCandidate);
setStorage("memosAccount", anonymousCandidate);
setAnonymousUserCookie(anonymousCandidate);
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
setUserName(name);
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
return;
}
}
await getOrCreateUserId();
const afterLocalType = getStorage("paidType");
const afterLocalUserName = getStorage("userName");
const afterLocalUserEmail = getStorage("userEmail");
if (afterLocalType && (afterLocalUserName || afterLocalUserEmail)) {
setPaidType(afterLocalType);
setUserName(afterLocalUserEmail || afterLocalUserName || "");
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
return;
}
// 检查本站 Cookie 登录态
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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 });
setUserEmail(meData.user.email || null);
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
if (vipData?.vip) {
// 匿名 id 不覆盖;仅当当前无匿名 id 时才写入(可能是纯邮箱登录)
const cur = getStorage("userId");
if (cur && /^user\d+$/.test(cur)) {
setStorage("memosAccount", cur);
} else if (
typeof meData.user.anonymousUserId === "string" &&
/^user\d+$/.test(meData.user.anonymousUserId)
) {
setStorage("memosAccount", meData.user.anonymousUserId);
}
setStorage("userId", meData.user.id);
setStorage("emailLinked", "1");
if (meData.user.email) {
setStorage("userEmail", meData.user.email);
}
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
return;
}
}
} catch {
/* ignore */
}
// 仅当有本地存储的 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);
}
}
}
};
init();
}, [
clearAllStorage,
getOrCreateUserId,
getStoredAnonymousUserId,
checkPaymentFromPB,
savePaidType,
saveUserName,
tryRecoverByAnonymousUserId,
]);
useEffect(() => {
if (!isH5) return;
const syncPendingOrder = () => {
const pendingOrderId = getPendingOrderId();
if (!pendingOrderId) return;
setPayPendingOrderId((current) =>
current === pendingOrderId ? current : pendingOrderId
);
};
const onVisible = () => {
if (document.visibilityState === "visible") {
syncPendingOrder();
}
};
syncPendingOrder();
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", syncPendingOrder);
window.addEventListener("focus", syncPendingOrder);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", syncPendingOrder);
window.removeEventListener("focus", syncPendingOrder);
};
}, [isH5]);
useEffect(() => {
const syncFromStorage = () => {
const localType = getStorage("paidType");
const localName = getStorage("userEmail") || getStorage("userName");
if (localType && localName && !paidType) {
setPaidType(localType);
setUserName(localName);
}
};
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();
setUserEmail(null);
clearAllStorage();
}, [clearAllStorage]);
const isLoggedIn = paidType === "vip";
return (
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
{isLoggedIn ? (
<DashboardPage userName={userName} userEmail={userEmail} />
) : (
<>
<LandingPage
onJoin={handlePay}
onVerify={checkPaymentFromPB}
onVerifyByEmail={checkVerifyByEmail}
onVerifySuccess={handleVerifySuccess}
/>
</>
)}
</VipLayout>
</>
);
export default function Page() {
return <HomeClientLoader />;
}

View File

@@ -23,7 +23,7 @@ export function TopicComingSoon({ topicId }: TopicComingSoonProps) {
rel="noopener noreferrer"
className="inline-flex 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"
>
前往数字游民指南了解更多 →
了解更多 →
</Link>
</div>
</section>

View File

@@ -38,28 +38,32 @@ export function TopicFooter({ topicId }: TopicFooterProps) {
模块列表
</Link>
{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>
<span className="hidden">
<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>
</span>
) : (
<button
type="button"
onClick={() => setModalOpen(true)}
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="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>
即时问答
</button>
<span className="hidden">
<button
type="button"
onClick={() => setModalOpen(true)}
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="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>
即时问答
</button>
</span>
)}
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">

View File

@@ -48,28 +48,32 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
首页
</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>
<span className="hidden">
<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 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: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>
</span>
) : (
<button
type="button"
onClick={() => setQaModalOpen(true)}
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="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>
即时问答
</button>
<span className="hidden">
<button
type="button"
onClick={() => setQaModalOpen(true)}
className="inline-flex 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: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="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>
即时问答
</button>
</span>
)}
{config?.externalUrl ? (
<Link

View File

@@ -1,11 +1,26 @@
"use client";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
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";
import type { TopicPrepVariant } from "./TopicPrepThreeScene";
const TopicPrepThreeScene = dynamic(
() => import("./TopicPrepThreeScene").then((m) => m.TopicPrepThreeScene),
{
ssr: false,
loading: () => (
<div
className="mx-auto mt-2 min-h-[260px] w-full max-w-2xl animate-pulse rounded-2xl border border-[var(--border)] bg-[var(--muted)]/20"
aria-hidden
/>
),
},
);
type TopicHeroProps = {
topicId: string;
@@ -36,6 +51,10 @@ export function TopicHero({ topicId }: TopicHeroProps) {
const isEnrollCta = !config.externalUrl && !last;
const showPrepThree =
(topicId === "indie" || topicId === "aiagent") && !curriculum?.hasContent;
const prepVariant: TopicPrepVariant = topicId === "indie" ? "indie" : "aiagent";
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
@@ -74,6 +93,14 @@ export function TopicHero({ topicId }: TopicHeroProps) {
</div>
))}
</div>
{showPrepThree ? (
<div
className="animate__animated animate__fadeInUp mx-auto mb-10 w-full max-w-2xl sm:mb-12"
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
>
<TopicPrepThreeScene variant={prepVariant} />
</div>
) : null}
{curriculum?.hasContent && (
<div
className="animate__animated animate__fadeInDown flex flex-wrap justify-center gap-3 sm:gap-4"

View File

@@ -0,0 +1,405 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export type TopicPrepVariant = "indie" | "aiagent";
type Props = {
variant: TopicPrepVariant;
};
function disposeObject3D(root: THREE.Object3D) {
const materials = new Set<THREE.Material>();
const geometries = new Set<THREE.BufferGeometry>();
root.traverse((object) => {
if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments) {
const g = object.geometry;
if (g && !geometries.has(g)) {
geometries.add(g);
g.dispose();
}
const mat = object.material;
if (Array.isArray(mat)) mat.forEach((m) => materials.add(m));
else if (mat) materials.add(mat);
}
});
materials.forEach((m) => m.dispose());
}
/** 独立开发:居家书桌 + 显示器 + homelab 机架 */
function buildIndieDeskScene(root: THREE.Group) {
const wood = new THREE.MeshStandardMaterial({
color: 0x6b5344,
roughness: 0.88,
metalness: 0.05,
});
const deskDark = new THREE.MeshStandardMaterial({
color: 0x18181b,
roughness: 0.55,
metalness: 0.25,
});
const screenGlow = new THREE.MeshStandardMaterial({
color: 0x052e16,
emissive: 0x4ade80,
emissiveIntensity: 0.55,
roughness: 0.35,
});
const keyCap = new THREE.MeshStandardMaterial({
color: 0x3f3f46,
roughness: 0.45,
metalness: 0.15,
});
const rackLed = new THREE.MeshStandardMaterial({
color: 0x0c0a09,
emissive: 0xf59e0b,
emissiveIntensity: 0.5,
roughness: 0.4,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(7, 5.5), wood);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.15;
root.add(floor);
const rug = new THREE.Mesh(new THREE.PlaneGeometry(2.8, 2), new THREE.MeshStandardMaterial({
color: 0x3d3832,
roughness: 0.95,
}));
rug.rotation.x = -Math.PI / 2;
rug.position.set(0, -1.14, 0.1);
root.add(rug);
const deskTop = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.07, 1), deskDark);
deskTop.position.set(0, -0.38, 0.15);
root.add(deskTop);
const legGeo = new THREE.BoxGeometry(0.08, 0.45, 0.08);
const legPositions: [number, number, number][] = [
[-1, -0.65, -0.25],
[1, -0.65, -0.25],
[-1, -0.65, 0.55],
[1, -0.65, 0.55],
];
for (const [x, y, z] of legPositions) {
const leg = new THREE.Mesh(legGeo, deskDark);
leg.position.set(x, y, z);
root.add(leg);
}
const monitorBezel = new THREE.Mesh(new THREE.BoxGeometry(1, 0.62, 0.045), deskDark);
monitorBezel.position.set(0, 0.12, -0.28);
root.add(monitorBezel);
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.88, 0.52), screenGlow);
screen.position.set(0, 0.12, -0.28 + 0.023);
root.add(screen);
const stand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.12, 0.15), deskDark);
stand.position.set(0, -0.28, -0.22);
root.add(stand);
const keyboard = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.035, 0.3), keyCap);
keyboard.position.set(0, -0.32, 0.38);
keyboard.rotation.x = -0.06;
root.add(keyboard);
const mug = new THREE.Mesh(
new THREE.CylinderGeometry(0.09, 0.08, 0.12, 16),
new THREE.MeshStandardMaterial({ color: 0x3f3f46, roughness: 0.4 }),
);
mug.position.set(-0.85, -0.31, 0.42);
root.add(mug);
const rack = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.95, 0.42), deskDark);
rack.position.set(1.05, 0.1, -0.05);
root.add(rack);
const rackFace = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 0.82), rackLed);
rackFace.position.set(1.05 + 0.191, 0.1, -0.05);
root.add(rackFace);
for (let row = 0; row < 4; row++) {
const led = new THREE.Mesh(
new THREE.PlaneGeometry(0.22, 0.04),
new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: row % 2 === 0 ? 0x22c55e : 0xf59e0b,
emissiveIntensity: 0.65,
}),
);
led.position.set(1.05 + 0.192, 0.35 - row * 0.18, -0.05);
root.add(led);
}
const miniServer = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.15, 0.18), deskDark);
miniServer.position.set(1.05, -0.28, 0.45);
root.add(miniServer);
}
/** AI Agent:赛博空间网格 + 机器人 + 霓虹光环 */
function buildAiCyberScene(root: THREE.Group) {
const grid = new THREE.GridHelper(9, 28, 0x6366f1, 0x312e81);
grid.position.y = -1.05;
const gMat = grid.material;
if (Array.isArray(gMat)) {
gMat.forEach((m) => {
if ("opacity" in m) {
(m as THREE.Material & { opacity: number }).opacity = 0.45;
m.transparent = true;
}
});
} else if (gMat && "opacity" in gMat) {
(gMat as THREE.Material & { opacity: number }).opacity = 0.45;
gMat.transparent = true;
}
root.add(grid);
const bodyMat = new THREE.MeshStandardMaterial({
color: 0x52525b,
metalness: 0.55,
roughness: 0.28,
flatShading: true,
});
const glowCyan = new THREE.MeshStandardMaterial({
color: 0x06b6d4,
emissive: 0x22d3ee,
emissiveIntensity: 0.85,
});
const glowMagenta = new THREE.MeshStandardMaterial({
color: 0xd946ef,
emissive: 0xc026d3,
emissiveIntensity: 0.55,
});
const robot = new THREE.Group();
robot.position.y = -0.15;
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.58, 0.32), bodyMat);
torso.position.y = 0.1;
robot.add(torso);
const chestPanel = new THREE.Mesh(new THREE.PlaneGeometry(0.35, 0.25), glowCyan);
chestPanel.position.set(0, 0.12, 0.161);
robot.add(chestPanel);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.36, 0.38), bodyMat);
head.position.y = 0.55;
robot.add(head);
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.12, 0.02), glowCyan);
visor.position.set(0, 0.52, 0.19);
robot.add(visor);
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan);
eyeL.position.set(-0.1, 0.56, 0.18);
const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan);
eyeR.position.set(0.1, 0.56, 0.18);
robot.add(eyeL, eyeR);
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 0.28, 8), glowMagenta);
antenna.position.set(0, 0.82, 0);
robot.add(antenna);
const armL = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.45, 0.12), bodyMat);
armL.position.set(-0.38, 0.05, 0);
const armR = armL.clone();
armR.position.x = 0.38;
robot.add(armL, armR);
root.add(robot);
for (let i = 0; i < 3; i++) {
const ringMat = new THREE.MeshBasicMaterial({
color: i === 1 ? 0xa855f7 : 0x22d3ee,
transparent: true,
opacity: 0.35 - i * 0.08,
});
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.95 + i * 0.22, 0.012, 8, 64), ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.y = -0.85 + i * 0.15;
root.add(ring);
}
const holo = new THREE.Mesh(
new THREE.OctahedronGeometry(0.18, 0),
new THREE.MeshBasicMaterial({ color: 0x67e8f9, wireframe: true, transparent: true, opacity: 0.5 }),
);
holo.position.set(-0.55, 0.35, 0.35);
root.add(holo);
return { robot, holo };
}
export function TopicPrepThreeScene({ variant }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.35, 5.2);
const group = new THREE.Group();
scene.add(group);
let bobRefs: { robot?: THREE.Group; holo?: THREE.Mesh } | null = null;
if (variant === "indie") {
scene.background = null;
const ambient = new THREE.AmbientLight(0xfff5e6, 0.45);
scene.add(ambient);
const warm = new THREE.PointLight(0xffb86b, 1.1, 12);
warm.position.set(0.6, 1.2, 1.2);
scene.add(warm);
const cool = new THREE.PointLight(0x4ade80, 0.35, 8);
cool.position.set(-0.3, 0.5, 0.5);
scene.add(cool);
const dir = new THREE.DirectionalLight(0xffffff, 0.35);
dir.position.set(-2, 4, 3);
scene.add(dir);
buildIndieDeskScene(group);
group.rotation.y = 0.25;
} else {
scene.fog = new THREE.FogExp2(0x0a0a14, 0.055);
const ambient = new THREE.AmbientLight(0x4c1d95, 0.35);
scene.add(ambient);
const key = new THREE.PointLight(0x22d3ee, 1.2, 16);
key.position.set(1.5, 2, 2);
scene.add(key);
const rim = new THREE.PointLight(0xd946ef, 0.85, 14);
rim.position.set(-2, 0.5, -1);
scene.add(rim);
const dir = new THREE.DirectionalLight(0xa78bfa, 0.4);
dir.position.set(0, 3, 4);
scene.add(dir);
bobRefs = buildAiCyberScene(group);
}
let rx = 0.1;
let ry = variant === "indie" ? 0.2 : 0;
let vx = 0;
let vy = 0;
const sens = 0.0052;
const damping = 0.93;
const autoRy = variant === "indie" ? 0.0018 : 0.0024;
let dragging = false;
let lastX = 0;
let lastY = 0;
const onPointerDown = (e: PointerEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
vy += dx * sens;
vx += dy * sens;
};
const onPointerEnd = (e: PointerEvent) => {
dragging = false;
try {
canvas.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerEnd);
canvas.addEventListener("pointercancel", onPointerEnd);
canvas.style.touchAction = "none";
const resize = () => {
const w = Math.max(280, container.clientWidth);
const h = Math.min(440, Math.max(236, Math.round(w * 0.5)));
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h, false);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
let raf = 0;
const t0 = performance.now();
const tick = () => {
raf = requestAnimationFrame(tick);
const t = (performance.now() - t0) * 0.001;
if (!dragging) vy += autoRy;
rx += vx;
ry += vy;
vx *= damping;
vy *= damping;
group.rotation.x = rx;
group.rotation.y = ry;
if (bobRefs?.robot) {
bobRefs.robot.position.y = -0.15 + Math.sin(t * 1.8) * 0.035;
}
if (bobRefs?.holo) {
bobRefs.holo.rotation.y = t * 0.9;
bobRefs.holo.rotation.x = t * 0.45;
}
renderer.render(scene, camera);
};
tick();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerEnd);
canvas.removeEventListener("pointercancel", onPointerEnd);
scene.fog = null;
disposeObject3D(group);
scene.remove(group);
renderer.dispose();
};
}, [variant]);
const label =
variant === "indie" ? "独立开发:居家工作台与 homelab 预览" : "AI Agent:赛博空间与机器人预览";
const hint =
variant === "indie"
? "拖动旋转 · 居家书桌、显示器与 homelab 机架(课程筹备视觉预告)"
: "拖动旋转 · 赛博网格空间与机器人(课程筹备视觉预告)";
return (
<div ref={containerRef} className="mx-auto mt-2 w-full max-w-2xl">
<p className="mb-2 text-center text-xs text-[var(--muted-foreground)]">{hint}</p>
<canvas
ref={canvasRef}
className="mx-auto block w-full cursor-grab touch-none rounded-2xl border border-[var(--border)] bg-[var(--muted)]/25 active:cursor-grabbing dark:bg-[var(--muted)]/15"
aria-label={label}
role="img"
/>
</div>
);
}

View File

@@ -12,7 +12,7 @@ export type TopicConfig = {
subtitle: string;
desc: string;
stats: { value: string; label: string }[];
/** 使用的 curriculum 模块索引,空则用全部 */
/** 云手机课纲的模块索引;不传则用全部;传 [] 表示本专题暂无课程(勿套用云手机章节) */
moduleIndices?: number[];
/** 外部链接,有则显示「前往了解」而非课程列表 */
externalUrl?: string;
@@ -51,12 +51,12 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
icon: "🛠️",
title: "独立开发",
subtitle: "从想法到产品,独立开发者全流程指南",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
desc: "即将上线,敬请期待。",
stats: [
{ value: "—", label: "课程筹备中" },
{ value: "—", label: "敬请期待" },
],
externalUrl: "https://digital.hackrobot.cn/zh",
moduleIndices: [],
payUrl: "/",
},
aiagent: {
@@ -64,12 +64,12 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
icon: "🤖",
title: "AI Agent",
subtitle: "AIGC 与智能体,Vibe Coding 跨界创造",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
desc: "即将上线,敬请期待。",
stats: [
{ value: "—", label: "课程筹备中" },
{ value: "—", label: "敬请期待" },
],
externalUrl: "https://digital.hackrobot.cn/zh",
moduleIndices: [],
payUrl: "/",
},
};
@@ -115,7 +115,10 @@ export function getTopicCurriculum(topicId: string) {
if (topicId === "livestream") {
return { modules: LIVESTREAM_CURRICULUM, hasContent: LIVESTREAM_CURRICULUM.length > 0 };
}
const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i);
const indices =
config.moduleIndices !== undefined
? config.moduleIndices
: CURRICULUM_CONFIG.modules.map((_, i) => i);
const modules = indices
.map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i }))
.filter((x) => x.module)

View File

@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { WelcomeBlock } from "./dashboard/WelcomeBlock";
import { CommunityAccessModal } from "./dashboard/CommunityAccessModal";
import { UnlockedStats } from "./dashboard/UnlockedStats";
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
@@ -31,6 +33,7 @@ function requiresEmailBinding(): boolean {
export function DashboardPage({ userName, userEmail }: DashboardPageProps) {
const bindingRequired = requiresEmailBinding();
const [communityModalOpen, setCommunityModalOpen] = useState(false);
return (
<>
@@ -40,7 +43,11 @@ export function DashboardPage({ userName, userEmail }: DashboardPageProps) {
bindingRequired={bindingRequired}
/>
<UnlockedStats />
<TopicEbookShowcase />
<TopicEbookShowcase onCommunityCardClick={() => setCommunityModalOpen(true)} />
<CommunityAccessModal
open={communityModalOpen}
onClose={() => setCommunityModalOpen(false)}
/>
</>
);
}

View File

@@ -9,6 +9,7 @@ import { CommunityPreview } from "./landing/CommunityPreview";
import { FAQSection } from "./landing/FAQSection";
import { LandingCTA } from "./landing/LandingCTA";
import { LoginModal } from "./landing/LoginModal";
import styles from "./vip.module.css";
type LandingPageProps = {
onJoin: () => void;
@@ -31,13 +32,26 @@ export function LandingPage({
return (
<>
<LandingHero />
<AssetStats />
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
<CompareSection />
<CommunityPreview />
<FAQSection />
<LandingCTA onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} />
<div className={styles.landingScene}>
<div className={styles.landingSceneBg} aria-hidden>
<div className={styles.landingOrb} />
<div className={styles.landingOrb} />
<div className={styles.landingOrb} />
<div className={styles.landingMesh} />
<div className={styles.landingNoise} />
</div>
<div className={styles.landingSceneInner}>
<LandingHero />
<AssetStats />
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
<CompareSection />
<div className="hidden" aria-hidden>
<CommunityPreview />
</div>
<FAQSection />
<LandingCTA onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} />
</div>
</div>
<LoginModal
open={loginModalOpen}
onClose={() => setLoginModalOpen(false)}

View File

@@ -43,12 +43,7 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead
return (
<header className={styles.header}>
<Link
href="https://digital.hackrobot.cn/zh"
className={styles.logo}
target="_blank"
rel="noopener"
>
<Link href="/" className={styles.logo}>
🌍 异度星球
</Link>
<nav className={styles.nav}>

View File

@@ -0,0 +1,133 @@
"use client";
import { useEffect, useState } from "react";
import { Copyable } from "../Copyable";
import styles from "../vip.module.css";
const COMMUNITY_URL = "https://qun.hackrobot.cn";
const DEFAULT_PASSWORD = "12345678";
function getStorage(key: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
}
function isAnonymousUserId(value: string | null): value is string {
return !!value && /^user\d+$/.test(value);
}
type CommunityAccessModalProps = {
open: boolean;
onClose: () => void;
};
/** 与 WelcomeBlock 同步的社群登录账号解析,用于弹窗展示 */
export function CommunityAccessModal({ open, onClose }: CommunityAccessModalProps) {
const userId = typeof window !== "undefined" ? getStorage("userId") : null;
const [backendMemosAccount, setBackendMemosAccount] = useState<string | null>(null);
const memosAccount =
getStorage("memosAccount") ||
backendMemosAccount ||
(isAnonymousUserId(userId) ? userId : null);
useEffect(() => {
if (!open || !userId) return;
fetch(`/api/vip/linked-email?user_id=${encodeURIComponent(userId)}`, {
cache: "no-store",
})
.then((res) => res.json())
.then((data) => {
const linkedAnonymousUserId =
typeof data?.anonymousUserId === "string" && /^user\d+$/.test(data.anonymousUserId)
? data.anonymousUserId
: null;
if (linkedAnonymousUserId && typeof window !== "undefined") {
setBackendMemosAccount(linkedAnonymousUserId);
localStorage.setItem("memosAccount", linkedAnonymousUserId);
}
})
.catch(() => {});
}, [open, userId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className={styles.communityModalBackdrop}
role="presentation"
onClick={onClose}
>
<div
className={styles.communityModalPanel}
role="dialog"
aria-modal="true"
aria-labelledby="community-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className={styles.communityModalGlow} aria-hidden />
<button
type="button"
className={styles.communityModalClose}
onClick={onClose}
aria-label="关闭"
>
×
</button>
<div className={styles.communityModalIconWrap}>
<span className={styles.communityModalIcon}>👥</span>
</div>
<h2 id="community-modal-title" className={styles.communityModalTitle}>
私密社群 · 登录信息
</h2>
<p className={styles.communityModalDesc}>
使用以下信息访问社群站点,可随时点击复制
</p>
<div className={styles.communityModalRows}>
<div className={styles.communityModalRow}>
<span className={styles.communityModalLabel}>网址</span>
<div className={styles.communityModalValueWrap}>
<Copyable text={COMMUNITY_URL} className={styles.communityModalCopyable}>
<span className={styles.communityModalMono}>qun.hackrobot.cn</span>
</Copyable>
<a
href={COMMUNITY_URL}
target="_blank"
rel="noopener noreferrer"
className={styles.communityModalOpenLink}
>
打开
</a>
</div>
</div>
<div className={styles.communityModalRow}>
<span className={styles.communityModalLabel}>账号</span>
<Copyable text={memosAccount || ""} className={styles.communityModalCopyable}>
<span className={styles.communityModalMono}>{memosAccount || "—"}</span>
</Copyable>
</div>
<div className={styles.communityModalRow}>
<span className={styles.communityModalLabel}>密码</span>
<Copyable text={DEFAULT_PASSWORD} className={styles.communityModalCopyable}>
<span className={styles.communityModalMono}>{DEFAULT_PASSWORD}</span>
</Copyable>
</div>
</div>
<button type="button" className={styles.communityModalDone} onClick={onClose}>
知道了
</button>
</div>
</div>
);
}

View File

@@ -287,38 +287,12 @@ export function WelcomeBlock({
>
欢迎回来,<Copyable text={displayName}>{displayName}</Copyable>
</h1>
<p
className="animate__animated animate__fadeInDown"
style={{
fontSize: "0.95rem",
color: "var(--muted-foreground)",
margin: "0.25rem 0 0",
animationDelay: "0.15s",
animationFillMode: "both",
}}
>
以下是当前会员账号和登录信息
</p>
<div className={styles.loginCard}>
<div className={styles.loginCardTitle}>会员登录信息</div>
<div className={styles.loginCardRow}>
<span className={styles.loginCardLabel}>网址</span>
<Copyable text="https://qun.hackrobot.cn">
qun.hackrobot.cn
</Copyable>
<a
href="https://qun.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
className={styles.loginCardLink}
>
打开
</a>
</div>
<div className={styles.loginCardRow}>
<span className={styles.loginCardLabel}>账号</span>
<Copyable text={memosAccount || ""}>
<span className={styles.loginCardValue}>{memosAccount || "-"}</span>
<span className={styles.loginCardLabel}>邮箱</span>
<Copyable text={displayEmail || ""}>
<span className={styles.loginCardValue}>{displayEmail || "-"}</span>
</Copyable>
</div>
<div className={styles.loginCardRow}>
@@ -327,12 +301,6 @@ export function WelcomeBlock({
<span className={styles.loginCardValue}>{DEFAULT_PASSWORD}</span>
</Copyable>
</div>
<div className={styles.loginCardRow}>
<span className={styles.loginCardLabel}>邮箱</span>
<Copyable text={displayEmail || ""}>
<span className={styles.loginCardValue}>{displayEmail || "-"}</span>
</Copyable>
</div>
</div>
</section>
</>

View File

@@ -25,7 +25,7 @@ export function AssetStats() {
return (
<div
key={key}
className={styles.card}
className={`${styles.card} ${styles.landingStatCard}`}
style={{
textAlign: "center",
animationDelay: `${0.25 + i * 0.05}s`,

View File

@@ -4,54 +4,75 @@ import { useState } from "react";
import { FAQ_ITEMS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
function ChevronDown() {
return (
<svg className={styles.faqChevron} width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>常见问题</h2>
<div
className="animate__animated animate__fadeInUp"
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{FAQ_ITEMS.map((item, i) => (
<div
key={i}
className={styles.card}
style={{
marginBottom: "0.75rem",
cursor: "pointer",
}}
onClick={() => setOpenIndex(openIndex === i ? null : i)}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontWeight: 500,
color: "var(--foreground)",
}}
>
{item.q}
<span style={{ fontSize: "1.2rem", transition: "transform 0.2s", transform: openIndex === i ? "rotate(180deg)" : "none" }}>
▼
</span>
</div>
{openIndex === i && (
<p
style={{
marginTop: "0.75rem",
fontSize: "0.9rem",
color: "var(--muted-foreground)",
lineHeight: 1.5,
}}
<section className={`${styles.section} ${styles.faqSection}`} aria-labelledby="faq-heading">
<div className={`${styles.faqInner} animate__animated animate__fadeInUp`} style={{ animationDelay: "0.15s", animationFillMode: "both" }}>
<p className={styles.faqEyebrow}>
<span className={styles.faqEyebrowDot} aria-hidden />
FAQ
</p>
<h2 id="faq-heading" className={styles.faqHeadline}>
常见问题
</h2>
<p className={styles.faqSub}>
购买前可快速了解会员权益与流程;点击问题展开详情。
</p>
<div className={styles.faqList} role="list">
{FAQ_ITEMS.map((item, i) => {
const open = openIndex === i;
const panelId = `faq-panel-${i}`;
const triggerId = `faq-trigger-${i}`;
return (
<div
key={i}
className={`${styles.faqItem} ${open ? styles.faqItemOpen : ""}`}
role="listitem"
>
{item.a}
</p>
)}
</div>
))}
<button
type="button"
id={triggerId}
className={styles.faqTrigger}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpenIndex(open ? null : i)}
>
<span className={styles.faqIndex} aria-hidden>
{i + 1}
</span>
<span className={styles.faqQuestionText}>{item.q}</span>
<ChevronDown />
</button>
<div
id={panelId}
role="region"
aria-labelledby={triggerId}
className={`${styles.faqPanel} ${open ? styles.faqPanelOpen : ""}`}
>
<div className={styles.faqPanelInner}>
<p className={styles.faqAnswer}>{item.a}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);

View File

@@ -9,6 +9,9 @@ type LandingCTAProps = {
onLogin: () => void;
};
/** 设为 true 可重新展示「适合你 / 请止步」区块(下方 JSX 与 vip.module.css 内样式均保留) */
const SHOW_CTA_AUDIENCE_BOUNDARY = false;
export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
const [restoreDate, setRestoreDate] = useState<string>("");
useEffect(() => {
@@ -16,50 +19,68 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
}, []);
return (
<section className={styles.section} style={{ paddingBottom: "2rem" }}>
<div
className={`${styles.card} animate__animated animate__zoomIn`}
style={{
maxWidth: "560px",
margin: "0 auto",
padding: "1.5rem",
textAlign: "center",
animationDelay: "0.2s",
animationFillMode: "both",
}}
>
<h2 style={{ fontSize: "1.35rem", margin: "0 0 0.5rem", color: "var(--foreground)" }}>
限时特惠 · {restoreDate || "—"}恢复原价
</h2>
<p style={{ fontSize: "0.9rem", color: "var(--muted-foreground)", margin: "0 0 1rem" }}>
阶梯定价 · 数字交付 · 不支持退款
</p>
<p style={{ fontSize: "0.8rem", color: "var(--muted-foreground)", marginBottom: "1.5rem" }}>
🚫 不适合躺赚、现金流紧张
</p>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "1rem",
justifyContent: "center",
alignItems: "center",
}}
>
<button
type="button"
onClick={onJoin}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
<section className={`${styles.section} ${styles.ctaSection}`} aria-labelledby="cta-heading">
<div className={`${styles.ctaInner} animate__animated animate__zoomIn`} style={{ animationDelay: "0.15s", animationFillMode: "both" }}>
<div className={styles.ctaGlow} aria-hidden />
<div className={styles.ctaCard}>
<h2 id="cta-heading" className={styles.ctaPromoLine}>
<span className={styles.ctaBadgeInline}>
<span className={styles.ctaBadgePulse} aria-hidden />
限时特惠
</span>
<span className={styles.ctaPromoDivider} aria-hidden>
·
</span>
<span className={styles.ctaPromoMain}>
<span className={styles.ctaHighlight}>{restoreDate || "—"}</span>
<span className={styles.ctaPromoSuffix}>恢复原价</span>
</span>
</h2>
<div className={styles.ctaChips}>
<span className={styles.ctaChip}>阶梯定价</span>
<span className={styles.ctaChip}>数字交付</span>
<span className={styles.ctaChip}>持续更新</span>
<span className={styles.ctaChip}>不支持退款</span>
</div>
<div
className={`${styles.ctaBoundary} ${SHOW_CTA_AUDIENCE_BOUNDARY ? "" : styles.ctaBoundaryHidden}`}
role="note"
aria-hidden={!SHOW_CTA_AUDIENCE_BOUNDARY}
>
🔒 立即加入
</button>
<button
type="button"
onClick={onLogin}
className={styles.btnSecondary}
>
已有账号登录
</button>
<div className={styles.ctaBoundarySheen} aria-hidden />
<div className={styles.ctaBoundaryInner}>
<p className={styles.ctaBoundaryPos}>
<span className={styles.ctaBoundaryMark} aria-hidden>
✦
</span>
<span>
<strong className={styles.ctaBoundaryStrong}>适合你:</strong>
愿意投入行动、认真做增长与出海的同行者
</span>
</p>
<p className={styles.ctaBoundaryNeg}>
<span className={styles.ctaBoundaryMarkNeg} aria-hidden>
·
</span>
<span>
<strong className={styles.ctaBoundaryStrongNeg}>请止步:</strong>
指望躺赚,或当下现金流极度紧张的朋友
</span>
</p>
</div>
</div>
<div className={styles.ctaActions}>
<button
type="button"
onClick={onJoin}
className={`${styles.btnPrimary} ${styles.btnLarge} ${styles.ctaBtnPrimary}`}
>
🔒 立即加入
</button>
<button type="button" onClick={onLogin} className={styles.ctaBtnSecondary}>
已有账号登录
</button>
</div>
</div>
</div>
</section>

View File

@@ -4,50 +4,23 @@ import styles from "../vip.module.css";
export function LandingHero() {
return (
<section className={`${styles.section} ${styles.heroSection}`} style={{ textAlign: "center" }}>
<section className={`${styles.section} ${styles.heroSection} ${styles.heroSection3d}`} style={{ textAlign: "center" }}>
<p
className="animate__animated animate__fadeInDown"
style={{
display: "inline-block",
fontSize: "0.875rem",
color: "var(--muted-foreground)",
background: "var(--card)",
padding: "0.35rem 0.85rem",
borderRadius: "9999px",
border: "1px solid var(--border)",
marginBottom: "1rem",
animationDelay: "0.1s",
animationFillMode: "both",
}}
className={`${styles.heroEyebrow} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
>
异度星球会员中心
</p>
<h1
className="animate__animated animate__fadeInDown"
style={{
fontSize: "clamp(2rem, 6vw, 3rem)",
fontWeight: 700,
margin: "0 0 0.5rem",
letterSpacing: "-0.02em",
lineHeight: 1.2,
animationDelay: "0.2s",
animationFillMode: "both",
}}
className={`${styles.heroTitle} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
全模块解锁 · 持续更新 · 抱团出海
<span className={styles.heroTitleAccent}>全模块解锁</span>
<span className={styles.heroTitleRest}> · 持续更新 · 抱团出海</span>
</h1>
<p
className="animate__animated animate__fadeInDown"
style={{
fontSize: "1.125rem",
color: "var(--muted-foreground)",
maxWidth: "540px",
margin: "0 auto",
lineHeight: 1.6,
textAlign: "center",
animationDelay: "0.3s",
animationFillMode: "both",
}}
className={`${styles.heroLead} animate__animated animate__fadeInDown`}
style={{ animationDelay: "0.3s", animationFillMode: "both" }}
>
一次付费,解锁电子书、视频教程、私密社群、即时问答,每周更新
</p>

View File

@@ -12,6 +12,8 @@ type LoginFormProps = {
) => Promise<boolean | { ok: boolean; error?: string }>;
onSuccess: () => void;
autoFocus?: boolean;
/** 验证请求进行中(用于全屏 loading 等) */
onBusyChange?: (busy: boolean) => void;
};
export function LoginForm({
@@ -19,6 +21,7 @@ export function LoginForm({
onVerifyByEmail,
onSuccess,
autoFocus,
onBusyChange,
}: LoginFormProps) {
const [mode, setMode] = useState<"user_id" | "email">("user_id");
const [userId, setUserId] = useState("");
@@ -41,6 +44,10 @@ export function LoginForm({
if (autoFocus) inputRef.current?.focus();
}, [autoFocus, mode]);
useEffect(() => {
onBusyChange?.(loading);
}, [loading, onBusyChange]);
const handleUserIdSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmed = userId.trim();
@@ -51,14 +58,19 @@ export function LoginForm({
setError(null);
setLoading(true);
let succeeded = false;
try {
const ok = await onVerify(trimmed);
if (ok) onSuccess();
else setError("未找到该账号的会员记录,请确认账号正确");
if (ok) {
succeeded = true;
onSuccess();
} else {
setError("未找到该账号的会员记录,请确认账号正确");
}
} catch {
setError("验证失败,请稍后重试");
} finally {
setLoading(false);
if (!succeeded) setLoading(false);
}
};
@@ -79,6 +91,7 @@ export function LoginForm({
setError(null);
setLoading(true);
let succeeded = false;
try {
const candidateUserId = getCandidateUserId();
const result = await onVerifyByEmail(
@@ -88,12 +101,16 @@ export function LoginForm({
);
const ok = typeof result === "object" ? result.ok : result;
const errMsg = typeof result === "object" ? result.error : undefined;
if (ok) onSuccess();
else setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录");
if (ok) {
succeeded = true;
onSuccess();
} else {
setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录");
}
} catch {
setError("验证失败,请稍后重试");
} finally {
setLoading(false);
if (!succeeded) setLoading(false);
}
};

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { HomeBootOverlay } from "@/app/components/HomeBootOverlay";
import styles from "../vip.module.css";
import { LoginForm } from "./LoginForm";
@@ -17,11 +18,14 @@ type LoginModalProps = {
};
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
const [verifyOverlay, setVerifyOverlay] = useState(false);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
setVerifyOverlay(false);
}
return () => {
document.body.style.overflow = "";
@@ -31,56 +35,63 @@ export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Escape" && !verifyOverlay) onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
}, [open, onClose, verifyOverlay]);
const handleSuccess = () => {
onClose();
// 勿先 onClose:父级会把 open 设为 false,弹窗卸载会清掉全屏 loading,应在 reload 前保持验证中态
onSuccess();
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
>
<>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
onClick={(e) => e.stopPropagation()}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
>
<div className="mb-4 flex items-center justify-between">
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
登录
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<LoginForm
onVerify={onVerify}
onVerifyByEmail={onVerifyByEmail}
onSuccess={handleSuccess}
autoFocus
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={verifyOverlay ? undefined : onClose}
/>
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
登录
</h2>
<button
type="button"
onClick={onClose}
disabled={verifyOverlay}
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:pointer-events-none disabled:opacity-40"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<LoginForm
onVerify={onVerify}
onVerifyByEmail={onVerifyByEmail}
onSuccess={handleSuccess}
onBusyChange={setVerifyOverlay}
autoFocus
/>
</div>
</div>
</div>
{verifyOverlay ? (
<HomeBootOverlay visible subtitle="正在验证并解锁会员…" />
) : null}
</>
);
}

View File

@@ -7,9 +7,14 @@ import { NetdiskDownloadsSection } from "@/app/components/NetdiskDownloadsSectio
type TopicEbookShowcaseProps = {
onLockedClick?: () => void;
/** VIP 首页:点击「私密社群」打开社群登录信息弹窗 */
onCommunityCardClick?: () => void;
};
export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
export function TopicEbookShowcase({
onLockedClick,
onCommunityCardClick,
}: TopicEbookShowcaseProps) {
return (
<section id="topics" className={`${styles.section} ${styles.topicsSection}`}>
{/* 模块展示 */}
@@ -21,6 +26,7 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
{TOPICS_DISPLAY.map((t) => {
const { id, href } = t;
const isLocked = "locked" in t && t.locked && onLockedClick;
const isCommunityModal = id === "community" && onCommunityCardClick;
const content = (
<span className={styles.topicCardInner}>
{isLocked && (
@@ -44,6 +50,19 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
</button>
);
}
if (isCommunityModal) {
return (
<button
key={id}
type="button"
className={styles.topicCard}
onClick={onCommunityCardClick}
style={{ cursor: "pointer", border: "none", font: "inherit" }}
>
{content}
</button>
);
}
if (href) {
return (
<Link

View File

@@ -118,6 +118,233 @@
}
}
/* ========== 首页 · 沉浸式背景(透视网格 / 光晕 · 轻 3D 感,无 WebGL 依赖) ========== */
.landingScene {
position: relative;
isolation: isolate;
margin-left: -1.5rem;
margin-right: -1.5rem;
width: calc(100% + 3rem);
padding-left: 1.5rem;
padding-right: 1.5rem;
box-sizing: border-box;
}
@media (max-width: 640px) {
.landingScene {
margin-left: -1rem;
margin-right: -1rem;
width: calc(100% + 2rem);
padding-left: 1rem;
padding-right: 1rem;
}
}
.landingSceneBg {
position: absolute;
z-index: 0;
left: 0;
right: 0;
top: 0;
height: min(118vh, 1400px);
overflow: hidden;
pointer-events: none;
}
.landingSceneInner {
position: relative;
z-index: 1;
}
.landingOrb {
position: absolute;
border-radius: 50%;
filter: blur(52px);
pointer-events: none;
}
.landingOrb:nth-child(1) {
width: min(42vw, 380px);
height: min(42vw, 380px);
top: -4%;
left: -10%;
background: radial-gradient(circle, rgba(245, 158, 11, 0.38) 0%, transparent 68%);
animation: landingOrbA 22s ease-in-out infinite;
}
.landingOrb:nth-child(2) {
width: min(48vw, 420px);
height: min(48vw, 420px);
top: 10%;
right: -16%;
background: radial-gradient(circle, rgba(245, 158, 11, 0.14) 0%, transparent 72%);
animation: landingOrbB 26s ease-in-out infinite;
}
.landingOrb:nth-child(3) {
width: min(34vw, 300px);
height: min(34vw, 300px);
top: 36%;
left: 14%;
background: radial-gradient(circle, rgba(251, 191, 36, 0.22) 0%, transparent 72%);
animation: landingOrbC 19s ease-in-out infinite alternate;
}
.dark .landingOrb:nth-child(2) {
background: radial-gradient(circle, rgba(129, 140, 248, 0.22) 0%, transparent 72%);
}
@keyframes landingOrbA {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(5%, 5%) scale(1.06); }
}
@keyframes landingOrbB {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-4%, 6%) scale(1.04); }
}
@keyframes landingOrbC {
0% { transform: translate(0, 0); }
100% { transform: translate(4%, -3%); }
}
.landingMesh {
position: absolute;
left: -20%;
width: 140%;
top: 0;
height: min(64vh, 580px);
pointer-events: none;
perspective: 520px;
opacity: 0.88;
}
.landingMesh::before {
content: "";
display: block;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(245, 158, 11, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(245, 158, 11, 0.1) 1px, transparent 1px);
background-size: 52px 52px;
transform-origin: 50% 0%;
transform: rotateX(74deg) translateY(-5%) scale(1.06);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.62) 0%, transparent 92%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.62) 0%, transparent 92%);
animation: landingMeshScroll 28s linear infinite;
}
.dark .landingMesh::before {
background-image:
linear-gradient(rgba(245, 158, 11, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(245, 158, 11, 0.15) 1px, transparent 1px);
}
@keyframes landingMeshScroll {
from {
background-position: 0 0, 0 0;
}
to {
background-position: 0 52px, 52px 0;
}
}
.landingNoise {
position: absolute;
inset: 0;
opacity: 0.04;
pointer-events: none;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.dark .landingNoise {
opacity: 0.08;
mix-blend-mode: soft-light;
}
.heroSection3d {
position: relative;
}
.heroEyebrow {
display: inline-block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--muted-foreground);
padding: 0.4rem 0.95rem;
border-radius: 9999px;
border: 1px solid rgba(245, 158, 11, 0.22);
margin-bottom: 1rem;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px) saturate(1.2);
box-shadow:
0 10px 36px rgba(0, 0, 0, 0.07),
0 0 0 1px rgba(255, 255, 255, 0.55) inset;
}
.dark .heroEyebrow {
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
.heroTitle {
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 700;
margin: 0 0 0.5rem;
letter-spacing: -0.03em;
line-height: 1.18;
}
.heroTitleAccent {
background: linear-gradient(120deg, var(--foreground) 0%, var(--accent) 42%, #fbbf24 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.heroTitleRest {
color: var(--foreground);
font-weight: 700;
}
.heroLead {
font-size: clamp(1rem, 2.8vw, 1.125rem);
color: var(--muted-foreground);
max-width: 540px;
margin: 0 auto;
line-height: 1.65;
text-align: center;
}
.landingStatCard {
position: relative;
backdrop-filter: blur(12px) saturate(1.15);
background: rgba(255, 255, 255, 0.52);
border-color: rgba(245, 158, 11, 0.2);
transition: transform 0.35s ease, box-shadow 0.35s ease, border-color 0.2s;
}
.dark .landingStatCard {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(245, 158, 11, 0.24);
}
.landingStatCard:hover {
transform: translateY(-3px) perspective(880px) rotateX(5deg);
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(245, 158, 11, 0.14);
}
.dark .landingStatCard:hover {
box-shadow:
0 22px 52px rgba(0, 0, 0, 0.48),
0 0 0 1px rgba(245, 158, 11, 0.22);
}
.statsSection {
padding-top: 2rem;
padding-bottom: 2rem;
@@ -426,3 +653,731 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 私密社群 · 登录信息弹窗 */
.communityModalBackdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(10px);
}
.communityModalPanel {
position: relative;
width: 100%;
max-width: 420px;
padding: 1.75rem 1.5rem 1.5rem;
border-radius: 16px;
border: 1px solid rgba(245, 158, 11, 0.25);
background: var(--card);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.04) inset;
}
.communityModalGlow {
pointer-events: none;
position: absolute;
inset: -1px;
border-radius: 16px;
background: radial-gradient(
ellipse 80% 60% at 50% -20%,
rgba(245, 158, 11, 0.18),
transparent 55%
);
opacity: 0.9;
}
.communityModalClose {
position: absolute;
top: 0.65rem;
right: 0.65rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
background: transparent;
color: var(--muted-foreground);
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.communityModalClose:hover {
color: var(--foreground);
background: var(--muted);
}
.communityModalIconWrap {
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
}
.communityModalIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
border-radius: 14px;
font-size: 1.75rem;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(245, 158, 11, 0.06));
border: 1px solid rgba(245, 158, 11, 0.25);
}
.communityModalTitle {
margin: 0 0 0.35rem;
font-size: 1.2rem;
font-weight: 700;
text-align: center;
color: var(--foreground);
position: relative;
}
.communityModalDesc {
margin: 0 0 1.25rem;
font-size: 0.85rem;
line-height: 1.5;
text-align: center;
color: var(--muted-foreground);
position: relative;
}
.communityModalRows {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1.25rem;
position: relative;
}
.communityModalRow {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
border-radius: 10px;
background: var(--muted);
border: 1px solid var(--border);
}
.communityModalLabel {
flex-shrink: 0;
width: 2.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-foreground);
}
.communityModalValueWrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.communityModalCopyable {
flex: 1;
min-width: 0;
justify-content: flex-end !important;
}
.communityModalMono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.85rem;
word-break: break-all;
text-align: right;
color: var(--foreground);
}
.communityModalOpenLink {
flex-shrink: 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
padding: 0.2rem 0.5rem;
border-radius: 6px;
transition: background 0.15s;
}
.communityModalOpenLink:hover {
background: rgba(245, 158, 11, 0.12);
}
.communityModalDone {
width: 100%;
padding: 0.75rem 1rem;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
color: var(--accent-foreground);
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35);
transition: transform 0.15s, box-shadow 0.15s;
position: relative;
}
.communityModalDone:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(245, 158, 11, 0.45);
}
/* ========== 首页 · 常见问题 ========== */
.faqSection {
padding: 2rem 0 2.5rem;
}
@media (min-width: 768px) {
.faqSection {
padding: 2.5rem 0 3rem;
}
}
.faqInner {
max-width: 720px;
margin: 0 auto;
}
.faqEyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.5rem;
}
.faqEyebrowDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2);
}
.faqHeadline {
font-size: clamp(1.35rem, 3.5vw, 1.75rem);
font-weight: 700;
color: var(--foreground);
margin: 0 0 0.35rem;
letter-spacing: -0.02em;
}
.faqSub {
font-size: 0.9rem;
color: var(--muted-foreground);
margin: 0 0 1.5rem;
line-height: 1.55;
max-width: 36rem;
}
.faqList {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.faqItem {
border-radius: 14px;
border: 1px solid var(--border);
background: var(--card);
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.faqItem:hover {
border-color: rgba(245, 158, 11, 0.35);
}
.faqItemOpen {
border-color: rgba(245, 158, 11, 0.45);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(245, 158, 11, 0.08);
}
.dark .faqItemOpen {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(245, 158, 11, 0.12);
}
.faqTrigger {
display: flex;
width: 100%;
align-items: center;
gap: 0.85rem;
padding: 1rem 1.1rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
color: var(--foreground);
transition: background 0.15s ease;
}
.faqTrigger:hover {
background: var(--muted);
}
.faqTrigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.faqIndex {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: var(--accent);
background: rgba(245, 158, 11, 0.12);
border-radius: 8px;
}
.faqQuestionText {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.4;
padding-right: 0.25rem;
}
.faqChevron {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--muted-foreground);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s, background 0.2s;
}
.faqItemOpen .faqChevron {
transform: rotate(180deg);
color: var(--accent);
background: rgba(245, 158, 11, 0.1);
}
.faqPanel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.faqPanelOpen {
grid-template-rows: 1fr;
}
.faqPanelInner {
overflow: hidden;
min-height: 0;
}
.faqAnswer {
padding: 0 1.1rem 1.1rem 3.65rem;
margin: 0;
font-size: 0.9rem;
line-height: 1.65;
color: var(--muted-foreground);
}
@media (max-width: 480px) {
.faqAnswer {
padding-left: 1.1rem;
}
}
/* ========== 首页 · 立即加入 CTA ========== */
.ctaSection {
padding: 0 0 2.75rem;
}
.ctaInner {
max-width: 640px;
margin: 0 auto;
position: relative;
}
.ctaGlow {
pointer-events: none;
position: absolute;
inset: -20% -10% auto;
height: 70%;
background: radial-gradient(
ellipse 70% 80% at 50% 0%,
rgba(245, 158, 11, 0.22),
transparent 65%
);
opacity: 0.85;
filter: blur(2px);
}
.ctaCard {
position: relative;
border-radius: 20px;
padding: 1.75rem 1.5rem 1.65rem;
text-align: center;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(16px) saturate(1.25);
border: 1px solid rgba(245, 158, 11, 0.24);
box-shadow:
0 20px 50px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.45) inset;
}
.dark .ctaCard {
background: rgba(20, 20, 26, 0.78);
box-shadow:
0 24px 56px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
@media (min-width: 640px) {
.ctaCard {
padding: 2rem 2.25rem 1.85rem;
}
}
/* 与主标题同一行的限时标签 */
.ctaPromoLine {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
margin: 0 0 0.85rem;
font-size: clamp(0.8125rem, 3.4vw, 1.125rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.ctaBadgeInline {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.65rem;
font-size: 0.7em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 9999px;
}
.ctaPromoDivider {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.55;
font-weight: 500;
user-select: none;
}
.ctaPromoMain {
display: inline-flex;
flex-wrap: nowrap;
align-items: baseline;
gap: 0.25em;
min-width: 0;
white-space: nowrap;
}
.ctaPromoSuffix {
flex-shrink: 0;
color: var(--foreground);
font-weight: 700;
}
.ctaBadgePulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: ctaPulse 2s ease-in-out infinite;
}
@keyframes ctaPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.65; transform: scale(0.92); }
}
.ctaHighlight {
background: linear-gradient(120deg, var(--accent) 0%, #fbbf24 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.ctaChips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
margin-bottom: 0.85rem;
opacity: 0.92;
}
.ctaChip {
font-size: clamp(0.5625rem, 2.1vw, 0.6875rem);
font-weight: 500;
color: var(--muted-foreground);
padding: 0.2rem 0.45rem;
border-radius: 9999px;
border: 1px solid var(--border);
background: var(--muted);
opacity: 0.68;
}
/* 与 SHOW_CTA_AUDIENCE_BOUNDARY 配合:隐藏时不删 DOM,仅不占布局 */
.ctaBoundaryHidden {
display: none;
}
.ctaBoundary {
position: relative;
margin: 0 auto 1.4rem;
max-width: 26rem;
border-radius: 14px;
overflow: hidden;
text-align: left;
border: 1px solid rgba(245, 158, 11, 0.22);
background:
linear-gradient(125deg, rgba(245, 158, 11, 0.1) 0%, transparent 45%),
linear-gradient(180deg, rgba(255, 255, 255, 0.75) 0%, rgba(248, 248, 250, 0.94) 100%);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
}
.dark .ctaBoundary {
background:
linear-gradient(125deg, rgba(245, 158, 11, 0.14) 0%, transparent 48%),
linear-gradient(180deg, rgba(32, 32, 40, 0.92) 0%, rgba(16, 16, 20, 0.96) 100%);
border-color: rgba(245, 158, 11, 0.28);
box-shadow:
0 16px 48px rgba(0, 0, 0, 0.38),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
.ctaBoundarySheen {
position: absolute;
top: 0;
left: -50%;
width: 42%;
height: 100%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.28) 48%,
transparent 72%
);
animation: ctaBoundaryShine 8s ease-in-out infinite;
pointer-events: none;
}
.dark .ctaBoundarySheen {
background: linear-gradient(
100deg,
transparent 0%,
rgba(251, 191, 36, 0.1) 48%,
transparent 72%
);
}
@keyframes ctaBoundaryShine {
0%, 100% {
transform: translateX(-20%);
opacity: 0;
}
40% {
opacity: 0.85;
}
55% {
transform: translateX(380%);
opacity: 0;
}
}
.ctaBoundaryInner {
position: relative;
z-index: 1;
padding: 0.85rem 1rem 0.95rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.ctaBoundaryPos {
margin: 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: clamp(0.75rem, 2.4vw, 0.875rem);
line-height: 1.52;
color: var(--foreground);
}
.ctaBoundaryNeg {
margin: 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: clamp(0.7rem, 2.15vw, 0.8125rem);
line-height: 1.52;
color: var(--muted-foreground);
padding-top: 0.5rem;
border-top: 1px dashed rgba(245, 158, 11, 0.28);
}
.ctaBoundaryMark {
flex-shrink: 0;
width: 1.4rem;
height: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
line-height: 1;
color: var(--accent);
background: rgba(245, 158, 11, 0.16);
border-radius: 8px;
border: 1px solid rgba(245, 158, 11, 0.28);
}
.ctaBoundaryMarkNeg {
flex-shrink: 0;
width: 1.4rem;
text-align: center;
font-weight: 800;
font-size: 1rem;
line-height: 1.4rem;
color: rgba(220, 80, 80, 0.88);
}
.dark .ctaBoundaryMarkNeg {
color: rgba(252, 165, 165, 0.88);
}
.ctaBoundaryStrong {
font-weight: 600;
color: var(--accent);
}
.ctaBoundaryStrongNeg {
font-weight: 600;
color: rgba(220, 80, 80, 0.95);
}
.dark .ctaBoundaryStrongNeg {
color: rgba(254, 202, 202, 0.92);
}
.ctaActions {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
@media (min-width: 480px) {
.ctaActions {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
}
.ctaBtnPrimary {
flex: 1;
min-width: 0;
max-width: 280px;
margin: 0 auto;
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.35);
position: relative;
overflow: hidden;
}
@media (min-width: 480px) {
.ctaBtnPrimary {
margin: 0;
flex: 0 1 auto;
}
}
.ctaBtnPrimary::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
120deg,
transparent 40%,
rgba(255, 255, 255, 0.18) 50%,
transparent 60%
);
transform: translateX(-100%);
transition: transform 0.6s ease;
}
.ctaBtnPrimary:hover::after {
transform: translateX(100%);
}
.ctaBtnSecondary {
flex: 1;
min-width: 0;
max-width: 280px;
margin: 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1rem 1.75rem;
font-size: 1rem;
font-weight: 600;
border-radius: 12px;
color: var(--foreground);
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
font-family: inherit;
transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
}
@media (min-width: 480px) {
.ctaBtnSecondary {
margin: 0;
flex: 0 1 auto;
}
}
.ctaBtnSecondary:hover {
transform: translateY(-1px);
background: var(--muted);
}