From 7b326ee025553f2f632276960e864926006f4bb5 Mon Sep 17 00:00:00 2001 From: zhongyingmin Date: Tue, 18 Aug 2026 13:39:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E3=80=81=E8=B7=AF=E7=94=B1=E4=B8=8E=E4=B8=9A=E5=8A=A1=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=96=B0=E5=A2=9E=E8=AF=BE=E7=A8=8B=E6=BC=94?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 79 +++- src/app.config.ts | 46 ++ .../business/Navigation/Navigation.tsx | 15 +- .../business/Navigation/Navigation.types.ts | 9 + src/components/business/Navigation/index.ts | 1 + src/components/business/index.ts | 1 + src/main.tsx | 1 + src/pages/About/About.tsx | 16 - src/pages/About/index.ts | 1 - src/pages/About/index.tsx | 19 + src/pages/Components/DragDropDemo.tsx | 405 ++++++++++++++++++ src/pages/Components/DragDropDemo.types.ts | 30 ++ src/pages/Components/index.tsx | 389 +++++++++++++++++ src/pages/CourseDemo/CourseDemo.data.ts | 107 +++++ src/pages/CourseDemo/CourseDemo.dialog.tsx | 46 ++ src/pages/CourseDemo/CourseDemo.spotlight.tsx | 30 ++ src/pages/CourseDemo/CourseDemo.types.ts | 22 + .../CourseDemo/CourseDemoGuideDialog.tsx | 15 + .../CourseDemo/CourseDemoGuideDialogPanel.tsx | 135 ++++++ .../CourseDemo/CourseDemoGuideSpotlight.tsx | 15 + .../CourseDemoGuideSpotlightPanel.tsx | 226 ++++++++++ src/pages/CourseDemo/index.tsx | 336 +++++++++++++++ src/pages/Home/Home.tsx | 16 - src/pages/Home/index.ts | 1 - src/pages/Home/index.tsx | 60 +++ src/pages/index.ts | 6 +- src/router/index.tsx | 18 +- src/styles/index.css | 4 +- src/styles/reset.css | 13 +- 29 files changed, 2005 insertions(+), 57 deletions(-) create mode 100644 src/app.config.ts create mode 100644 src/components/business/Navigation/Navigation.types.ts delete mode 100644 src/pages/About/About.tsx delete mode 100644 src/pages/About/index.ts create mode 100644 src/pages/About/index.tsx create mode 100644 src/pages/Components/DragDropDemo.tsx create mode 100644 src/pages/Components/DragDropDemo.types.ts create mode 100644 src/pages/Components/index.tsx create mode 100644 src/pages/CourseDemo/CourseDemo.data.ts create mode 100644 src/pages/CourseDemo/CourseDemo.dialog.tsx create mode 100644 src/pages/CourseDemo/CourseDemo.spotlight.tsx create mode 100644 src/pages/CourseDemo/CourseDemo.types.ts create mode 100644 src/pages/CourseDemo/CourseDemoGuideDialog.tsx create mode 100644 src/pages/CourseDemo/CourseDemoGuideDialogPanel.tsx create mode 100644 src/pages/CourseDemo/CourseDemoGuideSpotlight.tsx create mode 100644 src/pages/CourseDemo/CourseDemoGuideSpotlightPanel.tsx create mode 100644 src/pages/CourseDemo/index.tsx delete mode 100644 src/pages/Home/Home.tsx delete mode 100644 src/pages/Home/index.ts create mode 100644 src/pages/Home/index.tsx 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; + })} +
+

+ 拖到这里完成分类 +

+
+ )} +
+ ))} +
+
+
+ +
+ + 课程 {courseId ?? "本地预览"} · 学员 {userId ?? "访客"} + + 每次正确分类都会自动保存,刷新页面后继续学习。 +
+
+ + 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,