forked from zhongyidian/yx-react-template
merge: 合并课程模板通用能力
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
155f6269ca
70 files changed
+2840
-177
No files matched your search
@@ -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/course/canvas/`:设计稿画布、缩放、viewport 和课程 Portal 根节点。
|
||||
- `src/course/orientation/`:横竖屏访问控制。
|
||||
- `src/course/interactions/`:由 CLI 复制的受控课程交互内核,不依赖具体页面、Store 或 common 适配器。
|
||||
- `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。
|
||||
- 模块内私有组件:仅本模块使用、其他模块用不到的,放到本模块目录的 `components/` 子目录(没有就创建);被多个模块或组件复用才下沉到 `src/components/common/`(通用基础能力)或 `business/`(业务组合),按组件核心类型区分。该规则约束新代码,存量平铺在页面目录下的模块内组件不强制迁移。
|
||||
- `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。
|
||||
- `src/stores/`:Zustand 状态管理。
|
||||
- `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`。
|
||||
- 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。
|
||||
@@ -61,7 +66,7 @@ bun run lint:fix # 自动修复代码问题
|
||||
|
||||
## 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`:
|
||||
|
||||
- 需要合并外部传入的 `className`。
|
||||
- 存在条件 class。
|
||||
- 条件 class 需要和其他 class 合并。
|
||||
- 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。
|
||||
- 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。
|
||||
|
||||
@@ -90,9 +95,10 @@ bun run lint:fix # 自动修复代码问题
|
||||
|
||||
```tsx
|
||||
<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 类的边界
|
||||
|
||||
「何时提取 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`,新增代码优先使用 `@` 避免过长相对路径。
|
||||
@@ -129,10 +167,14 @@ bun run lint:fix # 自动修复代码问题
|
||||
|
||||
开发时要尊重模板已有能力:
|
||||
|
||||
- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course-runtime/canvas` 的 `CourseCanvas` 组件承担。
|
||||
- 横屏访问提示由 `src/course-runtime/orientation` 的 `OrientationGuard` 组件承担。
|
||||
- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course/canvas` 的 `CourseCanvas` 组件承担。
|
||||
- 横屏访问提示由 `src/course/orientation` 的 `OrientationGuard` 组件承担。
|
||||
- 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。
|
||||
- 移动端调试使用 `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) 增加示例。
|
||||
|
||||
## Claude Code 工作准则
|
||||
|
||||
@@ -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) 中补充对应示例。
|
||||
@@ -152,22 +156,31 @@ http://localhost:5173/#/components
|
||||
src/
|
||||
├── api/ # API 请求相关代码
|
||||
├── assets/ # 项目资源
|
||||
├── components/ # 组件
|
||||
│ ├── business/ # 业务组件
|
||||
│ └── common/ # 通用组件
|
||||
├── 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/ # Zustand 状态管理
|
||||
│ └── modules/ # 状态模块
|
||||
│ ├── app.ts # 应用状态
|
||||
│ └── user.ts # 易班用户状态
|
||||
├── stores/
|
||||
│ ├── modules/ # Zustand store 实现
|
||||
│ └── types/ # Store 类型
|
||||
├── styles/ # 全局样式
|
||||
├── types/ # TypeScript 类型
|
||||
└── utils/ # 工具函数
|
||||
├── vconsole.ts # 调试工具
|
||||
├── 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";
|
||||
|
||||
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>;
|
||||
</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";
|
||||
|
||||
function LocalPanel() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
<div ref={rootRef} className="relative" />;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<Overlay
|
||||
container={rootRef}
|
||||
className="absolute"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
{/* 挂到自定义容器里的弹层 */}
|
||||
</Overlay>;
|
||||
业务弹层
|
||||
</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";
|
||||
|
||||
在页面初始化时启动引导:
|
||||
|
||||
```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",
|
||||
export const lessonSpotlight = defineSpotlight({
|
||||
id: "lesson-intro",
|
||||
steps: [
|
||||
{
|
||||
id: "start",
|
||||
target: { dataGuide: "start-button" },
|
||||
id: "start-button",
|
||||
title: "开始学习",
|
||||
description: "点击这里开始本节课程。",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
在页面中启动并渲染:
|
||||
|
||||
```tsx
|
||||
import { useEffect } from "react";
|
||||
import { Button, GuideSpotlight } from "@/components/common";
|
||||
import { lessonSpotlight } from "./lessonSpotlight";
|
||||
|
||||
export default function LessonPage() {
|
||||
useEffect(() => {
|
||||
lessonSpotlight.start();
|
||||
}, []);
|
||||
|
||||
return <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。
|
||||
|
||||
## 路由懒加载
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"howler": "^2.2.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.12.0",
|
||||
@@ -19,6 +22,7 @@
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/howler": "^2.2.13",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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
@@ -9,14 +9,14 @@
|
||||
},
|
||||
"directories": {
|
||||
"ui": "src/components/ui",
|
||||
"interactions": "src/components/course-interactions",
|
||||
"runtime": "src/course-runtime",
|
||||
"interactions": "src/course/interactions",
|
||||
"runtime": "src/course",
|
||||
"lib": "src/lib"
|
||||
},
|
||||
"aliases": {
|
||||
"ui": "@/components/ui",
|
||||
"interactions": "@/components/course-interactions",
|
||||
"runtime": "@/course-runtime",
|
||||
"interactions": "@/course/interactions",
|
||||
"runtime": "@/course",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"howler": "^2.2.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.12.0",
|
||||
@@ -30,6 +33,7 @@
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/howler": "^2.2.13",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
|
||||
+9
-3
@@ -1,10 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
|
||||
import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { OrientationGuard } from "@/course-runtime/orientation";
|
||||
import {
|
||||
CourseCanvas,
|
||||
registerAppPortalRoot,
|
||||
useCourseCanvas,
|
||||
} from "@/course/canvas";
|
||||
import { OrientationGuard } from "@/course/orientation";
|
||||
import { GameAudioProvider } from "@/utils/audio";
|
||||
import { Navigation } from "./components/business";
|
||||
import { registerAppPortalRoot } from "./components/common/canvasPortal";
|
||||
|
||||
function CourseShell() {
|
||||
const { portalContainer } = useCourseCanvas();
|
||||
@@ -12,6 +16,7 @@ function CourseShell() {
|
||||
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
|
||||
|
||||
return (
|
||||
<GameAudioProvider>
|
||||
<ToastProvider>
|
||||
<Navigation />
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
@@ -19,6 +24,7 @@ function CourseShell() {
|
||||
</main>
|
||||
<ToastViewport container={portalContainer} />
|
||||
</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 {
|
||||
type GuideDialogProps,
|
||||
GuideDialog as RegistryGuideDialog,
|
||||
} from "@/components/course-interactions/guide-dialog";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
|
||||
import { useDialogStore } from "@/stores";
|
||||
import { getAppPortalRoot } from "../canvasPortal";
|
||||
import type { GuideDialogProps } from "./GuideDialog.types";
|
||||
|
||||
export function GuideDialog({
|
||||
children,
|
||||
onFinish,
|
||||
onSkip,
|
||||
...props
|
||||
}: Omit<GuideDialogProps, "actions" | "state" | "viewport"> & {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}) {
|
||||
}: GuideDialogProps) {
|
||||
const {
|
||||
open,
|
||||
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 {
|
||||
GuideDialogProps,
|
||||
GuideDialogRenderState,
|
||||
GuideDialogTheme,
|
||||
} from "@/components/course-interactions/guide-dialog";
|
||||
} from "@/course/interactions/guide-dialog";
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type GuideSpotlightProps,
|
||||
GuideSpotlight as RegistryGuideSpotlight,
|
||||
} from "@/components/course-interactions/guide-spotlight";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
|
||||
import { useSpotlightStore } from "@/stores";
|
||||
import { getAppPortalRoot } from "../canvasPortal";
|
||||
import type { GuideSpotlightProps } from "./GuideSpotlight.types";
|
||||
|
||||
export function GuideSpotlight({
|
||||
children,
|
||||
@@ -14,10 +11,7 @@ export function GuideSpotlight({
|
||||
container = getAppPortalRoot,
|
||||
viewport,
|
||||
...props
|
||||
}: Omit<GuideSpotlightProps, "actions" | "state"> & {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}) {
|
||||
}: GuideSpotlightProps) {
|
||||
const {
|
||||
open,
|
||||
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 {
|
||||
GuideSpotlightProps,
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightTheme,
|
||||
Rect,
|
||||
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;
|
||||
}
|
||||
@@ -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,6 +15,13 @@ export type {
|
||||
ToastViewportProps,
|
||||
} 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 {
|
||||
DefineDialogOptions,
|
||||
GuideDialogController,
|
||||
@@ -31,4 +38,12 @@ export type {
|
||||
SpotlightController,
|
||||
} from "./GuideSpotlight";
|
||||
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
|
||||
export type { ImageButtonProps } from "./ImageButton";
|
||||
export { ImageButton } from "./ImageButton";
|
||||
export { RouteError, RouteFallback } from "./RouteFallback";
|
||||
export type {
|
||||
VideoOverlayProps,
|
||||
VideoPlayerProps,
|
||||
VideoPlayerRef,
|
||||
} from "./Video";
|
||||
export { VideoOverlay, VideoPlayer } from "./Video";
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -5,3 +5,4 @@ export type {
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+15
-4
@@ -128,10 +128,21 @@ export function GuideDialog({
|
||||
}
|
||||
|
||||
const context = getCoordinateContext({ container, viewport });
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||
height: panelRect ? panelRect.height / context.scale : 220,
|
||||
width: panelElement
|
||||
? 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(
|
||||
step.placement ?? "bottom",
|
||||
@@ -239,7 +250,7 @@ export function GuideDialog({
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
styles.panel,
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
)}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
@@ -149,6 +149,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.highlight,
|
||||
.panelContainer,
|
||||
.panel,
|
||||
.missingPanel {
|
||||
animation: none;
|
||||
+15
-4
@@ -312,10 +312,21 @@ export function GuideSpotlight({
|
||||
}
|
||||
|
||||
const nextHighlightRect = getHighlightRect(target, padding, context);
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||
height: panelRect ? panelRect.height / context.scale : 180,
|
||||
width: panelElement
|
||||
? 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(
|
||||
nextHighlightRect,
|
||||
@@ -503,7 +514,7 @@ export function GuideSpotlight({
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
styles.panel,
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCourseCanvas } from "@/course/canvas";
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragDropProviderProps,
|
||||
Draggable,
|
||||
Droppable,
|
||||
} from "@/components/course-interactions/drag-drop";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
} from "@/course/interactions/drag-drop";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type {
|
||||
DragDemoItem,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { getAppPortalRoot } from "@/components/common/canvasPortal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Overlay, type OverlayProps } from "@/components/ui/overlay";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { useSpotlightStore } from "@/stores";
|
||||
import { DragDropDemo } from "./DragDropDemo";
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
GuideDialogRenderState,
|
||||
GuideSpotlightRenderState,
|
||||
} from "@/components/common";
|
||||
|
||||
export type CourseResourceCategory = "policy" | "practice" | "support";
|
||||
|
||||
export interface CourseResource {
|
||||
@@ -20,3 +25,11 @@ export interface CourseFlowProgress {
|
||||
completed: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CourseDemoGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
|
||||
export interface CourseDemoGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { GuideDialogRenderState } from "@/components/common";
|
||||
|
||||
interface CourseDemoGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
import type { CourseDemoGuideDialogPanelProps } from "./CourseDemo.types";
|
||||
|
||||
function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { GuideSpotlightRenderState } from "@/components/common";
|
||||
|
||||
interface CourseDemoGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { CourseDemoGuideSpotlightPanelProps } from "./CourseDemo.types";
|
||||
|
||||
function GuideActions({
|
||||
spotlight,
|
||||
@@ -31,7 +28,10 @@ function GuideActions({
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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 {
|
||||
DragDropProvider,
|
||||
type DragDropProviderProps,
|
||||
Draggable,
|
||||
Droppable,
|
||||
} from "@/components/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";
|
||||
} from "@/course/interactions/drag-drop";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { getUserStorage, setUserStorage } from "@/utils/yiban";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
>
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "react";
|
||||
import type { GameAudioManager } from "./audio.types";
|
||||
|
||||
export const GameAudioContext = createContext<GameAudioManager | null>(null);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./assets";
|
||||
export * from "./audio";
|
||||
export * from "./lazyRoute";
|
||||
export * from "./motion";
|
||||
export * from "./tailwind";
|
||||
export * from "./yiban";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -38,6 +38,22 @@ function getCategory(page: Page, title: string): 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.beforeEach(async ({ page }) => {
|
||||
await page.goto("/#/course-demo");
|
||||
@@ -58,6 +74,7 @@ test.describe("CourseDemo connected flow", () => {
|
||||
// First step should appear
|
||||
await expect(page.locator("text=欢迎进入学生资助政策微课")).toBeVisible();
|
||||
await expect(page.locator("text=1 / 4")).toBeVisible();
|
||||
await expectDialogContentCentered(page);
|
||||
|
||||
// Click next
|
||||
await page.click("button:has-text('了解任务')");
|
||||
|
||||
Reference in new issue
Block a user