feat: 食品添加剂课程业务开发(接待/主场景/取证/记录/信函学习页面)
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["biomejs.biome"]
|
||||||
|
}
|
||||||
@@ -1,82 +1,86 @@
|
|||||||
# 易班 H5 开发模板
|
# 食品添加剂侦探课程
|
||||||
|
|
||||||
一个基于 React 19、TypeScript、Vite、React Router、Tailwind CSS 和 Zustand 的易班平台 H5 开发模板。
|
「零食侦探事务所」主题的食品添加剂网络课程。学生以实习侦探身份,围绕一段关于零食添加剂的网络舆论展开探案式学习,在「接收线索 → 开展调查 → 获取证据 → 作出判定」的流程中,逐步建立对食品添加剂的科学认知。
|
||||||
|
|
||||||
## 特性
|
课程面向易班 H5 平台交付,按 1920×1080 设计稿开发,整体等比缩放适配 PC 与移动端横屏。
|
||||||
|
|
||||||
|
## 课程概要
|
||||||
|
|
||||||
|
### 学习路径
|
||||||
|
|
||||||
|
学生首次学习沿剧情主线推进,完成每个阶段的调查任务即可积累经验、提升侦探等级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
实习侦探(序幕)
|
||||||
|
↓
|
||||||
|
助理侦探(第一幕)
|
||||||
|
↓
|
||||||
|
初级侦探(第二幕)
|
||||||
|
↓
|
||||||
|
中级侦探(第三幕)
|
||||||
|
↓
|
||||||
|
高级侦探(第四幕)
|
||||||
|
↓
|
||||||
|
食安神探(终章 · 零食风暴)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 四条调查线
|
||||||
|
|
||||||
|
序幕从前情舆论视频切入,学生从求助信中梳理出四条调查线索,分别对应四幕核心案件:
|
||||||
|
|
||||||
|
| 案件 | 调查线 | 待核验说法 | 核心问题 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 🌈 色彩迷局 | 着色剂身份线 / 来源辨析线 | 「颜色这么鲜艳,色素肯定没少加」 | 食品里的「色素」是什么?天然与人工合成能直接代表安全吗? |
|
||||||
|
| 🍊 味觉密码 | 香精与风味线 | 「水果味都是香精调出来的」 | 食品的香气从哪里来?香精的使用是否有据可依? |
|
||||||
|
| 🍮 Q 弹真相 | 增稠剂线 | 「果冻 Q 弹是因为加了很多胶」 | 「胶」是什么?增稠剂在食品中起什么作用? |
|
||||||
|
| ⏳ 保鲜疑云 | 防腐剂线 | 「面包放得久是因为防腐剂多」 | 防腐剂为什么被使用?如何判断其安全性? |
|
||||||
|
|
||||||
|
每条调查线统一遵循五步调查轨迹:**待核验说法 → 调查问题 → 探查方式 → 关键证据 → 调查判定**。
|
||||||
|
|
||||||
|
### 核心教学要点
|
||||||
|
|
||||||
|
- **着色剂**:赋予或改善食品色泽的一类食品添加剂;食品有颜色不等于额外加入了着色剂,颜色鲜艳也不代表用量多。
|
||||||
|
- **身份核验**:能染色 ≠ 可作为食品着色剂。苏丹红是工业染料、非法添加物,不属于食品着色剂;胭脂红、栀子黄等才具有食品着色剂身份。
|
||||||
|
- **天然 vs 人工合成**:两者首先反映的是来源与获得方式不同,不能直接等同于「安全」与「有害」;无论哪种来源,都要结合具体物质、食品类别及相应标准判断。
|
||||||
|
- **铝色淀**:把水溶性着色剂转变为水不溶性着色形态,满足彩色糖衣、蛋糕装饰等不掉色、不迁移的加工需要。
|
||||||
|
- **国标意识**:GB 2760-2024《食品安全国家标准 食品添加剂使用标准》对每种允许使用的添加剂的适用食品类别、最大使用量、使用范围均有严格规定。
|
||||||
|
|
||||||
|
### 案件档案与学习资源
|
||||||
|
|
||||||
|
课程把调查过程中产生和解锁的内容沉淀为「零食侦探事务所」资源清单,首次学习沿剧情推进,完成后可脱离剧情顺序回看复习:
|
||||||
|
|
||||||
|
| 分类 | 功能定位 |
|
||||||
|
| --- | --- |
|
||||||
|
| 01 物证材料 | 案件调查的原始物证,调查的事实起点 |
|
||||||
|
| 02 收件箱 | 市民求助、补充线索及后续来信 |
|
||||||
|
| 03 调查资料 | 科普动画、微课、案例短片、旧案资料,承担主要知识讲解 |
|
||||||
|
| 04 调查成果 | 学生实际完成过的调查操作及判断记录 |
|
||||||
|
| 05 添加剂档案 | 持续积累的食品添加剂知识库,学一个解锁一个 |
|
||||||
|
| 06 国标档案 | 培养「遇到添加剂问题查标准」的意识,提供标准依据 |
|
||||||
|
| 07 案件线索板 | 剧情导航与学习进度导航,首页常驻入口 |
|
||||||
|
| 08 调查报告 | 对一幕中多条调查线的最终汇总与消费建议 |
|
||||||
|
| 09 侦探等级 | 学习者身份与整体课程成长记录 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
- React 19 + TypeScript
|
- React 19 + TypeScript
|
||||||
- Vite 8 开发与构建
|
- Vite 8 开发与构建
|
||||||
- React Router 7 路由管理(支持懒加载)
|
- React Router 7 路由管理(Hash 路由 + 懒加载)
|
||||||
- Tailwind CSS 4 原子化样式
|
- Tailwind CSS 4 原子化样式
|
||||||
- Zustand 轻量状态管理
|
- Zustand 轻量状态管理
|
||||||
|
- GSAP + @gsap/react 动效
|
||||||
|
- dnd-kit 拖拽交互
|
||||||
- Biome 代码格式化与检查
|
- Biome 代码格式化与检查
|
||||||
- Husky + lint-staged 提交前检查
|
- Husky + lint-staged 提交前检查
|
||||||
- 1920x1080 整体缩放适配
|
- 1920×1080 整体缩放适配
|
||||||
- 横屏访问提示
|
- 横屏访问提示
|
||||||
- vconsole 移动端调试工具(仅开发环境)
|
- vconsole 移动端调试(仅开发环境)
|
||||||
- 易班 videoajax.js 封装
|
- 易班 videoajax.js 封装与用户信息状态管理
|
||||||
- 易班用户信息状态管理
|
|
||||||
- `@` 指向 `src` 的路径别名
|
|
||||||
- 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码
|
|
||||||
- 组件分层:Registry copied source、模板适配器、业务组件、页面组件
|
|
||||||
|
|
||||||
## 使用模板
|
|
||||||
|
|
||||||
### 通过 GitHub 页面创建
|
|
||||||
|
|
||||||
1. 打开模板仓库页面
|
|
||||||
2. 点击 **Use this template**
|
|
||||||
3. 创建自己的新仓库
|
|
||||||
4. 克隆新仓库并安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-name/your-app.git
|
|
||||||
cd your-app
|
|
||||||
bun install
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通过 GitHub CLI 创建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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` 字段,以及 [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",
|
|
||||||
design: {
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
scale: {
|
|
||||||
max: 1,
|
|
||||||
},
|
|
||||||
viewport: {
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1.0,
|
|
||||||
minimumScale: 1.0,
|
|
||||||
maximumScale: 1.0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
修改该配置后需要重启开发服务。
|
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
bun install # 安装依赖
|
||||||
bun dev # 启动开发服务器
|
bun dev # 启动开发服务器
|
||||||
bun run build # 类型检查并构建生产版本
|
bun run build # 类型检查并构建生产版本
|
||||||
bun run preview # 预览生产构建
|
bun run preview # 预览生产构建
|
||||||
@@ -84,367 +88,97 @@ bun run lint # 代码检查
|
|||||||
bun run lint:fix # 自动修复代码问题
|
bun run lint:fix # 自动修复代码问题
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发规范
|
修改代码后至少运行 `bun run lint`;涉及路由、构建配置、页面结构或类型系统的改动,同时运行 `bun run build`。
|
||||||
|
|
||||||
完整的 Claude Code/自动化开发规范见 [CLAUDE.md](CLAUDE.md)。日常开发遵守以下约定:
|
## 应用配置
|
||||||
|
|
||||||
- 使用 Bun 执行项目脚本,提交前确保 `bun run lint` 通过;涉及路由、构建配置、页面结构或类型系统的改动建议同时运行 `bun run build`。
|
页面标题、描述、图标、课程标识和 viewport 统一维护在 [src/app.config.ts](src/app.config.ts)。修改后需重启开发服务。
|
||||||
- 代码风格以 [biome.json](biome.json) 为准:tab 缩进、双引号、Biome 检查和导入整理。
|
|
||||||
- 组件、页面、store、API 的类型不要和实现代码写在同一个文件;组件类型放同目录 `组件名.types.ts`,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/components/course-interactions/ # GuideDialog、GuideSpotlight、DragDrop
|
|
||||||
src/course-runtime/ # 固定课程画布运行时
|
|
||||||
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 背景、背景图片、禁止点击背景关闭等交互示例
|
|
||||||
- `SpotlightGuide`: 课程引导和高亮目标区域示例
|
|
||||||
- `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例
|
|
||||||
|
|
||||||
后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。
|
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── api/ # API 请求相关代码
|
├── api/ # API 请求封装
|
||||||
├── assets/ # 项目资源
|
├── assets/ # 静态资源
|
||||||
├── components/ # 组件
|
│ └── images/ # 课程切图(按页面分子目录)
|
||||||
│ ├── business/ # 业务组件
|
├── components/
|
||||||
│ └── common/ # 通用组件
|
│ ├── business/ # 业务组合组件
|
||||||
├── pages/ # 页面组件
|
│ ├── common/ # 通用组件(Button、Overlay、Portal、Toast、ImageButton 等)
|
||||||
├── router/ # 路由配置
|
│ ├── course-interactions/ # 课程交互(GuideDialog、GuideSpotlight、DragDrop)
|
||||||
├── stores/ # Zustand 状态管理
|
│ └── ui/ # 基础 UI 组件
|
||||||
│ └── modules/ # 状态模块
|
├── course-runtime/ # 固定课程画布运行时(缩放、横屏守护)
|
||||||
│ ├── app.ts # 应用状态
|
├── lib/cn.ts # class 合并工具
|
||||||
│ └── user.ts # 易班用户状态
|
├── pages/ # 页面组件
|
||||||
├── styles/ # 全局样式
|
├── router/ # 路由配置
|
||||||
├── types/ # TypeScript 类型
|
├── stores/ # Zustand 状态管理
|
||||||
└── utils/ # 工具函数
|
├── styles/ # 全局样式
|
||||||
├── vconsole.ts # 调试工具
|
├── types/ # 跨模块共享类型
|
||||||
└── yiban.ts # 易班 API 封装
|
└── utils/ # 工具函数(assets、motion、yiban、lazyRoute 等)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 易班开发说明
|
完整开发规范见 [CLAUDE.md](CLAUDE.md)。
|
||||||
|
|
||||||
模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。
|
## 课程交互能力
|
||||||
|
|
||||||
### videoajax.js 使用
|
项目内置一套课程交互能力,用于支撑探案式学习中的对话、引导、拖拽、弹层等场景。这些源码直接维护在当前仓库,可按需修改。
|
||||||
|
|
||||||
模板已集成易班 videoajax.js,提供以下功能:
|
### 引导聚光灯 SpotlightGuide
|
||||||
|
|
||||||
- [public/videoajax/jquery-3.7.1.min.js](public/videoajax/jquery-3.7.1.min.js)
|
适合课程首次学习时突出关键操作区域。在元素上标注 `data-guide`,在页面初始化时启动引导:
|
||||||
- [public/videoajax/videoajax.js](public/videoajax/videoajax.js)
|
|
||||||
|
|
||||||
这些文件需要和构建后的页面同源、同相对路径部署。若易班平台提供了新的 `videoajax.js`,请同步确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录相关能力。
|
```tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { GuideSpotlight } from "@/components/common";
|
||||||
|
import { useSpotlightStore } from "@/stores";
|
||||||
|
|
||||||
### 易班环境判断
|
export default function LessonPage() {
|
||||||
|
useEffect(() => {
|
||||||
|
useSpotlightStore.getState().startSpotlight({
|
||||||
|
courseId: "color-mystery",
|
||||||
|
spotlightId: "intro",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
target: { dataGuide: "start-button" },
|
||||||
|
title: "开始调查",
|
||||||
|
description: "点击这里开始第一幕调查。",
|
||||||
|
placement: "bottom",
|
||||||
|
allowTargetClick: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
模板启动时会从 URL 中读取:
|
return (
|
||||||
|
<>
|
||||||
- `courseId` / `courseid`
|
<button data-guide="start-button">立即立案</button>
|
||||||
- `uid`
|
<GuideSpotlight />
|
||||||
|
</>
|
||||||
生产环境中,只要存在以上任意参数,就会认为当前处于易班环境。
|
);
|
||||||
|
|
||||||
本地开发环境中,即使 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) {
|
|
||||||
console.log('当前进度:', progress.data.process);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 播放断点管理
|
- `target` 支持 CSS selector 或 `{ dataGuide: "xxx" }`。
|
||||||
|
- `placement`:`top` / `bottom` / `left` / `right` / `auto`。
|
||||||
|
- `allowTargetClick`:让高亮区域下方的真实元素可被点击,但不会自动切换步骤,需在业务点击逻辑中手动调用 `nextStep` / `finishSpotlight` / `closeSpotlight`。
|
||||||
|
- 同一 `courseId + spotlightId` 默认只显示一次(完成或跳过即标记已完成),可用 `resetSpotlight(courseId, spotlightId)` 重新显示。
|
||||||
|
- 组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统 `prefers-reduced-motion`。
|
||||||
|
|
||||||
```ts
|
### 引导对话 GuideDialog
|
||||||
import { saveBreakPoint, getBreakPoint } from '@/utils/yiban';
|
|
||||||
|
|
||||||
// 保存播放断点
|
适合在剧情节点弹出侦探助手对话、问题选择等。通过 `defineDialog` 定义、`GuideDialog` 渲染:
|
||||||
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-runtime/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 和 SpotlightGuide 的基础接入方式。
|
|
||||||
|
|
||||||
## 弹层基础组件
|
|
||||||
|
|
||||||
模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等弹层内容。
|
|
||||||
|
|
||||||
`Overlay` 已经内置 `Portal`,默认会挂载到 App 缩放画布内部,适合课程弹层跟随 `1920 x 1080` 画布一起缩放,并支持直接放入课程自己的切图和内容:
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Overlay } from "@/components/common";
|
import { defineDialog, GuideDialog } from "@/components/common";
|
||||||
|
|
||||||
<Overlay
|
const dialog = defineDialog({ /* steps, characters, ... */ });
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
dialog.start();
|
||||||
backgroundImage="url(./images/dialog-bg.png)"
|
|
||||||
backgroundClassName="bg-cover bg-center"
|
|
||||||
contentClassName="flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<img src="./images/dialog.png" alt="课程提示" />
|
|
||||||
</Overlay>;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
### 拖拽交互 DragDrop
|
||||||
|
|
||||||
- `backgroundImage`: 传入原始 CSS `background-image` 值(例如 `url(./images/dialog-bg.png)` 或 `linear-gradient(...)`),组件不参与路径处理;引用 public 资源时可用 `getPublicPath` 拼接相对路径。
|
基于 dnd-kit,适合证据分类、调查轨迹排序、选项匹配等交互:
|
||||||
- `backgroundClassName`: 使用 Tailwind class 控制背景层样式,例如 `bg-cover`、`bg-center`、渐变、透明度等。
|
|
||||||
- `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。
|
|
||||||
- `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。
|
|
||||||
- `closeOnEscape`: 是否允许按 Esc 关闭,默认开启。
|
|
||||||
- `lockScroll`: 弹层打开时是否锁定页面滚动,默认开启。
|
|
||||||
- `variant`: 提供 `dim` / `light` / `transparent` 三种基础背景。
|
|
||||||
|
|
||||||
### Portal 挂载位置
|
|
||||||
|
|
||||||
`Portal` 默认挂载到 `document.body`,`Overlay` 默认挂载到 App 缩放画布内部。如果 `Portal` 也需要跟随模板的 `1920 x 1080` 课程画布一起缩放,可以使用 `placement="app"`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Overlay placement="app" open={open} onClose={() => setOpen(false)}>
|
|
||||||
{/* 和课程画布一起缩放的弹层 */}
|
|
||||||
</Overlay>
|
|
||||||
```
|
|
||||||
|
|
||||||
如果需要挂载到业务自己的容器,可以传入 `container`。`container` 支持 DOM 元素、React ref 或返回元素的函数:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { Overlay } from "@/components/common";
|
|
||||||
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
<div ref={rootRef} className="relative" />;
|
|
||||||
|
|
||||||
<Overlay
|
|
||||||
container={rootRef}
|
|
||||||
className="absolute"
|
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{/* 挂到自定义容器里的弹层 */}
|
|
||||||
</Overlay>;
|
|
||||||
```
|
|
||||||
|
|
||||||
挂载优先级:`container` > `placement` > 组件默认值。`Portal` 默认 `placement="body"`,`Overlay` 默认 `placement="app"`。如只需要改变渲染位置,也可以单独使用 `Portal`。
|
|
||||||
|
|
||||||
## 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
|
```tsx
|
||||||
import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop";
|
import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop";
|
||||||
@@ -452,166 +186,127 @@ import { useCourseCanvas } from "@/course-runtime/canvas";
|
|||||||
|
|
||||||
const { scale } = useCourseCanvas();
|
const { scale } = useCourseCanvas();
|
||||||
|
|
||||||
<DragDropProvider
|
<DragDropProvider scale={scale} onDragEnd={(event) => console.log(event.active.id, event.over?.id)}>
|
||||||
scale={scale}
|
<Draggable id="tartrazine">柠檬黄</Draggable>
|
||||||
onDragEnd={(event) => console.log(event.active.id, event.over?.id)}
|
<Droppable id="natural-zone">天然来源</Droppable>
|
||||||
|
</DragDropProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
由于画布整体使用 `transform: scale(...)` 缩放,拖拽内容在画布内时建议给 `DragDropProvider` 传入当前 `scale`,避免拖拽元素与手指/鼠标移动不同步。
|
||||||
|
|
||||||
|
### 动效 GSAP
|
||||||
|
|
||||||
|
项目通过 [src/utils/motion.ts](src/utils/motion.ts) 统一封装 GSAP 入口:`gsap` / `useGSAP` 已注册导出,提供统一缓动常量 `EASE_OUT` / `EASE_IN_OUT`,以及 `useReducedMotion()` 响应 `prefers-reduced-motion` 做动画降级。后续课程页面需要 ScrollTrigger 等插件时,在该文件按需 `registerPlugin`。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { gsap, useGSAP, EASE_OUT, useReducedMotion } from "@/utils";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 弹层 Overlay / Portal
|
||||||
|
|
||||||
|
`Overlay` 已内置 `Portal`,默认挂载到 App 缩放画布内部,适合课程弹窗跟随画布缩放:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Overlay } from "@/components/common";
|
||||||
|
|
||||||
|
<Overlay
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
backgroundImage="url(./images/dialog-bg.png)"
|
||||||
|
backgroundClassName="bg-cover bg-center"
|
||||||
>
|
>
|
||||||
<Draggable id="apple">苹果</Draggable>
|
{/* 课程弹层内容 */}
|
||||||
<Droppable id="fruit-zone">水果区域</Droppable>
|
</Overlay>
|
||||||
</DragDropProvider>;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
由于模板整体使用 `transform: scale(...)` 缩放课程画布,拖拽内容在画布内时建议给 `DragDropProvider` 传入当前 `scale`,避免拖拽元素和鼠标/手指移动不同步。
|
`Portal` 默认挂载到 `document.body`,`Overlay` 默认 `placement="app"`(跟随画布缩放);也可用 `placement="app"` 或 `container` 自定义挂载位置。挂载优先级:`container` > `placement` > 组件默认值。
|
||||||
|
|
||||||
`DragDropProvider` 默认会根据 `scale` 修正拖拽启动距离和拖拽位移,并使用 `pointerWithin` 优先、`closestCenter` 兜底的碰撞检测策略,让“拖到目标区域”类课程交互更符合手指/鼠标位置直觉。
|
### Toast 轻提示
|
||||||
|
|
||||||
本地运行 `bun dev` 后,可以访问 `/#/components` 查看拖拽示例。
|
`App` 已默认接入 `ToastProvider` 和 `ToastViewport`,页面中可直接使用:
|
||||||
|
|
||||||
> 当前基础封装优先覆盖多端拖拽和投放区域判断。排序、分类、拼图等更上层课程交互可以继续基于这些组件封装。
|
|
||||||
|
|
||||||
## 引导聚光灯
|
|
||||||
|
|
||||||
模板内置 `SpotlightGuide` 引导组件,适合课程首次学习时突出关键操作区域。
|
|
||||||
|
|
||||||
### 基础使用
|
|
||||||
|
|
||||||
在页面元素上添加 `data-guide`:
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Button data-guide="start-button">开始学习</Button>
|
import { useToast } from "@/components/common";
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
toast.success("证据已收入档案");
|
||||||
|
toast.error("选择有误,请重新核验");
|
||||||
```
|
```
|
||||||
|
|
||||||
在页面初始化时启动引导:
|
支持 `info` / `success` / `warning` / `error` / `show`。
|
||||||
|
|
||||||
```tsx
|
## 易班平台接入
|
||||||
import { useEffect } from "react";
|
|
||||||
import { SpotlightGuide } from "@/components/common";
|
|
||||||
import { useGuideStore } from "@/stores";
|
|
||||||
|
|
||||||
export default function LessonPage() {
|
项目面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。
|
||||||
useEffect(() => {
|
|
||||||
useGuideStore.getState().startGuide({
|
|
||||||
courseId: "lesson-1",
|
|
||||||
guideId: "intro",
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
id: "start",
|
|
||||||
target: { dataGuide: "start-button" },
|
|
||||||
title: "开始学习",
|
|
||||||
description: "点击这里开始本节课程。",
|
|
||||||
placement: "bottom",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <SpotlightGuide />;
|
### 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),需与构建产物同源、同相对路径部署。若易班平台提供新版本,确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录能力。
|
||||||
|
|
||||||
|
### 环境判断
|
||||||
|
|
||||||
|
启动时从 URL 读取 `courseId` / `courseid`、`uid`。生产环境中只要存在以上任意参数即视为易班环境。
|
||||||
|
|
||||||
|
本地开发即使 URL 带这些参数也默认使用 `sessionStorage`,避免误请求易班接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173/?courseId=123&uid=456
|
||||||
```
|
```
|
||||||
|
|
||||||
### 持久化说明
|
本地确需强制走易班接口时追加 `yiban=1`:
|
||||||
|
|
||||||
引导状态接入 Zustand,并通过 Zustand `persist` 只持久化已完成记录:
|
```text
|
||||||
|
http://localhost:5173/?courseId=123&uid=456&yiban=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 易班能力
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
completedGuideMap: {
|
import {
|
||||||
"lesson-1:intro": true,
|
setUserStorage,
|
||||||
}
|
getUserStorage,
|
||||||
|
reportCourseProgress,
|
||||||
|
getCourseProgress,
|
||||||
|
saveBreakPoint,
|
||||||
|
getBreakPoint,
|
||||||
|
saveUserAnswer,
|
||||||
|
} from "@/utils/yiban";
|
||||||
|
import { useUserStore } from "@/stores";
|
||||||
```
|
```
|
||||||
|
|
||||||
默认同一个 `courseId + guideId` 只显示一次。点击“完成”或“跳过”都会标记为已完成,后续不会自动显示。
|
- **存取用户数据**:`setUserStorage(lessonId, key, value)` / `getUserStorage(lessonId, key)`,自动根据易班连接状态选择存储方式。
|
||||||
|
- **课程进度**:`reportCourseProgress(order)` / `getCourseProgress(order)`。
|
||||||
|
- **播放断点**:`saveBreakPoint({ userId, courseId, lessonId, breakPoint })` / `getBreakPoint(...)`。
|
||||||
|
- **答题记录**:`saveUserAnswer({ userId, lessonId, courseId, questionOrder, answer })`。
|
||||||
|
- **用户状态**:`useUserStore()` 提供 `userId`、`userName`、`setUserInfo`、`setYibanConnectionStatus` 等。
|
||||||
|
|
||||||
如需重新显示:
|
### 移动端调试
|
||||||
|
|
||||||
```ts
|
开发环境自动启用 vconsole,可在移动端查看 Console、Network、Element、Storage。
|
||||||
useGuideStore.getState().resetGuide("lesson-1", "intro");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 配置
|
## 课程画布适配
|
||||||
|
|
||||||
```ts
|
按 1920×1080 设计稿开发,由 [src/course-runtime/canvas](src/course-runtime/canvas) 的 `CourseCanvas` 组件承担整体缩放,默认 `width=1920`、`height=1080`、`maxScale=1`。
|
||||||
{
|
|
||||||
id: "start",
|
|
||||||
target: { dataGuide: "start-button" },
|
|
||||||
title: "开始学习",
|
|
||||||
description: "点击这里开始本节课程。",
|
|
||||||
placement: "bottom",
|
|
||||||
allowTargetClick: true,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
支持:
|
- PC 端:按可视区域等比缩放并居中。
|
||||||
|
- 大屏:`maxScale` 默认为 1,不超过设计稿原始尺寸,周围留白。
|
||||||
|
- 移动端:优先横屏访问,竖屏时由 `OrientationGuard` 显示横屏提示。
|
||||||
|
- 视口优先读取 `visualViewport`,移动端地址栏显示/隐藏时更稳定。
|
||||||
|
- 页面内容按设计稿像素开发,不使用 rem 适配。
|
||||||
|
|
||||||
- `target`: CSS selector 或 `{ dataGuide: "xxx" }`
|
## 路由
|
||||||
- `placement`: `top` / `bottom` / `left` / `right` / `auto`
|
|
||||||
- `allowTargetClick`: 是否允许点击高亮目标
|
|
||||||
- `padding` / `radius`: 调整高亮区域
|
|
||||||
- `panelClassName`: 针对单步自定义提示框样式
|
|
||||||
|
|
||||||
> 建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。
|
路由集中在 [src/router/index.tsx](src/router/index.tsx),使用 Hash 路由 + `lazyRoute` 懒加载,新增页面遵循该模式并配 `HydrateFallback: RouteFallback`:
|
||||||
|
|
||||||
### 高级交互说明
|
|
||||||
|
|
||||||
`allowTargetClick` 只负责让高亮区域下方的真实元素可以被点击,不会自动切换引导步骤。
|
|
||||||
|
|
||||||
如果目标元素点击后需要进入下一步,需要在业务点击逻辑中手动调用 `nextStep`、`finishGuide` 或 `closeGuide`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const { nextStep } = useGuideStore();
|
|
||||||
|
|
||||||
<Button
|
|
||||||
data-guide="play-button"
|
|
||||||
onClick={() => {
|
|
||||||
playVideo();
|
|
||||||
nextStep();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
播放
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 样式与层级
|
|
||||||
|
|
||||||
`SpotlightGuide` 默认通过 Portal 挂载到 `document.body`,可以正确适配整体缩放后的坐标。
|
|
||||||
|
|
||||||
如遇到课程弹窗、横屏提示等全局层级冲突,可以通过 `theme.zIndex` 调整:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<SpotlightGuide theme={{ zIndex: 1200 }} />
|
|
||||||
```
|
|
||||||
|
|
||||||
开发环境中,如果目标元素不存在,页面会显示开发提示面板,方便检查 `data-guide` 或 selector;生产环境默认会自动跳过找不到目标的步骤。
|
|
||||||
|
|
||||||
组件内置了遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。
|
|
||||||
|
|
||||||
## 路由懒加载
|
|
||||||
|
|
||||||
模板内置了 `lazyRoute` 工具函数,新页面可以这样配置:
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
{
|
{
|
||||||
path: "about",
|
path: "about",
|
||||||
lazy: lazyRoute(() => import("../pages/About")),
|
lazy: lazyRoute(() => import("../pages/About")),
|
||||||
|
HydrateFallback: RouteFallback,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
页面组件使用默认导出:
|
页面组件使用默认导出。
|
||||||
|
|
||||||
```tsx
|
|
||||||
export default function About() {
|
|
||||||
return <div>About</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 发布为 GitHub 模板仓库
|
|
||||||
|
|
||||||
如果你要把这个项目作为模板发布:
|
|
||||||
|
|
||||||
1. 推送到 GitHub
|
|
||||||
2. 进入仓库 **Settings**
|
|
||||||
3. 勾选 **Template repository**
|
|
||||||
|
|
||||||
之后其他人就可以通过 **Use this template** 使用它。
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@gsap/react": "^2.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
@@ -68,6 +70,8 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||||
|
|
||||||
|
"@gsap/react": ["@gsap/react@2.1.2", "https://registry.npmmirror.com/@gsap/react/-/react-2.1.2.tgz", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -216,6 +220,8 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"gsap": ["gsap@3.15.0", "https://registry.npmmirror.com/gsap/-/gsap-3.15.0.tgz", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
|
||||||
|
|
||||||
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "yx-react-template",
|
"name": "food-add-edu",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@gsap/react": "^2.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Outlet } from "react-router-dom";
|
|||||||
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
|
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
|
||||||
import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
|
import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
|
||||||
import { OrientationGuard } from "@/course-runtime/orientation";
|
import { OrientationGuard } from "@/course-runtime/orientation";
|
||||||
import { Navigation } from "./components/business";
|
|
||||||
import { registerAppPortalRoot } from "./components/common/canvasPortal";
|
import { registerAppPortalRoot } from "./components/common/canvasPortal";
|
||||||
|
|
||||||
function CourseShell() {
|
function CourseShell() {
|
||||||
@@ -13,10 +12,7 @@ function CourseShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<Navigation />
|
<Outlet />
|
||||||
<main className="flex-1 overflow-auto p-8">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
<ToastViewport container={portalContainer} />
|
<ToastViewport container={portalContainer} />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ const env = (import.meta as ImportMeta & { env?: Record<string, string> }).env;
|
|||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
/** 应用标题 */
|
/** 应用标题 */
|
||||||
title: "React Vite App",
|
title: "食品添加剂侦探课程",
|
||||||
|
|
||||||
/** 网站描述 */
|
/** 网站描述 */
|
||||||
description: "易班 H5 课程模板",
|
description: "零食侦探事务所 · 食品添加剂网络课程",
|
||||||
|
|
||||||
/** Favicon 路径(相对于 public 目录) */
|
/** Favicon 路径(相对于 public 目录) */
|
||||||
favicon: "./vite.svg",
|
favicon: "./vite.svg",
|
||||||
|
|
||||||
/** 课程配置 */
|
/** 课程配置 */
|
||||||
course: {
|
course: {
|
||||||
id: env?.VITE_COURSE_ID ?? "course-demo",
|
id: env?.VITE_COURSE_ID ?? "food-add-edu",
|
||||||
name: env?.VITE_COURSE_NAME ?? "标准课程页示例",
|
name: env?.VITE_COURSE_NAME ?? "食品添加剂侦探课程",
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 本地存储配置 */
|
/** 本地存储配置 */
|
||||||
storage: {
|
storage: {
|
||||||
prefix: "yiban-course-template",
|
prefix: "food-add-edu",
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 视口配置 */
|
/** 视口配置 */
|
||||||
|
|||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="none" viewBox="0 0 793.01 162.76"><defs><style>.cls-1{opacity:0.55;}.cls-2{fill:#e8f9b8;}.cls-3{fill:none;stroke:#d4d97c;stroke-miterlimit:10;stroke-width:2px;}</style></defs><g id="图层_2" data-name="图层 2"><g id="图层_1-2" data-name="图层 1"><image opacity="0.55" width="793" height="162" transform="translate(0.01 0.76)" xlink:href="data:image/png;base64,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 truncated
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |