Template
Compare commits
12
Commits
3995656175
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155f6269ca | ||
|
|
a641d0e2ea | ||
|
|
ad37dfeb24 | ||
|
|
1e02c7835e | ||
|
|
d77281ff0d | ||
|
|
cba0d42474 | ||
|
|
dc4197b207 | ||
|
|
ce9567d1da | ||
|
|
a98b1d383e | ||
|
|
17fee63a16 | ||
|
|
7b326ee025 | ||
|
|
d3ae0a8ca7 |
No files matched your search
@@ -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/course/canvas/`:设计稿画布、缩放、viewport 和课程 Portal 根节点。
|
||||
- `src/course/orientation/`:横竖屏访问控制。
|
||||
- `src/course/interactions/`:由 CLI 复制的受控课程交互内核,不依赖具体页面、Store 或 common 适配器。
|
||||
- `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。
|
||||
- 模块内私有组件:仅本模块使用、其他模块用不到的,放到本模块目录的 `components/` 子目录(没有就创建);被多个模块或组件复用才下沉到 `src/components/common/`(通用基础能力)或 `business/`(业务组合),按组件核心类型区分。该规则约束新代码,存量平铺在页面目录下的模块内组件不强制迁移。
|
||||
- `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。
|
||||
- `src/stores/`:Zustand 状态管理。
|
||||
- `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`。
|
||||
- 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。
|
||||
@@ -61,7 +66,7 @@ bun run lint:fix # 自动修复代码问题
|
||||
|
||||
## 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`:
|
||||
|
||||
- 需要合并外部传入的 `className`。
|
||||
- 存在条件 class。
|
||||
- 条件 class 需要和其他 class 合并。
|
||||
- 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。
|
||||
- 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。
|
||||
|
||||
@@ -90,9 +95,10 @@ bun run lint:fix # 自动修复代码问题
|
||||
|
||||
```tsx
|
||||
<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 类的边界
|
||||
|
||||
「何时提取 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`,新增代码优先使用 `@` 避免过长相对路径。
|
||||
@@ -129,10 +167,14 @@ bun run lint:fix # 自动修复代码问题
|
||||
|
||||
开发时要尊重模板已有能力:
|
||||
|
||||
- 页面按 `1920 x 1080` 设计稿开发,整体缩放逻辑在 `src/utils/useScale.ts`。
|
||||
- 横屏访问提示逻辑在 `src/utils/useOrientation.ts` 与通用组件中。
|
||||
- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course/canvas` 的 `CourseCanvas` 组件承担。
|
||||
- 横屏访问提示由 `src/course/orientation` 的 `OrientationGuard` 组件承担。
|
||||
- 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。
|
||||
- 移动端调试使用 `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) 增加示例。
|
||||
|
||||
## Claude Code 工作准则
|
||||
|
||||
@@ -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
|
||||
- Vite 6 开发与构建
|
||||
- React Router 7 路由管理
|
||||
- 路由页面懒加载
|
||||
- Tailwind CSS 4
|
||||
- Vite 8 开发与构建
|
||||
- React Router 7 路由管理(支持懒加载)
|
||||
- Tailwind CSS 4 原子化样式
|
||||
- Zustand 轻量状态管理
|
||||
- Biome 代码格式化与检查
|
||||
- Husky + lint-staged 提交前检查
|
||||
- 1920x1080 整体缩放适配
|
||||
- 横屏访问提示
|
||||
- vconsole 移动端调试工具(仅开发环境)
|
||||
- 易班 videoajax.js 封装
|
||||
- 易班用户信息状态管理
|
||||
- 图片按钮、视频播放器与视频弹层基础组件
|
||||
- 音频管理(配音 / BGM / 音效)
|
||||
- GSAP 动画能力(复杂时间轴编排)
|
||||
- `@` 指向 `src` 的路径别名
|
||||
- 组件分层:通用组件、业务组件、页面组件
|
||||
- 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码
|
||||
- 组件分层:Registry copied source、模板适配器、业务组件、页面组件
|
||||
|
||||
## 使用模板
|
||||
|
||||
@@ -33,41 +42,673 @@ bun dev
|
||||
### 通过 GitHub CLI 创建
|
||||
|
||||
```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
|
||||
bun install
|
||||
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
|
||||
bun dev # 启动开发服务器
|
||||
bun build # 类型检查并构建生产版本
|
||||
bun preview # 预览生产构建
|
||||
bun lint # 代码检查
|
||||
bun lint:fix # 自动修复代码问题
|
||||
bun dev # 启动开发服务器
|
||||
bun run build # 类型检查并构建生产版本
|
||||
bun run preview # 预览生产构建
|
||||
bun run lint # 代码检查
|
||||
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
|
||||
src/
|
||||
├── api/ # API 请求相关代码
|
||||
├── assets/ # 项目资源
|
||||
├── components/ # 组件
|
||||
│ ├── business/ # 业务组件
|
||||
│ └── common/ # 通用组件
|
||||
├── hooks/ # 自定义 Hooks
|
||||
├── pages/ # 页面组件
|
||||
├── router/ # 路由配置
|
||||
├── styles/ # 全局样式
|
||||
├── types/ # TypeScript 类型
|
||||
└── utils/ # 工具函数
|
||||
├── api/ # API 请求相关代码
|
||||
├── assets/ # 项目资源
|
||||
├── components/
|
||||
│ ├── business/ # 业务组合组件
|
||||
│ ├── common/ # 模板通用组件和 Store/Canvas 适配器
|
||||
│ └── ui/ # Button、Portal、Overlay、Toast 等基础 UI
|
||||
├── course/
|
||||
│ ├── canvas/ # 1920 x 1080 画布、viewport 与 Portal 根节点
|
||||
│ ├── interactions/ # Guide、DragDrop 等课程交互内核
|
||||
│ └── orientation/ # 横竖屏访问控制
|
||||
├── lib/ # 无业务依赖的底层工具
|
||||
├── pages/ # 页面组件
|
||||
├── router/ # 路由配置
|
||||
├── stores/
|
||||
│ ├── modules/ # Zustand store 实现
|
||||
│ └── types/ # Store 类型
|
||||
├── styles/ # 全局样式
|
||||
├── types/ # 跨多个模块共享的全局类型
|
||||
└── 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` 工具函数,新页面可以这样配置:
|
||||
@@ -75,7 +716,7 @@ src/
|
||||
```tsx
|
||||
{
|
||||
path: "about",
|
||||
lazy: lazyRoute(() => import("../pages/About/About")),
|
||||
lazy: lazyRoute(() => import("../pages/About")),
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"howler": "^2.2.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.12.0",
|
||||
@@ -19,6 +22,7 @@
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/howler": "^2.2.13",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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
@@ -9,14 +9,14 @@
|
||||
},
|
||||
"directories": {
|
||||
"ui": "src/components/ui",
|
||||
"interactions": "src/components/course-interactions",
|
||||
"runtime": "src/course-runtime",
|
||||
"interactions": "src/course/interactions",
|
||||
"runtime": "src/course",
|
||||
"lib": "src/lib"
|
||||
},
|
||||
"aliases": {
|
||||
"ui": "@/components/ui",
|
||||
"interactions": "@/components/course-interactions",
|
||||
"runtime": "@/course-runtime",
|
||||
"interactions": "@/course/interactions",
|
||||
"runtime": "@/course",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "react-template",
|
||||
"name": "yx-react-template",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,10 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"howler": "^2.2.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.12.0",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/howler": "^2.2.13",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
|
||||
Vendored
+4902
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
Vendored
+1
@@ -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
@@ -1,15 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
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 { 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() {
|
||||
return (
|
||||
<div className={cn("min-h-screen flex flex-col")}>
|
||||
<Navigation />
|
||||
<main className={cn("flex-1 p-8")}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<>
|
||||
<OrientationGuard />
|
||||
<CourseCanvas
|
||||
className="bg-gray-900"
|
||||
canvasClassName="flex flex-col overflow-hidden bg-white"
|
||||
>
|
||||
<CourseShell />
|
||||
</CourseCanvas>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
-11
@@ -3,19 +3,10 @@
|
||||
* 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端
|
||||
*/
|
||||
|
||||
import type { ApiError, ApiResponse } from "./client.types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础请求函数
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
+2
-9
@@ -1,9 +1,2 @@
|
||||
export {
|
||||
type ApiError,
|
||||
type ApiResponse,
|
||||
del,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
request,
|
||||
} from "./client.ts";
|
||||
export { del, get, post, put, request } from "./client.ts";
|
||||
export type { ApiError, ApiResponse } from "./client.types";
|
||||
@@ -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 { cn } from "@/utils";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./Navigation.module.css";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
items?: NavItem[];
|
||||
className?: string;
|
||||
}
|
||||
import type { NavItem, NavigationProps } from "./Navigation.types";
|
||||
|
||||
const defaultItems: NavItem[] = [
|
||||
{ label: "首页", path: "/" },
|
||||
{ label: "关于", path: "/about" },
|
||||
{ label: "组件示例", path: "/components" },
|
||||
{ label: "课程示例", path: "/course-demo" },
|
||||
];
|
||||
|
||||
export function Navigation({
|
||||
|
||||
@@ -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 type { NavItem, NavigationProps } from "./Navigation.types";
|
||||
@@ -1 +1,2 @@
|
||||
export type { NavItem, NavigationProps } 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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,2 +1,49 @@
|
||||
export type { ButtonProps } from "./Button";
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps } from "@/components/ui/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";
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
|
||||
@@ -6,17 +6,25 @@ interface CanvasScaleOptions {
|
||||
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({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
if (typeof window === "undefined") return maxScale;
|
||||
return Math.min(
|
||||
window.innerWidth / width,
|
||||
window.innerHeight / height,
|
||||
maxScale,
|
||||
);
|
||||
const viewport = getViewportSize();
|
||||
if (!viewport) return maxScale;
|
||||
return Math.min(viewport.width / width, viewport.height / height, maxScale);
|
||||
}
|
||||
|
||||
export function useCanvasScale({
|
||||
@@ -33,11 +41,15 @@ export function useCanvasScale({
|
||||
setScale(calculateScale({ width, height, maxScale }));
|
||||
updateScale();
|
||||
window.addEventListener("resize", updateScale);
|
||||
window.addEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.addEventListener("resize", updateScale);
|
||||
window.visualViewport?.addEventListener("scroll", updateScale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateScale);
|
||||
window.removeEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.removeEventListener("resize", updateScale);
|
||||
window.visualViewport?.removeEventListener("scroll", updateScale);
|
||||
};
|
||||
}, [height, maxScale, width]);
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+15
-4
@@ -128,10 +128,21 @@ export function GuideDialog({
|
||||
}
|
||||
|
||||
const context = getCoordinateContext({ container, viewport });
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||
height: panelRect ? panelRect.height / context.scale : 220,
|
||||
width: panelElement
|
||||
? 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(
|
||||
step.placement ?? "bottom",
|
||||
@@ -239,7 +250,7 @@ export function GuideDialog({
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
styles.panel,
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
)}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
@@ -149,6 +149,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.highlight,
|
||||
.panelContainer,
|
||||
.panel,
|
||||
.missingPanel {
|
||||
animation: none;
|
||||
+15
-4
@@ -312,10 +312,21 @@ export function GuideSpotlight({
|
||||
}
|
||||
|
||||
const nextHighlightRect = getHighlightRect(target, padding, context);
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||
height: panelRect ? panelRect.height / context.scale : 180,
|
||||
width: panelElement
|
||||
? 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(
|
||||
nextHighlightRect,
|
||||
@@ -503,7 +514,7 @@ export function GuideSpotlight({
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
styles.panel,
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { OrientationGuard } from "./OrientationGuard";
|
||||
export type { OrientationGuardProps } from "./OrientationGuard.types";
|
||||
export { useOrientation } from "./useOrientation";
|
||||
@@ -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 +0,0 @@
|
||||
export { useLocalStorage } from "./useLocalStorage.ts";
|
||||
@@ -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];
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import "./utils/vconsole";
|
||||
import "./styles/index.css";
|
||||
import { router } from "./router/index.tsx";
|
||||
import { initializeYibanSession } from "./utils/yiban";
|
||||
|
||||
initializeYibanSession();
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/common";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">关于我们</h1>
|
||||
<p className="mb-4 text-lg">这是关于页面</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/">
|
||||
<Button variant="secondary">返回首页</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { About } from "./About.tsx";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">关于我们</h1>
|
||||
<p className="text-lg text-gray-600">这是关于页面</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/">
|
||||
<Button variant="secondary">返回首页</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState } from "react";
|
||||
import { 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;
|
||||
@@ -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={ref} 挂载的内容
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-5 space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Overlay</h2>
|
||||
<p className="text-gray-600">
|
||||
Overlay 内部已使用 Portal,可同时测试背景、关闭行为和挂载位置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{overlayCards.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="rounded-xl border border-gray-200 p-4"
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mb-4 min-h-12 text-sm text-gray-600">
|
||||
{item.description}
|
||||
</p>
|
||||
<Button onClick={() => setActiveOverlay(item.key)}>
|
||||
{item.buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{currentOverlay && (
|
||||
<Overlay
|
||||
open={Boolean(currentOverlay)}
|
||||
onClose={() => setActiveOverlay(null)}
|
||||
container={overlayContainer}
|
||||
className={isCustomOverlay ? "z-10" : undefined}
|
||||
contentClassName="flex items-center justify-center p-8"
|
||||
{...currentOverlay.overlayProps}
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-3xl bg-white p-8 text-center shadow-2xl">
|
||||
<p className="mb-2 text-sm font-medium text-blue-600">
|
||||
Overlay Demo
|
||||
</p>
|
||||
<h3 className="mb-3 text-2xl font-bold text-gray-900">
|
||||
{currentOverlay.title}
|
||||
</h3>
|
||||
<p className="mb-6 text-gray-600">{currentOverlay.description}</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button onClick={() => setActiveOverlay(null)}>关闭</Button>
|
||||
<Button variant="outline" onClick={() => setActiveOverlay(null)}>
|
||||
模拟继续学习
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
<GuideSpotlight />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CourseCategory,
|
||||
CourseFlowProgress,
|
||||
CourseResource,
|
||||
CourseResourceCategory,
|
||||
} from "./CourseDemo.types";
|
||||
|
||||
export const courseResources: CourseResource[] = [
|
||||
{
|
||||
id: "policy-video",
|
||||
title: "资助政策微课",
|
||||
description: "了解国家助学政策和申请条件",
|
||||
category: "policy",
|
||||
icon: "🎬",
|
||||
},
|
||||
{
|
||||
id: "policy-guide",
|
||||
title: "申请流程图",
|
||||
description: "按步骤核对材料与办理节点",
|
||||
category: "policy",
|
||||
icon: "🗺️",
|
||||
},
|
||||
{
|
||||
id: "practice-case",
|
||||
title: "诚信申请案例",
|
||||
description: "判断案例中的规范与风险",
|
||||
category: "practice",
|
||||
icon: "🧩",
|
||||
},
|
||||
{
|
||||
id: "practice-checklist",
|
||||
title: "材料自查清单",
|
||||
description: "动手检查课程中的模拟材料",
|
||||
category: "practice",
|
||||
icon: "✅",
|
||||
},
|
||||
{
|
||||
id: "support-qa",
|
||||
title: "常见问题答疑",
|
||||
description: "查找填报和审核中的常见问题",
|
||||
category: "support",
|
||||
icon: "💬",
|
||||
},
|
||||
{
|
||||
id: "support-contact",
|
||||
title: "校园咨询渠道",
|
||||
description: "联系辅导员与学生资助中心",
|
||||
category: "support",
|
||||
icon: "☎️",
|
||||
},
|
||||
];
|
||||
|
||||
export const courseCategories: CourseCategory[] = [
|
||||
{
|
||||
id: "policy",
|
||||
title: "政策学习",
|
||||
description: "帮助理解政策与办理流程",
|
||||
icon: "📚",
|
||||
},
|
||||
{
|
||||
id: "practice",
|
||||
title: "互动实践",
|
||||
description: "需要判断、检查或动手完成",
|
||||
icon: "🧠",
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
title: "学习支持",
|
||||
description: "遇到问题时可以随时查阅",
|
||||
icon: "🤝",
|
||||
},
|
||||
];
|
||||
|
||||
export function createEmptyProgress(): CourseFlowProgress {
|
||||
return {
|
||||
placed: { policy: [], practice: [], support: [] },
|
||||
completed: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProgress(content: string): CourseFlowProgress | null {
|
||||
try {
|
||||
const value = JSON.parse(content) as Partial<CourseFlowProgress>;
|
||||
if (!value.placed || typeof value.placed !== "object") return null;
|
||||
const placed = Object.fromEntries(
|
||||
courseCategories.map(({ id }) => [
|
||||
id,
|
||||
Array.isArray(value.placed?.[id])
|
||||
? value.placed[id].filter((item): item is string =>
|
||||
courseResources.some(
|
||||
(resource) => resource.id === item && resource.category === id,
|
||||
),
|
||||
)
|
||||
: [],
|
||||
]),
|
||||
) as Record<CourseResourceCategory, string[]>;
|
||||
const placedCount = Object.values(placed).flat().length;
|
||||
return {
|
||||
placed,
|
||||
completed: placedCount === courseResources.length,
|
||||
updatedAt: value.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineDialog } from "@/components/common";
|
||||
|
||||
export const courseDemoDialog = defineDialog({
|
||||
id: "resource-map-opening",
|
||||
showOnce: false,
|
||||
steps: [
|
||||
{
|
||||
id: "welcome",
|
||||
character: { name: "小易", avatar: "🧭", mood: "ready" },
|
||||
title: "欢迎进入学生资助政策微课",
|
||||
description:
|
||||
"这节课不需要记住组件名称。你将通过一次真实的资源分类任务,认识政策学习、互动实践和学习支持之间的关系。",
|
||||
nextLabel: "了解任务",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "choose-path",
|
||||
character: { name: "小易", avatar: "✨", mood: "thinking" },
|
||||
title: "选择你的学习方式",
|
||||
description: "第一次体验可以先查看操作引导,熟悉拖拽后也可以直接开始。",
|
||||
options: [
|
||||
{ id: "guided", label: "先听操作说明", nextId: "guided-start" },
|
||||
{ id: "direct", label: "直接开始分类", nextId: "direct-start" },
|
||||
],
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "guided-start",
|
||||
character: { name: "小易", avatar: "🗺️", mood: "guide" },
|
||||
title: "按资源用途判断分类",
|
||||
description:
|
||||
"关闭对话后点击“查看操作引导”,页面会依次介绍资源区、分类区和进度区。",
|
||||
doneLabel: "进入页面",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "direct-start",
|
||||
character: { name: "小易", avatar: "🎯", mood: "active" },
|
||||
title: "准备开始",
|
||||
description:
|
||||
"阅读卡片说明,将六张资源卡放入正确区域。错误分类可以立即重试,正确结果会自动保存。",
|
||||
doneLabel: "开始挑战",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineSpotlight } from "@/components/common";
|
||||
|
||||
export const courseDemoSpotlight = defineSpotlight({
|
||||
id: "resource-map",
|
||||
showOnce: false,
|
||||
steps: [
|
||||
{
|
||||
id: "course-resources",
|
||||
title: "先阅读资源卡片",
|
||||
description: "每张卡片都包含用途说明。按住卡片,将它拖向右侧的正确分类。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "course-categories",
|
||||
title: "放入对应学习阶段",
|
||||
description:
|
||||
"政策材料用于理解知识,互动资源用于练习,答疑与咨询属于学习支持。",
|
||||
placement: "top",
|
||||
padding: 12,
|
||||
},
|
||||
{
|
||||
id: "course-progress",
|
||||
title: "自动记录学习进度",
|
||||
description:
|
||||
"每次正确分类都会保存;刷新页面后仍可继续,完成时会展示本节学习结果。",
|
||||
placement: "left",
|
||||
doneLabel: "开始分类",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/common";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">首页</h1>
|
||||
<p className="mb-4 text-lg">欢迎来到首页</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/about">
|
||||
<Button>关于我们</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Home } from "./Home.tsx";
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppStore, useSpotlightStore } from "@/stores";
|
||||
|
||||
export default function Home() {
|
||||
const { count, increment, reset } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
useSpotlightStore.getState().startSpotlight({
|
||||
courseId: "template-home",
|
||||
spotlightId: "intro",
|
||||
steps: [
|
||||
{
|
||||
id: "counter",
|
||||
target: { dataGuide: "counter-actions" },
|
||||
title: "状态管理示例",
|
||||
description: "这里演示 Zustand 的基础用法。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
id: "about-link",
|
||||
target: { dataGuide: "about-link" },
|
||||
title: "路由跳转",
|
||||
description: "点击这里可以跳转到关于页面。",
|
||||
placement: "top",
|
||||
},
|
||||
],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold">首页</h1>
|
||||
<p className="text-lg text-gray-600">欢迎来到首页</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center gap-4"
|
||||
data-guide="counter-actions"
|
||||
>
|
||||
<Button onClick={increment}>计数:{count}</Button>
|
||||
<Button variant="secondary" onClick={reset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link to="/about" data-guide="about-link">
|
||||
<Button>关于我们</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<GuideSpotlight />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -1,2 +1,4 @@
|
||||
export { About } from "./About";
|
||||
export { Home } from "./Home";
|
||||
export { default as About } from "./About";
|
||||
export { default as Components } from "./Components";
|
||||
export { default as CourseDemo } from "./CourseDemo";
|
||||
export { default as Home } from "./Home";
|
||||
+16
-2
@@ -1,19 +1,33 @@
|
||||
import { createHashRouter } from "react-router-dom";
|
||||
import App from "../App.tsx";
|
||||
import { RouteError, RouteFallback } from "../components/common";
|
||||
import { lazyRoute } from "../utils";
|
||||
|
||||
export const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyRoute(() => import("../pages/Home/Home")),
|
||||
lazy: lazyRoute(() => import("../pages/Home")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
{
|
||||
path: "about",
|
||||
lazy: lazyRoute(() => import("../pages/About/About")),
|
||||
lazy: lazyRoute(() => import("../pages/About")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
{
|
||||
path: "components",
|
||||
lazy: lazyRoute(() => import("../pages/Components")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
{
|
||||
path: "course-demo",
|
||||
lazy: lazyRoute(() => import("../pages/CourseDemo")),
|
||||
HydrateFallback: RouteFallback,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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
Reference in new issue
Block a user