This commit is contained in:
eric
2026-03-08 01:10:10 -06:00
parent 0ef4d51406
commit f589b71b19
14 changed files with 2484 additions and 152 deletions

View File

@@ -1,6 +1,21 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { VideoCard } from "./VideoCard";
import { useVideoProgress } from "./useVideoProgress";
import AuthModal from "../components/AuthModal";
function getStoredUser(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem("pb_user");
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
/* ─── data ─── */
@@ -61,68 +76,58 @@ const benefits = [
},
];
// 示例视频w3schools 公开样本,可替换为 MinIO CDN URL如 https://minioweb.hackrobot.cn/hackrobot/course/xxx.mp4
const SAMPLE_VIDEO = "https://www.w3schools.com/html/mov_bbb.mp4";
// 一级part 二级section 三级lesson
const curriculum = [
{
emoji: "🚀",
chapter: "基础篇 · 认识数字游民",
lessons: [
{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30" },
{ id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45" },
part: "基础篇",
sections: [
{ name: "认识数字游民", lessons: [{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30", videoUrl: SAMPLE_VIDEO }, { id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "💡",
chapter: "技能篇 · 远程工作能力",
lessons: [
{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20" },
{ id: "04", title: "30天技能升级路线从评估到接单", duration: "11:15" },
{ id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40" },
part: "技能篇",
sections: [
{ name: "远程工作能力", lessons: [{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20", videoUrl: SAMPLE_VIDEO }, { id: "04", title: "30天技能升级路线从评估到接单", duration: "11:15", videoUrl: SAMPLE_VIDEO }, { id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "💰",
chapter: "收入篇 · 构建收入体系",
lessons: [
{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05" },
{ id: "07", title: "自由职业平台实操Upwork / Toptal / 电鸭", duration: "13:50" },
{ id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20" },
{ id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30" },
part: "收入篇",
sections: [
{ name: "构建收入体系", lessons: [{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05", videoUrl: SAMPLE_VIDEO }, { id: "07", title: "自由职业平台实操Upwork / Toptal / 电鸭", duration: "13:50", videoUrl: SAMPLE_VIDEO }, { id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20", videoUrl: SAMPLE_VIDEO }, { id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🛠️",
chapter: "工具篇 · 移动办公装备",
lessons: [
{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45" },
{ id: "11", title: "远程协作工具栈Slack / Notion / Linear", duration: "08:30" },
{ id: "12", title: "安全必修课VPN / 密码管理 / 2FA", duration: "05:55" },
{ id: "13", title: "跨境财务工具Wise / Stripe / Revolut", duration: "07:10" },
part: "工具篇",
sections: [
{ name: "移动办公装备", lessons: [{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45", videoUrl: SAMPLE_VIDEO }, { id: "11", title: "远程协作工具栈Slack / Notion / Linear", duration: "08:30", videoUrl: SAMPLE_VIDEO }, { id: "12", title: "安全必修课VPN / 密码管理 / 2FA", duration: "05:55", videoUrl: SAMPLE_VIDEO }, { id: "13", title: "跨境财务工具Wise / Stripe / Revolut", duration: "07:10", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "📋",
chapter: "合规篇 · 签证税务保险",
lessons: [
{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30" },
{ id: "15", title: "税务居民身份与合规规划", duration: "11:40" },
{ id: "16", title: "数字游民保险选购指南", duration: "06:20" },
part: "合规篇",
sections: [
{ name: "签证税务保险", lessons: [{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30", videoUrl: SAMPLE_VIDEO }, { id: "15", title: "税务居民身份与合规规划", duration: "11:40", videoUrl: SAMPLE_VIDEO }, { id: "16", title: "数字游民保险选购指南", duration: "06:20", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🗺️",
chapter: "目的地篇 · 选择你的城市",
lessons: [
{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50" },
{ id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25" },
{ id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15" },
part: "目的地篇",
sections: [
{ name: "选择你的城市", lessons: [{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50", videoUrl: SAMPLE_VIDEO }, { id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25", videoUrl: SAMPLE_VIDEO }, { id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🏆",
chapter: "实战篇 · 启程出发",
lessons: [
{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40" },
{ id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10" },
part: "实战篇",
sections: [
{ name: "启程出发", lessons: [{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40", videoUrl: SAMPLE_VIDEO }, { id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10", videoUrl: SAMPLE_VIDEO }] },
],
},
];
@@ -155,22 +160,58 @@ const instructors = [
export default function CoursePage() {
const [modalOpen, setModalOpen] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [expandedPart, setExpandedPart] = useState<string | null>(null);
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
useEffect(() => {
setUserEmail(getStoredUser());
}, []);
const handleLogout = () => {
localStorage.removeItem("pb_token");
localStorage.removeItem("pb_user");
setUserEmail(null);
};
return (
<div className="min-h-screen bg-white text-slate-900">
{/* ── Nav ── */}
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4 sm:px-6">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3 px-4 sm:px-6">
<a href="/" className="flex items-center gap-2 text-sm font-bold">
<span className="text-lg">🌍</span>
</a>
<button
onClick={() => setModalOpen(true)}
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
>
</button>
<div className="flex items-center gap-2 sm:gap-3">
{userEmail ? (
<>
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 sm:inline">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
退
</button>
</>
) : (
<button
onClick={() => setAuthOpen(true)}
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
/
</button>
)}
<button
onClick={() => setModalOpen(true)}
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
>
</button>
</div>
</div>
</header>
@@ -264,55 +305,133 @@ export default function CoursePage() {
</div>
</section>
{/* ── Curriculum ── */}
<section className="py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
21
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
{/* ── Curriculum ── 参考:垂直时间线 + 学习路径视觉 */}
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/50 to-slate-50 py-24 sm:py-28">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 标题区:大数字 + 学习路径感 */}
<div className="mb-16 text-center">
<div className="inline-flex items-baseline gap-2">
<span className="gradient-text text-6xl font-extrabold tabular-nums tracking-tight sm:text-7xl">
21
</span>
<span className="text-2xl font-bold text-slate-800 sm:text-3xl">
</span>
</div>
<p className="mt-4 text-slate-500">
·
</p>
<div className="mx-auto mt-6 h-px w-24 rounded-full bg-gradient-to-r from-transparent via-sky-300/60 to-transparent" />
</div>
<div className="mt-14 space-y-6">
{curriculum.map((ch) => (
<div
key={ch.chapter}
className="overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm"
>
{/* Chapter header */}
<div className="flex items-center gap-3 border-b border-slate-100 bg-slate-50/80 px-6 py-4">
<span className="text-xl">{ch.emoji}</span>
<h3 className="font-bold text-slate-800">{ch.chapter}</h3>
</div>
{/* 垂直时间线:左侧竖线 + 节点 */}
<div className="relative">
{/* 中央竖线 */}
<div
className="absolute left-[19px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-sky-200/80 via-sky-300/60 to-sky-200/80 sm:left-[23px]"
aria-hidden
/>
{/* Lessons */}
<ul>
{ch.lessons.map((lesson, i) => (
<li
key={lesson.id}
className={`flex items-center justify-between px-6 py-3.5 transition-colors hover:bg-sky-50/40 ${
i < ch.lessons.length - 1
? "border-b border-slate-50"
: ""
}`}
>
<div className="flex items-center gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-xs font-bold text-slate-500">
{lesson.id}
</span>
<span className="text-sm font-medium text-slate-700 sm:text-base">
{lesson.title}
</span>
<div className="space-y-0">
{curriculum.map((ch, idx) => {
const allLessons = ch.sections.flatMap((s) => s.lessons);
const partCompleted = isPartCompleted(allLessons.map((l) => l.id));
const isExpanded = expandedPart === ch.part;
const totalLessons = allLessons.length;
return (
<div key={ch.part} className="relative flex gap-6 pb-10 last:pb-0">
{/* 左侧节点圆:完成时显示 ✓ */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold shadow-sm transition-colors sm:h-12 sm:w-12 ${
partCompleted
? "border-2 border-emerald-300 bg-emerald-50 text-emerald-600"
: "border-2 border-sky-200 bg-white text-sky-600"
}`}
>
{partCompleted ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
idx + 1
)}
</div>
<span className="ml-4 shrink-0 text-xs tabular-nums text-slate-400">
{lesson.duration}
</span>
</li>
))}
</ul>
</div>
))}
</div>
{/* 右侧内容卡片 */}
<div className="min-w-0 flex-1">
<div
className={`overflow-hidden rounded-2xl border shadow-sm transition-colors ${
partCompleted ? "border-emerald-200/80 bg-emerald-50/30" : "border-slate-200/80 bg-white"
}`}
>
{/* 一级Part 标题,点击展开/收起 */}
<button
type="button"
onClick={() => setExpandedPart(isExpanded ? null : ch.part)}
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition-colors hover:bg-slate-50/80 sm:px-6"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{ch.emoji}</span>
<h3 className="font-bold text-slate-800">{ch.part}</h3>
{partCompleted && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
</span>
)}
</div>
<span className="flex items-center gap-2 text-sm text-slate-400">
<span>{totalLessons} </span>
<svg
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{/* 展开后:二级 section + 三级 lesson */}
{isExpanded && (
<div className="border-t border-slate-100 bg-slate-50/40">
{ch.sections.map((sec) => (
<div key={sec.name} className="border-b border-slate-100 last:border-b-0">
{/* 二级标题 */}
<div className="flex items-center gap-3 border-b border-slate-100/80 bg-slate-50/60 px-5 py-3.5 sm:px-6">
<span className="flex h-6 w-1 shrink-0 rounded-full bg-gradient-to-b from-sky-400 to-sky-500" />
<h4 className="text-sm font-semibold text-slate-700">
{sec.name}
</h4>
<span className="rounded-full bg-slate-200/60 px-2 py-0.5 text-xs font-medium text-slate-500">
{sec.lessons.length}
</span>
</div>
{/* 三级:视频卡片网格 */}
<div className="grid gap-5 p-5 sm:grid-cols-2 sm:p-6 lg:grid-cols-3">
{sec.lessons.map((lesson) => (
<VideoCard
key={lesson.id}
lesson={lesson as { id: string; title: string; duration: string; videoUrl?: string }}
completed={isCompleted(lesson.id)}
onComplete={() => markCompleted(lesson.id)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
@@ -391,7 +510,13 @@ export default function CoursePage() {
·
</footer>
{/* ── Modal ── */}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
/>
{/* ── 报名 Modal ── */}
{modalOpen && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"