Files

13 KiB
Raw Permalink Blame History

食品添加剂侦探课程

「零食侦探事务所」主题的食品添加剂网络课程。学生以实习侦探身份,围绕一段关于零食添加剂的网络舆论展开探案式学习,在「接收线索 → 开展调查 → 获取证据 → 作出判定」的流程中,逐步建立对食品添加剂的科学认知。

课程面向易班 H5 平台交付,按 1920×1080 设计稿开发,整体等比缩放适配 PC 与移动端横屏。

课程概要

学习路径

学生首次学习沿剧情主线推进,完成每个阶段的调查任务即可积累经验、提升侦探等级:

实习侦探(序幕)
  ↓
助理侦探(第一幕)
  ↓
初级侦探(第二幕)
  ↓
中级侦探(第三幕)
  ↓
高级侦探(第四幕)
  ↓
食安神探(终章 · 零食风暴)

四条调查线

序幕从前情舆论视频切入,学生从求助信中梳理出四条调查线索,分别对应四幕核心案件:

案件 调查线 待核验说法 核心问题
🌈 色彩迷局 着色剂身份线 / 来源辨析线 「颜色这么鲜艳,色素肯定没少加」 食品里的「色素」是什么?天然与人工合成能直接代表安全吗?
🍊 味觉密码 香精与风味线 「水果味都是香精调出来的」 食品的香气从哪里来?香精的使用是否有据可依?
🍮 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 封装与用户信息状态管理

开发命令

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/
├── 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。

课程交互能力

项目内置一套课程交互能力,用于支撑探案式学习中的对话、引导、拖拽、弹层等场景。这些源码直接维护在当前仓库,可按需修改。

引导聚光灯 SpotlightGuide

适合课程首次学习时突出关键操作区域。在元素上标注 data-guide,在页面初始化时启动引导:

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 (
		<>
			<button data-guide="start-button">立即立案</button>
			<GuideSpotlight />
		</>
	);
}
  • 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 渲染:

import { defineDialog, GuideDialog } from "@/components/common";

const dialog = defineDialog({ /* steps, characters, ... */ });

dialog.start();

拖拽交互 DragDrop

基于 dnd-kit,适合证据分类、调查轨迹排序、选项匹配等交互:

import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop";
import { useCourseCanvas } from "@/course-runtime/canvas";

const { scale } = useCourseCanvas();

<DragDropProvider scale={scale} onDragEnd={(event) => console.log(event.active.id, event.over?.id)}>
	<Draggable id="tartrazine">柠檬黄</Draggable>
	<Droppable id="natural-zone">天然来源</Droppable>
</DragDropProvider>

由于画布整体使用 transform: scale(...) 缩放,拖拽内容在画布内时建议给 DragDropProvider 传入当前 scale,避免拖拽元素与手指/鼠标移动不同步。

动效 GSAP

项目通过 src/utils/motion.ts 统一封装 GSAP 入口:gsap / useGSAP 已注册导出,提供统一缓动常量 EASE_OUT / EASE_IN_OUT,以及 useReducedMotion() 响应 prefers-reduced-motion 做动画降级。后续课程页面需要 ScrollTrigger 等插件时,在该文件按需 registerPlugin。

import { gsap, useGSAP, EASE_OUT, useReducedMotion } from "@/utils";

弹层 Overlay / Portal

Overlay 已内置 Portal,默认挂载到 App 缩放画布内部,适合课程弹窗跟随画布缩放:

import { Overlay } from "@/components/common";

<Overlay
	open={open}
	onClose={() => setOpen(false)}
	backgroundImage="url(./images/dialog-bg.png)"
	backgroundClassName="bg-cover bg-center"
>
	{/* 课程弹层内容 */}
</Overlay>

Portal 默认挂载到 document.body,Overlay 默认 placement="app"(跟随画布缩放);也可用 placement="app" 或 container 自定义挂载位置。挂载优先级:container > placement > 组件默认值。

Toast 轻提示

App 已默认接入 ToastProvider 和 ToastViewport,页面中可直接使用:

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/videoajax.js,需与构建产物同源、同相对路径部署。若易班平台提供新版本,确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录能力。

环境判断

启动时从 URL 读取 courseId / courseid、uid。生产环境中只要存在以上任意参数即视为易班环境。

本地开发即使 URL 带这些参数也默认使用 sessionStorage,避免误请求易班接口:

http://localhost:5173/?courseId=123&uid=456

本地确需强制走易班接口时追加 yiban=1:

http://localhost:5173/?courseId=123&uid=456&yiban=1

易班能力

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 的 CourseCanvas 组件承担整体缩放,默认 width=1920、height=1080、maxScale=1。

  • PC 端:按可视区域等比缩放并居中。
  • 大屏:maxScale 默认为 1,不超过设计稿原始尺寸,周围留白。
  • 移动端:优先横屏访问,竖屏时由 OrientationGuard 显示横屏提示。
  • 视口优先读取 visualViewport,移动端地址栏显示/隐藏时更稳定。
  • 页面内容按设计稿像素开发,不使用 rem 适配。

路由

路由集中在 src/router/index.tsx,使用 Hash 路由 + lazyRoute 懒加载,新增页面遵循该模式并配 HydrateFallback: RouteFallback:

{
	path: "about",
	lazy: lazyRoute(() => import("../pages/About")),
	HydrateFallback: RouteFallback,
}

页面组件使用默认导出。

License

MIT