This commit is contained in:
root
2026-06-07 18:06:52 +08:00
parent 9de2465793
commit a1daa040ef
33 changed files with 1917 additions and 439 deletions

View File

@@ -2,15 +2,23 @@
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { Link } from "@/app/digital/i18n/navigation";
import { VideoPlayer } from "@/app/digital/components/course/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "@/config/digital/lessons";
import { getLesson, type LessonDetail } from "@/config/digital/lessons";
import { CURRICULUM_CONFIG, DOWNLOAD_CONFIG } from "@/config/digital/course";
import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
import { setLastWatched, markCompleted } from "@/app/digital/lib/learning";
import { DOWNLOAD_CONFIG } from "@/config/digital/course";
import {
getCompletedLessons,
getNote,
isBookmarked,
markCompleted,
setLastWatched,
setNote as saveNote,
toggleBookmark,
} from "@/app/digital/lib/learning";
import Header from "@/app/digital/components/Header";
import AuthModal from "@/app/digital/components/AuthModal";
import {
@@ -18,23 +26,191 @@ import {
redirectToPay,
getDeviceFromEnv,
} from "@/app/digital/lib/payment";
const COURSE_LESSONS = CURRICULUM_CONFIG.modules.flatMap(({ lessons }, moduleIndex) =>
lessons.map((_, lessonIndex) => ({ moduleIndex, lessonIndex }))
);
type DeckSlide = {
title: string;
body: string;
};
function buildDeckSlides(lesson: LessonDetail): DeckSlide[] {
const sections = lesson.markdown
.split("\n## ")
.slice(1)
.map((section) => {
const [rawTitle, ...bodyLines] = section.split("\n");
return {
title: rawTitle.trim(),
body: bodyLines
.filter((line) => line.trim())
.slice(0, 5)
.join("\n")
.replace(/^- /gm, "")
.replace(/^\d+\. /gm, ""),
};
})
.filter((slide) => slide.title && slide.body);
return [
{
title: "本节导览",
body: `${lesson.name}\n预计学习 ${lesson.duration}\n完成课件后可标记进度并保存笔记。`,
},
...sections,
];
}
function LessonDeck({
lesson,
onComplete,
}: {
lesson: LessonDetail;
onComplete: () => void;
}) {
const slides = buildDeckSlides(lesson);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const activeSlide = slides[activeIndex] ?? slides[0];
const progress = slides.length > 0 ? Math.round(((activeIndex + 1) / slides.length) * 100) : 0;
useEffect(() => {
if (!playing) return;
const timer = window.setTimeout(() => {
setActiveIndex((current) => {
if (current >= slides.length - 1) {
setPlaying(false);
onComplete();
return current;
}
return current + 1;
});
}, 6500);
return () => window.clearTimeout(timer);
}, [activeIndex, onComplete, playing, slides.length]);
return (
<div className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-xl md:rounded-2xl">
<div className="flex min-h-[min(58vw,420px)] flex-col bg-slate-950 text-white">
<div className="flex items-center justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-white/50">Interactive Lesson</p>
<h2 className="truncate text-base font-semibold sm:text-lg">{activeSlide.title}</h2>
</div>
<span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs text-white/70">
{activeIndex + 1} / {slides.length}
</span>
</div>
<div className="flex flex-1 items-center px-5 py-8 sm:px-8">
<p className="whitespace-pre-line text-base leading-8 text-white/85 sm:text-lg sm:leading-9">
{activeSlide.body}
</p>
</div>
<div className="border-t border-white/10 px-5 py-4">
<div className="mb-4 h-1.5 overflow-hidden rounded-full bg-white/15">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-2">
<button
type="button"
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
disabled={activeIndex === 0}
className="rounded-lg border border-white/15 px-3 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => setActiveIndex((index) => Math.min(slides.length - 1, index + 1))}
disabled={activeIndex === slides.length - 1}
className="rounded-lg border border-white/15 px-3 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPlaying((value) => !value)}
className="rounded-lg bg-white px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-white/90"
>
{playing ? "暂停" : "播放课件"}
</button>
<button
type="button"
onClick={onComplete}
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default function LessonPage() {
const params = useParams();
const router = useRouter();
const { t } = useTranslation("community");
const { user, vip, refetch } = useAuthAndVip();
const [toast, setToast] = useState(false);
const { vip, refetch } = useAuthAndVip();
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
const [bookmarked, setBookmarked] = useState(false);
const [completed, setCompleted] = useState(false);
const [lessonNote, setLessonNote] = useState("");
const [statusMessage, setStatusMessage] = useState("");
const moduleIndex = Number(params.moduleIndex);
const lessonIndex = Number(params.lessonIndex);
const lesson = getLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
const lessonName = lesson?.name;
const currentLessonIndex = COURSE_LESSONS.findIndex(
(item) => item.moduleIndex === moduleIndex && item.lessonIndex === lessonIndex
);
const previousLesson = currentLessonIndex > 0 ? COURSE_LESSONS[currentLessonIndex - 1] : null;
const nextLesson =
currentLessonIndex >= 0 && currentLessonIndex < COURSE_LESSONS.length - 1
? COURSE_LESSONS[currentLessonIndex + 1]
: null;
useEffect(() => {
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
if (lessonName && unlocked) setLastWatched(moduleIndex, lessonIndex, lessonName);
}, [moduleIndex, lessonIndex, lessonName, unlocked]);
useEffect(() => {
if (!lesson) return;
const key = `${moduleIndex}-${lessonIndex}`;
setBookmarked(isBookmarked(moduleIndex, lessonIndex));
setCompleted(getCompletedLessons().has(key));
setLessonNote(getNote(moduleIndex, lessonIndex));
setStatusMessage("");
}, [moduleIndex, lessonIndex, lesson]);
const handleBookmark = () => {
const active = toggleBookmark(moduleIndex, lessonIndex);
setBookmarked(active);
setStatusMessage(active ? "已收藏本节" : "已取消收藏");
};
const handleMarkCompleted = () => {
markCompleted(moduleIndex, lessonIndex);
setCompleted(true);
setStatusMessage("本节已完成");
};
const handleSaveNote = () => {
saveNote(moduleIndex, lessonIndex, lessonNote);
setStatusMessage("笔记已保存");
};
const startPay = (userId: string) => {
const env = getPayEnv();
@@ -155,17 +331,24 @@ export default function LessonPage() {
<div className="relative mb-12">
{unlocked ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`course-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
/>
lesson.videoUrl ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`course-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={handleMarkCompleted}
/>
) : (
<LessonDeck key={`${moduleIndex}-${lessonIndex}`} lesson={lesson} onComplete={handleMarkCompleted} />
)
) : (
<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>
<svg className="h-14 w-14 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 11V7a4 4 0 0 1 8 0v4" />
<rect x="5" y="11" width="14" height="10" rx="2" strokeWidth={1.8} />
</svg>
<p className="text-lg text-white/90"></p>
<button
type="button"
@@ -180,6 +363,55 @@ export default function LessonPage() {
)}
</div>
{unlocked && (
<div className="mb-8 grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<button
type="button"
onClick={handleBookmark}
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
<svg className="h-4 w-4" fill={bookmarked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m12 3 2.9 5.88 6.5.95-4.7 4.58 1.1 6.47L12 17.83l-5.8 3.05 1.1-6.47-4.7-4.58 6.5-.95L12 3z" />
</svg>
{bookmarked ? "已收藏" : "收藏本节"}
</button>
<button
type="button"
onClick={handleMarkCompleted}
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 6 9 17l-5-5" />
</svg>
{completed ? "已完成" : "标记完成"}
</button>
<div className="flex items-center justify-center gap-2">
{previousLesson && (
<Link
href={`/course/${previousLesson.moduleIndex}/${previousLesson.lessonIndex}`}
className="inline-flex items-center justify-center rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
</Link>
)}
{nextLesson && (
<Link
href={`/course/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`}
className="inline-flex items-center justify-center rounded-xl bg-[var(--accent)] px-4 py-3 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
)}
</div>
</div>
)}
{statusMessage && (
<p className="mb-6 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm text-[var(--muted-foreground)]" role="status">
{statusMessage}
</p>
)}
{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">
@@ -192,6 +424,33 @@ export default function LessonPage() {
</div>
)}
{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" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 20h9" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</h2>
<textarea
value={lessonNote}
onChange={(event) => setLessonNote(event.target.value)}
className="min-h-32 w-full rounded-xl border border-[var(--border)] bg-[var(--background)] px-4 py-3 text-sm leading-6 outline-none transition focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
placeholder="记录本节重点、行动清单或自己的实践结果"
/>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSaveNote}
className="inline-flex cursor-pointer items-center justify-center rounded-full bg-[var(--accent)] px-6 py-3 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</button>
</div>
</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">
@@ -200,7 +459,7 @@ export default function LessonPage() {
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]"></p>
<p className="mb-4 text-sm text-[var(--muted-foreground)]">便</p>
<a
href={DOWNLOAD_CONFIG.url}
target="_blank"
@@ -216,14 +475,6 @@ export default function LessonPage() {
)}
</div>
</main>
{toast && (
<div
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
role="alert"
>
{t("updating")}
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}