diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..16e8e66 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/README.md b/README.md index 5dab328..b326969 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,86 @@ -# 易班 H5 开发模板 +# 食品添加剂侦探课程 -一个基于 React 19、TypeScript、Vite、React Router、Tailwind CSS 和 Zustand 的易班平台 H5 开发模板。 +「零食侦探事务所」主题的食品添加剂网络课程。学生以实习侦探身份,围绕一段关于零食添加剂的网络舆论展开探案式学习,在「接收线索 → 开展调查 → 获取证据 → 作出判定」的流程中,逐步建立对食品添加剂的科学认知。 -## 特性 +课程面向易班 H5 平台交付,按 1920×1080 设计稿开发,整体等比缩放适配 PC 与移动端横屏。 + +## 课程概要 + +### 学习路径 + +学生首次学习沿剧情主线推进,完成每个阶段的调查任务即可积累经验、提升侦探等级: + +```text +实习侦探(序幕) + ↓ +助理侦探(第一幕) + ↓ +初级侦探(第二幕) + ↓ +中级侦探(第三幕) + ↓ +高级侦探(第四幕) + ↓ +食安神探(终章 · 零食风暴) +``` + +### 四条调查线 + +序幕从前情舆论视频切入,学生从求助信中梳理出四条调查线索,分别对应四幕核心案件: + +| 案件 | 调查线 | 待核验说法 | 核心问题 | +| --- | --- | --- | --- | +| 🌈 色彩迷局 | 着色剂身份线 / 来源辨析线 | 「颜色这么鲜艳,色素肯定没少加」 | 食品里的「色素」是什么?天然与人工合成能直接代表安全吗? | +| 🍊 味觉密码 | 香精与风味线 | 「水果味都是香精调出来的」 | 食品的香气从哪里来?香精的使用是否有据可依? | +| 🍮 Q 弹真相 | 增稠剂线 | 「果冻 Q 弹是因为加了很多胶」 | 「胶」是什么?增稠剂在食品中起什么作用? | +| ⏳ 保鲜疑云 | 防腐剂线 | 「面包放得久是因为防腐剂多」 | 防腐剂为什么被使用?如何判断其安全性? | + +每条调查线统一遵循五步调查轨迹:**待核验说法 → 调查问题 → 探查方式 → 关键证据 → 调查判定**。 + +### 核心教学要点 + +- **着色剂**:赋予或改善食品色泽的一类食品添加剂;食品有颜色不等于额外加入了着色剂,颜色鲜艳也不代表用量多。 +- **身份核验**:能染色 ≠ 可作为食品着色剂。苏丹红是工业染料、非法添加物,不属于食品着色剂;胭脂红、栀子黄等才具有食品着色剂身份。 +- **天然 vs 人工合成**:两者首先反映的是来源与获得方式不同,不能直接等同于「安全」与「有害」;无论哪种来源,都要结合具体物质、食品类别及相应标准判断。 +- **铝色淀**:把水溶性着色剂转变为水不溶性着色形态,满足彩色糖衣、蛋糕装饰等不掉色、不迁移的加工需要。 +- **国标意识**:GB 2760-2024《食品安全国家标准 食品添加剂使用标准》对每种允许使用的添加剂的适用食品类别、最大使用量、使用范围均有严格规定。 + +### 案件档案与学习资源 + +课程把调查过程中产生和解锁的内容沉淀为「零食侦探事务所」资源清单,首次学习沿剧情推进,完成后可脱离剧情顺序回看复习: + +| 分类 | 功能定位 | +| --- | --- | +| 01 物证材料 | 案件调查的原始物证,调查的事实起点 | +| 02 收件箱 | 市民求助、补充线索及后续来信 | +| 03 调查资料 | 科普动画、微课、案例短片、旧案资料,承担主要知识讲解 | +| 04 调查成果 | 学生实际完成过的调查操作及判断记录 | +| 05 添加剂档案 | 持续积累的食品添加剂知识库,学一个解锁一个 | +| 06 国标档案 | 培养「遇到添加剂问题查标准」的意识,提供标准依据 | +| 07 案件线索板 | 剧情导航与学习进度导航,首页常驻入口 | +| 08 调查报告 | 对一幕中多条调查线的最终汇总与消费建议 | +| 09 侦探等级 | 学习者身份与整体课程成长记录 | + +## 技术栈 - React 19 + TypeScript - Vite 8 开发与构建 -- React Router 7 路由管理(支持懒加载) +- React Router 7 路由管理(Hash 路由 + 懒加载) - Tailwind CSS 4 原子化样式 - Zustand 轻量状态管理 +- GSAP + @gsap/react 动效 +- dnd-kit 拖拽交互 - Biome 代码格式化与检查 - Husky + lint-staged 提交前检查 -- 1920x1080 整体缩放适配 +- 1920×1080 整体缩放适配 - 横屏访问提示 -- vconsole 移动端调试工具(仅开发环境) -- 易班 videoajax.js 封装 -- 易班用户信息状态管理 -- `@` 指向 `src` 的路径别名 -- 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码 -- 组件分层:Registry copied source、模板适配器、业务组件、页面组件 - -## 使用模板 - -### 通过 GitHub 页面创建 - -1. 打开模板仓库页面 -2. 点击 **Use this template** -3. 创建自己的新仓库 -4. 克隆新仓库并安装依赖 - -```bash -git clone https://github.com/your-name/your-app.git -cd your-app -bun install -bun dev -``` - -### 通过 GitHub CLI 创建 - -```bash -gh repo create my-react-app --template your-name/yx-react-template --public -cd my-react-app -bun install -bun dev -``` - -创建项目后,建议修改 [package.json](package.json) 中的 `name` 字段,以及 [src/app.config.ts](src/app.config.ts) 中的页面标题、描述和图标配置。 - -## 应用配置 - -页面标签页标题、描述、图标和 viewport 配置统一维护在 [src/app.config.ts](src/app.config.ts): - -```ts -export const appConfig = { - title: "React Vite App", - description: "易班 H5 课程模板", - favicon: "./vite.svg", - design: { - width: 1920, - height: 1080, - }, - scale: { - max: 1, - }, - viewport: { - width: "device-width", - initialScale: 1.0, - minimumScale: 1.0, - maximumScale: 1.0, - }, -}; -``` - -修改该配置后需要重启开发服务。 +- vconsole 移动端调试(仅开发环境) +- 易班 videoajax.js 封装与用户信息状态管理 ## 开发命令 ```bash +bun install # 安装依赖 bun dev # 启动开发服务器 bun run build # 类型检查并构建生产版本 bun run preview # 预览生产构建 @@ -84,367 +88,97 @@ bun run lint # 代码检查 bun run lint:fix # 自动修复代码问题 ``` -## 开发规范 +修改代码后至少运行 `bun run lint`;涉及路由、构建配置、页面结构或类型系统的改动,同时运行 `bun run build`。 -完整的 Claude Code/自动化开发规范见 [CLAUDE.md](CLAUDE.md)。日常开发遵守以下约定: +## 应用配置 -- 使用 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/)。 -- 短的静态 Tailwind class 可直接写在 JSX 中;只有需要条件合并、外部 `className`、冲突合并或组合 CSS Module 时才使用 `cn`。 -- Tailwind class 过长、重复、条件组合复杂,或直接实现过于冗余时,再提取为局部常量、同目录文件、CSS Module 或通用组件。 -- 新增页面沿用 [src/router/index.tsx](src/router/index.tsx) 中的 `lazyRoute` 和 `RouteFallback` 模式,导入优先使用 `@` 指向 `src` 的路径别名。 -- 新增通用组件时,按需维护 [src/components/common/index.ts](src/components/common/index.ts),并考虑在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 补充示例。 - -## 课程交互源码 - -模板中的基础组件和课程交互不是外部 React 运行时包,而是由 `@yuxiang/cli` 复制并提交到当前项目: - -```text -src/components/ui/ # Button、Portal、Overlay、Toast -src/components/course-interactions/ # GuideDialog、GuideSpotlight、DragDrop -src/course-runtime/ # 固定课程画布运行时 -src/lib/cn.ts # 本地 class 合并工具 -``` - -项目根目录的 [components.json](components.json) 配置 copied source 的目录与 alias。需要新增能力时运行: - -```bash -bunx @yuxiang/cli add -``` - -CLI 默认不会覆盖不同内容的本地文件。安装后请把源码作为模板代码维护,不要重新添加 `@yuxiang/react` 或 `@yuxiang/core` 依赖。 - -## 真实课程案例 - -本地打开以下路由可体验连贯的课程流程: - -```text -http://localhost:5173/#/course-demo -``` - -案例包含课程导入与分支、页面 Spotlight、课程资源分类拖拽、错误/正确 Toast、完成 Overlay,以及易班或 session fallback 的进度保存与刷新恢复。 - -## 组件开发 Smoke 页 - -本地开发时可以打开组件示例页查看和测试基础组件: - -```text -http://localhost:5173/#/components -``` - -该路由仅用于开发时检查 copied source 的基础状态,不作为产品级组件展示墙。真实组合用法以 `#/course-demo` 为准。 - -当前 smoke 页包含: - -- `Button`: variant 和 size 示例 -- `Toast`: info / success / warning / error 轻提示示例 -- `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例 -- `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例 -- `SpotlightGuide`: 课程引导和高亮目标区域示例 -- `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例 - -后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。 +页面标题、描述、图标、课程标识和 viewport 统一维护在 [src/app.config.ts](src/app.config.ts)。修改后需重启开发服务。 ## 目录结构 ```text src/ -├── api/ # API 请求相关代码 -├── assets/ # 项目资源 -├── components/ # 组件 -│ ├── business/ # 业务组件 -│ └── common/ # 通用组件 -├── pages/ # 页面组件 -├── router/ # 路由配置 -├── stores/ # Zustand 状态管理 -│ └── modules/ # 状态模块 -│ ├── app.ts # 应用状态 -│ └── user.ts # 易班用户状态 -├── styles/ # 全局样式 -├── types/ # TypeScript 类型 -└── utils/ # 工具函数 - ├── vconsole.ts # 调试工具 - └── yiban.ts # 易班 API 封装 +├── api/ # API 请求封装 +├── assets/ # 静态资源 +│ └── images/ # 课程切图(按页面分子目录) +├── components/ +│ ├── business/ # 业务组合组件 +│ ├── common/ # 通用组件(Button、Overlay、Portal、Toast、ImageButton 等) +│ ├── course-interactions/ # 课程交互(GuideDialog、GuideSpotlight、DragDrop) +│ └── ui/ # 基础 UI 组件 +├── course-runtime/ # 固定课程画布运行时(缩放、横屏守护) +├── lib/cn.ts # class 合并工具 +├── pages/ # 页面组件 +├── router/ # 路由配置 +├── stores/ # Zustand 状态管理 +├── styles/ # 全局样式 +├── types/ # 跨模块共享类型 +└── utils/ # 工具函数(assets、motion、yiban、lazyRoute 等) ``` -## 易班开发说明 +完整开发规范见 [CLAUDE.md](CLAUDE.md)。 -模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。 +## 课程交互能力 -### videoajax.js 使用 +项目内置一套课程交互能力,用于支撑探案式学习中的对话、引导、拖拽、弹层等场景。这些源码直接维护在当前仓库,可按需修改。 -模板已集成易班 videoajax.js,提供以下功能: +### 引导聚光灯 SpotlightGuide -- [public/videoajax/jquery-3.7.1.min.js](public/videoajax/jquery-3.7.1.min.js) -- [public/videoajax/videoajax.js](public/videoajax/videoajax.js) +适合课程首次学习时突出关键操作区域。在元素上标注 `data-guide`,在页面初始化时启动引导: -这些文件需要和构建后的页面同源、同相对路径部署。若易班平台提供了新的 `videoajax.js`,请同步确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录相关能力。 +```tsx +import { useEffect } from "react"; +import { GuideSpotlight } from "@/components/common"; +import { useSpotlightStore } from "@/stores"; -### 易班环境判断 +export default function LessonPage() { + useEffect(() => { + useSpotlightStore.getState().startSpotlight({ + courseId: "color-mystery", + spotlightId: "intro", + steps: [ + { + id: "start", + target: { dataGuide: "start-button" }, + title: "开始调查", + description: "点击这里开始第一幕调查。", + placement: "bottom", + allowTargetClick: true, + }, + ], + }); + }, []); -模板启动时会从 URL 中读取: - -- `courseId` / `courseid` -- `uid` - -生产环境中,只要存在以上任意参数,就会认为当前处于易班环境。 - -本地开发环境中,即使 URL 带有 `courseId` / `uid`,也默认使用 `sessionStorage`,避免误请求易班接口: - -```text -http://localhost:5173/?courseId=123&uid=456 -``` - -如果本地确实需要强制走易班接口,可追加: - -```text -http://localhost:5173/?courseId=123&uid=456&yiban=1 -``` - -模板已集成以下易班能力: - -#### 1. 存取用户数据 - -```ts -import { setUserStorage, getUserStorage } from '@/utils/yiban'; - -// 保存数据(自动根据易班连接状态选择存储方式) -await setUserStorage(lessonId, key, value); - -// 读取数据 -const result = await getUserStorage(lessonId, key); -if (result.status === 'success') { - console.log(result.content); -} - -// 强制指定存储方式 -await setUserStorage(lessonId, key, value, { backend: 'session' }); -``` - -#### 2. 课程进度管理 - -```ts -import { reportCourseProgress, getCourseProgress } from '@/utils/yiban'; - -// 上报课程进度(自动读取 store 中的 courseId 和连接状态) -await reportCourseProgress(order); - -// 获取课程进度 -const progress = await getCourseProgress(order); -if (progress) { - console.log('当前进度:', progress.data.process); + return ( + <> + + + + ); } ``` -#### 3. 播放断点管理 +- `target` 支持 CSS selector 或 `{ dataGuide: "xxx" }`。 +- `placement`:`top` / `bottom` / `left` / `right` / `auto`。 +- `allowTargetClick`:让高亮区域下方的真实元素可被点击,但不会自动切换步骤,需在业务点击逻辑中手动调用 `nextStep` / `finishSpotlight` / `closeSpotlight`。 +- 同一 `courseId + spotlightId` 默认只显示一次(完成或跳过即标记已完成),可用 `resetSpotlight(courseId, spotlightId)` 重新显示。 +- 组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统 `prefers-reduced-motion`。 -```ts -import { saveBreakPoint, getBreakPoint } from '@/utils/yiban'; +### 引导对话 GuideDialog -// 保存播放断点 -await saveBreakPoint({ - userId: '123456', - courseId: '789', - lessonId: '001', - breakPoint: 120, // 秒 -}); - -// 获取播放断点 -const breakpoint = await getBreakPoint({ - userId: '123456', - courseId: '789', - lessonId: '001', -}); -``` - -#### 4. 用户答题记录 - -```ts -import { saveUserAnswer } from '@/utils/yiban'; - -// 保存答题记录 -await saveUserAnswer({ - userId: '123456', - lessonId: '001', - courseId: '789', - questionOrder: 1, - answer: 'A', -}); -``` - -#### 5. 用户状态管理 - -```ts -import { useUserStore } from '@/stores'; - -function MyComponent() { - const { userId, userName, setUserInfo, setYibanConnectionStatus } = useUserStore(); - - // 设置易班连接状态 - setYibanConnectionStatus('connected'); - - // 设置用户信息 - setUserInfo({ - userId: '123456', - userName: '张三', - userAvatar: 'https://...' - }); -} -``` - -### 移动端调试 - -开发环境自动启用 vconsole,可在移动端查看: -- Console 日志 -- Network 请求 -- Element 结构 -- Storage 存储 - -### 课程画布适配 - -模板按 `1920 x 1080` 设计稿开发,由 `src/course-runtime/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。 - -当前默认缩放策略: - -- PC 端:按可视区域等比缩放并居中显示 -- 大屏:`maxScale` 默认为 `1`,不会超过设计稿原始尺寸,周围留白 -- 移动端:优先横屏访问,竖屏时由 `OrientationGuard` 显示横屏提示 -- 视口:缩放逻辑优先读取 `visualViewport`,移动端地址栏显示/隐藏时更稳定 -- 页面内容:按 `1920 x 1080` 设计稿进行开发,不使用 rem 适配 - -本地运行 `bun dev` 后,可以访问标准课程页示例: - -```text -http://localhost:5173/#/course-demo -``` - -`CourseDemo` 用于展示标准课程页面结构、易班 session fallback、Toast、Overlay 和 SpotlightGuide 的基础接入方式。 - -## 弹层基础组件 - -模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等弹层内容。 - -`Overlay` 已经内置 `Portal`,默认会挂载到 App 缩放画布内部,适合课程弹层跟随 `1920 x 1080` 画布一起缩放,并支持直接放入课程自己的切图和内容: +适合在剧情节点弹出侦探助手对话、问题选择等。通过 `defineDialog` 定义、`GuideDialog` 渲染: ```tsx -import { Overlay } from "@/components/common"; +import { defineDialog, GuideDialog } from "@/components/common"; - setOpen(false)} - backgroundImage="url(./images/dialog-bg.png)" - backgroundClassName="bg-cover bg-center" - contentClassName="flex items-center justify-center" -> - 课程提示 -; +const dialog = defineDialog({ /* steps, characters, ... */ }); + +dialog.start(); ``` -说明: +### 拖拽交互 DragDrop -- `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` 三种基础背景。 - -### Portal 挂载位置 - -`Portal` 默认挂载到 `document.body`,`Overlay` 默认挂载到 App 缩放画布内部。如果 `Portal` 也需要跟随模板的 `1920 x 1080` 课程画布一起缩放,可以使用 `placement="app"`: - -```tsx - setOpen(false)}> - {/* 和课程画布一起缩放的弹层 */} - -``` - -如果需要挂载到业务自己的容器,可以传入 `container`。`container` 支持 DOM 元素、React ref 或返回元素的函数: - -```tsx -import { useRef } from "react"; -import { Overlay } from "@/components/common"; - -const rootRef = useRef(null); - -
; - - setOpen(false)} -> - {/* 挂到自定义容器里的弹层 */} -; -``` - -挂载优先级:`container` > `placement` > 组件默认值。`Portal` 默认 `placement="body"`,`Overlay` 默认 `placement="app"`。如只需要改变渲染位置,也可以单独使用 `Portal`。 - -## Toast 轻提示 - -模板内置 `ToastProvider`、`ToastViewport` 和 `useToast`,用于保存成功、提交失败、网络异常等轻量反馈。 - -`App` 已默认接入 `ToastProvider` 和 `ToastViewport`,页面组件中可以直接使用: - -```tsx -import { useToast } from "@/components/common"; - -const toast = useToast(); - -toast.success("保存成功"); -toast.error("网络异常,请稍后重试"); -``` - -支持: - -- `toast.info(message)` -- `toast.success(message)` -- `toast.warning(message)` -- `toast.error(message)` -- `toast.show({ message, type, title, duration, className, data })` - -如果课程视觉风格不同,可以自定义 `ToastViewport`: - -```tsx - -``` - -也可以完全接管单条 Toast 渲染: - -```tsx - ( -
- {toast.message} - -
- )} -/> -``` - -本地运行 `bun dev` 后,可以访问 `/#/components` 查看 Toast 示例。 - -## 拖拽交互 - -模板引入 dnd-kit 作为拖拽基础能力,适合网络课程中常见的拖物品到目标区、分类、匹配、排序等交互。 - -已封装基础组件: - -- `DragDropProvider`: 拖拽上下文,默认启用 Pointer / Touch / Keyboard sensors。 -- `Draggable`: 可拖拽元素。 -- `Droppable`: 可投放区域。 - -基础示例: +基于 dnd-kit,适合证据分类、调查轨迹排序、选项匹配等交互: ```tsx import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop"; @@ -452,166 +186,127 @@ import { useCourseCanvas } from "@/course-runtime/canvas"; const { scale } = useCourseCanvas(); - console.log(event.active.id, event.over?.id)} + console.log(event.active.id, event.over?.id)}> + 柠檬黄 + 天然来源 + +``` + +由于画布整体使用 `transform: scale(...)` 缩放,拖拽内容在画布内时建议给 `DragDropProvider` 传入当前 `scale`,避免拖拽元素与手指/鼠标移动不同步。 + +### 动效 GSAP + +项目通过 [src/utils/motion.ts](src/utils/motion.ts) 统一封装 GSAP 入口:`gsap` / `useGSAP` 已注册导出,提供统一缓动常量 `EASE_OUT` / `EASE_IN_OUT`,以及 `useReducedMotion()` 响应 `prefers-reduced-motion` 做动画降级。后续课程页面需要 ScrollTrigger 等插件时,在该文件按需 `registerPlugin`。 + +```ts +import { gsap, useGSAP, EASE_OUT, useReducedMotion } from "@/utils"; +``` + +### 弹层 Overlay / Portal + +`Overlay` 已内置 `Portal`,默认挂载到 App 缩放画布内部,适合课程弹窗跟随画布缩放: + +```tsx +import { Overlay } from "@/components/common"; + + setOpen(false)} + backgroundImage="url(./images/dialog-bg.png)" + backgroundClassName="bg-cover bg-center" > - 苹果 - 水果区域 -; + {/* 课程弹层内容 */} + ``` -由于模板整体使用 `transform: scale(...)` 缩放课程画布,拖拽内容在画布内时建议给 `DragDropProvider` 传入当前 `scale`,避免拖拽元素和鼠标/手指移动不同步。 +`Portal` 默认挂载到 `document.body`,`Overlay` 默认 `placement="app"`(跟随画布缩放);也可用 `placement="app"` 或 `container` 自定义挂载位置。挂载优先级:`container` > `placement` > 组件默认值。 -`DragDropProvider` 默认会根据 `scale` 修正拖拽启动距离和拖拽位移,并使用 `pointerWithin` 优先、`closestCenter` 兜底的碰撞检测策略,让“拖到目标区域”类课程交互更符合手指/鼠标位置直觉。 +### Toast 轻提示 -本地运行 `bun dev` 后,可以访问 `/#/components` 查看拖拽示例。 - -> 当前基础封装优先覆盖多端拖拽和投放区域判断。排序、分类、拼图等更上层课程交互可以继续基于这些组件封装。 - -## 引导聚光灯 - -模板内置 `SpotlightGuide` 引导组件,适合课程首次学习时突出关键操作区域。 - -### 基础使用 - -在页面元素上添加 `data-guide`: +`App` 已默认接入 `ToastProvider` 和 `ToastViewport`,页面中可直接使用: ```tsx - +import { useToast } from "@/components/common"; + +const toast = useToast(); +toast.success("证据已收入档案"); +toast.error("选择有误,请重新核验"); ``` -在页面初始化时启动引导: +支持 `info` / `success` / `warning` / `error` / `show`。 -```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: [ - { - id: "start", - target: { dataGuide: "start-button" }, - title: "开始学习", - description: "点击这里开始本节课程。", - placement: "bottom", - }, - ], - }); - }, []); +项目面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。 - return ; -} +### videoajax.js + +已集成易班 [public/videoajax/jquery-3.7.1.min.js](public/videoajax/jquery-3.7.1.min.js) 与 [public/videoajax/videoajax.js](public/videoajax/videoajax.js),需与构建产物同源、同相对路径部署。若易班平台提供新版本,确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录能力。 + +### 环境判断 + +启动时从 URL 读取 `courseId` / `courseid`、`uid`。生产环境中只要存在以上任意参数即视为易班环境。 + +本地开发即使 URL 带这些参数也默认使用 `sessionStorage`,避免误请求易班接口: + +```text +http://localhost:5173/?courseId=123&uid=456 ``` -### 持久化说明 +本地确需强制走易班接口时追加 `yiban=1`: -引导状态接入 Zustand,并通过 Zustand `persist` 只持久化已完成记录: +```text +http://localhost:5173/?courseId=123&uid=456&yiban=1 +``` + +### 易班能力 ```ts -completedGuideMap: { - "lesson-1:intro": true, -} +import { + setUserStorage, + getUserStorage, + reportCourseProgress, + getCourseProgress, + saveBreakPoint, + getBreakPoint, + saveUserAnswer, +} from "@/utils/yiban"; +import { useUserStore } from "@/stores"; ``` -默认同一个 `courseId + guideId` 只显示一次。点击“完成”或“跳过”都会标记为已完成,后续不会自动显示。 +- **存取用户数据**:`setUserStorage(lessonId, key, value)` / `getUserStorage(lessonId, key)`,自动根据易班连接状态选择存储方式。 +- **课程进度**:`reportCourseProgress(order)` / `getCourseProgress(order)`。 +- **播放断点**:`saveBreakPoint({ userId, courseId, lessonId, breakPoint })` / `getBreakPoint(...)`。 +- **答题记录**:`saveUserAnswer({ userId, lessonId, courseId, questionOrder, answer })`。 +- **用户状态**:`useUserStore()` 提供 `userId`、`userName`、`setUserInfo`、`setYibanConnectionStatus` 等。 -如需重新显示: +### 移动端调试 -```ts -useGuideStore.getState().resetGuide("lesson-1", "intro"); -``` +开发环境自动启用 vconsole,可在移动端查看 Console、Network、Element、Storage。 -### Step 配置 +## 课程画布适配 -```ts -{ - id: "start", - target: { dataGuide: "start-button" }, - title: "开始学习", - description: "点击这里开始本节课程。", - placement: "bottom", - allowTargetClick: true, -} -``` +按 1920×1080 设计稿开发,由 [src/course-runtime/canvas](src/course-runtime/canvas) 的 `CourseCanvas` 组件承担整体缩放,默认 `width=1920`、`height=1080`、`maxScale=1`。 -支持: +- PC 端:按可视区域等比缩放并居中。 +- 大屏:`maxScale` 默认为 1,不超过设计稿原始尺寸,周围留白。 +- 移动端:优先横屏访问,竖屏时由 `OrientationGuard` 显示横屏提示。 +- 视口优先读取 `visualViewport`,移动端地址栏显示/隐藏时更稳定。 +- 页面内容按设计稿像素开发,不使用 rem 适配。 -- `target`: CSS selector 或 `{ dataGuide: "xxx" }` -- `placement`: `top` / `bottom` / `left` / `right` / `auto` -- `allowTargetClick`: 是否允许点击高亮目标 -- `padding` / `radius`: 调整高亮区域 -- `panelClassName`: 针对单步自定义提示框样式 +## 路由 -> 建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。 - -### 高级交互说明 - -`allowTargetClick` 只负责让高亮区域下方的真实元素可以被点击,不会自动切换引导步骤。 - -如果目标元素点击后需要进入下一步,需要在业务点击逻辑中手动调用 `nextStep`、`finishGuide` 或 `closeGuide`: - -```tsx -const { nextStep } = useGuideStore(); - - -``` - -### 样式与层级 - -`SpotlightGuide` 默认通过 Portal 挂载到 `document.body`,可以正确适配整体缩放后的坐标。 - -如遇到课程弹窗、横屏提示等全局层级冲突,可以通过 `theme.zIndex` 调整: - -```tsx - -``` - -开发环境中,如果目标元素不存在,页面会显示开发提示面板,方便检查 `data-guide` 或 selector;生产环境默认会自动跳过找不到目标的步骤。 - -组件内置了遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。 - -## 路由懒加载 - -模板内置了 `lazyRoute` 工具函数,新页面可以这样配置: +路由集中在 [src/router/index.tsx](src/router/index.tsx),使用 Hash 路由 + `lazyRoute` 懒加载,新增页面遵循该模式并配 `HydrateFallback: RouteFallback`: ```tsx { - path: "about", - lazy: lazyRoute(() => import("../pages/About")), + path: "about", + lazy: lazyRoute(() => import("../pages/About")), + HydrateFallback: RouteFallback, } ``` -页面组件使用默认导出: - -```tsx -export default function About() { - return
About
; -} -``` - -## 发布为 GitHub 模板仓库 - -如果你要把这个项目作为模板发布: - -1. 推送到 GitHub -2. 进入仓库 **Settings** -3. 勾选 **Template repository** - -之后其他人就可以通过 **Use this template** 使用它。 +页面组件使用默认导出。 ## License diff --git a/bun.lock b/bun.lock index 8a649a1..ea3feda 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,9 @@ "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", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.12.0", @@ -68,6 +70,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=="], @@ -216,6 +220,8 @@ "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=="], + "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=="], diff --git a/docs/添加剂课程+制作文档(to制作人员).pdf b/docs/添加剂课程+制作文档(to制作人员).pdf new file mode 100644 index 0000000..507f0ad Binary files /dev/null and b/docs/添加剂课程+制作文档(to制作人员).pdf differ diff --git a/package.json b/package.json index fd5538a..3d9071a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "yx-react-template", + "name": "food-add-edu", "private": true, "version": "0.0.0", "type": "module", @@ -18,7 +18,9 @@ "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", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.12.0", diff --git a/public/video/Pt1_zhuosejikepudonghua.mp4 b/public/video/Pt1_zhuosejikepudonghua.mp4 new file mode 100644 index 0000000..402d438 Binary files /dev/null and b/public/video/Pt1_zhuosejikepudonghua.mp4 differ diff --git a/public/video/demo.mp4 b/public/video/demo.mp4 new file mode 100644 index 0000000..402d438 Binary files /dev/null and b/public/video/demo.mp4 differ diff --git a/src/App.tsx b/src/App.tsx index a62d9ce..a10f7ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,6 @@ 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 { Navigation } from "./components/business"; import { registerAppPortalRoot } from "./components/common/canvasPortal"; function CourseShell() { @@ -13,10 +12,7 @@ function CourseShell() { return ( - -
- -
+
); diff --git a/src/app.config.ts b/src/app.config.ts index 5672539..5e8998b 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -6,23 +6,23 @@ const env = (import.meta as ImportMeta & { env?: Record }).env; export const appConfig = { /** 应用标题 */ - title: "React Vite App", + title: "食品添加剂侦探课程", /** 网站描述 */ - description: "易班 H5 课程模板", + description: "零食侦探事务所 · 食品添加剂网络课程", /** Favicon 路径(相对于 public 目录) */ favicon: "./vite.svg", /** 课程配置 */ course: { - id: env?.VITE_COURSE_ID ?? "course-demo", - name: env?.VITE_COURSE_NAME ?? "标准课程页示例", + id: env?.VITE_COURSE_ID ?? "food-add-edu", + name: env?.VITE_COURSE_NAME ?? "食品添加剂侦探课程", }, /** 本地存储配置 */ storage: { - prefix: "yiban-course-template", + prefix: "food-add-edu", }, /** 视口配置 */ diff --git a/src/assets/images/components/button/按钮-离开.png b/src/assets/images/components/button/按钮-离开.png new file mode 100644 index 0000000..e93d44a Binary files /dev/null and b/src/assets/images/components/button/按钮-离开.png differ diff --git a/src/assets/images/components/button/按钮-返回.png b/src/assets/images/components/button/按钮-返回.png new file mode 100644 index 0000000..f706d33 Binary files /dev/null and b/src/assets/images/components/button/按钮-返回.png differ diff --git a/src/assets/images/home/底图-首页.png b/src/assets/images/home/底图-首页.png new file mode 100644 index 0000000..ecbf05a Binary files /dev/null and b/src/assets/images/home/底图-首页.png differ diff --git a/src/assets/images/home/按钮-课程简介.png b/src/assets/images/home/按钮-课程简介.png new file mode 100644 index 0000000..85fab66 Binary files /dev/null and b/src/assets/images/home/按钮-课程简介.png differ diff --git a/src/assets/images/home/按钮-进入课程.png b/src/assets/images/home/按钮-进入课程.png new file mode 100644 index 0000000..975d35a Binary files /dev/null and b/src/assets/images/home/按钮-进入课程.png differ diff --git a/src/assets/images/main-scene/backgrounds/功能页背景.png b/src/assets/images/main-scene/backgrounds/功能页背景.png new file mode 100644 index 0000000..de12dda Binary files /dev/null and b/src/assets/images/main-scene/backgrounds/功能页背景.png differ diff --git a/src/assets/images/main-scene/backgrounds/背景.png b/src/assets/images/main-scene/backgrounds/背景.png new file mode 100644 index 0000000..9aec920 Binary files /dev/null and b/src/assets/images/main-scene/backgrounds/背景.png differ diff --git a/src/assets/images/main-scene/backgrounds/视频区域.png b/src/assets/images/main-scene/backgrounds/视频区域.png new file mode 100644 index 0000000..4fd6e91 Binary files /dev/null and b/src/assets/images/main-scene/backgrounds/视频区域.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-关闭.png b/src/assets/images/main-scene/buttons/按钮-关闭.png new file mode 100644 index 0000000..0147c55 Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-关闭.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-关闭物证信息.png b/src/assets/images/main-scene/buttons/按钮-关闭物证信息.png new file mode 100644 index 0000000..866db80 Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-关闭物证信息.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-接收.png b/src/assets/images/main-scene/buttons/按钮-接收.png new file mode 100644 index 0000000..64a0bb6 Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-接收.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-查看.png b/src/assets/images/main-scene/buttons/按钮-查看.png new file mode 100644 index 0000000..25444b6 Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-查看.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-深入调查.png b/src/assets/images/main-scene/buttons/按钮-深入调查.png new file mode 100644 index 0000000..5999e5a Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-深入调查.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-稍后.png b/src/assets/images/main-scene/buttons/按钮-稍后.png new file mode 100644 index 0000000..f4c343e Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-稍后.png differ diff --git a/src/assets/images/main-scene/buttons/按钮-立即立案.png b/src/assets/images/main-scene/buttons/按钮-立即立案.png new file mode 100644 index 0000000..226d68d Binary files /dev/null and b/src/assets/images/main-scene/buttons/按钮-立即立案.png differ diff --git a/src/assets/images/main-scene/cards/卡片-Q弹真相.png b/src/assets/images/main-scene/cards/卡片-Q弹真相.png new file mode 100644 index 0000000..fc76848 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-Q弹真相.png differ diff --git a/src/assets/images/main-scene/cards/卡片-保鲜疑云.png b/src/assets/images/main-scene/cards/卡片-保鲜疑云.png new file mode 100644 index 0000000..2001ba6 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-保鲜疑云.png differ diff --git a/src/assets/images/main-scene/cards/卡片-味觉密码.png b/src/assets/images/main-scene/cards/卡片-味觉密码.png new file mode 100644 index 0000000..9432c95 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-味觉密码.png differ diff --git a/src/assets/images/main-scene/cards/卡片-看评论.png b/src/assets/images/main-scene/cards/卡片-看评论.png new file mode 100644 index 0000000..b482430 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-看评论.png differ diff --git a/src/assets/images/main-scene/cards/卡片-看颜色.png b/src/assets/images/main-scene/cards/卡片-看颜色.png new file mode 100644 index 0000000..50835c5 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-看颜色.png differ diff --git a/src/assets/images/main-scene/cards/卡片-色彩迷局.png b/src/assets/images/main-scene/cards/卡片-色彩迷局.png new file mode 100644 index 0000000..dc42f52 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-色彩迷局.png differ diff --git a/src/assets/images/main-scene/cards/卡片-调资料.png b/src/assets/images/main-scene/cards/卡片-调资料.png new file mode 100644 index 0000000..4871a31 Binary files /dev/null and b/src/assets/images/main-scene/cards/卡片-调资料.png differ diff --git a/src/assets/images/main-scene/entries/入口-未有邮件.png b/src/assets/images/main-scene/entries/入口-未有邮件.png new file mode 100644 index 0000000..4505d09 Binary files /dev/null and b/src/assets/images/main-scene/entries/入口-未有邮件.png differ diff --git a/src/assets/images/main-scene/entries/入口-案件.png b/src/assets/images/main-scene/entries/入口-案件.png new file mode 100644 index 0000000..0a3a04d Binary files /dev/null and b/src/assets/images/main-scene/entries/入口-案件.png differ diff --git a/src/assets/images/main-scene/entries/入口-邮件.png b/src/assets/images/main-scene/entries/入口-邮件.png new file mode 100644 index 0000000..9784498 Binary files /dev/null and b/src/assets/images/main-scene/entries/入口-邮件.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱-可乐.png b/src/assets/images/main-scene/evidence/证物箱-可乐.png new file mode 100644 index 0000000..6f7546e Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱-可乐.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱-吐司.png b/src/assets/images/main-scene/evidence/证物箱-吐司.png new file mode 100644 index 0000000..07c29b9 Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱-吐司.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱-果冻.png b/src/assets/images/main-scene/evidence/证物箱-果冻.png new file mode 100644 index 0000000..910adc5 Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱-果冻.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱-橙汁.png b/src/assets/images/main-scene/evidence/证物箱-橙汁.png new file mode 100644 index 0000000..b675254 Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱-橙汁.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱-葡萄味气泡水.png b/src/assets/images/main-scene/evidence/证物箱-葡萄味气泡水.png new file mode 100644 index 0000000..d7e3a04 Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱-葡萄味气泡水.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱-软糖.png b/src/assets/images/main-scene/evidence/证物箱-软糖.png new file mode 100644 index 0000000..b5971d7 Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱-软糖.png differ diff --git a/src/assets/images/main-scene/evidence/证物箱.png b/src/assets/images/main-scene/evidence/证物箱.png new file mode 100644 index 0000000..cda0fc5 Binary files /dev/null and b/src/assets/images/main-scene/evidence/证物箱.png differ diff --git a/src/assets/images/main-scene/interactions/恭喜关键线索提炼.png b/src/assets/images/main-scene/interactions/恭喜关键线索提炼.png new file mode 100644 index 0000000..5948053 Binary files /dev/null and b/src/assets/images/main-scene/interactions/恭喜关键线索提炼.png differ diff --git a/src/assets/images/main-scene/interactions/手套.png b/src/assets/images/main-scene/interactions/手套.png new file mode 100644 index 0000000..d9eaead Binary files /dev/null and b/src/assets/images/main-scene/interactions/手套.png differ diff --git a/src/assets/images/main-scene/interactions/拖拽-橙.png b/src/assets/images/main-scene/interactions/拖拽-橙.png new file mode 100644 index 0000000..8c9792a Binary files /dev/null and b/src/assets/images/main-scene/interactions/拖拽-橙.png differ diff --git a/src/assets/images/main-scene/interactions/拖拽-红.png b/src/assets/images/main-scene/interactions/拖拽-红.png new file mode 100644 index 0000000..d44ff57 Binary files /dev/null and b/src/assets/images/main-scene/interactions/拖拽-红.png differ diff --git a/src/assets/images/main-scene/interactions/拖拽-绿.png b/src/assets/images/main-scene/interactions/拖拽-绿.png new file mode 100644 index 0000000..da1281b Binary files /dev/null and b/src/assets/images/main-scene/interactions/拖拽-绿.png differ diff --git a/src/assets/images/main-scene/interactions/拖拽-蓝.png b/src/assets/images/main-scene/interactions/拖拽-蓝.png new file mode 100644 index 0000000..19fba7b Binary files /dev/null and b/src/assets/images/main-scene/interactions/拖拽-蓝.png differ diff --git a/src/assets/images/main-scene/interactions/拖拽区域.png b/src/assets/images/main-scene/interactions/拖拽区域.png new file mode 100644 index 0000000..986c46a Binary files /dev/null and b/src/assets/images/main-scene/interactions/拖拽区域.png differ diff --git a/src/assets/images/main-scene/interactions/拖拽提示.png b/src/assets/images/main-scene/interactions/拖拽提示.png new file mode 100644 index 0000000..7b5871d Binary files /dev/null and b/src/assets/images/main-scene/interactions/拖拽提示.png differ diff --git a/src/assets/images/main-scene/interactions/提示.png b/src/assets/images/main-scene/interactions/提示.png new file mode 100644 index 0000000..829e2cd Binary files /dev/null and b/src/assets/images/main-scene/interactions/提示.png differ diff --git a/src/assets/images/main-scene/interactions/点击查看物证.png b/src/assets/images/main-scene/interactions/点击查看物证.png new file mode 100644 index 0000000..35a9d12 Binary files /dev/null and b/src/assets/images/main-scene/interactions/点击查看物证.png differ diff --git a/src/assets/images/main-scene/interactions/点击线.png b/src/assets/images/main-scene/interactions/点击线.png new file mode 100644 index 0000000..86bd0f2 Binary files /dev/null and b/src/assets/images/main-scene/interactions/点击线.png differ diff --git a/src/assets/images/main-scene/letters/信图标.png b/src/assets/images/main-scene/letters/信图标.png new file mode 100644 index 0000000..d036770 Binary files /dev/null and b/src/assets/images/main-scene/letters/信图标.png differ diff --git a/src/assets/images/main-scene/letters/求助信-交互底板用.png b/src/assets/images/main-scene/letters/求助信-交互底板用.png new file mode 100644 index 0000000..5564f7b Binary files /dev/null and b/src/assets/images/main-scene/letters/求助信-交互底板用.png differ diff --git a/src/assets/images/main-scene/letters/求助信.png b/src/assets/images/main-scene/letters/求助信.png new file mode 100644 index 0000000..ac56140 Binary files /dev/null and b/src/assets/images/main-scene/letters/求助信.png differ diff --git a/src/assets/images/main-scene/panels/侦探工作档案.png b/src/assets/images/main-scene/panels/侦探工作档案.png new file mode 100644 index 0000000..dbb8b67 Binary files /dev/null and b/src/assets/images/main-scene/panels/侦探工作档案.png differ diff --git a/src/assets/images/main-scene/panels/底板-提示.png b/src/assets/images/main-scene/panels/底板-提示.png new file mode 100644 index 0000000..1a4512c Binary files /dev/null and b/src/assets/images/main-scene/panels/底板-提示.png differ diff --git a/src/assets/images/main-scene/panels/底板-物证信息.png b/src/assets/images/main-scene/panels/底板-物证信息.png new file mode 100644 index 0000000..0cb57d2 Binary files /dev/null and b/src/assets/images/main-scene/panels/底板-物证信息.png differ diff --git a/src/assets/images/main-scene/panels/旧案卷宗.png b/src/assets/images/main-scene/panels/旧案卷宗.png new file mode 100644 index 0000000..e18d925 Binary files /dev/null and b/src/assets/images/main-scene/panels/旧案卷宗.png differ diff --git a/src/assets/images/main-scene/panels/案件信息板-信息.png b/src/assets/images/main-scene/panels/案件信息板-信息.png new file mode 100644 index 0000000..cace13a Binary files /dev/null and b/src/assets/images/main-scene/panels/案件信息板-信息.png differ diff --git a/src/assets/images/main-scene/panels/案件信息板-暂无信息.png b/src/assets/images/main-scene/panels/案件信息板-暂无信息.png new file mode 100644 index 0000000..2537ad0 Binary files /dev/null and b/src/assets/images/main-scene/panels/案件信息板-暂无信息.png differ diff --git a/src/assets/images/main-scene/panels/案件信息板-色彩迷局.png b/src/assets/images/main-scene/panels/案件信息板-色彩迷局.png new file mode 100644 index 0000000..014c24c Binary files /dev/null and b/src/assets/images/main-scene/panels/案件信息板-色彩迷局.png differ diff --git a/src/assets/images/main-scene/panels/案件登记.png b/src/assets/images/main-scene/panels/案件登记.png new file mode 100644 index 0000000..e3438f9 Binary files /dev/null and b/src/assets/images/main-scene/panels/案件登记.png differ diff --git a/src/assets/images/main-scene/panels/添加剂档案.png b/src/assets/images/main-scene/panels/添加剂档案.png new file mode 100644 index 0000000..ac63cb8 Binary files /dev/null and b/src/assets/images/main-scene/panels/添加剂档案.png differ diff --git a/src/assets/images/main-scene/panels/线索板.png b/src/assets/images/main-scene/panels/线索板.png new file mode 100644 index 0000000..681f071 Binary files /dev/null and b/src/assets/images/main-scene/panels/线索板.png differ diff --git a/src/assets/images/main-scene/panels/调查结果.png b/src/assets/images/main-scene/panels/调查结果.png new file mode 100644 index 0000000..09b9502 Binary files /dev/null and b/src/assets/images/main-scene/panels/调查结果.png differ diff --git a/src/assets/images/main-scene/panels/调查资料.png b/src/assets/images/main-scene/panels/调查资料.png new file mode 100644 index 0000000..40d1f6d Binary files /dev/null and b/src/assets/images/main-scene/panels/调查资料.png differ diff --git a/src/assets/images/main-scene/reminders/提醒-新增舆情.png b/src/assets/images/main-scene/reminders/提醒-新增舆情.png new file mode 100644 index 0000000..aafef66 Binary files /dev/null and b/src/assets/images/main-scene/reminders/提醒-新增舆情.png differ diff --git a/src/assets/images/main-scene/reminders/提醒-新邮件.png b/src/assets/images/main-scene/reminders/提醒-新邮件.png new file mode 100644 index 0000000..f69d250 Binary files /dev/null and b/src/assets/images/main-scene/reminders/提醒-新邮件.png differ diff --git a/src/assets/images/main-scene/reminders/提醒-案件登记.png b/src/assets/images/main-scene/reminders/提醒-案件登记.png new file mode 100644 index 0000000..3217225 Binary files /dev/null and b/src/assets/images/main-scene/reminders/提醒-案件登记.png differ diff --git a/src/assets/images/reception/components/按钮-关闭.png b/src/assets/images/reception/components/按钮-关闭.png new file mode 100644 index 0000000..0147c55 Binary files /dev/null and b/src/assets/images/reception/components/按钮-关闭.png differ diff --git a/src/assets/images/reception/components/按钮-创建档案.png b/src/assets/images/reception/components/按钮-创建档案.png new file mode 100644 index 0000000..cae0384 Binary files /dev/null and b/src/assets/images/reception/components/按钮-创建档案.png differ diff --git a/src/assets/images/reception/components/新人档案登记.png b/src/assets/images/reception/components/新人档案登记.png new file mode 100644 index 0000000..14aabbc Binary files /dev/null and b/src/assets/images/reception/components/新人档案登记.png differ diff --git a/src/assets/images/reception/底图-新人登记.png b/src/assets/images/reception/底图-新人登记.png new file mode 100644 index 0000000..e9c1e20 Binary files /dev/null and b/src/assets/images/reception/底图-新人登记.png differ diff --git a/src/assets/images/reception/引导窗.png b/src/assets/images/reception/引导窗.png new file mode 100644 index 0000000..4e34900 Binary files /dev/null and b/src/assets/images/reception/引导窗.png differ diff --git a/src/assets/images/reception/按钮-进入.png b/src/assets/images/reception/按钮-进入.png new file mode 100644 index 0000000..2140ea3 Binary files /dev/null and b/src/assets/images/reception/按钮-进入.png differ diff --git a/src/assets/images/reception/新人登记.png b/src/assets/images/reception/新人登记.png new file mode 100644 index 0000000..3a8f500 Binary files /dev/null and b/src/assets/images/reception/新人登记.png differ diff --git a/src/assets/images/record/侦探助手.svg b/src/assets/images/record/侦探助手.svg new file mode 100644 index 0000000..0a08ecf --- /dev/null +++ b/src/assets/images/record/侦探助手.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/record/发现线索.png b/src/assets/images/record/发现线索.png new file mode 100644 index 0000000..66a0f5b Binary files /dev/null and b/src/assets/images/record/发现线索.png differ diff --git a/src/assets/images/record/发送.png b/src/assets/images/record/发送.png new file mode 100644 index 0000000..f82e175 Binary files /dev/null and b/src/assets/images/record/发送.png differ diff --git a/src/assets/images/record/发送区域.png b/src/assets/images/record/发送区域.png new file mode 100644 index 0000000..a7d52ca Binary files /dev/null and b/src/assets/images/record/发送区域.png differ diff --git a/src/assets/images/record/可乐大.png b/src/assets/images/record/可乐大.png new file mode 100644 index 0000000..5916282 Binary files /dev/null and b/src/assets/images/record/可乐大.png differ diff --git a/src/assets/images/record/可乐小.png b/src/assets/images/record/可乐小.png new file mode 100644 index 0000000..7fe0ac6 Binary files /dev/null and b/src/assets/images/record/可乐小.png differ diff --git a/src/assets/images/record/可拖拽选项.png b/src/assets/images/record/可拖拽选项.png new file mode 100644 index 0000000..0a8b641 Binary files /dev/null and b/src/assets/images/record/可拖拽选项.png differ diff --git a/src/assets/images/record/可点击-求助信.png b/src/assets/images/record/可点击-求助信.png new file mode 100644 index 0000000..fc309f7 Binary files /dev/null and b/src/assets/images/record/可点击-求助信.png differ diff --git a/src/assets/images/record/可点击-线索板.png b/src/assets/images/record/可点击-线索板.png new file mode 100644 index 0000000..0a60eac Binary files /dev/null and b/src/assets/images/record/可点击-线索板.png differ diff --git a/src/assets/images/record/图-舆论热点区.png b/src/assets/images/record/图-舆论热点区.png new file mode 100644 index 0000000..220b253 Binary files /dev/null and b/src/assets/images/record/图-舆论热点区.png differ diff --git a/src/assets/images/record/圈出跟颜色相关添加剂.png b/src/assets/images/record/圈出跟颜色相关添加剂.png new file mode 100644 index 0000000..a863c29 Binary files /dev/null and b/src/assets/images/record/圈出跟颜色相关添加剂.png differ diff --git a/src/assets/images/record/头像-侦探助手.png b/src/assets/images/record/头像-侦探助手.png new file mode 100644 index 0000000..a41d761 Binary files /dev/null and b/src/assets/images/record/头像-侦探助手.png differ diff --git a/src/assets/images/record/头像-添侦探.png b/src/assets/images/record/头像-添侦探.png new file mode 100644 index 0000000..60d16f0 Binary files /dev/null and b/src/assets/images/record/头像-添侦探.png differ diff --git a/src/assets/images/record/对话侧边背景.png b/src/assets/images/record/对话侧边背景.png new file mode 100644 index 0000000..63efadd Binary files /dev/null and b/src/assets/images/record/对话侧边背景.png differ diff --git a/src/assets/images/record/对话全屏-置顶桌板.png b/src/assets/images/record/对话全屏-置顶桌板.png new file mode 100644 index 0000000..d737939 Binary files /dev/null and b/src/assets/images/record/对话全屏-置顶桌板.png differ diff --git a/src/assets/images/record/对话全屏背景.png b/src/assets/images/record/对话全屏背景.png new file mode 100644 index 0000000..0de19dd Binary files /dev/null and b/src/assets/images/record/对话全屏背景.png differ diff --git a/src/assets/images/record/引导-点击查看求助信.png b/src/assets/images/record/引导-点击查看求助信.png new file mode 100644 index 0000000..7741527 Binary files /dev/null and b/src/assets/images/record/引导-点击查看求助信.png differ diff --git a/src/assets/images/record/引导-点击标签核查.png b/src/assets/images/record/引导-点击标签核查.png new file mode 100644 index 0000000..782717c Binary files /dev/null and b/src/assets/images/record/引导-点击标签核查.png differ diff --git a/src/assets/images/record/引导标题-进入线索板.png b/src/assets/images/record/引导标题-进入线索板.png new file mode 100644 index 0000000..9ed7ef2 Binary files /dev/null and b/src/assets/images/record/引导标题-进入线索板.png differ diff --git a/src/assets/images/record/形象-侦探助手.png b/src/assets/images/record/形象-侦探助手.png new file mode 100644 index 0000000..d71e3ef Binary files /dev/null and b/src/assets/images/record/形象-侦探助手.png differ diff --git a/src/assets/images/record/形象-添侦探.png b/src/assets/images/record/形象-添侦探.png new file mode 100644 index 0000000..465c0fd Binary files /dev/null and b/src/assets/images/record/形象-添侦探.png differ diff --git a/src/assets/images/record/推理出调查线结论.png b/src/assets/images/record/推理出调查线结论.png new file mode 100644 index 0000000..011e331 Binary files /dev/null and b/src/assets/images/record/推理出调查线结论.png differ diff --git a/src/assets/images/record/敲章-线索已整理.png b/src/assets/images/record/敲章-线索已整理.png new file mode 100644 index 0000000..e7dae9a Binary files /dev/null and b/src/assets/images/record/敲章-线索已整理.png differ diff --git a/src/assets/images/record/标签核查底板.png b/src/assets/images/record/标签核查底板.png new file mode 100644 index 0000000..1c68588 Binary files /dev/null and b/src/assets/images/record/标签核查底板.png differ diff --git a/src/assets/images/record/添侦探.svg b/src/assets/images/record/添侦探.svg new file mode 100644 index 0000000..d23e389 --- /dev/null +++ b/src/assets/images/record/添侦探.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/record/点击效果.png b/src/assets/images/record/点击效果.png new file mode 100644 index 0000000..54b2a8e Binary files /dev/null and b/src/assets/images/record/点击效果.png differ diff --git a/src/assets/images/record/焦糖色卡.png b/src/assets/images/record/焦糖色卡.png new file mode 100644 index 0000000..91665e9 Binary files /dev/null and b/src/assets/images/record/焦糖色卡.png differ diff --git a/src/assets/images/record/称号标签-侦探助手.png b/src/assets/images/record/称号标签-侦探助手.png new file mode 100644 index 0000000..2e19c3d Binary files /dev/null and b/src/assets/images/record/称号标签-侦探助手.png differ diff --git a/src/assets/images/record/称号标签-添侦探.png b/src/assets/images/record/称号标签-添侦探.png new file mode 100644 index 0000000..f96b80f Binary files /dev/null and b/src/assets/images/record/称号标签-添侦探.png differ diff --git a/src/assets/images/record/选择标题-正确着色剂.png b/src/assets/images/record/选择标题-正确着色剂.png new file mode 100644 index 0000000..9447f43 Binary files /dev/null and b/src/assets/images/record/选择标题-正确着色剂.png differ diff --git a/src/assets/images/record/选择标题-装饰-右.png b/src/assets/images/record/选择标题-装饰-右.png new file mode 100644 index 0000000..0364bf8 Binary files /dev/null and b/src/assets/images/record/选择标题-装饰-右.png differ diff --git a/src/assets/images/record/选择标题-装饰-左.png b/src/assets/images/record/选择标题-装饰-左.png new file mode 100644 index 0000000..7d25765 Binary files /dev/null and b/src/assets/images/record/选择标题-装饰-左.png differ diff --git a/src/assets/images/record/选项-栀子黄.png b/src/assets/images/record/选项-栀子黄.png new file mode 100644 index 0000000..7c66e10 Binary files /dev/null and b/src/assets/images/record/选项-栀子黄.png differ diff --git a/src/assets/images/record/选项-胭脂红.png b/src/assets/images/record/选项-胭脂红.png new file mode 100644 index 0000000..d7f42fc Binary files /dev/null and b/src/assets/images/record/选项-胭脂红.png differ diff --git a/src/assets/images/record/选项-苏丹红.png b/src/assets/images/record/选项-苏丹红.png new file mode 100644 index 0000000..ac0e4bc Binary files /dev/null and b/src/assets/images/record/选项-苏丹红.png differ diff --git a/src/assets/images/record/选项-问题A.png b/src/assets/images/record/选项-问题A.png new file mode 100644 index 0000000..732cab3 Binary files /dev/null and b/src/assets/images/record/选项-问题A.png differ diff --git a/src/assets/images/record/选项-问题B.png b/src/assets/images/record/选项-问题B.png new file mode 100644 index 0000000..0ed17e6 Binary files /dev/null and b/src/assets/images/record/选项-问题B.png differ diff --git a/src/assets/images/record/选项-问题c.png b/src/assets/images/record/选项-问题c.png new file mode 100644 index 0000000..2fdb67d Binary files /dev/null and b/src/assets/images/record/选项-问题c.png differ diff --git a/src/assets/images/title/按钮-标签核查.png b/src/assets/images/title/按钮-标签核查.png new file mode 100644 index 0000000..6b67164 Binary files /dev/null and b/src/assets/images/title/按钮-标签核查.png differ diff --git a/src/assets/images/title/按钮-调查记录.png b/src/assets/images/title/按钮-调查记录.png new file mode 100644 index 0000000..3ffcf5b Binary files /dev/null and b/src/assets/images/title/按钮-调查记录.png differ diff --git a/src/assets/images/title/推理进行中.png b/src/assets/images/title/推理进行中.png new file mode 100644 index 0000000..ad7993d Binary files /dev/null and b/src/assets/images/title/推理进行中.png differ diff --git a/src/assets/svg/magnifier.svg b/src/assets/svg/magnifier.svg new file mode 100644 index 0000000..f34c5cf --- /dev/null +++ b/src/assets/svg/magnifier.svg @@ -0,0 +1,34 @@ + + 零食侦探放大镜 + + + + + + + + + + + diff --git a/src/components/business/Navigation/Navigation.tsx b/src/components/business/Navigation/Navigation.tsx index 947e7d9..84b6fd2 100644 --- a/src/components/business/Navigation/Navigation.tsx +++ b/src/components/business/Navigation/Navigation.tsx @@ -6,8 +6,10 @@ import type { NavItem, NavigationProps } from "./Navigation.types"; const defaultItems: NavItem[] = [ { label: "首页", path: "/" }, { label: "关于", path: "/about" }, + /* 模板 Demo,业务开发时暂时隐藏 { label: "组件示例", path: "/components" }, { label: "课程示例", path: "/course-demo" }, + */ ]; export function Navigation({ diff --git a/src/components/common/BackgroundContainer/BackgroundContainer.module.css b/src/components/common/BackgroundContainer/BackgroundContainer.module.css new file mode 100644 index 0000000..1f66177 --- /dev/null +++ b/src/components/common/BackgroundContainer/BackgroundContainer.module.css @@ -0,0 +1,9 @@ +@reference "tailwindcss"; + +.container { + @apply relative; +} + +.content { + @apply relative h-full z-10; +} diff --git a/src/components/common/BackgroundContainer/BackgroundContainer.tsx b/src/components/common/BackgroundContainer/BackgroundContainer.tsx new file mode 100644 index 0000000..4d87424 --- /dev/null +++ b/src/components/common/BackgroundContainer/BackgroundContainer.tsx @@ -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 ( + +
{children}
+
+ ); +} diff --git a/src/components/common/BackgroundContainer/BackgroundContainer.types.ts b/src/components/common/BackgroundContainer/BackgroundContainer.types.ts new file mode 100644 index 0000000..f3441ad --- /dev/null +++ b/src/components/common/BackgroundContainer/BackgroundContainer.types.ts @@ -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, "children"> { + /** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */ + src: string; + fit?: BackgroundContainerFit; + position?: string; + repeat?: BackgroundContainerRepeat; + as?: BackgroundContainerElement; + contentClassName?: string; + children?: ReactNode; +} diff --git a/src/components/common/BackgroundContainer/index.ts b/src/components/common/BackgroundContainer/index.ts new file mode 100644 index 0000000..e1ae994 --- /dev/null +++ b/src/components/common/BackgroundContainer/index.ts @@ -0,0 +1,7 @@ +export { BackgroundContainer } from "./BackgroundContainer"; +export type { + BackgroundContainerElement, + BackgroundContainerFit, + BackgroundContainerProps, + BackgroundContainerRepeat, +} from "./BackgroundContainer.types"; diff --git a/src/components/common/ImageButton/ImageButton.module.css b/src/components/common/ImageButton/ImageButton.module.css new file mode 100644 index 0000000..2616e39 --- /dev/null +++ b/src/components/common/ImageButton/ImageButton.module.css @@ -0,0 +1,12 @@ +@reference "tailwindcss"; + +/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */ +.button { + @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; +} + +.image { + @apply block h-full w-full object-cover; +} diff --git a/src/components/common/ImageButton/ImageButton.tsx b/src/components/common/ImageButton/ImageButton.tsx new file mode 100644 index 0000000..0608c1b --- /dev/null +++ b/src/components/common/ImageButton/ImageButton.tsx @@ -0,0 +1,105 @@ +import { forwardRef, type Ref, useRef } from "react"; +import { cn } from "@/lib/cn"; +import { getStaticImage } from "@/utils/assets"; +import { EASE_OUT, gsap, useGSAP, useReducedMotion } from "@/utils/motion"; +import styles from "./ImageButton.module.css"; +import type { ImageButtonProps } from "./ImageButton.types"; + +// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 +function resolveImage(src: string): string { + return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src; +} + +function mergeRefs(...refs: Array | undefined>) { + return (node: T | null) => { + for (const ref of refs) { + if (typeof ref === "function") ref(node); + else if (ref && "current" in ref) + (ref as { current: T | null }).current = node; + } + }; +} + +export const ImageButton = forwardRef( + ( + { + src, + alt = "", + width, + height, + hoverScale = 1.05, + pressScale = 0.95, + className, + style, + disabled, + onPointerEnter, + onPointerLeave, + onPointerDown, + onPointerUp, + onPointerCancel, + ...rest + }, + ref, + ) => { + const innerRef = useRef(null); + const reduced = useReducedMotion(); + const imageSrc = resolveImage(src); + + const animate = (scale: number, duration = 0.18) => { + if (reduced || disabled || !innerRef.current) return; + gsap.to(innerRef.current, { + scale, + duration, + ease: EASE_OUT, + overwrite: "auto", + }); + }; + + // 卸载时清理该元素上的所有 tween + useGSAP(() => { + return () => { + if (innerRef.current) gsap.killTweensOf(innerRef.current); + }; + }); + + return ( + + ); + }, +); + +ImageButton.displayName = "ImageButton"; diff --git a/src/components/common/ImageButton/ImageButton.types.ts b/src/components/common/ImageButton/ImageButton.types.ts new file mode 100644 index 0000000..c741df2 --- /dev/null +++ b/src/components/common/ImageButton/ImageButton.types.ts @@ -0,0 +1,15 @@ +import type { ButtonHTMLAttributes } from "react"; + +export interface ImageButtonProps + extends ButtonHTMLAttributes { + /** 图片:静态资源 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; +} diff --git a/src/components/common/ImageButton/index.ts b/src/components/common/ImageButton/index.ts new file mode 100644 index 0000000..62f91f7 --- /dev/null +++ b/src/components/common/ImageButton/index.ts @@ -0,0 +1,2 @@ +export { ImageButton } from "./ImageButton"; +export type { ImageButtonProps } from "./ImageButton.types"; diff --git a/src/components/common/RouteFallback/RouteFallback.module.css b/src/components/common/RouteFallback/RouteFallback.module.css new file mode 100644 index 0000000..bae6b8d --- /dev/null +++ b/src/components/common/RouteFallback/RouteFallback.module.css @@ -0,0 +1,50 @@ +@reference "tailwindcss"; + +/* ===== 容器:纯黑背景,极简风格 ===== */ +.container { + @apply relative w-full h-full; + background: #0a0a0a; +} + +/* ===== 中央内容 ===== */ +.center { + @apply absolute flex flex-col items-center gap-5; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* ===== Q 标志 ===== */ +.logoMark { + @apply flex items-center justify-center font-bold; + width: 48px; + height: 48px; + font-size: 24px; + color: #c9a84c; + border: 2px solid #c9a84c; + border-radius: 50%; + font-family: Georgia, serif; +} + +/* ===== 进度条 ===== */ +.progressTrack { + @apply overflow-hidden; + width: 120px; + height: 2px; + background: rgba(255, 255, 255, 0.1); + border-radius: 1px; +} + +.progressFill { + @apply h-full; + background: #c9a84c; + border-radius: 1px; + transition: width 0.3s ease-out; +} + +/* ===== 提示文字 ===== */ +.tip { + @apply text-3xl font-bold tracking-wider; + color: rgba(255, 255, 255, 0.4); + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; +} diff --git a/src/components/common/RouteFallback/RouteFallback.tsx b/src/components/common/RouteFallback/RouteFallback.tsx index af17100..1cefb73 100644 --- a/src/components/common/RouteFallback/RouteFallback.tsx +++ b/src/components/common/RouteFallback/RouteFallback.tsx @@ -1,7 +1,43 @@ -export function RouteFallback() { +import { useEffect, useState } from "react"; +import styles from "./RouteFallback.module.css"; + +interface RouteFallbackProps { + /** 自定义提示文字 */ + tip?: string; +} + +export function RouteFallback({ tip = "正在进入事务所" }: RouteFallbackProps) { + const [progress, setProgress] = useState(0); + + // 进度条动画 + useEffect(() => { + const timer = setInterval(() => { + setProgress((prev) => { + if (prev >= 92) return prev; + return Math.min(prev + Math.random() * 8 + 2, 92); + }); + }, 300); + return () => clearInterval(timer); + }, []); + return ( -
- 加载中... +
+ {/* 中央内容 */} +
+ {/* 侦探 Q 标志 */} +
Q
+ + {/* 进度条 */} +
+
+
+ + {/* 提示文字 */} +

{tip}

+
); } diff --git a/src/components/common/Video/VideoOverlay.tsx b/src/components/common/Video/VideoOverlay.tsx new file mode 100644 index 0000000..298811d --- /dev/null +++ b/src/components/common/Video/VideoOverlay.tsx @@ -0,0 +1,66 @@ +import { getAppPortalRoot } from "@/components/common/canvasPortal"; +import { Overlay } from "@/components/ui/overlay"; +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 ( + +
+ + + {skipLabel && ( + + )} +
+
+ ); +} diff --git a/src/components/common/Video/VideoOverlay.types.ts b/src/components/common/Video/VideoOverlay.types.ts new file mode 100644 index 0000000..35ffa0a --- /dev/null +++ b/src/components/common/Video/VideoOverlay.types.ts @@ -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; +} diff --git a/src/components/common/Video/VideoPlayer.module.css b/src/components/common/Video/VideoPlayer.module.css new file mode 100644 index 0000000..bf29914 --- /dev/null +++ b/src/components/common/Video/VideoPlayer.module.css @@ -0,0 +1,118 @@ +@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 absolute bottom-0 left-0 right-0 h-1 bg-white/20 transition-opacity duration-300; +} + +.miniProgressBar { + @apply h-full bg-red-500 transition-all duration-100; +} + +/* 进度条 */ +.progressContainer { + @apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30; +} + +.progressBar { + @apply absolute left-0 top-0 h-full rounded-full bg-red-500; +} + +.progressBuffered { + @apply absolute left-0 top-0 h-full rounded-full bg-white/30; +} + +.progressThumb { + @apply absolute top-1/2 h-3.5 w-3.5 -translate-y-1/2 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200; +} + +.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; +} diff --git a/src/components/common/Video/VideoPlayer.tsx b/src/components/common/Video/VideoPlayer.tsx new file mode 100644 index 0000000..50b3b53 --- /dev/null +++ b/src/components/common/Video/VideoPlayer.tsx @@ -0,0 +1,690 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/cn"; +import styles from "./VideoPlayer.module.css"; +import type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types"; + +const PLAYBACK_RATES = [0.5, 1, 1.5, 2] as const; +const IDLE_HIDE_DELAY = 3000; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +function isMobileDevice(): boolean { + if (typeof navigator === "undefined") return false; + return ( + /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ) || + ("ontouchstart" in window && navigator.maxTouchPoints > 0) + ); +} + +export const VideoPlayer = forwardRef( + ( + { + src, + poster, + autoPlay = false, + loop = false, + muted: initialMuted = false, + preload = "metadata", + playbackRate: initialRate = 1, + mode = "window", + className, + onPlay, + onPause, + onEnded, + onTimeUpdate, + onProgress, + onVolumeChange, + onPlaybackRateChange, + onFullscreenChange, + }, + ref, + ) => { + const videoRef = useRef(null); + const containerRef = useRef(null); + const progressRef = useRef(null); + const idleTimerRef = useRef>(undefined); + const wasPlayingRef = useRef(false); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolumeState] = useState(1); + const [isMuted, setIsMuted] = useState(initialMuted); + const [playbackRate, setPlaybackRateState] = useState(initialRate); + const [buffered, setBuffered] = useState(0); + const [showControls, setShowControls] = useState(true); + const [isDragging, setIsDragging] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [showCenterButton, setShowCenterButton] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(mode === "fullscreen"); + const isMobile = isMobileDevice(); + + // 暴露方法给父组件 + useImperativeHandle(ref, () => ({ + play: () => videoRef.current?.play(), + pause: () => videoRef.current?.pause(), + seek: (time: number) => { + if (videoRef.current) videoRef.current.currentTime = time; + }, + setVolume: (v: number) => { + if (videoRef.current) videoRef.current.volume = v; + }, + setPlaybackRate: (rate: number) => { + if (videoRef.current) videoRef.current.playbackRate = rate; + }, + getCurrentTime: () => videoRef.current?.currentTime ?? 0, + getDuration: () => videoRef.current?.duration ?? 0, + })); + + // 自动隐藏控制条 + const resetIdleTimer = useCallback(() => { + setShowControls(true); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => { + if (isPlaying) setShowControls(false); + }, IDLE_HIDE_DELAY); + }, [isPlaying]); + + // 播放/暂停 + const togglePlay = useCallback(() => { + const video = videoRef.current; + if (!video) return; + if (video.paused) { + video.play().catch(() => {}); + } else { + video.pause(); + } + resetIdleTimer(); + }, [resetIdleTimer]); + + // 静音切换 + const toggleMute = useCallback(() => { + const video = videoRef.current; + if (!video) return; + video.muted = !video.muted; + setIsMuted(video.muted); + resetIdleTimer(); + }, [resetIdleTimer]); + + // 音量调整 + const handleVolumeChange = useCallback( + (event: React.ChangeEvent) => { + const video = videoRef.current; + if (!video) return; + const newVolume = Number.parseFloat(event.target.value); + video.volume = newVolume; + if (newVolume > 0 && video.muted) video.muted = false; + setVolumeState(newVolume); + setIsMuted(video.muted); + resetIdleTimer(); + }, + [resetIdleTimer], + ); + + // 倍速切换 + const cyclePlaybackRate = useCallback(() => { + const video = videoRef.current; + if (!video) return; + const currentIdx = PLAYBACK_RATES.indexOf( + playbackRate as (typeof PLAYBACK_RATES)[number], + ); + const nextIdx = (currentIdx + 1) % PLAYBACK_RATES.length; + const nextRate = PLAYBACK_RATES[nextIdx]; + video.playbackRate = nextRate; + setPlaybackRateState(nextRate); + onPlaybackRateChange?.(nextRate); + resetIdleTimer(); + }, [playbackRate, onPlaybackRateChange, resetIdleTimer]); + + // 全屏切换(CSS 方式,避免调用原生 API 导致 canvas 缩放问题) + const toggleFullscreen = useCallback(() => { + const next = !isFullscreen; + setIsFullscreen(next); + onFullscreenChange?.(next); + resetIdleTimer(); + }, [isFullscreen, onFullscreenChange, resetIdleTimer]); + + // 进度条点击/拖动 + const getProgressFromEvent = useCallback( + (event: React.MouseEvent | React.TouchEvent) => { + const bar = progressRef.current; + if (!bar) return 0; + const rect = bar.getBoundingClientRect(); + const clientX = + "touches" in event + ? (event.touches[0]?.clientX ?? + event.changedTouches[0]?.clientX ?? + 0) + : event.clientX; + const ratio = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + return ratio * duration; + }, + [duration], + ); + + const handleProgressMouseDown = useCallback( + (event: React.MouseEvent | React.TouchEvent) => { + event.preventDefault(); + const video = videoRef.current; + if (!video) return; + wasPlayingRef.current = !video.paused; + video.pause(); + setIsDragging(true); + const time = getProgressFromEvent(event); + video.currentTime = time; + setCurrentTime(time); + resetIdleTimer(); + }, + [getProgressFromEvent, resetIdleTimer], + ); + + const handleProgressMouseMove = useCallback( + (event: React.MouseEvent | React.TouchEvent) => { + if (!isDragging) return; + const video = videoRef.current; + if (!video) return; + const time = getProgressFromEvent(event); + video.currentTime = time; + setCurrentTime(time); + }, + [isDragging, getProgressFromEvent], + ); + + const handleProgressKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const video = videoRef.current; + if (!video) return; + + let nextTime = video.currentTime; + switch (event.key) { + case "ArrowLeft": + case "ArrowDown": + nextTime -= 5; + break; + case "ArrowRight": + case "ArrowUp": + nextTime += 5; + break; + case "Home": + nextTime = 0; + break; + case "End": + nextTime = duration; + break; + default: + return; + } + + event.preventDefault(); + event.stopPropagation(); + video.currentTime = Math.max(0, Math.min(duration, nextTime)); + setCurrentTime(video.currentTime); + resetIdleTimer(); + }, + [duration, resetIdleTimer], + ); + + const handleProgressMouseUp = useCallback(() => { + if (!isDragging) return; + setIsDragging(false); + if (wasPlayingRef.current) { + videoRef.current?.play().catch(() => {}); + } + }, [isDragging]); + + // 快进/快退 + const skipTime = useCallback( + (seconds: number) => { + const video = videoRef.current; + if (!video) return; + video.currentTime = Math.max( + 0, + Math.min(duration, video.currentTime + seconds), + ); + resetIdleTimer(); + }, + [duration, resetIdleTimer], + ); + + // 视频事件处理 + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handlePlay = () => { + setIsPlaying(true); + setShowCenterButton(false); + onPlay?.(); + }; + const handlePause = () => { + setIsPlaying(false); + setShowCenterButton(true); + setShowControls(true); + onPause?.(); + }; + const handleEnded = () => { + setIsPlaying(false); + setShowCenterButton(true); + setShowControls(true); + onEnded?.(); + }; + const handleTimeUpdate = () => { + setCurrentTime(video.currentTime); + onTimeUpdate?.(video.currentTime); + }; + const handleLoadedMetadata = () => { + setDuration(video.duration); + setVolumeState(video.volume); + setIsMuted(video.muted); + }; + const handleProgress = () => { + if (video.buffered.length > 0 && duration > 0) { + const end = video.buffered.end(video.buffered.length - 1); + setBuffered(end / duration); + } + onProgress?.(video.buffered); + }; + const handleVolume = () => { + setVolumeState(video.volume); + setIsMuted(video.muted); + onVolumeChange?.(video.volume, video.muted); + }; + const handleWaiting = () => setIsLoading(true); + const handleCanPlay = () => setIsLoading(false); + + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handleEnded); + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("loadedmetadata", handleLoadedMetadata); + video.addEventListener("progress", handleProgress); + video.addEventListener("volumechange", handleVolume); + video.addEventListener("waiting", handleWaiting); + video.addEventListener("canplay", handleCanPlay); + + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handleEnded); + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + video.removeEventListener("progress", handleProgress); + video.removeEventListener("volumechange", handleVolume); + video.removeEventListener("waiting", handleWaiting); + video.removeEventListener("canplay", handleCanPlay); + }; + }, [ + duration, + onPlay, + onPause, + onEnded, + onTimeUpdate, + onProgress, + onVolumeChange, + ]); + + // 键盘快捷键 + const handlePlayerKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const video = videoRef.current; + if (!video) return; + switch (event.key) { + case " ": + event.preventDefault(); + togglePlay(); + break; + case "ArrowLeft": + event.preventDefault(); + skipTime(-5); + break; + case "ArrowRight": + event.preventDefault(); + skipTime(5); + break; + case "ArrowUp": + event.preventDefault(); + if (video.volume < 1) + video.volume = Math.min(1, video.volume + 0.1); + break; + case "ArrowDown": + event.preventDefault(); + if (video.volume > 0) + video.volume = Math.max(0, video.volume - 0.1); + break; + case "f": + event.preventDefault(); + toggleFullscreen(); + break; + case "m": + event.preventDefault(); + toggleMute(); + break; + } + }, + [togglePlay, skipTime, toggleFullscreen, toggleMute], + ); + + // 全局鼠标/触摸事件(用于拖动进度条) + useEffect(() => { + if (!isDragging) return; + const handleMouseMove = (e: MouseEvent) => + handleProgressMouseMove(e as unknown as React.MouseEvent); + const handleMouseUp = () => handleProgressMouseUp(); + const handleTouchMove = (e: TouchEvent) => + handleProgressMouseMove(e as unknown as React.TouchEvent); + const handleTouchEnd = () => handleProgressMouseUp(); + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + document.addEventListener("touchend", handleTouchEnd); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + }, [isDragging, handleProgressMouseMove, handleProgressMouseUp]); + + // 清理 idle timer + useEffect(() => { + return () => { + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + }, []); + + const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + const bufferedPercent = buffered * 100; + + return ( +
{ + // 点击视频区域切换播放(但排除控件区域) + if ( + e.target === videoRef.current || + e.target === containerRef.current + ) { + togglePlay(); + } + }} + > +