Template
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
@@ -1,16 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/common";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">关于我们</h1>
|
||||
<p className="mb-4 text-lg">这是关于页面</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/">
|
||||
<Button variant="secondary">返回首页</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { About } from "./About.tsx";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">关于我们</h1>
|
||||
<p className="text-lg text-gray-600">这是关于页面</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/">
|
||||
<Button variant="secondary">返回首页</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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={ref} 挂载的内容
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CourseCategory,
|
||||
CourseFlowProgress,
|
||||
CourseResource,
|
||||
CourseResourceCategory,
|
||||
} from "./CourseDemo.types";
|
||||
|
||||
export const courseResources: CourseResource[] = [
|
||||
{
|
||||
id: "policy-video",
|
||||
title: "资助政策微课",
|
||||
description: "了解国家助学政策和申请条件",
|
||||
category: "policy",
|
||||
icon: "🎬",
|
||||
},
|
||||
{
|
||||
id: "policy-guide",
|
||||
title: "申请流程图",
|
||||
description: "按步骤核对材料与办理节点",
|
||||
category: "policy",
|
||||
icon: "🗺️",
|
||||
},
|
||||
{
|
||||
id: "practice-case",
|
||||
title: "诚信申请案例",
|
||||
description: "判断案例中的规范与风险",
|
||||
category: "practice",
|
||||
icon: "🧩",
|
||||
},
|
||||
{
|
||||
id: "practice-checklist",
|
||||
title: "材料自查清单",
|
||||
description: "动手检查课程中的模拟材料",
|
||||
category: "practice",
|
||||
icon: "✅",
|
||||
},
|
||||
{
|
||||
id: "support-qa",
|
||||
title: "常见问题答疑",
|
||||
description: "查找填报和审核中的常见问题",
|
||||
category: "support",
|
||||
icon: "💬",
|
||||
},
|
||||
{
|
||||
id: "support-contact",
|
||||
title: "校园咨询渠道",
|
||||
description: "联系辅导员与学生资助中心",
|
||||
category: "support",
|
||||
icon: "☎️",
|
||||
},
|
||||
];
|
||||
|
||||
export const courseCategories: CourseCategory[] = [
|
||||
{
|
||||
id: "policy",
|
||||
title: "政策学习",
|
||||
description: "帮助理解政策与办理流程",
|
||||
icon: "📚",
|
||||
},
|
||||
{
|
||||
id: "practice",
|
||||
title: "互动实践",
|
||||
description: "需要判断、检查或动手完成",
|
||||
icon: "🧠",
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
title: "学习支持",
|
||||
description: "遇到问题时可以随时查阅",
|
||||
icon: "🤝",
|
||||
},
|
||||
];
|
||||
|
||||
export function createEmptyProgress(): CourseFlowProgress {
|
||||
return {
|
||||
placed: { policy: [], practice: [], support: [] },
|
||||
completed: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProgress(content: string): CourseFlowProgress | null {
|
||||
try {
|
||||
const value = JSON.parse(content) as Partial<CourseFlowProgress>;
|
||||
if (!value.placed || typeof value.placed !== "object") return null;
|
||||
const placed = Object.fromEntries(
|
||||
courseCategories.map(({ id }) => [
|
||||
id,
|
||||
Array.isArray(value.placed?.[id])
|
||||
? value.placed[id].filter((item): item is string =>
|
||||
courseResources.some(
|
||||
(resource) => resource.id === item && resource.category === id,
|
||||
),
|
||||
)
|
||||
: [],
|
||||
]),
|
||||
) as Record<CourseResourceCategory, string[]>;
|
||||
const placedCount = Object.values(placed).flat().length;
|
||||
return {
|
||||
placed,
|
||||
completed: placedCount === courseResources.length,
|
||||
updatedAt: value.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineDialog } from "@/components/common";
|
||||
|
||||
export const courseDemoDialog = defineDialog({
|
||||
id: "resource-map-opening",
|
||||
showOnce: false,
|
||||
steps: [
|
||||
{
|
||||
id: "welcome",
|
||||
character: { name: "小易", avatar: "🧭", mood: "ready" },
|
||||
title: "欢迎进入学生资助政策微课",
|
||||
description:
|
||||
"这节课不需要记住组件名称。你将通过一次真实的资源分类任务,认识政策学习、互动实践和学习支持之间的关系。",
|
||||
nextLabel: "了解任务",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "choose-path",
|
||||
character: { name: "小易", avatar: "✨", mood: "thinking" },
|
||||
title: "选择你的学习方式",
|
||||
description: "第一次体验可以先查看操作引导,熟悉拖拽后也可以直接开始。",
|
||||
options: [
|
||||
{ id: "guided", label: "先听操作说明", nextId: "guided-start" },
|
||||
{ id: "direct", label: "直接开始分类", nextId: "direct-start" },
|
||||
],
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "guided-start",
|
||||
character: { name: "小易", avatar: "🗺️", mood: "guide" },
|
||||
title: "按资源用途判断分类",
|
||||
description:
|
||||
"关闭对话后点击“查看操作引导”,页面会依次介绍资源区、分类区和进度区。",
|
||||
doneLabel: "进入页面",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "direct-start",
|
||||
character: { name: "小易", avatar: "🎯", mood: "active" },
|
||||
title: "准备开始",
|
||||
description:
|
||||
"阅读卡片说明,将六张资源卡放入正确区域。错误分类可以立即重试,正确结果会自动保存。",
|
||||
doneLabel: "开始挑战",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineSpotlight } from "@/components/common";
|
||||
|
||||
export const courseDemoSpotlight = defineSpotlight({
|
||||
id: "resource-map",
|
||||
showOnce: false,
|
||||
steps: [
|
||||
{
|
||||
id: "course-resources",
|
||||
title: "先阅读资源卡片",
|
||||
description: "每张卡片都包含用途说明。按住卡片,将它拖向右侧的正确分类。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "course-categories",
|
||||
title: "放入对应学习阶段",
|
||||
description:
|
||||
"政策材料用于理解知识,互动资源用于练习,答疑与咨询属于学习支持。",
|
||||
placement: "top",
|
||||
padding: 12,
|
||||
},
|
||||
{
|
||||
id: "course-progress",
|
||||
title: "自动记录学习进度",
|
||||
description:
|
||||
"每次正确分类都会保存;刷新页面后仍可继续,完成时会展示本节学习结果。",
|
||||
placement: "left",
|
||||
doneLabel: "开始分类",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export type CourseResourceCategory = "policy" | "practice" | "support";
|
||||
|
||||
export interface CourseResource {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CourseResourceCategory;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CourseCategory {
|
||||
id: CourseResourceCategory;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CourseFlowProgress {
|
||||
placed: Record<CourseResourceCategory, string[]>;
|
||||
completed: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GuideDialog } from "@/components/common";
|
||||
import { CourseDemoGuideDialogPanel } from "./CourseDemoGuideDialogPanel";
|
||||
|
||||
export function CourseDemoGuideDialog() {
|
||||
return (
|
||||
<GuideDialog
|
||||
theme={{
|
||||
panelMaxWidth: 820,
|
||||
overlayColor: "rgb(2 6 23 / 0.68)",
|
||||
}}
|
||||
>
|
||||
{(dialog) => <CourseDemoGuideDialogPanel dialog={dialog} />}
|
||||
</GuideDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { GuideDialogRenderState } from "@/components/common";
|
||||
|
||||
interface CourseDemoGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
|
||||
function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-cyan-100">
|
||||
{Array.from({ length: dialog.progress.total }).map((_, index) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static progress marks match dialog step order.
|
||||
key={index}
|
||||
className={
|
||||
index === dialog.progress.index
|
||||
? "h-2 w-8 rounded-full bg-current"
|
||||
: "h-2 w-2 rounded-full bg-current/24"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogActions({ dialog }: CourseDemoGuideDialogPanelProps) {
|
||||
const { actions, labels, status } = dialog;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.skip}
|
||||
className="rounded-full border border-white/18 bg-white/10 px-4 py-2 text-sm font-bold text-white/82 backdrop-blur transition hover:bg-white/18"
|
||||
>
|
||||
{labels.skip}
|
||||
</button>
|
||||
{!status.isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.prev}
|
||||
className="rounded-full border border-white/18 bg-white/10 px-4 py-2 text-sm font-bold text-white/82 backdrop-blur transition hover:bg-white/18"
|
||||
>
|
||||
{labels.prev}
|
||||
</button>
|
||||
)}
|
||||
{!status.hasOptions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.primary}
|
||||
className="rounded-full bg-cyan-300 px-6 py-2 text-sm font-black text-slate-950 shadow-xl shadow-cyan-300/30 transition hover:bg-cyan-200"
|
||||
>
|
||||
{labels.primary}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseDemoGuideDialogPanel({
|
||||
dialog,
|
||||
}: CourseDemoGuideDialogPanelProps) {
|
||||
const { character, options, step } = dialog;
|
||||
|
||||
return (
|
||||
<div className="relative w-[820px] text-white">
|
||||
<div className="absolute -top-20 left-12 flex h-36 w-36 items-center justify-center rounded-[42px] border-[6px] border-cyan-200 bg-slate-950 text-6xl shadow-[0_0_48px_rgba(103,232,249,0.45)]">
|
||||
{character?.avatar ?? "🙂"}
|
||||
</div>
|
||||
<div className="absolute left-36 top-8 h-0 w-0 border-y-[22px] border-r-[42px] border-y-transparent border-r-cyan-200" />
|
||||
|
||||
<div className="overflow-hidden rounded-[42px] border-[6px] border-cyan-200 bg-slate-950/94 shadow-[0_32px_100px_rgba(8,47,73,0.62)] backdrop-blur">
|
||||
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-cyan-400/20 blur-3xl" />
|
||||
<div className="absolute -bottom-28 left-20 h-72 w-72 rounded-full bg-blue-500/20 blur-3xl" />
|
||||
<div className="relative grid grid-cols-[170px_1fr] gap-5 p-7 pt-10">
|
||||
<div className="flex flex-col justify-end pt-20">
|
||||
<div className="rounded-3xl bg-white/10 p-4 backdrop-blur">
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-cyan-200">
|
||||
Guide Dialog
|
||||
</p>
|
||||
{character?.name && (
|
||||
<p className="mt-3 text-2xl font-black">{character.name}</p>
|
||||
)}
|
||||
{character?.mood && (
|
||||
<p className="mt-1 text-sm font-semibold text-cyan-100/70">
|
||||
{character.mood}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<DialogProgress dialog={dialog} />
|
||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-black text-cyan-100">
|
||||
{dialog.progress.current} / {dialog.progress.total}
|
||||
</span>
|
||||
</div>
|
||||
{step.title && (
|
||||
<h3 className="mt-5 text-4xl font-black leading-tight">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-4 text-xl font-semibold leading-9 text-cyan-50/86">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="mt-6 grid gap-3">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => dialog.actions.selectOption(option.id)}
|
||||
className="group flex items-center justify-between rounded-2xl border border-cyan-200/24 bg-white/10 px-5 py-4 text-left text-base font-black text-white transition hover:border-cyan-200 hover:bg-cyan-200 hover:text-slate-950"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<span className="text-cyan-200 transition group-hover:translate-x-1 group-hover:text-slate-950">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-7 flex items-center justify-end">
|
||||
<DialogActions dialog={dialog} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { CourseDemoGuideSpotlightPanel } from "./CourseDemoGuideSpotlightPanel";
|
||||
|
||||
export function CourseDemoGuideSpotlight() {
|
||||
return (
|
||||
<GuideSpotlight
|
||||
theme={{
|
||||
panelMaxWidth: 620,
|
||||
offset: 20,
|
||||
}}
|
||||
>
|
||||
{(spotlight) => <CourseDemoGuideSpotlightPanel spotlight={spotlight} />}
|
||||
</GuideSpotlight>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { GuideSpotlightRenderState } from "@/components/common";
|
||||
|
||||
interface CourseDemoGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
|
||||
function GuideActions({
|
||||
spotlight,
|
||||
className,
|
||||
}: CourseDemoGuideSpotlightPanelProps & { className: string }) {
|
||||
const { actions, labels, status } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.skip}
|
||||
className="rounded-full bg-white/12 px-4 py-2 text-sm font-semibold text-current backdrop-blur transition hover:bg-white/22"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
{!status.isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.prev}
|
||||
className="rounded-full bg-white/12 px-4 py-2 text-sm font-semibold text-current backdrop-blur transition hover:bg-white/22"
|
||||
>
|
||||
{labels.prev}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.primary}
|
||||
className={`rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition ${className}`}
|
||||
>
|
||||
{labels.primary}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressMarks({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: spotlight.progress.total }).map((_, index) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static progress marks match spotlight step order.
|
||||
key={index}
|
||||
className={
|
||||
index === spotlight.progress.index
|
||||
? "h-2 w-8 rounded-full bg-current"
|
||||
: "h-2 w-2 rounded-full bg-current/20"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
const { progress, step } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="relative w-[720px] text-slate-950">
|
||||
<div className="absolute -top-8 left-10 h-8 w-[460px] border-t-4 border-dashed border-amber-300" />
|
||||
<div className="absolute -top-8 left-[455px] h-8 w-8 rounded-full border-4 border-amber-300 bg-white shadow-[0_0_28px_rgba(251,191,36,0.7)]" />
|
||||
<div className="absolute -top-4 left-[484px] h-0 w-0 border-y-[14px] border-l-[30px] border-y-transparent border-l-amber-300" />
|
||||
|
||||
<div className="relative ml-0 rounded-[36px] border-[6px] border-white bg-amber-50/92 px-8 py-6 shadow-[0_28px_80px_rgba(120,53,15,0.28)] backdrop-blur">
|
||||
<div className="absolute -right-8 -top-8 flex h-20 w-20 rotate-12 items-center justify-center rounded-full bg-amber-300 text-4xl shadow-xl shadow-amber-300/40">
|
||||
🔍
|
||||
</div>
|
||||
<div className="absolute -bottom-5 left-20 h-10 w-10 rotate-45 border-b-[6px] border-r-[6px] border-white bg-amber-50/92" />
|
||||
<div className="max-w-[610px]">
|
||||
<p className="text-sm font-black uppercase tracking-[0.24em] text-amber-600">
|
||||
Map Pointer · {progress.current}/{progress.total}
|
||||
</p>
|
||||
{step.title && (
|
||||
<h3 className="mt-3 text-4xl font-black leading-tight text-slate-900">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-3 text-xl font-semibold leading-9 text-slate-700">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between text-amber-700">
|
||||
<ProgressMarks spotlight={spotlight} />
|
||||
<GuideActions
|
||||
spotlight={spotlight}
|
||||
className="bg-amber-500 shadow-amber-500/35 hover:bg-amber-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
const { progress, step } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="relative w-[660px] text-white">
|
||||
<div className="absolute -left-16 top-12 h-28 w-28 rounded-full border-[10px] border-fuchsia-300/80 bg-fuchsia-500/20 shadow-[0_0_40px_rgba(217,70,239,0.7)]" />
|
||||
<div className="absolute -left-3 top-[88px] h-1.5 w-28 bg-linear-to-r from-fuchsia-300 to-transparent" />
|
||||
<div className="absolute -left-24 top-[104px] h-0 w-0 border-y-[18px] border-r-[38px] border-y-transparent border-r-fuchsia-300" />
|
||||
|
||||
<div className="relative ml-8 overflow-hidden rounded-[34px] bg-slate-950 shadow-[0_30px_90px_rgba(76,29,149,0.5)] ring-4 ring-fuchsia-300/45">
|
||||
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-fuchsia-500/30 blur-3xl" />
|
||||
<div className="absolute -bottom-20 left-10 h-52 w-52 rounded-full bg-violet-500/30 blur-3xl" />
|
||||
<div className="relative grid grid-cols-[1fr_170px] gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-fuchsia-200">
|
||||
Signal Route · {progress.current}/{progress.total}
|
||||
</p>
|
||||
{step.title && (
|
||||
<h3 className="mt-3 text-3xl font-black leading-tight">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-3 text-base leading-7 text-fuchsia-50/78">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-between gap-4 text-fuchsia-100">
|
||||
<ProgressMarks spotlight={spotlight} />
|
||||
<GuideActions
|
||||
spotlight={spotlight}
|
||||
className="bg-fuchsia-500 shadow-fuchsia-500/35 hover:bg-fuchsia-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-[28px] border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||
<div className="absolute -left-4 top-1/2 h-8 w-8 -translate-y-1/2 rotate-45 border-b border-l border-white/10 bg-white/10" />
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="text-5xl font-black leading-none text-fuchsia-200">
|
||||
01
|
||||
</div>
|
||||
<div className="space-y-2 text-sm font-bold text-white/88">
|
||||
<div className="rounded-full bg-white/12 px-3 py-2">
|
||||
写入缓存
|
||||
</div>
|
||||
<div className="rounded-full bg-white/12 px-3 py-2">
|
||||
读取进度
|
||||
</div>
|
||||
<div className="rounded-full bg-white/12 px-3 py-2">
|
||||
本地预览
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
|
||||
const { progress, step } = spotlight;
|
||||
|
||||
return (
|
||||
<div className="relative w-[700px] text-slate-950">
|
||||
<div className="absolute -right-8 top-1/2 h-32 w-32 -translate-y-1/2 rounded-full border-[12px] border-lime-300 bg-white/80 shadow-[0_0_44px_rgba(190,242,100,0.85)]" />
|
||||
<div className="absolute -right-28 top-1/2 h-[5px] w-32 -translate-y-1/2 bg-linear-to-l from-lime-300 to-transparent" />
|
||||
<div className="absolute -right-36 top-1/2 h-0 w-0 -translate-y-1/2 border-y-[16px] border-l-[34px] border-y-transparent border-l-lime-300" />
|
||||
|
||||
<div className="relative mr-16 overflow-hidden rounded-[38px] bg-lime-300 shadow-[0_30px_90px_rgba(63,98,18,0.32)]">
|
||||
<div className="grid grid-cols-[190px_1fr]">
|
||||
<div className="flex flex-col justify-between p-6">
|
||||
<div>
|
||||
<div className="text-6xl font-black leading-none">GO</div>
|
||||
<p className="mt-3 text-sm font-black uppercase tracking-[0.18em]">
|
||||
Action Target
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">
|
||||
{progress.current} / {progress.total} · 现在试试课程交互入口
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-l-[34px] bg-slate-950 p-7 text-white">
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-lime-300">
|
||||
Control Deck
|
||||
</p>
|
||||
{step.title && (
|
||||
<h3 className="mt-3 text-3xl font-black leading-tight">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
{step.description && (
|
||||
<div className="mt-3 text-base leading-7 text-slate-300">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-between text-lime-200">
|
||||
<ProgressMarks spotlight={spotlight} />
|
||||
<GuideActions
|
||||
spotlight={spotlight}
|
||||
className="bg-lime-400 text-slate-950 shadow-lime-400/30 hover:bg-lime-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseDemoGuideSpotlightPanel({
|
||||
spotlight,
|
||||
}: CourseDemoGuideSpotlightPanelProps) {
|
||||
switch (spotlight.step.id) {
|
||||
case "course-layout":
|
||||
return <LayoutGuidePanel spotlight={spotlight} />;
|
||||
case "session-fallback":
|
||||
return <SessionGuidePanel spotlight={spotlight} />;
|
||||
case "course-actions":
|
||||
return <ActionsGuidePanel spotlight={spotlight} />;
|
||||
default:
|
||||
return <LayoutGuidePanel spotlight={spotlight} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/common";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">首页</h1>
|
||||
<p className="mb-4 text-lg">欢迎来到首页</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/about">
|
||||
<Button>关于我们</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Home } from "./Home.tsx";
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppStore, useSpotlightStore } from "@/stores";
|
||||
|
||||
export default function Home() {
|
||||
const { count, increment, reset } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
useSpotlightStore.getState().startSpotlight({
|
||||
courseId: "template-home",
|
||||
spotlightId: "intro",
|
||||
steps: [
|
||||
{
|
||||
id: "counter",
|
||||
target: { dataGuide: "counter-actions" },
|
||||
title: "状态管理示例",
|
||||
description: "这里演示 Zustand 的基础用法。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "about-link",
|
||||
target: { dataGuide: "about-link" },
|
||||
title: "路由跳转",
|
||||
description: "点击这里可以跳转到关于页面。",
|
||||
placement: "top",
|
||||
},
|
||||
],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">首页</h1>
|
||||
<p className="text-lg text-gray-600">欢迎来到首页</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center gap-4"
|
||||
data-guide="counter-actions"
|
||||
>
|
||||
<Button onClick={increment}>计数:{count}</Button>
|
||||
<Button variant="secondary" onClick={reset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/about" data-guide="about-link">
|
||||
<Button>关于我们</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<GuideSpotlight />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -1,2 +1,4 @@
|
||||
export { About } from "./About";
|
||||
export { Home } from "./Home";
|
||||
export { default as About } from "./About";
|
||||
export { default as Components } from "./Components";
|
||||
export { default as CourseDemo } from "./CourseDemo";
|
||||
export { default as Home } from "./Home";
|
||||
Reference in new issue
Block a user