s
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user