Files
yx-react-template/README.md
2026-09-08 14:07:42 +08:00

744 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 易班 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