Compare commits

...
12 Commits
Author SHA1 Message Date
zhongyidianandClaude Opus 5 155f6269ca merge: 合并课程模板通用能力
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 14:13:51 +08:00
zhongyidianandClaude Opus 5 a641d0e2ea feat: 完善课程模板通用能力
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 14:07:42 +08:00
zhongyingmin ad37dfeb24 Merge pull request #1 from zhongyingmin/fix/course-runtime-reliability
fix: 修复课程运行时初始化与拖拽测试
2026-08-27 10:38:29 +08:00
zhongyidianandClaude Opus 4.8 1e02c7835e fix: 修复课程运行时初始化与拖拽测试
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-27 10:35:17 +08:00
zhongyidianandClaude Opus 4.8 d77281ff0d chore: 项目名统一为 yx-react-template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 17:54:51 +08:00
zhongyidianandClaude Opus 4.8 cba0d42474 docs: 同步缩放与横屏提示的组件化文档
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 17:33:42 +08:00
zhongyidianandClaude Opus 4.8 dc4197b207 refactor: CourseCanvas 使用默认画布尺寸,移除冗余 width/height 配置
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 17:32:18 +08:00
zhongyidianandClaude Opus 4.8 ce9567d1da refactor: 横屏提示与缩放改为组件标签(OrientationGuard)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 17:24:43 +08:00
zhongyidianandClaude Opus 4.8 a98b1d383e docs: 更新模板使用文档
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 13:40:07 +08:00
zhongyidianandClaude Opus 4.8 17fee63a16 test: 新增课程流程 e2e 测试
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 13:39:55 +08:00
zhongyidianandClaude Opus 4.8 7b326ee025 feat: 重构页面、路由与业务组件,新增课程演示
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 13:39:48 +08:00
zhongyidianandClaude Opus 4.8 d3ae0a8ca7 feat: 模板适配层、易班能力与全局状态
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 13:39:42 +08:00
127 changed files with 12346 additions and 312 deletions

No files matched your search

