diff --git a/src/App.tsx b/src/App.tsx
index dc3ae56..0d72b33 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,15 +1,84 @@
+import { useEffect } from "react";
import { Outlet } from "react-router-dom";
+import { ToastProvider, ToastViewport } from "@/components/ui/toast";
+import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
import { Navigation } from "./components/business";
-import { cn } from "./utils";
+import { LandscapePrompt } from "./components/common";
+import { registerAppPortalRoot } from "./components/common/canvasPortal";
+import { useUserStore } from "./stores";
+import {
+ DESIGN_HEIGHT,
+ DESIGN_WIDTH,
+ isVideoAjaxAvailable,
+ useOrientation,
+} from "./utils";
+
+function CourseShell() {
+ const { portalContainer } = useCourseCanvas();
+
+ useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
-function App() {
return (
-
+
-
+
-
+
+
+ );
+}
+
+function App() {
+ const isPortrait = useOrientation();
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const courseId = params.get("courseId") ?? params.get("courseid");
+ const uid = params.get("uid");
+ const forceYiban = params.get("yiban") === "1";
+ const hasYibanParams = !!(courseId || uid);
+ const hasVideoAjax = isVideoAjaxAvailable();
+ const isYibanEnv = import.meta.env.DEV
+ ? hasYibanParams && forceYiban
+ : hasYibanParams;
+ const storageMode = isYibanEnv && hasVideoAjax ? "yiban" : "session";
+ const userStore = useUserStore.getState();
+
+ userStore.setYibanConnectionStatus(
+ storageMode === "yiban" ? "connected" : "disconnected",
+ );
+ if (courseId) userStore.setCourseId(courseId);
+ if (uid) userStore.setUserInfo({ userId: uid });
+
+ console.info(
+ storageMode === "yiban"
+ ? "[yiban] initialized"
+ : "[yiban] use session storage fallback",
+ {
+ courseId,
+ uid,
+ isDev: import.meta.env.DEV,
+ forceYiban,
+ hasYibanParams,
+ hasVideoAjax,
+ storageMode,
+ },
+ );
+ }, []);
+
+ return (
+ <>
+ {isPortrait && }
+
+
+
+ >
);
}
diff --git a/src/app.config.ts b/src/app.config.ts
new file mode 100644
index 0000000..f7bc34e
--- /dev/null
+++ b/src/app.config.ts
@@ -0,0 +1,46 @@
+/**
+ * 应用配置
+ * 修改此文件后需要重启开发服务
+ */
+const env = (import.meta as ImportMeta & { env?: Record }).env;
+
+export const appConfig = {
+ /** 应用标题 */
+ title: "React Vite App",
+
+ /** 网站描述 */
+ description: "易班 H5 课程模板",
+
+ /** Favicon 路径(相对于 public 目录) */
+ favicon: "./vite.svg",
+
+ /** 课程配置 */
+ course: {
+ id: env?.VITE_COURSE_ID ?? "course-demo",
+ name: env?.VITE_COURSE_NAME ?? "标准课程页示例",
+ },
+
+ /** 设计稿配置 */
+ design: {
+ width: 1920,
+ height: 1080,
+ },
+
+ /** 整体缩放配置 */
+ scale: {
+ max: 1,
+ },
+
+ /** 本地存储配置 */
+ storage: {
+ prefix: "yiban-course-template",
+ },
+
+ /** 视口配置 */
+ viewport: {
+ width: "device-width",
+ initialScale: 1.0,
+ minimumScale: 1.0,
+ maximumScale: 1.0,
+ },
+};
diff --git a/src/components/business/Navigation/Navigation.tsx b/src/components/business/Navigation/Navigation.tsx
index 00cdcd2..947e7d9 100644
--- a/src/components/business/Navigation/Navigation.tsx
+++ b/src/components/business/Navigation/Navigation.tsx
@@ -1,20 +1,13 @@
import { Link, useLocation } from "react-router-dom";
-import { cn } from "@/utils";
+import { cn } from "@/lib/cn";
import styles from "./Navigation.module.css";
-
-interface NavItem {
- label: string;
- path: string;
-}
-
-interface NavigationProps {
- items?: NavItem[];
- className?: string;
-}
+import type { NavItem, NavigationProps } from "./Navigation.types";
const defaultItems: NavItem[] = [
{ label: "首页", path: "/" },
{ label: "关于", path: "/about" },
+ { label: "组件示例", path: "/components" },
+ { label: "课程示例", path: "/course-demo" },
];
export function Navigation({
diff --git a/src/components/business/Navigation/Navigation.types.ts b/src/components/business/Navigation/Navigation.types.ts
new file mode 100644
index 0000000..65b2947
--- /dev/null
+++ b/src/components/business/Navigation/Navigation.types.ts
@@ -0,0 +1,9 @@
+export interface NavItem {
+ label: string;
+ path: string;
+}
+
+export interface NavigationProps {
+ items?: NavItem[];
+ className?: string;
+}
diff --git a/src/components/business/Navigation/index.ts b/src/components/business/Navigation/index.ts
index bc394c4..947d5b0 100644
--- a/src/components/business/Navigation/index.ts
+++ b/src/components/business/Navigation/index.ts
@@ -1 +1,2 @@
export { Navigation } from "./Navigation.tsx";
+export type { NavItem, NavigationProps } from "./Navigation.types";
diff --git a/src/components/business/index.ts b/src/components/business/index.ts
index 3d22051..45944dd 100644
--- a/src/components/business/index.ts
+++ b/src/components/business/index.ts
@@ -1 +1,2 @@
+export type { NavItem, NavigationProps } from "./Navigation";
export { Navigation } from "./Navigation";
diff --git a/src/main.tsx b/src/main.tsx
index 01454da..73a7fb2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
+import "./utils/vconsole";
import "./styles/index.css";
import { router } from "./router/index.tsx";
diff --git a/src/pages/About/About.tsx b/src/pages/About/About.tsx
deleted file mode 100644
index aa58eb3..0000000
--- a/src/pages/About/About.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Link } from "react-router-dom";
-import { Button } from "@/components/common";
-
-export default function About() {
- return (
-
-
关于我们
-
这是关于页面
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/About/index.ts b/src/pages/About/index.ts
deleted file mode 100644
index 62b948f..0000000
--- a/src/pages/About/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { About } from "./About.tsx";
diff --git a/src/pages/About/index.tsx b/src/pages/About/index.tsx
new file mode 100644
index 0000000..54e3d76
--- /dev/null
+++ b/src/pages/About/index.tsx
@@ -0,0 +1,19 @@
+import { Link } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+
+export default function About() {
+ return (
+
+ );
+}
diff --git a/src/pages/Components/DragDropDemo.tsx b/src/pages/Components/DragDropDemo.tsx
new file mode 100644
index 0000000..bce1119
--- /dev/null
+++ b/src/pages/Components/DragDropDemo.tsx
@@ -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) => (
+
+
+ {item.icon}
+
+
+ {item.label}
+
+ {item.description}
+
+
+
+);
+
+const renderPlacedItem = (item: DragDemoItem) => (
+
+
+ {item.icon}
+
+ {item.label}
+
+);
+
+export function DragDropDemo() {
+ const scale = useScale();
+ const [dropResult, setDropResult] = useState(null);
+ const [placedItemIds, setPlacedItemIds] = useState>(
+ {},
+ );
+ 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 (
+
+
+
+
+
+ Interactive Course Demo
+
+
+ Drag and Drop 分类挑战
+
+
+ 将左侧物品拖入右侧正确分类。拖拽预览使用自定义
+ DragOverlay,释放区根据是否匹配给出颜色反馈。
+
+
+
+
+ 完成 {placedCount}/{dragItems.length}
+
+
+ scale {scale.toFixed(3)}
+
+
+
+
+
+
+
+
+
{
+ const activeItem = findDragItem(String(activeId));
+
+ return activeItem
+ ? renderDragItem(
+ activeItem,
+ "pointer-events-none w-[300px] shadow-xl ring-4 ring-white/70",
+ )
+ : null;
+ }}
+ >
+
+
+
+
+
+ 待分类物品
+
+
+ 拖动物品到右侧对应区域
+
+
+
+ {dragItems.length - placedCount} left
+
+
+
+
+ {dragItems
+ .filter((item) => !placedIds.includes(item.id))
+ .map((item) => (
+
+ {renderDragItem(item)}
+
+ ))}
+ {isCompleted && (
+
+ 所有物品都已完成分类
+
+ )}
+
+
+
+
+ {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 (
+
{
+ 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)]",
+ )
+ }
+ >
+
+
+
+
+ {zone.icon}
+
+
+ {zone.title}
+
+
+ {zone.description}
+
+
+
+ {placedItems.length}/{zoneTotal}
+
+
+
+
+ {placedItems.length > 0 ? (
+ placedItems.map((item) => (
+
{renderPlacedItem(item)}
+ ))
+ ) : (
+
+ 等待放入正确物品
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ {dropResult && (
+
+ {dropResult.message}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Components/DragDropDemo.types.ts b/src/pages/Components/DragDropDemo.types.ts
new file mode 100644
index 0000000..4efee14
--- /dev/null
+++ b/src/pages/Components/DragDropDemo.types.ts
@@ -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;
diff --git a/src/pages/Components/index.tsx b/src/pages/Components/index.tsx
new file mode 100644
index 0000000..e257fc3
--- /dev/null
+++ b/src/pages/Components/index.tsx
@@ -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(null);
+ const [portalVisible, setPortalVisible] = useState(false);
+ const [customPortalVisible, setCustomPortalVisible] = useState(false);
+ const [activeOverlay, setActiveOverlay] = useState(
+ 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 (
+
+
+
+ Components Playground
+
+ 组件示例页
+
+ 用于验证组件在模板中正常使用,展示与组件库(yuxiang-kit)集成效果。
+
+
+
+ 📐 本模板使用固定 1920 × 1080 设计画布 + CSS transform 整体缩放
+
+
+ 当前缩放比例 scale = {scale.toFixed(3)}。 DragDrop 等组件通过 scale
+ 参数修正坐标, GuideSpotlight 通过 viewport + container
+ 定位画布内元素。
+
+
+
+
+
+
+
Button
+
现有按钮组件的 variant 和 size 示例。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Toast
+
+ 通过 useToast 显示轻提示,支持 info、success、warning、error
+ 四种类型。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GuideSpotlight
+
+
+ 引导组件会根据 data-guide 或 selector
+ 高亮页面元素,适合课程新手引导、功能提示和操作说明。
+
+
+
+
+
+
Guide Demo
+
+ 课程引导示例区域
+
+
+ 点击右侧按钮后,会依次高亮这个卡片、操作按钮和这段说明文字。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Portal
+
+ Portal 默认挂载到 document.body,也可以通过 container
+ 指定自定义挂载位置。
+
+
+
+
+
+
+
+
+
+
+ 这是自定义 ref 容器。Portal 或 Overlay 都可以通过 container
+ 挂载到这个容器里。
+
+
+
+ {portalVisible && (
+
+
+ 这是通过 Portal 挂载到 body 的内容
+
+
+ )}
+
+ {customPortalVisible && (
+
+
+ 这是通过 container={ref} 挂载的内容
+
+
+ )}
+
+
+
+
+
Overlay
+
+ Overlay 内部已使用 Portal,可同时测试背景、关闭行为和挂载位置。
+
+
+
+
+ {overlayCards.map((item) => (
+
+
+ {item.title}
+
+
+ {item.description}
+
+
+
+ ))}
+
+
+
+ {currentOverlay && (
+
setActiveOverlay(null)}
+ container={overlayContainer}
+ className={isCustomOverlay ? "z-10" : undefined}
+ contentClassName="flex items-center justify-center p-8"
+ {...currentOverlay.overlayProps}
+ >
+
+
+ Overlay Demo
+
+
+ {currentOverlay.title}
+
+
{currentOverlay.description}
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/CourseDemo/CourseDemo.data.ts b/src/pages/CourseDemo/CourseDemo.data.ts
new file mode 100644
index 0000000..fb72289
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemo.data.ts
@@ -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;
+ 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;
+ const placedCount = Object.values(placed).flat().length;
+ return {
+ placed,
+ completed: placedCount === courseResources.length,
+ updatedAt: value.updatedAt ?? new Date().toISOString(),
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/pages/CourseDemo/CourseDemo.dialog.tsx b/src/pages/CourseDemo/CourseDemo.dialog.tsx
new file mode 100644
index 0000000..32f586a
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemo.dialog.tsx
@@ -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",
+ },
+ ],
+});
diff --git a/src/pages/CourseDemo/CourseDemo.spotlight.tsx b/src/pages/CourseDemo/CourseDemo.spotlight.tsx
new file mode 100644
index 0000000..7555a8b
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemo.spotlight.tsx
@@ -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: "开始分类",
+ },
+ ],
+});
diff --git a/src/pages/CourseDemo/CourseDemo.types.ts b/src/pages/CourseDemo/CourseDemo.types.ts
new file mode 100644
index 0000000..a68a4a0
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemo.types.ts
@@ -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;
+ completed: boolean;
+ updatedAt: string;
+}
diff --git a/src/pages/CourseDemo/CourseDemoGuideDialog.tsx b/src/pages/CourseDemo/CourseDemoGuideDialog.tsx
new file mode 100644
index 0000000..cc7f64a
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemoGuideDialog.tsx
@@ -0,0 +1,15 @@
+import { GuideDialog } from "@/components/common";
+import { CourseDemoGuideDialogPanel } from "./CourseDemoGuideDialogPanel";
+
+export function CourseDemoGuideDialog() {
+ return (
+
+ {(dialog) => }
+
+ );
+}
diff --git a/src/pages/CourseDemo/CourseDemoGuideDialogPanel.tsx b/src/pages/CourseDemo/CourseDemoGuideDialogPanel.tsx
new file mode 100644
index 0000000..03027cd
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemoGuideDialogPanel.tsx
@@ -0,0 +1,135 @@
+import type { GuideDialogRenderState } from "@/components/common";
+
+interface CourseDemoGuideDialogPanelProps {
+ dialog: GuideDialogRenderState;
+}
+
+function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
+ return (
+
+ {Array.from({ length: dialog.progress.total }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function DialogActions({ dialog }: CourseDemoGuideDialogPanelProps) {
+ const { actions, labels, status } = dialog;
+
+ return (
+
+
+ {!status.isFirst && (
+
+ )}
+ {!status.hasOptions && (
+
+ )}
+
+ );
+}
+
+export function CourseDemoGuideDialogPanel({
+ dialog,
+}: CourseDemoGuideDialogPanelProps) {
+ const { character, options, step } = dialog;
+
+ return (
+
+
+ {character?.avatar ?? "🙂"}
+
+
+
+
+
+
+
+
+
+
+ Guide Dialog
+
+ {character?.name && (
+
{character.name}
+ )}
+ {character?.mood && (
+
+ {character.mood}
+
+ )}
+
+
+
+
+
+
+
+ {dialog.progress.current} / {dialog.progress.total}
+
+
+ {step.title && (
+
+ {step.title}
+
+ )}
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+ {options.length > 0 && (
+
+ {options.map((option) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/CourseDemo/CourseDemoGuideSpotlight.tsx b/src/pages/CourseDemo/CourseDemoGuideSpotlight.tsx
new file mode 100644
index 0000000..d6844c4
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemoGuideSpotlight.tsx
@@ -0,0 +1,15 @@
+import { GuideSpotlight } from "@/components/common";
+import { CourseDemoGuideSpotlightPanel } from "./CourseDemoGuideSpotlightPanel";
+
+export function CourseDemoGuideSpotlight() {
+ return (
+
+ {(spotlight) => }
+
+ );
+}
diff --git a/src/pages/CourseDemo/CourseDemoGuideSpotlightPanel.tsx b/src/pages/CourseDemo/CourseDemoGuideSpotlightPanel.tsx
new file mode 100644
index 0000000..e514e48
--- /dev/null
+++ b/src/pages/CourseDemo/CourseDemoGuideSpotlightPanel.tsx
@@ -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 (
+
+
+ {!status.isFirst && (
+
+ )}
+
+
+ );
+}
+
+function ProgressMarks({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
+ return (
+
+ {Array.from({ length: spotlight.progress.total }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function LayoutGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
+ const { progress, step } = spotlight;
+
+ return (
+
+
+
+
+
+
+
+ 🔍
+
+
+
+
+ Map Pointer · {progress.current}/{progress.total}
+
+ {step.title && (
+
+ {step.title}
+
+ )}
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+
+
+
+ );
+}
+
+function SessionGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
+ const { progress, step } = spotlight;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Signal Route · {progress.current}/{progress.total}
+
+ {step.title && (
+
+ {step.title}
+
+ )}
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+
+
+
+
+
+
+ 01
+
+
+
+ 写入缓存
+
+
+ 读取进度
+
+
+ 本地预览
+
+
+
+
+
+
+
+ );
+}
+
+function ActionsGuidePanel({ spotlight }: CourseDemoGuideSpotlightPanelProps) {
+ const { progress, step } = spotlight;
+
+ return (
+
+
+
+
+
+
+
+
+
+
GO
+
+ Action Target
+
+
+
+ {progress.current} / {progress.total} · 现在试试课程交互入口
+
+
+
+
+
+ Control Deck
+
+ {step.title && (
+
+ {step.title}
+
+ )}
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export function CourseDemoGuideSpotlightPanel({
+ spotlight,
+}: CourseDemoGuideSpotlightPanelProps) {
+ switch (spotlight.step.id) {
+ case "course-layout":
+ return ;
+ case "session-fallback":
+ return ;
+ case "course-actions":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/src/pages/CourseDemo/index.tsx b/src/pages/CourseDemo/index.tsx
new file mode 100644
index 0000000..a8bff34
--- /dev/null
+++ b/src/pages/CourseDemo/index.tsx
@@ -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(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 (
+ <>
+
+
+
+
+ 学生资助政策 · 互动学习
+
+
建立你的课程资源地图
+
+ 阅读每张资源卡片,将它拖入“政策学习、互动实践、学习支持”中的正确位置。
+
+
+
+
+
+
+
+
+ 本节进度
+ {progressPercent}%
+
+
+
+
+ 已完成 {placedIds.length}/{courseResources.length}
+
+
+ {restored ? "进度已恢复" : "正在恢复进度"}
+
+ scale {scale.toFixed(3)}
+ {yibanConnectionStatus}
+
+
+
+
+
{
+ const resource = courseResources.find(
+ (item) => item.id === String(activeId),
+ );
+ return resource ? (
+
+ ) : null;
+ }}
+ >
+
+
+
+
+ {courseCategories.map((category) => (
+
+ {({ isOver }) => (
+
+
{category.icon}
+
+ {category.title}
+
+
+ {category.description}
+
+
+ {progress.placed[category.id].map((resourceId) => {
+ const resource = courseResources.find(
+ (item) => item.id === resourceId,
+ );
+ return resource ? (
+
+ ) : null;
+ })}
+
+
+ 拖到这里完成分类
+
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+ setCompletionOpen(false)}
+ container={getAppPortalRoot}
+ contentClassName="flex items-center justify-center p-8"
+ >
+
+
🎉
+
+ 本节完成
+
+
+ 资源地图已建立
+
+
+ 你已经能够区分政策材料、互动练习和学习支持资源。学习结果已保存,可以进入下一节课程。
+
+
+
+
+
+
+ >
+ );
+}
+
+function ResourceCard({
+ resource,
+ overlay = false,
+ placed = false,
+}: {
+ resource: (typeof courseResources)[number];
+ overlay?: boolean;
+ placed?: boolean;
+}) {
+ return (
+
+
+ {resource.icon}
+
+
+
+ {resource.title}
+
+
+ {resource.description}
+
+
+
+ );
+}
diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx
deleted file mode 100644
index e520c9d..0000000
--- a/src/pages/Home/Home.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Link } from "react-router-dom";
-import { Button } from "@/components/common";
-
-export default function Home() {
- return (
-
-
首页
-
欢迎来到首页
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/Home/index.ts b/src/pages/Home/index.ts
deleted file mode 100644
index ec97b11..0000000
--- a/src/pages/Home/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { Home } from "./Home.tsx";
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
new file mode 100644
index 0000000..d3a95d9
--- /dev/null
+++ b/src/pages/Home/index.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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/index.ts b/src/pages/index.ts
index 95e915f..fef9de5 100644
--- a/src/pages/index.ts
+++ b/src/pages/index.ts
@@ -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";
diff --git a/src/router/index.tsx b/src/router/index.tsx
index e5e03f4..ed0aaad 100644
--- a/src/router/index.tsx
+++ b/src/router/index.tsx
@@ -1,19 +1,33 @@
import { createHashRouter } from "react-router-dom";
import App from "../App.tsx";
+import { RouteError, RouteFallback } from "../components/common";
import { lazyRoute } from "../utils";
export const router = createHashRouter([
{
path: "/",
element: ,
+ errorElement: ,
children: [
{
index: true,
- lazy: lazyRoute(() => import("../pages/Home/Home")),
+ lazy: lazyRoute(() => import("../pages/Home")),
+ HydrateFallback: RouteFallback,
},
{
path: "about",
- lazy: lazyRoute(() => import("../pages/About/About")),
+ lazy: lazyRoute(() => import("../pages/About")),
+ HydrateFallback: RouteFallback,
+ },
+ {
+ path: "components",
+ lazy: lazyRoute(() => import("../pages/Components")),
+ HydrateFallback: RouteFallback,
+ },
+ {
+ path: "course-demo",
+ lazy: lazyRoute(() => import("../pages/CourseDemo")),
+ HydrateFallback: RouteFallback,
},
],
},
diff --git a/src/styles/index.css b/src/styles/index.css
index f95dfec..72410f4 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -3,6 +3,8 @@
* 导入 Tailwind CSS 和自定义样式
*/
-@import "tailwindcss";
@import "./reset.css";
+@import "tailwindcss";
@import "./variables.css";
+@source not "../../.claude";
+@source not "../../dist";
diff --git a/src/styles/reset.css b/src/styles/reset.css
index aba1c30..66f9acd 100644
--- a/src/styles/reset.css
+++ b/src/styles/reset.css
@@ -7,22 +7,31 @@
*::before,
*::after {
box-sizing: border-box;
- margin: 0;
- padding: 0;
}
html {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
}
body {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+#root {
+ width: 100%;
+ height: 100%;
+}
+
img,
picture,
video,