This commit is contained in:
eric
2026-03-11 23:47:45 -05:00
parent 296d703307
commit 0c2ccadb6b
35 changed files with 2399 additions and 195 deletions

View File

@@ -0,0 +1,27 @@
"use client";
import { WelcomeBlock } from "./dashboard/WelcomeBlock";
import { UnlockedStats } from "./dashboard/UnlockedStats";
import { RecentUpdates } from "./dashboard/RecentUpdates";
import { ContinueLearning } from "./dashboard/ContinueLearning";
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
import { CommunityHighlights } from "./dashboard/CommunityHighlights";
import { WeeklyActions } from "./dashboard/WeeklyActions";
type DashboardPageProps = {
userName: string | null;
};
export function DashboardPage({ userName }: DashboardPageProps) {
return (
<>
<WelcomeBlock userName={userName} />
<UnlockedStats />
<ContinueLearning />
<RecentUpdates />
<TopicEbookShowcase />
<CommunityHighlights />
<WeeklyActions />
</>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { LandingHero } from "./landing/LandingHero";
import { AssetStats } from "./landing/AssetStats";
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
import { CompareSection } from "./landing/CompareSection";
import { CommunityPreview } from "./landing/CommunityPreview";
import { FAQSection } from "./landing/FAQSection";
import { LandingCTA } from "./landing/LandingCTA";
import { LoginBlock } from "./landing/LoginBlock";
type LandingPageProps = {
onJoin: () => void;
onLogin: () => void;
onVerify: (userId: string) => Promise<boolean>;
onVerifySuccess: () => void;
};
export function LandingPage({
onJoin,
onLogin,
onVerify,
onVerifySuccess,
}: LandingPageProps) {
return (
<>
<LandingHero />
<AssetStats />
<TopicEbookShowcase />
<CompareSection />
<CommunityPreview />
<FAQSection />
<LandingCTA onJoin={onJoin} onLogin={onLogin} />
<LoginBlock onVerify={onVerify} onSuccess={onVerifySuccess} />
</>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import styles from "./vip.module.css";
type VipHeaderProps = {
isLoggedIn: boolean;
userName?: string | null;
};
export function VipHeader({ isLoggedIn, userName }: VipHeaderProps) {
return (
<header className={styles.header}>
<Link
href="https://digital.hackrobot.cn/zh"
className={styles.logo}
target="_blank"
rel="noopener"
>
🌍
</Link>
<nav className={styles.nav}>
<ThemeToggle />
{isLoggedIn ? (
<span className={styles.userBadge}>{userName || "会员"}</span>
) : (
<Link href="/#login" className={styles.loginLink}>
</Link>
)}
</nav>
</header>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { type ReactNode } from "react";
import { VipHeader } from "./VipHeader";
import styles from "./vip.module.css";
type VipLayoutProps = {
children: ReactNode;
isLoggedIn: boolean;
userName?: string | null;
};
export function VipLayout({ children, isLoggedIn, userName }: VipLayoutProps) {
return (
<div className={styles.root}>
<VipHeader isLoggedIn={isLoggedIn} userName={userName} />
<main className={styles.main}>{children}</main>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { COMMUNITY_HIGHLIGHTS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function CommunityHighlights() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div
className="animate__animated animate__fadeInUp"
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{COMMUNITY_HIGHLIGHTS.map((item) => (
<Link
key={item.id}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className={styles.card}
style={{
display: "block",
marginBottom: "0.5rem",
textDecoration: "none",
color: "inherit",
}}
>
<span style={{ color: "var(--foreground)" }}>{item.text}</span>
<span style={{ marginLeft: "0.5rem", color: "var(--accent)", fontSize: "0.9rem" }}>
</span>
</Link>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { getLastWatched, getProgress } from "@/app/cloudphone/lib/learning";
import { CURRICULUM_CONFIG } from "@/app/cloudphone/config/course";
import { useState, useEffect } from "react";
import styles from "../vip.module.css";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function ContinueLearning() {
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
useEffect(() => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
const onProgress = () => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
};
window.addEventListener("learning:progress", onProgress);
return () => window.removeEventListener("learning:progress", onProgress);
}, []);
if (!last || progress.completed >= TOTAL_LESSONS) {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<Link
href="/cloudphone/course/0/0"
className={`${styles.card} ${styles.btnPrimary}`}
style={{ display: "block", textAlign: "center" }}
>
{progress.completed >= TOTAL_LESSONS ? "复习课程" : "开始学习"}
</Link>
</section>
);
}
const module = CURRICULUM_CONFIG.modules[last.moduleIndex];
const lesson = module?.lessons[last.lessonIndex];
const title = lesson?.name || `${last.moduleIndex + 1}`;
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<Link
href={`/cloudphone/course/${last.moduleIndex}/${last.lessonIndex}`}
className={styles.card}
style={{
display: "block",
textDecoration: "none",
color: "inherit",
}}
>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.5rem" }}>
{title}
</div>
<div className="animate__animated animate__fadeInUp" style={{ animationFillMode: "both" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
color: "var(--muted-foreground)",
marginBottom: "0.25rem",
}}
>
<span></span>
<span>
{progress.completed}/{TOTAL_LESSONS}
</span>
</div>
<div
style={{
height: "6px",
borderRadius: "3px",
background: "var(--muted)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${progress.percent}%`,
background: "var(--accent)",
borderRadius: "3px",
transition: "width 0.3s",
}}
/>
</div>
</div>
<div style={{ marginTop: "0.75rem", color: "var(--accent)", fontSize: "0.9rem" }}>
</div>
</Link>
</section>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { RECENT_UPDATES } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function RecentUpdates() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div
className="animate__animated animate__fadeInUp"
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{RECENT_UPDATES.map((item) => {
const content = (
<div
className={styles.card}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<span style={{ color: "var(--foreground)" }}>{item.title}</span>
<span style={{ fontSize: "0.8rem", color: "var(--muted-foreground)" }}>
{item.date}
</span>
</div>
);
if (item.href) {
return (
<Link key={item.id} href={item.href} style={{ textDecoration: "none" }}>
{content}
</Link>
);
}
return <div key={item.id}>{content}</div>;
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import Link from "next/link";
import { TOPICS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function TopicCards() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{TOPICS.map((t) => {
const content = (
<>
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
</>
);
if (t.href) {
return (
<Link
key={t.id}
href={t.href}
className={styles.topicCard}
target={t.href.startsWith("http") ? "_blank" : undefined}
rel={t.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
{content}
</Link>
);
}
return (
<div key={t.id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.7 }}>
{content}
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { ASSET_STATS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
const STAT_ITEMS = [
{ key: "topics", label: "已解锁模块" },
{ key: "ebooks", label: "电子书" },
{ key: "videos", label: "视频" },
{ key: "templates", label: "模板" },
] as const;
export function UnlockedStats() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div
className={`${styles.statsGrid4} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{STAT_ITEMS.map(({ key, label }) => {
const value = String(ASSET_STATS[key as keyof typeof ASSET_STATS]);
return (
<div key={key} className={styles.card} style={{ textAlign: "center" }}>
<div className={styles.bigNumber}>{value}</div>
<div className={styles.bigNumberLabel}>{label}</div>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { WEEKLY_ACTIONS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function WeeklyActions() {
return (
<section className={styles.section} style={{ paddingBottom: "2rem" }}>
<h2 className={styles.sectionTitle}></h2>
<div
className="animate__animated animate__fadeInUp"
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{WEEKLY_ACTIONS.map((item) => (
<Link
key={item.id}
href={item.href}
target={item.href.startsWith("http") ? "_blank" : undefined}
rel={item.href.startsWith("http") ? "noopener noreferrer" : undefined}
className={styles.card}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
marginBottom: "0.5rem",
textDecoration: "none",
color: "inherit",
}}
>
<span
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
background: "var(--accent)",
color: "var(--accent-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.75rem",
fontWeight: 600,
}}
>
{item.priority}
</span>
<span style={{ color: "var(--foreground)" }}>{item.text}</span>
<span style={{ marginLeft: "auto", color: "var(--accent)", fontSize: "0.9rem" }}>
</span>
</Link>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import Link from "next/link";
import styles from "../vip.module.css";
type WelcomeBlockProps = {
userName: string | null;
};
function getStorage(key: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(key);
}
export function WelcomeBlock({ userName }: WelcomeBlockProps) {
const displayName = userName || getStorage("userName") || "会员";
return (
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
<h1
className="animate__animated animate__fadeInDown"
style={{
fontSize: "clamp(1.5rem, 4vw, 2rem)",
fontWeight: 600,
margin: 0,
animationDelay: "0.1s",
animationFillMode: "both",
}}
>
{displayName}
</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>
<Link
href="https://qun.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
className={styles.card}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
marginTop: "1rem",
textDecoration: "none",
color: "var(--accent)",
fontWeight: 500,
}}
>
🌐 qun.hackrobot.cn
</Link>
</section>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { ASSET_STATS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
const STAT_ITEMS = [
{ key: "topics", label: "模块" },
{ key: "ebooks", label: "电子书" },
{ key: "videos", label: "视频" },
{ key: "templates", label: "模板" },
{ key: "updateFrequency", label: "更新" },
] as const;
export function AssetStats() {
return (
<section className={styles.section}>
<div
className={`${styles.statsGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{STAT_ITEMS.map(({ key, label }, i) => {
const value =
key === "updateFrequency"
? ASSET_STATS[key]
: String(ASSET_STATS[key as keyof typeof ASSET_STATS]);
return (
<div
key={key}
className={styles.card}
style={{
textAlign: "center",
animationDelay: `${0.25 + i * 0.05}s`,
animationFillMode: "both",
}}
>
<div className={styles.bigNumber}>{value}</div>
<div className={styles.bigNumberLabel}>{label}</div>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { COMMUNITY_PREVIEW } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function CommunityPreview() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div
className="animate__animated animate__fadeInUp"
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
animationDelay: "0.2s",
animationFillMode: "both",
}}
>
{COMMUNITY_PREVIEW.map((item, i) => (
<div
key={i}
className={styles.card}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
}}
>
<span style={{ fontSize: "1.5rem" }}>{item.icon}</span>
<span style={{ color: "var(--foreground)" }}>{item.text}</span>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { COMPARE_ITEMS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function CompareSection() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}> vs </h2>
<p className={styles.sectionDesc}></p>
<div
className="animate__animated animate__fadeInUp"
style={{
overflow: "hidden",
borderRadius: "12px",
border: "1px solid var(--border)",
animationDelay: "0.25s",
animationFillMode: "both",
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.9rem",
}}
>
<thead>
<tr style={{ background: "var(--muted)" }}>
<th
style={{
padding: "0.75rem 1rem",
textAlign: "left",
fontWeight: 600,
color: "var(--foreground)",
}}
>
</th>
<th
style={{
padding: "0.75rem 1rem",
textAlign: "center",
fontWeight: 600,
color: "var(--muted-foreground)",
}}
>
</th>
<th
style={{
padding: "0.75rem 1rem",
textAlign: "center",
fontWeight: 600,
color: "var(--accent)",
}}
>
</th>
</tr>
</thead>
<tbody>
{COMPARE_ITEMS.map((row, i) => (
<tr
key={i}
style={{
borderTop: "1px solid var(--border)",
background: i % 2 === 1 ? "var(--muted)" : "transparent",
}}
>
<td style={{ padding: "0.75rem 1rem", color: "var(--foreground)" }}>
{row.feature}
</td>
<td style={{ padding: "0.75rem 1rem", textAlign: "center", color: "var(--muted-foreground)" }}>
{row.single}
</td>
<td style={{ padding: "0.75rem 1rem", textAlign: "center", color: "var(--accent)" }}>
{row.member}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { FAQ_ITEMS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
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,
}}
>
{item.a}
</p>
)}
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { formatRestoreDateShort } from "@/config/promo.config";
import styles from "../vip.module.css";
type LandingCTAProps = {
onJoin: () => void;
onLogin: () => void;
};
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)" }}>
· {formatRestoreDateShort()}
</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}`}
>
🔒
</button>
<button
type="button"
onClick={onLogin}
className={styles.btnSecondary}
>
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import styles from "../vip.module.css";
export function LandingHero() {
return (
<section className={styles.section} style={{ paddingTop: "2rem", 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",
}}
>
</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",
}}
>
· ·
</h1>
<p
className="animate__animated animate__fadeInDown"
style={{
fontSize: "1.125rem",
color: "var(--muted-foreground)",
maxWidth: "540px",
margin: "0",
lineHeight: 1.6,
animationDelay: "0.3s",
animationFillMode: "both",
}}
>
</p>
</section>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useState, useRef, useEffect } from "react";
import styles from "../vip.module.css";
type LoginBlockProps = {
onVerify: (userId: string) => Promise<boolean>;
onSuccess: () => void;
};
export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
const [userId, setUserId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const hash = typeof window !== "undefined" ? window.location.hash : "";
if (hash === "#login") {
inputRef.current?.focus();
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = userId.trim();
if (!trimmed) {
setError("请输入您的账号");
return;
}
setError(null);
setLoading(true);
try {
const ok = await onVerify(trimmed);
if (ok) {
onSuccess();
} else {
setError("未找到该账号的会员记录,请确认账号正确");
}
} catch {
setError("验证失败,请稍后重试");
} finally {
setLoading(false);
}
};
return (
<section id="login" className={styles.section} style={{ paddingTop: "2rem" }}>
<div className={styles.card} style={{ maxWidth: "400px", margin: "0 auto" }}>
<h2 className={styles.sectionTitle}></h2>
<p className={styles.sectionDesc}>
使 user_id
</p>
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="例如user1234567890"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem 1rem",
fontSize: "1rem",
border: "1px solid var(--border)",
borderRadius: "8px",
background: "var(--background)",
color: "var(--foreground)",
marginBottom: "0.75rem",
}}
/>
{error && (
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
style={{ width: "100%" }}
>
{loading ? "验证中..." : "验证并登录"}
</button>
</form>
</div>
</section>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { TOPICS_DISPLAY, EBOOKS_DISPLAY, VIDEO_COURSES_DISPLAY } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function TopicEbookShowcase() {
return (
<section id="topics" className={styles.section}>
{/* 模块展示 */}
<h2 className={styles.sectionTitle}></h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both", marginBottom: "1.5rem" }}
>
{TOPICS_DISPLAY.map((t) => {
const content = (
<>
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
</>
);
if (t.href) {
return (
<Link
key={t.id}
href={t.href}
className={styles.topicCard}
target={t.href.startsWith("http") ? "_blank" : undefined}
rel={t.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
{content}
</Link>
);
}
return (
<div key={t.id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.7 }}>
{content}
</div>
);
})}
</div>
{/* 视频教程展示 - 参考 digital 资源导航,至少 4 个 */}
<h2 className={styles.sectionTitle}></h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.22s", animationFillMode: "both", marginBottom: "1.5rem" }}
>
{VIDEO_COURSES_DISPLAY.map((v) => (
<Link
key={v.id}
href={v.href}
className={styles.topicCard}
target={v.href.startsWith("http") ? "_blank" : undefined}
rel={v.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
<span className={styles.topicIcon}>{v.icon}</span>
<span className={styles.topicTitle}>{v.title}</span>
<span className={styles.topicDesc}>{v.desc}</span>
</Link>
))}
</div>
{/* 电子书展示 */}
<h2 className={styles.sectionTitle}></h2>
<div
className="animate__animated animate__fadeInUp"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "1rem",
animationDelay: "0.25s",
animationFillMode: "both",
}}
>
{EBOOKS_DISPLAY.map((e) => (
<Link
key={e.id}
href={e.href}
className={styles.card}
style={{
display: "block",
padding: "1.25rem",
textDecoration: "none",
color: "inherit",
}}
>
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
{e.title}
</div>
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--accent)" }}>
</div>
</Link>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import Link from "next/link";
import { TOPICS } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
export function TopicEntries() {
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{TOPICS.map((t, i) => {
const content = (
<>
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
</>
);
if (t.href) {
return (
<Link
key={t.id}
href={t.href}
className={styles.topicCard}
target={t.href.startsWith("http") ? "_blank" : undefined}
rel={t.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
{content}
</Link>
);
}
return (
<div key={t.id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.7 }}>
{content}
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,264 @@
/* VIP 会员中心 - 深色主题优先,会员感、控制台感、资产感 */
.root {
min-height: 100vh;
background: var(--background);
color: var(--foreground);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
@media (max-width: 640px) {
.header {
padding: 0.75rem 1rem;
}
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--foreground);
text-decoration: none;
}
.logo:hover {
color: var(--accent);
}
.nav {
display: flex;
align-items: center;
gap: 0.75rem;
}
.loginLink {
font-size: 0.9rem;
color: var(--muted-foreground);
text-decoration: none;
}
.loginLink:hover {
color: var(--accent);
}
.userBadge {
font-size: 0.875rem;
color: var(--accent);
font-weight: 500;
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem 2rem;
}
@media (max-width: 640px) {
.main {
padding: 0 1rem 1.5rem;
}
}
/* 区块通用 */
.section {
padding: 1.5rem 0;
}
.sectionTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--foreground);
margin: 0 0 1rem;
}
.sectionDesc {
font-size: 0.9rem;
color: var(--muted-foreground);
margin: 0 0 1rem;
line-height: 1.5;
}
/* 卡片 */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.card:hover {
border-color: rgba(245, 158, 11, 0.3);
}
/* 大数字 */
.bigNumber {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
line-height: 1.2;
}
@media (min-width: 640px) {
.bigNumber {
font-size: 2.5rem;
}
}
.bigNumberLabel {
font-size: 0.8rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
/* 按钮 */
.btnPrimary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.75rem;
font-size: 1rem;
font-weight: 600;
color: var(--accent-foreground);
background: var(--accent);
border: none;
border-radius: 12px;
cursor: pointer;
text-decoration: none;
transition: opacity 0.2s, transform 0.15s;
}
.btnPrimary:hover {
opacity: 0.95;
transform: translateY(-1px);
}
.btnSecondary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--muted-foreground);
background: transparent;
border: 1px solid var(--border);
border-radius: 12px;
text-decoration: none;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btnSecondary:hover {
border-color: var(--accent);
color: var(--accent);
}
.btnLarge {
padding: 1rem 2.5rem;
font-size: 1.1rem;
}
/* 资产统计网格 */
.statsGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.statsGrid {
grid-template-columns: repeat(5, 1fr);
}
}
.statsGrid4 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.statsGrid4 {
grid-template-columns: repeat(4, 1fr);
}
}
/* 模块网格 */
.topicGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.topicGrid {
grid-template-columns: repeat(4, 1fr);
}
}
.topicCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 1rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
text-decoration: none;
color: inherit;
transition: border-color 0.2s, transform 0.2s;
}
.topicCard:hover {
border-color: rgba(245, 158, 11, 0.4);
transform: translateY(-2px);
}
.topicIcon {
font-size: 2rem;
line-height: 1;
}
.topicTitle {
font-size: 1rem;
font-weight: 600;
color: var(--foreground);
}
.topicDesc {
font-size: 0.8rem;
color: var(--muted-foreground);
}
/* Loading */
.loading {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: var(--background);
color: var(--foreground);
}
.loadingSpinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}