This commit is contained in:
eric
2026-03-10 01:35:37 -05:00
parent ff51622bb6
commit be7a298604
69 changed files with 11016 additions and 57 deletions

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

@@ -0,0 +1,227 @@
import { NextRequest, NextResponse } from "next/server";
import { getZPayConfig } from "@/lib/sdk";
import { SITE_URL } from "@/app/lib/env";
const ZPAY_REQUIRED = [
"pid",
"type",
"out_trade_no",
"notify_url",
"return_url",
"name",
"money",
"sign",
"sign_type",
];
function escapeAttr(s: string): string {
return String(s)
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
const flat: Record<string, string> = {};
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
const s = String(v).trim();
if (s === "") continue;
flat[k] = s;
}
const inputs = Object.entries(flat)
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
.join("\n");
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>跳转支付...</title>
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
</head>
<body>
<p>正在跳转到支付页面...</p>
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
${inputs}
</form>
<script>document.getElementById("zpayForm").submit();</script>
</body>
</html>`;
}
function detectDevice(request: NextRequest): "pc" | "h5" | "wechat" {
const ua = request.headers.get("user-agent") || "";
if (/MicroMessenger/i.test(ua)) return "wechat";
if (/Mobile|Android|iPhone|iPad/i.test(ua)) return "h5";
return "pc";
}
export async function POST(request: NextRequest) {
try {
let body: Record<string, string | number>;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = await request.json();
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record<string, string>;
}
const payConfig = getZPayConfig();
const user_id = String(body?.user_id || `user_${Date.now()}`).trim();
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount;
const type = String(body?.type || payConfig.defaultType);
const channel = String(body?.channel || "alipay");
const device = (body?.device as string) || detectDevice(request);
const origin =
request.headers.get("x-forwarded-host")
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
: request.headers.get("origin") || SITE_URL;
const finalReturnUrl = return_url || `${origin}/pay/paid`;
const payload = {
user_id,
total_fee,
type,
channel: device === "wechat" ? "wxpay" : channel,
return_url: finalReturnUrl,
device,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const redirectRes = await fetch(`${payConfig.apiUrl}/payh5/redirect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
client_ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"",
}),
redirect: "manual",
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim();
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const res = NextResponse.redirect(location);
if (orderId) {
res.cookies.set("pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
}
return res;
}
}
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.use_form) {
const { createPayOrder } = await import("@/lib/sdk");
const { params } = await createPayOrder({
user_id,
return_url: finalReturnUrl,
total_fee,
type,
channel: "wxpay",
});
const html = buildPayHtml(payConfig.zpaySubmitUrl, params);
const res = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (orderId) {
res.cookies.set("pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
}
return res;
}
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const ordId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c
);
const ch = String(resData.channel || "alipay").toLowerCase();
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}a{color:#0ea5e9;margin-top:16px;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
<body>
<h2>${esc(title)}</h2>
<p>${esc(hint)}</p>
<img src="${imgSrc}" alt="支付二维码" />
<p class="tip" id="tip">支付成功后将自动跳转...</p>
<script>
(function(){
var orderId="${ordId}";
var returnUrl="${returnUrl}";
if(!orderId){return;}
var tip=document.getElementById("tip");
var t=0;
var maxT=300;
function check(){
t++;
if(t>maxT){tip.textContent="轮询超时";return;}
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
setTimeout(check,2000);
})
.catch(function(){setTimeout(check,2000);});
}
setTimeout(check,2000);
})();
</script>
</body></html>`;
return new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
if (redirectRes.status === 400 || redirectRes.status === 500) {
return NextResponse.json(
{ ok: false, error: resData?.detail || resData?.msg || "支付跳转失败" },
{ status: redirectRes.status }
);
}
return NextResponse.json(
{ ok: false, error: "支付服务暂不可用,请稍后重试" },
{ status: 500 }
);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const msg = err.message || "";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py"
: (err as Error).name === "AbortError"
? "请求超时,请检查 payjsapi 是否正常运行"
: msg;
console.error("Pay API error:", err);
return NextResponse.json(
{ ok: false, error: `支付请求失败: ${hint}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrderStatus } from "@/lib/sdk";
export async function GET(request: NextRequest) {
const orderId = request.nextUrl.searchParams.get("order_id");
if (!orderId) {
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
}
try {
const { paid } = await getOrderStatus(orderId);
return NextResponse.json({ ok: true, paid });
} catch (e) {
return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 });
}
}

82
app/certificate/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Header } from "@/app/components";
import { getProgress } from "@/app/lib/learning";
import { CURRICULUM_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
const TOTAL = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export default function CertificatePage() {
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
const [paid, setPaid] = useState(false);
useEffect(() => {
setProgress(getProgress(TOTAL));
setPaid(isPaid());
}, []);
const completed = progress.percent >= 100;
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="pt-20 pb-16">
<div className="mx-auto max-w-2xl px-4 text-center">
{!paid ? (
<div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-8">
<h1 className="mb-4 text-2xl font-bold">报名后可查看证书</h1>
<p className="mb-6 text-[var(--muted-foreground)]">
完成全部 {TOTAL} 节课程后可申请结业证书
</p>
<Link
href="/pay"
className="inline-flex rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] hover:opacity-90"
>
立即报名
</Link>
</div>
) : completed ? (
<div className="rounded-2xl border-2 border-[var(--accent)] bg-[var(--card)] p-8">
<div className="mb-6 text-5xl">🎓</div>
<h1 className="mb-2 text-2xl font-bold">结业证书</h1>
<p className="mb-6 text-[var(--muted-foreground)]">
恭喜你完成《数字游民实战课》全部 {TOTAL} 节课程
</p>
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
异度世界、大师兄、小美
</p>
<Link
href="/"
className="text-[var(--accent)] underline hover:no-underline"
>
返回课程
</Link>
</div>
) : (
<div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-8">
<h1 className="mb-4 text-2xl font-bold">继续加油</h1>
<p className="mb-6 text-[var(--muted-foreground)]">
已完成 {progress.completed}/{TOTAL} 节,完成全部可解锁证书
</p>
<div className="mb-6 h-3 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress.percent}%` }}
/>
</div>
<Link
href="/course/0/0"
className="inline-flex rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] hover:opacity-90"
>
继续学习
</Link>
</div>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";
type Props = HTMLMotionProps<"div"> & {
delay?: number;
};
export function AnimateOnScroll({ children, delay = 0, className = "", ...rest }: Props) {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.08, margin: "0px 0px -40px 0px" }}
transition={{ duration: 0.6, ease: "easeOut", delay: delay / 1000 }}
className={className}
{...rest}
>
{children}
</motion.div>
);
}

43
app/components/Button.tsx Normal file
View File

@@ -0,0 +1,43 @@
import Link from "next/link";
type ButtonProps = {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
className?: string;
};
const sizeClasses = {
sm: "px-4 py-2 text-sm",
md: "px-5 py-2.5 text-base",
lg: "px-8 py-4 text-lg sm:px-10",
};
export function Button({
href,
children,
variant = "primary",
size = "md",
className = "",
}: ButtonProps) {
const base =
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition";
const variants = {
primary:
"bg-[var(--accent)] text-[var(--accent-foreground)] hover:opacity-90",
secondary:
"border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--muted)]",
};
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className={`${base} ${variants[variant]} ${sizeClasses[size]} ${className}`}
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { COMMUNITY_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
export function CommunityButton() {
const [open, setOpen] = useState(false);
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPay = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPay);
return () => window.removeEventListener("pay:updated", onPay);
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
if (open) {
window.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
if (!COMMUNITY_CONFIG.enabled || !COMMUNITY_CONFIG.wechat) return null;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="text-sm text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
>
{COMMUNITY_CONFIG.wechat}
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onClick={() => setOpen(false)}
>
<div
className="relative w-full max-w-[420px] overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--background)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-[var(--accent)] via-cyan-400 to-[var(--accent)]" />
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 rounded-full 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>
{paid ? (
<div className="p-8 pt-10">
<div className="mb-6 text-center">
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--accent)]/30 to-cyan-500/20 text-3xl shadow-lg">
💬
</div>
<h3 className="text-xl font-semibold">加入数字游民社群</h3>
<p className="mt-2 text-sm text-[var(--muted-foreground)]">
与导师、同学一起交流,获取持续支持与灵感
</p>
</div>
<div className="flex justify-center">
<div className="relative flex h-56 w-56 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--card)] to-[var(--muted)]/30 p-5 shadow-inner ring-2 ring-[var(--accent)]/20">
<img
src={COMMUNITY_CONFIG.qrCodeUrl}
alt="社群二维码"
className="h-full w-full object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
const fallback = target.parentElement?.querySelector(".qr-fallback");
if (fallback) (fallback as HTMLElement).classList.remove("hidden");
}}
/>
<div className="qr-fallback hidden absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-2xl bg-[var(--muted)]/40 p-4 text-center text-sm text-[var(--muted-foreground)]">
<span className="text-4xl">📱</span>
<span>请配置社群二维码</span>
<span className="text-xs">config/course.ts → qrCodeUrl</span>
</div>
</div>
</div>
<p className="mt-5 text-center text-sm text-[var(--muted-foreground)]">
长按识别二维码加入
</p>
</div>
) : (
<div className="p-8 pt-10">
<div className="mb-6 text-center">
<div className="mb-3 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-500/20 to-orange-500/10 text-4xl shadow-lg">
🔒
</div>
<h3 className="text-xl font-semibold">报名后解锁社群</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted-foreground)]">
完成课程报名后,即可加入专属社群,与导师、同学交流答疑
</p>
</div>
<div className="flex flex-col gap-3">
<Link
href="/pay"
className="rounded-xl bg-[var(--accent)] px-6 py-4 text-center font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
onClick={() => setOpen(false)}
>
立即报名
</Link>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-xl border border-[var(--border)] px-6 py-3 text-sm transition hover:bg-[var(--muted)]"
>
稍后再说
</button>
</div>
</div>
)}
</div>
</div>
)}
</>
);
}

