forked from zhongyidian/food-add-edu
feat: 重构页面、路由与业务组件,新增课程演示
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d3ae0a8ca7
commit
7b326ee025
29 files changed
+2005
-57
No files matched your search
@@ -0,0 +1,336 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user