feat: 重构页面、路由与业务组件,新增课程演示

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-08-18 13:39:48 +08:00
1 parent d3ae0a8ca7
commit 7b326ee025
29 files changed
+2005 -57

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
import { useState } from "react";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/components/course-interactions/drag-drop";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cn";
import { useScale } from "@/utils";
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 = useScale();
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>
);
}
@@ -0,0 +1,30 @@
export type DragDemoItemTone =
| "rose"
| "blue"
| "amber"
| "lime"
| "cyan"
| "indigo";
export type DragDemoZoneTone = "emerald" | "sky" | "violet";
export interface DragDemoItem {
id: string;
label: string;
description: string;
icon: string;
tone: DragDemoItemTone;
targetZoneId: string;
}
export interface DragDemoZone {
id: string;
title: string;
description: string;
icon: string;
tone: DragDemoZoneTone;
}
export type DragDropDemoResult = {
type: "success" | "error";
message: string;
} | null;
+389
View File
@@ -0,0 +1,389 @@
import { useRef, useState } from "react";
import { GuideSpotlight } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Button } from "@/components/ui/button";
import { Overlay, type OverlayProps } from "@/components/ui/overlay";
import { Portal } from "@/components/ui/portal";
import { useToast } from "@/components/ui/toast";
import { useSpotlightStore } from "@/stores";
import { useScale } from "@/utils";
import { DragDropDemo } from "./DragDropDemo";
type OverlayDemoKey =
| "dim"
| "body"
| "custom"
| "light"
| "gradient"
| "image"
| "locked";
const overlayCards: Array<{
key: OverlayDemoKey;
title: string;
description: string;
buttonText: string;
overlayProps?: Pick<
OverlayProps,
| "variant"
| "backgroundImage"
| "backgroundClassName"
| "closeOnBackgroundClick"
>;
}> = [
{
key: "dim",
title: "课程画布遮罩",
description: "挂载到 App 画布,适合课程弹窗、确认提示和答题反馈。",
buttonText: "打开画布 Overlay",
overlayProps: {},
},
{
key: "body",
title: "body 挂载",
description: "默认挂载到 body,适合作为浏览器视口全局层。",
buttonText: "打开 body Overlay",
overlayProps: {},
},
{
key: "custom",
title: "ref 容器挂载",
description:
"通过 container={ref} 挂到上方蓝色虚线容器内,适合局部弹层测试。",
buttonText: "打开 ref Overlay",
overlayProps: { backgroundClassName: "bg-black/50" },
},
{
key: "light",
title: "浅色背景",
description: "适合浅色视觉风格或需要弱化遮罩的课程页面。",
buttonText: "打开浅色 Overlay",
overlayProps: { variant: "light" },
},
{
key: "gradient",
title: "Tailwind 自定义背景",
description: "通过 backgroundClassName 使用 Tailwind class 控制背景层。",
buttonText: "打开渐变 Overlay",
overlayProps: {
variant: "transparent",
backgroundClassName:
"bg-gradient-to-br from-blue-950/80 via-indigo-900/70 to-purple-900/80",
},
},
{
key: "image",
title: "背景图片示例",
description: "backgroundImage 接收合法的 CSS background-image 值。",
buttonText: "打开图片背景 Overlay",
overlayProps: {
variant: "transparent",
backgroundImage:
"linear-gradient(135deg, rgb(30 58 138 / 0.88), rgb(88 28 135 / 0.8))",
backgroundClassName: "bg-cover bg-center",
},
},
{
key: "locked",
title: "禁止点击背景关闭",
description: "适合必须点击按钮确认的课程提示或关键操作。",
buttonText: "打开锁定 Overlay",
overlayProps: {
closeOnBackgroundClick: false,
backgroundClassName: "bg-black/70",
},
},
];
export default function Components() {
const customRootRef = useRef<HTMLDivElement>(null);
const [portalVisible, setPortalVisible] = useState(false);
const [customPortalVisible, setCustomPortalVisible] = useState(false);
const [activeOverlay, setActiveOverlay] = useState<OverlayDemoKey | null>(
null,
);
const currentOverlay = overlayCards.find(
(item) => item.key === activeOverlay,
);
const isCustomOverlay = currentOverlay?.key === "custom";
const isBodyOverlay = currentOverlay?.key === "body";
const overlayContainer = isBodyOverlay
? undefined
: isCustomOverlay
? customRootRef
: getAppPortalRoot;
const toast = useToast();
const scale = useScale();
const startGuideSpotlight = () => {
useSpotlightStore.getState().startSpotlight({
courseId: "components-playground",
spotlightId: "spotlight-demo",
steps: [
{
id: "guide-card",
target: { dataGuide: "spotlight-card" },
title: "引导目标区域",
description:
"通过 data-guide 标记页面元素,GuideSpotlight 会自动定位并高亮目标。",
placement: "bottom",
},
{
id: "guide-actions",
target: { dataGuide: "spotlight-actions" },
title: "引导操作按钮",
description: "可以为不同步骤设置 placement、文案和高亮间距。",
placement: "top",
padding: 12,
},
{
id: "guide-note",
target: { dataGuide: "spotlight-note" },
title: "完成引导",
description:
"示例页中可以手动触发引导,便于调试课程页面的新手提示流程。",
placement: "left",
doneLabel: "知道了",
},
],
});
};
return (
<div className="mx-auto max-w-6xl space-y-10 px-6 py-10 text-left">
<header className="space-y-3 text-center">
<p className="text-sm font-medium text-blue-600">
Components Playground
</p>
<h1 className="text-4xl font-bold text-gray-900">组件示例页</h1>
<p className="text-gray-600">
用于验证组件在模板中正常使用,展示与组件库(yuxiang-kit)集成效果。
</p>
<div className="mx-auto mt-4 max-w-2xl rounded-2xl border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 text-left">
<p className="text-sm font-semibold text-blue-700">
📐 本模板使用固定 1920 × 1080 设计画布 + CSS transform 整体缩放
</p>
<p className="mt-1 text-sm text-blue-600">
当前缩放比例 scale = {scale.toFixed(3)}。 DragDrop 等组件通过 scale
参数修正坐标, GuideSpotlight 通过 viewport + container
定位画布内元素。
</p>
</div>
</header>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Button</h2>
<p className="text-gray-600">现有按钮组件的 variant 和 size 示例。</p>
</div>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Toast</h2>
<p className="text-gray-600">
通过 useToast 显示轻提示,支持 info、success、warning、error
四种类型。
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button onClick={() => toast.info("这是一条普通提示。")}>
普通提示
</Button>
<Button
variant="secondary"
onClick={() => toast.success("操作成功。")}
>
成功提示
</Button>
<Button
variant="outline"
onClick={() => toast.warning("请注意检查。")}
>
警告提示
</Button>
<Button
variant="ghost"
className="text-red-600"
onClick={() => toast.error("网络异常,请稍后重试。")}
>
错误提示
</Button>
<Button
variant="outline"
onClick={() =>
toast.show({
message: "这条提示使用了单条 className 覆盖。",
type: "success",
className: "rounded-full border-0 bg-black/80 px-8 text-white",
})
}
>
自定义样式提示
</Button>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">
GuideSpotlight
</h2>
<p className="text-gray-600">
引导组件会根据 data-guide 或 selector
高亮页面元素,适合课程新手引导、功能提示和操作说明。
</p>
</div>
<div
className="grid gap-4 rounded-xl border border-dashed border-blue-300 bg-blue-50 p-5 md:grid-cols-[1fr_auto] md:items-center"
data-guide="spotlight-card"
>
<div className="space-y-2">
<p className="text-sm font-medium text-blue-600">Guide Demo</p>
<h3 className="text-xl font-semibold text-gray-900">
课程引导示例区域
</h3>
<p className="text-sm text-gray-600" data-guide="spotlight-note">
点击右侧按钮后,会依次高亮这个卡片、操作按钮和这段说明文字。
</p>
</div>
<div className="flex flex-wrap gap-3" data-guide="spotlight-actions">
<Button onClick={startGuideSpotlight}>开始引导</Button>
<Button
variant="outline"
onClick={() =>
useSpotlightStore
.getState()
.resetSpotlight("components-playground", "spotlight-demo")
}
>
重置引导记录
</Button>
</div>
</div>
</section>
<DragDropDemo />
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Portal</h2>
<p className="text-gray-600">
Portal 默认挂载到 document.body,也可以通过 container
指定自定义挂载位置。
</p>
</div>
<div className="mb-5 flex flex-wrap gap-3">
<Button onClick={() => setPortalVisible((visible) => !visible)}>
{portalVisible ? "隐藏 body Portal" : "显示 body Portal"}
</Button>
<Button
variant="secondary"
onClick={() => setCustomPortalVisible((visible) => !visible)}
>
{customPortalVisible ? "隐藏 ref Portal" : "显示 ref Portal"}
</Button>
</div>
<div
ref={customRootRef}
className="relative h-44 overflow-hidden rounded-xl border-2 border-dashed border-blue-300 bg-blue-50 p-4"
>
<p className="text-sm text-blue-700">
这是自定义 ref 容器。Portal 或 Overlay 都可以通过 container
挂载到这个容器里。
</p>
</div>
{portalVisible && (
<Portal>
<div className="fixed right-8 bottom-8 z-60 rounded-xl bg-blue-600 px-5 py-4 text-white shadow-xl">
这是通过 Portal 挂载到 body 的内容
</div>
</Portal>
)}
{customPortalVisible && (
<Portal container={customRootRef}>
<div className="absolute right-4 bottom-4 rounded-xl bg-purple-600 px-5 py-4 text-white shadow-xl">
这是通过 container=&#123;ref&#125; 挂载的内容
</div>
</Portal>
)}
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Overlay</h2>
<p className="text-gray-600">
Overlay 内部已使用 Portal,可同时测试背景、关闭行为和挂载位置。
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{overlayCards.map((item) => (
<div
key={item.key}
className="rounded-xl border border-gray-200 p-4"
>
<h3 className="mb-2 text-lg font-semibold text-gray-900">
{item.title}
</h3>
<p className="mb-4 min-h-12 text-sm text-gray-600">
{item.description}
</p>
<Button onClick={() => setActiveOverlay(item.key)}>
{item.buttonText}
</Button>
</div>
))}
</div>
</section>
{currentOverlay && (
<Overlay
open={Boolean(currentOverlay)}
onClose={() => setActiveOverlay(null)}
container={overlayContainer}
className={isCustomOverlay ? "z-10" : undefined}
contentClassName="flex items-center justify-center p-8"
{...currentOverlay.overlayProps}
>
<div className="w-full max-w-xl rounded-3xl bg-white p-8 text-center shadow-2xl">
<p className="mb-2 text-sm font-medium text-blue-600">
Overlay Demo
</p>
<h3 className="mb-3 text-2xl font-bold text-gray-900">
{currentOverlay.title}
</h3>
<p className="mb-6 text-gray-600">{currentOverlay.description}</p>
<div className="flex justify-center gap-3">
<Button onClick={() => setActiveOverlay(null)}>关闭</Button>
<Button variant="outline" onClick={() => setActiveOverlay(null)}>
模拟继续学习
</Button>
</div>
</div>
</Overlay>
)}
<GuideSpotlight />
</div>
);
}