forked from zhongyidian/food-add-edu
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
DragDropProvider,
|
|
type DragDropProviderProps,
|
|
Draggable,
|
|
Droppable,
|
|
} from "@/components/course-interactions/drag-drop";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useCourseCanvas } from "@/course-runtime/canvas";
|
|
import { cn } from "@/lib/cn";
|
|
import type {
|
|
DragDemoItem,
|
|
DragDemoZone,
|
|
DragDropDemoResult,
|
|
} from "./DragDropDemo.types";
|
|
|
|
const dragItems = [
|
|
{
|
|
id: "apple",
|
|
label: "苹果",
|
|
description: "富含维生素的水果",
|
|
icon: "🍎",
|
|
tone: "rose",
|
|
targetZoneId: "fruit-zone",
|
|
},
|
|
{
|
|
id: "banana",
|
|
label: "香蕉",
|
|
description: "常见的热带水果",
|
|
icon: "🍌",
|
|
tone: "lime",
|
|
targetZoneId: "fruit-zone",
|
|
},
|
|
{
|
|
id: "car",
|
|
label: "汽车",
|
|
description: "陆地出行工具",
|
|
icon: "🚗",
|
|
tone: "blue",
|
|
targetZoneId: "traffic-zone",
|
|
},
|
|
{
|
|
id: "bike",
|
|
label: "自行车",
|
|
description: "绿色低碳出行工具",
|
|
icon: "🚲",
|
|
tone: "cyan",
|
|
targetZoneId: "traffic-zone",
|
|
},
|
|
{
|
|
id: "book",
|
|
label: "书本",
|
|
description: "阅读和学习资料",
|
|
icon: "📘",
|
|
tone: "amber",
|
|
targetZoneId: "study-zone",
|
|
},
|
|
{
|
|
id: "pencil",
|
|
label: "铅笔",
|
|
description: "书写和绘画工具",
|
|
icon: "✏️",
|
|
tone: "indigo",
|
|
targetZoneId: "study-zone",
|
|
},
|
|
] satisfies DragDemoItem[];
|
|
|
|
const dropZones = [
|
|
{
|
|
id: "fruit-zone",
|
|
title: "水果类",
|
|
description: "把可以食用的水果放到这里",
|
|
icon: "🍓",
|
|
tone: "emerald",
|
|
},
|
|
{
|
|
id: "traffic-zone",
|
|
title: "交通工具",
|
|
description: "把出行相关物品放到这里",
|
|
icon: "🚌",
|
|
tone: "sky",
|
|
},
|
|
{
|
|
id: "study-zone",
|
|
title: "学习用品",
|
|
description: "把学习和书写用品放到这里",
|
|
icon: "🎒",
|
|
tone: "violet",
|
|
},
|
|
] satisfies DragDemoZone[];
|
|
|
|
const dragItemClassNames = {
|
|
rose: "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-100",
|
|
blue: "border-blue-200 bg-blue-50 text-blue-700 shadow-blue-100",
|
|
amber: "border-amber-200 bg-amber-50 text-amber-700 shadow-amber-100",
|
|
lime: "border-lime-200 bg-lime-50 text-lime-700 shadow-lime-100",
|
|
cyan: "border-cyan-200 bg-cyan-50 text-cyan-700 shadow-cyan-100",
|
|
indigo: "border-indigo-200 bg-indigo-50 text-indigo-700 shadow-indigo-100",
|
|
};
|
|
|
|
const zoneIconClassNames = {
|
|
emerald: "bg-emerald-100 text-emerald-600",
|
|
sky: "bg-sky-100 text-sky-600",
|
|
violet: "bg-violet-100 text-violet-600",
|
|
};
|
|
|
|
const zoneOverClassNames = {
|
|
emerald:
|
|
"border-emerald-400 bg-emerald-50 shadow-[0_0_0_6px_rgba(16,185,129,0.10),0_18px_45px_rgba(16,185,129,0.16)]",
|
|
sky: "border-sky-400 bg-sky-50 shadow-[0_0_0_6px_rgba(14,165,233,0.10),0_18px_45px_rgba(14,165,233,0.16)]",
|
|
violet:
|
|
"border-violet-400 bg-violet-50 shadow-[0_0_0_6px_rgba(139,92,246,0.10),0_18px_45px_rgba(139,92,246,0.16)]",
|
|
};
|
|
|
|
const findDragItem = (itemId: string) =>
|
|
dragItems.find((item) => item.id === itemId);
|
|
|
|
const getZoneTotal = (zoneId: string) =>
|
|
dragItems.filter((item) => item.targetZoneId === zoneId).length;
|
|
|
|
const renderDragItem = (item: DragDemoItem, className?: string) => (
|
|
<div
|
|
className={cn(
|
|
"flex h-16 items-center gap-3 rounded-2xl border px-4 font-medium shadow-sm select-none",
|
|
dragItemClassNames[item.tone],
|
|
className,
|
|
)}
|
|
>
|
|
<span className="flex size-10 items-center justify-center rounded-full bg-white/70 text-xl shadow-sm">
|
|
{item.icon}
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block truncate">{item.label}</span>
|
|
<span className="mt-0.5 block truncate text-xs opacity-70">
|
|
{item.description}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
const renderPlacedItem = (item: DragDemoItem) => (
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium shadow-sm",
|
|
dragItemClassNames[item.tone],
|
|
)}
|
|
>
|
|
<span className="flex size-7 items-center justify-center rounded-full bg-white/70 text-base shadow-sm">
|
|
{item.icon}
|
|
</span>
|
|
<span>{item.label}</span>
|
|
</div>
|
|
);
|
|
|
|
export function DragDropDemo() {
|
|
const { scale } = useCourseCanvas();
|
|
const [dropResult, setDropResult] = useState<DragDropDemoResult>(null);
|
|
const [placedItemIds, setPlacedItemIds] = useState<Record<string, string[]>>(
|
|
{},
|
|
);
|
|
const placedIds = Object.values(placedItemIds).flat();
|
|
const placedCount = placedIds.length;
|
|
const progress = Math.round((placedCount / dragItems.length) * 100);
|
|
const isCompleted = placedCount === dragItems.length;
|
|
|
|
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
|
|
if (isCompleted) {
|
|
return;
|
|
}
|
|
|
|
const draggedId = String(event.active.id);
|
|
const droppedId = event.over ? String(event.over.id) : null;
|
|
const draggedItem = findDragItem(draggedId);
|
|
|
|
if (!droppedId || !draggedItem) {
|
|
setDropResult({
|
|
type: "error",
|
|
message: "没有放入目标区域,请再试一次。",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (placedIds.includes(draggedId)) {
|
|
return;
|
|
}
|
|
|
|
if (draggedItem.targetZoneId !== droppedId) {
|
|
setDropResult({
|
|
type: "error",
|
|
message: `还不对:${draggedItem.label} 不属于这个分类。`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const nextPlacedItemIds = {
|
|
...placedItemIds,
|
|
[droppedId]: [...(placedItemIds[droppedId] ?? []), draggedId],
|
|
};
|
|
const nextPlacedCount = Object.values(nextPlacedItemIds).flat().length;
|
|
|
|
setPlacedItemIds(nextPlacedItemIds);
|
|
setDropResult({
|
|
type: "success",
|
|
message:
|
|
nextPlacedCount === dragItems.length
|
|
? "全部分类完成,做得很好!"
|
|
: `答对了:${draggedItem.label} 已放入正确分类。`,
|
|
});
|
|
};
|
|
|
|
const handleDragStart = () => {
|
|
if (!isCompleted) {
|
|
setDropResult(null);
|
|
}
|
|
};
|
|
|
|
const handleDragCancel = () => {
|
|
if (!isCompleted) {
|
|
setDropResult(null);
|
|
}
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
setDropResult(null);
|
|
setPlacedItemIds({});
|
|
};
|
|
|
|
return (
|
|
<section className="overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white shadow-sm">
|
|
<div className="border-slate-200 border-b bg-white/80 px-6 py-5">
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-semibold text-sky-600">
|
|
Interactive Course Demo
|
|
</p>
|
|
<h2 className="text-2xl font-semibold text-slate-950">
|
|
Drag and Drop 分类挑战
|
|
</h2>
|
|
<p className="max-w-2xl text-slate-500">
|
|
将左侧物品拖入右侧正确分类。拖拽预览使用自定义
|
|
DragOverlay,释放区根据是否匹配给出颜色反馈。
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500">
|
|
完成 {placedCount}/{dragItems.length}
|
|
</div>
|
|
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500">
|
|
scale {scale.toFixed(3)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 h-2 overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-sky-400 to-emerald-400 transition-[width] duration-300"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<DragDropProvider
|
|
scale={scale}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
renderOverlay={({ activeId }) => {
|
|
const activeItem = findDragItem(String(activeId));
|
|
|
|
return activeItem
|
|
? renderDragItem(
|
|
activeItem,
|
|
"pointer-events-none w-[300px] shadow-xl ring-4 ring-white/70",
|
|
)
|
|
: null;
|
|
}}
|
|
>
|
|
<div className="grid gap-5 lg:grid-cols-[320px_1fr]">
|
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
<div className="mb-4 flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-semibold text-slate-800">
|
|
待分类物品
|
|
</div>
|
|
<div className="mt-1 text-xs text-slate-400">
|
|
拖动物品到右侧对应区域
|
|
</div>
|
|
</div>
|
|
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
|
|
{dragItems.length - placedCount} left
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
{dragItems
|
|
.filter((item) => !placedIds.includes(item.id))
|
|
.map((item) => (
|
|
<Draggable
|
|
key={item.id}
|
|
id={item.id}
|
|
disabled={isCompleted}
|
|
>
|
|
{renderDragItem(item)}
|
|
</Draggable>
|
|
))}
|
|
{isCompleted && (
|
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
|
所有物品都已完成分类
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{dropZones.map((zone) => {
|
|
const zonePlacedIds = placedItemIds[zone.id] ?? [];
|
|
const placedItems = zonePlacedIds
|
|
.map((itemId) => findDragItem(itemId))
|
|
.filter((item): item is DragDemoItem => Boolean(item));
|
|
const zoneTotal = getZoneTotal(zone.id);
|
|
|
|
return (
|
|
<Droppable
|
|
key={zone.id}
|
|
id={zone.id}
|
|
canDrop={(activeId) => {
|
|
const activeItem = findDragItem(String(activeId));
|
|
|
|
return (
|
|
activeItem?.targetZoneId === zone.id &&
|
|
!zonePlacedIds.includes(String(activeId))
|
|
);
|
|
}}
|
|
className={({ isOver, canDrop }) =>
|
|
cn(
|
|
"min-h-80 rounded-3xl border-2 border-dashed border-slate-300 bg-white p-5 transition-[background-color,border-color,box-shadow] duration-150",
|
|
isOver && canDrop && zoneOverClassNames[zone.tone],
|
|
isOver &&
|
|
!canDrop &&
|
|
"border-rose-300 bg-rose-50 shadow-[0_0_0_6px_rgba(244,63,94,0.08)]",
|
|
)
|
|
}
|
|
>
|
|
<div className="flex h-full flex-col">
|
|
<div className="mb-4 flex items-start justify-between gap-3">
|
|
<div>
|
|
<div
|
|
className={cn(
|
|
"mb-3 flex size-12 items-center justify-center rounded-2xl text-xl",
|
|
zoneIconClassNames[zone.tone],
|
|
)}
|
|
>
|
|
{zone.icon}
|
|
</div>
|
|
<div className="font-semibold text-slate-800">
|
|
{zone.title}
|
|
</div>
|
|
<div className="mt-1 text-sm text-slate-400">
|
|
{zone.description}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
|
|
{placedItems.length}/{zoneTotal}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-auto grid gap-2">
|
|
{placedItems.length > 0 ? (
|
|
placedItems.map((item) => (
|
|
<div key={item.id}>{renderPlacedItem(item)}</div>
|
|
))
|
|
) : (
|
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-5 text-center text-sm text-slate-400">
|
|
等待放入正确物品
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Droppable>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</DragDropProvider>
|
|
|
|
{dropResult && (
|
|
<div
|
|
className={cn(
|
|
"mt-4 flex items-center justify-between gap-3 rounded-2xl border bg-white px-4 py-3 text-sm shadow-sm",
|
|
dropResult.type === "success"
|
|
? "border-emerald-200 text-emerald-700"
|
|
: "border-rose-200 text-rose-600",
|
|
)}
|
|
>
|
|
<span>{dropResult.message}</span>
|
|
<Button size="sm" variant="ghost" onClick={handleRetry}>
|
|
重试
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|