'优化课程'

This commit is contained in:
eric
2026-03-11 22:26:12 -05:00
parent ade21e076f
commit 867d53936c
26 changed files with 2178 additions and 885 deletions

View File

@@ -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<string | null>(null);
useEffect(() => {
setPoster(generateVideoPoster(lesson.title, lesson.id));
}, [lesson.title, lesson.id]);
const handleEnded = useCallback(() => {
onComplete?.();
}, [onComplete]);
return (
<article
className={`group overflow-hidden rounded-2xl border shadow-sm transition-all duration-200 hover:shadow-md ${
completed
? "border-emerald-200 bg-emerald-50/50 hover:border-emerald-300"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
{/* 三级标题:视频上方,突出显示 */}
<div className="flex items-center gap-3 border-b border-slate-100 bg-white/80 px-4 py-3">
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
completed ? "bg-emerald-100 text-emerald-600" : "bg-sky-50 text-sky-600"
}`}
>
{lesson.id}
</span>
<h4 className="min-w-0 flex-1 text-base font-semibold leading-snug text-slate-800 line-clamp-2">
{lesson.title}
</h4>
</div>
<div className="relative aspect-video overflow-hidden bg-slate-900">
<video
src={lesson.videoUrl}
poster={poster ?? undefined}
controls
playsInline
onEnded={handleEnded}
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-[1.01]"
preload="metadata"
>
</video>
<span className="absolute bottom-2 right-2 rounded bg-black/60 px-2 py-0.5 text-xs font-medium tabular-nums text-white/90 backdrop-blur-sm">
{lesson.duration}
</span>
{completed && (
<span className="absolute top-2 right-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500 text-white shadow-md">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
)}
</div>
</article>
);
}

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

@@ -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 (
<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="/course"
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="/course" 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="/join"
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>
<a
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>
</a>
</div>
)}
</div>
</main>
</div>
);
}

View File

