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

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