'init'
This commit is contained in:
227
app/api/pay/route.ts
Normal file
227
app/api/pay/route.ts
Normal 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, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ordId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
app/api/pay/status/route.ts
Normal file
15
app/api/pay/status/route.ts
Normal 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
82
app/certificate/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/components/AnimateOnScroll.tsx
Normal file
22
app/components/AnimateOnScroll.tsx
Normal 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
43
app/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
app/components/CommunityButton.tsx
Normal file
135
app/components/CommunityButton.tsx
Normal 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
25
app/components/Footer.tsx
Normal 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
45
app/components/Header.tsx
Normal 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
111
app/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
app/components/LessonNotes.tsx
Normal file
40
app/components/LessonNotes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
app/components/MarkdownContent.tsx
Normal file
89
app/components/MarkdownContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/components/ParticleBackground.tsx
Normal file
55
app/components/ParticleBackground.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
app/components/PayButton.tsx
Normal file
59
app/components/PayButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/components/Section.tsx
Normal file
17
app/components/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
app/components/ShareButton.tsx
Normal file
379
app/components/ShareButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
app/components/ThemeToggle.tsx
Normal file
36
app/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
app/components/VideoPlayer.tsx
Normal file
369
app/components/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/components/custom/CustomAnimateOnScroll.tsx
Normal file
43
app/components/custom/CustomAnimateOnScroll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
app/components/custom/CustomParticleBackground.tsx
Normal file
117
app/components/custom/CustomParticleBackground.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
app/components/custom/index.ts
Normal file
2
app/components/custom/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CustomParticleBackground } from "./CustomParticleBackground";
|
||||
export { CustomAnimateOnScroll } from "./CustomAnimateOnScroll";
|
||||
15
app/components/index.ts
Normal file
15
app/components/index.ts
Normal 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";
|
||||
27
app/components/sections/AudienceSection.tsx
Normal file
27
app/components/sections/AudienceSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/components/sections/BenefitsSection.tsx
Normal file
31
app/components/sections/BenefitsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
app/components/sections/CTASection.tsx
Normal file
46
app/components/sections/CTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
app/components/sections/CurriculumSection.tsx
Normal file
159
app/components/sections/CurriculumSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/components/sections/FAQSection.tsx
Normal file
43
app/components/sections/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/sections/InstructorsSection.tsx
Normal file
37
app/components/sections/InstructorsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/components/sections/RatingSection.tsx
Normal file
67
app/components/sections/RatingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
app/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
Normal file
195
app/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
154
app/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
154
app/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
111
app/globals.css
Normal file
111
app/globals.css
Normal 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
47
app/layout.tsx
Normal 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
5
app/lib/env.ts
Normal 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
141
app/lib/learning.ts
Normal 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
20
app/lib/payment.ts
Normal 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
44
app/page.tsx
Normal 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
60
app/pay/page.tsx
Normal 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
37
app/pay/paid/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
app/providers/ThemeProvider.tsx
Normal file
84
app/providers/ThemeProvider.tsx
Normal 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
11
app/theme-init.ts
Normal 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);
|
||||
})();
|
||||
`;
|
||||
Reference in New Issue
Block a user