+49 -7
View File
@@ -39,9 +39,14 @@ bun run lint:fix # 自动修复代码问题
新增文件前先判断职责归属,优先沿用现有目录结构: 新增文件前先判断职责归属,优先沿用现有目录结构:
- `src/components/common/`:通用可复用组件。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。 - `src/components/ui/`:无课程业务和全局状态依赖的基础 UI copied source。
- `src/components/common/`:模板通用组件和适配器;其中 `GuideDialog`、`GuideSpotlight` 负责连接 Zustand、CourseCanvas 与交互内核。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。
- `src/components/business/`:业务组合组件,不放通用基础能力。 - `src/components/business/`:业务组合组件,不放通用基础能力。
- `src/course/canvas/`:设计稿画布、缩放、viewport 和课程 Portal 根节点。
- `src/course/orientation/`:横竖屏访问控制。
- `src/course/interactions/`:由 CLI 复制的受控课程交互内核,不依赖具体页面、Store 或 common 适配器。
- `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。 - `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。
- 模块内私有组件:仅本模块使用、其他模块用不到的,放到本模块目录的 `components/` 子目录(没有就创建);被多个模块或组件复用才下沉到 `src/components/common/`(通用基础能力)或 `business/`(业务组合),按组件核心类型区分。该规则约束新代码,存量平铺在页面目录下的模块内组件不强制迁移。
- `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。 - `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。
- `src/stores/`:Zustand 状态管理。 - `src/stores/`:Zustand 状态管理。
- `src/utils/`:通用工具、hooks 或平台封装。 - `src/utils/`:通用工具、hooks 或平台封装。
@@ -50,7 +55,7 @@ bun run lint:fix # 自动修复代码问题
### 类型放置规则 ### 类型放置规则
组件、页面、store、API 的 `props`、`interface`、`type` 不要直接写在实现文件里: 组件、页面、store、API 的对外 `props` 与跨文件共享的 `interface`、`type` 放在 `.types.ts`;文件内私有的 helper 类型留在实现文件即可:
- 组件类型放在同组件目录的 `组件名.types.ts`,例如 `Button/Button.types.ts`。 - 组件类型放在同组件目录的 `组件名.types.ts`,例如 `Button/Button.types.ts`。
- 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。 - 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。
@@ -61,7 +66,7 @@ bun run lint:fix # 自动修复代码问题
## Tailwind、cn 与样式提取规则 ## Tailwind、cn 与样式提取规则
项目已有 [src/utils/tailwind.ts](src/utils/tailwind.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突。使用时遵守以下边界。 项目已有 [src/lib/cn.ts](src/lib/cn.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突([src/utils/tailwind.ts](src/utils/tailwind.ts) 只是 re-export)。使用时遵守以下边界。
### 默认策略 ### 默认策略
@@ -74,7 +79,7 @@ bun run lint:fix # 自动修复代码问题
在以下场景使用 `cn`: 在以下场景使用 `cn`:
- 需要合并外部传入的 `className`。 - 需要合并外部传入的 `className`。
- 存在条件 class。 - 条件 class 需要和其他 class 合并。
- 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。 - 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。
- 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。 - 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。
@@ -90,9 +95,10 @@ bun run lint:fix # 自动修复代码问题
```tsx ```tsx
<div className="flex items-center justify-center" /> <div className="flex items-center justify-center" />
<div className={active ? "h-2 w-8 bg-current" : "h-2 w-2 bg-current/20"} />
``` ```
单个固定字符串且没有条件、合并、冲突处理需求时,直接写 `className="..."`。 单个固定字符串,或在两个完整字符串之间二选一的三元且不需要再合并其他 class 时,直接写 `className`。
### 什么时候提取样式 ### 什么时候提取样式
@@ -110,6 +116,38 @@ bun run lint:fix # 自动修复代码问题
- 同目录多个文件共享时,可放同目录局部文件。 - 同目录多个文件共享时,可放同目录局部文件。
- 跨组件复用的样式能力,再考虑沉淀为通用组件、工具或 CSS Module。 - 跨组件复用的样式能力,再考虑沉淀为通用组件、工具或 CSS Module。
### 组件与 CSS 类的边界
「何时提取 class」见上一节;本节只解决复用一段 UI 时该抽组件还是抽 CSS 类。
组件/页面文件超过 500 行时,先审视是否该拆分(这是触发思考的信号,不是强制拆分线);拆出的子组件按「文件创建与目录归属」放置。
重复出现时优先抽组件,CSS 类是兜底,但避免「为整洁而抽」。抽组件必须同时满足:
- 同一组「标记 + 样式 + 行为」在 2 处以上真实重复(第一次出现不抽)。
- 组件有清晰可命名的语义,能一句话说清它是什么。
- 复用的是整体而非几个 class;props 少且稳定(一般 ≤3~4 个)。
以下情况改用 CSS 类或局部常量,而不是抽组件:
- 只复用样式、不复用标记/行为,内容由数据或富文本决定。
- 仅同一文件内的小重复。
- 给第三方/框架等无法插入组件的位置加样式。
- Tailwind 表达不了或 class 串过长,但元素本身不够独立语义。
反模式(不要抽):只被用一次仅为缩短 JSX;组件 props 塞满 className/style 覆盖;名字说不清职责(`CardTopSection`、`Wrapper` 之类)。
## 动画使用规则
项目使用 CSS/Tailwind 和 GSAP 时遵守以下边界:
- 普通状态过渡、hover、active、简单淡入淡出及单元素入场默认使用 CSS/Tailwind。
- 只有动画需要时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径时,才使用 GSAP。
- 不因项目已经安装 GSAP 而重写现有 CSS 动画,也不为简单动画建立 GSAP preset 或通用封装。
- CSS 与 GSAP 不要在同一交互周期内控制同一元素的同一属性,尤其是 `transform` 和 `opacity`。
- GSAP 动画通过 `src/utils/motion.ts` 使用 `useGSAP` 管理生命周期,并处理 `prefers-reduced-motion` 降级。
- 不使用 GSAP 修改 `CourseCanvas`、画布 portal 根节点或 dnd-kit 正在控制的 `transform`。
## 导入与导出 ## 导入与导出
- `@` 指向 `src`,新增代码优先使用 `@` 避免过长相对路径。 - `@` 指向 `src`,新增代码优先使用 `@` 避免过长相对路径。
@@ -129,10 +167,14 @@ bun run lint:fix # 自动修复代码问题
开发时要尊重模板已有能力: 开发时要尊重模板已有能力:
- 页面按 `1920 x 1080` 设计稿开发,整体缩放逻辑在 `src/utils/useScale.ts`。 - 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course/canvas` 的 `CourseCanvas` 组件承担。
- 横屏访问提示逻辑在 `src/utils/useOrientation.ts` 与通用组件中。 - 横屏访问提示由 `src/course/orientation` 的 `OrientationGuard` 组件承担。
- 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。 - 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。
- 移动端调试使用 `src/utils/vconsole.ts`。 - 移动端调试使用 `src/utils/vconsole.ts`。
- 图片按钮由 `src/components/common/ImageButton` 提供;视频播放与视频弹层由 `src/components/common/Video` 提供。
- 拖拽交互由 `src/course/interactions/drag-drop` 提供(基于 dnd-kit)。
- 音频能力由 `src/utils/audio` 提供(`GameAudioProvider` / `useGameAudio` / `AudioManager`)。
- GSAP 复杂动画通过 `src/utils/motion.ts` 统一引入,边界见「动画使用规则」。
- 新增通用组件时,同步考虑是否需要在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 增加示例。 - 新增通用组件时,同步考虑是否需要在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 增加示例。
## Claude Code 工作准则 ## Claude Code 工作准则
+661 -20
View File
@@ -1,18 +1,27 @@
# React Vite Template # 易班 H5 开发模板
一个基于 React 19、TypeScript、Vite、React Router 和 Tailwind CSS 的前端项目模板。 一个基于 React 19、TypeScript、Vite、React Router、Tailwind CSS 和 Zustand 的易班平台 H5 开发模板。
## 特性 ## 特性
- React 19 + TypeScript - React 19 + TypeScript
- Vite 6 开发与构建 - Vite 8 开发与构建
- React Router 7 路由管理 - React Router 7 路由管理(支持懒加载)
- 路由页面懒加载 - Tailwind CSS 4 原子化样式
- Tailwind CSS 4 - Zustand 轻量状态管理
- Biome 代码格式化与检查 - Biome 代码格式化与检查
- Husky + lint-staged 提交前检查 - Husky + lint-staged 提交前检查
- 1920x1080 整体缩放适配
- 横屏访问提示
- vconsole 移动端调试工具(仅开发环境)
- 易班 videoajax.js 封装
- 易班用户信息状态管理
- 图片按钮、视频播放器与视频弹层基础组件
- 音频管理(配音 / BGM / 音效)
- GSAP 动画能力(复杂时间轴编排)
- `@` 指向 `src` 的路径别名 - `@` 指向 `src` 的路径别名
- 组件分层:通用组件、业务组件、页面组件 - 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码
- 组件分层:Registry copied source、模板适配器、业务组件、页面组件
## 使用模板 ## 使用模板
@@ -33,41 +42,673 @@ bun dev
### 通过 GitHub CLI 创建 ### 通过 GitHub CLI 创建
```bash ```bash
gh repo create my-react-app --template your-name/react-vite-template --public gh repo create my-react-app --template your-name/yx-react-template --public
cd my-react-app cd my-react-app
bun install bun install
bun dev bun dev
``` ```
创建项目后,建议修改 [package.json](package.json) 中的 `name` 字段。 创建项目后,建议修改 [package.json](package.json) 中的 `name` 字段,以及 [src/app.config.ts](src/app.config.ts) 中的页面标题、描述和图标配置。
## 应用配置
页面标签页标题、描述、图标、课程标识、存储前缀和 viewport 配置统一维护在 [src/app.config.ts](src/app.config.ts):
```ts
export const appConfig = {
title: "React Vite App",
description: "易班 H5 课程模板",
favicon: "./vite.svg",
course: {
id: import.meta.env.VITE_COURSE_ID ?? "course-demo",
name: import.meta.env.VITE_COURSE_NAME ?? "标准课程页示例",
},
storage: {
prefix: "yiban-course-template",
},
viewport: {
width: "device-width",
initialScale: 1.0,
minimumScale: 1.0,
maximumScale: 1.0,
},
};
```
课程画布尺寸和最大缩放比例由 `CourseCanvas` props 控制,不属于 `appConfig`。修改应用配置后需要重启开发服务。
## 开发命令 ## 开发命令
```bash ```bash
bun dev # 启动开发服务器 bun dev # 启动开发服务器
bun build # 类型检查并构建生产版本 bun run build # 类型检查并构建生产版本
bun preview # 预览生产构建 bun run preview # 预览生产构建
bun lint # 代码检查 bun run lint # 代码检查
bun lint:fix # 自动修复代码问题 bun run lint:fix # 自动修复代码问题
``` ```
## 开发规范
完整的 Claude Code/自动化开发规范见 [CLAUDE.md](CLAUDE.md)。日常开发遵守以下约定:
- 使用 Bun 执行项目脚本,提交前确保 `bun run lint` 通过;涉及路由、构建配置、页面结构或类型系统的改动建议同时运行 `bun run build`。
- 代码风格以 [biome.json](biome.json) 为准:tab 缩进、双引号、Biome 检查和导入整理。
- 组件、页面、store、API 的对外 Props 和跨文件共享类型放入 `.types.ts`;文件内私有 helper 类型留在实现文件。store 类型放 [src/stores/types/](src/stores/types/),只有跨多个模块复用的全局类型才放入 [src/types/](src/types/)。
- 短的静态 Tailwind class 可直接写在 JSX 中;只有需要条件合并、外部 `className`、冲突合并或组合 CSS Module 时才使用 `cn`。
- Tailwind class 过长、重复、条件组合复杂,或直接实现过于冗余时,再提取为局部常量、同目录文件、CSS Module 或通用组件。
- 新增页面沿用 [src/router/index.tsx](src/router/index.tsx) 中的 `lazyRoute` 和 `RouteFallback` 模式,导入优先使用 `@` 指向 `src` 的路径别名。
- 新增通用组件时,按需维护 [src/components/common/index.ts](src/components/common/index.ts),并考虑在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 补充示例。
## 课程交互源码
模板中的基础组件和课程交互不是外部 React 运行时包,而是由 `@yuxiang/cli` 复制并提交到当前项目:
```text
src/components/ui/ # Button、Portal、Overlay、Toast
src/course/interactions/ # GuideDialog、GuideSpotlight、DragDrop 受控内核
src/course/canvas/ # 固定课程画布、缩放与 Portal 根节点
src/course/orientation/ # 横竖屏访问控制
src/lib/cn.ts # 本地 class 合并工具
```
项目根目录的 [components.json](components.json) 配置 copied source 的目录与 alias。需要新增能力时运行:
```bash
bunx @yuxiang/cli add <item>
```
CLI 默认不会覆盖不同内容的本地文件。安装后请把源码作为模板代码维护,不要重新添加 `@yuxiang/react` 或 `@yuxiang/core` 依赖。
## 真实课程案例
本地打开以下路由可体验连贯的课程流程:
```text
http://localhost:5173/#/course-demo
```
案例包含课程导入与分支、页面 Spotlight、课程资源分类拖拽、错误/正确 Toast、完成 Overlay,以及易班或 session fallback 的进度保存与刷新恢复。
## 组件开发 Smoke 页
本地开发时可以打开组件示例页查看和测试基础组件:
```text
http://localhost:5173/#/components
```
该路由仅用于开发时检查 copied source 的基础状态,不作为产品级组件展示墙。真实组合用法以 `#/course-demo` 为准。
当前 smoke 页包含:
- `Button`: variant 和 size 示例
- `Toast`: info / success / warning / error 轻提示示例
- `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例
- `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例
- `GuideSpotlight`: 课程引导和高亮目标区域示例
- `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例
后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。
## 目录结构 ## 目录结构
```text ```text
src/ src/
├── api/ # API 请求相关代码 ├── api/ # API 请求相关代码
├── assets/ # 项目资源 ├── assets/ # 项目资源
├── components/ # 组件 ├── components/
│ ├── business/ # 业务组件 │ ├── business/ # 业务组合组件
│ └── common/ # 通用组件 │ ├── common/ # 模板通用组件和 Store/Canvas 适配器
├── hooks/ # 自定义 Hooks │ └── ui/ # Button、Portal、Overlay、Toast 等基础 UI
├── course/
│ ├── canvas/ # 1920 x 1080 画布、viewport 与 Portal 根节点
│ ├── interactions/ # Guide、DragDrop 等课程交互内核
│ └── orientation/ # 横竖屏访问控制
├── lib/ # 无业务依赖的底层工具
├── pages/ # 页面组件 ├── pages/ # 页面组件
├── router/ # 路由配置 ├── router/ # 路由配置
├── stores/
│ ├── modules/ # Zustand store 实现
│ └── types/ # Store 类型
├── styles/ # 全局样式 ├── styles/ # 全局样式
├── types/ # TypeScript 类型 ├── types/ # 跨多个模块共享的全局类型
└── utils/ # 工具函数 └── utils/
├── audio/ # 音频管理
├── motion.ts # GSAP 动画统一入口
├── vconsole.ts # 移动端调试
└── yiban.ts # 易班 API 封装
``` ```
`src/course/interactions` 保持无具体课程 Store 依赖;`src/components/common/GuideDialog` 和 `GuideSpotlight` 负责连接 Zustand、CourseCanvas 和 Portal。
## 易班开发说明
模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。
### videoajax.js 使用
模板已集成易班 videoajax.js,提供以下功能:
- [public/videoajax/jquery-3.7.1.min.js](public/videoajax/jquery-3.7.1.min.js)
- [public/videoajax/videoajax.js](public/videoajax/videoajax.js)
这些文件需要和构建后的页面同源、同相对路径部署。若易班平台提供了新的 `videoajax.js`,请同步确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录相关能力。
### 易班环境判断
模板启动时会从 URL 中读取:
- `courseId` / `courseid`
- `uid`
生产环境中,只要存在以上任意参数,就会认为当前处于易班环境。
本地开发环境中,即使 URL 带有 `courseId` / `uid`,也默认使用 `sessionStorage`,避免误请求易班接口:
```text
http://localhost:5173/?courseId=123&uid=456
```
如果本地确实需要强制走易班接口,可追加:
```text
http://localhost:5173/?courseId=123&uid=456&yiban=1
```
模板已集成以下易班能力:
#### 1. 存取用户数据
```ts
import { setUserStorage, getUserStorage } from '@/utils/yiban';
// 保存数据(自动根据易班连接状态选择存储方式)
await setUserStorage(lessonId, key, value);
// 读取数据
const result = await getUserStorage(lessonId, key);
if (result.status === 'success') {
console.log(result.content);
}
// 强制指定存储方式
await setUserStorage(lessonId, key, value, { backend: 'session' });
```
#### 2. 课程进度管理
```ts
import { reportCourseProgress, getCourseProgress } from '@/utils/yiban';
// 上报课程进度(自动读取 store 中的 courseId 和连接状态)
await reportCourseProgress(order);
// 获取课程进度
const progress = await getCourseProgress(order);
if (progress) {
// videoajax.js 返回平台原始响应,使用前请按平台响应结构进行类型收窄
console.log("当前进度响应:", progress);
}
```
#### 3. 播放断点管理
```ts
import { saveBreakPoint, getBreakPoint } from '@/utils/yiban';
// 保存播放断点
await saveBreakPoint({
userId: '123456',
courseId: '789',
lessonId: '001',
breakPoint: 120, // 秒
});
// 获取播放断点
const breakpoint = await getBreakPoint({
userId: '123456',
courseId: '789',
lessonId: '001',
});
```
#### 4. 用户答题记录
```ts
import { saveUserAnswer } from '@/utils/yiban';
// 保存答题记录
await saveUserAnswer({
userId: '123456',
lessonId: '001',
courseId: '789',
questionOrder: 1,
answer: 'A',
});
```
#### 5. 用户状态管理
```ts
import { useUserStore } from '@/stores';
function MyComponent() {
const { userId, userName, setUserInfo, setYibanConnectionStatus } = useUserStore();
// 设置易班连接状态
setYibanConnectionStatus('connected');
// 设置用户信息
setUserInfo({
userId: '123456',
userName: '张三',
userAvatar: 'https://...'
});
}
```
### 移动端调试
开发环境自动启用 vconsole,可在移动端查看:
- Console 日志
- Network 请求
- Element 结构
- Storage 存储
### 课程画布适配
模板按 `1920 x 1080` 设计稿开发,由 `src/course/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。
当前默认缩放策略:
- PC 端:按可视区域等比缩放并居中显示
- 大屏:`maxScale` 默认为 `1`,不会超过设计稿原始尺寸,周围留白
- 移动端:优先横屏访问,竖屏时由 `OrientationGuard` 显示横屏提示
- 视口:缩放逻辑优先读取 `visualViewport`,移动端地址栏显示/隐藏时更稳定
- 页面内容:按 `1920 x 1080` 设计稿进行开发,不使用 rem 适配
本地运行 `bun dev` 后,可以访问标准课程页示例:
```text
http://localhost:5173/#/course-demo
```
`CourseDemo` 用于展示标准课程页面结构、易班 session fallback、Toast、Overlay 和 GuideSpotlight 的基础接入方式。
## 背景、图片按钮与视频
### BackgroundContainer
`BackgroundContainer` 用于给页面区域设置背景图片。`src` 可以传入相对 `src/assets/images/` 的静态资源 key,也可以传入 public 相对路径或外部 URL:
```tsx
import { BackgroundContainer } from "@/components/common";
import { getPublicPath } from "@/utils/assets";
<BackgroundContainer
src={getPublicPath("images/course-bg.jpg")}
fit="cover"
position="center"
className="h-full w-full"
>
<section className="p-10">课程内容</section>
</BackgroundContainer>;
```
调用方传入的 `style` 优先级最高,可以覆盖 `backgroundImage`、`backgroundSize` 等默认值。
### ImageButton
`ImageButton` 提供图片按钮、hover/按下缩放、禁用状态和键盘焦点样式:
```tsx
import { ImageButton } from "@/components/common";
<ImageButton
src="start-button.png"
alt="开始学习"
width={280}
height={96}
onClick={startCourse}
/>;
```
静态资源 key 相对 `src/assets/images/`;外部 `http(s)`、`data:` 和 `blob:` URL 会直接使用。图片按钮应提供可访问名称,例如 `alt` 或 `aria-label`。
### VideoPlayer 与 VideoOverlay
```tsx
import { VideoOverlay, VideoPlayer } from "@/components/common";
<VideoPlayer
src="./videos/lesson.mp4"
poster="./images/video-poster.jpg"
mode="window"
onEnded={handleEnded}
/>;
<VideoOverlay
open={videoOpen}
src="./videos/intro.mp4"
skipLabel="跳过"
onSkip={() => setVideoOpen(false)}
onClose={() => setVideoOpen(false)}
onEnded={() => setVideoOpen(false)}
/>;
```
`VideoOverlay` 默认挂载到课程画布 Portal 根节点。`dismissible=false` 时不会通过背景点击或 Esc 自动关闭,播放结束、跳过和流程推进仍由调用方决定。
## 弹层基础组件
模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等内容。
`Overlay` 内置 `Portal`。不传 `container` 时默认挂载到 `document.body`;如需跟随 `1920 x 1080` 课程画布一起缩放,应传入 `CourseCanvas` 提供的 `portalContainer`:
```tsx
import { Overlay } from "@/components/common";
import { useCourseCanvas } from "@/course/canvas";
function CoursePanel() {
const { portalContainer } = useCourseCanvas();
return (
<Overlay
open={open}
container={portalContainer}
onClose={() => setOpen(false)}
backgroundImage="url(./images/dialog-bg.png)"
backgroundClassName="bg-cover bg-center"
contentClassName="flex items-center justify-center"
>
<img src="./images/dialog.png" alt="课程提示" />
</Overlay>
);
}
```
说明:
- `backgroundImage`:原始 CSS `background-image` 值,组件不参与路径处理;引用 public 资源时可用 `getPublicPath`。
- `backgroundClassName`:控制背景层样式。
- `contentClassName`:控制内容层布局。
- `closeOnBackgroundClick`:点击背景关闭,默认开启。
- `closeOnEscape`:按 Esc 关闭,默认开启。
- `lockScroll`:打开时锁定页面滚动,默认开启。
- `variant`:提供 `dim` / `light` / `transparent` 三种基础背景。
- `fixed`:默认在无 `container` 时使用 fixed,传入 `container` 时使用 absolute;可以显式覆盖。
如果需要挂载到业务自己的容器,也可以传入 DOM 元素、React ref 或返回元素的函数:
```tsx
import { useRef } from "react";
import { Overlay } from "@/components/common";
function LocalPanel() {
const rootRef = useRef<HTMLDivElement>(null);
return (
<div ref={rootRef} className="relative">
<Overlay
container={rootRef}
open={open}
onClose={() => setOpen(false)}
>
业务弹层
</Overlay>
</div>
);
}
```
`Overlay` 当前没有 `placement` prop;渲染位置只由 `container` 和 `disabledPortal` 决定。
## Toast 轻提示
模板内置 `ToastProvider`、`ToastViewport` 和 `useToast`,用于保存成功、提交失败、网络异常等轻量反馈。
`App` 已默认接入 `ToastProvider` 和 `ToastViewport`,页面组件中可以直接使用:
```tsx
import { useToast } from "@/components/common";
const toast = useToast();
toast.success("保存成功");
toast.error("网络异常,请稍后重试");
```
支持:
- `toast.info(message)`
- `toast.success(message)`
- `toast.warning(message)`
- `toast.error(message)`
- `toast.show({ message, type, title, duration, className, data })`
如果课程视觉风格不同,可以自定义 `ToastViewport`:
```tsx
<ToastViewport
classNames={{
item: "rounded-full border-0 bg-black/80 px-8 py-4 text-white",
title: "hidden",
message: "text-white",
close: "text-white/70",
}}
/>
```
也可以完全接管单条 Toast 渲染:
```tsx
<ToastViewport
renderToast={(toast, { remove }) => (
<div className="pointer-events-auto rounded-full bg-black/80 px-8 py-4 text-white">
<span>{toast.message}</span>
<button onClick={() => remove(toast.id)}>关闭</button>
</div>
)}
/>
```
本地运行 `bun dev` 后,可以访问 `/#/components` 查看 Toast 示例。
## 拖拽交互
模板引入 dnd-kit 作为拖拽基础能力,适合网络课程中常见的拖物品到目标区、分类、匹配、排序等交互。
已封装基础组件:
- `DragDropProvider`: 拖拽上下文,默认启用 Pointer / Touch / Keyboard sensors。
- `Draggable`: 可拖拽元素。
- `Droppable`: 可投放区域。
基础示例:
```tsx
import { DragDropProvider, Draggable, Droppable } from "@/course/interactions/drag-drop";
import { useCourseCanvas } from "@/course/canvas";
const { scale } = useCourseCanvas();
<DragDropProvider
scale={scale}
onDragEnd={(event) => console.log(event.active.id, event.over?.id)}
>
<Draggable id="apple">苹果</Draggable>
<Droppable id="fruit-zone">水果区域</Droppable>
</DragDropProvider>;
```
由于模板整体使用 `transform: scale(...)` 缩放课程画布,拖拽内容在画布内时建议给 `DragDropProvider` 传入当前 `scale`,避免拖拽元素和鼠标/手指移动不同步。
`DragDropProvider` 默认会根据 `scale` 修正拖拽启动距离和拖拽位移,并使用 `pointerWithin` 优先、`closestCenter` 兜底的碰撞检测策略,让“拖到目标区域”类课程交互更符合手指/鼠标位置直觉。
本地运行 `bun dev` 后,可以访问 `/#/components` 查看拖拽示例。
> 当前基础封装优先覆盖多端拖拽和投放区域判断。排序、分类、拼图等更上层课程交互可以继续基于这些组件封装。
## 引导聚光灯
模板提供 `GuideSpotlight` 和 `defineSpotlight`,适合课程首次学习时突出关键操作区域。页面已经位于 `CourseCanvas` 内时,组件默认挂载到课程画布 Portal 根节点并自动使用画布 viewport。
### 基础使用
先定义引导。未显式提供 `target` 时,`defineSpotlight` 默认使用和步骤 `id` 相同的 `data-guide`:
```tsx
import { defineSpotlight } from "@/components/common";
export const lessonSpotlight = defineSpotlight({
id: "lesson-intro",
steps: [
{
id: "start-button",
title: "开始学习",
description: "点击这里开始本节课程。",
placement: "bottom",
},
],
});
```
在页面中启动并渲染:
```tsx
import { useEffect } from "react";
import { Button, GuideSpotlight } from "@/components/common";
import { lessonSpotlight } from "./lessonSpotlight";
export default function LessonPage() {
useEffect(() => {
lessonSpotlight.start();
}, []);
return (
<>
<Button data-guide="start-button">开始学习</Button>
<GuideSpotlight />
</>
);
}
```
### 持久化说明
引导状态通过 Zustand persist 只保存已完成记录:
```ts
completedSpotlightMap: {
"course-demo:lesson-intro": true,
}
```
`showOnce` 默认为 `true`。点击完成或跳过都会调用 `finishSpotlight` 并标记为已完成;`closeSpotlight` 只关闭当前引导,不会写入完成记录。
重新允许展示:
```ts
lessonSpotlight.reset();
```
### Step 配置
支持:
- `target`:CSS selector 或 `{ dataGuide: "xxx" }`;省略时由 `defineSpotlight` 使用步骤 `id`。
- `placement`:`top` / `bottom` / `left` / `right` / `auto`。
- `allowTargetClick`:是否允许点击高亮目标。
- `padding` / `radius`:调整高亮区域。
- `panelClassName`:单步提示框样式。
- `nextLabel` / `prevLabel` / `doneLabel` / `skipLabel`:按钮文案。
建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。
### 高级交互
`allowTargetClick` 只让高亮区域下方的真实元素可点击,不会自动切换步骤。业务操作完成后可显式推进:
```tsx
import { useSpotlightStore } from "@/stores";
const nextStep = useSpotlightStore((state) => state.nextStep);
<Button
data-guide="play-button"
onClick={() => {
playVideo();
nextStep();
}}
>
播放
</Button>;
```
可通过 `theme.zIndex` 调整层级:
```tsx
<GuideSpotlight theme={{ zIndex: 1200 }} />
```
目标不存在时,`skipIfTargetMissing` 默认为 `true`,组件会前进到下一步或结束引导。开发排查时可以显式传入 `skipIfTargetMissing={false}` 显示缺失目标面板。
组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。
## 音频管理
模板内置 `GameAudioProvider` 和 `useGameAudio`,按 `voice`(配音)/ `bgm`(背景音乐)/ `sfx`(音效)三个通道管理播放,底层基于 howler。`App` 已默认挂载 `GameAudioProvider`,页面内直接使用:
```tsx
import { useEffect } from "react";
import { useGameAudio } from "@/utils/audio";
export default function LessonPage() {
const audio = useGameAudio();
useEffect(() => {
// 必须先 enterPage,播放才会生效;离开页面时 dispose 会停止本页所有音频
const scope = audio.enterPage("lesson-1");
audio.bgm.play("./audio/bgm.mp3");
return () => scope.dispose();
}, [audio]);
return (
<button onClick={() => audio.sfx.play("./audio/click.mp3")}>点击</button>
);
}
```
说明:
- 同一通道同时只播放一个音频,再次 `play` 会自动停止上一个。
- `play` 返回 `{ started, stop }`,`started` 解析为 `playing` / `cancelled` / `blocked`(自动播放被浏览器拦截)/ `load-error` / `play-error`,可据此提示用户点击后重试。
- `audio.preload(source)` 可提前加载本页音频;页面切到后台时配音和音效会停止,BGM 暂停并在回到前台后恢复。
## GSAP 动画
GSAP 仅用于时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径。简单 hover、状态过渡和单元素入场优先使用 CSS/Tailwind。
```tsx
import { useRef } from "react";
import { EASE_OUT, gsap, useGSAP, useReducedMotion } from "@/utils/motion";
function TimelineExample() {
const rootRef = useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
useGSAP(() => {
if (reducedMotion) return;
gsap.from("[data-animate]", { y: 24, opacity: 0, ease: EASE_OUT });
}, { scope: rootRef, dependencies: [reducedMotion] });
return <div ref={rootRef} data-animate>课程内容</div>;
}
```
请从 `@/utils/motion` 直接导入,避免不使用动画的入口无意加载 GSAP;不要让 GSAP 控制 `CourseCanvas`、画布 Portal 根节点或 dnd-kit 正在控制的 transform。
## 路由懒加载 ## 路由懒加载
模板内置了 `lazyRoute` 工具函数,新页面可以这样配置: 模板内置了 `lazyRoute` 工具函数,新页面可以这样配置:
@@ -75,7 +716,7 @@ src/
```tsx ```tsx
{ {
path: "about", path: "about",
lazy: lazyRoute(() => import("../pages/About/About")), lazy: lazyRoute(() => import("../pages/About")),
} }
``` ```
+12
View File
@@ -7,7 +7,10 @@
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@gsap/react": "^2.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gsap": "^3.15.0",
"howler": "^2.2.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
@@ -19,6 +22,7 @@
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/howler": "^2.2.13",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -68,6 +72,8 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@gsap/react": ["@gsap/react@2.1.2", "https://registry.npmmirror.com/@gsap/react/-/react-2.1.2.tgz", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -148,6 +154,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/howler": ["@types/howler@2.2.13", "https://registry.npmmirror.com/@types/howler/-/howler-2.2.13.tgz", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="],
"@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"@types/react": ["@types/react@19.2.9", "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react": ["@types/react@19.2.9", "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
@@ -216,6 +224,10 @@
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gsap": ["gsap@3.15.0", "https://registry.npmmirror.com/gsap/-/gsap-3.15.0.tgz", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
"howler": ["howler@2.2.4", "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
+4 -4
View File
@@ -9,14 +9,14 @@
}, },
"directories": { "directories": {
"ui": "src/components/ui", "ui": "src/components/ui",
"interactions": "src/components/course-interactions", "interactions": "src/course/interactions",
"runtime": "src/course-runtime", "runtime": "src/course",
"lib": "src/lib" "lib": "src/lib"
}, },
"aliases": { "aliases": {
"ui": "@/components/ui", "ui": "@/components/ui",
"interactions": "@/components/course-interactions", "interactions": "@/course/interactions",
"runtime": "@/course-runtime", "runtime": "@/course",
"lib": "@/lib" "lib": "@/lib"
} }
} }
+5 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "react-template", "name": "yx-react-template",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -18,7 +18,10 @@
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@gsap/react": "^2.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gsap": "^3.15.0",
"howler": "^2.2.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
@@ -30,6 +33,7 @@
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/howler": "^2.2.13",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
File diff suppressed because it is too large. Load diff
+457
View File
@@ -0,0 +1,457 @@
/*
* $Id: base64.js,v 2.15 2014/04/05 12:58:57 dankogai Exp dankogai $
*
* Licensed under the MIT license.
* http://opensource.org/licenses/mit-license
*
* References:
* http://en.wikipedia.org/wiki/Base64
*/
;(function (global) {
'use strict'
// existing version for noConflict()
var _Base64 = global.Base64
var version = '2.1.5'
// if node.js, we use Buffer
var buffer
if (typeof module !== 'undefined' && module.exports) {
buffer = require('buffer').Buffer
}
// constants
var b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
var b64tab = (function (bin) {
var t = {}
for (var i = 0, l = bin.length; i < l; i++) t[bin.charAt(i)] = i
return t
})(b64chars)
var fromCharCode = String.fromCharCode
// encoder stuff
var cb_utob = function (c) {
if (c.length < 2) {
var cc = c.charCodeAt(0)
return cc < 0x80
? c
: cc < 0x800
? fromCharCode(0xc0 | (cc >>> 6)) + fromCharCode(0x80 | (cc & 0x3f))
: fromCharCode(0xe0 | ((cc >>> 12) & 0x0f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
} else {
var cc = 0x10000 + (c.charCodeAt(0) - 0xd800) * 0x400 + (c.charCodeAt(1) - 0xdc00)
return (
fromCharCode(0xf0 | ((cc >>> 18) & 0x07)) +
fromCharCode(0x80 | ((cc >>> 12) & 0x3f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
)
}
}
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
var utob = function (u) {
return u.replace(re_utob, cb_utob)
}
var cb_encode = function (ccc) {
var padlen = [0, 2, 1][ccc.length % 3],
ord =
(ccc.charCodeAt(0) << 16) |
((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8) |
(ccc.length > 2 ? ccc.charCodeAt(2) : 0),
chars = [
b64chars.charAt(ord >>> 18),
b64chars.charAt((ord >>> 12) & 63),
padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63),
padlen >= 1 ? '=' : b64chars.charAt(ord & 63),
]
return chars.join('')
}
var btoa = global.btoa
? function (b) {
return global.btoa(b)
}
: function (b) {
return b.replace(/[\s\S]{1,3}/g, cb_encode)
}
var _encode = buffer
? function (u) {
return new buffer(u).toString('base64')
}
: function (u) {
return btoa(utob(u))
}
var encode = function (u, urisafe) {
return !urisafe
? _encode(u)
: _encode(u)
.replace(/[+\/]/g, function (m0) {
return m0 == '+' ? '-' : '_'
})
.replace(/=/g, '')
}
var encodeURI = function (u) {
return encode(u, true)
}
// decoder stuff
var re_btou = new RegExp(
['[\xC0-\xDF][\x80-\xBF]', '[\xE0-\xEF][\x80-\xBF]{2}', '[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),
'g'
)
var cb_btou = function (cccc) {
switch (cccc.length) {
case 4:
var cp =
((0x07 & cccc.charCodeAt(0)) << 18) |
((0x3f & cccc.charCodeAt(1)) << 12) |
((0x3f & cccc.charCodeAt(2)) << 6) |
(0x3f & cccc.charCodeAt(3)),
offset = cp - 0x10000
return fromCharCode((offset >>> 10) + 0xd800) + fromCharCode((offset & 0x3ff) + 0xdc00)
case 3:
return fromCharCode(
((0x0f & cccc.charCodeAt(0)) << 12) |
((0x3f & cccc.charCodeAt(1)) << 6) |
(0x3f & cccc.charCodeAt(2))
)
default:
return fromCharCode(((0x1f & cccc.charCodeAt(0)) << 6) | (0x3f & cccc.charCodeAt(1)))
}
}
var btou = function (b) {
return b.replace(re_btou, cb_btou)
}
var cb_decode = function (cccc) {
var len = cccc.length,
padlen = len % 4,
n =
(len > 0 ? b64tab[cccc.charAt(0)] << 18 : 0) |
(len > 1 ? b64tab[cccc.charAt(1)] << 12 : 0) |
(len > 2 ? b64tab[cccc.charAt(2)] << 6 : 0) |
(len > 3 ? b64tab[cccc.charAt(3)] : 0),
chars = [fromCharCode(n >>> 16), fromCharCode((n >>> 8) & 0xff), fromCharCode(n & 0xff)]
chars.length -= [0, 0, 2, 1][padlen]
return chars.join('')
}
var atob = global.atob
? function (a) {
return global.atob(a)
}
: function (a) {
return a.replace(/[\s\S]{1,4}/g, cb_decode)
}
var _decode = buffer
? function (a) {
return new buffer(a, 'base64').toString()
}
: function (a) {
return btou(atob(a))
}
var decode = function (a) {
return _decode(
a
.replace(/[-_]/g, function (m0) {
return m0 == '-' ? '+' : '/'
})
.replace(/[^A-Za-z0-9\+\/]/g, '')
)
}
var noConflict = function () {
var Base64 = global.Base64
global.Base64 = _Base64
return Base64
}
// export Base64
global.Base64 = {
VERSION: version,
atob: atob,
btoa: btoa,
fromBase64: decode,
toBase64: encode,
utob: utob,
encode: encode,
encodeURI: encodeURI,
btou: btou,
decode: decode,
noConflict: noConflict,
}
// if ES5 is available, make Base64.extendString() available
if (typeof Object.defineProperty === 'function') {
var noEnum = function (v) {
return { value: v, enumerable: false, writable: true, configurable: true }
}
global.Base64.extendString = function () {
Object.defineProperty(
String.prototype,
'fromBase64',
noEnum(function () {
return decode(this)
})
)
Object.defineProperty(
String.prototype,
'toBase64',
noEnum(function (urisafe) {
return encode(this, urisafe)
})
)
Object.defineProperty(
String.prototype,
'toBase64URI',
noEnum(function () {
return encode(this, true)
})
)
}
}
// that's it!
})(this)
if (this['Meteor']) {
Base64 = global.Base64 // for normal export in Meteor.js
}
;(function (root) {
// body...
var videoajax = function (argument) {
// body...
var host = '/'
this._setHost = function (hoststr) {
host = hoststr
}
this._getHost = function () {
return host
}
}
$.extend(videoajax.prototype, {
/**
* 设置主域名
* @param hosts {String} default: '/' 主域名字符串
*/
setHost: function (hosts) {
this._setHost(hosts)
},
//user_id,course_id,lesson_id,break_point
/**
* 播放退出时间点
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
* break_point {Number} 断点
*@param callback {Function} 回调方法
*/
quitBreakPoint: function (data, callback) {
var paramfiled = ['user_id', 'course_id', 'lesson_id', 'break_point']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addBreakPoint',
{
userViewPoint: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
}
)
},
/**
* user_id:123,course_id:23,lesson_id:3
*播放历史记录
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
*@param callback {Function} 回调方法
*/
getBreakPoint: function (data, callback) {
var paramfiled = ['user_id', 'course_id', 'lesson_id']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=userbreakpoint',
{
userCourseInfo: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*用户答题答案
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
* question_order {Number} 问题序号
* answer {String} 问题
*@param callback {Function} 回调方法
*/
getUserAnswer: function (data, callback) {
var paramfiled = ['user_id', 'lesson_id', 'course_id', 'question_order', 'answer']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserAnswer',
{
userAnswerInfo: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
}
)
},
/**
*播放结束
* @param data {dataObject} 向后台传递的数据对象,应该包含
* order {Number} 排序ID
* co {Number} 课时ID
*@param callback {Function} 回调方法
*/
finishCourse: function (data, callback) {
var paramfiled = ['order', 'co', 'process']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=user&c=learnrecord&a=playcount',
data,
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*课程进度获取
* @param data {dataObject} 向后台传递的数据对象,应该包含
* order {Number} 排序ID
* co {Number} 课时ID
*@param callback {Function} 回调方法
* 注回调使用的参数,值类似为: {"status":true,"data":{"process":"91"}}
* 或: {"status":false,"data":'参数丢失'}
* 其中status为true为调用正常, 如果有值则data返回json形式的进度, 没有值则返回[]
* status为false时, data保存的是错误提示
*/
getfinishCourse: function (data, callback) {
var paramfiled = ['order', 'co']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=user&c=learnrecord&a=getuserlessionprocess',
data,
function (data) {
isFunction(callback) ? callback(data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*解码加密的breakpoint
* @pparam basecode {String} 要解码的字符串
*/
fromBaseCode: function (basecode) {
return getBreakPoint(basecode)
},
/**
*通过key值和课时值获取自定的数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* order {Number} 排序ID
*@param callback {Function} 回调方法
*/
getUserDefineInfo: function (data, callback) {
var paramfiled = ['lessonId', 'order']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=getUserDefineInfo',
{
defineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
/**
*通过key值和课时值及用户ID获取自定的数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* order {Number} 排序ID
*@param callback {Function} 回调方法
*/
getUserDefineInfoByUser: function (data, callback) {
var paramfiled = ['lessonId', 'order']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=getUserDefineInfoByUser',
{
defineUserDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
/**
*增加自定数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* key {string} 键值ID
* content {string} 字内容
*@param callback {Function} 回调方法
*/
addUserDefine: function (data, callback) {
var paramfiled = ['lessonId', 'key', 'content']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserDefine',
{
addDefineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
})
var getDate = function (url, data, callback) {
$.get(
url,
data,
function (data) {
callback(data)
},
'json'
)
}
var isFunction = function (fn) {
if (typeof fn === 'function') return true
return false
}
var verfiyData = function (field, data) {
var arr = []
console.log(field)
console.log('<br/>')
console.log(data)
for (var i in field) {
if (!data[field[i]]) {
throw new Error('data is error')
}
arr.push(field[i] + ':' + data[field[i]])
}
if (root.JSON) {
return JSON.stringify(data)
} else {
return '{' + arr.join(',') + '}'
}
}
var getBreakPoint = function (base64point) {
var newbc = base64point.split('').reverse().join('')
return Base64.fromBase64(newbc)
}
root.videoAjax = function () {
return new videoajax()
}
})(this)
+1
View File
@@ -0,0 +1 @@
(function(global){'use strict';var _Base64=global.Base64;var version="2.1.5";var buffer;if(typeof module!=='undefined'&&module.exports){buffer=require('buffer').Buffer}var b64chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';var b64tab=function(bin){var t={};for(var i=0,l=bin.length;i<l;i++)t[bin.charAt(i)]=i;return t}(b64chars);var fromCharCode=String.fromCharCode;var cb_utob=function(c){if(c.length<2){var cc=c.charCodeAt(0);return cc<0x80?c:cc<0x800?(fromCharCode(0xc0|(cc>>>6))+fromCharCode(0x80|(cc&0x3f))):(fromCharCode(0xe0|((cc>>>12)&0x0f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}else{var cc=0x10000+(c.charCodeAt(0)-0xD800)*0x400+(c.charCodeAt(1)-0xDC00);return(fromCharCode(0xf0|((cc>>>18)&0x07))+fromCharCode(0x80|((cc>>>12)&0x3f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}};var re_utob=/[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;var utob=function(u){return u.replace(re_utob,cb_utob)};var cb_encode=function(ccc){var padlen=[0,2,1][ccc.length%3],ord=ccc.charCodeAt(0)<<16|((ccc.length>1?ccc.charCodeAt(1):0)<<8)|((ccc.length>2?ccc.charCodeAt(2):0)),chars=[b64chars.charAt(ord>>>18),b64chars.charAt((ord>>>12)&63),padlen>=2?'=':b64chars.charAt((ord>>>6)&63),padlen>=1?'=':b64chars.charAt(ord&63)];return chars.join('')};var btoa=global.btoa?function(b){return global.btoa(b)}:function(b){return b.replace(/[\s\S]{1,3}/g,cb_encode)};var _encode=buffer?function(u){return(new buffer(u)).toString('base64')}:function(u){return btoa(utob(u))};var encode=function(u,urisafe){return!urisafe?_encode(u):_encode(u).replace(/[+\/]/g,function(m0){return m0=='+'?'-':'_'}).replace(/=/g,'')};var encodeURI=function(u){return encode(u,true)};var re_btou=new RegExp(['[\xC0-\xDF][\x80-\xBF]','[\xE0-\xEF][\x80-\xBF]{2}','[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),'g');var cb_btou=function(cccc){switch(cccc.length){case 4:var cp=((0x07&cccc.charCodeAt(0))<<18)|((0x3f&cccc.charCodeAt(1))<<12)|((0x3f&cccc.charCodeAt(2))<<6)|(0x3f&cccc.charCodeAt(3)),offset=cp-0x10000;return(fromCharCode((offset>>>10)+0xD800)+fromCharCode((offset&0x3FF)+0xDC00));case 3:return fromCharCode(((0x0f&cccc.charCodeAt(0))<<12)|((0x3f&cccc.charCodeAt(1))<<6)|(0x3f&cccc.charCodeAt(2)));default:return fromCharCode(((0x1f&cccc.charCodeAt(0))<<6)|(0x3f&cccc.charCodeAt(1)))}};var btou=function(b){return b.replace(re_btou,cb_btou)};var cb_decode=function(cccc){var len=cccc.length,padlen=len%4,n=(len>0?b64tab[cccc.charAt(0)]<<18:0)|(len>1?b64tab[cccc.charAt(1)]<<12:0)|(len>2?b64tab[cccc.charAt(2)]<<6:0)|(len>3?b64tab[cccc.charAt(3)]:0),chars=[fromCharCode(n>>>16),fromCharCode((n>>>8)&0xff),fromCharCode(n&0xff)];chars.length-=[0,0,2,1][padlen];return chars.join('')};var atob=global.atob?function(a){return global.atob(a)}:function(a){return a.replace(/[\s\S]{1,4}/g,cb_decode)};var _decode=buffer?function(a){return(new buffer(a,'base64')).toString()}:function(a){return btou(atob(a))};var decode=function(a){return _decode(a.replace(/[-_]/g,function(m0){return m0=='-'?'+':'/'}).replace(/[^A-Za-z0-9\+\/]/g,''))};var noConflict=function(){var Base64=global.Base64;global.Base64=_Base64;return Base64};global.Base64={VERSION:version,atob:atob,btoa:btoa,fromBase64:decode,toBase64:encode,utob:utob,encode:encode,encodeURI:encodeURI,btou:btou,decode:decode,noConflict:noConflict};if(typeof Object.defineProperty==='function'){var noEnum=function(v){return{value:v,enumerable:false,writable:true,configurable:true}};global.Base64.extendString=function(){Object.defineProperty(String.prototype,'fromBase64',noEnum(function(){return decode(this)}));Object.defineProperty(String.prototype,'toBase64',noEnum(function(urisafe){return encode(this,urisafe)}));Object.defineProperty(String.prototype,'toBase64URI',noEnum(function(){return encode(this,true)}))}}})(this);if(this['Meteor']){Base64=global.Base64};(function(root){var videoajax=function(argument){var host='/';this._setHost=function(hoststr){host=hoststr};this._getHost=function(){return host}};$.extend(videoajax.prototype,{setHost:function(hosts){this._setHost(hosts)},quitBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id","break_point"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addBreakPoint',{userViewPoint:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=userbreakpoint',{userCourseInfo:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getUserAnswer:function(data,callback){var paramfiled=["user_id","lesson_id","course_id","question_order","answer"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addUserAnswer',{userAnswerInfo:dataStr},function(data){isFunction(callback)?callbacLine truncated
+36 -7
View File
@@ -1,15 +1,44 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
import {
CourseCanvas,
registerAppPortalRoot,
useCourseCanvas,
} from "@/course/canvas";
import { OrientationGuard } from "@/course/orientation";
import { GameAudioProvider } from "@/utils/audio";
import { Navigation } from "./components/business"; import { Navigation } from "./components/business";
import { cn } from "./utils";
function CourseShell() {
const { portalContainer } = useCourseCanvas();
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
return (
<GameAudioProvider>
<ToastProvider>
<Navigation />
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
<ToastViewport container={portalContainer} />
</ToastProvider>
</GameAudioProvider>
);
}
function App() { function App() {
return ( return (
<div className={cn("min-h-screen flex flex-col")}> <>
<Navigation /> <OrientationGuard />
<main className={cn("flex-1 p-8")}> <CourseCanvas
<Outlet /> className="bg-gray-900"
</main> canvasClassName="flex flex-col overflow-hidden bg-white"
</div> >
<CourseShell />
</CourseCanvas>
</>
); );
} }
+2 -11
View File
@@ -3,19 +3,10 @@
* 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端 * 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端
*/ */
import type { ApiError, ApiResponse } from "./client.types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
export interface ApiResponse<T> {
data: T;
message?: string;
code?: number;
}
export interface ApiError {
message: string;
code?: number;
}
/** /**
* 基础请求函数 * 基础请求函数
*/ */
+10
View File
@@ -0,0 +1,10 @@
export interface ApiResponse<T> {
data: T;
message?: string;
code?: number;
}
export interface ApiError {
message: string;
code?: number;
}
+2 -9
View File
@@ -1,9 +1,2 @@
export { export { del, get, post, put, request } from "./client.ts";
type ApiError, export type { ApiError, ApiResponse } from "./client.types";
type ApiResponse,
del,
get,
post,
put,
request,
} from "./client.ts";
+35
View File
@@ -0,0 +1,35 @@
/**
* 应用配置
* 修改此文件后需要重启开发服务
*/
const env = (import.meta as ImportMeta & { env?: Record<string, string> }).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 ?? "标准课程页示例",
},
/** 本地存储配置 */
storage: {
prefix: "yiban-course-template",
},
/** 视口配置 */
viewport: {
width: "device-width",
initialScale: 1.0,
minimumScale: 1.0,
maximumScale: 1.0,
},
};
@@ -1,20 +1,13 @@
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils"; import { cn } from "@/lib/cn";
import styles from "./Navigation.module.css"; import styles from "./Navigation.module.css";
import type { NavItem, NavigationProps } from "./Navigation.types";
interface NavItem {
label: string;
path: string;
}
interface NavigationProps {
items?: NavItem[];
className?: string;
}
const defaultItems: NavItem[] = [ const defaultItems: NavItem[] = [
{ label: "首页", path: "/" }, { label: "首页", path: "/" },
{ label: "关于", path: "/about" }, { label: "关于", path: "/about" },
{ label: "组件示例", path: "/components" },
{ label: "课程示例", path: "/course-demo" },
]; ];
export function Navigation({ export function Navigation({
@@ -0,0 +1,9 @@
export interface NavItem {
label: string;
path: string;
}
export interface NavigationProps {
items?: NavItem[];
className?: string;
}
@@ -1 +1,2 @@
export { Navigation } from "./Navigation.tsx"; export { Navigation } from "./Navigation.tsx";
export type { NavItem, NavigationProps } from "./Navigation.types";
+1
View File
@@ -1 +1,2 @@
export type { NavItem, NavigationProps } from "./Navigation";
export { Navigation } from "./Navigation"; export { Navigation } from "./Navigation";
@@ -0,0 +1,9 @@
@reference "tailwindcss";
.container {
@apply relative;
}
.content {
@apply relative h-full z-10;
}
@@ -0,0 +1,41 @@
import { cn } from "@/lib/cn";
import { getStaticImage } from "@/utils/assets";
import styles from "./BackgroundContainer.module.css";
import type { BackgroundContainerProps } from "./BackgroundContainer.types";
const DIRECT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/|\.\.?\/)/i;
function resolveBackgroundImage(src: string): string {
return DIRECT_URL_PATTERN.test(src) ? src : getStaticImage(src) || src;
}
export function BackgroundContainer({
src,
fit = "cover",
position = "center",
repeat = "no-repeat",
as: Component = "div",
className,
contentClassName,
children,
style,
...props
}: BackgroundContainerProps) {
const imageSrc = resolveBackgroundImage(src);
return (
<Component
className={cn(styles.container, className)}
style={{
backgroundImage: `url(${JSON.stringify(imageSrc)})`,
backgroundSize: fit,
backgroundPosition: position,
backgroundRepeat: repeat,
...style,
}}
{...props}
>
<div className={cn(styles.content, contentClassName)}>{children}</div>
</Component>
);
}
@@ -0,0 +1,21 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BackgroundContainerElement = "div" | "section" | "main" | "article";
export type BackgroundContainerFit = "cover" | "contain";
export type BackgroundContainerRepeat =
| "no-repeat"
| "repeat"
| "repeat-x"
| "repeat-y";
export interface BackgroundContainerProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
/** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */
src: string;
fit?: BackgroundContainerFit;
position?: string;
repeat?: BackgroundContainerRepeat;
as?: BackgroundContainerElement;
contentClassName?: string;
children?: ReactNode;
}
@@ -0,0 +1,7 @@
export { BackgroundContainer } from "./BackgroundContainer";
export type {
BackgroundContainerElement,
BackgroundContainerFit,
BackgroundContainerProps,
BackgroundContainerRepeat,
} from "./BackgroundContainer.types";
@@ -1,36 +0,0 @@
@reference "tailwindcss";
.button {
@apply inline-flex items-center justify-center rounded-md font-medium transition-colors;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
@apply disabled:pointer-events-none disabled:opacity-50;
}
.primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
.secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
}
.outline {
@apply border border-gray-300 bg-transparent hover:bg-gray-100;
}
.ghost {
@apply bg-transparent hover:bg-gray-100;
}
.sm {
@apply h-8 px-3 text-sm;
}
.md {
@apply h-10 px-4 text-base;
}
.lg {
@apply h-12 px-6 text-lg;
}
-22
View File
@@ -1,22 +0,0 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/utils";
import styles from "./Button.module.css";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", ...props }, ref) => {
return (
<button
ref={ref}
className={cn(styles.button, styles[variant], styles[size], className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
-2
View File
@@ -1,2 +0,0 @@
export type { ButtonProps } from "./Button.tsx";
export { Button } from "./Button.tsx";
@@ -0,0 +1,44 @@
import { appConfig } from "@/app.config";
import { useDialogStore } from "@/stores";
import type {
GuideDialogStep,
StartDialogOptions,
} from "@/stores/types/dialog.types";
export interface DefineDialogOptions {
id: string;
steps: GuideDialogStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface GuideDialogController {
id: string;
steps: GuideDialogStep[];
start: (options?: Pick<StartDialogOptions, "initialStep">) => void;
reset: () => void;
}
export function defineDialog({
id,
steps,
showOnce = true,
initialStep = 0,
}: DefineDialogOptions): GuideDialogController {
return {
id,
steps,
start: (options) => {
useDialogStore.getState().startDialog({
courseId: appConfig.course.id,
dialogId: id,
showOnce,
steps,
initialStep: options?.initialStep ?? initialStep,
});
},
reset: () => {
useDialogStore.getState().resetDialog(appConfig.course.id, id);
},
};
}
@@ -0,0 +1,56 @@
import { useCallback } from "react";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
import { useDialogStore } from "@/stores";
import type { GuideDialogProps } from "./GuideDialog.types";
export function GuideDialog({
children,
onFinish,
onSkip,
...props
}: GuideDialogProps) {
const {
open,
steps,
current,
nextStep,
prevStep,
selectOption,
finishDialog,
skipDialog,
} = useDialogStore();
const { viewport } = useCourseCanvas();
const finish = useCallback(() => {
finishDialog();
onFinish?.();
}, [finishDialog, onFinish]);
const skip = useCallback(() => {
skipDialog();
onSkip?.();
}, [onSkip, skipDialog]);
return (
<RegistryGuideDialog
state={{
open,
steps,
current,
}}
actions={{
next: nextStep,
prev: prevStep,
skip,
finish,
selectOption,
}}
container={getAppPortalRoot}
viewport={viewport}
{...props}
>
{children}
</RegistryGuideDialog>
);
}
@@ -0,0 +1,12 @@
import type { GuideDialogProps as RegistryGuideDialogProps } from "@/course/interactions/guide-dialog";
export interface GuideDialogProps
extends Omit<RegistryGuideDialogProps, "actions" | "state" | "viewport"> {
onFinish?: () => void;
onSkip?: () => void;
}
export type {
GuideDialogRenderState,
GuideDialogTheme,
} from "@/course/interactions/guide-dialog";
@@ -0,0 +1,11 @@
export { GuideDialog } from "./GuideDialog";
export type {
DefineDialogOptions,
GuideDialogController,
} from "./GuideDialog.helpers";
export { defineDialog } from "./GuideDialog.helpers";
export type {
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "./GuideDialog.types";
@@ -0,0 +1,57 @@
import { appConfig } from "@/app.config";
import { useSpotlightStore } from "@/stores";
import type {
SpotlightStep,
StartSpotlightOptions,
} from "@/stores/types/spotlight.types";
type DefineSpotlightStep = Omit<SpotlightStep, "target"> & {
target?: SpotlightStep["target"];
};
export interface DefineSpotlightOptions {
id: string;
steps: DefineSpotlightStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface SpotlightController {
id: string;
steps: SpotlightStep[];
start: (options?: Pick<StartSpotlightOptions, "initialStep">) => void;
reset: () => void;
}
function normalizeSteps(steps: DefineSpotlightStep[]): SpotlightStep[] {
return steps.map((step) => ({
...step,
target: step.target ?? { dataGuide: step.id },
}));
}
export function defineSpotlight({
id,
steps,
showOnce = true,
initialStep = 0,
}: DefineSpotlightOptions): SpotlightController {
const normalizedSteps = normalizeSteps(steps);
return {
id,
steps: normalizedSteps,
start: (options) => {
useSpotlightStore.getState().startSpotlight({
courseId: appConfig.course.id,
spotlightId: id,
showOnce,
steps: normalizedSteps,
initialStep: options?.initialStep ?? initialStep,
});
},
reset: () => {
useSpotlightStore.getState().resetSpotlight(appConfig.course.id, id);
},
};
}
@@ -0,0 +1,56 @@
import { useCallback } from "react";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
import { useSpotlightStore } from "@/stores";
import type { GuideSpotlightProps } from "./GuideSpotlight.types";
export function GuideSpotlight({
children,
onFinish,
onSkip,
container = getAppPortalRoot,
viewport,
...props
}: GuideSpotlightProps) {
const {
open,
steps,
current,
nextStep,
prevStep,
finishSpotlight,
skipSpotlight,
} = useSpotlightStore();
const { viewport: canvasViewport } = useCourseCanvas();
const finish = useCallback(() => {
finishSpotlight();
onFinish?.();
}, [finishSpotlight, onFinish]);
const skip = useCallback(() => {
skipSpotlight();
onSkip?.();
}, [onSkip, skipSpotlight]);
return (
<RegistryGuideSpotlight
state={{
open,
steps,
current,
}}
actions={{
next: nextStep,
prev: prevStep,
skip,
finish,
}}
container={container}
viewport={viewport ?? canvasViewport}
{...props}
>
{children}
</RegistryGuideSpotlight>
);
}
@@ -0,0 +1,14 @@
import type { GuideSpotlightProps as RegistryGuideSpotlightProps } from "@/course/interactions/guide-spotlight";
export interface GuideSpotlightProps
extends Omit<RegistryGuideSpotlightProps, "actions" | "state"> {
onFinish?: () => void;
onSkip?: () => void;
}
export type {
GuideSpotlightRenderState,
GuideSpotlightTheme,
Rect,
SpotlightLayout,
} from "@/course/interactions/guide-spotlight";
@@ -0,0 +1,11 @@
export { GuideSpotlight } from "./GuideSpotlight";
export type {
DefineSpotlightOptions,
SpotlightController,
} from "./GuideSpotlight.helpers";
export { defineSpotlight } from "./GuideSpotlight.helpers";
export type {
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
} from "./GuideSpotlight.types";
@@ -0,0 +1,34 @@
@reference "tailwindcss";
/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */
.button {
/* hover/按下缩放倍率由组件通过内联 --hover-scale / --press-scale 变量传入 */
--hover-scale: 1.05;
--press-scale: 0.95;
transition: transform 0.18s ease-out;
@apply m-0 box-border cursor-pointer select-none appearance-none overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle hover:brightness-110;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500;
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
}
.button:hover {
transform: scale(var(--hover-scale));
}
.button:active {
transform: scale(var(--press-scale));
}
.image {
@apply block h-full w-full object-cover;
}
@media (prefers-reduced-motion: reduce) {
.button,
.button:hover,
.button:active {
transform: none;
transition: none;
}
}
@@ -0,0 +1,53 @@
import { type CSSProperties, forwardRef } from "react";
import { cn } from "@/lib/cn";
import { getStaticImage } from "@/utils/assets";
import styles from "./ImageButton.module.css";
import type { ImageButtonProps } from "./ImageButton.types";
// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 <img>
function resolveImage(src: string): string {
return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src;
}
export const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
(
{
src,
alt = "",
width,
height,
hoverScale = 1.05,
pressScale = 0.95,
className,
style,
...rest
},
ref,
) => {
const imageSrc = resolveImage(src);
// hover/按下缩放由 CSS 过渡实现,倍率通过内联变量传入(见 ImageButton.module.css)
const scaleVars = {
"--hover-scale": hoverScale,
"--press-scale": pressScale,
} as CSSProperties;
return (
<button
ref={ref}
type="button"
className={cn(styles.button, className)}
style={{ ...scaleVars, width, height, ...style }}
{...rest}
>
<img
src={imageSrc}
alt={alt}
className={styles.image}
draggable={false}
/>
</button>
);
},
);
ImageButton.displayName = "ImageButton";
@@ -0,0 +1,15 @@
import type { ButtonHTMLAttributes } from "react";
export interface ImageButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** 图片:静态资源 key(相对 src/assets/images/)或外部 http(s)/data/blob URL */
src: string;
alt?: string;
/** 显示尺寸(数字按 px,字符串原样透传)。图片按钮通常应有确定尺寸 */
width?: number | string;
height?: number | string;
/** hover 放大倍率,默认 1.05 */
hoverScale?: number;
/** 按下缩小倍率,默认 0.95 */
pressScale?: number;
}
@@ -0,0 +1,2 @@
export { ImageButton } from "./ImageButton";
export type { ImageButtonProps } from "./ImageButton.types";
@@ -0,0 +1,39 @@
import { isRouteErrorResponse, Link, useRouteError } from "react-router-dom";
export function RouteError() {
const error = useRouteError();
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: "页面加载失败";
return (
<div className="flex min-h-full items-center justify-center p-8">
<div className="w-full max-w-xl rounded-3xl border border-rose-200 bg-white p-8 text-center shadow-xl">
<p className="text-sm font-semibold text-rose-600">页面运行异常</p>
<h1 className="mt-3 text-2xl font-bold text-gray-950">
当前示例无法加载
</h1>
<p className="mt-3 break-words text-sm leading-6 text-gray-600">
{message}
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
to="/"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white"
>
返回首页
</Link>
<button
type="button"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
onClick={() => window.location.reload()}
>
重新加载
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
export function RouteFallback() {
return (
<div className="flex min-h-[200px] items-center justify-center text-gray-500">
加载中...
</div>
);
}
@@ -0,0 +1,2 @@
export { RouteError } from "./RouteError";
export { RouteFallback } from "./RouteFallback";
@@ -0,0 +1,67 @@
import { Overlay } from "@/components/ui/overlay";
import { getAppPortalRoot } from "@/course/canvas";
import type { VideoOverlayProps } from "./VideoOverlay.types";
import { VideoPlayer } from "./VideoPlayer";
/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */
export function VideoOverlay({
open,
src,
poster,
autoPlay = true,
mode = "fullscreen",
windowWidth = 1200,
windowHeight = 675,
dismissible = false,
lockScroll = true,
skipLabel,
onClose,
onEnded,
onSkip,
}: VideoOverlayProps) {
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
variant="dim"
closeOnEscape={dismissible}
closeOnBackgroundClick={dismissible}
lockScroll={lockScroll}
className="z-50"
contentClassName="flex items-center justify-center"
>
<div
style={
mode === "window"
? { width: windowWidth, height: windowHeight }
: undefined
}
className={
mode === "window"
? "relative overflow-hidden rounded-lg bg-black shadow-2xl"
: "absolute inset-0 h-full w-full bg-black"
}
>
<VideoPlayer
src={src}
poster={poster}
autoPlay={autoPlay}
mode={mode}
allowFullscreenToggle={false}
onEnded={onEnded}
/>
{skipLabel && (
<button
type="button"
className="absolute right-14 top-10 z-10 rounded-full bg-black/55 px-10 py-4 text-[30px] text-white/90 hover:bg-black/75"
onClick={onSkip}
>
{skipLabel}
</button>
)}
</div>
</Overlay>
);
}
@@ -0,0 +1,28 @@
export interface VideoOverlayProps {
/** 是否显示 */
open: boolean;
/** 可直接交给 video 元素的视频 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 播放模式 */
mode?: "fullscreen" | "window";
/** 窗口模式下的宽度(px) */
windowWidth?: number;
/** 窗口模式下的高度(px) */
windowHeight?: number;
/** 是否允许点击背景或按 Escape 关闭 */
dismissible?: boolean;
/** 弹层打开时是否锁定页面滚动 */
lockScroll?: boolean;
/** 跳过按钮文案,不传则不显示 */
skipLabel?: string;
/** 关闭回调 */
onClose?: () => void;
/** 播放结束回调 */
onEnded?: () => void;
/** 点击跳过回调 */
onSkip?: () => void;
}
@@ -0,0 +1,134 @@
@reference "tailwindcss";
/* 播放器容器 */
.player {
@apply relative flex items-center justify-center overflow-hidden bg-black;
}
.playerFullscreen {
@apply absolute inset-0 h-full w-full;
}
.playerWindow {
@apply relative mx-auto;
}
/* 视频元素 */
.video {
@apply h-full w-full object-contain;
}
/* 中央播放按钮(暂停时显示) */
.centerPlayButton {
@apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300;
}
.centerPlayButton:hover {
@apply bg-black/70;
}
/* 控制条容器 */
.controls {
@apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300;
}
.controlsHidden {
@apply pointer-events-none opacity-0;
}
/* 小进度条(控件隐藏时显示) */
.miniProgress {
@apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300;
}
.miniProgressHidden {
@apply opacity-0;
}
.miniProgressBar {
@apply h-full w-full origin-left bg-red-500;
transform: scaleX(0);
will-change: transform;
}
/* 进度条 */
.progressContainer {
@apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30;
touch-action: none;
}
.progressBar,
.progressBuffered {
@apply absolute left-0 top-0 h-full w-full origin-left rounded-full;
transform: scaleX(0);
will-change: transform;
}
.progressBar {
@apply bg-red-500;
}
.progressBuffered {
@apply bg-white/30;
}
.progressThumb {
@apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200;
transform: translate3d(0, -50%, 0);
will-change: transform;
}
.progressContainer:hover .progressThumb,
.progressDragging .progressThumb {
@apply opacity-100;
}
.progressDragging {
@apply cursor-grabbing;
}
/* 时间显示 */
.timeDisplay {
@apply text-xs text-white/90 tabular-nums;
}
/* 按钮 */
.controlButton {
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white;
}
.controlButton:disabled {
@apply cursor-not-allowed opacity-50;
}
/* 倍速选择器 */
.rateSelector {
@apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white;
}
/* 音量滑块 */
.volumeSlider {
@apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30;
}
.volumeSlider::-webkit-slider-thumb {
@apply h-3 w-3 appearance-none rounded-full bg-white shadow-md;
}
.volumeSlider::-moz-range-thumb {
@apply h-3 w-3 rounded-full border-0 bg-white shadow-md;
}
/* 底部控制行 */
.controlRow {
@apply flex items-center gap-2;
}
/* 加载状态 */
.loading {
@apply absolute inset-0 flex items-center justify-center bg-black/30;
}
.spinner {
@apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
export interface VideoPlayerRef {
play: () => void;
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setPlaybackRate: (rate: number) => void;
getCurrentTime: () => number;
getDuration: () => number;
}
export interface VideoPlayerProps {
/** 视频源 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 循环播放 */
loop?: boolean;
/** 默认静音 */
muted?: boolean;
/** 预加载策略 */
preload?: "none" | "metadata" | "auto";
/** 初始播放速度 */
playbackRate?: number;
/** 显示模式:全屏覆盖或窗口 */
mode?: "fullscreen" | "window";
/** 是否允许在全屏覆盖与窗口模式之间切换 */
allowFullscreenToggle?: boolean;
/** 自定义 className */
className?: string;
// 事件回调
/** 播放开始时触发 */
onPlay?: () => void;
/** 暂停时触发 */
onPause?: () => void;
/** 播放结束时触发 */
onEnded?: () => void;
/** 时间更新时触发 */
onTimeUpdate?: (currentTime: number) => void;
/** 进度加载时触发 */
onProgress?: (buffered: TimeRanges | null) => void;
/** 音量变化时触发 */
onVolumeChange?: (volume: number, muted: boolean) => void;
/** 播放速度变化时触发 */
onPlaybackRateChange?: (rate: number) => void;
/** 全屏状态变化时触发 */
onFullscreenChange?: (isFullscreen: boolean) => void;
}
+4
View File
@@ -0,0 +1,4 @@
export { VideoOverlay } from "./VideoOverlay";
export type { VideoOverlayProps } from "./VideoOverlay.types";
export { VideoPlayer } from "./VideoPlayer";
export type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
+49 -2
View File
@@ -1,2 +1,49 @@
export type { ButtonProps } from "./Button"; export type { ButtonProps } from "@/components/ui/button";
export { Button } from "./Button"; export { Button } from "@/components/ui/button";
export type { OverlayProps } from "@/components/ui/overlay";
export { Overlay } from "@/components/ui/overlay";
export type { PortalContainer, PortalProps } from "@/components/ui/portal";
export { Portal } from "@/components/ui/portal";
export type {
ToastContextValue,
ToastItem as ToastItemData,
ToastOptions,
ToastProviderProps,
ToastRenderApi,
ToastType,
ToastViewportClassNames,
ToastViewportProps,
} from "@/components/ui/toast";
export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast";
export type {
BackgroundContainerElement,
BackgroundContainerFit,
BackgroundContainerProps,
BackgroundContainerRepeat,
} from "./BackgroundContainer";
export { BackgroundContainer } from "./BackgroundContainer";
export type {
DefineDialogOptions,
GuideDialogController,
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "./GuideDialog";
export { defineDialog, GuideDialog } from "./GuideDialog";
export type {
DefineSpotlightOptions,
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
SpotlightController,
} from "./GuideSpotlight";
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
export type { ImageButtonProps } from "./ImageButton";
export { ImageButton } from "./ImageButton";
export { RouteError, RouteFallback } from "./RouteFallback";
export type {
VideoOverlayProps,
VideoPlayerProps,
VideoPlayerRef,
} from "./Video";
export { VideoOverlay, VideoPlayer } from "./Video";
+16
View File
@@ -0,0 +1,16 @@
import type { RefObject } from "react";
let portalRoot: RefObject<HTMLDivElement | null> | null = null;
export function registerAppPortalRoot(
container: RefObject<HTMLDivElement | null>,
): () => void {
portalRoot = container;
return () => {
if (portalRoot === container) portalRoot = null;
};
}
export function getAppPortalRoot(): HTMLDivElement | null {
return portalRoot?.current ?? null;
}
@@ -5,3 +5,4 @@ export type {
CourseCanvasProps, CourseCanvasProps,
CourseCanvasViewport, CourseCanvasViewport,
} from "./CourseCanvas.types"; } from "./CourseCanvas.types";
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
@@ -6,17 +6,25 @@ interface CanvasScaleOptions {
maxScale: number; maxScale: number;
} }
function getViewportSize() {
if (typeof window === "undefined") {
return null;
}
return {
width: window.visualViewport?.width ?? window.innerWidth,
height: window.visualViewport?.height ?? window.innerHeight,
};
}
function calculateScale({ function calculateScale({
width, width,
height, height,
maxScale, maxScale,
}: CanvasScaleOptions): number { }: CanvasScaleOptions): number {
if (typeof window === "undefined") return maxScale; if (typeof window === "undefined") return maxScale;
return Math.min( const viewport = getViewportSize();
window.innerWidth / width, if (!viewport) return maxScale;
window.innerHeight / height, return Math.min(viewport.width / width, viewport.height / height, maxScale);
maxScale,
);
} }
export function useCanvasScale({ export function useCanvasScale({
@@ -33,11 +41,15 @@ export function useCanvasScale({
setScale(calculateScale({ width, height, maxScale })); setScale(calculateScale({ width, height, maxScale }));
updateScale(); updateScale();
window.addEventListener("resize", updateScale); window.addEventListener("resize", updateScale);
window.addEventListener("orientationchange", updateScale);
window.visualViewport?.addEventListener("resize", updateScale); window.visualViewport?.addEventListener("resize", updateScale);
window.visualViewport?.addEventListener("scroll", updateScale);
return () => { return () => {
window.removeEventListener("resize", updateScale); window.removeEventListener("resize", updateScale);
window.removeEventListener("orientationchange", updateScale);
window.visualViewport?.removeEventListener("resize", updateScale); window.visualViewport?.removeEventListener("resize", updateScale);
window.visualViewport?.removeEventListener("scroll", updateScale);
}; };
}, [height, maxScale, width]); }, [height, maxScale, width]);
@@ -128,10 +128,21 @@ export function GuideDialog({
} }
const context = getCoordinateContext({ container, viewport }); const context = getCoordinateContext({ container, viewport });
const panelRect = panelRef.current?.getBoundingClientRect(); const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = { const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth, width: panelElement
height: panelRect ? panelRect.height / context.scale : 220, ? Math.max(
panelRect?.width ?? 0,
panelElement.scrollWidth * context.scale,
) / context.scale
: panelMaxWidth,
height: panelElement
? Math.max(
panelRect?.height ?? 0,
panelElement.scrollHeight * context.scale,
) / context.scale
: 220,
}; };
const position = getPanelPosition( const position = getPanelPosition(
step.placement ?? "bottom", step.placement ?? "bottom",
@@ -239,7 +250,7 @@ export function GuideDialog({
ref={panelRef} ref={panelRef}
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
styles.panel, panelClassName,
styles.measurePanel, styles.measurePanel,
theme?.panelClassName, theme?.panelClassName,
)} )}
@@ -149,6 +149,7 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.mask, .mask,
.highlight, .highlight,
.panelContainer,
.panel, .panel,
.missingPanel { .missingPanel {
animation: none; animation: none;
@@ -312,10 +312,21 @@ export function GuideSpotlight({
} }
const nextHighlightRect = getHighlightRect(target, padding, context); const nextHighlightRect = getHighlightRect(target, padding, context);
const panelRect = panelRef.current?.getBoundingClientRect(); const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = { const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth, width: panelElement
height: panelRect ? panelRect.height / context.scale : 180, ? Math.max(
panelRect?.width ?? 0,
panelElement.scrollWidth * context.scale,
) / context.scale
: panelMaxWidth,
height: panelElement
? Math.max(
panelRect?.height ?? 0,
panelElement.scrollHeight * context.scale,
) / context.scale
: 180,
}; };
const position = getPanelPosition( const position = getPanelPosition(
nextHighlightRect, nextHighlightRect,
@@ -503,7 +514,7 @@ export function GuideSpotlight({
ref={panelRef} ref={panelRef}
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
styles.panel, panelClassName,
styles.measurePanel, styles.measurePanel,
theme?.panelClassName, theme?.panelClassName,
step.panelClassName, step.panelClassName,
@@ -0,0 +1,37 @@
import type { OrientationGuardProps } from "./OrientationGuard.types";
import { useOrientation } from "./useOrientation";
function DefaultLandscapePrompt() {
return (
<div className="fixed inset-0 z-2000 flex items-center justify-center bg-gray-950/80 px-8 text-white">
<div className="text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center">
<svg
aria-hidden="true"
className="h-full w-full text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</svg>
</div>
<h1 className="text-2xl font-semibold">请旋转屏幕</h1>
<p className="mt-3 text-base text-gray-400">
为获得最佳体验,请横屏浏览
</p>
</div>
</div>
);
}
export function OrientationGuard({ renderPrompt }: OrientationGuardProps) {
const isPortrait = useOrientation();
if (!isPortrait) return null;
if (renderPrompt) return <>{renderPrompt()}</>;
return <DefaultLandscapePrompt />;
}
@@ -0,0 +1,6 @@
import type { ReactNode } from "react";
export interface OrientationGuardProps {
/** 竖屏时渲染的自定义提示;不传则用内置的横屏提示 */
renderPrompt?: () => ReactNode;
}
+3
View File
@@ -0,0 +1,3 @@
export { OrientationGuard } from "./OrientationGuard";
export type { OrientationGuardProps } from "./OrientationGuard.types";
export { useOrientation } from "./useOrientation";
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
function getIsPortrait() {
if (typeof window === "undefined") return false;
return window.innerHeight > window.innerWidth;
}
export function useOrientation() {
const [isPortrait, setIsPortrait] = useState(getIsPortrait);
useEffect(() => {
const updateOrientation = () => setIsPortrait(getIsPortrait());
window.addEventListener("resize", updateOrientation);
window.addEventListener("orientationchange", updateOrientation);
return () => {
window.removeEventListener("resize", updateOrientation);
window.removeEventListener("orientationchange", updateOrientation);
};
}, []);
return isPortrait;
}
-1
View File
@@ -1 +0,0 @@
export { useLocalStorage } from "./useLocalStorage.ts";
-52
View File
@@ -1,52 +0,0 @@
import { useEffect, useState } from "react";
/**
* 自定义 Hook:使用 localStorage 存储状态
* @param key - localStorage 的 key
* @param initialValue - 初始值
* @returns [storedValue, setValue] - 存储的值和更新函数
*/
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue) as T);
} catch (error) {
console.error(`Error parsing storage event for key "${key}":`, error);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key]);
return [storedValue, setValue];
}
+4
View File
@@ -1,8 +1,12 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import "./utils/vconsole";
import "./styles/index.css"; import "./styles/index.css";
import { router } from "./router/index.tsx"; import { router } from "./router/index.tsx";
import { initializeYibanSession } from "./utils/yiban";
initializeYibanSession();
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
-16
View File
@@ -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
View File
@@ -1 +0,0 @@
export { About } from "./About.tsx";
+19
View File
@@ -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>
);
}
+405
View File
@@ -0,0 +1,405 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useCourseCanvas } from "@/course/canvas";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/course/interactions/drag-drop";
import { cn } from "@/lib/cn";
import type {
DragDemoItem,
DragDemoZone,
DragDropDemoResult,
} from "./DragDropDemo.types";
const dragItems = [
{
id: "apple",
label: "苹果",
description: "富含维生素的水果",
icon: "🍎",
tone: "rose",
targetZoneId: "fruit-zone",
},
{
id: "banana",
label: "香蕉",
description: "常见的热带水果",
icon: "🍌",
tone: "lime",
targetZoneId: "fruit-zone",
},
{
id: "car",
label: "汽车",
description: "陆地出行工具",
icon: "🚗",
tone: "blue",
targetZoneId: "traffic-zone",
},
{
id: "bike",
label: "自行车",
description: "绿色低碳出行工具",
icon: "🚲",
tone: "cyan",
targetZoneId: "traffic-zone",
},
{
id: "book",
label: "书本",
description: "阅读和学习资料",
icon: "📘",
tone: "amber",
targetZoneId: "study-zone",
},
{
id: "pencil",
label: "铅笔",
description: "书写和绘画工具",
icon: "✏️",
tone: "indigo",
targetZoneId: "study-zone",
},
] satisfies DragDemoItem[];
const dropZones = [
{
id: "fruit-zone",
title: "水果类",
description: "把可以食用的水果放到这里",
icon: "🍓",
tone: "emerald",
},
{
id: "traffic-zone",
title: "交通工具",
description: "把出行相关物品放到这里",
icon: "🚌",
tone: "sky",
},
{
id: "study-zone",
title: "学习用品",
description: "把学习和书写用品放到这里",
icon: "🎒",
tone: "violet",
},
] satisfies DragDemoZone[];
const dragItemClassNames = {
rose: "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-100",
blue: "border-blue-200 bg-blue-50 text-blue-700 shadow-blue-100",
amber: "border-amber-200 bg-amber-50 text-amber-700 shadow-amber-100",
lime: "border-lime-200 bg-lime-50 text-lime-700 shadow-lime-100",
cyan: "border-cyan-200 bg-cyan-50 text-cyan-700 shadow-cyan-100",
indigo: "border-indigo-200 bg-indigo-50 text-indigo-700 shadow-indigo-100",
};
const zoneIconClassNames = {
emerald: "bg-emerald-100 text-emerald-600",
sky: "bg-sky-100 text-sky-600",
violet: "bg-violet-100 text-violet-600",
};
const zoneOverClassNames = {
emerald:
"border-emerald-400 bg-emerald-50 shadow-[0_0_0_6px_rgba(16,185,129,0.10),0_18px_45px_rgba(16,185,129,0.16)]",
sky: "border-sky-400 bg-sky-50 shadow-[0_0_0_6px_rgba(14,165,233,0.10),0_18px_45px_rgba(14,165,233,0.16)]",
violet:
"border-violet-400 bg-violet-50 shadow-[0_0_0_6px_rgba(139,92,246,0.10),0_18px_45px_rgba(139,92,246,0.16)]",
};
const findDragItem = (itemId: string) =>
dragItems.find((item) => item.id === itemId);
const getZoneTotal = (zoneId: string) =>
dragItems.filter((item) => item.targetZoneId === zoneId).length;
const renderDragItem = (item: DragDemoItem, className?: string) => (
<div
className={cn(
"flex h-16 items-center gap-3 rounded-2xl border px-4 font-medium shadow-sm select-none",
dragItemClassNames[item.tone],
className,
)}
>
<span className="flex size-10 items-center justify-center rounded-full bg-white/70 text-xl shadow-sm">
{item.icon}
</span>
<span className="min-w-0">
<span className="block truncate">{item.label}</span>
<span className="mt-0.5 block truncate text-xs opacity-70">
{item.description}
</span>
</span>
</div>
);
const renderPlacedItem = (item: DragDemoItem) => (
<div
className={cn(
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium shadow-sm",
dragItemClassNames[item.tone],
)}
>
<span className="flex size-7 items-center justify-center rounded-full bg-white/70 text-base shadow-sm">
{item.icon}
</span>
<span>{item.label}</span>
</div>
);
export function DragDropDemo() {
const { scale } = useCourseCanvas();
const [dropResult, setDropResult] = useState<DragDropDemoResult>(null);
const [placedItemIds, setPlacedItemIds] = useState<Record<string, string[]>>(
{},
);
const placedIds = Object.values(placedItemIds).flat();
const placedCount = placedIds.length;
const progress = Math.round((placedCount / dragItems.length) * 100);
const isCompleted = placedCount === dragItems.length;
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
if (isCompleted) {
return;
}
const draggedId = String(event.active.id);
const droppedId = event.over ? String(event.over.id) : null;
const draggedItem = findDragItem(draggedId);
if (!droppedId || !draggedItem) {
setDropResult({
type: "error",
message: "没有放入目标区域,请再试一次。",
});
return;
}
if (placedIds.includes(draggedId)) {
return;
}
if (draggedItem.targetZoneId !== droppedId) {
setDropResult({
type: "error",
message: `还不对:${draggedItem.label} 不属于这个分类。`,
});
return;
}
const nextPlacedItemIds = {
...placedItemIds,
[droppedId]: [...(placedItemIds[droppedId] ?? []), draggedId],
};
const nextPlacedCount = Object.values(nextPlacedItemIds).flat().length;
setPlacedItemIds(nextPlacedItemIds);
setDropResult({
type: "success",
message:
nextPlacedCount === dragItems.length
? "全部分类完成,做得很好!"
: `答对了:${draggedItem.label} 已放入正确分类。`,
});
};
const handleDragStart = () => {
if (!isCompleted) {
setDropResult(null);
}
};
const handleDragCancel = () => {
if (!isCompleted) {
setDropResult(null);
}
};
const handleRetry = () => {
setDropResult(null);
setPlacedItemIds({});
};
return (
<section className="overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white shadow-sm">
<div className="border-slate-200 border-b bg-white/80 px-6 py-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-sky-600">
Interactive Course Demo
</p>
<h2 className="text-2xl font-semibold text-slate-950">
Drag and Drop 分类挑战
</h2>
<p className="max-w-2xl text-slate-500">
将左侧物品拖入右侧正确分类。拖拽预览使用自定义
DragOverlay,释放区根据是否匹配给出颜色反馈。
</p>
</div>
<div className="flex items-center gap-2">
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500">
完成 {placedCount}/{dragItems.length}
</div>
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500">
scale {scale.toFixed(3)}
</div>
</div>
</div>
<div className="mt-5 h-2 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-gradient-to-r from-sky-400 to-emerald-400 transition-[width] duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="p-6">
<DragDropProvider
scale={scale}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
renderOverlay={({ activeId }) => {
const activeItem = findDragItem(String(activeId));
return activeItem
? renderDragItem(
activeItem,
"pointer-events-none w-[300px] shadow-xl ring-4 ring-white/70",
)
: null;
}}
>
<div className="grid gap-5 lg:grid-cols-[320px_1fr]">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-slate-800">
待分类物品
</div>
<div className="mt-1 text-xs text-slate-400">
拖动物品到右侧对应区域
</div>
</div>
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
{dragItems.length - placedCount} left
</div>
</div>
<div className="grid gap-3">
{dragItems
.filter((item) => !placedIds.includes(item.id))
.map((item) => (
<Draggable
key={item.id}
id={item.id}
disabled={isCompleted}
>
{renderDragItem(item)}
</Draggable>
))}
{isCompleted && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
所有物品都已完成分类
</div>
)}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{dropZones.map((zone) => {
const zonePlacedIds = placedItemIds[zone.id] ?? [];
const placedItems = zonePlacedIds
.map((itemId) => findDragItem(itemId))
.filter((item): item is DragDemoItem => Boolean(item));
const zoneTotal = getZoneTotal(zone.id);
return (
<Droppable
key={zone.id}
id={zone.id}
canDrop={(activeId) => {
const activeItem = findDragItem(String(activeId));
return (
activeItem?.targetZoneId === zone.id &&
!zonePlacedIds.includes(String(activeId))
);
}}
className={({ isOver, canDrop }) =>
cn(
"min-h-80 rounded-3xl border-2 border-dashed border-slate-300 bg-white p-5 transition-[background-color,border-color,box-shadow] duration-150",
isOver && canDrop && zoneOverClassNames[zone.tone],
isOver &&
!canDrop &&
"border-rose-300 bg-rose-50 shadow-[0_0_0_6px_rgba(244,63,94,0.08)]",
)
}
>
<div className="flex h-full flex-col">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div
className={cn(
"mb-3 flex size-12 items-center justify-center rounded-2xl text-xl",
zoneIconClassNames[zone.tone],
)}
>
{zone.icon}
</div>
<div className="font-semibold text-slate-800">
{zone.title}
</div>
<div className="mt-1 text-sm text-slate-400">
{zone.description}
</div>
</div>
<div className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
{placedItems.length}/{zoneTotal}
</div>
</div>
<div className="mt-auto grid gap-2">
{placedItems.length > 0 ? (
placedItems.map((item) => (
<div key={item.id}>{renderPlacedItem(item)}</div>
))
) : (
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-5 text-center text-sm text-slate-400">
等待放入正确物品
</div>
)}
</div>
</div>
</Droppable>
);
})}
</div>
</div>
</DragDropProvider>
{dropResult && (
<div
className={cn(
"mt-4 flex items-center justify-between gap-3 rounded-2xl border bg-white px-4 py-3 text-sm shadow-sm",
dropResult.type === "success"
? "border-emerald-200 text-emerald-700"
: "border-rose-200 text-rose-600",
)}
>
<span>{dropResult.message}</span>
<Button size="sm" variant="ghost" onClick={handleRetry}>
重试
</Button>
</div>
)}
</div>
</section>
);
}
@@ -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;
+388
View File
@@ -0,0 +1,388 @@
import { useRef, useState } from "react";
import { GuideSpotlight } from "@/components/common";
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 { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { useSpotlightStore } from "@/stores";
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 } = useCourseCanvas();
const startGuideSpotlight = () => {
useSpotlightStore.getState().startSpotlight({
courseId: "components-playground",
spotlightId: "spotlight-demo",
steps: [
{
id: "guide-card",
target: { dataGuide: "spotlight-card" },
title: "引导目标区域",
description:
"通过 data-guide 标记页面元素,GuideSpotlight 会自动定位并高亮目标。",
placement: "bottom",
},
{
id: "guide-actions",
target: { dataGuide: "spotlight-actions" },
title: "引导操作按钮",
description: "可以为不同步骤设置 placement、文案和高亮间距。",
placement: "top",
padding: 12,
},
{
id: "guide-note",
target: { dataGuide: "spotlight-note" },
title: "完成引导",
description:
"示例页中可以手动触发引导,便于调试课程页面的新手提示流程。",
placement: "left",
doneLabel: "知道了",
},
],
});
};
return (
<div className="mx-auto max-w-6xl space-y-10 px-6 py-10 text-left">
<header className="space-y-3 text-center">
<p className="text-sm font-medium text-blue-600">
Components Playground
</p>
<h1 className="text-4xl font-bold text-gray-900">组件示例页</h1>
<p className="text-gray-600">
用于验证组件在模板中正常使用,展示与组件库(yuxiang-kit)集成效果。
</p>
<div className="mx-auto mt-4 max-w-2xl rounded-2xl border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 text-left">
<p className="text-sm font-semibold text-blue-700">
📐 本模板使用固定 1920 × 1080 设计画布 + CSS transform 整体缩放
</p>
<p className="mt-1 text-sm text-blue-600">
当前缩放比例 scale = {scale.toFixed(3)}。 DragDrop 等组件通过 scale
参数修正坐标, GuideSpotlight 通过 viewport + container
定位画布内元素。
</p>
</div>
</header>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Button</h2>
<p className="text-gray-600">现有按钮组件的 variant 和 size 示例。</p>
</div>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Toast</h2>
<p className="text-gray-600">
通过 useToast 显示轻提示,支持 info、success、warning、error
四种类型。
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button onClick={() => toast.info("这是一条普通提示。")}>
普通提示
</Button>
<Button
variant="secondary"
onClick={() => toast.success("操作成功。")}
>
成功提示
</Button>
<Button
variant="outline"
onClick={() => toast.warning("请注意检查。")}
>
警告提示
</Button>
<Button
variant="ghost"
className="text-red-600"
onClick={() => toast.error("网络异常,请稍后重试。")}
>
错误提示
</Button>
<Button
variant="outline"
onClick={() =>
toast.show({
message: "这条提示使用了单条 className 覆盖。",
type: "success",
className: "rounded-full border-0 bg-black/80 px-8 text-white",
})
}
>
自定义样式提示
</Button>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">
GuideSpotlight
</h2>
<p className="text-gray-600">
引导组件会根据 data-guide 或 selector
高亮页面元素,适合课程新手引导、功能提示和操作说明。
</p>
</div>
<div
className="grid gap-4 rounded-xl border border-dashed border-blue-300 bg-blue-50 p-5 md:grid-cols-[1fr_auto] md:items-center"
data-guide="spotlight-card"
>
<div className="space-y-2">
<p className="text-sm font-medium text-blue-600">Guide Demo</p>
<h3 className="text-xl font-semibold text-gray-900">
课程引导示例区域
</h3>
<p className="text-sm text-gray-600" data-guide="spotlight-note">
点击右侧按钮后,会依次高亮这个卡片、操作按钮和这段说明文字。
</p>
</div>
<div className="flex flex-wrap gap-3" data-guide="spotlight-actions">
<Button onClick={startGuideSpotlight}>开始引导</Button>
<Button
variant="outline"
onClick={() =>
useSpotlightStore
.getState()
.resetSpotlight("components-playground", "spotlight-demo")
}
>
重置引导记录
</Button>
</div>
</div>
</section>
<DragDropDemo />
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Portal</h2>
<p className="text-gray-600">
Portal 默认挂载到 document.body,也可以通过 container
指定自定义挂载位置。
</p>
</div>
<div className="mb-5 flex flex-wrap gap-3">
<Button onClick={() => setPortalVisible((visible) => !visible)}>
{portalVisible ? "隐藏 body Portal" : "显示 body Portal"}
</Button>
<Button
variant="secondary"
onClick={() => setCustomPortalVisible((visible) => !visible)}
>
{customPortalVisible ? "隐藏 ref Portal" : "显示 ref Portal"}
</Button>
</div>
<div
ref={customRootRef}
className="relative h-44 overflow-hidden rounded-xl border-2 border-dashed border-blue-300 bg-blue-50 p-4"
>
<p className="text-sm text-blue-700">
这是自定义 ref 容器。Portal 或 Overlay 都可以通过 container
挂载到这个容器里。
</p>
</div>
{portalVisible && (
<Portal>
<div className="fixed right-8 bottom-8 z-60 rounded-xl bg-blue-600 px-5 py-4 text-white shadow-xl">
这是通过 Portal 挂载到 body 的内容
</div>
</Portal>
)}
{customPortalVisible && (
<Portal container={customRootRef}>
<div className="absolute right-4 bottom-4 rounded-xl bg-purple-600 px-5 py-4 text-white shadow-xl">
这是通过 container=&#123;ref&#125; 挂载的内容
</div>
</Portal>
)}
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-5 space-y-2">
<h2 className="text-2xl font-semibold text-gray-900">Overlay</h2>
<p className="text-gray-600">
Overlay 内部已使用 Portal,可同时测试背景、关闭行为和挂载位置。
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{overlayCards.map((item) => (
<div
key={item.key}
className="rounded-xl border border-gray-200 p-4"
>
<h3 className="mb-2 text-lg font-semibold text-gray-900">
{item.title}
</h3>
<p className="mb-4 min-h-12 text-sm text-gray-600">
{item.description}
</p>
<Button onClick={() => setActiveOverlay(item.key)}>
{item.buttonText}
</Button>
</div>
))}
</div>
</section>
{currentOverlay && (
<Overlay
open={Boolean(currentOverlay)}
onClose={() => setActiveOverlay(null)}
container={overlayContainer}
className={isCustomOverlay ? "z-10" : undefined}
contentClassName="flex items-center justify-center p-8"
{...currentOverlay.overlayProps}
>
<div className="w-full max-w-xl rounded-3xl bg-white p-8 text-center shadow-2xl">
<p className="mb-2 text-sm font-medium text-blue-600">
Overlay Demo
</p>
<h3 className="mb-3 text-2xl font-bold text-gray-900">
{currentOverlay.title}
</h3>
<p className="mb-6 text-gray-600">{currentOverlay.description}</p>
<div className="flex justify-center gap-3">
<Button onClick={() => setActiveOverlay(null)}>关闭</Button>
<Button variant="outline" onClick={() => setActiveOverlay(null)}>
模拟继续学习
</Button>
</div>
</div>
</Overlay>
)}
<GuideSpotlight />
</div>
);
}
+107
View File
@@ -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: "开始分类",
},
],
});
+35
View File
@@ -0,0 +1,35 @@
import type {
GuideDialogRenderState,
GuideSpotlightRenderState,
} from "@/components/common";
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;
}
export interface CourseDemoGuideDialogPanelProps {
dialog: GuideDialogRenderState;
}
export interface CourseDemoGuideSpotlightPanelProps {
spotlight: GuideSpotlightRenderState;
}
@@ -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,131 @@
import type { CourseDemoGuideDialogPanelProps } from "./CourseDemo.types";
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 { cn } from "@/lib/cn";
import type { CourseDemoGuideSpotlightPanelProps } from "./CourseDemo.types";
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={cn(
"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} />;
}
}
+335
View File
@@ -0,0 +1,335 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Overlay } from "@/components/ui/overlay";
import { useToast } from "@/components/ui/toast";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/course/interactions/drag-drop";
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>
);
}
-16
View File
@@ -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
View File
@@ -1 +0,0 @@
export { Home } from "./Home.tsx";
+60
View File
@@ -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
View File
@@ -1,2 +1,4 @@
export { About } from "./About"; export { default as About } from "./About";
export { Home } from "./Home"; export { default as Components } from "./Components";
export { default as CourseDemo } from "./CourseDemo";
export { default as Home } from "./Home";
+16 -2
View File
@@ -1,19 +1,33 @@
import { createHashRouter } from "react-router-dom"; import { createHashRouter } from "react-router-dom";
import App from "../App.tsx"; import App from "../App.tsx";
import { RouteError, RouteFallback } from "../components/common";
import { lazyRoute } from "../utils"; import { lazyRoute } from "../utils";
export const router = createHashRouter([ export const router = createHashRouter([
{ {
path: "/", path: "/",
element: <App />, element: <App />,
errorElement: <RouteError />,
children: [ children: [
{ {
index: true, index: true,
lazy: lazyRoute(() => import("../pages/Home/Home")), lazy: lazyRoute(() => import("../pages/Home")),
HydrateFallback: RouteFallback,
}, },
{ {
path: "about", 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,
}, },
], ],
}, },
+17
View File
@@ -0,0 +1,17 @@
export * from "./modules";
export type {
AppState,
GuideDialogCharacter,
GuideDialogOption,
GuideDialogPlacement,
GuideDialogState,
GuideDialogStep,
SpotlightPlacement,
SpotlightState,
SpotlightStep,
SpotlightTarget,
StartDialogOptions,
StartSpotlightOptions,
UserState,
YibanConnectionStatus,
} from "./types";
+8
View File
@@ -0,0 +1,8 @@
import { create } from "zustand";
import type { AppState } from "../types/app.types";
export const useAppStore = create<AppState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
+136
View File
@@ -0,0 +1,136 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { appConfig } from "@/app.config";
import type { GuideDialogState } from "../types/dialog.types";
const getDialogKey = (courseId: string, dialogId: string) =>
`${courseId}:${dialogId}`;
const getInitialState = () => ({
open: false,
courseId: null,
dialogId: null,
steps: [],
current: 0,
showOnce: true,
});
export const useDialogStore = create<GuideDialogState>()(
persist(
(set, get) => ({
...getInitialState(),
completedDialogMap: {},
startDialog: ({
courseId,
dialogId,
steps,
showOnce = true,
initialStep = 0,
}) => {
if (steps.length === 0) return;
const key = getDialogKey(courseId, dialogId);
if (showOnce && get().completedDialogMap[key]) {
return;
}
set({
open: true,
courseId,
dialogId,
steps,
current: Math.min(Math.max(initialStep, 0), steps.length - 1),
showOnce,
});
},
closeDialog: () => {
set(getInitialState());
},
nextStep: () => {
const { current, steps, finishDialog } = get();
if (current >= steps.length - 1) {
finishDialog();
return;
}
set({ current: current + 1 });
},
prevStep: () => {
const { current } = get();
set({ current: Math.max(current - 1, 0) });
},
setStep: (index) => {
const { steps } = get();
if (steps.length === 0) return;
set({ current: Math.min(Math.max(index, 0), steps.length - 1) });
},
selectOption: (optionId) => {
const { current, steps, setStep, finishDialog, nextStep } = get();
const step = steps[current];
const option = step?.options?.find((item) => item.id === optionId);
if (!option) return;
if (option.finish) {
finishDialog();
return;
}
if (option.nextId) {
const nextIndex = steps.findIndex(
(item) => item.id === option.nextId,
);
if (nextIndex >= 0) {
setStep(nextIndex);
return;
}
}
nextStep();
},
finishDialog: () => {
const { courseId, dialogId, completedDialogMap } = get();
if (!courseId || !dialogId) {
set(getInitialState());
return;
}
const key = getDialogKey(courseId, dialogId);
set({
...getInitialState(),
completedDialogMap: {
...completedDialogMap,
[key]: true,
},
});
},
skipDialog: () => {
get().finishDialog();
},
resetDialog: (courseId, dialogId) => {
const key = getDialogKey(courseId, dialogId);
const { [key]: _removed, ...rest } = get().completedDialogMap;
const isCurrentDialog =
get().courseId === courseId && get().dialogId === dialogId;
set({
...(isCurrentDialog ? getInitialState() : {}),
completedDialogMap: rest,
});
},
}),
{
name: `${appConfig.storage.prefix}-dialog-storage`,
partialize: (state) => ({
completedDialogMap: state.completedDialogMap,
}),
},
),
);
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more