Files
food-add-edu/src/pages/CourseDemo/index.tsx
T
2026-08-18 13:39:48 +08:00

337 lines
11 KiB
TypeScript

import { useEffect, useState } from "react";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/components/course-interactions/drag-drop";
import { Button } from "@/components/ui/button";
import { Overlay } from "@/components/ui/overlay";
import { useToast } from "@/components/ui/toast";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { cn } from "@/lib/cn";
import { useUserStore } from "@/stores";
import { getUserStorage, setUserStorage } from "@/utils/yiban";
import {
courseCategories,
courseResources,
createEmptyProgress,
parseProgress,
} from "./CourseDemo.data";
import { courseDemoDialog } from "./CourseDemo.dialog";
import { courseDemoSpotlight } from "./CourseDemo.spotlight";
import type { CourseFlowProgress } from "./CourseDemo.types";
import { CourseDemoGuideDialog } from "./CourseDemoGuideDialog";
import { CourseDemoGuideSpotlight } from "./CourseDemoGuideSpotlight";
const LESSON_ID = "course-flow";
const PROGRESS_KEY = "resource-classification";
export default function CourseDemo() {
const { scale } = useCourseCanvas();
const toast = useToast();
const { courseId, userId, yibanConnectionStatus } = useUserStore();
const [progress, setProgress] =
useState<CourseFlowProgress>(createEmptyProgress);
const [restored, setRestored] = useState(false);
const [completionOpen, setCompletionOpen] = useState(false);
const placedIds = Object.values(progress.placed).flat();
const progressPercent = Math.round(
(placedIds.length / courseResources.length) * 100,
);
useEffect(() => {
let active = true;
getUserStorage(LESSON_ID, PROGRESS_KEY)
.then((result) => {
if (!active || result.status !== "success") return;
const savedProgress = parseProgress(result.content);
if (savedProgress) setProgress(savedProgress);
})
.catch(() => {
if (active) toast.warning("暂时无法恢复进度,将从本节开始学习。");
})
.finally(() => {
if (active) setRestored(true);
});
return () => {
active = false;
};
}, [toast]);
const persistProgress = async (nextProgress: CourseFlowProgress) => {
try {
await setUserStorage(
LESSON_ID,
PROGRESS_KEY,
JSON.stringify(nextProgress),
);
} catch {
toast.warning("学习结果已保留在当前页面,远端同步稍后重试。");
}
};
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
const resource = courseResources.find(
(item) => item.id === String(event.active.id),
);
const categoryId = event.over ? String(event.over.id) : null;
if (!resource || !categoryId) {
toast.error("请把资源放入一个分类区域。");
return;
}
if (resource.category !== categoryId) {
toast.error(`“${resource.title}”不属于这个分类,再想一想。`);
return;
}
if (placedIds.includes(resource.id)) return;
const placed = {
...progress.placed,
[resource.category]: [...progress.placed[resource.category], resource.id],
};
const completed =
Object.values(placed).flat().length === courseResources.length;
const nextProgress = {
placed,
completed,
updatedAt: new Date().toISOString(),
};
setProgress(nextProgress);
void persistProgress(nextProgress);
toast.success(
completed
? "全部资源分类正确,本节练习已完成。"
: `分类正确:${resource.title}`,
);
if (completed) setCompletionOpen(true);
};
const resetProgress = () => {
const nextProgress = createEmptyProgress();
setProgress(nextProgress);
setCompletionOpen(false);
void persistProgress(nextProgress);
toast.info("练习已重置,可以重新完成分类。");
};
return (
<>
<div className="mx-auto max-w-7xl space-y-6 px-6 py-6 text-left">
<header className="grid gap-6 rounded-3xl bg-gradient-to-br from-slate-950 via-blue-950 to-indigo-950 p-7 text-white shadow-xl lg:grid-cols-[1fr_360px] lg:items-center">
<div className="space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-blue-200">
学生资助政策 · 互动学习
</p>
<h1 className="text-4xl font-bold">建立你的课程资源地图</h1>
<p className="max-w-3xl leading-7 text-blue-100">
阅读每张资源卡片,将它拖入“政策学习、互动实践、学习支持”中的正确位置。
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Button onClick={() => courseDemoDialog.start()}>
进入课程导入
</Button>
<Button
variant="outline"
onClick={() => courseDemoSpotlight.start()}
>
查看操作引导
</Button>
</div>
</div>
<div
className="rounded-2xl border border-white/15 bg-white/10 p-5 backdrop-blur"
data-guide="course-progress"
>
<div className="flex items-center justify-between text-sm text-blue-100">
<span>本节进度</span>
<strong>{progressPercent}%</strong>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/15">
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-300 to-emerald-300 transition-[width] duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-blue-100">
<span>
已完成 {placedIds.length}/{courseResources.length}
</span>
<span className="text-right">
{restored ? "进度已恢复" : "正在恢复进度"}
</span>
<span>scale {scale.toFixed(3)}</span>
<span className="text-right">{yibanConnectionStatus}</span>
</div>
</div>
</header>
<DragDropProvider
scale={scale}
onDragEnd={handleDragEnd}
renderOverlay={({ activeId }) => {
const resource = courseResources.find(
(item) => item.id === String(activeId),
);
return resource ? (
<ResourceCard resource={resource} overlay />
) : null;
}}
>
<section className="grid gap-5 lg:grid-cols-[360px_1fr]">
<aside
className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm"
data-guide="course-resources"
>
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-blue-600">
待分类资源
</p>
<h2 className="text-xl font-bold text-slate-950">资源卡片</h2>
</div>
<Button size="sm" variant="ghost" onClick={resetProgress}>
重新开始
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{courseResources
.filter((resource) => !placedIds.includes(resource.id))
.map((resource) => (
<Draggable
key={resource.id}
id={resource.id}
data={{
id: resource.id,
title: resource.title,
category: resource.category,
}}
>
<ResourceCard resource={resource} />
</Draggable>
))}
{progress.completed && (
<p className="rounded-2xl bg-emerald-50 p-5 text-center text-sm font-medium text-emerald-700">
所有资源都已进入正确分类。
</p>
)}
</div>
</aside>
<main
className="grid gap-4 md:grid-cols-3"
data-guide="course-categories"
>
{courseCategories.map((category) => (
<Droppable key={category.id} id={category.id}>
{({ isOver }) => (
<div
className={cn(
"flex min-h-[410px] flex-col rounded-3xl border-2 border-dashed bg-white p-5 shadow-sm transition",
isOver
? "border-blue-500 bg-blue-50 shadow-blue-100"
: "border-slate-200",
)}
>
<div className="text-3xl">{category.icon}</div>
<h2 className="mt-3 text-xl font-bold text-slate-950">
{category.title}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">
{category.description}
</p>
<div className="mt-5 space-y-3">
{progress.placed[category.id].map((resourceId) => {
const resource = courseResources.find(
(item) => item.id === resourceId,
);
return resource ? (
<ResourceCard
key={resource.id}
resource={resource}
placed
/>
) : null;
})}
</div>
<p className="mt-auto pt-5 text-xs font-medium text-slate-400">
拖到这里完成分类
</p>
</div>
)}
</Droppable>
))}
</main>
</section>
</DragDropProvider>
<footer className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-sm text-slate-500">
<span>
课程 {courseId ?? "本地预览"} · 学员 {userId ?? "访客"}
</span>
<span>每次正确分类都会自动保存,刷新页面后继续学习。</span>
</footer>
</div>
<Overlay
open={completionOpen}
onClose={() => setCompletionOpen(false)}
container={getAppPortalRoot}
contentClassName="flex items-center justify-center p-8"
>
<div className="w-[560px] max-w-full rounded-3xl bg-white p-8 text-center shadow-2xl">
<div className="text-5xl">🎉</div>
<p className="mt-4 text-sm font-semibold text-emerald-600">
本节完成
</p>
<h2 className="mt-2 text-3xl font-bold text-slate-950">
资源地图已建立
</h2>
<p className="mt-3 leading-7 text-slate-600">
你已经能够区分政策材料、互动练习和学习支持资源。学习结果已保存,可以进入下一节课程。
</p>
<Button className="mt-6" onClick={() => setCompletionOpen(false)}>
继续学习
</Button>
</div>
</Overlay>
<CourseDemoGuideDialog />
<CourseDemoGuideSpotlight />
</>
);
}
function ResourceCard({
resource,
overlay = false,
placed = false,
}: {
resource: (typeof courseResources)[number];
overlay?: boolean;
placed?: boolean;
}) {
return (
<div
className={cn(
"flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm",
overlay &&
"pointer-events-none w-[330px] shadow-xl ring-4 ring-blue-100",
placed && "border-emerald-200 bg-emerald-50",
)}
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-xl">
{resource.icon}
</span>
<span>
<strong className="block text-sm text-slate-900">
{resource.title}
</strong>
<span className="mt-1 block text-xs leading-5 text-slate-500">
{resource.description}
</span>
</span>
</div>
);
}