forked from zhongyidian/robot-shanghai
Initial commit
This commit is contained in:
commit
6b1e803320
158 files changed
+16461
No files matched your search
@@ -0,0 +1,743 @@
|
||||
# 易班 H5 开发模板
|
||||
|
||||
一个基于 React 19、TypeScript、Vite、React Router、Tailwind CSS 和 Zustand 的易班平台 H5 开发模板。
|
||||
|
||||
## 特性
|
||||
|
||||
- React 19 + TypeScript
|
||||
- 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、模板适配器、业务组件、页面组件
|
||||
|
||||
## 使用模板
|
||||
|
||||
### 通过 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",
|
||||
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 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/ # 模板通用组件和 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` 工具函数,新页面可以这样配置:
|
||||
|
||||
```tsx
|
||||
{
|
||||
path: "about",
|
||||
lazy: lazyRoute(() => import("../pages/About")),
|
||||
}
|
||||
```
|
||||
|
||||
页面组件使用默认导出:
|
||||
|
||||
```tsx
|
||||
export default function About() {
|
||||
return <div>About</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 发布为 GitHub 模板仓库
|
||||
|
||||
如果你要把这个项目作为模板发布:
|
||||
|
||||
1. 推送到 GitHub
|
||||
2. 进入仓库 **Settings**
|
||||
3. 勾选 **Template repository**
|
||||
|
||||
之后其他人就可以通过 **Use this template** 使用它。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in new issue
Block a user