From 867d53936cac05c60f54513fd58c840221c762a6 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 11 Mar 2026 22:26:12 -0500 Subject: [PATCH] =?UTF-8?q?'=E4=BC=98=E5=8C=96=E8=AF=BE=E7=A8=8B'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[locale]/course/VideoCard.tsx | 76 --- .../[lessonIndex]/LessonMarkdown.tsx | 194 ++++++ .../[lessonIndex]/lesson-markdown.module.css | 349 +++++++++++ .../[moduleIndex]/[lessonIndex]/page.tsx | 154 +++++ app/[locale]/course/page.tsx | 573 +----------------- app/[locale]/course/useVideoProgress.ts | 56 -- app/[locale]/course/videoPoster.ts | 184 ------ app/api/pay/status/route.ts | 10 +- app/components/Tools.tsx | 16 +- app/components/course/AudienceSection.tsx | 27 + app/components/course/BenefitsSection.tsx | 31 + app/components/course/CTASection.tsx | 46 ++ app/components/course/CourseHero.tsx | 89 +++ app/components/course/CurriculumSection.tsx | 159 +++++ app/components/course/FAQSection.tsx | 43 ++ app/components/course/InstructorsSection.tsx | 37 ++ app/components/course/Section.tsx | 17 + app/components/course/VideoPlayer.tsx | 362 +++++++++++ app/components/course/index.ts | 9 + app/globals.css | 72 ++- app/lib/course-payment.ts | 31 + app/lib/learning.ts | 141 +++++ config/course.ts | 209 +++++++ config/lessons.ts | 112 ++++ package.json | 9 +- pnpm-lock.yaml | 57 ++ 26 files changed, 2178 insertions(+), 885 deletions(-) delete mode 100644 app/[locale]/course/VideoCard.tsx create mode 100644 app/[locale]/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx create mode 100644 app/[locale]/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css create mode 100644 app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx delete mode 100644 app/[locale]/course/useVideoProgress.ts delete mode 100644 app/[locale]/course/videoPoster.ts create mode 100644 app/components/course/AudienceSection.tsx create mode 100644 app/components/course/BenefitsSection.tsx create mode 100644 app/components/course/CTASection.tsx create mode 100644 app/components/course/CourseHero.tsx create mode 100644 app/components/course/CurriculumSection.tsx create mode 100644 app/components/course/FAQSection.tsx create mode 100644 app/components/course/InstructorsSection.tsx create mode 100644 app/components/course/Section.tsx create mode 100644 app/components/course/VideoPlayer.tsx create mode 100644 app/components/course/index.ts create mode 100644 app/lib/course-payment.ts create mode 100644 app/lib/learning.ts create mode 100644 config/course.ts create mode 100644 config/lessons.ts diff --git a/app/[locale]/course/VideoCard.tsx b/app/[locale]/course/VideoCard.tsx deleted file mode 100644 index 3960930..0000000 --- a/app/[locale]/course/VideoCard.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { generateVideoPoster } from "./videoPoster"; - -type Lesson = { - id: string; - title: string; - duration: string; - videoUrl?: string; -}; - -type Props = { - lesson: Lesson; - completed?: boolean; - onComplete?: () => void; -}; - -export function VideoCard({ lesson, completed = false, onComplete }: Props) { - const [poster, setPoster] = useState(null); - - useEffect(() => { - setPoster(generateVideoPoster(lesson.title, lesson.id)); - }, [lesson.title, lesson.id]); - - const handleEnded = useCallback(() => { - onComplete?.(); - }, [onComplete]); - - return ( -
- {/* 三级标题:视频上方,突出显示 */} -
- - {lesson.id} - -

- {lesson.title} -

-
-
- - - {lesson.duration} - - {completed && ( - - - - - - )} -
-
- ); -} diff --git a/app/[locale]/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx b/app/[locale]/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx new file mode 100644 index 0000000..2d9ee72 --- /dev/null +++ b/app/[locale]/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx @@ -0,0 +1,194 @@ +"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(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 ( + <> +
+ +
+ { + const text = String(children); + const id = slugify(text); + return ( +

+ {children} +

+ ); + }, + h2: ({ children }) => { + const text = String(children); + const id = slugify(text); + return ( +

+ {children} +

+ ); + }, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>
{children}
, + h6: ({ children }) =>
{children}
, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children, className }) => ( +
  • + {children} +
  • + ), + pre: ({ children }) => ( +
    {children}
    + ), + code: ({ className, children }) => ( + {children} + ), + blockquote: ({ children }) =>
    {children}
    , + a: ({ href, children }) => ( + + {children} + + ), + img: ({ src, alt }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ), + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => {children}, + td: ({ children }) => {children}, + hr: () =>
    , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + }} + > + {content} +
    +
    + +
    setShowToc(false)} + aria-hidden="true" + /> + + + {showBackTop && ( + <> + {toc.length > 0 && ( + + )} + + + )} + + ); +} diff --git a/app/[locale]/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css b/app/[locale]/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css new file mode 100644 index 0000000..2909096 --- /dev/null +++ b/app/[locale]/course/[moduleIndex]/[lessonIndex]/lesson-markdown.module.css @@ -0,0 +1,349 @@ +/* 参考 nomadlms,适配日间/夜间主题 */ + +.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); +} + +.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; +} + +.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; +} + +.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 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; + } +} diff --git a/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx new file mode 100644 index 0000000..926480a --- /dev/null +++ b/app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Link } from "@/i18n/navigation"; +import { VideoPlayer } from "@/app/components/course/VideoPlayer"; +import { LessonMarkdown } from "./LessonMarkdown"; +import { getLesson } from "@/config/lessons"; +import { canWatchLesson } from "@/app/lib/course-payment"; +import { setLastWatched, markCompleted } from "@/app/lib/learning"; +import { DOWNLOAD_CONFIG } from "@/config/course"; +import Header from "@/app/components/Header"; + +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 ( +
    +
    +
    +
    +

    章节不存在

    +

    + 请检查链接或返回课程首页 +

    + + ← 返回首页 + +
    +
    +
    + ); + } + + return ( +
    +
    +
    +
    + {/* 返回导航 */} + + + {/* 标题 */} +

    + {lesson.name} + {isFreeTrial && ( + + 试看 + + )} +

    +

    + 时长 {lesson.duration} +

    + + {/* 视频播放器 / 锁定 overlay */} +
    + {unlocked ? ( + markCompleted(moduleIndex, lessonIndex)} + /> + ) : ( +
    +
    + 🔒 +

    报名解锁本章节

    + + 立即报名 + +
    +
    + )} +
    + + {/* 配套文档 - 仅解锁后显示 */} + {unlocked && ( +
    +

    + + + + 配套文档 +

    + +
    + )} + + {/* 网盘下载 */} + {DOWNLOAD_CONFIG.enabled && unlocked && ( +
    +

    + + + + {DOWNLOAD_CONFIG.title} +

    +

    + 课程配套资料已上传至网盘,报名后可前往下载 +

    + + {DOWNLOAD_CONFIG.buttonText} + + + + +
    + )} +
    +
    +
    + ); +} diff --git a/app/[locale]/course/page.tsx b/app/[locale]/course/page.tsx index 9b551b2..694a104 100644 --- a/app/[locale]/course/page.tsx +++ b/app/[locale]/course/page.tsx @@ -1,558 +1,31 @@ "use client"; -import { useState, useEffect } from "react"; -import { VideoCard } from "./VideoCard"; -import { useVideoProgress } from "./useVideoProgress"; -import AuthModal from "../../components/AuthModal"; -import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase"; - -/* ─── data ─── */ - -const stats = [ - { value: "21", label: "节实战课程" }, - { value: "150+", label: "分钟视频" }, - { value: "3", label: "位实战讲师" }, -]; - -const audiences = [ - { - icon: "💼", - title: "职场人想转型", - desc: "受够了 996?学会远程工作技能,平滑过渡到自由生活方式", - }, - { - icon: "👨‍💻", - title: "开发者 / 设计师", - desc: "你的技能天然适合远程,学会如何接全球客户、拿美金报酬", - }, - { - icon: "📱", - title: "自媒体 / 内容创作者", - desc: "边旅行边创作,掌握数字游民内容变现的全套方法论", - }, - { - icon: "🌍", - title: "想出国旅居的人", - desc: "签证、税务、保险、目的地一站搞定,少走弯路直接出发", - }, -]; - -const benefits = [ - { - num: 1, - title: "完整的远程工作体系", - desc: "从技能评估到客户获取,建立可持续的远程收入系统", - }, - { - num: 2, - title: "打通 3+ 收入渠道", - desc: "远程全职、自由职业、数字产品、内容创作多管齐下", - }, - { - num: 3, - title: "掌握 50+ 远程工具", - desc: "协作、会议、项目管理、财务,一个背包装下整个办公室", - }, - { - num: 4, - title: "签证 + 税务 + 保险全通关", - desc: "55 国数字游民签证政策、合规税务方案、保险选购指南", - }, - { - num: 5, - title: "跑通第一次数字游民旅程", - desc: "从选目的地到打包出发,手把手带你完成首次数字游民体验", - }, -]; - -// 示例视频: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: "🚀", - part: "基础篇", - sections: [ - { name: "认识数字游民", lessons: [{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30", videoUrl: SAMPLE_VIDEO }, { id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45", videoUrl: SAMPLE_VIDEO }] }, - ], - }, - { - emoji: "💡", - 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: "💰", - 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: "🛠️", - 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: "📋", - 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: "🗺️", - 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: "🏆", - part: "实战篇", - sections: [ - { name: "启程出发", lessons: [{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40", videoUrl: SAMPLE_VIDEO }, { id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10", videoUrl: SAMPLE_VIDEO }] }, - ], - }, -]; - -const instructors = [ - { - name: "Alex", - avatar: "🧑‍💻", - role: "全栈开发者 / 5年数字游民", - tag: "技术导师", - desc: "前字节跳动高级工程师,2021年开始数字游民生活,足迹遍布 20+ 国家,远程服务硅谷客户", - }, - { - name: "Lisa", - avatar: "👩‍🎨", - role: "品牌设计师 / 自由职业教练", - tag: "变现专家", - desc: "Toptal 认证设计师,年收入 $120K+,帮助 500+ 人成功转型远程自由职业", - }, - { - name: "Marco", - avatar: "🧳", - role: "旅居作家 / 签证顾问", - tag: "旅居达人", - desc: "常驻里斯本,持有 3 国数字游民签证,《数字游民签证手册》作者,深谙各国政策", - }, -]; - -/* ─── component ─── */ +import Header from "@/app/components/Header"; +import Footer from "@/app/components/Footer"; +import { + CourseHero, + AudienceSection, + BenefitsSection, + CurriculumSection, + InstructorsSection, + FAQSection, + CTASection, +} from "@/app/components/course"; export default function CoursePage() { - const [modalOpen, setModalOpen] = useState(false); - const [authOpen, setAuthOpen] = useState(false); - const [userEmail, setUserEmail] = useState(null); - const [expandedPart, setExpandedPart] = useState(null); - const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress(); - - useEffect(() => { - setUserEmail(getStoredUserEmail()); - }, []); - - const handleLogout = () => { - pbLogout(); - setUserEmail(null); - }; - return ( -
    - {/* ── Nav ── */} -
    -
    - - 🌍 - 数字游民指南 - -
    - {userEmail ? ( - <> - - {userEmail} - - - - ) : ( - - )} - -
    -
    -
    - - {/* ── Hero ── */} -
    -
    -
    -

    - 数字游民实战课 -

    -

    - 7天打造你的远程工作体系与自由生活方式 -

    - -
    - {stats.map((s) => ( -
    -
    - {s.value} -
    -
    {s.label}
    -
    - ))} -
    - - -
    -
    - - {/* ── Audience ── */} -
    -
    -

    - 这门课适合谁? -

    -

    - 不管你是什么背景,只要想拥有地点自由的生活 -

    - -
    - {audiences.map((a) => ( -
    -
    - {a.icon} -
    -

    {a.title}

    -

    - {a.desc} -

    -
    - ))} -
    -
    -
    - - {/* ── Benefits ── */} -
    -
    -

    - 学完你将获得 -

    -

    - 5 大核心收益,助你顺利开启数字游民生活 -

    - -
    - {benefits.map((b) => ( -
    -
    - {b.num} -
    -
    -

    {b.title}

    -

    {b.desc}

    -
    -
    - ))} -
    -
    -
    - - {/* ── Curriculum ── 参考:垂直时间线 + 学习路径视觉 */} -
    -
    - {/* 标题区:大数字 + 学习路径感 */} -
    -
    - - 21 - - - 节实战课程 - -
    -

    - 从入门到启程 · 完整的学习路径 -

    -
    -
    - - {/* 垂直时间线:左侧竖线 + 节点 */} -
    - {/* 中央竖线 */} -
    - -
    - {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 ( -
    - {/* 左侧节点圆:完成时显示 ✓ */} -
    -
    - {partCompleted ? ( - - - - ) : ( - idx + 1 - )} -
    -
    - - {/* 右侧内容卡片 */} -
    -
    - {/* 一级:Part 标题,点击展开/收起 */} - - - {/* 展开后:二级 section + 三级 lesson */} - {isExpanded && ( -
    - {ch.sections.map((sec) => ( -
    - {/* 二级标题 */} -
    - -

    - {sec.name} -

    - - {sec.lessons.length} 节 - -
    - {/* 三级:视频卡片网格 */} -
    - {sec.lessons.map((lesson) => ( - markCompleted(lesson.id)} - /> - ))} -
    -
    - ))} -
    - )} -
    -
    -
    - ); - })} -
    -
    -
    -
    - - {/* ── Instructors ── */} -
    -
    -

    - 三位实战讲师联合授课 -

    -

    - 真正的数字游民亲自带你上手 -

    - -
    - {instructors.map((ins) => ( -
    -
    - {ins.avatar} -
    -

    {ins.name}

    -

    {ins.role}

    - - {ins.tag} - -

    - {ins.desc} -

    -
    - ))} -
    -
    -
    - - {/* ── Final CTA ── */} -
    -
    -

    - 准备好开启你的数字游民生活了吗? -

    -

    - 加入我们,跟着实战讲师一起,从零开始规划属于你的自由生活 -

    - -
    - - 21节实战视频 - - - 永久回看 - - - 社群答疑 - -
    - - -
    -
    - - {/* ── Footer ── */} - - - setAuthOpen(false)} - onSuccess={(email) => setUserEmail(email)} - /> - - {/* ── 报名 Modal ── */} - {modalOpen && ( -
    setModalOpen(false)} - > -
    e.stopPropagation()} - > - - -

    - 数字游民实战课 -

    -

    - 7天打造你的远程工作体系 -

    - -
    -
    🌍
    -

    - 扫码报名 -

    -

    ¥199

    -

    - 微信扫码 · 小程序购买 -

    -
    - -
    -
    💬
    -

    - 添加微信进群 -

    -

    - 交流答疑 · 文档资料 · 权益通知 -

    -
    -
    -
    - )} +
    +
    +
    + + + + + + + +
    +
    ); } diff --git a/app/[locale]/course/useVideoProgress.ts b/app/[locale]/course/useVideoProgress.ts deleted file mode 100644 index 33d1e1f..0000000 --- a/app/[locale]/course/useVideoProgress.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -const STORAGE_KEY = "digital-course-video-completed"; - -function loadCompleted(): Set { - if (typeof window === "undefined") return new Set(); - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return new Set(); - const arr = JSON.parse(raw) as string[]; - return new Set(Array.isArray(arr) ? arr : []); - } catch { - return new Set(); - } -} - -function saveCompleted(ids: Set) { - if (typeof window === "undefined") return; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids])); - } catch { - // ignore - } -} - -export function useVideoProgress() { - const [completed, setCompleted] = useState>(() => new Set()); - - useEffect(() => { - setCompleted(loadCompleted()); - }, []); - - const markCompleted = useCallback((lessonId: string) => { - setCompleted((prev) => { - const next = new Set(prev); - next.add(lessonId); - saveCompleted(next); - return next; - }); - }, []); - - const isCompleted = useCallback( - (lessonId: string) => completed.has(lessonId), - [completed] - ); - - const isPartCompleted = useCallback( - (lessonIds: string[]) => - lessonIds.length > 0 && lessonIds.every((id) => completed.has(id)), - [completed] - ); - - return { completed, markCompleted, isCompleted, isPartCompleted }; -} diff --git a/app/[locale]/course/videoPoster.ts b/app/[locale]/course/videoPoster.ts deleted file mode 100644 index 69b6846..0000000 --- a/app/[locale]/course/videoPoster.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * 生成视频封面图(数字游民插画风格,无序号) - * 返回 data URL,可直接用作 video poster - */ -export function generateVideoPoster( - title: string, - _lessonId: string, - options?: { - width?: number; - height?: number; - bgGradient?: [string, string]; - accentColor?: string; - } -): string { - const width = options?.width ?? 640; - const height = options?.height ?? 360; - const [from, to] = options?.bgGradient ?? ["#0ea5e9", "#06b6d4"]; - const accent = options?.accentColor ?? "#ffffff"; - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) return ""; - - // 数字游民插画:天空渐变背景 - const skyGrad = ctx.createLinearGradient(0, 0, 0, height); - skyGrad.addColorStop(0, "#87CEEB"); - skyGrad.addColorStop(0.5, "#B8E0F0"); - skyGrad.addColorStop(0.75, "#E8F4F8"); - skyGrad.addColorStop(1, "#F5E6D3"); - ctx.fillStyle = skyGrad; - ctx.fillRect(0, 0, width, height); - - // 海洋 - ctx.fillStyle = "rgba(14, 165, 233, 0.25)"; - ctx.fillRect(0, height * 0.65, width, height * 0.35); - ctx.strokeStyle = "rgba(255,255,255,0.4)"; - ctx.lineWidth = 2; - for (let i = 0; i < 6; i++) { - ctx.beginPath(); - const x = (i * width) / 5; - ctx.moveTo(x, height * 0.72); - ctx.quadraticCurveTo(x + 50, height * 0.68, x + 100, height * 0.72); - ctx.stroke(); - } - - // 太阳 - const sunGrad = ctx.createRadialGradient(width * 0.85, height * 0.15, 0, width * 0.85, height * 0.15, 50); - sunGrad.addColorStop(0, "#FEF3C7"); - sunGrad.addColorStop(0.6, "#FCD34D"); - sunGrad.addColorStop(1, "rgba(252, 211, 77, 0)"); - ctx.fillStyle = sunGrad; - ctx.beginPath(); - ctx.arc(width * 0.85, height * 0.15, 50, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#FCD34D"; - ctx.beginPath(); - ctx.arc(width * 0.85, height * 0.15, 18, 0, Math.PI * 2); - ctx.fill(); - - // 椰子树(简化插画) - const palmX = width * 0.12; - const palmY = height * 0.55; - ctx.fillStyle = "#8B7355"; - ctx.fillRect(palmX - 4, palmY, 8, height * 0.35); - ctx.fillStyle = "#22C55E"; - for (let i = 0; i < 5; i++) { - const a = (i / 5) * Math.PI * 0.8 - Math.PI * 0.2; - ctx.beginPath(); - ctx.moveTo(palmX, palmY); - ctx.quadraticCurveTo(palmX + Math.cos(a) * 35, palmY - 25, palmX + Math.cos(a) * 55, palmY - 45); - ctx.strokeStyle = "#22C55E"; - ctx.lineWidth = 6; - ctx.lineCap = "round"; - ctx.stroke(); - } - - // 地球/ globe - const globeX = width * 0.78; - const globeY = height * 0.35; - ctx.strokeStyle = "rgba(255,255,255,0.6)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(globeX, globeY, 45, 0, Math.PI * 2); - ctx.stroke(); - ctx.beginPath(); - ctx.ellipse(globeX, globeY, 45, 15, 0.3, 0, Math.PI * 2); - ctx.stroke(); - ctx.beginPath(); - ctx.ellipse(globeX, globeY, 15, 45, -0.2, 0, Math.PI * 2); - ctx.stroke(); - ctx.fillStyle = "rgba(14, 165, 233, 0.15)"; - ctx.fill(); - - // 笔记本电脑(数字游民工作象征) - const lapX = width * 0.25; - const lapY = height * 0.5; - ctx.fillStyle = "#E5E7EB"; - ctx.fillRect(lapX - 35, lapY - 20, 70, 45); - ctx.fillStyle = "#94A3B8"; - ctx.fillRect(lapX - 32, lapY - 18, 64, 35); - ctx.fillStyle = "#0ea5e9"; - ctx.globalAlpha = 0.6; - ctx.fillRect(lapX - 28, lapY - 14, 56, 27); - ctx.globalAlpha = 1; - ctx.fillStyle = "#64748B"; - ctx.fillRect(lapX - 38, lapY + 22, 76, 6); - ctx.fillRect(lapX - 2, lapY + 18, 4, 8); - - // 云朵 - ctx.fillStyle = "rgba(255,255,255,0.7)"; - ctx.beginPath(); - ctx.arc(width * 0.5, height * 0.2, 25, 0, Math.PI * 2); - ctx.arc(width * 0.55, height * 0.18, 20, 0, Math.PI * 2); - ctx.arc(width * 0.6, height * 0.22, 22, 0, Math.PI * 2); - ctx.fill(); - - // 底部半透明遮罩,让标题更清晰 - const overlay = ctx.createLinearGradient(0, height * 0.5, 0, height); - overlay.addColorStop(0, "rgba(0,0,0,0)"); - overlay.addColorStop(0.5, "rgba(0,0,0,0.1)"); - overlay.addColorStop(1, "rgba(0,0,0,0.4)"); - ctx.fillStyle = overlay; - ctx.fillRect(0, 0, width, height); - - // 标题:多行自动换行 - ctx.fillStyle = accent; - ctx.font = `600 ${Math.min(width * 0.045, 28)}px system-ui, "PingFang SC", sans-serif`; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - - const maxWidth = width * 0.9; - const lineHeight = Math.min(width * 0.05, 32); - const paddingX = width * 0.06; - const paddingY = height * 0.68; - - const words = title.split(""); - let line = ""; - const lines: string[] = []; - - for (const char of words) { - const testLine = line + char; - const metrics = ctx.measureText(testLine); - if (metrics.width > maxWidth && line) { - lines.push(line); - line = char; - } else { - line = testLine; - } - } - if (line) lines.push(line); - - ctx.shadowColor = "rgba(0,0,0,0.35)"; - ctx.shadowBlur = 6; - ctx.shadowOffsetY = 1; - - const maxLines = 2; - const startY = paddingY; - lines.slice(0, maxLines).forEach((ln, i) => { - ctx.fillText(ln, paddingX, startY + i * lineHeight); - }); - - ctx.shadowColor = "transparent"; - ctx.shadowBlur = 0; - - // 播放图标(右下角) - const playSize = Math.min(width, height) * 0.1; - const playX = width - width * 0.12; - const playY = height - height * 0.15; - ctx.fillStyle = "rgba(255,255,255,0.9)"; - ctx.beginPath(); - ctx.arc(playX, playY, playSize, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = from; - ctx.beginPath(); - ctx.moveTo(playX - playSize * 0.3, playY - playSize * 0.5); - ctx.lineTo(playX - playSize * 0.3, playY + playSize * 0.5); - ctx.lineTo(playX + playSize * 0.5, playY); - ctx.closePath(); - ctx.fill(); - - return canvas.toDataURL("image/jpeg", 0.92); -} diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts index 1a52f85..82b175b 100644 --- a/app/api/pay/status/route.ts +++ b/app/api/pay/status/route.ts @@ -11,13 +11,15 @@ export async function GET(request: NextRequest) { return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 }); } try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 25000); const res = await fetch( `${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, - { cache: "no-store" } - ); + { cache: "no-store", signal: controller.signal } + ).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); return NextResponse.json({ ok: true, paid: !!data?.paid }); - } catch (e) { - return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 }); + } catch { + return NextResponse.json({ ok: true, paid: false }); } } diff --git a/app/components/Tools.tsx b/app/components/Tools.tsx index 91723ed..8fdf773 100644 --- a/app/components/Tools.tsx +++ b/app/components/Tools.tsx @@ -7,11 +7,6 @@ type ToolsProps = { data: { categories: ToolsCategory[] }; }; -function truncateDesc(desc: string, maxLen = 5) { - if (!desc || desc.length <= maxLen) return desc; - return desc.slice(0, maxLen) + "..."; -} - export default function Tools({ data }: ToolsProps) { const categories = data.categories; const { t } = useTranslation("tools"); @@ -64,14 +59,13 @@ export default function Tools({ data }: ToolsProps) { className="group/item flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors hover:bg-slate-100/80 hover:text-sky-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:hover:text-sky-400" > {tool.name} - - {truncateDesc(tool.desc)} - {tool.desc && tool.desc.length > 5 && ( - + {tool.desc && ( + + {tool.desc} - )} - + + )} ))}
    diff --git a/app/components/course/AudienceSection.tsx b/app/components/course/AudienceSection.tsx new file mode 100644 index 0000000..5901699 --- /dev/null +++ b/app/components/course/AudienceSection.tsx @@ -0,0 +1,27 @@ +import { Section } from "./Section"; +import { AUDIENCE_CONFIG } from "@/config/course"; + +export function AudienceSection() { + return ( +
    +

    + {AUDIENCE_CONFIG.title} +

    +

    + {AUDIENCE_CONFIG.subtitle} +

    +
    + {AUDIENCE_CONFIG.items.map((a) => ( +
    +
    {a.emoji}
    +

    {a.title}

    +

    {a.desc}

    +
    + ))} +
    +
    + ); +} diff --git a/app/components/course/BenefitsSection.tsx b/app/components/course/BenefitsSection.tsx new file mode 100644 index 0000000..e0b5b48 --- /dev/null +++ b/app/components/course/BenefitsSection.tsx @@ -0,0 +1,31 @@ +import { Section } from "./Section"; +import { BENEFITS_CONFIG } from "@/config/course"; + +export function BenefitsSection() { + return ( +
    +

    + {BENEFITS_CONFIG.title} +

    +

    + {BENEFITS_CONFIG.subtitle} +

    +
    + {BENEFITS_CONFIG.items.map((b) => ( +
    +
    + {b.num} +
    +
    +

    {b.title}

    +

    {b.desc}

    +
    +
    + ))} +
    +
    + ); +} diff --git a/app/components/course/CTASection.tsx b/app/components/course/CTASection.tsx new file mode 100644 index 0000000..7015155 --- /dev/null +++ b/app/components/course/CTASection.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Link } from "@/i18n/navigation"; +import { CTA_CONFIG } from "@/config/course"; +import { isPaid } from "@/app/lib/course-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 ( +
    +
    +

    + {CTA_CONFIG.title} +

    +

    + {CTA_CONFIG.subtitle} +

    +
    + {CTA_CONFIG.badges.map((b, i) => ( + + {b} + + ))} +
    + + {paid ? "开始学习 →" : "立即加入 →"} + +
    +
    + ); +} diff --git a/app/components/course/CourseHero.tsx b/app/components/course/CourseHero.tsx new file mode 100644 index 0000000..571e13d --- /dev/null +++ b/app/components/course/CourseHero.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Link } from "@/i18n/navigation"; +import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course"; +import { isPaid } from "@/app/lib/course-payment"; +import { getLastWatched, getProgress } from "@/app/lib/learning"; + +const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0); + +export function CourseHero() { + const [paid, setPaid] = useState(false); + const [last, setLast] = useState>(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 ( +
    +
    +
    +

    + {HERO_CONFIG.title} +

    +

    + {HERO_CONFIG.subtitle} +

    +
    + {HERO_CONFIG.stats.map((s) => ( +
    +
    + {s.value} +
    +
    + {s.label} +
    +
    + ))} +
    +
    + {paid && last ? ( + + 继续学习 → + + ) : ( + + {paid ? "进入课程 →" : "立即报名 →"} + + )} +
    + {paid && progress.completed > 0 && ( +
    +
    + 学习进度 + {progress.completed}/{TOTAL_LESSONS} 节 +
    +
    +
    +
    +
    + )} +
    +
    + ); +} diff --git a/app/components/course/CurriculumSection.tsx b/app/components/course/CurriculumSection.tsx new file mode 100644 index 0000000..cb8369f --- /dev/null +++ b/app/components/course/CurriculumSection.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Link } from "@/i18n/navigation"; +import { Section } from "./Section"; +import { CURRICULUM_CONFIG } from "@/config/course"; +import { canWatchLesson } from "@/app/lib/course-payment"; +import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning"; + +export function CurriculumSection() { + const [expanded, setExpanded] = useState>(() => + Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0])) + ); + const [search, setSearch] = useState(""); + const [bookmarks, setBookmarks] = useState>(new Set()); + const [completed, setCompleted] = useState>(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 ( +
    +

    + {CURRICULUM_CONFIG.title} +

    +

    + {CURRICULUM_CONFIG.subtitle} +

    +
    + 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)]" + /> +
    +
    + {filteredByModule.map((c) => { + const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title); + const isOpen = expanded[moduleIndex] ?? true; + return ( +
    + + {isOpen && ( +
      + {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 ( +
    • + + + + + {l.name} + + {free && ( + + 试看 + + )} + {isCompleted && ( + + ✓ + + )} + {!unlocked && ( + + 🔒 + + )} + + + {l.duration} + + + +
    • + ); + })} +
    + )} +
    + ); + })} +
    +
    + ); +} diff --git a/app/components/course/FAQSection.tsx b/app/components/course/FAQSection.tsx new file mode 100644 index 0000000..acce5da --- /dev/null +++ b/app/components/course/FAQSection.tsx @@ -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(null); + + return ( +
    +

    + {FAQ_CONFIG.title} +

    +
    + {FAQ_CONFIG.items.map((item, i) => ( +
    + + {openIndex === i && ( +
    + {item.a} +
    + )} +
    + ))} +
    +
    + ); +} diff --git a/app/components/course/InstructorsSection.tsx b/app/components/course/InstructorsSection.tsx new file mode 100644 index 0000000..98be585 --- /dev/null +++ b/app/components/course/InstructorsSection.tsx @@ -0,0 +1,37 @@ +import { Section } from "./Section"; +import { INSTRUCTORS_CONFIG } from "@/config/course"; + +export function InstructorsSection() { + return ( +
    +

    + {INSTRUCTORS_CONFIG.title} +

    +

    + {INSTRUCTORS_CONFIG.subtitle} +

    +
    + {INSTRUCTORS_CONFIG.items.map((i) => ( +
    +
    +
    + {i.name.slice(0, 1)} +
    +
    +

    {i.name}

    +

    {i.role}

    +

    + {i.desc} +

    + + {i.tag} + +
    + ))} +
    +
    + ); +} diff --git a/app/components/course/Section.tsx b/app/components/course/Section.tsx new file mode 100644 index 0000000..46a4a33 --- /dev/null +++ b/app/components/course/Section.tsx @@ -0,0 +1,17 @@ +import { type ReactNode } from "react"; + +type SectionProps = { + children: ReactNode; + className?: string; +}; + +/** 通用区块容器 - 统一内边距与最大宽度,响应式(来自 nomadlms) */ +export function Section({ children, className = "" }: SectionProps) { + return ( +
    +
    {children}
    +
    + ); +} diff --git a/app/components/course/VideoPlayer.tsx b/app/components/course/VideoPlayer.tsx new file mode 100644 index 0000000..5bafd3f --- /dev/null +++ b/app/components/course/VideoPlayer.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const STORAGE_PREFIX = "lms-video-"; +const SAVE_INTERVAL = 5000; +const MIN_PROGRESS_TO_SHOW = 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; + videoId?: string; + onComplete?: () => void; +}; + +export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) { + const videoRef = useRef(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 | 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) => { + 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) => { + 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 ( +
    setShowControls(false)} + > +