merge: 合并课程模板通用能力

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 5 committed 2026-09-08 14:13:51 +08:00
commit 155f6269ca
70 files changed
+2840 -177

No files matched your search

+49 -7
View File
@@ -39,9 +39,14 @@ bun run lint:fix # 自动修复代码问题
新增文件前先判断职责归属,优先沿用现有目录结构: 新增文件前先判断职责归属,优先沿用现有目录结构:
- `src/components/common/`:通用可复用组件。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。 - `src/components/ui/`:无课程业务和全局状态依赖的基础 UI copied source。
- `src/components/common/`:模板通用组件和适配器;其中 `GuideDialog`、`GuideSpotlight` 负责连接 Zustand、CourseCanvas 与交互内核。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。
- `src/components/business/`:业务组合组件,不放通用基础能力。 - `src/components/business/`:业务组合组件,不放通用基础能力。
- `src/course/canvas/`:设计稿画布、缩放、viewport 和课程 Portal 根节点。
- `src/course/orientation/`:横竖屏访问控制。
- `src/course/interactions/`:由 CLI 复制的受控课程交互内核,不依赖具体页面、Store 或 common 适配器。
- `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。 - `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。
- 模块内私有组件:仅本模块使用、其他模块用不到的,放到本模块目录的 `components/` 子目录(没有就创建);被多个模块或组件复用才下沉到 `src/components/common/`(通用基础能力)或 `business/`(业务组合),按组件核心类型区分。该规则约束新代码,存量平铺在页面目录下的模块内组件不强制迁移。
- `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。 - `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。
- `src/stores/`:Zustand 状态管理。 - `src/stores/`:Zustand 状态管理。
- `src/utils/`:通用工具、hooks 或平台封装。 - `src/utils/`:通用工具、hooks 或平台封装。
@@ -50,7 +55,7 @@ bun run lint:fix # 自动修复代码问题
### 类型放置规则 ### 类型放置规则
组件、页面、store、API 的 `props`、`interface`、`type` 不要直接写在实现文件里: 组件、页面、store、API 的对外 `props` 与跨文件共享的 `interface`、`type` 放在 `.types.ts`;文件内私有的 helper 类型留在实现文件即可:
- 组件类型放在同组件目录的 `组件名.types.ts`,例如 `Button/Button.types.ts`。 - 组件类型放在同组件目录的 `组件名.types.ts`,例如 `Button/Button.types.ts`。
- 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。 - 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。
@@ -61,7 +66,7 @@ bun run lint:fix # 自动修复代码问题
## Tailwind、cn 与样式提取规则 ## Tailwind、cn 与样式提取规则
项目已有 [src/utils/tailwind.ts](src/utils/tailwind.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突。使用时遵守以下边界。 项目已有 [src/lib/cn.ts](src/lib/cn.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突([src/utils/tailwind.ts](src/utils/tailwind.ts) 只是 re-export)。使用时遵守以下边界。
### 默认策略 ### 默认策略
@@ -74,7 +79,7 @@ bun run lint:fix # 自动修复代码问题
在以下场景使用 `cn`: 在以下场景使用 `cn`:
- 需要合并外部传入的 `className`。 - 需要合并外部传入的 `className`。
- 存在条件 class。 - 条件 class 需要和其他 class 合并。
- 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。 - 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。
- 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。 - 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。
@@ -90,9 +95,10 @@ bun run lint:fix # 自动修复代码问题
```tsx ```tsx
<div className="flex items-center justify-center" /> <div className="flex items-center justify-center" />
<div className={active ? "h-2 w-8 bg-current" : "h-2 w-2 bg-current/20"} />
``` ```
单个固定字符串且没有条件、合并、冲突处理需求时,直接写 `className="..."`。 单个固定字符串,或在两个完整字符串之间二选一的三元且不需要再合并其他 class 时,直接写 `className`。
### 什么时候提取样式 ### 什么时候提取样式
@@ -110,6 +116,38 @@ bun run lint:fix # 自动修复代码问题
- 同目录多个文件共享时,可放同目录局部文件。 - 同目录多个文件共享时,可放同目录局部文件。
- 跨组件复用的样式能力,再考虑沉淀为通用组件、工具或 CSS Module。 - 跨组件复用的样式能力,再考虑沉淀为通用组件、工具或 CSS Module。
### 组件与 CSS 类的边界
「何时提取 class」见上一节;本节只解决复用一段 UI 时该抽组件还是抽 CSS 类。
组件/页面文件超过 500 行时,先审视是否该拆分(这是触发思考的信号,不是强制拆分线);拆出的子组件按「文件创建与目录归属」放置。
重复出现时优先抽组件,CSS 类是兜底,但避免「为整洁而抽」。抽组件必须同时满足:
- 同一组「标记 + 样式 + 行为」在 2 处以上真实重复(第一次出现不抽)。
- 组件有清晰可命名的语义,能一句话说清它是什么。
- 复用的是整体而非几个 class;props 少且稳定(一般 ≤3~4 个)。
以下情况改用 CSS 类或局部常量,而不是抽组件:
- 只复用样式、不复用标记/行为,内容由数据或富文本决定。
- 仅同一文件内的小重复。
- 给第三方/框架等无法插入组件的位置加样式。
- Tailwind 表达不了或 class 串过长,但元素本身不够独立语义。
反模式(不要抽):只被用一次仅为缩短 JSX;组件 props 塞满 className/style 覆盖;名字说不清职责(`CardTopSection`、`Wrapper` 之类)。
## 动画使用规则
项目使用 CSS/Tailwind 和 GSAP 时遵守以下边界:
- 普通状态过渡、hover、active、简单淡入淡出及单元素入场默认使用 CSS/Tailwind。
- 只有动画需要时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径时,才使用 GSAP。
- 不因项目已经安装 GSAP 而重写现有 CSS 动画,也不为简单动画建立 GSAP preset 或通用封装。
- CSS 与 GSAP 不要在同一交互周期内控制同一元素的同一属性,尤其是 `transform` 和 `opacity`。
- GSAP 动画通过 `src/utils/motion.ts` 使用 `useGSAP` 管理生命周期,并处理 `prefers-reduced-motion` 降级。
- 不使用 GSAP 修改 `CourseCanvas`、画布 portal 根节点或 dnd-kit 正在控制的 `transform`。
## 导入与导出 ## 导入与导出
- `@` 指向 `src`,新增代码优先使用 `@` 避免过长相对路径。 - `@` 指向 `src`,新增代码优先使用 `@` 避免过长相对路径。
@@ -129,10 +167,14 @@ bun run lint:fix # 自动修复代码问题
开发时要尊重模板已有能力: 开发时要尊重模板已有能力:
- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course-runtime/canvas` 的 `CourseCanvas` 组件承担。 - 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course/canvas` 的 `CourseCanvas` 组件承担。
- 横屏访问提示由 `src/course-runtime/orientation` 的 `OrientationGuard` 组件承担。 - 横屏访问提示由 `src/course/orientation` 的 `OrientationGuard` 组件承担。
- 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。 - 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。
- 移动端调试使用 `src/utils/vconsole.ts`。 - 移动端调试使用 `src/utils/vconsole.ts`。
- 图片按钮由 `src/components/common/ImageButton` 提供;视频播放与视频弹层由 `src/components/common/Video` 提供。
- 拖拽交互由 `src/course/interactions/drag-drop` 提供(基于 dnd-kit)。
- 音频能力由 `src/utils/audio` 提供(`GameAudioProvider` / `useGameAudio` / `AudioManager`)。
- GSAP 复杂动画通过 `src/utils/motion.ts` 统一引入,边界见「动画使用规则」。
- 新增通用组件时,同步考虑是否需要在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 增加示例。 - 新增通用组件时,同步考虑是否需要在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 增加示例。
## Claude Code 工作准则 ## Claude Code 工作准则
+234 -109
View File
@@ -16,6 +16,9 @@
- vconsole 移动端调试工具(仅开发环境) - vconsole 移动端调试工具(仅开发环境)
- 易班 videoajax.js 封装 - 易班 videoajax.js 封装
- 易班用户信息状态管理 - 易班用户信息状态管理
- 图片按钮、视频播放器与视频弹层基础组件
- 音频管理(配音 / BGM / 音效)
- GSAP 动画能力(复杂时间轴编排)
- `@` 指向 `src` 的路径别名 - `@` 指向 `src` 的路径别名
- 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码 - 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码
- 组件分层:Registry copied source、模板适配器、业务组件、页面组件 - 组件分层: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 ```ts
export const appConfig = { export const appConfig = {
title: "React Vite App", title: "React Vite App",
description: "易班 H5 课程模板", description: "易班 H5 课程模板",
favicon: "./vite.svg", favicon: "./vite.svg",
design: { course: {
width: 1920, id: import.meta.env.VITE_COURSE_ID ?? "course-demo",
height: 1080, name: import.meta.env.VITE_COURSE_NAME ?? "标准课程页示例",
}, },
scale: { storage: {
max: 1, prefix: "yiban-course-template",
}, },
viewport: { viewport: {
width: "device-width", 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`。 - 使用 Bun 执行项目脚本,提交前确保 `bun run lint` 通过;涉及路由、构建配置、页面结构或类型系统的改动建议同时运行 `bun run build`。
- 代码风格以 [biome.json](biome.json) 为准:tab 缩进、双引号、Biome 检查和导入整理。 - 代码风格以 [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 可直接写在 JSX 中;只有需要条件合并、外部 `className`、冲突合并或组合 CSS Module 时才使用 `cn`。
- Tailwind class 过长、重复、条件组合复杂,或直接实现过于冗余时,再提取为局部常量、同目录文件、CSS Module 或通用组件。 - Tailwind class 过长、重复、条件组合复杂,或直接实现过于冗余时,再提取为局部常量、同目录文件、CSS Module 或通用组件。
- 新增页面沿用 [src/router/index.tsx](src/router/index.tsx) 中的 `lazyRoute` 和 `RouteFallback` 模式,导入优先使用 `@` 指向 `src` 的路径别名。 - 新增页面沿用 [src/router/index.tsx](src/router/index.tsx) 中的 `lazyRoute` 和 `RouteFallback` 模式,导入优先使用 `@` 指向 `src` 的路径别名。
@@ -102,8 +105,9 @@ bun run lint:fix # 自动修复代码问题
```text ```text
src/components/ui/ # Button、Portal、Overlay、Toast src/components/ui/ # Button、Portal、Overlay、Toast
src/components/course-interactions/ # GuideDialog、GuideSpotlight、DragDrop src/course/interactions/ # GuideDialog、GuideSpotlight、DragDrop 受控内核
src/course-runtime/ # 固定课程画布运行时 src/course/canvas/ # 固定课程画布、缩放与 Portal 根节点
src/course/orientation/ # 横竖屏访问控制
src/lib/cn.ts # 本地 class 合并工具 src/lib/cn.ts # 本地 class 合并工具
``` ```
@@ -141,7 +145,7 @@ http://localhost:5173/#/components
- `Toast`: info / success / warning / error 轻提示示例 - `Toast`: info / success / warning / error 轻提示示例
- `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例 - `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例
- `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例 - `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例
- `SpotlightGuide`: 课程引导和高亮目标区域示例 - `GuideSpotlight`: 课程引导和高亮目标区域示例
- `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例 - `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例
后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。 后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。
@@ -152,22 +156,31 @@ http://localhost:5173/#/components
src/ src/
├── api/ # API 请求相关代码 ├── api/ # API 请求相关代码
├── assets/ # 项目资源 ├── assets/ # 项目资源
├── components/ # 组件 ├── components/
│ ├── business/ # 业务组件 │ ├── business/ # 业务组合组件
│ └── common/ # 通用组件 │ ├── common/ # 模板通用组件和 Store/Canvas 适配器
│ └── ui/ # Button、Portal、Overlay、Toast 等基础 UI
├── course/
│ ├── canvas/ # 1920 x 1080 画布、viewport 与 Portal 根节点
│ ├── interactions/ # Guide、DragDrop 等课程交互内核
│ └── orientation/ # 横竖屏访问控制
├── lib/ # 无业务依赖的底层工具
├── pages/ # 页面组件 ├── pages/ # 页面组件
├── router/ # 路由配置 ├── router/ # 路由配置
├── stores/ # Zustand 状态管理 ├── stores/
│ └── modules/ # 状态模块 │ ├── modules/ # Zustand store 实现
│ ├── app.ts # 应用状态 │ └── types/ # Store 类型
│ └── user.ts # 易班用户状态
├── styles/ # 全局样式 ├── styles/ # 全局样式
├── types/ # TypeScript 类型 ├── types/ # 跨多个模块共享的全局类型
└── utils/ # 工具函数 └── utils/
├── vconsole.ts # 调试工具 ├── audio/ # 音频管理
├── motion.ts # GSAP 动画统一入口
├── vconsole.ts # 移动端调试
└── yiban.ts # 易班 API 封装 └── yiban.ts # 易班 API 封装
``` ```
`src/course/interactions` 保持无具体课程 Store 依赖;`src/components/common/GuideDialog` 和 `GuideSpotlight` 负责连接 Zustand、CourseCanvas 和 Portal。
## 易班开发说明 ## 易班开发说明
模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。 模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。
@@ -233,7 +246,8 @@ await reportCourseProgress(order);
// 获取课程进度 // 获取课程进度
const progress = await getCourseProgress(order); const progress = await getCourseProgress(order);
if (progress) { 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 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 ```tsx
import { Overlay } from "@/components/common"; import { Overlay } from "@/components/common";
import { useCourseCanvas } from "@/course/canvas";
function CoursePanel() {
const { portalContainer } = useCourseCanvas();
return (
<Overlay <Overlay
open={open} open={open}
container={portalContainer}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
backgroundImage="url(./images/dialog-bg.png)" backgroundImage="url(./images/dialog-bg.png)"
backgroundClassName="bg-cover bg-center" backgroundClassName="bg-cover bg-center"
contentClassName="flex items-center justify-center" contentClassName="flex items-center justify-center"
> >
<img src="./images/dialog.png" alt="课程提示" /> <img src="./images/dialog.png" alt="课程提示" />
</Overlay>; </Overlay>
);
}
``` ```
说明: 说明:
- `backgroundImage`: 传入原始 CSS `background-image` 值(例如 `url(./images/dialog-bg.png)` 或 `linear-gradient(...)`),组件不参与路径处理;引用 public 资源时可用 `getPublicPath` 拼接相对路径。 - `backgroundImage`:原始 CSS `background-image` 值,组件不参与路径处理;引用 public 资源时可用 `getPublicPath`。
- `backgroundClassName`: 使用 Tailwind class 控制背景层样式,例如 `bg-cover`、`bg-center`、渐变、透明度等。 - `backgroundClassName`:控制背景层样式。
- `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。 - `contentClassName`:控制内容层布局。
- `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。 - `closeOnBackgroundClick`:点击背景关闭,默认开启。
- `closeOnEscape`: 是否允许按 Esc 关闭,默认开启。 - `closeOnEscape`:按 Esc 关闭,默认开启。
- `lockScroll`: 弹层打开时是否锁定页面滚动,默认开启。 - `lockScroll`:打开时锁定页面滚动,默认开启。
- `variant`: 提供 `dim` / `light` / `transparent` 三种基础背景。 - `variant`:提供 `dim` / `light` / `transparent` 三种基础背景。
- `fixed`:默认在无 `container` 时使用 fixed,传入 `container` 时使用 absolute;可以显式覆盖。
### Portal 挂载位置 如果需要挂载到业务自己的容器,也可以传入 DOM 元素、React ref 或返回元素的函数:
`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 ```tsx
import { useRef } from "react"; import { useRef } from "react";
import { Overlay } from "@/components/common"; import { Overlay } from "@/components/common";
function LocalPanel() {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
<div ref={rootRef} className="relative" />; return (
<div ref={rootRef} className="relative">
<Overlay <Overlay
container={rootRef} container={rootRef}
className="absolute"
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
> >
{/* 挂到自定义容器里的弹层 */} 业务弹层
</Overlay>; </Overlay>
</div>
);
}
``` ```
挂载优先级:`container` > `placement` > 组件默认值。`Portal` 默认 `placement="body"`,`Overlay` 默认 `placement="app"`。如只需要改变渲染位置,也可以单独使用 `Portal`。 `Overlay` 当前没有 `placement` prop;渲染位置只由 `container` 和 `disabledPortal` 决定。
## Toast 轻提示 ## Toast 轻提示
@@ -447,8 +527,8 @@ toast.error("网络异常,请稍后重试");
基础示例: 基础示例:
```tsx ```tsx
import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop"; import { DragDropProvider, Draggable, Droppable } from "@/course/interactions/drag-drop";
import { useCourseCanvas } from "@/course-runtime/canvas"; import { useCourseCanvas } from "@/course/canvas";
const { scale } = useCourseCanvas(); const { scale } = useCourseCanvas();
@@ -471,93 +551,88 @@ const { scale } = useCourseCanvas();
## 引导聚光灯 ## 引导聚光灯
模板内置 `SpotlightGuide` 引导组件,适合课程首次学习时突出关键操作区域。 模板提供 `GuideSpotlight` 和 `defineSpotlight`,适合课程首次学习时突出关键操作区域。页面已经位于 `CourseCanvas` 内时,组件默认挂载到课程画布 Portal 根节点并自动使用画布 viewport。
### 基础使用 ### 基础使用
在页面元素上添加 `data-guide`: 先定义引导。未显式提供 `target` 时,`defineSpotlight` 默认使用和步骤 `id` 相同的 `data-guide`:
```tsx ```tsx
<Button data-guide="start-button">开始学习</Button> import { defineSpotlight } from "@/components/common";
```
在页面初始化时启动引导: export const lessonSpotlight = defineSpotlight({
id: "lesson-intro",
```tsx
import { useEffect } from "react";
import { SpotlightGuide } from "@/components/common";
import { useGuideStore } from "@/stores";
export default function LessonPage() {
useEffect(() => {
useGuideStore.getState().startGuide({
courseId: "lesson-1",
guideId: "intro",
steps: [ steps: [
{ {
id: "start", id: "start-button",
target: { dataGuide: "start-button" },
title: "开始学习", title: "开始学习",
description: "点击这里开始本节课程。", description: "点击这里开始本节课程。",
placement: "bottom", 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 <SpotlightGuide />; return (
<>
<Button data-guide="start-button">开始学习</Button>
<GuideSpotlight />
</>
);
} }
``` ```
### 持久化说明 ### 持久化说明
引导状态接入 Zustand,并通过 Zustand `persist` 只持久化已完成记录: 引导状态通过 Zustand persist 只保存已完成记录:
```ts ```ts
completedGuideMap: { completedSpotlightMap: {
"lesson-1:intro": true, "course-demo:lesson-intro": true,
} }
``` ```
默认同一个 `courseId + guideId` 只显示一次。点击“完成”或“跳过”都会标记为已完成,后续不会自动显示。 `showOnce` 默认为 `true`。点击完成或跳过都会调用 `finishSpotlight` 并标记为已完成;`closeSpotlight` 只关闭当前引导,不会写入完成记录。
如需重新显示: 重新允许展示:
```ts ```ts
useGuideStore.getState().resetGuide("lesson-1", "intro"); lessonSpotlight.reset();
``` ```
### Step 配置 ### Step 配置
```ts
{
id: "start",
target: { dataGuide: "start-button" },
title: "开始学习",
description: "点击这里开始本节课程。",
placement: "bottom",
allowTargetClick: true,
}
```
支持: 支持:
- `target`: CSS selector 或 `{ dataGuide: "xxx" }` - `target`:CSS selector 或 `{ dataGuide: "xxx" }`;省略时由 `defineSpotlight` 使用步骤 `id`。
- `placement`: `top` / `bottom` / `left` / `right` / `auto` - `placement`:`top` / `bottom` / `left` / `right` / `auto`。
- `allowTargetClick`: 是否允许点击高亮目标 - `allowTargetClick`:是否允许点击高亮目标。
- `padding` / `radius`: 调整高亮区域 - `padding` / `radius`:调整高亮区域。
- `panelClassName`: 针对单步自定义提示框样式 - `panelClassName`:单步提示框样式。
- `nextLabel` / `prevLabel` / `doneLabel` / `skipLabel`:按钮文案。
> 建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。 建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。
### 高级交互说明 ### 高级交互
`allowTargetClick` 只负责让高亮区域下方的真实元素可以被点击,不会自动切换引导步骤。 `allowTargetClick` 只让高亮区域下方的真实元素可点击,不会自动切换步骤。业务操作完成后可显式推进:
如果目标元素点击后需要进入下一步,需要在业务点击逻辑中手动调用 `nextStep`、`finishGuide` 或 `closeGuide`:
```tsx ```tsx
const { nextStep } = useGuideStore(); import { useSpotlightStore } from "@/stores";
const nextStep = useSpotlightStore((state) => state.nextStep);
<Button <Button
data-guide="play-button" data-guide="play-button"
@@ -567,22 +642,72 @@ const { nextStep } = useGuideStore();
}} }}
> >
播放 播放
</Button> </Button>;
``` ```
### 样式与层级 可通过 `theme.zIndex` 调整层级:
`SpotlightGuide` 默认通过 Portal 挂载到 `document.body`,可以正确适配整体缩放后的坐标。
如遇到课程弹窗、横屏提示等全局层级冲突,可以通过 `theme.zIndex` 调整:
```tsx ```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。
## 路由懒加载 ## 路由懒加载
+12
View File
@@ -7,7 +7,10 @@
"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",
"howler": "^2.2.4",
"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",
@@ -19,6 +22,7 @@
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/howler": "^2.2.13",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -68,6 +72,8 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@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=="],
@@ -148,6 +154,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/howler": ["@types/howler@2.2.13", "https://registry.npmmirror.com/@types/howler/-/howler-2.2.13.tgz", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="],
"@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"@types/react": ["@types/react@19.2.9", "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react": ["@types/react@19.2.9", "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
@@ -216,6 +224,10 @@
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gsap": ["gsap@3.15.0", "https://registry.npmmirror.com/gsap/-/gsap-3.15.0.tgz", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
"howler": ["howler@2.2.4", "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "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=="],
+4 -4
View File
@@ -9,14 +9,14 @@
}, },
"directories": { "directories": {
"ui": "src/components/ui", "ui": "src/components/ui",
"interactions": "src/components/course-interactions", "interactions": "src/course/interactions",
"runtime": "src/course-runtime", "runtime": "src/course",
"lib": "src/lib" "lib": "src/lib"
}, },
"aliases": { "aliases": {
"ui": "@/components/ui", "ui": "@/components/ui",
"interactions": "@/components/course-interactions", "interactions": "@/course/interactions",
"runtime": "@/course-runtime", "runtime": "@/course",
"lib": "@/lib" "lib": "@/lib"
} }
} }
+4
View File
@@ -18,7 +18,10 @@
"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",
"howler": "^2.2.4",
"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",
@@ -30,6 +33,7 @@
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/howler": "^2.2.13",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
+9 -3
View File
@@ -1,10 +1,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Outlet } from "react-router-dom"; 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 {
import { OrientationGuard } from "@/course-runtime/orientation"; CourseCanvas,
registerAppPortalRoot,
useCourseCanvas,
} from "@/course/canvas";
import { OrientationGuard } from "@/course/orientation";
import { GameAudioProvider } from "@/utils/audio";
import { Navigation } from "./components/business"; import { Navigation } from "./components/business";
import { registerAppPortalRoot } from "./components/common/canvasPortal";
function CourseShell() { function CourseShell() {
const { portalContainer } = useCourseCanvas(); const { portalContainer } = useCourseCanvas();
@@ -12,6 +16,7 @@ function CourseShell() {
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]); useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
return ( return (
<GameAudioProvider>
<ToastProvider> <ToastProvider>
<Navigation /> <Navigation />
<main className="flex-1 overflow-auto p-8"> <main className="flex-1 overflow-auto p-8">
@@ -19,6 +24,7 @@ function CourseShell() {
</main> </main>
<ToastViewport container={portalContainer} /> <ToastViewport container={portalContainer} />
</ToastProvider> </ToastProvider>
</GameAudioProvider>
); );
} }
@@ -0,0 +1,9 @@
@reference "tailwindcss";
.container {
@apply relative;
}
.content {
@apply relative h-full z-10;
}
@@ -0,0 +1,41 @@
import { cn } from "@/lib/cn";
import { getStaticImage } from "@/utils/assets";
import styles from "./BackgroundContainer.module.css";
import type { BackgroundContainerProps } from "./BackgroundContainer.types";
const DIRECT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/|\.\.?\/)/i;
function resolveBackgroundImage(src: string): string {
return DIRECT_URL_PATTERN.test(src) ? src : getStaticImage(src) || src;
}
export function BackgroundContainer({
src,
fit = "cover",
position = "center",
repeat = "no-repeat",
as: Component = "div",
className,
contentClassName,
children,
style,
...props
}: BackgroundContainerProps) {
const imageSrc = resolveBackgroundImage(src);
return (
<Component
className={cn(styles.container, className)}
style={{
backgroundImage: `url(${JSON.stringify(imageSrc)})`,
backgroundSize: fit,
backgroundPosition: position,
backgroundRepeat: repeat,
...style,
}}
{...props}
>
<div className={cn(styles.content, contentClassName)}>{children}</div>
</Component>
);
}
@@ -0,0 +1,21 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BackgroundContainerElement = "div" | "section" | "main" | "article";
export type BackgroundContainerFit = "cover" | "contain";
export type BackgroundContainerRepeat =
| "no-repeat"
| "repeat"
| "repeat-x"
| "repeat-y";
export interface BackgroundContainerProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
/** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */
src: string;
fit?: BackgroundContainerFit;
position?: string;
repeat?: BackgroundContainerRepeat;
as?: BackgroundContainerElement;
contentClassName?: string;
children?: ReactNode;
}
@@ -0,0 +1,7 @@
export { BackgroundContainer } from "./BackgroundContainer";
export type {
BackgroundContainerElement,
BackgroundContainerFit,
BackgroundContainerProps,
BackgroundContainerRepeat,
} from "./BackgroundContainer.types";
@@ -1,21 +1,15 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
type GuideDialogProps, import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
GuideDialog as RegistryGuideDialog,
} from "@/components/course-interactions/guide-dialog";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useDialogStore } from "@/stores"; import { useDialogStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal"; import type { GuideDialogProps } from "./GuideDialog.types";
export function GuideDialog({ export function GuideDialog({
children, children,
onFinish, onFinish,
onSkip, onSkip,
...props ...props
}: Omit<GuideDialogProps, "actions" | "state" | "viewport"> & { }: GuideDialogProps) {
onFinish?: () => void;
onSkip?: () => void;
}) {
const { const {
open, open,
steps, steps,
@@ -1,5 +1,12 @@
import type { GuideDialogProps as RegistryGuideDialogProps } from "@/course/interactions/guide-dialog";
export interface GuideDialogProps
extends Omit<RegistryGuideDialogProps, "actions" | "state" | "viewport"> {
onFinish?: () => void;
onSkip?: () => void;
}
export type { export type {
GuideDialogProps,
GuideDialogRenderState, GuideDialogRenderState,
GuideDialogTheme, GuideDialogTheme,
} from "@/components/course-interactions/guide-dialog"; } from "@/course/interactions/guide-dialog";
@@ -1,11 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
type GuideSpotlightProps, import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
GuideSpotlight as RegistryGuideSpotlight,
} from "@/components/course-interactions/guide-spotlight";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useSpotlightStore } from "@/stores"; import { useSpotlightStore } from "@/stores";
import { getAppPortalRoot } from "../canvasPortal"; import type { GuideSpotlightProps } from "./GuideSpotlight.types";
export function GuideSpotlight({ export function GuideSpotlight({
children, children,
@@ -14,10 +11,7 @@ export function GuideSpotlight({
container = getAppPortalRoot, container = getAppPortalRoot,
viewport, viewport,
...props ...props
}: Omit<GuideSpotlightProps, "actions" | "state"> & { }: GuideSpotlightProps) {
onFinish?: () => void;
onSkip?: () => void;
}) {
const { const {
open, open,
steps, steps,
@@ -1,7 +1,14 @@
import type { GuideSpotlightProps as RegistryGuideSpotlightProps } from "@/course/interactions/guide-spotlight";
export interface GuideSpotlightProps
extends Omit<RegistryGuideSpotlightProps, "actions" | "state"> {
onFinish?: () => void;
onSkip?: () => void;
}
export type { export type {
GuideSpotlightProps,
GuideSpotlightRenderState, GuideSpotlightRenderState,
GuideSpotlightTheme, GuideSpotlightTheme,
Rect, Rect,
SpotlightLayout, SpotlightLayout,
} from "@/components/course-interactions/guide-spotlight"; } from "@/course/interactions/guide-spotlight";
@@ -0,0 +1,34 @@
@reference "tailwindcss";
/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */
.button {
/* hover/按下缩放倍率由组件通过内联 --hover-scale / --press-scale 变量传入 */
--hover-scale: 1.05;
--press-scale: 0.95;
transition: transform 0.18s ease-out;
@apply m-0 box-border cursor-pointer select-none appearance-none overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle hover:brightness-110;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500;
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
}
.button:hover {
transform: scale(var(--hover-scale));
}
.button:active {
transform: scale(var(--press-scale));
}
.image {
@apply block h-full w-full object-cover;
}
@media (prefers-reduced-motion: reduce) {
.button,
.button:hover,
.button:active {
transform: none;
transition: none;
}
}
@@ -0,0 +1,53 @@
import { type CSSProperties, forwardRef } from "react";
import { cn } from "@/lib/cn";
import { getStaticImage } from "@/utils/assets";
import styles from "./ImageButton.module.css";
import type { ImageButtonProps } from "./ImageButton.types";
// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 <img>
function resolveImage(src: string): string {
return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src;
}
export const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
(
{
src,
alt = "",
width,
height,
hoverScale = 1.05,
pressScale = 0.95,
className,
style,
...rest
},
ref,
) => {
const imageSrc = resolveImage(src);
// hover/按下缩放由 CSS 过渡实现,倍率通过内联变量传入(见 ImageButton.module.css)
const scaleVars = {
"--hover-scale": hoverScale,
"--press-scale": pressScale,
} as CSSProperties;
return (
<button
ref={ref}
type="button"
className={cn(styles.button, className)}
style={{ ...scaleVars, width, height, ...style }}
{...rest}
>
<img
src={imageSrc}
alt={alt}
className={styles.image}
draggable={false}
/>
</button>
);
},
);
ImageButton.displayName = "ImageButton";
@@ -0,0 +1,15 @@
import type { ButtonHTMLAttributes } from "react";
export interface ImageButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** 图片:静态资源 key(相对 src/assets/images/)或外部 http(s)/data/blob URL */
src: string;
alt?: string;
/** 显示尺寸(数字按 px,字符串原样透传)。图片按钮通常应有确定尺寸 */
width?: number | string;
height?: number | string;
/** hover 放大倍率,默认 1.05 */
hoverScale?: number;
/** 按下缩小倍率,默认 0.95 */
pressScale?: number;
}
@@ -0,0 +1,2 @@
export { ImageButton } from "./ImageButton";
export type { ImageButtonProps } from "./ImageButton.types";
@@ -0,0 +1,67 @@
import { Overlay } from "@/components/ui/overlay";
import { getAppPortalRoot } from "@/course/canvas";
import type { VideoOverlayProps } from "./VideoOverlay.types";
import { VideoPlayer } from "./VideoPlayer";
/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */
export function VideoOverlay({
open,
src,
poster,
autoPlay = true,
mode = "fullscreen",
windowWidth = 1200,
windowHeight = 675,
dismissible = false,
lockScroll = true,
skipLabel,
onClose,
onEnded,
onSkip,
}: VideoOverlayProps) {
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
variant="dim"
closeOnEscape={dismissible}
closeOnBackgroundClick={dismissible}
lockScroll={lockScroll}
className="z-50"
contentClassName="flex items-center justify-center"
>
<div
style={
mode === "window"
? { width: windowWidth, height: windowHeight }
: undefined
}
className={
mode === "window"
? "relative overflow-hidden rounded-lg bg-black shadow-2xl"
: "absolute inset-0 h-full w-full bg-black"
}
>
<VideoPlayer
src={src}
poster={poster}
autoPlay={autoPlay}
mode={mode}
allowFullscreenToggle={false}
onEnded={onEnded}
/>
{skipLabel && (
<button
type="button"
className="absolute right-14 top-10 z-10 rounded-full bg-black/55 px-10 py-4 text-[30px] text-white/90 hover:bg-black/75"
onClick={onSkip}
>
{skipLabel}
</button>
)}
</div>
</Overlay>
);
}
@@ -0,0 +1,28 @@
export interface VideoOverlayProps {
/** 是否显示 */
open: boolean;
/** 可直接交给 video 元素的视频 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 播放模式 */
mode?: "fullscreen" | "window";
/** 窗口模式下的宽度(px) */
windowWidth?: number;
/** 窗口模式下的高度(px) */
windowHeight?: number;
/** 是否允许点击背景或按 Escape 关闭 */
dismissible?: boolean;
/** 弹层打开时是否锁定页面滚动 */
lockScroll?: boolean;
/** 跳过按钮文案,不传则不显示 */
skipLabel?: string;
/** 关闭回调 */
onClose?: () => void;
/** 播放结束回调 */
onEnded?: () => void;
/** 点击跳过回调 */
onSkip?: () => void;
}
@@ -0,0 +1,134 @@
@reference "tailwindcss";
/* 播放器容器 */
.player {
@apply relative flex items-center justify-center overflow-hidden bg-black;
}
.playerFullscreen {
@apply absolute inset-0 h-full w-full;
}
.playerWindow {
@apply relative mx-auto;
}
/* 视频元素 */
.video {
@apply h-full w-full object-contain;
}
/* 中央播放按钮(暂停时显示) */
.centerPlayButton {
@apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300;
}
.centerPlayButton:hover {
@apply bg-black/70;
}
/* 控制条容器 */
.controls {
@apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300;
}
.controlsHidden {
@apply pointer-events-none opacity-0;
}
/* 小进度条(控件隐藏时显示) */
.miniProgress {
@apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300;
}
.miniProgressHidden {
@apply opacity-0;
}
.miniProgressBar {
@apply h-full w-full origin-left bg-red-500;
transform: scaleX(0);
will-change: transform;
}
/* 进度条 */
.progressContainer {
@apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30;
touch-action: none;
}
.progressBar,
.progressBuffered {
@apply absolute left-0 top-0 h-full w-full origin-left rounded-full;
transform: scaleX(0);
will-change: transform;
}
.progressBar {
@apply bg-red-500;
}
.progressBuffered {
@apply bg-white/30;
}
.progressThumb {
@apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200;
transform: translate3d(0, -50%, 0);
will-change: transform;
}
.progressContainer:hover .progressThumb,
.progressDragging .progressThumb {
@apply opacity-100;
}
.progressDragging {
@apply cursor-grabbing;
}
/* 时间显示 */
.timeDisplay {
@apply text-xs text-white/90 tabular-nums;
}
/* 按钮 */
.controlButton {
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white;
}
.controlButton:disabled {
@apply cursor-not-allowed opacity-50;
}
/* 倍速选择器 */
.rateSelector {
@apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white;
}
/* 音量滑块 */
.volumeSlider {
@apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30;
}
.volumeSlider::-webkit-slider-thumb {
@apply h-3 w-3 appearance-none rounded-full bg-white shadow-md;
}
.volumeSlider::-moz-range-thumb {
@apply h-3 w-3 rounded-full border-0 bg-white shadow-md;
}
/* 底部控制行 */
.controlRow {
@apply flex items-center gap-2;
}
/* 加载状态 */
.loading {
@apply absolute inset-0 flex items-center justify-center bg-black/30;
}
.spinner {
@apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
export interface VideoPlayerRef {
play: () => void;
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setPlaybackRate: (rate: number) => void;
getCurrentTime: () => number;
getDuration: () => number;
}
export interface VideoPlayerProps {
/** 视频源 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 循环播放 */
loop?: boolean;
/** 默认静音 */
muted?: boolean;
/** 预加载策略 */
preload?: "none" | "metadata" | "auto";
/** 初始播放速度 */
playbackRate?: number;
/** 显示模式:全屏覆盖或窗口 */
mode?: "fullscreen" | "window";
/** 是否允许在全屏覆盖与窗口模式之间切换 */
allowFullscreenToggle?: boolean;
/** 自定义 className */
className?: string;
// 事件回调
/** 播放开始时触发 */
onPlay?: () => void;
/** 暂停时触发 */
onPause?: () => void;
/** 播放结束时触发 */
onEnded?: () => void;
/** 时间更新时触发 */
onTimeUpdate?: (currentTime: number) => void;
/** 进度加载时触发 */
onProgress?: (buffered: TimeRanges | null) => void;
/** 音量变化时触发 */
onVolumeChange?: (volume: number, muted: boolean) => void;
/** 播放速度变化时触发 */
onPlaybackRateChange?: (rate: number) => void;
/** 全屏状态变化时触发 */
onFullscreenChange?: (isFullscreen: boolean) => void;
}
+4
View File
@@ -0,0 +1,4 @@
export { VideoOverlay } from "./VideoOverlay";
export type { VideoOverlayProps } from "./VideoOverlay.types";
export { VideoPlayer } from "./VideoPlayer";
export type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
+15
View File
@@ -15,6 +15,13 @@ export type {
ToastViewportProps, ToastViewportProps,
} from "@/components/ui/toast"; } from "@/components/ui/toast";
export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast"; export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast";
export type {
BackgroundContainerElement,
BackgroundContainerFit,
BackgroundContainerProps,
BackgroundContainerRepeat,
} from "./BackgroundContainer";
export { BackgroundContainer } from "./BackgroundContainer";
export type { export type {
DefineDialogOptions, DefineDialogOptions,
GuideDialogController, GuideDialogController,
@@ -31,4 +38,12 @@ export type {
SpotlightController, SpotlightController,
} from "./GuideSpotlight"; } from "./GuideSpotlight";
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight"; export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
export type { ImageButtonProps } from "./ImageButton";
export { ImageButton } from "./ImageButton";
export { RouteError, RouteFallback } from "./RouteFallback"; export { RouteError, RouteFallback } from "./RouteFallback";
export type {
VideoOverlayProps,
VideoPlayerProps,
VideoPlayerRef,
} from "./Video";
export { VideoOverlay, VideoPlayer } from "./Video";
File renamed without changes.
@@ -5,3 +5,4 @@ export type {
CourseCanvasProps, CourseCanvasProps,
CourseCanvasViewport, CourseCanvasViewport,
} from "./CourseCanvas.types"; } from "./CourseCanvas.types";
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
@@ -128,10 +128,21 @@ export function GuideDialog({
} }
const context = getCoordinateContext({ container, viewport }); const context = getCoordinateContext({ container, viewport });
const panelRect = panelRef.current?.getBoundingClientRect(); const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = { const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth, width: panelElement
height: panelRect ? panelRect.height / context.scale : 220, ? Math.max(
panelRect?.width ?? 0,
panelElement.scrollWidth * context.scale,
) / context.scale
: panelMaxWidth,
height: panelElement
? Math.max(
panelRect?.height ?? 0,
panelElement.scrollHeight * context.scale,
) / context.scale
: 220,
}; };
const position = getPanelPosition( const position = getPanelPosition(
step.placement ?? "bottom", step.placement ?? "bottom",
@@ -239,7 +250,7 @@ export function GuideDialog({
ref={panelRef} ref={panelRef}
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
styles.panel, panelClassName,
styles.measurePanel, styles.measurePanel,
theme?.panelClassName, theme?.panelClassName,
)} )}
@@ -149,6 +149,7 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.mask, .mask,
.highlight, .highlight,
.panelContainer,
.panel, .panel,
.missingPanel { .missingPanel {
animation: none; animation: none;
@@ -312,10 +312,21 @@ export function GuideSpotlight({
} }
const nextHighlightRect = getHighlightRect(target, padding, context); const nextHighlightRect = getHighlightRect(target, padding, context);
const panelRect = panelRef.current?.getBoundingClientRect(); const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = { const panelSize = {
width: panelRect ? panelRect.width / context.scale : panelMaxWidth, width: panelElement
height: panelRect ? panelRect.height / context.scale : 180, ? Math.max(
panelRect?.width ?? 0,
panelElement.scrollWidth * context.scale,
) / context.scale
: panelMaxWidth,
height: panelElement
? Math.max(
panelRect?.height ?? 0,
panelElement.scrollHeight * context.scale,
) / context.scale
: 180,
}; };
const position = getPanelPosition( const position = getPanelPosition(
nextHighlightRect, nextHighlightRect,
@@ -503,7 +514,7 @@ export function GuideSpotlight({
ref={panelRef} ref={panelRef}
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
styles.panel, panelClassName,
styles.measurePanel, styles.measurePanel,
theme?.panelClassName, theme?.panelClassName,
step.panelClassName, step.panelClassName,
File renamed without changes.
+3 -3
View File
@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useCourseCanvas } from "@/course/canvas";
import { import {
DragDropProvider, DragDropProvider,
type DragDropProviderProps, type DragDropProviderProps,
Draggable, Draggable,
Droppable, Droppable,
} from "@/components/course-interactions/drag-drop"; } from "@/course/interactions/drag-drop";
import { Button } from "@/components/ui/button";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import type { import type {
DragDemoItem, DragDemoItem,
+1 -2
View File
@@ -1,11 +1,10 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { GuideSpotlight } from "@/components/common"; import { GuideSpotlight } from "@/components/common";
import { getAppPortalRoot } from "@/components/common/canvasPortal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Overlay, type OverlayProps } from "@/components/ui/overlay"; import { Overlay, type OverlayProps } from "@/components/ui/overlay";
import { Portal } from "@/components/ui/portal"; import { Portal } from "@/components/ui/portal";
import { useToast } from "@/components/ui/toast"; import { useToast } from "@/components/ui/toast";
import { useCourseCanvas } from "@/course-runtime/canvas"; import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { useSpotlightStore } from "@/stores"; import { useSpotlightStore } from "@/stores";
import { DragDropDemo } from "./DragDropDemo"; import { DragDropDemo } from "./DragDropDemo";
+13
View File
@@ -1,3 +1,8 @@
import type {
GuideDialogRenderState,
GuideSpotlightRenderState,
} from "@/components/common";
export type CourseResourceCategory = "policy" | "practice" | "support"; export type CourseResourceCategory = "policy" | "practice" | "support";
export interface CourseResource { export interface CourseResource {
@@ -20,3 +25,11 @@ export interface CourseFlowProgress {
completed: boolean; completed: boolean;
updatedAt: string; updatedAt: string;
} }
export interface CourseDemoGuideDialogPanelProps {
dialog: GuideDialogRenderState;
}
export interface CourseDemoGuideSpotlightPanelProps {
spotlight: GuideSpotlightRenderState;
}
@@ -1,8 +1,4 @@
import type { GuideDialogRenderState } from "@/components/common"; import type { CourseDemoGuideDialogPanelProps } from "./CourseDemo.types";
interface CourseDemoGuideDialogPanelProps {
dialog: GuideDialogRenderState;
}
function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) { function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
return ( return (
@@ -1,8 +1,5 @@
import type { GuideSpotlightRenderState } from "@/components/common"; import { cn } from "@/lib/cn";
import type { CourseDemoGuideSpotlightPanelProps } from "./CourseDemo.types";
interface CourseDemoGuideSpotlightPanelProps {
spotlight: GuideSpotlightRenderState;
}
function GuideActions({ function GuideActions({
spotlight, spotlight,
@@ -31,7 +28,10 @@ function GuideActions({
<button <button
type="button" type="button"
onClick={actions.primary} onClick={actions.primary}
className={`rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition ${className}`} className={cn(
"rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition",
className,
)}
> >
{labels.primary} {labels.primary}
</button> </button>
+5 -6
View File
@@ -1,15 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getAppPortalRoot } from "@/components/common/canvasPortal"; import { Button } from "@/components/ui/button";
import { Overlay } from "@/components/ui/overlay";
import { useToast } from "@/components/ui/toast";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { import {
DragDropProvider, DragDropProvider,
type DragDropProviderProps, type DragDropProviderProps,
Draggable, Draggable,
Droppable, Droppable,
} from "@/components/course-interactions/drag-drop"; } from "@/course/interactions/drag-drop";
import { Button } from "@/components/ui/button";
import { Overlay } from "@/components/ui/overlay";
import { useToast } from "@/components/ui/toast";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { useUserStore } from "@/stores"; import { useUserStore } from "@/stores";
import { getUserStorage, setUserStorage } from "@/utils/yiban"; import { getUserStorage, setUserStorage } from "@/utils/yiban";
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import type { GameAudioManager } from "./audio.types";
import { GameAudioContext } from "./audioContext";
import { createAudioManager } from "./audioManager";
import type { GameAudioProviderProps } from "./GameAudioProvider.types";
import { createHowlerAudioEngine } from "./howlerEngine";
const activeManagers = new Set<GameAudioManager>();
export function GameAudioProvider({
children,
manager: injectedManager,
}: GameAudioProviderProps) {
const managerRef = useRef<GameAudioManager | null>(null);
if (!managerRef.current) {
managerRef.current =
injectedManager ??
createAudioManager({ engine: createHowlerAudioEngine() });
}
const manager = managerRef.current;
useEffect(() => {
activeManagers.add(manager);
const handleVisibilityChange = () => {
manager.handleVisibilityChange(document.hidden);
};
const handlePageHide = () => manager.stopAll();
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("pagehide", handlePageHide);
handleVisibilityChange();
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("pagehide", handlePageHide);
activeManagers.delete(manager);
manager.stopAll();
};
}, [manager]);
return (
<GameAudioContext.Provider value={manager}>
{children}
</GameAudioContext.Provider>
);
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
for (const manager of activeManagers) manager.stopAll();
activeManagers.clear();
});
}
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import type { GameAudioManager } from "./audio.types";
export interface GameAudioProviderProps {
children: ReactNode;
/** 测试或特殊运行环境可注入自定义 manager。 */
manager?: GameAudioManager;
}
+104
View File
@@ -0,0 +1,104 @@
export type AudioChannel = "voice" | "bgm" | "sfx";
export type AudioSource = string | readonly string[];
export interface AudioPlayOptions {
loop?: boolean;
volume?: number;
rate?: number;
/** 长音频使用 HTML5 Audio 流式加载,短音频默认使用 Web Audio。 */
stream?: boolean;
onEnd?: () => void;
onError?: (result: AudioPlayFailureResult) => void;
}
export type AudioPlayFailureResult =
| { status: "blocked"; error: unknown }
| { status: "load-error"; error: unknown }
| { status: "play-error"; error: unknown };
export type AudioPlayResult =
| { status: "playing" }
| { status: "cancelled" }
| AudioPlayFailureResult;
export type AudioPreloadResult =
| { status: "loaded" }
| { status: "cancelled" }
| { status: "load-error"; error: unknown };
export interface AudioPlaybackHandle {
started: Promise<AudioPlayResult>;
stop: () => void;
}
export interface AudioPreloadHandle {
ready: Promise<AudioPreloadResult>;
release: () => void;
}
export interface AudioPageScope {
readonly id: number;
dispose: () => void;
}
export interface AudioChannelController {
play: (
source: AudioSource,
options?: AudioPlayOptions,
) => AudioPlaybackHandle;
stop: () => void;
}
export interface GameAudioManager {
readonly voice: AudioChannelController;
readonly bgm: AudioChannelController;
readonly sfx: AudioChannelController;
enterPage: (key?: string) => AudioPageScope;
preload: (
source: AudioSource,
options?: Pick<AudioPlayOptions, "stream">,
) => AudioPreloadHandle;
stopPage: () => void;
stopAll: () => void;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
handleVisibilityChange: (hidden: boolean) => void;
}
export interface AudioEngineClipOptions {
source: readonly string[];
loop: boolean;
volume: number;
rate: number;
stream: boolean;
}
export interface AudioEngineClip {
load: () => Promise<void>;
play: () => Promise<void>;
pause: () => void;
resume: () => Promise<void>;
stop: () => void;
unload: () => void;
configure: (
options: Omit<AudioEngineClipOptions, "source" | "stream">,
) => void;
onEnd: (listener: () => void) => () => void;
}
export interface AudioEngineFactory {
createClip: (options: AudioEngineClipOptions) => AudioEngineClip;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
}
export interface CreateAudioManagerOptions {
engine: AudioEngineFactory;
channelDefaults?: Partial<
Record<
AudioChannel,
Pick<AudioPlayOptions, "loop" | "volume" | "rate" | "stream">
>
>;
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "react";
import type { GameAudioManager } from "./audio.types";
export const GameAudioContext = createContext<GameAudioManager | null>(null);
+483
View File
@@ -0,0 +1,483 @@
import type {
AudioChannel,
AudioChannelController,
AudioEngineClip,
AudioPageScope,
AudioPlaybackHandle,
AudioPlayFailureResult,
AudioPlayOptions,
AudioPlayResult,
AudioPreloadHandle,
AudioPreloadResult,
AudioSource,
CreateAudioManagerOptions,
GameAudioManager,
} from "./audio.types";
const CHANNELS: readonly AudioChannel[] = ["voice", "bgm", "sfx"];
const DEFAULT_OPTIONS: Record<
AudioChannel,
Required<Pick<AudioPlayOptions, "loop" | "volume" | "rate" | "stream">>
> = {
voice: { loop: false, volume: 1, rate: 1, stream: false },
bgm: { loop: true, volume: 0.45, rate: 1, stream: true },
sfx: { loop: false, volume: 0.8, rate: 1, stream: false },
};
interface PageScopeState {
id: number;
key?: string;
active: boolean;
}
interface ChannelSlot {
generation: number;
current?: PlaybackRecord;
}
interface PlaybackRecord {
id: number;
channel: AudioChannel;
generation: number;
scopeId: number;
clip: AudioEngineClip;
state: "loading" | "playing" | "visibility-paused";
cleaned: boolean;
detachEnd: () => void;
resolveStarted: (result: AudioPlayResult) => void;
startedSettled: boolean;
onEnd?: () => void;
onError?: (result: AudioPlayFailureResult) => void;
}
interface PreloadRecord {
id: number;
key: string;
scopeId: number;
clip: AudioEngineClip;
ready: Promise<AudioPreloadResult>;
released: boolean;
claimed: boolean;
}
function normalizeSource(source: AudioSource): readonly string[] {
const values = typeof source === "string" ? [source] : source;
return values.map((value) => value.trim()).filter(Boolean);
}
function clampVolume(volume: number) {
return Math.min(1, Math.max(0, volume));
}
function createPreloadKey(source: readonly string[], stream: boolean) {
return `${stream ? "stream" : "buffer"}:${JSON.stringify(source)}`;
}
function isPlaybackBlocked(error: unknown) {
if (typeof error === "object" && error !== null && "name" in error) {
const name = String(error.name);
if (name === "NotAllowedError") return true;
}
const message = error instanceof Error ? error.message : String(error);
return /notallowed|autoplay|user (?:gesture|interaction)/i.test(message);
}
export class AudioManager implements GameAudioManager {
readonly voice: AudioChannelController;
readonly bgm: AudioChannelController;
readonly sfx: AudioChannelController;
private readonly slots: Record<AudioChannel, ChannelSlot> = {
voice: { generation: 0 },
bgm: { generation: 0 },
sfx: { generation: 0 },
};
private readonly scopes = new Map<number, PageScopeState>();
private readonly preloads = new Set<PreloadRecord>();
private activeScope?: PageScopeState;
private nextScopeId = 1;
private nextRequestId = 1;
private hidden = false;
constructor(private readonly options: CreateAudioManagerOptions) {
this.voice = this.createChannelController("voice");
this.bgm = this.createChannelController("bgm");
this.sfx = this.createChannelController("sfx");
}
enterPage = (key?: string): AudioPageScope => {
this.activeScope?.active && this.disposeScope(this.activeScope.id);
const scope: PageScopeState = {
id: this.nextScopeId++,
key,
active: true,
};
this.scopes.set(scope.id, scope);
this.activeScope = scope;
return {
id: scope.id,
dispose: () => this.disposeScope(scope.id),
};
};
preload = (
source: AudioSource,
options: Pick<AudioPlayOptions, "stream"> = {},
): AudioPreloadHandle => {
const normalizedSource = normalizeSource(source);
const scope = this.activeScope;
if (!scope?.active || normalizedSource.length === 0) {
return {
ready: Promise.resolve({ status: "cancelled" }),
release: () => undefined,
};
}
const stream = options.stream ?? false;
const clip = this.options.engine.createClip({
source: normalizedSource,
loop: false,
volume: 1,
rate: 1,
stream,
});
const record: PreloadRecord = {
id: this.nextRequestId++,
key: createPreloadKey(normalizedSource, stream),
scopeId: scope.id,
clip,
ready: Promise.resolve({ status: "cancelled" }),
released: false,
claimed: false,
};
record.ready = clip.load().then(
(): AudioPreloadResult =>
record.released || !this.isScopeActive(record.scopeId)
? { status: "cancelled" }
: { status: "loaded" },
(error: unknown): AudioPreloadResult => {
if (record.released || !this.isScopeActive(record.scopeId)) {
return { status: "cancelled" };
}
this.releasePreload(record);
return { status: "load-error", error };
},
);
this.preloads.add(record);
return {
ready: record.ready,
release: () => this.releasePreload(record),
};
};
stopPage = () => {
const scope = this.activeScope;
if (scope?.active) this.stopOwnedByScope(scope.id);
};
stopAll = () => {
for (const channel of CHANNELS) this.stopChannel(channel);
for (const preload of [...this.preloads]) this.releasePreload(preload);
};
setMuted = (muted: boolean) => {
this.options.engine.setMuted(muted);
};
setVolume = (volume: number) => {
this.options.engine.setVolume(clampVolume(volume));
};
handleVisibilityChange = (hidden: boolean) => {
if (this.hidden === hidden) return;
this.hidden = hidden;
if (hidden) {
this.stopChannel("voice");
this.stopChannel("sfx");
const bgm = this.slots.bgm.current;
if (!bgm) return;
if (bgm.state === "playing") {
bgm.clip.pause();
bgm.state = "visibility-paused";
} else if (bgm.state === "loading") {
this.stopChannel("bgm");
}
return;
}
const bgm = this.slots.bgm.current;
if (!bgm || bgm.state !== "visibility-paused" || !this.isCurrent(bgm)) {
return;
}
void this.resumeAfterVisibility(bgm);
};
private createChannelController(
channel: AudioChannel,
): AudioChannelController {
return {
play: (source, options) => this.play(channel, source, options),
stop: () => this.stopChannel(channel),
};
}
private play(
channel: AudioChannel,
source: AudioSource,
options: AudioPlayOptions = {},
): AudioPlaybackHandle {
const normalizedSource = normalizeSource(source);
const scope = this.activeScope;
if (!scope?.active || this.hidden || normalizedSource.length === 0) {
return {
started: Promise.resolve({ status: "cancelled" }),
stop: () => undefined,
};
}
const defaults = {
...DEFAULT_OPTIONS[channel],
...this.options.channelDefaults?.[channel],
};
const resolved = {
loop: options.loop ?? defaults.loop ?? false,
volume: clampVolume(options.volume ?? defaults.volume ?? 1),
rate: options.rate ?? defaults.rate ?? 1,
stream: options.stream ?? defaults.stream ?? false,
};
const slot = this.slots[channel];
slot.generation += 1;
if (slot.current)
this.cleanupPlayback(slot.current, true, { status: "cancelled" });
const preload = this.claimPreload(
normalizedSource,
resolved.stream,
scope.id,
);
const clip =
preload?.clip ??
this.options.engine.createClip({
source: normalizedSource,
...resolved,
});
if (preload) {
clip.configure({
loop: resolved.loop,
volume: resolved.volume,
rate: resolved.rate,
});
}
let resolveStarted: (result: AudioPlayResult) => void = () => undefined;
const started = new Promise<AudioPlayResult>((resolve) => {
resolveStarted = resolve;
});
const record: PlaybackRecord = {
id: this.nextRequestId++,
channel,
generation: slot.generation,
scopeId: scope.id,
clip,
state: "loading",
cleaned: false,
detachEnd: () => undefined,
resolveStarted,
startedSettled: false,
onEnd: options.onEnd,
onError: options.onError,
};
record.detachEnd = clip.onEnd(() => this.handleEnd(record));
slot.current = record;
void this.startPlayback(record, preload?.ready);
return {
started,
stop: () => {
if (!this.isCurrent(record)) return;
this.slots[channel].generation += 1;
this.cleanupPlayback(record, true, { status: "cancelled" });
},
};
}
private async startPlayback(
record: PlaybackRecord,
preloaded?: Promise<AudioPreloadResult>,
) {
try {
if (preloaded) {
const preloadResult = await preloaded;
if (preloadResult.status === "load-error") throw preloadResult.error;
if (preloadResult.status === "cancelled") {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
} else {
await record.clip.load();
}
} catch (error) {
this.failPlayback(record, { status: "load-error", error });
return;
}
if (!this.isCurrent(record) || this.hidden) {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
try {
await record.clip.play();
} catch (error) {
this.failPlayback(record, {
status: isPlaybackBlocked(error) ? "blocked" : "play-error",
error,
});
return;
}
if (!this.isCurrent(record) || this.hidden) {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
record.state = "playing";
this.settleStarted(record, { status: "playing" });
}
private async resumeAfterVisibility(record: PlaybackRecord) {
try {
await record.clip.resume();
} catch (error) {
this.failPlayback(record, {
status: isPlaybackBlocked(error) ? "blocked" : "play-error",
error,
});
return;
}
if (!this.hidden && this.isCurrent(record)) {
record.state = "playing";
} else {
this.cleanupPlayback(record, true, { status: "cancelled" });
}
}
private handleEnd(record: PlaybackRecord) {
if (!this.isCurrent(record)) return;
const onEnd = record.onEnd;
this.cleanupPlayback(record, false);
onEnd?.();
}
private failPlayback(record: PlaybackRecord, result: AudioPlayFailureResult) {
if (!this.isCurrent(record)) {
this.cleanupPlayback(record, true, { status: "cancelled" });
return;
}
const onError = record.onError;
this.cleanupPlayback(record, true, result);
onError?.(result);
}
private settleStarted(record: PlaybackRecord, result: AudioPlayResult) {
if (record.startedSettled) return;
record.startedSettled = true;
record.resolveStarted(result);
}
private cleanupPlayback(
record: PlaybackRecord,
stop: boolean,
result?: AudioPlayResult,
) {
if (record.cleaned) return;
record.cleaned = true;
record.detachEnd();
if (stop) record.clip.stop();
record.clip.unload();
const slot = this.slots[record.channel];
if (slot.current === record) slot.current = undefined;
if (result) this.settleStarted(record, result);
}
private stopChannel(channel: AudioChannel) {
const slot = this.slots[channel];
slot.generation += 1;
if (slot.current) {
this.cleanupPlayback(slot.current, true, { status: "cancelled" });
}
}
private disposeScope(scopeId: number) {
const scope = this.scopes.get(scopeId);
if (!scope?.active) return;
scope.active = false;
this.stopOwnedByScope(scopeId);
this.scopes.delete(scopeId);
if (this.activeScope?.id === scopeId) this.activeScope = undefined;
}
private stopOwnedByScope(scopeId: number) {
for (const channel of CHANNELS) {
const record = this.slots[channel].current;
if (record?.scopeId !== scopeId) continue;
this.slots[channel].generation += 1;
this.cleanupPlayback(record, true, { status: "cancelled" });
}
for (const preload of [...this.preloads]) {
if (preload.scopeId === scopeId) this.releasePreload(preload);
}
}
private isScopeActive(scopeId: number) {
return this.scopes.get(scopeId)?.active === true;
}
private isCurrent(record: PlaybackRecord) {
const slot = this.slots[record.channel];
return (
!record.cleaned &&
this.isScopeActive(record.scopeId) &&
slot.generation === record.generation &&
slot.current === record
);
}
private claimPreload(
source: readonly string[],
stream: boolean,
scopeId: number,
) {
const key = createPreloadKey(source, stream);
const preload = [...this.preloads].find(
(candidate) =>
candidate.key === key &&
candidate.scopeId === scopeId &&
!candidate.released &&
!candidate.claimed,
);
if (!preload) return undefined;
preload.claimed = true;
this.preloads.delete(preload);
return preload;
}
private releasePreload(record: PreloadRecord) {
if (record.released || record.claimed) return;
record.released = true;
this.preloads.delete(record);
record.clip.stop();
record.clip.unload();
}
}
export function createAudioManager(options: CreateAudioManagerOptions) {
return new AudioManager(options);
}
+151
View File
@@ -0,0 +1,151 @@
import { Howl, Howler } from "howler";
import type {
AudioEngineClip,
AudioEngineClipOptions,
AudioEngineFactory,
} from "./audio.types";
function toError(value: unknown, fallback: string) {
return value instanceof Error ? value : new Error(String(value || fallback));
}
class HowlerAudioClip implements AudioEngineClip {
private readonly howl: Howl;
private readonly endListeners = new Set<() => void>();
private readonly pendingRejectors = new Set<(error: Error) => void>();
private loadPromise?: Promise<void>;
private soundId?: number;
private loop: boolean;
private disposed = false;
constructor(options: AudioEngineClipOptions) {
this.loop = options.loop;
this.howl = new Howl({
src: [...options.source],
autoplay: false,
html5: options.stream,
loop: options.loop,
preload: false,
rate: options.rate,
volume: options.volume,
});
this.howl.on("end", this.handleEnd);
}
load = () => {
if (this.disposed) return Promise.reject(new Error("Audio clip unloaded"));
if (this.howl.state() === "loaded") return Promise.resolve();
if (this.loadPromise) return this.loadPromise;
this.loadPromise = new Promise<void>((resolve, reject) => {
const rejectPending = (error: Error) => {
cleanup();
reject(error);
};
const handleLoad = () => {
cleanup();
resolve();
};
const handleLoadError = (_id: number, error: unknown) => {
rejectPending(toError(error, "Audio failed to load"));
};
const cleanup = () => {
this.pendingRejectors.delete(rejectPending);
this.howl.off("load", handleLoad);
this.howl.off("loaderror", handleLoadError);
};
this.pendingRejectors.add(rejectPending);
this.howl.once("load", handleLoad);
this.howl.once("loaderror", handleLoadError);
this.howl.load();
});
return this.loadPromise;
};
play = () => this.startSound();
pause = () => {
if (this.soundId !== undefined) this.howl.pause(this.soundId);
};
resume = () => {
if (this.soundId === undefined) {
return Promise.reject(new Error("Audio has not started"));
}
return this.startSound(this.soundId);
};
stop = () => {
if (this.soundId !== undefined) this.howl.stop(this.soundId);
};
unload = () => {
if (this.disposed) return;
this.disposed = true;
const error = new Error("Audio clip unloaded");
for (const reject of [...this.pendingRejectors]) reject(error);
this.pendingRejectors.clear();
this.endListeners.clear();
this.howl.off();
this.howl.unload();
};
configure = (options: Omit<AudioEngineClipOptions, "source" | "stream">) => {
if (this.disposed) return;
this.loop = options.loop;
this.howl.loop(options.loop);
this.howl.volume(options.volume);
this.howl.rate(options.rate);
};
onEnd = (listener: () => void) => {
this.endListeners.add(listener);
return () => this.endListeners.delete(listener);
};
private startSound(soundId?: number) {
if (this.disposed) return Promise.reject(new Error("Audio clip unloaded"));
return new Promise<void>((resolve, reject) => {
let expectedId = soundId;
const rejectPending = (error: Error) => {
cleanup();
reject(error);
};
const handlePlay = (playedId: number) => {
if (expectedId !== undefined && playedId !== expectedId) return;
cleanup();
resolve();
};
const handlePlayError = (playedId: number, error: unknown) => {
if (expectedId !== undefined && playedId !== expectedId) return;
rejectPending(toError(error, "Audio failed to play"));
};
const cleanup = () => {
this.pendingRejectors.delete(rejectPending);
this.howl.off("play", handlePlay);
this.howl.off("playerror", handlePlayError);
};
this.pendingRejectors.add(rejectPending);
this.howl.on("play", handlePlay);
this.howl.on("playerror", handlePlayError);
expectedId = this.howl.play(soundId);
this.soundId = expectedId;
});
}
private handleEnd = (soundId: number) => {
if (this.soundId !== undefined && soundId !== this.soundId) return;
// 循环音频每次回到起点都会触发 end,不应视作播放结束
if (this.loop) return;
for (const listener of this.endListeners) listener();
};
}
export const createHowlerAudioEngine = (): AudioEngineFactory => ({
createClip: (options) => new HowlerAudioClip(options),
setMuted: (muted) => Howler.mute(muted),
setVolume: (volume) => Howler.volume(volume),
});
+21
View File
@@ -0,0 +1,21 @@
export type {
AudioChannel,
AudioChannelController,
AudioEngineClip,
AudioEngineClipOptions,
AudioEngineFactory,
AudioPageScope,
AudioPlaybackHandle,
AudioPlayFailureResult,
AudioPlayOptions,
AudioPlayResult,
AudioPreloadHandle,
AudioPreloadResult,
AudioSource,
CreateAudioManagerOptions,
GameAudioManager,
} from "./audio.types";
export { AudioManager, createAudioManager } from "./audioManager";
export { GameAudioProvider } from "./GameAudioProvider";
export type { GameAudioProviderProps } from "./GameAudioProvider.types";
export { useGameAudio } from "./useGameAudio";
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { GameAudioContext } from "./audioContext";
export function useGameAudio() {
const manager = useContext(GameAudioContext);
if (!manager) {
throw new Error("useGameAudio must be used within GameAudioProvider");
}
return manager;
}
+2
View File
@@ -1,4 +1,6 @@
export * from "./assets"; export * from "./assets";
export * from "./audio";
export * from "./lazyRoute"; export * from "./lazyRoute";
export * from "./motion";
export * from "./tailwind"; export * from "./tailwind";
export * from "./yiban"; export * from "./yiban";
+29
View File
@@ -0,0 +1,29 @@
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { useEffect, useState } from "react";
// GSAP 仅用于时间轴编排、播放控制和 CSS 难以清晰表达的课件动画。
// 普通状态过渡、hover 和简单入场动画优先使用 CSS/Tailwind。
gsap.registerPlugin(useGSAP);
// 项目统一缓动,对齐现有 CSS cubic-bezier(0.16, 1, 0.3, 1)
export const EASE_OUT = "power3.out";
export const EASE_IN_OUT = "power2.inOut";
export { gsap, useGSAP };
// 响应 prefers-reduced-motion,供动画降级
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mql.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return reduced;
}
+17
View File
@@ -38,6 +38,22 @@ function getCategory(page: Page, title: string): Locator {
return page.getByRole("heading", { name: title, exact: true }).locator(".."); return page.getByRole("heading", { name: title, exact: true }).locator("..");
} }
async function expectDialogContentCentered(page: Page): Promise<void> {
const dialogContent = page
.getByRole("dialog")
.locator(":scope > div")
.first();
const box = await dialogContent.boundingBox();
const viewport = page.viewportSize();
if (!box || !viewport) {
throw new Error("Dialog content or viewport is not visible");
}
const contentCenter = box.x + box.width / 2;
expect(Math.abs(contentCenter - viewport.width / 2)).toBeLessThan(2);
}
test.describe("CourseDemo connected flow", () => { test.describe("CourseDemo connected flow", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/#/course-demo"); await page.goto("/#/course-demo");
@@ -58,6 +74,7 @@ test.describe("CourseDemo connected flow", () => {
// First step should appear // First step should appear
await expect(page.locator("text=欢迎进入学生资助政策微课")).toBeVisible(); await expect(page.locator("text=欢迎进入学生资助政策微课")).toBeVisible();
await expect(page.locator("text=1 / 4")).toBeVisible(); await expect(page.locator("text=1 / 4")).toBeVisible();
await expectDialogContentCentered(page);
// Click next // Click next
await page.click("button:has-text('了解任务')"); await page.click("button:has-text('了解任务')");