feat: 完善课程模板通用能力

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 5 committed 2026-09-08 14:07:42 +08:00
1 parent 1e02c7835e
commit a641d0e2ea
70 files changed
+2868 -205

No files matched your search

+255 -130
View File
@@ -16,6 +16,9 @@
- vconsole 移动端调试工具(仅开发环境)
- 易班 videoajax.js 封装
- 易班用户信息状态管理
- 图片按钮、视频播放器与视频弹层基础组件
- 音频管理(配音 / BGM / 音效)
- GSAP 动画能力(复杂时间轴编排)
- `@` 指向 `src` 的路径别名
- 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码
- 组件分层:Registry copied source、模板适配器、业务组件、页面组件
@@ -49,19 +52,19 @@ bun dev
## 应用配置
页面标签页标题、描述、图标和 viewport 配置统一维护在 [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,
course: {
id: import.meta.env.VITE_COURSE_ID ?? "course-demo",
name: import.meta.env.VITE_COURSE_NAME ?? "标准课程页示例",
},
scale: {
max: 1,
storage: {
prefix: "yiban-course-template",
},
viewport: {
width: "device-width",
@@ -72,7 +75,7 @@ export const appConfig = {
};
```
修改该配置后需要重启开发服务。
课程画布尺寸和最大缩放比例由 `CourseCanvas` props 控制,不属于 `appConfig`。修改应用配置后需要重启开发服务。
## 开发命令
@@ -90,7 +93,7 @@ bun run lint:fix # 自动修复代码问题
- 使用 Bun 执行项目脚本,提交前确保 `bun run lint` 通过;涉及路由、构建配置、页面结构或类型系统的改动建议同时运行 `bun run build`。
- 代码风格以 [biome.json](biome.json) 为准:tab 缩进、双引号、Biome 检查和导入整理。
- 组件、页面、store、API 的类型不要和实现代码写在同一个文件;组件类型放同目录 `组件名.types.ts`,store 类型放 [src/stores/types/](src/stores/types/),跨模块共享类型才放入 [src/types/](src/types/)。
- 组件、页面、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` 的路径别名。
@@ -102,8 +105,9 @@ bun run lint:fix # 自动修复代码问题
```text
src/components/ui/ # Button、Portal、Overlay、Toast
src/components/course-interactions/ # GuideDialog、GuideSpotlight、DragDrop
src/course-runtime/ # 固定课程画布运行时
src/course/interactions/ # GuideDialog、GuideSpotlight、DragDrop 受控内核
src/course/canvas/ # 固定课程画布、缩放与 Portal 根节点
src/course/orientation/ # 横竖屏访问控制
src/lib/cn.ts # 本地 class 合并工具
```
@@ -141,7 +145,7 @@ http://localhost:5173/#/components
- `Toast`: info / success / warning / error 轻提示示例
- `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例
- `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例
- `SpotlightGuide`: 课程引导和高亮目标区域示例
- `GuideSpotlight`: 课程引导和高亮目标区域示例
- `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例
后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。
@@ -150,24 +154,33 @@ http://localhost:5173/#/components
```text
src/
├── api/ # API 请求相关代码
├── assets/ # 项目资源
├── components/ # 组件
│ ├── business/ # 业务组件
│ └── common/ # 通用组件
├── pages/ # 页面组件
├── router/ # 路由配置
├── stores/ # Zustand 状态管理
│ └── modules/ # 状态模块
│ ├── app.ts # 应用状态
│ └── user.ts # 易班用户状态
├── styles/ # 全局样式
├── types/ # TypeScript 类型
└── utils/ # 工具函数
├── vconsole.ts # 调试工具
└── yiban.ts # 易班 API 封装
├── 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` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。
@@ -233,7 +246,8 @@ await reportCourseProgress(order);
// 获取课程进度
const progress = await getCourseProgress(order);
if (progress) {
console.log('当前进度:', progress.data.process);
// videoajax.js 返回平台原始响应,使用前请按平台响应结构进行类型收窄
console.log("当前进度响应:", progress);
}
```
@@ -303,7 +317,7 @@ function MyComponent() {
### 课程画布适配
模板按 `1920 x 1080` 设计稿开发,由 `src/course-runtime/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。
模板按 `1920 x 1080` 设计稿开发,由 `src/course/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。
当前默认缩放策略:
@@ -319,69 +333,135 @@ function MyComponent() {
http://localhost:5173/#/course-demo
```
`CourseDemo` 用于展示标准课程页面结构、易班 session fallback、Toast、Overlay 和 SpotlightGuide 的基础接入方式。
`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 等弹层内容。
模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等内容。
`Overlay` 已经内置 `Portal`,默认会挂载到 App 缩放画布内部,适合课程弹层跟随 `1920 x 1080` 画布一起缩放,并支持直接放入课程自己的切图和内容:
`Overlay` 内置 `Portal`。不传 `container` 时默认挂载到 `document.body`;如需跟随 `1920 x 1080` 课程画布一起缩放,应传入 `CourseCanvas` 提供的 `portalContainer`:
```tsx
import { Overlay } from "@/components/common";
import { useCourseCanvas } from "@/course/canvas";
<Overlay
open={open}
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>;
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` 值(例如 `url(./images/dialog-bg.png)` 或 `linear-gradient(...)`),组件不参与路径处理;引用 public 资源时可用 `getPublicPath` 拼接相对路径。
- `backgroundClassName`: 使用 Tailwind class 控制背景层样式,例如 `bg-cover`、`bg-center`、渐变、透明度等。
- `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。
- `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。
- `closeOnEscape`: 是否允许按 Esc 关闭,默认开启。
- `lockScroll`: 弹层打开时是否锁定页面滚动,默认开启。
- `variant`: 提供 `dim` / `light` / `transparent` 三种基础背景。
- `backgroundImage`:原始 CSS `background-image` 值,组件不参与路径处理;引用 public 资源时可用 `getPublicPath`。
- `backgroundClassName`:控制背景层样式。
- `contentClassName`:控制内容层布局。
- `closeOnBackgroundClick`:点击背景关闭,默认开启。
- `closeOnEscape`:按 Esc 关闭,默认开启。
- `lockScroll`:打开时锁定页面滚动,默认开启。
- `variant`:提供 `dim` / `light` / `transparent` 三种基础背景。
- `fixed`:默认在无 `container` 时使用 fixed,传入 `container` 时使用 absolute;可以显式覆盖。
### 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 或返回元素的函数:
如果需要挂载到业务自己的容器,也可以传入 DOM 元素、React ref 或返回元素的函数:
```tsx
import { useRef } from "react";
import { Overlay } from "@/components/common";
const rootRef = useRef<HTMLDivElement>(null);
function LocalPanel() {
const rootRef = useRef<HTMLDivElement>(null);
<div ref={rootRef} className="relative" />;
<Overlay
container={rootRef}
className="absolute"
open={open}
onClose={() => setOpen(false)}
>
{/* 挂到自定义容器里的弹层 */}
</Overlay>;
return (
<div ref={rootRef} className="relative">
<Overlay
container={rootRef}
open={open}
onClose={() => setOpen(false)}
>
业务弹层
</Overlay>
</div>
);
}
```
挂载优先级:`container` > `placement` > 组件默认值。`Portal` 默认 `placement="body"`,`Overlay` 默认 `placement="app"`。如只需要改变渲染位置,也可以单独使用 `Portal`。
`Overlay` 当前没有 `placement` prop;渲染位置只由 `container` 和 `disabledPortal` 决定。
## Toast 轻提示
@@ -447,8 +527,8 @@ toast.error("网络异常,请稍后重试");
基础示例:
```tsx
import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { DragDropProvider, Draggable, Droppable } from "@/course/interactions/drag-drop";
import { useCourseCanvas } from "@/course/canvas";
const { scale } = useCourseCanvas();
@@ -471,93 +551,88 @@ const { scale } = useCourseCanvas();
## 引导聚光灯
模板内置 `SpotlightGuide` 引导组件,适合课程首次学习时突出关键操作区域。
模板提供 `GuideSpotlight` 和 `defineSpotlight`,适合课程首次学习时突出关键操作区域。页面已经位于 `CourseCanvas` 内时,组件默认挂载到课程画布 Portal 根节点并自动使用画布 viewport。
### 基础使用
在页面元素上添加 `data-guide`:
先定义引导。未显式提供 `target` 时,`defineSpotlight` 默认使用和步骤 `id` 相同的 `data-guide`:
```tsx
<Button data-guide="start-button">开始学习</Button>
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 { SpotlightGuide } from "@/components/common";
import { useGuideStore } from "@/stores";
import { Button, GuideSpotlight } from "@/components/common";
import { lessonSpotlight } from "./lessonSpotlight";
export default function LessonPage() {
useEffect(() => {
useGuideStore.getState().startGuide({
courseId: "lesson-1",
guideId: "intro",
steps: [
{
id: "start",
target: { dataGuide: "start-button" },
title: "开始学习",
description: "点击这里开始本节课程。",
placement: "bottom",
},
],
});
lessonSpotlight.start();
}, []);
return <SpotlightGuide />;
return (
<>
<Button data-guide="start-button">开始学习</Button>
<GuideSpotlight />
</>
);
}
```
### 持久化说明
引导状态接入 Zustand,并通过 Zustand `persist` 只持久化已完成记录:
引导状态通过 Zustand persist 只保存已完成记录:
```ts
completedGuideMap: {
"lesson-1:intro": true,
completedSpotlightMap: {
"course-demo:lesson-intro": true,
}
```
默认同一个 `courseId + guideId` 只显示一次。点击“完成”或“跳过”都会标记为已完成,后续不会自动显示。
`showOnce` 默认为 `true`。点击完成或跳过都会调用 `finishSpotlight` 并标记为已完成;`closeSpotlight` 只关闭当前引导,不会写入完成记录。
如需重新显示:
重新允许展示:
```ts
useGuideStore.getState().resetGuide("lesson-1", "intro");
lessonSpotlight.reset();
```
### Step 配置
```ts
{
id: "start",
target: { dataGuide: "start-button" },
title: "开始学习",
description: "点击这里开始本节课程。",
placement: "bottom",
allowTargetClick: true,
}
```
支持:
- `target`: CSS selector 或 `{ dataGuide: "xxx" }`
- `placement`: `top` / `bottom` / `left` / `right` / `auto`
- `allowTargetClick`: 是否允许点击高亮目标
- `padding` / `radius`: 调整高亮区域
- `panelClassName`: 针对单步自定义提示框样式
- `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`。
建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。
### 高级交互说明
### 高级交互
`allowTargetClick` 只负责让高亮区域下方的真实元素可以被点击,不会自动切换引导步骤。
如果目标元素点击后需要进入下一步,需要在业务点击逻辑中手动调用 `nextStep`、`finishGuide` 或 `closeGuide`:
`allowTargetClick` 只让高亮区域下方的真实元素可点击,不会自动切换步骤。业务操作完成后可显式推进:
```tsx
const { nextStep } = useGuideStore();
import { useSpotlightStore } from "@/stores";
const nextStep = useSpotlightStore((state) => state.nextStep);
<Button
data-guide="play-button"
@@ -567,22 +642,72 @@ const { nextStep } = useGuideStore();
}}
>
播放
</Button>
</Button>;
```
### 样式与层级
`SpotlightGuide` 默认通过 Portal 挂载到 `document.body`,可以正确适配整体缩放后的坐标。
如遇到课程弹窗、横屏提示等全局层级冲突,可以通过 `theme.zIndex` 调整:
可通过 `theme.zIndex` 调整层级:
```tsx
<SpotlightGuide theme={{ zIndex: 1200 }} />
<GuideSpotlight theme={{ zIndex: 1200 }} />
```
开发环境中,如果目标元素不存在,页面会显示开发提示面板,方便检查 `data-guide` 或 selector;生产环境默认会自动跳过找不到目标的步骤。
目标不存在时,`skipIfTargetMissing` 默认为 `true`,组件会前进到下一步或结束引导。开发排查时可以显式传入 `skipIfTargetMissing={false}` 显示缺失目标面板。
组件内置了遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。
组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `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。
## 路由懒加载