generated from zhongyidian/yx-react-template
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Overlay } from "@/components/ui/overlay";
|
|
import { useToast } from "@/components/ui/toast";
|
|
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
|
import {
|
|
DragDropProvider,
|
|
type DragDropProviderProps,
|
|
Draggable,
|
|
Droppable,
|
|
} from "@/course/interactions/drag-drop";
|
|
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>
|
|
);
|
|
}
|