# 食品添加剂侦探课程 「零食侦探事务所」主题的食品添加剂网络课程。学生以实习侦探身份,围绕一段关于零食添加剂的网络舆论展开探案式学习,在「接收线索 → 开展调查 → 获取证据 → 作出判定」的流程中,逐步建立对食品添加剂的科学认知。 课程面向易班 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 路由管理(Hash 路由 + 懒加载) - Tailwind CSS 4 原子化样式 - Zustand 轻量状态管理 - GSAP + @gsap/react 动效 - dnd-kit 拖拽交互 - Biome 代码格式化与检查 - Husky + lint-staged 提交前检查 - 1920×1080 整体缩放适配 - 横屏访问提示 - vconsole 移动端调试(仅开发环境) - 易班 videoajax.js 封装与用户信息状态管理 ## 开发命令 ```bash bun install # 安装依赖 bun dev # 启动开发服务器 bun run build # 类型检查并构建生产版本 bun run preview # 预览生产构建 bun run lint # 代码检查 bun run lint:fix # 自动修复代码问题 ``` 修改代码后至少运行 `bun run lint`;涉及路由、构建配置、页面结构或类型系统的改动,同时运行 `bun run build`。 ## 应用配置 页面标题、描述、图标、课程标识和 viewport 统一维护在 [src/app.config.ts](src/app.config.ts)。修改后需重启开发服务。 ## 目录结构 ```text src/ ├── 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)。 ## 课程交互能力 项目内置一套课程交互能力,用于支撑探案式学习中的对话、引导、拖拽、弹层等场景。这些源码直接维护在当前仓库,可按需修改。 ### 引导聚光灯 SpotlightGuide 适合课程首次学习时突出关键操作区域。在元素上标注 `data-guide`,在页面初始化时启动引导: ```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, }, ], }); }, []); return ( <> ); } ``` - `target` 支持 CSS selector 或 `{ dataGuide: "xxx" }`。 - `placement`:`top` / `bottom` / `left` / `right` / `auto`。 - `allowTargetClick`:让高亮区域下方的真实元素可被点击,但不会自动切换步骤,需在业务点击逻辑中手动调用 `nextStep` / `finishSpotlight` / `closeSpotlight`。 - 同一 `courseId + spotlightId` 默认只显示一次(完成或跳过即标记已完成),可用 `resetSpotlight(courseId, spotlightId)` 重新显示。 - 组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统 `prefers-reduced-motion`。 ### 引导对话 GuideDialog 适合在剧情节点弹出侦探助手对话、问题选择等。通过 `defineDialog` 定义、`GuideDialog` 渲染: ```tsx import { defineDialog, GuideDialog } from "@/components/common"; const dialog = defineDialog({ /* steps, characters, ... */ }); dialog.start(); ``` ### 拖拽交互 DragDrop 基于 dnd-kit,适合证据分类、调查轨迹排序、选项匹配等交互: ```tsx import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop"; import { useCourseCanvas } from "@/course-runtime/canvas"; const { scale } = useCourseCanvas(); 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" > {/* 课程弹层内容 */} ``` `Portal` 默认挂载到 `document.body`,`Overlay` 默认 `placement="app"`(跟随画布缩放);也可用 `placement="app"` 或 `container` 自定义挂载位置。挂载优先级:`container` > `placement` > 组件默认值。 ### Toast 轻提示 `App` 已默认接入 `ToastProvider` 和 `ToastViewport`,页面中可直接使用: ```tsx import { useToast } from "@/components/common"; const toast = useToast(); toast.success("证据已收入档案"); toast.error("选择有误,请重新核验"); ``` 支持 `info` / `success` / `warning` / `error` / `show`。 ## 易班平台接入 项目面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。 ### 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`: ```text http://localhost:5173/?courseId=123&uid=456&yiban=1 ``` ### 易班能力 ```ts import { setUserStorage, getUserStorage, reportCourseProgress, getCourseProgress, saveBreakPoint, getBreakPoint, saveUserAnswer, } from "@/utils/yiban"; import { useUserStore } from "@/stores"; ``` - **存取用户数据**:`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` 等。 ### 移动端调试 开发环境自动启用 vconsole,可在移动端查看 Console、Network、Element、Storage。 ## 课程画布适配 按 1920×1080 设计稿开发,由 [src/course-runtime/canvas](src/course-runtime/canvas) 的 `CourseCanvas` 组件承担整体缩放,默认 `width=1920`、`height=1080`、`maxScale=1`。 - PC 端:按可视区域等比缩放并居中。 - 大屏:`maxScale` 默认为 1,不超过设计稿原始尺寸,周围留白。 - 移动端:优先横屏访问,竖屏时由 `OrientationGuard` 显示横屏提示。 - 视口优先读取 `visualViewport`,移动端地址栏显示/隐藏时更稳定。 - 页面内容按设计稿像素开发,不使用 rem 适配。 ## 路由 路由集中在 [src/router/index.tsx](src/router/index.tsx),使用 Hash 路由 + `lazyRoute` 懒加载,新增页面遵循该模式并配 `HydrateFallback: RouteFallback`: ```tsx { path: "about", lazy: lazyRoute(() => import("../pages/About")), HydrateFallback: RouteFallback, } ``` 页面组件使用默认导出。 ## License MIT