feat: 订餐终端设备概览新增餐线营养秤Tab + 净菜订单新增生成历史/生成方式列/就餐人数

- din-terminal.html: 设备概览新增「餐线营养秤」Tab(四维度子页切换),表头列筛选 + 人脸识别视频回放弹窗 + 运行日志报错详情
- prod-clean-order.html: 新增「净菜订单生成历史」按钮与弹窗、主列表「生成方式」列、详情抽屉自动订单「就餐人数」列
- 新增文档: 异常场景素材-秤体视角 / 加工厂硬件流程说明 / 角色场景流程图 / 营养秤多维状态矩阵
This commit is contained in:
zhangyan
2026-08-27 09:00:12 +08:00
parent 059d1c82ab
commit 40e0b311c4
7 changed files with 1314 additions and 8 deletions
@@ -0,0 +1,179 @@
# 营养秤异常场景素材(秤体视角)
> 为模型师提供素材,以秤体为主体视角,每条场景按「机器描述 → 场景分析」组织。
---
## 1. 夹子是否异常
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 1.1 | 初始在夹盘,取餐后仍在夹盘 | ✅ 正常 |
| 1.2 | 初始在食物盆,取餐后仍在食物盆 | ✅ 正常 |
| 1.3 | 秤检测到:夹子初始在夹盘,取餐后检测到夹子在食物盆 | 用户取餐后把夹子扔进盆里,未归位 |
| 1.4 | 秤检测到:夹子初始在食物盆,取餐后检测到夹子在夹盘 | 用户把夹子从盆里拿出来放回夹盘 |
| 1.5 | 秤检测到:夹子初始在夹盘,取餐后夹子信号消失 | 用户拿着夹子走了 |
| 1.6 | 秤检测到:夹子初始在食物盆,取餐后夹子信号消失 | 用户从盆里抓起夹子带走了 |
| 1.7 | 秤检测到:本轮取餐开始前,夹子就不在夹盘上 | 上一轮遗留的异常状态,初始态已错 |
---
## 2. 是否本人取餐
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 2.1 | 人脸 ID=A,取餐动作归属 A | ✅ 正常 |
| 2.2 | 人脸 ID=A,取餐动作归属 B | B 的手伸进画面取了菜。身份冒用 |
| 2.3 | 人脸未识别到,但发生了重量减少 | 识别故障,取餐无主 |
| 2.4 | 人脸 ID=A,A 正在取餐,B 的手也伸入 | 多人混取 |
| 2.5 | 两人同时站在识别区,两笔重量同时发生 | 两人同时取餐 |
| 2.6 | 两人同时站在识别区,重量分先后两次 | 两人轮流取餐 |
| 2.7 | 人脸 ID=A 持续识别中突断(扭头),数秒后恢复,期间发生了重量变化 | 人脸丢失又恢复,中间段归属存疑 |
| 2.8 | 人脸 ID=A 持续识别中突断(摄像头被遮挡),无恢复 | 非主动丢失,可能是物品遮挡镜头 |
---
## 3. 是否正常取餐
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 3.1 | 重量陡降 N 克,随后稳定,期间人脸持续识别 | ✅ 正常取餐 |
| 3.2 | 重量陡降 N 克,数秒后重量恢复 N 克,人脸持续识别 | 夹起来看了看又放回去了(反悔) |
| 3.3 | 重量连续多次小幅波动(±几克),最终稳定在一个降幅 | 夹子试探/抖动,多次夹取后最终取走 |
| 3.4 | 重量无变化,但夹子从夹盘拿起又放回 | 空夹——夹了但什么都没夹到 |
| 3.5 | 重量陡降 N 克,过程中有短暂微幅回升,最终降幅 < N | 夹起时汤汁滴落或食物部分滑落回盆,实际取走量小于夹取量 |
| 3.6 | 重量陡降 N 克,且邻盆也检测到微小重量变化 | 食物掉落台面或汤汁溅到邻盆 |
| 3.7 | 重量出现持续不规则波动,幅度较小,期间检测到夹子未动作 | 用户身体倚靠或手碰到台面 |
| 3.8 | 邻盆无夹子动作但发生微小重量变化 | 用户取餐时碰到了旁边的盆 |
---
## 4. 重量变化是否合理
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 4.1 | 重量减少,幅度在合理范围内 | ✅ 正常 |
| 4.2 | 重量增加(排除补菜后) | 方向异常——可能是回退、夹子掉入盆中、或人为按压 |
| 4.3 | 重量单次减少远超单人单次合理取餐量 | 超量——可能是夹子掉入盆中被当作取餐、或传感器异常 |
| 4.4 | 重量在无操作期间持续缓慢变化(非阶跃) | 传感器漂移,或热食蒸汽导致读数偏移 |
| 4.5 | 重量瞬时跳变(毫秒级阶跃),无夹子动作伴随 | 秤体被碰撞或台面振动 |
| 4.6 | 当前盆内净重接近于零(仅剩碎屑),发生了取餐动作 | 空盆取餐——边界值易误判,可能取到的是盆重波动 |
---
## 5. 是否发生回退
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 5.1 | 取餐完成后,重量出现增加,人脸 ID 与取餐时一致 | 用户从自己盘里夹回一部分到盆中 |
| 5.2 | 取餐完成后,重量增加至接近初始值,人脸 ID 与取餐时一致 | 用户全部倒回,完全反悔。全程净变化≈0 |
| 5.3 | 取餐完成后,重量增加过程中人脸信号中断(扭头)又恢复 | 回退过程中转头,中间段归属存疑 |
| 5.4 | 取餐时人脸 ID=A,回退时人脸 ID=B | A 取走的菜,B 往回放。跨用户反向操作 |
| 5.5 | 无人识别状态,重量先增后恢复(手按/抬秤面),随后人脸识别进入,正常取餐 | 恶意操作——取餐前人为改变初始重量基准,骗取重量差 |
| 5.6 | 取餐完成后,重量出现增加,但回退餐品与当前盆内菜品不一致(用户从 A 盆取了菜,却往回放到 B 盆) | 跨菜品回退——回退食物不属于当前盆,导致 B 盆重量增加但菜品成分不匹配 |
---
## 6. 秤体能否检测到变化
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 6.1 | 重量持续以 1~2g/次的幅度递减,单次变化 < 传感器分辨率,累计已超阈值但全程无触发 | 逐克静默取餐——用户少量多次夹取,秤"感觉不到"但食物已大量减少 |
| 6.2 | 重量极缓慢持续下降,斜率低于触发阈值,累计变化已达显著值 | 漂移式取餐——用户缓慢倾倒食物,规避检测 |
| 6.3 | 重量先降 A 克又增 B 克,|A-B| < 阈值,净变化近似为零 | 取多补少——取走一部分又补回一部分,净值抵消 |
| 6.4 | 多次微量取餐,每次单独 < 阈值,累计远超阈值 | 分散取餐——需要累积判定才能发现 |
| 6.5 | 开餐/补餐后重量仍在波动中(食物刚倒入、盆未稳定),此时发生取餐 | 补餐后不稳定期取餐——基准未锁定,变化量计算不准 |
---
## 7. 是否为服务员操作
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 7.1 | 重量陡增(幅度远超单人回退),无人脸识别 | 服务员补菜 |
| 7.2 | 重量发生整体替换式变化(先清零后陡增),无人脸识别 | 服务员换整盆菜 |
| 7.3 | 重量大幅减少(负向变化 ≈ 盆自重),读数变为负值(低于去皮基准),无人脸识别 | 服务员收走空盆 |
| 7.4 | 重量陡增期间,恰有用户在识别区取餐 | 服务员补菜与用户取餐并发——重量增和减同时发生 |
| 7.5 | 重量出现频繁小幅不规则波动,无人脸识别,无夹子动作 | 保洁擦拭台面 |
---
## 8. 秤体自身是否正常
| # | 机器描述 | 场景分析 |
|---|---------|---------|
| 8.1 | 重量短时间内出现巨大跳变后恢复,无夹子动作 | 秤体被碰撞或搬动 |
| 8.2 | 热食蒸汽持续熏蒸秤面,重量读数持续单向漂移 | 温度干扰导致传感器漂移 |
| 8.3 | 运行中突然断电→重启,期间发生的取餐数据全部丢失 | 断电丢数——重启后状态不连续 |
| 8.4 | 网络中断,秤端本地缓存数据未上报 | 断网积压——恢复后需补传,时序可能错乱 |
| 8.5 | 本秤检测到微小振动,但夹子和重量均无变化,邻秤恰好有取餐事件 | 邻秤串扰——隔壁取餐的振动传到了本秤 |
| 8.6 | 台面上有上一轮遗留的食物残渣/汤汁,导致初始重量偏高 | 台面未清洁——初始重量含异物 |
| 8.7 | 台面上有残留物品,当前用户取餐后残留物被一并带走 | 遗留物被清走——减重包含异物重量 |
---
## 9. 多人交叉场景
### 9.1 交叉矩阵总览
```
A 的状态 × B 的动作 = 场景
B仅入镜 B取餐 B回退 B按压秤面
A取餐中 M1 M2 M3 M4
A完成未离开 M5 M6 M7 M8
A识别未取餐 — M9 M10 —
```
### 9.2 A 取餐中 + B 入镜
| # | A 状态 | B 动作 | 机器描述 | 场景分析 |
|---|--------|--------|---------|---------|
| M1 | A 取餐中 | B 入镜不动 | A 人脸持续,画面中出现 B 人脸;重量仅 A 侧在变化 | 两人同时被识别,但只有 A 在取。订单归属以 A 为准 |
| M2 | A 取餐中 | B 取餐 | A/B 人脸交替或同框;两笔重量下降交织发生 | 两人同时取餐,需帧级对齐人脸与重量变化,拆分成两笔 |
| M3 | A 取餐中 | B 回退 | A 人脸持续,B 人脸同时出现;重量增加与 B 入镜时序吻合 | A 在减重、B 在增重,净值需解耦 |
| M4 | A 取餐中 | B 按压秤面 | A/B 人脸同框;重量突然阶跃(增/减),与任何夹子动作无关 | A 正在取餐时 B 恶意改变基准,A 的取餐量被污染 |
### 9.3 A 完成未离开 + B 入镜
| # | A 状态 | B 动作 | 机器描述 | 场景分析 |
|---|--------|--------|---------|---------|
| M5 | A 完成未离开 | B 入镜不动 | A/B 双人脸同框,重量已稳定 | A 占位,B 等待。系统需判定当前订单归属仍为 A |
| M6 | A 完成未离开 | B 取餐 | A 人脸存在,B 人脸出现;新重量下降与 B 人脸时序匹配 | B 接力取餐,需将新减重从 A 名下切换到 B |
| M7 | A 完成未离开 | B 回退 | A 人脸存在,B 人脸出现;重量增加与 B 入镜时序吻合 | A 已取完,B 往回放。回退归属应为 B |
| M8 | A 完成未离开 | B 按压秤面 | A/B 双人脸同框;重量异常阶跃无夹子动作 | A 的结算基准被篡改 |
### 9.4 A 识别未取餐 + B 入镜
| # | A 状态 | B 动作 | 机器描述 | 场景分析 |
|---|--------|--------|---------|---------|
| M9 | A 识别未取餐 | B 取餐 | A 人脸存在,B 人脸出现;重量下降与 B 入镜同步,A 无动作 | A 站着没动,B 先取了。A 的订单怎么处理?空单关闭还是保留? |
| M10 | A 识别未取餐 | B 回退 | A 人脸存在,B 人脸出现;重量增加与 B 入镜同步 | A 还没取餐,重量先涨了。回退归属 B,A 订单状态存疑 |
### 9.5 A 离开后 B 继承异常状态
| # | A 遗留 | B 动作 | 机器描述 | 场景分析 |
|---|--------|--------|---------|---------|
| M11 | A 取餐后夹子留在盆里 | B 入镜取餐 | 夹子初始在食物盆(A 遗留);B 人脸识别正常,取餐动作正常 | B 继承异常初始态,夹子位置判定基准被 A 污染 |
| M12 | A 把夹子带走 | B 入镜取餐 | 夹盘无夹子;B 已识别,用手或其他方式取餐 | 工具缺失,B 取餐方式改变 |
| M13 | A 遗留汤汁/残渣在台面 | B 入镜取餐 | 初始重量含遗留物;B 取餐过程中可能碰掉残渣 | B 的取餐计量含遗留物干扰 |
| M14 | A 按压秤面后离开 | B 入镜取餐 | 秤未归零,初始基准被 A 篡改;B 正常取餐 | 重量绝对值不准,差值也可能偏移 |
| M15 | A 完成→离开→B 入镜取餐 | B 未识别,重量下降 | 摄像头未能识别 B,但发生了取餐 | 设备识别故障,取餐无主 |
| M16 | A 完成→离开→B 入镜回退 | B 未识别,重量增加 | 摄像头未能识别 B,但发生了回退 | 设备识别故障,回退无主 |
---
## 维度统计
| 维度 | 条目数 |
|------|:-----:|
| 1. 夹子是否异常 | 7 |
| 2. 是否本人取餐 | 8 |
| 3. 是否正常取餐 | 8 |
| 4. 重量变化是否合理 | 6 |
| 5. 是否发生回退 | 6 |
| 6. 秤体能否检测到变化 | 5 |
| 7. 是否为服务员操作 | 5 |
| 8. 秤体自身是否正常 | 7 |
| 9. 多人交叉场景 | 16 |
| **合计** | **68** |
@@ -0,0 +1,172 @@
# 加工厂硬件流程说明
> 适用范围:`other-module/canteen-device/` 下「卫生供应」分组所覆盖的加工厂硬件终端(「健康生产」「营养用餐」分组属餐厅/食堂现场,不在加工厂范畴)
> 创建时间:2026-08-13
> 说明:本文档描述的硬件设备,以已设计(有实际原型页面)的设备为准;仅占位、未设计的设备视为"暂不存在"。
---
## 目录
- **一、业务描述**
- 1.1 加工厂介绍
- 1.2 硬件设备清单
- 1.3 设备之间的关系
- 1.4 完整业务流程
- **二、硬件使用文档**
- 2.1 毛菜验收秤
---
## 一、业务描述
### 1.1 加工厂介绍
加工厂承担「卫生供应」业务,将毛菜加工成可直接净配、包装、发货的净菜包。本期共 8 类硬件设备,其中 7 台已设计、1 台(毛菜卫检设备)待硬件选型。
### 1.2 硬件设备清单
#### 已设计设备(共 7 台)
| 序号 | 设备 | 目录 | 核心职能 | 设计位置 |
|---|---|---|---|---|
| 1 | 毛菜验收秤 | `raw-veg-inspection-scale` | 毛菜到货验收、称重、AI 食材识别、质检判定、食材采样 | 设备端 8 页 + PRD / FLOW |
| 2 | 毛菜库存柜 | `raw-veg-inventory-cabinet` | 毛菜储位分配、出入库管理 | 后台 `sup-warehouse` 仓储管理 / `sup-in-raw` 毛菜入库 |
| 3 | 加工毛菜秤 | `raw-veg-processing-scale` | 毛菜加工任务、批次称重、清洗操作 | 设备端 7 页 + FLOW |
| 4 | 厨配秤 | `clean-veg-ratio-scale` | 净菜上秤识别、加工确认(含尺寸/形状/刀工规格) | 设备端 6 页 |
| 5 | 净菜库存柜 | `clean-veg-inventory-cabinet` | 净菜库存管理与盘点 | 后台 `sup-warehouse` 仓储管理 / `sup-in-cleanin` 净菜入库 |
| 6 | 净配秤 | `ratio-scale` | 净菜包 / 餐品净菜包净配(主辅材 + 调料双区称重) | 设备端 5 页 + PRD |
| 7 | 净菜包装贴标秤 | `clean-veg-packaging-labeler` | 核对实物、包装塑封、打印标签、发货 | 设备端 4 页 |
#### 待硬件选型设备(共 1 台)
| 设备 | 目录 | 规划职能 | 现状 |
|---|---|---|---|
| 毛菜卫检设备 | `raw-veg-hygiene-inspection` | 毛菜卫生检测与质量判定 | 占位页,待硬件选型(后台 `sup-hygiene-check` 卫生检测已设计) |
> 另有历史遗留目录 `raw-veg-washing-scale`(毛菜清洗秤),已并入「加工毛菜秤」,未出现在导航中,不计入设备清单。
### 1.3 设备之间的关系
7 台已设计设备构成一条**毛菜 → 净菜包**的加工流水线,上游设备的产出是下游设备的输入:
```mermaid
flowchart LR
A[毛菜验收秤<br/>到货验收·称重·AI识别] --> B[毛菜库存柜<br/>储位分配·出入库]
B --> C[加工毛菜秤<br/>毛菜清洗]
C --> D[厨配秤<br/>净菜切配·加工确认]
D --> E[净菜库存柜<br/>净菜库存·盘点]
E --> F[净配秤<br/>净菜包/餐品组合打包]
F --> G[净菜包装贴标秤<br/>包装·打印·发货]
```
| 设备 | 上游(输入) | 下游(输出) |
|---|---|---|
| 毛菜验收秤 | 后台「绿色种采-采供管理」填好配送信息的采购订单 | 毛菜入库记录(后台建档)→ 毛菜库存柜 |
| 毛菜库存柜 | 毛菜验收秤入库的毛菜 | 按储位取出的毛菜 → 加工毛菜秤 |
| 加工毛菜秤 | 毛菜库存柜取出的毛菜 + 后台推送的毛菜加工任务 | 清洗完成的毛菜 → 厨配秤 |
| 厨配秤 | 加工毛菜秤清洗完成的毛菜 + 切配任务 | 切配好的净菜 → 净菜库存柜 |
| 净菜库存柜 | 厨配秤切配好的净菜 | 库存净菜 → 净配秤 |
| 净配秤 | 净菜库存柜取出的净菜 + 净配任务 | 净配完成的净菜包 → 净菜包装贴标秤 |
| 净菜包装贴标秤 | 净配秤净配完成的订单(净菜包 / 餐品净菜包) | 已包装、已打印标签、可发货的净菜包 |
> 本期所有硬件设备以**场所(食堂/加工厂)为维度**组织数据,设备是场所的附属资源,不作为独立维度。任务单、验收记录、设备配置等均归属到场所。
### 1.4 完整业务流程
加工厂业务是一条「**采购 → 验收 → 入库 → 清洗 → 切配 → 入库 → 净配 → 包装 → 发货**」的完整闭环,全程由后台系统(Web 管理端「卫生供应 / 绿色种采」)驱动任务下发,硬件终端负责现场执行并回传数据。
```mermaid
flowchart TD
subgraph 后台系统
P[采供管理<br/>采购下单·填配送信息]
T[任务整合<br/>定时推送加工任务]
end
subgraph 加工厂现场
S1[毛菜验收秤<br/>到货验收·采样]
S2[毛菜库存柜<br/>储位分配·出入库]
S3[加工毛菜秤<br/>毛菜清洗]
S4[厨配秤<br/>净菜切配]
S5[净菜库存柜<br/>净菜库存·盘点]
S6[净配秤<br/>净菜包净配]
S7[净菜包装贴标秤<br/>包装·发货]
end
P --> S1
S1 --> S2
S2 --> S3
T -.毛菜加工任务.-> S3
T -.切配任务.-> S4
S3 --> S4
S4 --> S5
S5 --> S6
T -.净配任务.-> S6
S6 --> S7
S7 --> OUT[发货/外售·内供]
S1 --> DB[(毛菜入库<br/>后台建档)]
S3 --> DB2[(清洗记录)]
S4 --> DB3[(加工记录)]
S6 --> DB4[(净配记录)]
S7 --> DB5[(包装·发货记录)]
```
| 阶段 | 执行设备 | 关键动作 | 后台联动 |
|---|---|---|---|
| ① 采购 | 后台采供管理 | 采购下单、填配送信息 | 生成采购订单 PO |
| ② 到货验收 | 毛菜验收秤 | 称重、AI 识别、质检判定、采样 | 毛菜入库建档、溯源码分配 |
| ③ 毛菜入库 | 毛菜库存柜 | 储位分配、出入库管理 | `sup-warehouse` 仓储管理、`sup-in-raw` 毛菜入库 |
| ④ 毛菜清洗 | 加工毛菜秤 | 批次称重、毛菜加工任务处理 | 毛菜加工任务推送、任务状态回传 |
| ⑤ 净菜切配 | 厨配秤 | 上秤识别、加工确认(切配规格) | 切配任务推送、加工记录 |
| ⑥ 净菜入库 | 净菜库存柜 | 净菜库存管理、盘点 | `sup-warehouse` 仓储管理、`sup-in-cleanin` 净菜入库 |
| ⑦ 净配 | 净配秤 | 净菜包 / 餐品组合打包 | 净配任务推送、净配记录 |
| ⑧ 包装发货 | 净菜包装贴标秤 | 核对、包装、打印标签、发货 | 发货状态回传、配送信息 |
> 「毛菜卫检」环节(对应后台 `sup-hygiene-check` 卫生检测)因设备端尚待硬件选型,未纳入上述闭环;待选型完成后将插入「到货验收」与「毛菜入库」之间。
---
## 二、硬件使用文档
### 2.1 毛菜验收秤
#### 用途
毛菜验收秤负责毛菜到货的**验收、采样、记录查询**。
#### 硬件配置
| 组件 | 用途 |
|---|---|
| 触摸屏 | 交互操作、信息确认、不合格处理 |
| 称重传感器 + 秤盘 | 自动读取重量(kg) |
| 食材识别摄像头 | AI 自动识别食材品种 |
| 场景监控摄像头 | 后台全程静默录像,不参与前端交互 |
#### 登录方式
账号密码登录,登录后保持会话;身份以登录账号为准,未登录无法操作。
#### 场景
| 场景 | 触发条件 | 说明 |
|---|---|---|
| **有订单验收** | 后台采购订单已填配送信息 | 主场景,按订单逐食材验收 |
| **食材采样** | 验收前采集样本 | 验收前置环节 |
#### 按场景使用
##### 场景一:有订单验收(主场景)
1. 在欢迎页输入账号密码登录。
2. 点击「验收食材」,选择要验收的订单。
3. 把食材放上秤盘,系统自动识别并称重;拿起食材即完成该食材的验收。
4. 订单内食材全部验收完后,在弹出的汇总弹窗点「提交并继续」或「提交并关闭」。
##### 场景二:食材采样(验收前置)
1. 在欢迎页点击「食材采样」,登录后进入采样页。
2. 输入食材名称,点击拍照。
3. 点击「保存」或「保存并继续」。
+378 -1
View File
@@ -76,6 +76,7 @@ tbody tr:hover{background:#F8FBFF;}
.tag-orange{background:#FFF7E6;color:var(--warning);}
.tag-red{background:#FFF1F0;color:var(--danger);}
.tag-gray{background:#F5F5F5;color:#999;}
.tag-purple{background:#F9F0FF;color:#722ED1;}
.link{color:var(--primary);cursor:pointer;font-size:var(--font-sm);}.link:hover{color:#40a9ff;}
.pagination{display:flex;align-items:center;justify-content:flex-end;padding:16px 24px;gap:8px;border-top:1px solid var(--border);}
.pagination span{font-size:var(--font-sm);color:var(--text-secondary);}
@@ -109,7 +110,7 @@ tbody tr:hover{background:#F8FBFF;}
.stat-card__label{font-size:var(--font-sm);color:var(--text-secondary);}
.action-bar{display:flex;align-items:center;gap:8px;margin-bottom:16px;}
/* 动态弹窗基础 */
.dyn-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:1010;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s;}
.dyn-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:1100;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s;}
.dyn-overlay.show{opacity:1;}
.dyn-modal{background:#fff;border-radius:var(--radius-lg);max-height:80vh;display:flex;flex-direction:column;transform:translateY(30px);transition:transform .25s;box-shadow:0 12px 40px rgba(0,0,0,0.18);}
.dyn-overlay.show .dyn-modal{transform:translateY(0);}
@@ -311,6 +312,46 @@ tbody tr:hover{background:#F8FBFF;}
/* Tab 内嵌区块标题 */
.ov-section-title{font-size:var(--font-base);font-weight:600;color:var(--text-primary);margin:0 0 12px;display:flex;align-items:center;gap:6px;}
.ov-section-title::before{content:'';display:inline-block;width:3px;height:15px;background:var(--primary);border-radius:2px;}
/* 表头列筛选 */
.th-filter{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-left:4px;border-radius:3px;color:#c0c4cc;font-size:10px;cursor:pointer;vertical-align:-3px;transition:all .15s;}
.th-filter:hover{color:var(--primary);background:#e8f1ff;}
.th-filter--active{color:var(--primary);background:#e8f1ff;}
.th-filter-pop{position:fixed;z-index:1700;min-width:150px;background:#fff;border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 6px 20px rgba(0,0,0,.15);padding:6px;max-height:240px;overflow-y:auto;}
.th-filter-pop__item{padding:6px 10px;font-size:var(--font-sm);color:var(--text-primary);border-radius:4px;cursor:pointer;white-space:nowrap;}
.th-filter-pop__item:hover{background:#f5f8ff;}
.th-filter-pop__item.active{color:var(--primary);background:#e8f1ff;font-weight:500;}
/* ===== 餐线营养秤概览(子页切换) ===== */
.scale-sub-tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid var(--border);}
.scale-sub-tab{padding:8px 16px;font-size:var(--font-sm);color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;user-select:none;transition:all .15s;}
.scale-sub-tab:hover{color:var(--primary);}
.scale-sub-tab.active{color:var(--primary);font-weight:600;border-bottom-color:var(--primary);}
/* ===== 人脸识别视频回放弹窗 ===== */
@keyframes ovScan{0%{top:10%}50%{top:88%}100%{top:10%}}
.face-video-mask{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1600;display:flex;align-items:center;justify-content:center;animation:ovFadeIn .2s ease;}
.face-video-mask.closing{animation:ovFadeOut .2s ease forwards;}
.face-video-modal{width:680px;max-width:92vw;max-height:86vh;background:#fff;border-radius:var(--radius-md);box-shadow:0 12px 40px rgba(0,0,0,.22);display:flex;flex-direction:column;overflow:hidden;animation:ovModalIn .22s ease;}
.face-video-mask.closing .face-video-modal{animation:ovModalOut .18s ease forwards;}
.face-video-modal__header{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);flex-shrink:0;}
.face-video-modal__title{font-size:var(--font-base);font-weight:600;color:var(--text-primary);}
.face-video-modal__close{width:32px;height:32px;border:none;background:none;font-size:20px;cursor:pointer;border-radius:var(--radius-sm);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;}
.face-video-modal__close:hover{background:#f5f5f5;color:var(--text-primary);}
.face-video-modal__body{padding:20px;overflow-y:auto;flex:1;}
.face-video-player{position:relative;background:#141a24;border-radius:var(--radius-md);height:280px;display:flex;align-items:center;justify-content:center;overflow:hidden;}
.face-video-player__scan{position:absolute;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,#40a9ff,transparent);animation:ovScan 3s linear infinite;}
.face-video-player__badge{position:absolute;top:12px;left:14px;font-size:10px;color:#fff;background:rgba(255,77,79,.85);padding:2px 8px;border-radius:10px;z-index:1;}
.face-video-player__meta{position:absolute;bottom:12px;left:14px;right:14px;display:flex;justify-content:space-between;color:rgba(255,255,255,.65);font-size:var(--font-xs);font-family:monospace;z-index:1;}
.face-video-player__play{width:52px;height:52px;border-radius:50%;background:rgba(24,144,255,.18);border:2px solid rgba(24,144,255,.7);display:flex;align-items:center;justify-content:center;color:#40a9ff;cursor:pointer;z-index:1;font-size:18px;}
.face-video-controls{display:flex;align-items:center;gap:10px;padding:14px 4px 4px;}
.face-video-btn{height:30px;padding:0 12px;border:1px solid var(--border);background:#fff;border-radius:var(--radius-sm);cursor:pointer;font-size:var(--font-xs);color:var(--text-secondary);white-space:nowrap;}
.face-video-btn:hover{border-color:var(--primary);color:var(--primary);}
.face-video-btn--primary{min-width:36px;padding:0;display:flex;align-items:center;justify-content:center;font-size:14px;border-color:var(--primary);color:var(--primary);}
.face-video-bar{flex:1;height:6px;background:#f0f0f0;border-radius:3px;overflow:hidden;cursor:pointer;}
.face-video-bar__progress{height:100%;background:var(--primary);border-radius:3px;width:0;}
.face-video-time{font-size:var(--font-xs);color:var(--text-secondary);font-family:monospace;white-space:nowrap;}
.face-video-info{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-top:12px;padding-top:14px;border-top:1px solid #f0f0f0;}
.face-video-info__item{font-size:var(--font-sm);}
.face-video-info__item .label{display:block;font-size:var(--font-xs);color:var(--text-placeholder);margin-bottom:4px;}
.face-video-info__item strong{color:var(--text-primary);font-weight:600;}
</style>
</head>
<body>
@@ -668,6 +709,7 @@ document.querySelectorAll('tbody tr').forEach(row=>{
/* ===== 设备概览大弹窗(原型演示:Tab 集中演示各类设备,实际交付按设备类型显示对应概览) ===== */
/* Tab 定义:仅「智能货柜」已实现内容,其余为占位 */
var OV_TABS=[
{key:'scale',name:'餐线营养秤',icon:'⚖️'},
{key:'plate',name:'餐盘柜',icon:'🍽️'},
{key:'smart',name:'智能货柜',icon:'🗄️'},
{key:'raw',name:'毛菜货柜',icon:'🥬'},
@@ -725,6 +767,7 @@ function switchOvTab(key){
/* 按 Tab 渲染内容:占位 / 智能货柜可视化 */
function renderOvTabBody(key){
if(key==='scale')return renderCanteenScale();
if(key==='smart')return renderSmartCabinet();
if(key==='plate')return renderPlateCabinet();
if(key==='clean')return renderStockCabinet('clean');
@@ -741,6 +784,340 @@ function renderOvTabBody(key){
</div>`;
}
/* ===== 表头列筛选(餐线营养秤四个 Tab 复用) ===== */
var SCALE_FILTERS={status:{},face:{},weight:{},log:{}};
/* 每个 Tab 的列定义:key 列标识 / label 表头文字 / field 数据字段 */
var SCALE_COLS={
status:[
{key:'time',label:'日期/时间',field:'time'},
{key:'item',label:'监控项',field:'item'},
{key:'event',label:'事件',field:'event'}
],
face:[
{key:'start',label:'开始时间',field:'start'},
{key:'end',label:'结束时间',field:'end'},
{key:'status',label:'状态',field:'status'},
{key:'weight',label:'重量变化',field:'weight'},
{key:'total',label:'总重量',field:'total'}
],
weight:[
{key:'start',label:'开始时间',field:'start'},
{key:'end',label:'结束时间',field:'end'},
{key:'target',label:'识别对象',field:'target'},
{key:'submit',label:'提交重量',field:'submit'},
{key:'total',label:'总重量',field:'total'}
],
log:[
{key:'time',label:'日期/时间',field:'time'},
{key:'type',label:'报错类型',field:'type'},
{key:'content',label:'报错内容',field:'code'}
]
};
/* 各维度数据声明(实际数据在对应渲染函数上方赋值) */
var SCALE_STATUS_EVENTS=[];
var SCALE_WEIGHT_RECORDS=[];
var SCALE_LOG_RECORDS=[];
/* 生成带筛选图标的表头单元格 */
function scaleTh(colKey,label,tabKey){
var active=SCALE_FILTERS[tabKey]&&SCALE_FILTERS[tabKey][colKey];
return label+'<span class="th-filter'+(active?' th-filter--active':'')+'" data-tab="'+tabKey+'" data-col="'+colKey+'" onclick="toggleThFilter(event)">▾</span>';
}
/* 生成表头(可选追加操作列,操作列不可筛选) */
function scaleThead(tabKey,actionLabel){
var ths=SCALE_COLS[tabKey].map(function(c){return '<th>'+scaleTh(c.key,c.label,tabKey)+'</th>';}).join('');
if(actionLabel)ths+='<th>'+actionLabel+'</th>';
return '<thead><tr>'+ths+'</tr></thead>';
}
/* 取某 Tab 原始数据 */
function getScaleRows(tabKey){
if(tabKey==='status')return SCALE_STATUS_EVENTS||[];
if(tabKey==='face')return SCALE_FACE_RECORDS||[];
if(tabKey==='weight')return SCALE_WEIGHT_RECORDS||[];
if(tabKey==='log')return SCALE_LOG_RECORDS||[];
return [];
}
/* 应用当前筛选,返回过滤后的行 */
function getFilteredScaleRows(tabKey){
var rows=getScaleRows(tabKey);
var f=SCALE_FILTERS[tabKey]||{};
var cols=SCALE_COLS[tabKey]||[];
return rows.filter(function(r){
for(var i=0;i<cols.length;i++){
var v=f[cols[i].key];
if(v && r[cols[i].field]!==v)return false;
}
return true;
});
}
/* 取某列去重值(用于筛选下拉) */
function getScaleColValues(tabKey,colKey){
var col=(SCALE_COLS[tabKey]||[]).filter(function(c){return c.key===colKey;})[0];
if(!col)return [];
var rows=getScaleRows(tabKey);
var seen={},result=[];
rows.forEach(function(r){
var v=r[col.field];
if(v===undefined||v===null||v==='')return;
if(!seen[v]){seen[v]=1;result.push(v);}
});
return result;
}
function toggleThFilter(e){
e.stopPropagation();
closeThFilter();
var icon=e.currentTarget;
var tabKey=icon.dataset.tab,colKey=icon.dataset.col;
var values=getScaleColValues(tabKey,colKey);
var cur=(SCALE_FILTERS[tabKey]&&SCALE_FILTERS[tabKey][colKey])||'';
var pop=document.createElement('div');
pop.className='th-filter-pop';
pop.id='thFilterPop';
var html='<div class="th-filter-pop__item'+(cur===''?' active':'')+'" onclick="pickThFilter(\''+tabKey+'\',\''+colKey+'\',\'\')">全部</div>';
values.forEach(function(v){
html+='<div class="th-filter-pop__item'+(cur===v?' active':'')+'" onclick="pickThFilter(\''+tabKey+'\',\''+colKey+'\',\''+escapeAttr(v)+'\')">'+escapeHtml(v)+'</div>';
});
pop.innerHTML=html;
document.body.appendChild(pop);
var rect=icon.getBoundingClientRect();
pop.style.left=Math.min(rect.left,window.innerWidth-170)+'px';
pop.style.top=(rect.bottom+4)+'px';
document.addEventListener('click',onDocClickForFilter,{once:true});
}
function onDocClickForFilter(e){
if(!e.target.closest('.th-filter-pop'))closeThFilter();
}
function closeThFilter(){
var p=document.getElementById('thFilterPop');
if(p)p.remove();
}
function pickThFilter(tabKey,colKey,val){
if(!SCALE_FILTERS[tabKey])SCALE_FILTERS[tabKey]={};
SCALE_FILTERS[tabKey][colKey]=val;
closeThFilter();
var body=document.getElementById('scaleSubBody');
if(body)body.innerHTML=renderScaleSubBody(SCALE_SUB_TAB);
}
function escapeHtml(s){
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(s){
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
/* ===== 餐线营养秤:四个维度(子页切换) ===== */
var SCALE_SUB_TABS=[
{key:'status',name:'设备状态监控'},
{key:'weight',name:'重量变化数据'},
{key:'face',name:'人脸识别数据'},
{key:'log',name:'运行日志'}
];
var SCALE_SUB_TAB='status';
function renderCanteenScale(){
const tabsHtml=SCALE_SUB_TABS.map(t=>`<div class="scale-sub-tab${t.key===SCALE_SUB_TAB?' active':''}" data-key="${t.key}" onclick="switchScaleSubTab('${t.key}')">${t.name}</div>`).join('');
return `<div class="scale-sub-tabs">${tabsHtml}</div><div id="scaleSubBody">${renderScaleSubBody(SCALE_SUB_TAB)}</div>`;
}
function switchScaleSubTab(key){
SCALE_SUB_TAB=key;
document.querySelectorAll('#ovTabBody .scale-sub-tab').forEach(t=>t.classList.toggle('active',t.dataset.key===key));
const body=document.getElementById('scaleSubBody');
if(body)body.innerHTML=renderScaleSubBody(key);
}
function renderScaleSubBody(key){
if(key==='status')return renderScaleStatus();
if(key==='face')return renderScaleFace();
if(key==='weight')return renderScaleWeight();
if(key==='log')return renderScaleLog();
return '';
}
/* 维度1:设备状态监控 — 状态事件流水 */
SCALE_STATUS_EVENTS=[
{time:'2026-08-25 08:15:32.456',item:'网络连接',event:'重新连接成功'},
{time:'2026-08-25 08:00:00.000',item:'运行状态',event:'开餐'},
{time:'2026-08-25 07:40:12.511',item:'运行状态',event:'加餐'},
{time:'2026-08-25 06:30:00.000',item:'设备开关机',event:'开机'},
{time:'2026-08-25 04:12:33.098',item:'运行状态',event:'换餐'},
{time:'2026-08-24 23:00:00.000',item:'设备开关机',event:'关机'},
{time:'2026-08-24 22:30:18.764',item:'运行状态',event:'闭餐'},
{time:'2026-08-24 21:00:02.130',item:'运行状态',event:'进入待机'}
];
function renderScaleStatus(){
const rows=getFilteredScaleRows('status').map(s=>`<tr><td>${s.time}</td><td>${s.item}</td><td>${s.event}</td></tr>`).join('');
return `<div class="ov-section-title">设备状态监控</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('status')}<tbody>${rows}</tbody></table></div>`;
}
/* 维度2:人脸识别数据 — 开始/结束时间 / 状态 / 用户类型 / 重量变化 + 视频回放 */
var SCALE_FACE_RECORDS=[
{start:'2026-08-25 08:15:23.456',end:'2026-08-25 08:15:58.210',status:'刷脸+离开',userType:'系统用户',total:'486.5g',weight:'+486.5g'},
{start:'2026-08-25 08:14:51.203',end:'2026-08-25 08:15:02.118',status:'刷脸',userType:'系统用户',total:'462.0g',weight:'+462.0g'},
{start:'2026-08-25 08:13:47.889',end:'2026-08-25 08:14:10.540',status:'离开',userType:'系统用户',total:'0.0g',weight:'-445.8g'},
{start:'2026-08-25 08:12:09.310',end:'2026-08-25 08:12:30.775',status:'刷脸',userType:'陌生人',total:'0.0g',weight:'+0.0g'}
];
function renderScaleFace(){
const statusTagMap={
'刷脸':'<span class="tag tag-blue">刷脸</span>',
'离开':'<span class="tag tag-gray">离开</span>',
'刷脸+离开':'<span class="tag tag-purple">刷脸+离开</span>'
};
const rows=getFilteredScaleRows('face').map((f)=>{
const statusTag=statusTagMap[f.status]||'<span class="tag tag-gray">'+escapeHtml(f.status)+'</span>';
const isPos=f.weight.startsWith('+');
const weightHtml=`<span style="color:${isPos?'var(--success)':'var(--danger)'}">${f.weight}</span>`;
return `<tr><td>${f.start}</td><td>${f.end}</td><td>${statusTag}</td><td>${weightHtml}</td><td>${f.total}</td><td><a class="link" onclick="showFaceVideo('${f.start}')">回放视频</a></td></tr>`;
}).join('');
return `<div class="ov-section-title">人脸识别数据</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('face','操作')}<tbody>${rows}</tbody></table></div>`;
}
/* 录像回放弹窗:自动播放 + 快进/后退 + 进度条跳转 */
var FACE_VIDEO={playing:false,current:0,duration:30,timer:null};
function showFaceVideo(start){
const f=SCALE_FACE_RECORDS.find(x=>x.start===start);
if(!f)return;
FACE_VIDEO.current=0;
FACE_VIDEO.duration=30;
FACE_VIDEO.playing=true;
const mask=document.createElement('div');
mask.className='face-video-mask';
mask.id='faceVideoMask';
mask.innerHTML=`<div class="face-video-modal">
<div class="face-video-modal__header">
<div class="face-video-modal__title">人脸识别视频回放 — ${f.start}</div>
<button class="face-video-modal__close" onclick="closeFaceVideo()">&times;</button>
</div>
<div class="face-video-modal__body">
<div class="face-video-player">
<span class="face-video-player__badge">● REC</span>
<div class="face-video-player__scan"></div>
<div class="face-video-player__play" onclick="faceVideoPlayPause()">▶</div>
<div class="face-video-player__meta"><span>CAM-01</span><span>${f.start}</span></div>
</div>
<div class="face-video-controls">
<button class="face-video-btn" onclick="faceVideoSeek(-10)">⏪ 后退10s</button>
<button class="face-video-btn face-video-btn--primary" id="faceVideoPlayBtn" onclick="faceVideoPlayPause()">⏸</button>
<button class="face-video-btn" onclick="faceVideoSeek(10)">前进10s ⏩</button>
<div class="face-video-bar" id="faceVideoBar" onclick="faceVideoSeekTo(event)"><div class="face-video-bar__progress" id="faceVideoProgress"></div></div>
<div class="face-video-time" id="faceVideoTime">00:00 / 00:30</div>
</div>
<div class="face-video-info">
<div class="face-video-info__item"><span class="label">识别时间</span><strong>${f.start}</strong></div>
<div class="face-video-info__item"><span class="label">状态</span><strong>${f.status}</strong></div>
<div class="face-video-info__item"><span class="label">用户类型</span><strong>${f.userType}</strong></div>
<div class="face-video-info__item"><span class="label">重量变化</span><strong>${f.weight}</strong></div>
</div>
</div>
</div>`;
mask.addEventListener('click',e=>{if(e.target===mask)closeFaceVideo();});
document.body.appendChild(mask);
updateFaceVideoUI();
startFaceVideoTimer();
}
function closeFaceVideo(){
clearInterval(FACE_VIDEO.timer);
FACE_VIDEO.playing=false;
const m=document.getElementById('faceVideoMask');
if(!m)return;
m.classList.add('closing');
setTimeout(()=>m.remove(),200);
}
function startFaceVideoTimer(){
clearInterval(FACE_VIDEO.timer);
FACE_VIDEO.timer=setInterval(()=>{
if(!FACE_VIDEO.playing)return;
FACE_VIDEO.current=Math.min(FACE_VIDEO.duration,FACE_VIDEO.current+0.1);
if(FACE_VIDEO.current>=FACE_VIDEO.duration){
FACE_VIDEO.playing=false;
clearInterval(FACE_VIDEO.timer);
}
updateFaceVideoUI();
},100);
}
function faceVideoPlayPause(){
FACE_VIDEO.playing=!FACE_VIDEO.playing;
if(FACE_VIDEO.playing){
if(FACE_VIDEO.current>=FACE_VIDEO.duration)FACE_VIDEO.current=0;
startFaceVideoTimer();
}
updateFaceVideoUI();
}
function faceVideoSeek(delta){
FACE_VIDEO.current=Math.max(0,Math.min(FACE_VIDEO.duration,FACE_VIDEO.current+delta));
updateFaceVideoUI();
}
function faceVideoSeekTo(e){
const bar=document.getElementById('faceVideoBar');
if(!bar)return;
const rect=bar.getBoundingClientRect();
const pct=Math.max(0,Math.min(1,(e.clientX-rect.left)/rect.width));
FACE_VIDEO.current=pct*FACE_VIDEO.duration;
updateFaceVideoUI();
}
function updateFaceVideoUI(){
const progress=document.getElementById('faceVideoProgress');
const time=document.getElementById('faceVideoTime');
const btn=document.getElementById('faceVideoPlayBtn');
const big=document.querySelector('#faceVideoMask .face-video-player__play');
const pct=FACE_VIDEO.current/FACE_VIDEO.duration*100;
if(progress)progress.style.width=pct+'%';
if(time)time.textContent=formatFaceVideoTime(FACE_VIDEO.current)+' / '+formatFaceVideoTime(FACE_VIDEO.duration);
const icon=FACE_VIDEO.playing?'⏸':'▶';
if(btn)btn.textContent=icon;
if(big)big.textContent=icon;
}
function formatFaceVideoTime(t){
const m=Math.floor(t/60);
const s=Math.floor(t%60);
return (m<10?'0'+m:m)+':'+(s<10?'0'+s:s);
}
/* 维度3:重量变化数据 — 开始/结束时间 / 识别对象 / 提交重量 / 总重量 */
SCALE_WEIGHT_RECORDS=[
{start:'2026-08-25 08:15:22.701',end:'2026-08-25 08:15:30.210',target:'EMP0234 · 张伟',submit:'436.5',total:'486.5'},
{start:'2026-08-25 08:14:50.512',end:'2026-08-25 08:14:58.430',target:'无用户',submit:'412.0',total:'462.0'},
{start:'2026-08-25 08:13:46.098',end:'2026-08-25 08:13:55.118',target:'EMP0357 · 王强',submit:'—',total:'501.2'},
{start:'2026-08-25 08:12:08.876',end:'2026-08-25 08:12:18.775',target:'无用户',submit:'0.0',total:'0.0'}
];
function renderScaleWeight(){
const rows=getFilteredScaleRows('weight').map(w=>`<tr><td>${w.start}</td><td>${w.end}</td><td>${w.target}</td><td>${w.submit}</td><td>${w.total}</td></tr>`).join('');
return `<div class="ov-section-title">重量变化数据</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('weight')}<tbody>${rows}</tbody></table></div>`;
}
/* 维度4:运行日志 — 日期/时间 / 报错类型 / 报错内容(报错代码,20字符) / 操作 */
SCALE_LOG_RECORDS=[
{time:'2026-08-25 08:10:12.330',type:'异常报错',code:'E1001_WEIGHT_SENSOR_READ_TIMEOUT_RETRY',detail:'E1001_WEIGHT_SENSOR_READ_TIMEOUT_RETRY\n\n[2026-08-25 08:10:12.330] ERROR WeightSensorModule::read()\n 称重传感器读取超时,连续 3 次无响应\n 设备编号: DEV-00025 传感器通道: CH-02\n 已触发自动重试,若重试失败将上报设备故障\n TraceId: 9f3c1a7b-2e4d-4c8a-b1f0-5d6e7f8a9b0c'},
{time:'2026-08-25 08:02:45.117',type:'性能预警',code:'W2003_RECOG_LATENCY_OVER_THRESHOLD_320MS',detail:'W2003_RECOG_LATENCY_OVER_THRESHOLD_320MS\n\n[2026-08-25 08:02:45.117] WARN RecognitionModule::detect()\n 人脸识别响应延迟 320ms,接近阈值 300ms\n 摄像头: CAM-01 帧率: 25fps\n 建议检查现场光照条件与网络带宽\n TraceId: 4b8e2c31-7a9d-4f5b-9c2e-1a3b4c5d6e7f'},
{time:'2026-08-25 07:58:03.662',type:'性能预警',code:'W2004_MEMORY_USAGE_HIGH_82PCT',detail:'W2004_MEMORY_USAGE_HIGH_82PCT\n\n[2026-08-25 07:58:03.662] WARN SystemMonitor::check()\n 内存占用 82%,接近预警阈值 85%\n 当前进程: nutrition-scale-app\n 建议关注内存泄漏或及时清理缓存\n TraceId: c5d6e7f8-2a3b-4c4d-8e9f-0a1b2c3d4e5f'},
{time:'2026-08-25 07:30:00.000',type:'运行日志',code:'I0000_SELF_CHECK_PASS',detail:'I0000_SELF_CHECK_PASS\n\n[2026-08-25 07:30:00.000] INFO SystemMonitor::selfCheck()\n 设备完成夜间自检,各项指标正常\n 传感器: 正常 摄像头: 正常 网络: 正常\n TraceId: 6f1a2b3c-4d5e-4f6a-8b7c-9d0e1f2a3b4c'}
];
function truncateCode(code){
if(!code)return '';
return code.length>20?code.slice(0,20)+'…':code;
}
function renderScaleLog(){
const rows=getFilteredScaleRows('log').map(l=>{
const codeHtml=`<span title="${escapeAttr(l.code)}">${escapeHtml(truncateCode(l.code))}</span>`;
return `<tr><td>${l.time}</td><td>${l.type}</td><td>${codeHtml}</td><td><a class="link" onclick="showLogDetail('${escapeAttr(l.code)}')">查看详情</a></td></tr>`;
}).join('');
return `<div class="ov-section-title">运行日志</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('log','操作')}<tbody>${rows}</tbody></table></div>`;
}
function showLogDetail(code){
var rec=SCALE_LOG_RECORDS.filter(function(l){return l.code===code;})[0];
if(!rec)return;
var html='<div style="font-size:13px;color:var(--text-primary)">'
+'<div style="margin-bottom:14px"><div style="color:var(--text-placeholder);font-size:12px;margin-bottom:4px">日期/时间</div><div>'+escapeHtml(rec.time)+'</div></div>'
+'<div style="margin-bottom:14px"><div style="color:var(--text-placeholder);font-size:12px;margin-bottom:4px">报错类型</div><div>'+escapeHtml(rec.type)+'</div></div>'
+'<div><div style="color:var(--text-placeholder);font-size:12px;margin-bottom:4px">报错代码</div><div style="background:#1a1a1a;color:#b8b8b8;font-family:monospace;padding:14px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-size:12px;line-height:1.8;">'+escapeHtml(rec.detail)+'</div></div>'
+'</div>';
DynModal.open('报错详情',html,'<button class="btn btn-primary" onclick="DynModal.close()">关闭</button>');
}
/* ===== 智能货柜:柜子轮廓布局 + 储物状态(参考 prod-proc-cabinet.html 左侧柜子样式) ===== */
/* 格口 Mock:状态 occupied 储物中 / warning 临期预警 / alert 过期预警 / empty 空闲 */
var OV_SLOTS=[
+15
View File
@@ -1,5 +1,20 @@
# 营养用餐管理端 CHANGELOG
## [2026-08-27] feat: 订餐终端设备概览新增「餐线营养秤」Tab
- `din-terminal.html` 订餐终端设备概览:
- 新增「餐线营养秤」Tab,四个维度子页切换(设备状态监控/重量变化数据/人脸识别数据/运行日志)
- 表头新增列筛选(▾ 图标下拉,按列去重取值,四个维度复用)
- 人脸识别数据支持「回放视频」:弹窗含自动播放/快进后退 10s/进度条跳转/识别信息
- 运行日志支持「查看详情」:报错内容超 20 字符截断,详情弹窗展示完整报错日志(含 TraceId)
## [2026-08-26] feat: 净菜订单新增生成历史 + 生成方式列 + 详情就餐人数
- `prod-clean-order.html` 净菜订单:
- 新增「净菜订单生成历史」按钮与弹窗(食谱日期/预定配送时间/餐次/内配调料四筛选 + 9 列表格),由食谱创建订单时自动写入生成历史
- 主列表「订单类型」前新增「生成方式」列(手动/自动),预置自动订单 CO-009 示例
- 详情抽屉订单明细:仅自动生成的订单展示「就餐人数」列
## [2026-08-25] feat: 餐线设置新增用户范围必填复选 + 食谱订单餐品明细表份数调整
- `din-canteen-line.html` 餐线管理:
@@ -0,0 +1,159 @@
# 营养用餐模块 - 角色泳道图 & 问题汇总
> 横轴:角色(平台 → 加工商 → 场所 → 用户)
> 纵轴:场景流程
> 标注:每个场景下方附现有问题
---
## 一、角色场景泳道图
```mermaid
flowchart TD
subgraph 平台["🏛️ 平台(Platform"]
direction TB
subgraph 准备["阶段一:平台准备"]
S1["<b>场景1:食物成分录入</b><br/>──────────────<br/>营养用餐 → 食物成分<br/>→ 分类管理:新增多级分类<br/>→ 点击新增食物<br/>→ 录入食物成分信息<br/>→ 保存"]
S2["<b>场景2:餐品总库</b><br/>──────────────<br/>营养用餐 → 餐品总库<br/>→ 新增餐品分类<br/>→ 新增餐品<br/>→ 保存"]
end
subgraph 场所管理["阶段二:场所管理"]
S3["<b>场景3:新增场所</b><br/>──────────────<br/>营养用餐 → 场所管理<br/>→ 新增场所<br/>→ 录入场所信息<br/>→ 保存 → 场所创建完成<br/>→ 主账号发放"]
S4["<b>场景4:餐线管理</b><br/>──────────────<br/>场所管理 → 选择场所<br/>→ 餐线管理<br/>→ 绑定设备"]
end
准备 --> 场所管理
end
subgraph 加工商["🏭 加工商(Processor"]
P_EMPTY["(待补充)"]
end
subgraph 场所["📍 场所(Venue"]
V_EMPTY["(待补充)"]
end
subgraph 用户["👤 用户(User"]
U_EMPTY["(待补充)"]
end
平台 --> 加工商 --> 场所 --> 用户
%% 平台准备 - 食物成分问题
S1 -.->|"⚠️"| Q1["⑥ 缺少「食物功效」模块"]
S1 -.->|"⚠️"| Q2["⑦ 味道档案应改为<br/>酸甜苦辣咸下拉菜单"]
S1 -.->|"⚠️"| Q3["⑧ 分类应内置<br/>中国食物成分表标准分类"]
S1 -.->|"⚠️"| Q4["⑨ 口感和色泽字段不需要"]
S1 -.->|"⚠️"| Q5["⑩ 宏量营养素应为必填项"]
S1 -.->|"⚠️"| Q6["⑪ 食物类目启禁用<br/>应展示在列表中"]
S1 -.->|"⚠️"| Q7["⑫ 食物缺少启禁用状态"]
%% 平台准备 - 餐品总库问题
S2 -.->|"⚠️"| Q8["⑬ 餐品分类无法维护<br/>(增删改查)"]
S2 -.->|"⚠️"| Q9["⑭ 餐品分类无法启禁用"]
S2 -.->|"⚠️"| Q10["⑮ 基本档案中适用餐次、<br/>过敏原建议删除"]
S2 -.->|"⚠️"| Q11["⑯ 烹饪工艺/味道档案/<br/>口感档案/色泽档案<br/>建议删除"]
S2 -.->|"⚠️"| Q12["⑰ 营养指标模块<br/>应按2.0版本做"]
%% 场所管理 - 新增场所问题
S3 -.->|"⚠️"| Q13["① 负责人和负责人电话<br/>应为必填项"]
S3 -.->|"⚠️"| Q14["② 「是否使用三减终端」<br/>含义不明确"]
S3 -.->|"⚠️"| Q15["③ 详细地址应为必填项"]
S3 -.->|"⚠️"| Q16["④ 场所主账号应<br/>发送到负责人手机"]
S3 -.->|"⚠️"| Q17["⑤ 场所管理缺少<br/>启禁用按钮"]
%% 场所管理 - 餐线管理问题
S4 -.->|"⚠️"| Q18["⑥ 绑定设备数量不反显"]
S4 -.->|"⚠️"| Q19["⑦ 期望有默认餐线"]
```
---
## 二、泳道图(简化文字版)
```
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ 平台 │ 加工商 │ 场所 │ 用户 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ │ │ │ │
│ 【平台准备】 │ │ │ │
│ │ │ │ │
│ 1.食物成分 │ │ │ │
│ 分类管理 │ │ │ │
│ 新增食物 │ │ │ │
│ │ │ │ │
│ 2.餐品总库 │ │ │ │
│ 餐品分类 │ │ │ │
│ 新增餐品 │ │ │ │
│ │ │ │ │
│ 【场所管理】 │ │ │ │
│ │ │ │ │
│ 3.新增场所 │ │ │ │
│ 录入信息 │ │ │ │
│ 主账号发放 │ │ │ │
│ │ │ │ │
│ 4.餐线管理 │ │ │ │
│ 绑定设备 │ │ │ │
│ │ │ │ │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ │ (待补充) │ (待补充) │ (待补充) │
└──────────────┴──────────────┴──────────────┴──────────────┘
```
---
## 三、问题汇总表
### 平台准备 → 食物成分(#⑥ ~ #⑫)
| # | 问题 | 期望 |
|---|------|------|
| ⑥ | 缺少「食物功效」模块 | 新增模块,后期用于匹配模型自动关联用户调理方案 |
| ⑦ | 味道档案含主观填写字段(口味描述、辅味型、味道强度、饮食禁忌等) | 改为**酸甜苦辣咸**下拉菜单,去掉其余主观字段 |
| ⑧ | 分类需手动新建 | 内置**中国食物成分表**标准分类 |
| ⑨ | 有「口感」和「色泽」字段 | 去掉,不需要 |
| ⑩ | 宏量营养素非必填 | 改为**必填项** |
| ⑪ | 食物类目启禁用在编辑页内,操作不便 | 启禁用展示在**列表中**;禁用类目时,关联餐品**全部下架** |
| ⑫ | 食物缺少启禁用状态 | 新增启禁用,逻辑同类目:禁用食物时关联餐品全部下架 |
### 平台准备 → 餐品总库(#⑬ ~ #⑰)
| # | 问题 | 期望 |
|---|------|------|
| ⑬ | 餐品分类无法维护(增删改查) | 支持餐品分类的增删改查 |
| ⑭ | 餐品分类无法启禁用 | 新增启禁用功能 |
| ⑮ | 基本档案中「适用餐次」「过敏原」多余 | 删除适用餐次、过敏原字段 |
| ⑯ | 烹饪工艺、味道档案、口感档案、色泽档案模块多余 | 全部删除 |
| ⑰ | 营养指标模块不符合 2.0 版本规范 | 按 2.0 版本重新设计 |
### 场所管理 → 新增场所(#① ~ #⑤)
| # | 问题 | 期望 |
|---|------|------|
| ① | 负责人和负责人电话非必填 | 改为**必填项** |
| ② | 「是否使用三减终端」含义不明确 | 加字段说明或提示文案 |
| ③ | 详细地址非必填 | 改为**必填项** |
| ④ | 场所主账号发放逻辑不明确 | 创建场所后主账号**发送到负责人手机** |
| ⑤ | 场所管理缺少启禁用按钮 | 新增启禁用按钮 |
### 场所管理 → 餐线管理(#⑥ ~ #⑦)
| # | 问题 | 期望 |
|---|------|------|
| ⑥ | 绑定设备后数量不反显 | 绑定后实时显示已绑设备数量 |
| ⑦ | 没有默认餐线 | 创建场所后自动生成一条默认餐线 |
---
## 四、汇总统计
| 角色 | 阶段 | 场景数 | 问题数 | 状态 |
|------|------|--------|--------|------|
| 平台 | 平台准备 | 2(食物成分、餐品总库) | 12 | ✅ 已梳理 |
| 平台 | 场所管理 | 2(新增场所、餐线管理) | 7 | ✅ 已梳理 |
| **平台小计** | — | **4** | **19** | — |
| 加工商 | — | 0 | 0 | ⬜ 待补充 |
| 场所 | — | 0 | 0 | ⬜ 待补充 |
| 用户 | — | 0 | 0 | ⬜ 待补充 |
| **总计** | — | **4** | **19** | — |
+172 -7
View File
@@ -268,17 +268,18 @@ tr.detail-row td.td-actions{background:#FAFBFC;box-shadow:-4px 0 8px rgba(0,0,0,
<div class="action-bar">
<button class="btn btn-primary" onclick="openModal('modal-order')"> 新建订单</button>
<button class="btn" style="background:#722ED1;color:#fff;" onclick="openModal('modal-recipe-order')">📋 由食谱快速创建订单</button>
<button class="btn" style="background:#13C2C2;color:#fff;" onclick="openModal('modal-gen-history')">📜 净菜订单生成历史</button>
</div>
<div class="table-card">
<div class="table-card__header"><div class="table-card__title">净菜订单</div></div>
<div class="table-card__content">
<table id="orderTable">
<thead><tr><th style="width:36px;"></th><th>订单号</th><th>订单类型</th><th>净菜类型</th><th>菜品种类</th><th>总份数</th><th>总计量(kg)</th><th>订单金额</th><th>订单状态</th><th>申领时间</th><th>预定配送时间</th><th>创建人</th><th>物流信息</th><th>操作</th></tr></thead>
<thead><tr><th style="width:36px;"></th><th>订单号</th><th>生成方式</th><th>订单类型</th><th>净菜类型</th><th>菜品种类</th><th>总份数</th><th>总计量(kg)</th><th>订单金额</th><th>订单状态</th><th>申领时间</th><th>预定配送时间</th><th>创建人</th><th>物流信息</th><th>操作</th></tr></thead>
<tbody id="orderTableBody"></tbody>
</table>
</div>
<div class="pagination">
<span>显示 8 条,共 8</span>
<span>显示 9 条,共 9</span>
<button class="page-btn"></button><button class="page-btn active">1</button><button class="page-btn"></button>
</div>
</div>
@@ -397,6 +398,34 @@ tr.detail-row td.td-actions{background:#FAFBFC;box-shadow:-4px 0 8px rgba(0,0,0,
</div>
</div>
<!-- 弹窗:净菜订单生成历史 -->
<div class="modal-mask" id="modal-gen-history">
<div class="modal" style="width:1080px;">
<div class="modal__header">
<div class="modal__title">净菜订单生成历史</div>
<button class="modal__close" onclick="closeModal('modal-gen-history')"></button>
</div>
<div class="modal__body">
<!-- 筛选 + 订单切换 -->
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px;">
<div class="filter-item"><label>食谱日期</label><input class="filter-input" type="date" id="ghDate" onchange="renderGenHistory()" style="min-width:150px;"></div>
<button class="btn btn-default" onclick="resetGenHistoryFilter()">重置</button>
</div>
<!-- 生成记录明细(订单信息与餐品明细合并) -->
<div style="overflow-x:auto;">
<table class="ing-table">
<thead><tr><th>食谱日期</th><th>餐次</th><th>餐品名称</th><th>就餐人数</th><th>内配料(是否)</th><th>打包份数</th><th>预定配送时间</th><th>开始生成时间</th><th>生成成功时间</th></tr></thead>
<tbody id="genHistoryInfoBody"></tbody>
</table>
</div>
<div id="genHistoryEmpty" style="display:none;text-align:center;color:var(--text-placeholder);padding:40px 0;">暂无符合条件的生成记录</div>
</div>
<div class="modal__footer">
<button class="btn btn-default" onclick="closeModal('modal-gen-history')">关闭</button>
</div>
</div>
</div>
<script>
/* ========== 弹窗开关 ========== */
function openModal(id){
@@ -539,6 +568,15 @@ var orders=[
{id:'L16',ncType:'cp',foodName:'蒜蓉菠菜',spec:'标准份(400g',qty:15,weight:6,price:10,subtotal:60,status:'已完成',needSeasoning:false},
{id:'L17',ncType:'cp',foodName:'炒胡萝卜',spec:'标准份(300g',qty:20,weight:6,price:8,subtotal:48,status:'已完成',needSeasoning:true}
]
},
{
orderNo:'CO-009',orderType:'内供',ncType:'cp',genType:'auto',
claimTime:'2026-05-20 09:30',deliverTime:'2026-05-21 10:00',creator:'系统',sourceOrderNo:null,
logistics:null,
lines:[
{id:'L18',ncType:'cp',foodName:'宫保鸡丁',spec:'标准份(500g',qty:120,dinerCount:120,weight:60,price:12,subtotal:720,status:'生成中',needSeasoning:true},
{id:'L19',ncType:'cp',foodName:'清蒸鲈鱼',spec:'标准份(600g',qty:100,dinerCount:100,weight:60,price:15,subtotal:900,status:'生成中',needSeasoning:false}
]
}
];
@@ -741,6 +779,101 @@ function orderTypeTag(type){
return '<span class="tag tag-orange">🛒 外售</span>';
}
/* 生成方式徽标:自动(由食谱生成) / 手动(人工新建) */
function genTypeTag(type){
return type==='auto'
?'<span class="tag tag-cyan">⚡ 自动</span>'
:'<span class="tag tag-gray">✍ 手动</span>';
}
/* ========== 净菜订单生成历史 ========== */
var genHistory=[
{
orderNo:'CO-009',recipeDate:'2026-05-20',deliverTime:'2026-05-21 10:00',startTime:'2026-05-20 09:30:00',successTime:'2026-05-20 09:31:12',
details:[
{meal:'午餐',foodName:'宫保鸡丁',rawWeight:60,cookedWeight:51,dinerCount:120,needSeasoning:true,servings:120,avgWeight:0.5},
{meal:'午餐',foodName:'清蒸鲈鱼',rawWeight:60,cookedWeight:52.8,dinerCount:100,needSeasoning:false,servings:100,avgWeight:0.6}
]
},
{
orderNo:'CO-010',recipeDate:'2026-05-20',deliverTime:'2026-05-21 06:30',startTime:'2026-05-20 05:30:00',successTime:'2026-05-20 05:31:03',
details:[
{meal:'早餐',foodName:'小米粥',rawWeight:24,cookedWeight:20.4,dinerCount:80,needSeasoning:false,servings:80,avgWeight:0.3},
{meal:'早餐',foodName:'煮鸡蛋',rawWeight:4.8,cookedWeight:4.1,dinerCount:80,needSeasoning:false,servings:80,avgWeight:0.06}
]
},
{
orderNo:'CO-011',recipeDate:'2026-05-20',deliverTime:'2026-05-21 16:00',startTime:'2026-05-20 14:30:00',successTime:'2026-05-20 14:31:11',
details:[
{meal:'晚餐',foodName:'红烧肉',rawWeight:50,cookedWeight:42.5,dinerCount:100,needSeasoning:true,servings:100,avgWeight:0.5},
{meal:'晚餐',foodName:'清蒸鲈鱼',rawWeight:60,cookedWeight:52.8,dinerCount:100,needSeasoning:false,servings:100,avgWeight:0.6}
]
},
{
orderNo:'CO-012',recipeDate:'2026-05-19',deliverTime:'2026-05-20 16:00',startTime:'2026-05-19 14:30:00',successTime:'2026-05-19 14:31:15',
details:[
{meal:'晚餐',foodName:'麻婆豆腐',rawWeight:36,cookedWeight:30.6,dinerCount:90,needSeasoning:true,servings:90,avgWeight:0.4}
]
},
{
orderNo:'CO-013',recipeDate:'2026-05-20',deliverTime:'2026-05-21 14:00',startTime:'2026-05-20 13:30:00',successTime:'2026-05-20 13:31:02',
details:[
{meal:'加餐',foodName:'水果拼盘',rawWeight:18,cookedWeight:18,dinerCount:60,needSeasoning:false,servings:60,avgWeight:0.3}
]
}
];
function pad2(n){return String(n).padStart(2,'0');}
function formatDateTime(d){
return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate())+' '+pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds());
}
function genSeasoningTag(needSeasoning){
return needSeasoning
?'<span class="tag tag-blue">✓ 是</span>'
:'<span class="tag tag-gray">✗ 否</span>';
}
var genHistoryFiltered=[];
function renderGenHistory(){
var date=document.getElementById('ghDate').value;
genHistoryFiltered=genHistory.filter(function(r){
if(date&&r.recipeDate!==date)return false;
return true;
});
var empty=document.getElementById('genHistoryEmpty');
empty.style.display=genHistoryFiltered.length?'none':'block';
/* 默认展示最近一次(数组首位) */
if(genHistoryFiltered.length>0){
renderGenHistoryOrder(genHistoryFiltered[0].orderNo);
}else{
document.getElementById('genHistoryInfoBody').innerHTML='';
}
}
function renderGenHistoryOrder(orderNo){
var rec=genHistoryFiltered.find(function(r){return r.orderNo===orderNo;});
if(!rec)rec=genHistory.find(function(r){return r.orderNo===orderNo;});
if(!rec)return;
/* 订单信息与餐品明细合并为一张表:每条明细一行,重复订单级字段 */
var infoBody=document.getElementById('genHistoryInfoBody');
var html='';
rec.details.forEach(function(d){
html+='<tr>'
+'<td>'+rec.recipeDate+'</td>'
+'<td>'+d.meal+'</td>'
+'<td style="font-weight:500;">'+d.foodName+'</td>'
+'<td>'+d.dinerCount+'</td>'
+'<td>'+genSeasoningTag(d.needSeasoning)+'</td>'
+'<td>'+d.servings+'</td>'
+'<td>'+rec.deliverTime+'</td>'
+'<td>'+rec.startTime+'</td>'
+'<td>'+rec.successTime+'</td>'
+'</tr>';
});
infoBody.innerHTML=html;
}
function resetGenHistoryFilter(){
document.getElementById('ghDate').value='';
renderGenHistory();
}
/* ========== 渲染订单表格 ========== */
function renderOrderTable(){
var tbody=document.getElementById('orderTableBody');
@@ -752,6 +885,7 @@ function renderOrderTable(){
html+='<tr class="master-row" data-order-idx="'+oi+'" onclick="toggleExpand(this,'+oi+')">';
html+='<td><span class="expand-arrow" id="arrow'+oi+'">▶</span></td>';
html+='<td><b>'+order.orderNo+'</b></td>';
html+='<td>'+genTypeTag(order.genType)+'</td>';
html+='<td>'+orderTypeTag(order.orderType)+'</td>';
html+='<td>'+ncTypeTag(order.ncType)+'</td>';
html+='<td>'+order.lines.length+'</td>';
@@ -781,9 +915,9 @@ function renderOrderTable(){
/* 子表头 */
if(order.lines.length>0){
if(order.ncType==='nc'){
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td>食材</td><td>净菜名称</td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td>食材</td><td>净菜名称</td><td></td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td></tr>';
}else{
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td colspan="2">菜品/食材</td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td colspan="2">菜品/食材</td><td></td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td></tr>';
}
}
/* 明细行 */
@@ -795,6 +929,7 @@ function renderOrderTable(){
}else{
html+='<td colspan="2" style="font-weight:500;">'+line.foodName+'</td>';
}
html+='<td></td>';
html+='<td>'+line.spec+'</td>';
html+='<td>'+line.qty+'</td>';
html+='<td>'+line.weight+'</td>';
@@ -1087,7 +1222,7 @@ function submitCreateOrder(){
var orderNo='CO-'+String(orders.length+1).padStart(3,'0');
var deliverTime=document.getElementById('orderDeliveryTime').value||'—';
var newOrder={
orderNo:orderNo,orderType:'内供',
orderNo:orderNo,orderType:'内供',genType:'manual',
claimTime:new Date().toISOString().slice(0,16).replace('T',' '),
deliverTime:deliverTime,creator:creator,
ncType:newOrderNcType,
@@ -1194,12 +1329,16 @@ function showDetail(orderNo){
/* 订单明细列表 */
html+='<div class="drawer-section"><div class="drawer-section__title">订单明细('+order.lines.length+' 道菜品)</div>';
var isNcOrder=order.ncType==='nc';
var isAutoOrder=order.genType==='auto';
html+='<table class="ing-table"><thead><tr><th>净菜类型</th>';
if(isNcOrder){
html+='<th>食材</th><th>净菜名称</th>';
}else{
html+='<th>菜品/食材</th>';
}
if(isAutoOrder){
html+='<th>就餐人数</th>';
}
html+='<th>包装规格</th><th>份数</th><th>计量(kg)</th><th>单价(元/kg)</th><th>小计金额</th><th>内配调料</th><th>行状态</th></tr></thead><tbody>';
order.lines.forEach(function(line){
var tagCls={'已完成':'tag-green','配送中':'tag-blue','待配送':'tag-orange','备货中':'tag-gray'};
@@ -1209,12 +1348,15 @@ function showDetail(orderNo){
}else{
html+='<td'+(isNcOrder?' colspan="2"':'')+'><b>'+line.foodName+'</b></td>';
}
if(isAutoOrder){
html+='<td>'+(line.dinerCount||'—')+'</td>';
}
html+='<td>'+line.spec+'</td><td>'+line.qty+'</td><td>'+line.weight+'</td><td>'+(line.price||0)+'</td><td>'+formatMoney(line.subtotal||0)+'</td><td>'+seasoningTag(line)+'</td><td><span class="tag '+tagCls[line.status]+'">'+line.status+'</span></td></tr>';
/* 餐品净菜包显示食材明细 */
if(line.ncType==='cp'){
var ings=dishIngredients[line.foodName]||[];
if(ings.length>0){
var colSpan=isNcOrder?10:9;
var colSpan=isNcOrder?10:(isAutoOrder?10:9);
html+='<tr><td colspan="'+colSpan+'" style="padding:4px 10px;background:#fafafa;"><span style="font-size:11px;color:var(--text-placeholder);">食材构成:</span>';
ings.forEach(function(ing,i){
if(i>0)html+=', ';
@@ -1446,6 +1588,11 @@ function submitRecipeOrder(){
if(missing.length>0){alert('以下餐次尚未填写就餐人数:'+missing.join('、'));return;}
/* 由食谱创建一个包含所选餐次全部菜品的订单,订单初始状态为「生成中」 */
var daily=getDailyMenu(recipeDate);
/* 记录本次生成历史:开始时间与模拟成功时间(1~2 分钟后) */
var genStart=new Date();
var genStartStr=formatDateTime(genStart);
var genSuccessStr=formatDateTime(new Date(genStart.getTime()+(60+Math.round(Math.random()*60))*1000));
var genDetails=[];
var dishMap={};
mealIndices.forEach(function(mi){
var input=document.querySelector('#dailyMenuBody .meal-diner-input[data-meal-idx="'+mi+'"]');
@@ -1457,9 +1604,25 @@ function submitRecipeOrder(){
/* 同菜品跨餐次:累加人数(按总人数核算) */
dishMap[dishName].dinerCount+=dinerCount;
}
/* 收集生成历史明细 */
var ghSpec=getDishSpecForRecipe(dishName);
var ghBaseGram=ghSpec.baseGram||500;
var ghServings=ghBaseGram>4000?Math.ceil(ghBaseGram*dinerCount/4000):dinerCount;
var ghRawWeight=parseFloat((ghBaseGram*dinerCount/1000).toFixed(2));
genDetails.push({
meal:MEAL_NAMES[mi],foodName:dishName,
rawWeight:ghRawWeight,cookedWeight:parseFloat((ghRawWeight*0.85).toFixed(2)),
dinerCount:dinerCount,needSeasoning:recipeSeasoningMap[dishName]===true,
servings:ghServings,avgWeight:parseFloat((ghRawWeight/ghServings).toFixed(2))
});
});
});
var orderNo='CO-'+String(orders.length+1).padStart(3,'0');
/* 写入净菜订单生成历史(订单粒度) */
genHistory.unshift({
orderNo:orderNo,recipeDate:recipeDate,deliverTime:deliveryTime.replace('T',' '),
startTime:genStartStr,successTime:genSuccessStr,details:genDetails
});
var newLines=[];
var di=0;
for(var name in dishMap){
@@ -1475,6 +1638,7 @@ function submitRecipeOrder(){
spec:baseGram+'g/'+unit,
portion:sp.portion,baseGram:baseGram,unit:unit,
qty:servings,
dinerCount:item.dinerCount,
weight:parseFloat((baseGram*item.dinerCount/1000).toFixed(2)),
status:'生成中',
needSeasoning:recipeSeasoningMap[name]===true
@@ -1482,7 +1646,7 @@ function submitRecipeOrder(){
di++;
}
orders.unshift({
orderNo:orderNo,orderType:'内供',ncType:'cp',
orderNo:orderNo,orderType:'内供',ncType:'cp',genType:'auto',
claimTime:new Date().toISOString().slice(0,16).replace('T',' '),
deliverTime:deliveryTime.replace('T',' '),creator:'系统',
sourceOrderNo:null,
@@ -1509,6 +1673,7 @@ function sanitizeIntegerInput(el){el.value=el.value.replace(/[^\d]/g,'');}
recipeServingMap={};
onRecipeDateChange();
}
if(id==='modal-gen-history'){document.getElementById('ghDate').value='';renderGenHistory();}
document.getElementById(id).classList.add('show');
};
})();
+239
View File
@@ -0,0 +1,239 @@
# 营养秤三硬件多维状态穷举矩阵
> **硬件能力边界**
> - **压力传感器**:只能输出重量增减数值(g),无法自行判断"合理/异常/超限"
> - **下方摄像头**:人脸识别 + 上半身衣服颜色 + 扭头/转身检测 + 多人脸检测
> - **上方摄像头**:腰部~臀部 + 袖子颜色 + 手部动作(夹取/放回/抬盆等)+ 夹子位置追踪 + 身体倚靠
> - **所有"场景判定"均由三端数据协同得出,任何单一硬件无法独立判定**
>
> **铁律**:下方摄像头未识别到人脸 + 压力有变化 = 异常取餐
---
## 一、压力减少
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 1 | 减少 X g | 用户人脸A,衣服色=红 | 夹取食物取走,袖子色=红 | ✅ 正常取餐 | 重量减少+人脸识别+夹取动作,且上下颜色匹配确认为同一人 |
| 2 | 减少 X g(含滴落) | 用户人脸A | 夹取+汤汁滴落回盆 | 正常取餐(少量汤汁损失) | 重量减少中有少量滴落损失,上方确认非人为异常 |
| 3 | 减少 X g | 用户人脸A→扭头→短暂丢失→恢复,衣服色=红 | 夹取动作持续,袖子色=红 | ✅ 正常取餐(人脸短暂中断) | 下方检测扭头=主动转身,上方袖子色不变确认同一人,三端协同判定同一周期 |
| 4 | 减少 X g(前段波动后终减) | 用户人脸A | 试探抖动→最终取走 | 正常取餐(犹豫后取走) | 前段波动是手部试探,终态重量减少+夹取动作确认取走。典型场景:取炒面、粉丝等细长松散食物,需多次拨动试探才能夹稳 |
| 5 | 减少 X g | 多人脸(衣服色=红+蓝) | 多只手同时夹取(袖子色=红+蓝) | ⚠️ 多人混取 | 下方多脸+上方多手+重量减少,需颜色映射确定谁取了什么 |
| 6 | 减少 X g | 两人脸(衣服色=红+蓝) | 一人夹+一人端盘(袖子色=红夹+蓝端) | ⚠️ 双人协作 | 协作取餐模式,三端数据完整但需策略决定归属给夹的人还是端盘的人 |
| 7 | 减少 X g | 用户人脸A→B出现,衣服色A→B | 夹取手袖子色从A色变为B色 | ⚠️ 身份交替取餐 | 下方衣服色切换+上方袖子色切换+重量持续减少,确认A→B换人 |
| 8 | 减少 X g | 无人脸 | 有夹取动作(可见袖子色) | ❌ 取餐无主 | 有行为+有重量变化但无人脸。若结合时间序列发现此前A刚离开且袖子色变化,可进一步判定为B逃逸识别 |
| 10 | 减少 X g | 无人脸(画面全黑/模糊) | 有夹取动作 | ❌ 下方摄像头故障+有人取餐 | 下方无输出+上方有动作+压力减少=下方被遮挡或故障 |
| 11 | 减少 X g | 员工人脸 | 员工夹取食物(非补菜动作) | ⚠️ 员工自取 | 员工身份+夹取动作+减少=员工个人取餐行为,非运营操作 |
| 12 | 减少 X g | 人脸持续丢失(下方无扭头动作) | 夹取动作持续(袖子色一致) | ⚠️ 人脸被遮挡取餐 | 下方非扭头丢失(排除主动转身)+上方同一袖子持续取餐=疑似刻意遮挡 |
| 13 | 减少 X g | 用户人脸A | 夹取中手肘碰邻盆(邻盆同步波动) | 正常取餐(邻盆误触) | 本盆减少合理+邻盆无夹取但有重量波动=邻盆为误触 |
| 14 | 减少 X g(数值明显偏大) | 用户人脸A | 单人大量/多次堆叠夹取,袖子色一致 | ⚠️ 超量取餐 | 减少量超过单人单次合理上限,但上方确认同一人连续夹取,非多人 |
| 15 | 减少 X g(数值明显偏大) | 多人脸(衣服色=多色) | 多只手同时夹取(袖子色=多色对应) | ❌ 多人取餐 | 减少量大的原因=多人同时取同一盆,三端协同确认多人并发 |
| 16 | 减少量≈整盆重 | 员工人脸 | 服务员双手抬起波菲炉/食物盆 | ✅ 服务员换盆(抬走旧盆) | 整盆级减少+员工脸+抬盆动作=正常换盆运营操作 |
| 17 | 减少量≈整盆重 | 用户人脸A | 双手抬起波菲炉/食物盆 | ❌ 用户恶意减重 | 整盆级减少+用户脸+抬盆=用户故意抬盆干扰重量,非取餐行为 |
| 18 | 减少量≈整盆重 | 无人脸 | 有人抬起波菲炉/食物盆(可见袖子) | ❌ 恶意取餐(未识别+抬盆) | 整盆级减少+无人脸+抬盆动作=无人脸恶意规避+干扰重量 |
| 19 | 减少 X g(数值偏大) | 无人脸 | 单人大量夹取 | ❌ 未识别超量取餐 | 超量减少+无人脸+单个夹取手=双重异常 |
| 20 | 减少 X g(数值偏大) | 无人脸 | 多只手同时夹取 | ❌ 未识别多人取餐 | 超量减少+无人脸+多人手=三重异常 |
| 21 | 减少 X g(含跳变) | 用户人脸A | 夹取中身体碰撞秤体 | ⚠️ 取餐+碰撞叠加 | 减少量可能含碰撞跳变分量,需协同上方碰撞检测校准取量 |
| 22 | 减少 X g(数值偏大) | 多人脸 | 一人抬盆+一人遮挡 | ❌ 协同恶意取餐 | 多脸+抬盆+遮挡=有组织恶意操作 |
| 23 | 减少 ≈0(微量) | 用户人脸A | 空夹(夹子张开未夹到食物) | 空夹 | 重量几乎不变但有夹取动作=空夹,上方确认为无效操作 |
| 24 | 减少 ≈0(微量) | 用户人脸A | 夹子刮盆边/拨动碎屑 | 碎屑级误判 | 微量变化+非夹取动作=不构成有效取餐 |
| 25 | 减少 ≈0(微量) | 用户人脸A | 夹取中汤汁滴落 | 汤汁滴落损失 | 夹物后滴落导致实际取量偏小,上方确认滴落事件 |
| 26 | 减少 ≈0(单次<分辨率,累计可能超标) | 用户人脸A | 极缓慢倾倒/逐克添加 | ⚠️ 静默取餐 | 单次不触发阈值但上方确认持续微操作,需累积判定 |
| 27 | 减少 ≈0(微量) | 无人脸 | 空夹动作 | ❌ 未识别空夹 | 有动作无人脸,无法追溯操作者 |
| 28 | 减少 ≈0(微量) | 无人脸 | 有人拨动食物 | ❌ 未识别微量取餐 | 无人脸+微量变化+拨动动作=疑似逃逸 |
| 29 | 减少 ≈0(微量) | 无人脸 | 汤汁/碎屑自然掉落 | 无主食物损失 | 自然损失非人为,无人脸合理 |
| 30 | 减少 ≈0(微量) | 员工人脸 | 员工拨动/整理食物 | ✅ 员工整理食物 | 员工操作+微量变化=非取餐运营动作 |
| 31 | 减少→归零 | 员工人脸 | 服务员端走空盆 | ✅ 服务员收空盆 | 归零+员工脸+端盆=正常运营 |
| 32 | 减少→归零 | 无人脸 | 有人端走食物盆 | ❌ 非员工收盆 | 归零+非员工端盆=恶意或误操作 |
| 33 | 减少→归零 | 无人脸 | 无人(盆从画面消失) | ❌ 食物盆被盗/掉落 | 归零+无人脸+上方无端盆动作=盆非正常移除或秤故障 |
| 34 | 减少→新值(整盆级) | 员工人脸 | 换盆过渡(旧盆已抬走,新盆未放上) | ✅ 服务员换盆过渡态 | 中间过渡状态,三端一致为换盆流程 |
| 35 | 减少→新值(整盆级) | 用户人脸 | 用户抬盆后放下 | ❌ 用户抬盆干扰 | 用户抬盆形成虚假减少,上方确认抬盆行为 |
| 36 | 减少→新值(整盆级) | 无人脸 | 有人抬盆 | ❌ 恶意抬盆 | 无人脸+抬盆=双重异常 |
| 37 | 减少 X g(≈夹子重量) | 用户人脸A | 夹子从盆边滑入盆内(无夹取动作) | ⚠️ 夹子移位伪装取餐 | 减重=夹子重+上方无夹取+夹子位移=传感器看到"取餐"但实际是夹子动了 |
---
## 二、压力增加
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 38 | 增加 Y g | 用户人脸A,衣服色=红 | 放回食物到盆,袖子色=红 | 用户退餐(部分回退) | 增重+人脸+放回动作+上下颜色匹配=本人退餐 |
| 39 | 增加 Y g(≈此前减少量,净变化≈0) | 用户人脸A | 全部倒回,袖子色一致 | 完全反悔 | 先减后增回到原位,上方确认全部倒回 |
| 40 | 增加 Y g | 用户人脸A,衣服色=红 | 放回动作,袖子色=蓝 | ❌ 身份冒用回退 | 下方A脸但上方袖子色不匹配=B替A放回 |
| 41 | 增加 Y g | 用户人脸B | B放回食物(袖子色=B色) | ❌ 跨用户回退 | A取走的菜B往回放,归属关系混乱 |
| 42 | 增加 Y g | 无人脸 | 有人放回食物(可见袖子色) | ❌ 匿名回退 | 有放回动作但无人脸,可通过袖子色辅助追踪但无法确认身份 |
| 43 | 增加 Y g | 无人脸 | 无人(无任何放回动作) | ❌ 异常增重(原因不明) | 增重+无人脸+无动作=传感器异常或隐藏操作(如盆下塞物) |
| 44 | 增加 Y g(≈夹子重量) | 用户人脸A | 夹子从手中/台面掉入食物盆 | ⚠️ 夹子掉入盆 | 增重值=夹子克重+上方掉落轨迹确认=非回退 |
| 45 | 增加 Y g(≈夹子重量) | 无人脸 | 夹子掉入食物盆 | ⚠️ 夹子掉入(无人脸) | 夹子事件成立但人脸缺失 |
| 46 | 增加 Y g(≈夹子重量) | 用户人脸A | 无掉落画面(夹子在盆内自行移位) | ⚠️ 夹子移位伪装回退 | 增重=夹子重+无掉落+夹子位置变化=传感器以为是回退实则夹子动了 |
| 47 | 增加 Y g(小幅持续) | 用户人脸A | 身体倚靠台面/手撑 | 用户身体接触台面 | 持续小幅增重+上方倚靠/手撑姿态确认 |
| 48 | 增加 Y g(小幅持续) | 用户人脸A | 用户把餐盘/物品搁台面上 | 额外物品重量 | 持续增重+上方看到搁置物品=非食物重量 |
| 49 | 增加 Y g(小幅持续) | 用户人脸A | 小孩踮脚手撑台面 | 小孩手撑干扰 | 持续压力+手撑姿态=借助台面支撑 |
| 50 | 增加 Y g(小幅,初始偏高) | 无人脸 | 无人(台面有遗留物) | ⚠️ 遗留物品导致基准偏移 | 初始状态已有遗留物,后续取餐重量均偏移 |
| 51 | 增加 Y g(陡增) | 员工人脸 | 服务员端菜倒入食物盆 | ✅ 服务员补菜 | 陡增+员工脸+补菜动作=正常运营 |
| 52 | 增加 Y g(陡增) | 无人脸 | 服务员补菜动作 | ✅ 服务员补菜(下方未识别) | 上方确认为补菜动作,下方未识别到脸不影响运营判定 |
| 53 | 增加 Y g(陡增) | 用户人脸A | 用户往盆里倒东西 | ❌ 用户反向加菜 | 用户+增重+倒物动作=非正常退餐,异常行为 |
| 54 | 增加 Y g(陡增) | 无人脸 | 有人往盆里倒东西 | ❌ 非员工加菜 | 无人脸+加料=恶意嫌疑 |
| 55 | 增加≈整盆重(新值) | 员工人脸 | 服务员放入新盆 | ✅ 服务员换盆(放入新盆) | 整盆增重+员工脸+放盆=正常运营 |
| 56 | 增加≈整盆重(新值) | 无人脸 | 服务员放入新盆 | ✅ 服务员换盆(下方未识别) | 上方确认为运营操作 |
| 57 | 增加≈整盆重(新值) | 用户人脸 | 用户放下重物 | ❌ 用户放置异物 | 用户+整盆级增重=异常行为 |
| 58 | 增加≈整盆重(新值) | 无人脸 | 有人放下不明物体 | ❌ 不明物体放置 | 无人脸+放物=恶意或意外 |
| 59 | 增加+减少同时存在 | 用户人脸A+员工出现 | 用户取餐中服务员同步补菜 | ⚠️ 取餐+补菜并发 | 增重和减少叠加,需分离两个独立事件 |
| 60 | 反复增减 | 用户人脸A | 多次夹取→放回→夹取→放回,袖子色一致 | ⚠️ 反复取回操作 | 频繁增减+同一人反复操作+袖子色始终一致 |
| 61 | 反复增减 | 用户人脸A→下方扭头丢失→恢复 | 反复取回,袖子色一致 | ⚠️ 反复操作+人脸短暂丢失 | 下方扭头导致短暂丢失+上方袖子色不变=同一人 |
| 62 | 反复增减 | 无人脸 | 有人反复取回 | ❌ 匿名反复操作 | 反复+无人脸=无法追溯 |
| 63 | 反复增减 | A脸→B脸(衣服色A→B) | 袖子色从A→B | ❌ 跨用户反复回退 | 下方衣服色切换+上方袖子色切换=多端确认换人 |
---
## 三、压力无变化
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 64 | 无变化 | 用户人脸A | 空夹(夹子张开未夹到食物) | 空夹 | 压力无变化+有夹取动作+未夹到=空夹 |
| 65 | 无变化 | 用户人脸A | 夹走又放回(取多补少,未离开盆范围) | ⚠️ 净值抵消 | 有操作但净重不变,需上方行为记录补充 |
| 66 | 无变化 | 用户人脸A | 夹起悬空→犹豫→放回 | 犹豫反悔(未取走) | 夹起未离盆范围就放回,压力无净变化 |
| 67 | 无变化 | 用户人脸A | 无取餐动作(站着不动) | ⚠️ 占位阻塞 | 有人脸无动作无变化=纯占位 |
| 68 | 无变化 | 用户人脸A持续 | 无动作(已取完但不离开) | ⚠️ 取后阻塞 | 历史有取餐记录+当前无操作+人脸仍在 |
| 69 | 无变化 | 用户人脸A(长时间面对下方,无扭头) | 无动作 | ⚠️ 长时间面向摄像头不取餐 | 下方无扭头排除自然等待,持续占位疑似阻塞 |
| 70 | 无变化 | 用户人脸A→转身→人脸消失 | 取餐动作停止(手离开台面) | ✅ 正常离开 | 下方检测转身+上方无动作=标准离开 |
| 71 | 无变化 | 无人脸 | 有人取餐动作 | ❌ 传感器疑似故障 | 有动作+有脸(上方看到身体)+压力无响应=传感器可能异常 |
| 72 | 无变化 | 无人脸 | 无动作 | ✅ 空闲 | 三端一致无操作 |
| 73 | 无变化 | 无人脸 | 无动作(但夹盘无夹子) | ⚠️ 夹子缺失 | 无人但夹子不齐,影响后续取餐 |
| 74 | 无变化 | 用户人脸A | 有取餐动作 | ❌ 传感器故障 | 上下正常+压力无响应=传感器确认故障 |
| 75 | 无变化 | 员工人脸 | 员工检查/整理台面(不改变重量) | ✅ 员工巡检 | 员工操作+无重量变化=检查性操作 |
---
## 四、压力瞬时跳变
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 76 | 瞬时跳变 | 用户人脸A | 用户身体碰撞秤体 | ⚠️ 取餐中碰秤 | 跳变+碰撞动作=冲击型非取餐信号 |
| 77 | 瞬时跳变 | 用户人脸A | 夹子从手中滑落台面 | ⚠️ 夹子滑落冲击 | 跳变+滑落轨迹=夹子掉落冲击,非取餐 |
| 78 | 瞬时跳变 | 用户人脸A | 本秤无动作(邻秤有人操作) | 邻秤串扰 | 本秤无操作+邻秤有动作+跳变同步=振动传导 |
| 79 | 瞬时跳变 | 无人脸 | 有人碰撞秤体 | ❌ 未识别碰撞 | 有碰撞+无人脸 |
| 80 | 瞬时跳变 | 无人脸 | 台面振动(推车/物品撞击) | 外部振动干扰 | 振动型跳变+无人=环境因素 |
| 81 | 瞬时跳变 | 无人脸 | 无人/无振动 | ❌ 传感器自身异常跳变 | 排除所有外部因素=传感器硬件问题 |
| 82 | 瞬时跳变 | 员工人脸 | 员工推车/搬运碰撞 | ✅ 员工操作振动 | 员工操作导致的振动,正常运营干扰 |
---
## 五、压力缓慢漂移
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 83 | 缓慢漂移 | 无人脸 | 热食蒸汽升腾 | 蒸汽熏蒸漂移 | 蒸汽+无人+漂移=环境因素 |
| 84 | 缓慢漂移 | 无人脸 | 无蒸汽、无人 | 传感器温漂 | 排除蒸汽和人为=传感器自身漂移 |
| 85 | 缓慢漂移 | 无人脸 | 无蒸汽(秤体刚开机) | 初始不稳定期 | 上电预热阶段,暂不用于取餐判定 |
| 86 | 缓慢漂移 | 用户人脸A | 极缓慢倾倒/拨动,袖子色一致 | ⚠️ 漂移式取餐 | 漂移速率低于阈值+上方确认持续取餐动作=慢速取餐 |
| 87 | 缓慢漂移 | 无人脸 | 有人极缓慢取餐 | ❌ 未识别漂移式取餐 | 漂移+取餐动作+无人脸=规避识别 |
| 88 | 缓慢漂移 | 用户人脸A(长时间) | 无取餐动作 | 长时间占位+传感器漂移 | 人脸持续但无操作,漂移归因于传感器自身 |
---
## 六、多点同时变化
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 89 | 多盆同时减重 | 用户人脸A | 跨多盆夹取,袖子色一致 | ⚠️ 多菜品归属同一用户 | 多盆减少+同一人+跨盆动作=需合并归属 |
| 90 | 多盆同时减重 | 多人脸(衣服色各不同) | 多人同时取不同盆(袖子色各对应) | ⚠️ 多人并行取餐 | 多盆+多人+颜色可映射=可拆分归属 |
| 91 | 多盆同时减重 | 无人脸 | 多人取餐 | ❌ 多人取餐无主 | 多盆+多人+无人脸 |
| 92 | 多盆同时波动 | 用户人脸A | 身体倚靠导致全秤多点联动 | 倚靠致多盆联动 | 身体压台面导致多盆同步波动,非取餐特征 |
| 93 | 多盆同时波动 | 无人脸 | 台面整体振动 | 外部振动全秤响应 | 振动性多盆同步+无人=环境因素 |
| 94 | 多盆同时波动 | 无人脸 | 有人晃动台面 | ❌ 恶意晃动台面 | 人为振动+无人脸 |
---
## 七、夹子异常(上方摄像头追踪,压力变化量与夹子重比对)
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 95 | 减少 X g(正常) | 用户人脸A | 夹子初始在夹盘→取后留在盆 | ⚠️ 夹子归位错误 | 取餐正常但夹子未归位,上方追踪夹子最终位置 |
| 96 | 减少 X g(正常) | 用户人脸A | 夹子初始在盆→取后在盘 | 夹子位置(前用户遗留纠正) | 前一位放错,当前用户无意中纠正了夹子位置 |
| 97 | 减少 X g(正常) | 用户人脸A | 夹子初始在夹盘/盆→取后消失 | ❌ 夹子被带走 | 夹子消失在画面外+取餐完成+用户离开 |
| 98 | 无变化 | 用户人脸A | 夹子初始不在(用户发现无夹可用) | ⚠️ 夹子缺失影响取餐 | 无夹=无法取餐,需运营补充 |
| 99 | 无变化 | 无人脸 | 夹盘无夹子 | ⚠️ 夹子未配置/已丢失 | 开餐前未放或已被带走 |
| 100 | 瞬时跳变 | 用户人脸A | 放夹时滑落到台面/盆 | ⚠️ 夹子意外滑落 | 跳变+滑落轨迹=非取餐冲击 |
| 101 | 瞬时跳变 | 无人脸 | 夹子自然滑落(无人触碰) | 夹子非人为滑落 | 放置不稳导致自然滑落 |
| 102 | 减少≈夹子重 | 用户人脸A | 夹子从盆边滑入盆内(无夹取动作) | ⚠️ 夹子移位伪装取餐 | 压力传感器报"取餐",但上方无夹取+夹子位移=伪取餐信号 |
| 103 | 增加≈夹子重 | 用户人脸A | 夹子掉入盆内 | ⚠️ 夹子掉入伪装回退 | 压力传感器报"回退",但上方确认是夹子掉落=伪回退信号 |
| 104 | 增加≈夹子重 | 无人脸 | 夹子掉入盆内 | ❌ 夹子掉入(无人脸) | 夹子掉落成立但人脸未知 |
---
## 八、扭头/转身行为(下方独立检测,上方无法感知)
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 105 | 减少 X g | 用户人脸A→下方检测扭头→人脸消失→回头恢复 | 取餐动作持续,袖子色一致 | ✅ 主动扭头(正常中断恢复) | 下方扭头=主动行为,上方袖子色不变+动作持续=同一人正常取餐 |
| 106 | 减少 X g | 用户人脸A→人脸消失(下方无扭头动作) | 取餐动作持续,袖子色一致 | ⚠️ 非主动人脸丢失(疑似遮挡) | 下方未检测扭头=非转身,人脸丢失原因可能是摄像头被遮挡 |
| 107 | 减少 X g | 用户人脸A→人脸消失(无扭头) | 取餐动作持续,袖子色从A变B | ❌ 人脸遮挡+换人取餐 | 下方非扭头丢失+上方袖子色切换=A刷脸后B替取 |
| 108 | 无变化 | 用户人脸→下方检测转身→人脸消失 | 取餐停止(手离开) | ✅ 正常离开 | 下方转身+上方手离开+重量不变=标准离开 |
---
## 九、衣服颜色交叉验证(下方+上方协同判定身份一致性)
> **核心逻辑**:下方人脸+上半身衣服色 ↔ 上方袖子色,颜色匹配=同一人,不匹配=身份异常。
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 109 | 减少 X g | 人脸A+衣服色=红 | 夹取手+袖子色=红 ✅ | ✅ 颜色匹配→确认为A本人取餐 | 上下颜色一致=同一人,身份确认 |
| 110 | 减少 X g | 人脸A+衣服色=红 | 夹取手+袖子色=蓝 ❌ | ❌ 身份冒用(A刷脸B取餐) | 颜色不一致=A的脸+B的手,冒用确认 |
| 111 | 减少 X g | 人脸A+衣服色=红 | 两对袖子(红+蓝)同时出现 | ⚠️ A刷脸但两人操作 | A的脸但两人手,需判定红袖子(A)还是蓝袖子(B)是真正取餐人 |
| 112 | 增加 Y g(回退) | 人脸A+衣服色=红 | 放回手+袖子色=蓝 ❌ | ❌ B替A回退 | 回退动作非A本人执行 |
| 113 | 增加 Y g(回退) | 无人脸 | 放回手+袖子色=红 | ⚠️ 疑似A回退(但A未识别) | 袖子色匹配已知用户A,但下方未识别,无法确认 |
| 114 | 减少 X g | 人脸A+衣服色=红 | 夹取手袖子色=红→中途变蓝 | ❌ 取餐中途换人 | 同一取餐周期内袖子颜色切换=A开始后B接替 |
| 115 | 减少 X g | 人脸消失 | 袖子色从A色变B色 | ❌ 换人+人脸丢失叠加 | 袖子换色+下方丢失=疑似换人后刻意遮挡 |
| 116 | 多盆减少 | 多人脸(衣服色=红+蓝+绿) | 多对袖子(红+蓝+绿) | ⚠️ 多人身份可映射 | 颜色一一对应可实现归属分配 |
---
## 十、系统/环境异常(三端整体)
| # | 压力传感器 | 下方摄像头 | 上方摄像头 | 场景 | 三端协同判定理由 |
|---|---|---|---|---|---|
| 117 | 无数据 | 无数据 | 无数据 | ❌ 断电/重启 | 三端同时掉线 |
| 118 | 数据延迟/断点 | 数据延迟/断点 | 数据延迟/断点 | ❌ 网络断连 | 三端数据同时中断 |
| 119 | 持续有固定偏移(基准不准) | 正常 | 台面有残留汤汁/食物 | ⚠️ 初始未归零/未去皮 | 压力基准偏移+上方确认台面不干净=初始状态异常 |
| 120 | 持续有固定偏移 | 正常 | 台面有遗留物品(餐盘/手套等) | ⚠️ 上轮遗留未清理 | 遗留物导致基准偏移 |
| 121 | 长期无变化→突发大量减少 | 无人→突然多人脸 | 无→多人取餐 | ❌ 有组织逃逸取餐 | 突发多人无人脸取餐,疑似组织性规避 |
---
## 快速检索(压力信号 × 下方人脸)
| 压力信号 | 用户人脸A | 多人脸 | 员工人脸 | 无人脸 | 人脸丢失/切换 |
|---|---|---|---|---|---|
| 减少 | #1-#4 ✅ / #13 / #14/#17 ⚠️ | #5/#6 ⚠️ / #15/#22 ❌ | #11 ⚠️ / #16/#30/#31/#34 ✅ | #8/#9/#17-#19/#26/#27/#31/#32/#35 ❌ | #3/#104 ✅ / #7/#12/#105 ⚠️ / #106 ❌ |
| 减少(微量≈0 | #22-#25 | — | #29 ✅ | #26-#28 | — |
| 减少(≈夹子重) | #36/#101 ⚠️ | — | — | — | — |
| 增加 | #37/#38/#46-#48/#59 | #62 ❌ | #50/#54 ✅ | #41/#42/#44/#49/#51/#53/#55/#57/#61 | #39/#40 ❌ / #60 ⚠️ |
| 增加(≈夹子重) | #43/#45/#102 ⚠️ | — | — | #103 ❌ | — |
| 增加(整盆级) | #56 ❌ | — | #54 ✅ | #55 ✅ / #57 ❌ | — |
| 增加+减少并发 | #58 ⚠️ | — | #58 ⚠️ | — | — |
| 无变化 | #63-#68/#73 | — | #74 ✅ | #70-#72 | #69/#107 ✅ |
| 瞬时跳变 | #75-#77 ⚠️ | — | #81 ✅ | #78-#80 ❌ | — |
| 缓慢漂移 | #85/#87 ⚠️ | — | — | #82-#84/#86 ❌ | — |
| 多点同时 | #88/#91 ⚠️ | #89 ⚠️ | — | #90/#92/#93 ❌ | — |
| 颜色交叉验证 | #108 ✅ / #109/#113 ❌ | #110/#115 ⚠️ | — | #112 ⚠️ | #114 ❌ |
> **图例**:✅ 正常 | ⚠️ 需关注/需策略判定 | ❌ 异常取餐
## 硬件能力速查卡
| 能做什么 | 下方摄像头 | 上方摄像头 | 压力传感器 |
|---|---|---|---|
| 拍摄范围 | 人脸+上半身 | 腰部~臀部+手部 | — |
| 人脸识别 | ✅ | ❌ | — |
| 扭头/转身检测 | ✅ | ❌ | — |
| 衣服颜色 | ✅(上半身) | ✅(袖子) | — |
| 夹取/放回动作 | ❌ | ✅ | — |
| 夹子位置追踪 | ❌ | ✅ | — |
| 身体倚靠检测 | ❌ | ✅ | — |
| 服务员动作识别 | 人脸判定(员工/用户) | 动作类型判定(补菜/换盆/保洁) | 变化量级判定(陡增/整盆/微量) |
| 重量变化 | — | — | ✅(仅输出增减数值) |
| 取量是否合理 | ❌ | ❌ | ❌ |
| 场景综合判定 | 三端协同→ | 三端协同→ | 三端协同→ |