@@ -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<string | null>(null);
const [expandedPart, setExpandedPart] = useState<string | null>(null);
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
useEffect(() => {
setUserEmail(getStoredUserEmail());
}, []);
const handleLogout = () => {
pbLogout();
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 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>
<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>
{/* ── Hero ── */}
<section className="relative overflow-hidden bg-gradient-to-b from-slate-50 to-white py-20 sm:py-28">
<div className="pointer-events-none absolute top-0 left-1/2 h-[500px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/50 via-cyan-50/30 to-amber-50/20 blur-3xl" />
<div className="relative mx-auto max-w-3xl px-4 text-center sm:px-6">
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-6xl">
<span className="gradient-text"></span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-slate-600 sm:text-xl">
7
</p>
<div className="mt-10 flex items-center justify-center gap-6 sm:gap-10">
{stats.map((s) => (
<div key={s.label} className="text-center">
<div className="text-3xl font-extrabold text-sky-600 sm:text-4xl">
{s.value}
</div>
<div className="mt-1 text-sm text-slate-500">{s.label}</div>
</div>
))}
</div>
<button
onClick={() => setModalOpen(true)}
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
>
<span></span>
</button>
</div>
</section>
{/* ── Audience ── */}
<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">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{audiences.map((a) => (
<div
key={a.title}
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
>
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl">
{a.icon}
</div>
<h3 className="mb-2 font-bold">{a.title}</h3>
<p className="text-sm leading-relaxed text-slate-500">
{a.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Benefits ── */}
<section className="bg-slate-50 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">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
5
</p>
<div className="mt-14 space-y-5">
{benefits.map((b) => (
<div
key={b.num}
className="card-hover flex items-start gap-5 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm sm:items-center"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 to-cyan-500 text-xl font-extrabold text-white shadow-md shadow-sky-500/20">
{b.num}
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">{b.title}</h3>
<p className="mt-1 text-sm text-slate-500">{b.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* ── 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="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
/>
<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>
</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>
{/* ── Instructors ── */}
<section className="bg-slate-50 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">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
<div className="mt-14 grid gap-6 sm:grid-cols-3">
{instructors.map((ins) => (
<div
key={ins.name}
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
>
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-sky-100 to-cyan-50 text-5xl">
{ins.avatar}
</div>
<h3 className="text-lg font-bold">{ins.name}</h3>
<p className="mt-1 text-sm text-slate-500">{ins.role}</p>
<span className="mt-3 inline-block rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-600">
{ins.tag}
</span>
<p className="mt-4 text-sm leading-relaxed text-slate-500">
{ins.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Final CTA ── */}
<section className="py-20 sm:py-28">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-4 max-w-lg text-slate-500">
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500">
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
21
</span>
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
</span>
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
</span>
</div>
<button
onClick={() => setModalOpen(true)}
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
>
<span></span>
</button>
</div>
</section>
{/* ── Footer ── */}
<footer className="border-t border-slate-100 bg-white py-8 text-center text-sm text-slate-400">
<a href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
🌍
</a>
<span className="mx-2">·</span>
·
</footer>
<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"
onClick={() => setModalOpen(false)}
>
<div
className="relative w-full max-w-sm animate-fade-in rounded-2xl bg-white p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setModalOpen(false)}
className="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h3 className="text-center text-lg font-bold">
</h3>
<p className="mt-1 text-center text-sm text-slate-500">
7
</p>
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50 p-6 text-center">
<div className="text-6xl">🌍</div>
<p className="mt-3 text-sm font-medium text-slate-600">
</p>
<p className="mt-4 text-3xl font-extrabold text-sky-600">¥199</p>
<p className="mt-1 text-xs text-slate-400">
·
</p>
</div>
<div className="mt-5 rounded-xl border border-slate-100 bg-slate-50 p-4 text-center">
<div className="text-4xl">💬</div>
<p className="mt-2 text-sm font-medium text-slate-600">
</p>
<p className="mt-1 text-xs text-slate-400">
· ·
</p>
</div>
</div>
</div>
)}
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main>
<CourseHero />
<AudienceSection />
<BenefitsSection />
<CurriculumSection />
<InstructorsSection />
<FAQSection />
<CTASection />
</main>
<Footer />
</div>
);
}

View File

@@ -1,56 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "digital-course-video-completed";
function loadCompleted(): Set<string> {
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<string>) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
} catch {
// ignore
}
}
export function useVideoProgress() {
const [completed, setCompleted] = useState<Set<string>>(() => 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 };
}

View File

@@ -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);
}

View File

@@ -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 });
}
}

View File