25
app/components/Footer.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { SITE_CONFIG } from "@/config/course";
import { ShareButton } from "./ShareButton";
import { CommunityButton } from "./CommunityButton";
export function Footer() {
return (
<footer className="border-t border-[var(--border)] py-6 sm:py-8">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
<Link href="/certificate" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
我的证书
</Link>
<CommunityButton />
<ShareButton />
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
{SITE_CONFIG.footer}
</div>
</div>
</footer>
);
}

45
app/components/Header.tsx Normal file
View File

@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
import { SITE_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
export function Header() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-xl">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-3 sm:h-14 sm:gap-4 sm:px-4 md:h-16 md:px-6">
<Link href="/" className="min-w-0 truncate text-sm font-medium sm:text-base md:text-xl">
{SITE_CONFIG.brand}
</Link>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
<ThemeToggle />
<Link
href={paid ? "/course/0/0" : "/pay"}
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
<Link
href={paid ? "/course/0/0" : "/pay"}
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
aria-label={paid ? "进入课程" : "立即报名"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
</header>
);
}

111
app/components/Hero.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
import { getLastWatched, getProgress } from "@/app/lib/learning";
import { ParticleBackground } from "./ParticleBackground";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function Hero() {
const [paid, setPaid] = useState(false);
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
useEffect(() => {
setPaid(isPaid());
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
const onPay = () => setPaid(isPaid());
const onProgress = () => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
};
window.addEventListener("pay:updated", onPay);
window.addEventListener("learning:progress", onProgress);
return () => {
window.removeEventListener("pay:updated", onPay);
window.removeEventListener("learning:progress", onProgress);
};
}, []);
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" />
<ParticleBackground />
<div className="relative mx-auto max-w-4xl px-4 text-center sm:px-6">
<h1
className="mb-4 text-3xl font-bold tracking-tight animate__animated animate__fadeInDown sm:text-4xl md:text-5xl lg:text-6xl"
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
>
{HERO_CONFIG.title}
</h1>
<p
className="mb-10 text-lg text-[var(--muted-foreground)] animate__animated animate__fadeInDown sm:mb-12 sm:text-xl md:text-2xl"
style={{ animationDelay: "0.25s", animationFillMode: "both" }}
>
{HERO_CONFIG.subtitle}
</p>
<div
className="mb-10 flex flex-wrap justify-center gap-6 animate__animated animate__fadeInDown sm:mb-12 sm:gap-8"
style={{ animationDelay: "0.4s", animationFillMode: "both" }}
>
{HERO_CONFIG.stats.map((s) => (
<div key={s.label} className="text-center transition-transform duration-300 hover:scale-105">
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">
{s.value}
</div>
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">
{s.label}
</div>
</div>
))}
</div>
<div
className="flex flex-wrap justify-center gap-3 animate__animated animate__fadeInDown sm:gap-4"
style={{ animationDelay: "0.6s", animationFillMode: "both" }}
>
{paid && last ? (
<Link
href={`/course/${last.moduleIndex}/${last.lessonIndex}`}
className="inline-flex shrink-0 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 sm:px-10"
>
继续学习 →
</Link>
) : (
<Link
href={paid ? "/course/0/0" : "/pay"}
className="inline-flex shrink-0 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 sm:px-10"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
)}
</div>
{paid && progress.completed > 0 && (
<div
className="mt-6 w-full max-w-xs animate__animated animate__fadeInDown"
style={{ animationDelay: "0.7s", animationFillMode: "both" }}
>
<div className="mb-1 flex justify-between text-xs text-[var(--muted-foreground)]">
<span>学习进度</span>
<span>{progress.completed}/{TOTAL_LESSONS} 节</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress.percent}%` }}
/>
</div>
{progress.percent >= 100 && (
<Link href="/certificate" className="mt-2 inline-block text-sm font-medium text-[var(--accent)] hover:underline">
查看证书 →
</Link>
)}
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useState, useEffect } from "react";
import { getNote, setNote } from "@/app/lib/learning";
type Props = { moduleIndex: number; lessonIndex: number };
export function LessonNotes({ moduleIndex, lessonIndex }: Props) {
const [text, setText] = useState("");
const [saved, setSaved] = useState(false);
useEffect(() => {
setText(getNote(moduleIndex, lessonIndex));
}, [moduleIndex, lessonIndex]);
const handleSave = () => {
setNote(moduleIndex, lessonIndex, text);
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 sm:p-6">
<h3 className="mb-3 flex items-center gap-2 font-semibold">
<span>📝</span> 学习笔记
</h3>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={handleSave}
placeholder="记录你的学习心得..."
className="mb-3 w-full min-h-[100px] rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
rows={4}
/>
<p className="text-xs text-[var(--muted-foreground)]">
{saved ? "✓ 已保存" : "失焦自动保存"}
</p>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type MarkdownContentProps = {
content: string;
className?: string;
};
export function MarkdownContent({ content, className = "" }: MarkdownContentProps) {
return (
<article
className={`markdown-content text-[var(--foreground)] ${className}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="mb-4 mt-8 border-b border-[var(--border)] pb-2 text-2xl font-bold first:mt-0 sm:text-3xl">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 mt-6 text-xl font-semibold sm:text-2xl">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-4 text-lg font-semibold sm:text-xl">{children}</h3>
),
p: ({ children }) => <p className="mb-4 leading-7">{children}</p>,
ul: ({ children }) => (
<ul className="mb-4 list-disc space-y-1 pl-6">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 list-decimal space-y-1 pl-6">{children}</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
code: ({ className: codeClassName, children }) => {
const isBlock = codeClassName?.includes("language-");
if (isBlock) {
return (
<pre className="mb-4 overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm">
<code className={codeClassName}>{children}</code>
</pre>
);
}
return (
<code className="rounded bg-[var(--muted)] px-1.5 py-0.5 font-mono text-sm">
{children}
</code>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--accent)] pl-4 italic text-[var(--muted-foreground)]">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] underline hover:opacity-80"
>
{children}
</a>
),
table: ({ children }) => (
<div className="mb-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-[var(--border)]">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-[var(--border)] bg-[var(--muted)] px-4 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-[var(--border)] px-4 py-2">{children}</td>
),
}}
>
{content}
</ReactMarkdown>
</article>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import type { ISourceOptions } from "@tsparticles/engine";
export function ParticleBackground() {
const [init, setInit] = useState(false);
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => setInit(true));
}, []);
const options: ISourceOptions = {
background: { color: { value: "transparent" } },
fpsLimit: 60,
interactivity: {
events: { onHover: { enable: true, mode: "grab" } },
modes: { grab: { distance: 140, links: { opacity: 0.3 } } },
},
particles: {
color: { value: "#f59e0b" },
links: {
color: "#f59e0b",
distance: 120,
enable: true,
opacity: 0.15,
width: 0.5,
},
move: {
enable: true,
outModes: { default: "bounce" },
speed: 0.3,
},
number: { density: { enable: true, area: 800 }, value: 50 },
opacity: { value: { min: 0.1, max: 0.4 } },
shape: { type: "circle" },
size: { value: { min: 0.5, max: 2 } },
},
detectRetina: true,
};
if (!init) return null;
return (
<Particles
id="hero-particles"
className="pointer-events-none absolute inset-0"
options={options}
/>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
type PayButtonProps = {
children: React.ReactNode;
size?: "sm" | "md" | "lg";
className?: string;
channel?: "alipay" | "wxpay";
};
const sizeClasses = {
sm: "px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm",
md: "px-5 py-2.5 text-base",
lg: "px-8 py-4 text-lg sm:px-10",
};
export function PayButton({ children, size = "md", className = "", channel = "alipay" }: PayButtonProps) {
const [loading, setLoading] = useState(false);
const handlePay = () => {
if (loading) return;
setLoading(true);
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/pay";
form.style.display = "none";
const fields = {
user_id: `user_${Date.now()}`,
return_url: typeof window !== "undefined" ? `${window.location.origin}/pay/paid` : "",
total_fee: 80,
type: "meetup",
channel,
html: "1",
};
for (const [k, v] of Object.entries(fields)) {
const input = document.createElement("input");
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
};
return (
<button
type="button"
onClick={handlePay}
disabled={loading}
className={`inline-flex items-center justify-center gap-2 rounded-full bg-[var(--accent)] font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70 ${sizeClasses[size]} ${className}`}
>
{loading ? "跳转中..." : children}
</button>
);
}

View File

@@ -0,0 +1,17 @@
import { type ReactNode } from "react";
type SectionProps = {
children: ReactNode;
className?: string;
};
/** 通用区块容器 - 统一内边距与最大宽度,响应式 */
export function Section({ children, className = "" }: SectionProps) {
return (
<section
className={`border-t border-[var(--border)] py-12 md:py-16 lg:py-20 ${className}`}
>
<div className="mx-auto max-w-6xl px-4 sm:px-6">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,379 @@
"use client";
import { useState, useRef, useEffect } from "react";
import {
HERO_CONFIG,
BENEFITS_CONFIG,
CURRICULUM_CONFIG,
CTA_CONFIG,
INSTRUCTORS_CONFIG,
} from "@/config/course";
type Props = { title?: string; url?: string; className?: string };
const POSTER_WIDTH = 750;
const POSTER_HEIGHT = 1334;
/** 使用公共 API 生成二维码图片,无需 npm 包 */
async function getQRImage(url: string, size: number): Promise<HTMLImageElement | null> {
const encoded = encodeURIComponent(url);
const apiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encoded}&format=png&bgcolor=0f172a&color=22d3ee`;
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = apiUrl;
});
}
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number
): number {
const words = text.split("");
let line = "";
let lineCount = 0;
for (const char of words) {
const test = line + char;
const m = ctx.measureText(test);
if (m.width > maxWidth && line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
lineCount++;
line = char;
} else {
line = test;
}
}
if (line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
lineCount++;
}
return lineCount * lineHeight;
}
export function ShareButton({ title = HERO_CONFIG.title, url, className = "" }: Props) {
const [open, setOpen] = useState(false);
const [ready, setReady] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const getShareUrl = () => url || (typeof window !== "undefined" ? window.location.origin : "");
const drawPoster = async () => {
const canvas = canvasRef.current;
if (!canvas || typeof window === "undefined") return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const shareUrl = getShareUrl();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = POSTER_WIDTH * dpr;
canvas.height = POSTER_HEIGHT * dpr;
ctx.scale(dpr, dpr);
const w = POSTER_WIDTH;
const h = POSTER_HEIGHT;
const cyan = "#22d3ee";
const cyanDim = "rgba(34,211,238,0.6)";
const white = "#f8fafc";
const muted = "rgba(248,250,252,0.7)";
const dark = "#0f172a";
const cardBg = "rgba(15,23,42,0.8)";
// ========== 背景:深色科技感 ==========
const bgGrad = ctx.createLinearGradient(0, 0, w, h);
bgGrad.addColorStop(0, "#020617");
bgGrad.addColorStop(0.4, "#0f172a");
bgGrad.addColorStop(0.8, "#0c4a6e");
bgGrad.addColorStop(1, "#0e7490");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// 网格线
ctx.strokeStyle = "rgba(34,211,238,0.06)";
ctx.lineWidth = 1;
const gridStep = 40;
for (let i = 0; i <= w; i += gridStep) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, h);
ctx.stroke();
}
for (let i = 0; i <= h; i += gridStep) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(w, i);
ctx.stroke();
}
// 顶部光晕
const glow = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, 400);
glow.addColorStop(0, "rgba(34,211,238,0.15)");
glow.addColorStop(0.5, "rgba(34,211,238,0.04)");
glow.addColorStop(1, "transparent");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, 350);
// 底部光晕
const glowBot = ctx.createRadialGradient(w / 2, h, 0, w / 2, h, 350);
glowBot.addColorStop(0, "rgba(34,211,238,0.12)");
glowBot.addColorStop(1, "transparent");
ctx.fillStyle = glowBot;
ctx.fillRect(0, h - 400, w, 400);
// ========== 顶部:标题区 ==========
ctx.fillStyle = cyan;
ctx.font = "bold 38px sans-serif";
ctx.textAlign = "center";
ctx.fillText("🌍 " + title, w / 2, 88);
ctx.font = "20px sans-serif";
ctx.fillStyle = muted;
ctx.textAlign = "left";
wrapText(ctx, HERO_CONFIG.subtitle, 48, 128, 654, 26);
ctx.textAlign = "center";
// 分隔线
ctx.strokeStyle = "rgba(34,211,238,0.3)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(60, 168);
ctx.lineTo(w - 60, 168);
ctx.stroke();
// ========== 数据卡片 ==========
const cardY = 192;
const cardW = 210;
const cardH = 68;
const cardGap = (w - cardW * 3) / 4;
HERO_CONFIG.stats.forEach((s, i) => {
const x = cardGap + i * (cardW + cardGap);
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.25)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(x, cardY, cardW, cardH, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = cyan;
ctx.font = "bold 26px sans-serif";
ctx.textAlign = "center";
ctx.fillText(s.value, x + cardW / 2, cardY + 36);
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(s.label, x + cardW / 2, cardY + 56);
});
ctx.textAlign = "center";
// ========== 学完你将获得 ==========
let y = 290;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = white;
ctx.fillText(BENEFITS_CONFIG.title, w / 2, y);
y += 28;
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(BENEFITS_CONFIG.subtitle, w / 2, y);
y += 32;
BENEFITS_CONFIG.items.forEach((item) => {
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.15)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(48, y, w - 96, 40, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = cyanDim;
ctx.font = "15px sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${item.num}. ${item.title}`, 64, y + 26);
ctx.textAlign = "center";
y += 48;
});
// ========== 课程模块 ==========
y += 16;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = white;
ctx.fillText(CURRICULUM_CONFIG.title, w / 2, y);
y += 26;
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(CURRICULUM_CONFIG.subtitle, w / 2, y);
y += 32;
const cols = 2;
const modW = (w - 96 - 12) / cols;
CURRICULUM_CONFIG.modules.forEach((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const mx = 48 + col * (modW + 12);
const my = y + row * 44;
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.12)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(mx, my, modW, 38, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = white;
ctx.font = "14px sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${m.emoji} ${m.title}`, mx + 12, my + 24);
ctx.textAlign = "center";
});
y += Math.ceil(CURRICULUM_CONFIG.modules.length / cols) * 44 + 20;
// ========== 徽章 ==========
ctx.font = "15px sans-serif";
ctx.fillStyle = cyanDim;
ctx.fillText("✓ " + CTA_CONFIG.badges.join(" · "), w / 2, y);
y += 44;
// ========== 导师 ==========
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(INSTRUCTORS_CONFIG.subtitle, w / 2, y);
y += 32;
// ========== 二维码区 ==========
const qrSize = 200;
const qrX = (w - qrSize) / 2;
const qrY = y;
const qrImg = await getQRImage(shareUrl, qrSize);
if (qrImg) {
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.4)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(qrX - 10, qrY - 10, qrSize + 20, qrSize + 20, 12);
ctx.fill();
ctx.stroke();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
} else {
ctx.fillStyle = cardBg;
ctx.fillRect(qrX, qrY, qrSize, qrSize);
ctx.fillStyle = cyan;
ctx.font = "13px sans-serif";
ctx.fillText(shareUrl, w / 2, qrY + qrSize / 2 + 5);
}
y += qrSize + 24;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = cyan;
ctx.fillText("扫码访问课程", w / 2, y);
y += 24;
ctx.font = "14px sans-serif";
ctx.fillStyle = muted;
ctx.fillText("长按识别二维码", w / 2, y);
y += 28;
ctx.font = "12px sans-serif";
ctx.fillStyle = "rgba(248,250,252,0.5)";
ctx.fillText("自由工作 · 自在生活", w / 2, y);
setReady(true);
};
useEffect(() => {
if (open && canvasRef.current) {
setReady(false);
drawPoster();
}
}, [open, url]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
if (open) {
window.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
const handleDownload = () => {
const canvas = canvasRef.current;
if (!canvas || !ready) return;
const link = document.createElement("a");
link.download = `分享-${title}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={`inline-flex items-center gap-2 rounded-lg border border-[var(--border)] px-4 py-2 text-sm transition hover:bg-[var(--muted)] ${className}`}
>
分享
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={() => setOpen(false)}
>
<div
className="relative max-h-[90vh] overflow-auto rounded-2xl bg-[var(--background)] p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-2 top-2 rounded-full p-2 text-[var(--muted-foreground)] 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>
<h3 className="mb-4 text-center font-semibold">分享海报</h3>
<div className="flex justify-center rounded-xl bg-[var(--muted)]/30 p-2">
<canvas
ref={canvasRef}
width={POSTER_WIDTH}
height={POSTER_HEIGHT}
className="max-w-full rounded-lg"
style={{ width: POSTER_WIDTH, height: POSTER_HEIGHT, maxWidth: "100%" }}
/>
</div>
<p className="mt-3 text-center text-sm text-[var(--muted-foreground)]">
下载海报后,可通过微信、朋友圈等渠道分享
</p>
<div className="mt-4 flex justify-center gap-3">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-[var(--border)] px-6 py-2 text-sm transition hover:bg-[var(--muted)]"
>
取消
</button>
<button
type="button"
onClick={handleDownload}
disabled={!ready}
className="rounded-lg bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-50"
>
{ready ? "下载海报" : "生成中..."}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useTheme } from "../providers/ThemeProvider";
const SunIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
);
const MoonIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const toggle = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
};
return (
<button
type="button"
onClick={toggle}
className="flex items-center justify-center rounded-full p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label={resolvedTheme === "dark" ? "切换至日间模式" : "切换至夜间模式"}
title={resolvedTheme === "dark" ? "日间" : "夜间"}
>
{resolvedTheme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
);
}

View File

@@ -0,0 +1,369 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const STORAGE_PREFIX = "lms-video-";
const SAVE_INTERVAL = 5000; // 每 5 秒保存一次
const MIN_PROGRESS_TO_SHOW = 10; // 至少观看 10 秒才提示续播
function getStoredProgress(videoId: string): number {
if (typeof window === "undefined") return 0;
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`);
if (!raw) return 0;
const data = JSON.parse(raw);
return typeof data?.time === "number" ? data.time : 0;
} catch {
return 0;
}
}
function saveProgress(videoId: string, time: number): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time }));
} catch {
/* ignore */
}
}
type VideoPlayerProps = {
src: string;
poster?: string;
title?: string;
className?: string;
/** 用于存储观看进度的唯一 ID,如 course-0-1 */
videoId?: string;
/** 播完时回调,用于标记完成 */
onComplete?: () => void;
};
export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speed, setSpeed] = useState(1);
const [showControls, setShowControls] = useState(true);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [savedTime, setSavedTime] = useState(0);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSaveRef = useRef(0);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setPlaying(true);
} else {
v.pause();
setPlaying(false);
}
}, []);
const handleTimeUpdate = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const time = v.currentTime;
const dur = v.duration;
setCurrentTime(time);
setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
// 定期保存观看进度
if (videoId && v.currentTime > 0) {
const now = Date.now();
if (now - lastSaveRef.current > SAVE_INTERVAL) {
lastSaveRef.current = now;
saveProgress(videoId, v.currentTime);
}
}
}, [videoId]);
const handleLoadedMetadata = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
setDuration(dur);
// 检查是否有上次观看进度
if (videoId) {
const stored = getStoredProgress(videoId);
if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
setSavedTime(stored);
setShowResumePrompt(true);
}
}
}, [videoId]);
const handleEnded = useCallback(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
if (videoId) saveProgress(videoId, 0); // 播完清除进度
onComplete?.();
}, [videoId, onComplete]);
const handlePause = useCallback(() => {
const v = videoRef.current;
if (videoId && v && v.currentTime > 0) {
saveProgress(videoId, v.currentTime);
}
}, [videoId]);
const handleResume = useCallback(() => {
const v = videoRef.current;
if (!v || savedTime <= 0) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const time = Math.min(savedTime, dur - 0.1);
v.currentTime = time;
setCurrentTime(time);
setProgress((time / dur) * 100);
v.play();
setPlaying(true);
setShowResumePrompt(false);
}, [savedTime]);
const handleRestart = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (videoId) saveProgress(videoId, 0);
v.currentTime = 0;
setCurrentTime(0);
setProgress(0);
setShowResumePrompt(false);
}, [videoId]);
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width <= 0) return;
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const time = p * dur;
v.currentTime = time;
setCurrentTime(time);
setProgress(p * 100);
}, []);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
}, []);
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = videoRef.current;
if (!v) return;
const val = parseFloat(e.target.value);
v.volume = val;
setVolume(val);
setMuted(val === 0);
}, []);
const cycleSpeed = useCallback(() => {
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const i = speeds.indexOf(speed);
const next = speeds[(i + 1) % speeds.length];
const v = videoRef.current;
if (v) v.playbackRate = next;
setSpeed(next);
}, [speed]);
const toggleFullscreen = useCallback(() => {
const container = videoRef.current?.parentElement;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
const showControlsTemporarily = useCallback(() => {
setShowControls(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
const formatTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
const formatResumeTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0秒";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
if (m > 0) return `${m}分${sec}秒`;
return `${sec}秒`;
};
return (
<div
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
title={title}
className="aspect-video w-full object-contain"
onClick={togglePlay}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPause={handlePause}
playsInline
/>
{/* 续播提示 - 位于视频左下角上方 */}
{showResumePrompt && (
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
<p className="mb-3 text-sm text-white/90">
上次看到 <span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleResume}
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
>
从上次继续
</button>
<button
type="button"
onClick={handleRestart}
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
>
重新观看
</button>
</div>
</div>
)}
{/* 中央播放按钮 */}
{!playing && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
aria-label="播放"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
<svg
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
{/* 底部控制栏 */}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
{/* 进度条 */}
<div
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
onClick={handleSeek}
role="slider"
aria-valuenow={progress}
>
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<button
type="button"
onClick={togglePlay}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={playing ? "暂停" : "播放"}
>
{playing ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={muted ? "取消静音" : "静音"}
>
{muted || volume === 0 ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
/>
</div>
<button
type="button"
onClick={cycleSpeed}
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
>
{speed}x
</button>
<button
type="button"
onClick={toggleFullscreen}
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
aria-label="全屏"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
/**
* 自实现滚动入场动画 - 封装待用
* 基于 IntersectionObserver,无第三方依赖
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
};
export function CustomAnimateOnScroll({ children, className = "", delay = 0 }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setVisible(true);
},
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`animate-on-scroll ${visible ? "animate-on-scroll-visible" : ""} ${className}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
/**
* 自实现 Canvas 粒子背景 - 封装待用
* 无第三方依赖,轻量级替代方案
*/
import { useEffect, useRef } from "react";
const PARTICLE_COUNT = 50;
const CONNECT_DISTANCE = 120;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
r: number;
opacity: number;
};
export function CustomParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationId: number;
let particles: Particle[] = [];
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
particles = Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
r: Math.random() * 2 + 0.5,
opacity: Math.random() * 0.4 + 0.1,
}));
};
const getAccentRgb = () => {
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue("--accent").trim() || "#f59e0b";
const m = accent.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (m) return `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}`;
return "245, 158, 11";
};
const draw = () => {
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
const rgb = getAccentRgb();
ctx.clearRect(0, 0, w, h);
particles.forEach((p, i) => {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb}, ${p.opacity})`;
ctx.fill();
for (let j = i + 1; j < particles.length; j++) {
const q = particles[j];
const dx = p.x - q.x;
const dy = p.y - q.y;
const dist = Math.hypot(dx, dy);
if (dist < CONNECT_DISTANCE) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.strokeStyle = `rgba(${rgb}, ${0.08 * (1 - dist / CONNECT_DISTANCE)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
});
animationId = requestAnimationFrame(draw);
};
resize();
window.addEventListener("resize", resize);
draw();
return () => {
window.removeEventListener("resize", resize);
cancelAnimationFrame(animationId);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0"
aria-hidden
/>
);
}

View File

@@ -0,0 +1,2 @@
export { CustomParticleBackground } from "./CustomParticleBackground";
export { CustomAnimateOnScroll } from "./CustomAnimateOnScroll";

15
app/components/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export { Header } from "./Header";
export { Hero } from "./Hero";
export { Footer } from "./Footer";
export { Section } from "./Section";
export { AnimateOnScroll } from "./AnimateOnScroll";
export { CustomParticleBackground, CustomAnimateOnScroll } from "./custom";
export { Button } from "./Button";
export { ThemeToggle } from "./ThemeToggle";
export { AudienceSection } from "./sections/AudienceSection";
export { BenefitsSection } from "./sections/BenefitsSection";
export { CurriculumSection } from "./sections/CurriculumSection";
export { InstructorsSection } from "./sections/InstructorsSection";
export { FAQSection } from "./sections/FAQSection";
export { RatingSection } from "./sections/RatingSection";
export { CTASection } from "./sections/CTASection";

View File

@@ -0,0 +1,27 @@
import { Section } from "../Section";
import { AUDIENCE_CONFIG } from "@/config/course";
export function AudienceSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{AUDIENCE_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{AUDIENCE_CONFIG.subtitle}
</p>
<div className="grid gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{AUDIENCE_CONFIG.items.map((a) => (
<div
key={a.title}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:bg-[var(--muted)] hover:-translate-y-0.5 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 text-2xl sm:mb-4 sm:text-3xl">{a.emoji}</div>
<h3 className="mb-2 font-semibold">{a.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{a.desc}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,31 @@
import { Section } from "../Section";
import { BENEFITS_CONFIG } from "@/config/course";
export function BenefitsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{BENEFITS_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{BENEFITS_CONFIG.subtitle}
</p>
<div className="space-y-4 sm:space-y-6">
{BENEFITS_CONFIG.items.map((b) => (
<div
key={b.num}
className="hover-shimmer flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:-translate-y-0.5 sm:flex-row sm:gap-6 sm:rounded-2xl sm:p-6"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent-muted)] text-base font-bold text-[var(--accent)] sm:h-12 sm:w-12 sm:text-lg">
{b.num}
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-1 font-semibold">{b.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{b.desc}</p>
</div>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { CTA_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
export function CTASection() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">
{CTA_CONFIG.title}
</h2>
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">
{CTA_CONFIG.subtitle}
</p>
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
{CTA_CONFIG.badges.map((b, i) => (
<span
key={b}
className="animate-float-badge rounded-full border border-[var(--border)] bg-[var(--card)] px-4 py-2"
style={{ animationDelay: `${i * 0.2}s` }}
>
{b}
</span>
))}
</div>
<Link
href={paid ? "/course/0/0" : "/pay"}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
{paid ? "开始学习 →" : "立即加入 →"}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Section } from "../Section";
import { CURRICULUM_CONFIG } from "@/config/course";
import { canWatchLesson } from "@/app/lib/payment";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
export function CurriculumSection() {
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
);
const [search, setSearch] = useState("");
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
const [completed, setCompleted] = useState<Set<string>>(new Set());
useEffect(() => {
setBookmarks(getBookmarks());
setCompleted(getCompletedLessons());
const onB = () => setBookmarks(getBookmarks());
const onP = () => setCompleted(getCompletedLessons());
window.addEventListener("learning:bookmarks", onB);
window.addEventListener("learning:progress", onP);
return () => {
window.removeEventListener("learning:bookmarks", onB);
window.removeEventListener("learning:progress", onP);
};
}, []);
const toggle = (i: number) => {
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
};
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
);
const filtered = search.trim()
? allLessons.filter(
(l) =>
l.name.toLowerCase().includes(search.toLowerCase()) ||
l.moduleTitle.toLowerCase().includes(search.toLowerCase())
)
: allLessons;
const filteredByModule = CURRICULUM_CONFIG.modules
.map((m, mi) => ({
...m,
lessons: filtered.filter((l) => l.moduleIndex === mi),
}))
.filter((m) => m.lessons.length > 0);
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{CURRICULUM_CONFIG.title}
</h2>
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
{CURRICULUM_CONFIG.subtitle}
</p>
<div className="mx-auto mb-8 max-w-md">
<input
type="search"
placeholder="搜索课程..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
</div>
<div className="space-y-2 md:space-y-3">
{filteredByModule.map((c) => {
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
const isOpen = expanded[moduleIndex] ?? true;
return (
<div
key={c.title}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
>
<button
type="button"
onClick={() => toggle(moduleIndex)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
>
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<span
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
>
▶
</span>
<span>{c.emoji}</span>
{c.title}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{c.lessons.length} 节
</span>
</button>
{isOpen && (
<ul className="border-t border-[var(--border)]">
{c.lessons.map((l) => {
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex);
const key = `${l.moduleIndex}-${l.lessonIndex}`;
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
const isCompleted = completed.has(key);
return (
<li key={key}>
<Link
href={`/course/${l.moduleIndex}/${l.lessonIndex}`}
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
>
<span className="flex items-center gap-2 truncate sm:gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleBookmark(l.moduleIndex, l.lessonIndex);
}}
className="shrink-0 text-xs"
aria-label={bookmarked ? "取消收藏" : "收藏"}
>
{bookmarked ? "⭐" : "☆"}
</button>
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
{l.name}
</span>
{free && (
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
试看
</span>
)}
{isCompleted && (
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
✓
</span>
)}
{!unlocked && (
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
🔒
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
{l.duration}
<span className="text-[var(--accent)]">→</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Section } from "../Section";
import { FAQ_CONFIG } from "@/config/course";
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<Section>
<h2 className="mb-10 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{FAQ_CONFIG.title}
</h2>
<div className="mx-auto max-w-2xl space-y-2">
{FAQ_CONFIG.items.map((item, i) => (
<div
key={i}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] transition"
>
<button
type="button"
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
>
<span className="font-medium">{item.q}</span>
<span
className={`shrink-0 text-[var(--muted-foreground)] transition-transform ${openIndex === i ? "rotate-180" : ""}`}
>
▼
</span>
</button>
{openIndex === i && (
<div className="border-t border-[var(--border)] px-4 py-3 text-sm text-[var(--muted-foreground)] sm:px-5">
{item.a}
</div>
)}
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,37 @@
import { Section } from "../Section";
import { INSTRUCTORS_CONFIG } from "@/config/course";
export function InstructorsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{INSTRUCTORS_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{INSTRUCTORS_CONFIG.subtitle}
</p>
<div className="grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{INSTRUCTORS_CONFIG.items.map((i) => (
<div
key={i.name}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--accent)]/30 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 flex justify-center sm:mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-muted)] text-xl font-bold text-[var(--accent)] sm:h-20 sm:w-20 sm:text-2xl">
{i.name.slice(0, 1)}
</div>
</div>
<h3 className="mb-1 font-semibold">{i.name}</h3>
<p className="mb-1 text-sm text-[var(--accent)]">{i.role}</p>
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
{i.desc}
</p>
<span className="inline-block rounded-full bg-[var(--accent-muted)] px-3 py-1 text-xs text-[var(--accent)]">
{i.tag}
</span>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState, useEffect } from "react";
import { getRating, setRating } from "@/app/lib/learning";
export function RatingSection() {
const [stars, setStars] = useState(0);
const [comment, setComment] = useState("");
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
const r = getRating();
if (r) {
setStars(r.stars);
setComment(r.comment);
setSubmitted(true);
}
}, []);
const handleSubmit = () => {
setRating(stars, comment);
setSubmitted(true);
};
return (
<section className="border-t border-[var(--border)] py-12 sm:py-16">
<div className="mx-auto max-w-2xl px-4 text-center sm:px-6">
<h2 className="mb-2 text-xl font-bold sm:text-2xl">课程评价</h2>
<p className="mb-6 text-sm text-[var(--muted-foreground)]">
你的反馈对我们很重要
</p>
{submitted ? (
<p className="text-[var(--muted-foreground)]">感谢你的评价 ✓</p>
) : (
<>
<div className="mb-4 flex justify-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setStars(n)}
className="text-2xl transition hover:scale-110"
>
{n <= stars ? "⭐" : "☆"}
</button>
))}
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="选填:一句话评价"
className="mb-4 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
rows={2}
/>
<button
type="button"
onClick={handleSubmit}
className="rounded-full bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:opacity-90"
>
提交评价
</button>
</>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/atom-one-dark.min.css";
import styles from "./lesson-markdown.module.css";
type TocItem = { level: number; text: string; id: string };
function extractToc(content: string): TocItem[] {
const items: TocItem[] = [];
const lines = content.split("\n");
let idCounter = 0;
for (const line of lines) {
const h1 = line.match(/^# (.+)$/);
const h2 = line.match(/^## (.+)$/);
if (h1) {
items.push({ level: 1, text: h1[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
} else if (h2) {
items.push({ level: 2, text: h2[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
}
}
return items;
}
function slugify(text: string): string {
return text
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
export function LessonMarkdown({ content }: { content: string }) {
const [readProgress, setReadProgress] = useState(0);
const [showBackTop, setShowBackTop] = useState(false);
const [showToc, setShowToc] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const toc = extractToc(content);
useEffect(() => {
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0);
setShowBackTop(scrollTop > 400);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const handleTocJump = useCallback((index: number) => {
setShowToc(false);
const root = contentRef.current;
if (!root) return;
const headings = root.querySelectorAll("h1, h2");
const el = headings[index] as HTMLElement;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<>
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
<div ref={contentRef} className={`${styles.markdownBody} markdown-body`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
components={{
h1: ({ children }) => {
const text = String(children);
const id = slugify(text);
return (
<h1 id={id} className="scroll-mt-24">
{children}
</h1>
);
},
h2: ({ children }) => {
const text = String(children);
const id = slugify(text);
return (
<h2 id={id} className="scroll-mt-24">
{children}
</h2>
);
},
h3: ({ children }) => <h3 className="scroll-mt-20">{children}</h3>,
h4: ({ children }) => <h4>{children}</h4>,
h5: ({ children }) => <h5>{children}</h5>,
h6: ({ children }) => <h6>{children}</h6>,
p: ({ children }) => <p>{children}</p>,
ul: ({ children }) => <ul>{children}</ul>,
ol: ({ children }) => <ol>{children}</ol>,
li: ({ children, className }) => (
<li className={className?.includes("task-list-item") ? "flex items-start gap-2" : ""}>
{children}
</li>
),
pre: ({ children }) => (
<pre className={styles.codeBlock}>{children}</pre>
),
code: ({ className, children }) => (
<code className={className}>{children}</code>
),
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
img: ({ src, alt }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table>{children}</table>
</div>
),
th: ({ children }) => <th>{children}</th>,
td: ({ children }) => <td>{children}</td>,
hr: () => <hr />,
strong: ({ children }) => <strong>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
}}
>
{content}
</ReactMarkdown>
</div>
{/* 目录抽屉 */}
<div
className={`${styles.drawerMask} ${showToc ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowToc(false)}
aria-hidden="true"
/>
<aside className={`${styles.drawer} ${showToc ? styles.drawerOpen : ""}`}>
<div className={styles.drawerTitle}>目录</div>
{toc.map((item, i) => (
<div
key={item.id}
role="button"
tabIndex={0}
className={item.level === 2 ? `${styles.drawerItem} ${styles.drawerItemH2}` : styles.drawerItem}
onClick={() => handleTocJump(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTocJump(i);
}}
>
{item.text}
</div>
))}
</aside>
{showBackTop && (
<>
{toc.length > 0 && (
<button
type="button"
className={styles.tocButton}
onClick={() => setShowToc(true)}
aria-label="打开目录"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<button
type="button"
className={styles.backToTop}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="回到顶部"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
</>
)}
</>
);
}

View File

@@ -0,0 +1,362 @@
/* 参考 digital ebook 样式,适配日间/夜间主题 */
.readingProgress {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent) 0%, #fbbf24 100%);
z-index: 100;
transition: width 0.15s linear;
border-radius: 0 3px 3px 0;
}
.backToTop {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 50;
color: var(--accent);
transition: transform 0.2s, box-shadow 0.2s;
}
.backToTop:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.tocButton {
position: fixed;
bottom: 24px;
right: 84px;
width: 48px;
height: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 50;
color: var(--accent);
transition: transform 0.2s, box-shadow 0.2s;
}
.tocButton:hover {
transform: translateY(-2px);
}
.drawerMask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 140;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.drawerMaskOpen {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
background: var(--background);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 150;
padding: 80px 0 24px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawerOpen {
transform: translateX(0);
}
.drawerTitle {
padding: 0 20px 12px;
font-size: 14px;
font-weight: 600;
color: var(--muted-foreground);
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.drawerItem {
padding: 10px 20px;
font-size: 14px;
color: var(--foreground);
cursor: pointer;
transition: background 0.15s;
}
.drawerItem:hover {
background: var(--muted);
}
.drawerItemH2 {
padding-left: 28px;
font-size: 13px;
color: var(--muted-foreground);
}
/* Markdown 正文样式 */
.markdownBody {
font-size: 16px;
line-height: 1.85;
color: var(--foreground);
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdownBody h1 {
font-size: 1.5rem;
font-weight: 800;
color: var(--foreground);
margin: 28px 0 12px;
padding-bottom: 0;
border-bottom: none;
line-height: 1.3;
}
.markdownBody h1:first-child {
margin-top: 0;
}
.markdownBody h1::after {
content: "";
display: block;
width: 40px;
height: 3px;
background: linear-gradient(90deg, var(--accent), #fbbf24);
border-radius: 3px;
margin-top: 8px;
}
.markdownBody h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--foreground);
margin: 24px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
line-height: 1.35;
}
.markdownBody h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--foreground);
margin: 20px 0 8px;
}
.markdownBody h4,
.markdownBody h5,
.markdownBody h6 {
font-size: 1rem;
font-weight: 600;
color: var(--foreground);
margin: 16px 0 6px;
}
.markdownBody p {
font-size: 1rem;
margin-bottom: 14px;
line-height: 1.9;
}
.markdownBody blockquote {
position: relative;
margin: 24px 0;
padding: 16px 20px 16px 24px;
border-left: none;
background: var(--muted);
border-radius: 0 12px 12px 0;
color: var(--muted-foreground);
}
.markdownBody blockquote::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
/* 代码块 - highlight.js 主题已通过 atom-one-dark 提供语法高亮 */
.markdownBody pre.codeBlock {
margin: 24px 0;
padding: 0;
overflow-x: auto;
border-radius: 8px;
background: transparent;
}
.markdownBody pre.codeBlock code {
display: block;
padding: 16px;
font-size: 0.875em;
line-height: 1.6;
}
/* 行内代码 - 与代码块区分 */
.markdownBody p code,
.markdownBody li code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
background: var(--muted);
color: var(--foreground);
border-radius: 6px;
}
.light .markdownBody p code,
.light .markdownBody li code {
background: rgba(0, 0, 0, 0.06);
color: #c41e3a;
}
.markdownBody a {
color: var(--accent);
text-decoration: none;
}
.markdownBody a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.markdownBody img {
display: block;
margin: 28px auto;
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.markdownBody img:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.markdownBody ul,
.markdownBody ol {
margin: 16px 0;
padding-left: 28px;
}
.markdownBody li {
margin-bottom: 6px;
}
.markdownBody li > ul,
.markdownBody li > ol {
margin-top: 6px;
margin-bottom: 0;
}
.markdownBody table {
width: 100%;
margin: 24px 0;
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
}
.markdownBody th {
background: var(--muted);
font-weight: 600;
padding: 12px 16px;
text-align: left;
border: 1px solid var(--border);
}
.markdownBody td {
padding: 12px 16px;
border: 1px solid var(--border);
}
.markdownBody tr:nth-child(even) {
background: var(--muted);
}
.markdownBody hr {
border: none;
height: 1px;
background: var(--border);
margin: 28px 0;
}
.markdownBody strong {
font-weight: 700;
}
.markdownBody input[type="checkbox"] {
margin-right: 8px;
}
/* 响应式 */
@media (min-width: 768px) {
.markdownBody {
font-size: 17px;
line-height: 1.8;
}
.markdownBody h1 {
font-size: 1.75rem;
margin: 32px 0 14px;
}
.markdownBody h1::after {
width: 50px;
margin-top: 10px;
}
.markdownBody h2 {
font-size: 1.35rem;
margin: 28px 0 12px;
}
.markdownBody h3 {
font-size: 1.2rem;
margin: 24px 0 10px;
}
.markdownBody p {
margin-bottom: 16px;
}
.backToTop,
.tocButton {
bottom: 32px;
right: 32px;
}
.tocButton {
right: 92px;
}
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Header } from "@/app/components";
import { VideoPlayer } from "@/app/components/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "@/config/lessons";
import { canWatchLesson } from "@/app/lib/payment";
import { setLastWatched, markCompleted } from "@/app/lib/learning";
import { DOWNLOAD_CONFIG } from "@/config/course";
export default function LessonPage() {
const params = useParams();
const router = useRouter();
const moduleIndex = Number(params.moduleIndex);
const lessonIndex = Number(params.lessonIndex);
const lesson = getLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
useEffect(() => {
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<main className="pt-20 pb-16">
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<h1 className="mb-4 text-2xl font-bold">章节不存在</h1>
<p className="mb-6 text-[var(--muted-foreground)]">
请检查链接或返回课程首页
</p>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
← 返回首页
</Link>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="pt-14 pb-16 md:pt-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 返回导航 */}
<nav className="mb-6 flex items-center gap-2 text-sm">
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
返回
</button>
<span className="text-[var(--muted-foreground)]">/</span>
<Link href="/" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
课程首页
</Link>
</nav>
{/* 标题 */}
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
{lesson.name}
{isFreeTrial && (
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
试看
</span>
)}
</h1>
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
时长 {lesson.duration}
</p>
{/* 视频播放器 / 锁定 overlay */}
<div className="relative mb-12">
{unlocked ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`course-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
/>
) : (
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<p className="text-lg text-white/90">报名解锁本章节</p>
<Link
href="/pay"
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
立即报名
</Link>
</div>
</div>
)}
</div>
{/* 配套文档 - 仅解锁后显示 */}
{unlocked && (
<div className="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
配套文档
</h2>
<LessonMarkdown content={lesson.markdown} />
</div>
)}
{/* 网盘下载 - 可配置开关 */}
{DOWNLOAD_CONFIG.enabled && unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]">
课程配套资料已上传至网盘,报名后可前往下载
</p>
<Link
href={DOWNLOAD_CONFIG.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
{DOWNLOAD_CONFIG.buttonText}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</div>
)}
</div>
</main>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

111
app/globals.css Normal file
View File

@@ -0,0 +1,111 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "animate.css";
/* 主题变量 - 日间/夜间模式 */
:root,
.light {
--background: #fafafa;
--foreground: #171717;
--card: #ffffff;
--card-foreground: #171717;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--border: #e4e4e7;
--accent: #f59e0b;
--accent-foreground: #1c1917;
--accent-muted: rgba(245, 158, 11, 0.2);
--gradient-accent: rgba(251, 191, 36, 0.15);
}
.dark {
--background: #0c0c0f;
--foreground: #fafafa;
--card: rgba(255, 255, 255, 0.05);
--card-foreground: #fafafa;
--muted: rgba(255, 255, 255, 0.05);
--muted-foreground: #a1a1aa;
--border: rgba(255, 255, 255, 0.08);
--accent: #f59e0b;
--accent-foreground: #1c1917;
--accent-muted: rgba(245, 158, 11, 0.2);
--gradient-accent: rgba(251, 191, 36, 0.15);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-noto-sans-sc), var(--font-geist-sans), system-ui,
sans-serif;
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* 滚动入场动画 */
.animate-on-scroll {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.animate-on-scroll-visible {
opacity: 1;
transform: translateY(0);
}
/* 渐变背景动画 */
@keyframes gradient-shift {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
.animate-gradient {
animation: gradient-shift 8s ease-in-out infinite;
}
/* 卡片悬停光效 */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.hover-shimmer {
position: relative;
overflow: hidden;
}
.hover-shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
background-size: 200% 100%;
opacity: 0;
transition: opacity 0.3s;
}
.hover-shimmer:hover::after {
opacity: 1;
animation: shimmer 1.5s ease-in-out;
}
/* CTA 徽章轻微浮动 */
@keyframes float-badge {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
.animate-float-badge {
animation: float-badge 3s ease-in-out infinite;
}

47
app/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Noto_Sans_SC } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "./providers/ThemeProvider";
import { themeInitScript } from "./theme-init";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const notoSansSC = Noto_Sans_SC({
variable: "--font-noto-sans-sc",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "数字游民实战课 | 从朝九晚五到边旅行边赚钱",
description:
"数字游民实战课,21节课程带你掌握远程变现、被动收入、全球旅居,异度世界、大师兄、小美联合授课",
manifest: "/manifest.json",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} antialiased font-sans`}
>
<script
dangerouslySetInnerHTML={{ __html: themeInitScript }}
/>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

5
app/lib/env.ts Normal file
View File

@@ -0,0 +1,5 @@
const isDev = process.env.NODE_ENV === "development";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ||
(isDev ? "http://localhost:3000" : "https://example.com");

141
app/lib/learning.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* 学习进度、继续学习、收藏、笔记 - localStorage
*/
const PREFIX = "lms-";
const KEY_LAST = `${PREFIX}last`;
const KEY_COMPLETED = `${PREFIX}completed`;
const KEY_BOOKMARKS = `${PREFIX}bookmarks`;
const KEY_NOTES = `${PREFIX}notes`;
const KEY_RATING = `${PREFIX}rating`;
export type LastWatched = { moduleIndex: number; lessonIndex: number; name: string };
export function getLastWatched(): LastWatched | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(KEY_LAST);
if (!raw) return null;
const d = JSON.parse(raw);
if (typeof d?.moduleIndex !== "number" || typeof d?.lessonIndex !== "number") return null;
return { moduleIndex: d.moduleIndex, lessonIndex: d.lessonIndex, name: d.name || "" };
} catch {
return null;
}
}
export function setLastWatched(m: number, l: number, name: string): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(KEY_LAST, JSON.stringify({ moduleIndex: m, lessonIndex: l, name }));
} catch {
/* ignore */
}
}
export function getCompletedLessons(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = localStorage.getItem(KEY_COMPLETED);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
export function markCompleted(moduleIndex: number, lessonIndex: number): void {
if (typeof window === "undefined") return;
try {
const set = getCompletedLessons();
set.add(`${moduleIndex}-${lessonIndex}`);
localStorage.setItem(KEY_COMPLETED, JSON.stringify([...set]));
window.dispatchEvent(new CustomEvent("learning:progress"));
} catch {
/* ignore */
}
}
export function getProgress(totalLessons: number): { completed: number; percent: number } {
const set = getCompletedLessons();
const completed = set.size;
return { completed, percent: totalLessons > 0 ? Math.round((completed / totalLessons) * 100) : 0 };
}
export function getBookmarks(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = localStorage.getItem(KEY_BOOKMARKS);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
export function toggleBookmark(moduleIndex: number, lessonIndex: number): boolean {
if (typeof window === "undefined") return false;
try {
const set = getBookmarks();
const key = `${moduleIndex}-${lessonIndex}`;
if (set.has(key)) set.delete(key);
else set.add(key);
localStorage.setItem(KEY_BOOKMARKS, JSON.stringify([...set]));
window.dispatchEvent(new CustomEvent("learning:bookmarks"));
return set.has(key);
} catch {
return false;
}
}
export function isBookmarked(moduleIndex: number, lessonIndex: number): boolean {
return getBookmarks().has(`${moduleIndex}-${lessonIndex}`);
}
export function getNote(moduleIndex: number, lessonIndex: number): string {
if (typeof window === "undefined") return "";
try {
const raw = localStorage.getItem(KEY_NOTES);
if (!raw) return "";
const map = JSON.parse(raw);
return map[`${moduleIndex}-${lessonIndex}`] ?? "";
} catch {
return "";
}
}
export function setNote(moduleIndex: number, lessonIndex: number, text: string): void {
if (typeof window === "undefined") return;
try {
const raw = localStorage.getItem(KEY_NOTES);
const map = raw ? JSON.parse(raw) : {};
map[`${moduleIndex}-${lessonIndex}`] = text;
localStorage.setItem(KEY_NOTES, JSON.stringify(map));
} catch {
/* ignore */
}
}
export function getRating(): { stars: number; comment: string } | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(KEY_RATING);
if (!raw) return null;
const d = JSON.parse(raw);
return { stars: d?.stars ?? 0, comment: d?.comment ?? "" };
} catch {
return null;
}
}
export function setRating(stars: number, comment: string): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(KEY_RATING, JSON.stringify({ stars, comment }));
window.dispatchEvent(new CustomEvent("learning:rating"));
} catch {
/* ignore */
}
}

20
app/lib/payment.ts Normal file
View File

@@ -0,0 +1,20 @@
/** 支付状态 localStorage key */
export const PAY_STORAGE_KEY = "pay";
/** 是否已支付(含测试标记 pay=ok) */
export function isPaid(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(PAY_STORAGE_KEY) === "ok";
}
/** 前 2 章为试看,无需支付 */
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
if (moduleIndex === 0 && lessonIndex < 2) return true;
return false;
}
/** 章节是否可观看 */
export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean {
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
return isPaid();
}

44
app/page.tsx Normal file
View File

@@ -0,0 +1,44 @@
"use client";
import {
Header,
Hero,
Footer,
AudienceSection,
BenefitsSection,
CurriculumSection,
InstructorsSection,
FAQSection,
CTASection,
AnimateOnScroll,
} from "./components";
export default function Home() {
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main>
<Hero />
<AnimateOnScroll delay={0}>
<AudienceSection />
</AnimateOnScroll>
<AnimateOnScroll delay={100}>
<BenefitsSection />
</AnimateOnScroll>
<AnimateOnScroll delay={0}>
<CurriculumSection />
</AnimateOnScroll>
<AnimateOnScroll delay={100}>
<InstructorsSection />
</AnimateOnScroll>
<AnimateOnScroll delay={0}>
<FAQSection />
</AnimateOnScroll>
<AnimateOnScroll delay={100}>
<CTASection />
</AnimateOnScroll>
</main>
<Footer />
</div>
);
}

60
app/pay/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
"use client";
import { Suspense, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Header } from "@/app/components";
import { PayButton } from "@/app/components/PayButton";
function PayContent() {
const searchParams = useSearchParams();
useEffect(() => {
if (typeof window !== "undefined" && searchParams.get("test") === "ok") {
localStorage.setItem("pay", "ok");
window.location.href = "/pay/paid";
}
}, [searchParams]);
if (searchParams.get("test") === "ok") {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-[var(--muted-foreground)]">正在跳转...</p>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="flex min-h-[80vh] flex-col items-center justify-center px-4">
<h1 className="mb-2 text-2xl font-bold">报名数字游民实战课</h1>
<p className="mb-6 text-[var(--muted-foreground)]">
¥0.80 加入课程,解锁全部 21 节视频
</p>
<div className="mb-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
<PayButton size="lg" channel="alipay">支付宝支付</PayButton>
<PayButton size="lg" channel="wxpay">微信支付</PayButton>
</div>
<p className="mb-4 text-xs text-[var(--muted-foreground)]">
测试:<Link href="/pay?test=ok" className="text-[var(--accent)] underline">点击模拟支付成功</Link>
</p>
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
返回首页
</Link>
</main>
</div>
);
}
export default function PayPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
<p className="text-[var(--muted-foreground)]">加载中...</p>
</div>
}>
<PayContent />
</Suspense>
);
}

37
app/pay/paid/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Header } from "@/app/components";
const PAY_STORAGE_KEY = "pay";
export default function PayPaidPage() {
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem(PAY_STORAGE_KEY, "ok");
window.dispatchEvent(new CustomEvent("pay:updated"));
}
}, []);
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="flex min-h-[80vh] flex-col items-center justify-center px-4">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20 text-4xl">
✓
</div>
<h1 className="mb-2 text-2xl font-bold">支付成功</h1>
<p className="mb-8 text-[var(--muted-foreground)]">
已解锁全部课程视频,开始学习吧
</p>
<Link
href="/"
className="rounded-full bg-[var(--accent)] px-8 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
返回课程首页
</Link>
</main>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
export type ThemeMode = "light" | "dark" | "system";
type ThemeContextValue = {
theme: ThemeMode;
resolvedTheme: "light" | "dark";
setTheme: (theme: ThemeMode) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = "lms-theme";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function getStoredTheme(): ThemeMode {
if (typeof window === "undefined") return "system";
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || "system";
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>("system");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
const applyTheme = useCallback((mode: "light" | "dark") => {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(mode);
setResolvedTheme(mode);
}, []);
const setTheme = useCallback(
(newTheme: ThemeMode) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
const resolved =
newTheme === "system" ? getSystemTheme() : newTheme;
applyTheme(resolved);
},
[applyTheme]
);
useEffect(() => {
const stored = getStoredTheme();
setThemeState(stored);
const resolved = stored === "system" ? getSystemTheme() : stored;
applyTheme(resolved);
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
if (getStoredTheme() === "system") {
applyTheme(media.matches ? "dark" : "light");
}
};
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, [applyTheme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

11
app/theme-init.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* 主题初始化脚本 - 在 React 水合前执行,避免主题闪烁
* 通过 suppressHydrationWarning 在 html 上避免服务端/客户端 class 不一致警告
*/
export const themeInitScript = `
(function() {
var theme = localStorage.getItem('lms-theme');
var resolved = theme === 'light' ? 'light' : (theme === 'dark' ? 'dark' : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
document.documentElement.classList.add(resolved);
})();
`;