@@ -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"
>
<span className="font-medium text-slate-700 dark:text-slate-300">{tool.name}</span>
<span className="relative text-slate-400">
{truncateDesc(tool.desc)}
{tool.desc && tool.desc.length > 5 && (
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 w-max max-w-[90vw] -translate-x-1/2 whitespace-normal break-words rounded-lg bg-slate-800 px-3 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/item:opacity-100">
{tool.desc && (
<span className="relative shrink-0">
<span className="pointer-events-none absolute bottom-full right-0 z-10 mb-1 w-max max-w-[90vw] whitespace-normal break-words rounded-lg bg-slate-800 px-3 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/item:opacity-100">
{tool.desc}
</span>
)}
</span>
)}
</a>
))}
</div>

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import { Link } from "@/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 (
<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" : "/join"}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
{paid ? "开始学习 →" : "立即加入 →"}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,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<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" />
<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 sm:text-4xl md:text-5xl lg:text-6xl">
{HERO_CONFIG.title}
</h1>
<p className="mb-10 text-lg text-[var(--muted-foreground)] sm:mb-12 sm:text-xl md:text-2xl">
{HERO_CONFIG.subtitle}
</p>
<div className="mb-10 flex flex-wrap justify-center gap-6 sm:mb-12 sm:gap-8">
{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 sm:gap-4">
{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" : "/join"}
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">
<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>
</div>
)}
</div>
</section>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speed, setSpeed] = useState(1);
const [showControls, setShowControls] = useState(true);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [savedTime, setSavedTime] = useState(0);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSaveRef = useRef(0);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setPlaying(true);
} else {
v.pause();
setPlaying(false);
}
}, []);
const handleTimeUpdate = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const time = v.currentTime;
const dur = v.duration;
setCurrentTime(time);
setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
if (videoId && v.currentTime > 0) {
const now = Date.now();
if (now - lastSaveRef.current > SAVE_INTERVAL) {
lastSaveRef.current = now;
saveProgress(videoId, v.currentTime);
}
}
}, [videoId]);
const handleLoadedMetadata = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
setDuration(dur);
if (videoId) {
const stored = getStoredProgress(videoId);
if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
setSavedTime(stored);
setShowResumePrompt(true);
}
}
}, [videoId]);
const handleEnded = useCallback(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
if (videoId) saveProgress(videoId, 0);
onComplete?.();
}, [videoId, onComplete]);
const handlePause = useCallback(() => {
const v = videoRef.current;
if (videoId && v && v.currentTime > 0) {
saveProgress(videoId, v.currentTime);
}
}, [videoId]);
const handleResume = useCallback(() => {
const v = videoRef.current;
if (!v || savedTime <= 0) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const time = Math.min(savedTime, dur - 0.1);
v.currentTime = time;
setCurrentTime(time);
setProgress((time / dur) * 100);
v.play();
setPlaying(true);
setShowResumePrompt(false);
}, [savedTime]);
const handleRestart = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (videoId) saveProgress(videoId, 0);
v.currentTime = 0;
setCurrentTime(0);
setProgress(0);
setShowResumePrompt(false);
}, [videoId]);
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width <= 0) return;
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const time = p * dur;
v.currentTime = time;
setCurrentTime(time);
setProgress(p * 100);
}, []);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
}, []);
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = videoRef.current;
if (!v) return;
const val = parseFloat(e.target.value);
v.volume = val;
setVolume(val);
setMuted(val === 0);
}, []);
const cycleSpeed = useCallback(() => {
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const i = speeds.indexOf(speed);
const next = speeds[(i + 1) % speeds.length];
const v = videoRef.current;
if (v) v.playbackRate = next;
setSpeed(next);
}, [speed]);
const toggleFullscreen = useCallback(() => {
const container = videoRef.current?.parentElement;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
const showControlsTemporarily = useCallback(() => {
setShowControls(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
const formatTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
const formatResumeTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0秒";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
if (m > 0) return `${m}${sec}`;
return `${sec}`;
};
return (
<div
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
title={title}
className="aspect-video w-full object-contain"
onClick={togglePlay}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPause={handlePause}
playsInline
/>
{showResumePrompt && (
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
<p className="mb-3 text-sm text-white/90">
<span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleResume}
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
>
</button>
<button
type="button"
onClick={handleRestart}
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
>
</button>
</div>
</div>
)}
{!playing && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
aria-label="播放"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
<svg
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
<div
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
onClick={handleSeek}
role="slider"
aria-valuenow={progress}
>
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<button
type="button"
onClick={togglePlay}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={playing ? "暂停" : "播放"}
>
{playing ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={muted ? "取消静音" : "静音"}
>
{muted || volume === 0 ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
/>
</div>
<button
type="button"
onClick={cycleSpeed}
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
>
{speed}x
</button>
<button
type="button"
onClick={toggleFullscreen}
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
aria-label="全屏"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { Section } from "./Section";
export { VideoPlayer } from "./VideoPlayer";
export { CurriculumSection } from "./CurriculumSection";
export { AudienceSection } from "./AudienceSection";
export { BenefitsSection } from "./BenefitsSection";
export { InstructorsSection } from "./InstructorsSection";
export { FAQSection } from "./FAQSection";
export { CTASection } from "./CTASection";
export { CourseHero } from "./CourseHero";

View File

@@ -17,14 +17,33 @@
--animate-float: float 6s ease-in-out infinite;
}
:root {
:root,
.light {
--background: #ffffff;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--border: #e2e8f0;
--accent: #f59e0b;
--accent-foreground: #1c1917;
--accent-muted: rgba(245, 158, 11, 0.2);
--gradient-accent: rgba(251, 191, 36, 0.15);
}
.dark {
--background: #0f172a;
--foreground: #f8fafc;
--card: rgba(255, 255, 255, 0.05);
--card-foreground: #f8fafc;
--muted: rgba(255, 255, 255, 0.05);
--muted-foreground: #94a3b8;
--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);
}
@keyframes scroll {
@@ -42,6 +61,57 @@
50% { transform: translateY(-10px); }
}
/* 课程页 - 渐变背景动画 */
@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;
}
body {
background: var(--background);
color: var(--foreground);

31
app/lib/course-payment.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* 课程支付状态 - localStorage来自 nomadlms
* 与 join 报名支付分离,课程解锁用此模块
*/
const PAY_STORAGE_KEY = "lms-pay";
/** 是否已支付(课程已解锁) */
export function isPaid(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(PAY_STORAGE_KEY) === "ok";
}
/** 设置已支付(支付成功后调用) */
export function setPaid(): void {
if (typeof window === "undefined") return;
localStorage.setItem(PAY_STORAGE_KEY, "ok");
window.dispatchEvent(new CustomEvent("pay:updated"));
}
/** 前 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();
}

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

@@ -0,0 +1,141 @@
/**
* 学习进度、继续学习、收藏、笔记 - localStorage来自 nomadlms
*/
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 */
}
}

209
config/course.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* 课程落地页配置 - 数字游民主题(来自 nomadlms
*/
export const SITE_CONFIG = {
brand: "🌍 数字游民",
ctaUrl: "#",
footer: "🌍 数字游民 © 2024 | 自由工作,自在生活",
} as const;
export const HERO_CONFIG = {
title: "数字游民实战课",
subtitle: "从朝九晚五到边旅行边赚钱,开启你的自由人生",
stats: [
{ value: "21", label: "节实战课程" },
{ value: "170+", label: "分钟视频" },
{ value: "3", label: "位资深导师" },
],
} as const;
export const AUDIENCE_CONFIG = {
title: "这门课适合谁?",
subtitle: "向往自由,想用技能换时间与地点自由的人",
items: [
{
emoji: "💻",
title: "远程打工人",
desc: "厌倦通勤,想在家或任何地方高效工作",
},
{
emoji: "✈️",
title: "旅行爱好者",
desc: "想边旅行边赚钱,不再为假期发愁",
},
{
emoji: "📱",
title: "自由职业者",
desc: "想建立被动收入,减少接单焦虑",
},
{
emoji: "🌴",
title: "生活方式探索者",
desc: "想摆脱固定地点,体验不同城市与国家",
},
],
} as const;
export const BENEFITS_CONFIG = {
title: "学完你将获得",
subtitle: "5大核心能力助你成为真正的数字游民",
items: [
{
num: "1",
title: "远程工作技能体系",
desc: "从技能选择到接单渠道,系统掌握远程变现能力",
},
{
num: "2",
title: "被动收入搭建方法",
desc: "产品化你的技能,让收入不依赖时间",
},
{
num: "3",
title: "全球旅居实操指南",
desc: "签证、住宿、网络、保险,一站式搞定",
},
{
num: "4",
title: "一人公司运营心法",
desc: "从个人到团队,可持续的自由商业模式",
},
{
num: "5",
title: "数字游民社群与资源",
desc: "加入同频社群,获取持续支持与灵感",
},
],
} as const;
export const CURRICULUM_CONFIG = {
title: "21节实战课程",
subtitle: "从入门到进阶,完整成为数字游民",
modules: [
{
emoji: "🚀",
title: "基础篇 · 数字游民入门",
lessons: [
{ name: "01 什么是数字游民?你适合吗?", duration: "11:58" },
{ name: "02 从朝九晚五到边旅行边赚钱的路径", duration: "14:32" },
],
},
{
emoji: "💼",
title: "技能篇 · 远程变现能力",
lessons: [
{ name: "3.1 最适合远程的5大技能方向", duration: "06:25" },
{ name: "3.2 如何打造你的技能组合与作品集", duration: "07:34" },
],
},
{
emoji: "💰",
title: "收入篇 · 被动收入与副业",
lessons: [
{ name: "04 从接单到产品的思维转变", duration: "04:16" },
{ name: "05 打造你的第一个数字产品", duration: "06:18" },
{ name: "06 海外平台接单与变现", duration: "02:30" },
{ name: "07 被动收入组合:课程+会员+咨询", duration: "06:25" },
{ name: "08 如何定价才能既赚又自由", duration: "03:09" },
],
},
{
emoji: "🧠",
title: "心态篇 · 自由与自律",
lessons: [
{ name: "09 数字游民的时间管理心法", duration: "07:49" },
{ name: "09-1 独处与社交的平衡", duration: "01:24" },
{ name: "10 如何应对家人的不理解", duration: "04:07" },
],
},
{
emoji: "⚡",
title: "落地篇 · 旅居实操",
lessons: [
{ name: "11 签证与长期居留规划", duration: "04:12" },
{ name: "12 住宿选择短租、民宿、Co-living", duration: "05:19" },
{ name: "13 网络、保险与紧急预案", duration: "04:11" },
],
},
{
emoji: "🎯",
title: "实战篇 · 真实案例",
lessons: [
{ name: "14 巴厘岛数字游民社区探访", duration: "12:02" },
{ name: "15 曼谷/清迈生活成本与体验", duration: "09:39" },
{ name: "16 欧洲数字游民签证攻略", duration: "09:04" },
{ name: "17 一人公司如何合法合规运营", duration: "05:12" },
],
},
{
emoji: "🏆",
title: "进阶篇 · 高阶玩法",
lessons: [
{ name: "18 从一人到小团队的进化", duration: "11:25" },
{ name: "19 建立你的数字游民品牌", duration: "25:57" },
{ name: "20 年度旅居计划与预算规划", duration: "12:34" },
{ name: "21 长期数字游民的可持续之道", duration: "08:53" },
],
},
],
} as const;
export const INSTRUCTORS_CONFIG = {
title: "三位资深导师联合授课",
subtitle: "异度世界、大师兄、小美带你开启数字游民之旅",
items: [
{
name: "异度世界",
role: "数字游民先行者",
desc: "5年旅居经验走过20+国家",
tag: "课程主理",
},
{
name: "大师兄",
role: "一人公司实践者",
desc: "远程自由职业转型教练",
tag: "变现专家",
},
{
name: "小美",
role: "生活方式博主",
desc: "边旅行边创作的斜杠青年",
tag: "旅居达人",
},
],
} as const;
export const CTA_CONFIG = {
title: "准备好开启你的数字游民之旅了吗?",
subtitle: "加入我们,跟着异度世界、大师兄、小美一起,从零开始实现自由人生",
badges: ["21节实战视频", "永久回看", "社群答疑"],
} as const;
/** FAQ 常见问题 */
export const FAQ_CONFIG = {
title: "常见问题",
items: [
{ q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
{ q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
{ q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
],
} as const;
/** 社群入口 */
export const COMMUNITY_CONFIG = {
enabled: true,
wechat: "扫码加入数字游民社群",
qrCodeUrl: "/qrcode-community.png",
qq: "",
link: "",
} as const;
/** 章节底部网盘下载模块 */
export const DOWNLOAD_CONFIG = {
enabled: true,
url: "https://pan.baidu.com/s/example",
title: "课程资料下载",
buttonText: "前往网盘下载",
} as const;

112
config/lessons.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* 课程章节详情配置 - 数字游民主题(来自 nomadlms
*/
const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
const SAMPLE_MARKDOWN = `# 课程概述
本小节将带你理解数字游民的核心概念,掌握关键技能。
## 学习目标
- 理解数字游民的本质与生活方式
- 掌握远程变现的基础路径
- 完成个人定位与规划
## 核心要点
1. **定义**:数字游民 = 远程工作 + 自由旅行 + 地点独立
2. **技能**:选择可远程交付的技能方向
3. **心态**:自律与自由是相辅相成的
## 行动清单
- [ ] 评估自己的技能是否可远程交付
- [ ] 列出3个你想旅居的城市
- [ ] 设定6个月内的转型目标
### 代码示例
\`\`\`bash
# 安装依赖
pnpm install
# 启动开发
pnpm dev
\`\`\`
行内代码示例:\`npm run build\` 可生成生产版本。
## 注意事项
> 数字游民不是逃避,而是主动选择。建议先有稳定收入再考虑长期旅居。
| 项目 | 说明 |
|------|------|
| 收入 | 建议月收入稳定在 1 万以上 |
| 技能 | 至少 1 项可远程交付 |
| 心态 | 自律与自由平衡 |
`;
export type LessonDetail = {
name: string;
duration: string;
videoUrl: string;
markdown: string;
};
/** 扁平化课程数据moduleIndex-lessonIndex 作为 key */
export const LESSONS_MAP: Record<string, LessonDetail> = {
"0-0": {
name: "01 什么是数字游民?你适合吗?",
duration: "11:58",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "数字游民的定义与自我评估"),
},
"0-1": {
name: "02 从朝九晚五到边旅行边赚钱的路径",
duration: "14:32",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "从上班族到数字游民的转型路径"),
},
"1-0": {
name: "3.1 最适合远程的5大技能方向",
duration: "06:25",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "编程、设计、写作、营销、咨询等远程技能"),
},
"1-1": {
name: "3.2 如何打造你的技能组合与作品集",
duration: "07:34",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "作品集搭建与个人品牌展示"),
},
"2-0": { name: "04 从接单到产品的思维转变", duration: "04:16", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"2-1": { name: "05 打造你的第一个数字产品", duration: "06:18", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"2-2": { name: "06 海外平台接单与变现", duration: "02:30", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"2-3": { name: "07 被动收入组合:课程+会员+咨询", duration: "06:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"2-4": { name: "08 如何定价才能既赚又自由", duration: "03:09", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"3-0": { name: "09 数字游民的时间管理心法", duration: "07:49", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"3-1": { name: "09-1 独处与社交的平衡", duration: "01:24", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"3-2": { name: "10 如何应对家人的不理解", duration: "04:07", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"4-0": { name: "11 签证与长期居留规划", duration: "04:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"4-1": { name: "12 住宿选择短租、民宿、Co-living", duration: "05:19", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"4-2": { name: "13 网络、保险与紧急预案", duration: "04:11", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"5-0": { name: "14 巴厘岛数字游民社区探访", duration: "12:02", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"5-1": { name: "15 曼谷/清迈生活成本与体验", duration: "09:39", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"5-2": { name: "16 欧洲数字游民签证攻略", duration: "09:04", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"5-3": { name: "17 一人公司如何合法合规运营", duration: "05:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"6-0": { name: "18 从一人到小团队的进化", duration: "11:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"6-1": { name: "19 建立你的数字游民品牌", duration: "25:57", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"6-2": { name: "20 年度旅居计划与预算规划", duration: "12:34", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
"6-3": { name: "21 长期数字游民的可持续之道", duration: "08:53", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
};
export function getLessonKey(moduleIndex: number, lessonIndex: number): string {
return `${moduleIndex}-${lessonIndex}`;
}
export function getLesson(moduleIndex: number, lessonIndex: number): LessonDetail | null {
return LESSONS_MAP[getLessonKey(moduleIndex, lessonIndex)] ?? null;
}

View File

@@ -10,14 +10,16 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1004.0",
"github-markdown-css": "^5.6.1",
"highlight.js": "^11.11.1",
"marked": "^14.1.2",
"minio": "^8.0.7",
"next": "16.1.6",
"pocketbase": "^0.26.8",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"marked": "^14.1.2",
"github-markdown-css": "^5.6.1",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@@ -29,5 +31,6 @@
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
},
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
}

57
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
github-markdown-css:
specifier: ^5.6.1
version: 5.9.0
highlight.js:
specifier: ^11.11.1
version: 11.11.1
marked:
specifier: ^14.1.2
version: 14.1.4
@@ -35,6 +38,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.3)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -1688,9 +1694,15 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -1700,6 +1712,10 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -2001,6 +2017,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -2365,6 +2384,9 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -2645,6 +2667,9 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -4872,6 +4897,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -4892,6 +4921,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -4902,6 +4938,8 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
highlight.js@11.11.1: {}
html-url-attributes@3.0.1: {}
ignore@5.3.2: {}
@@ -5172,6 +5210,12 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -5791,6 +5835,14 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text: 4.0.2
lowlight: 3.3.0
unist-util-visit: 5.1.0
vfile: 6.0.3
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -6177,6 +6229,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3