Files
simba-rmic-2026/docs/desktop-ui-redesign.md
T
sloray e3acb37d32
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
docs: 按 humanizer 重写项目文档并同步 RMIC::2026 现状
2026-10-19 10:47:00 +08:00

1112 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# cam-desktop 重构设计:The Division 风格 UI/UX · 比赛控制 · 直播包装 · .zip 固件包
> 版本:草案 v0.1 | 日期:2026-10-19
> 范围:`desktop/`(React + Tauri v2)、`desktop/core`、`desktop/src-tauri`、`protocol/`、`scripts/`、`docs/firmware-maintenance.md`
> 约束:保留视频、血量、装甲板、录制、固件维护;Tauri v2 + React + 现有 Rust 后端;中文界面;跨平台。
> 规则依据:[SIMBA ΣDOCS · 2026 校内赛参赛手册](https://docs.simba-robotics.com/zh-cn/competition/)、[对抗赛与奖项](https://docs.simba-robotics.com/zh-cn/competition/matches/)、[裁判系统](https://docs.simba-robotics.com/zh-cn/competition/referee-system/)
>
> **实施状态(2026-10-19)**:`.zip` 打包/读取、F11/Alt+Enter 全屏、SHD 视觉 token 已落地;
> 比赛状态机/判负/技术暂停/直播页按 §7.1 的 M2–M4 分阶段推进,尚待 §7.4 Q1–Q4 确认。
---
## 0. 现状基线(读代码后的结论)
| 层 | 现状 | 对本次重构的意义 |
| --- | --- | --- |
| 页面 | `App.tsx` 单一分支树:`MaintenancePage` → 致命错误 → 等待快照 → 身份选择 → 设密码 → `TeamPage` / `RefereePage` | 没有路由、没有 Shell、没有直播视图;需先抽出 `AppShell` 与视图枚举 |
| 样式 | `styles/global.css` 单文件 1687 行,`:root` 20 个 token,深底 + 荧光绿强调 | token 体系要替换,且**中文排版规则已踩过坑**(见 1.3),不能推倒重来 |
| 状态 | 后端 5 Hz 推整份 `Snapshot`,前端 `useSyncExternalStore` 单例 | 比赛状态机应作为快照的一部分下发,前端不做第二份真相 |
| 视频 | WebSocket 二进制 JPEG + canvas 绘制,缩放/平移/网格/瞄准器/命中反馈全在 `VideoSurface` | 动效不得侵入 canvas 绘制路径(否则抢帧) |
| HUD | 5 格斜切血条,左上自己/右上对手,数字默认显示 | 5 格 = 20 点/格,而规则是**每发扣 3 点** → 数字必须保留(见 6.2) |
| 裁判端 | 双路等宽 + 队名编辑 + 装甲板表 + 录制/回放 + 底部 250px 固定高度 | 无计时/比分/暂停/判负;底部 Dock 要重排 |
| 后端 | UDP `5600` 视频 / `5601` 装甲板;命令集中在 `commands.rs`;密码闸门在 `state.rs` | 新命令同样要过闸门;比赛状态需要 `role == referee` 的限制 |
| 协议 | `MsgType`: Video=1, Subscribe=2, Hello=3, Heartbeat=4, ArmorHealth=5,**只有上行/广播,没有下行控制** | 客户端判负/暂停**无法**改变硬件血量(见 3.2、R1) |
| 固件包 | `bundle.json` + `esp32s3-app.bin` + `partition-table.bin`(+`bootloader.bin`),客户端选**目录**,`format: 1`,逐项 SHA256 | 改成 `.zip` 只需改 `maintenance::load_bundle` 的取数入口,校验逻辑可原样复用 |
| 产物 | `target/firmware/` 已有 `esp32s3-app.bin`(1,143,504 B)、`partition-table.bin`(3072=0xC00)、`bootloader.bin`、`BUILD-INFO.json`、`bundle.json`、`SHA256SUMS.txt` | 打包环节有现成输入,缺的是"构建后自动产出 zip" |
| 现场 | 适应性训练 **10-25**,决赛 **11-01** | 距决赛 13 天 → 优先级必须围绕这一天排(见 7.1) |
---
## 1. 设计系统(可落地)
### 1.1 六条设计原则(附取舍)
| # | 原则 | 具体约束 | 被否决的做法 |
| --- | --- | --- | --- |
| P1 | **画面优先** | 视频窗格内除 HUD 外零装饰;HUD 只贴四角与上沿;中心半径 18% 区域**永不**放常驻元素 | 半透明斜纹背景铺满、常驻扫描线 |
| P2 | **三秒可读** | 血量/时间/比分/状态四类信息位置固定、字号 ≥ 24px@1080p、对比 ≥ 4.5:1;3 m 外单眼可读 | 用颜色/图标单独表达状态 |
| P3 | **状态永不含糊** | 无数据不显示 0;「离线/演示/暂停/已判定」必须全屏可见;同一时刻只有**一个**主结论 | 用 toast 表达比赛状态 |
| P4 | **误操作成本 > 操作成本** | 破坏性动作(判负、清空配置、烧录)一律 `长按 ≥ 1000ms` + 理由 + 可撤销/可审计;高频只读动作零确认 | 用 `confirm()` 敷衍 |
| P5 | **同源渲染** | 用户端 HUD / 裁判端 / 直播三层共用同一 `Snapshot` + `MatchView` 与同一套安全区 | 给直播另写一套数据拼装 |
| P6 | **无障碍与光敏安全** | 闪烁 ≤ 3 Hz 且面积 < 屏幕 25%;`prefers-reduced-motion` 全局降级;色不单独承载信息 | 无限 glitch、随机闪白 |
The Division 能迁移过来的视觉语言只有四样:**近黑底 + 单一强调色 + 细线切角 + 等宽数字**。它的地图、装备详情、粒子特效属于"非实时、可暂停"的场景,搬进 3 分钟一局的比赛界面会直接损害 P1/P2。
### 1.2 色彩
**色板**(可直接替换 `global.css` 的 `:root`):
```css
:root {
/* 底色:三层就够,第四层用"提亮 4%"而不是再加一个变量 */
--bg-void: #07080a; /* 窗口最底 / 直播黑场 */
--bg-base: #0b0d10; /* 页面底 */
--bg-panel: #11151a; /* 面板 / 顶栏 / 状态行 */
--bg-raised: #171c22; /* 卡片 / 行 */
--bg-input: #090b0e; /* 输入框 / 日志 */
--bg-scrim: rgba(6, 8, 10, 0.78); /* 遮罩 */
/* 线:Division 的"1px 亮边"是主要体积感来源,阴影只做兜底 */
--line: #232a33;
--line-strong: #333c48;
--line-accent: rgba(255, 122, 26, 0.45);
/* 文本 */
--text: #e9ecf1; /* 15.2:1 on --bg-base */
--text-dim: #98a2b0; /* 6.4:1 */
--text-faint: #5e6875; /* 3.2:1 —— 只用于非信息性文字 */
--text-invert: #07080a;
/* 强调:SHD 橙。唯一强调色,用于焦点/当前项/CTA/直播描边 */
--accent: #ff7a1a;
--accent-hot: #ff9a4d;
--accent-deep: #b44e08;
--accent-veil: rgba(255, 122, 26, 0.12);
/* 语义 */
--danger: #ff3b30; /* 判负 / 录制点 / 错误 */
--warn: #f5b13d; /* 低血 / 链路抖动 / 未上锁 */
--ok: #3ddc84; /* 通过 / 在线 */
--info: #35d6e6; /* 技术暂停 / 扫描 / 遥测 */
/* 队伍:规则"编号小=红方、编号大=蓝方" → 队号 0 固定红、1 固定蓝 */
--team-0: #ff4d4f;
--team-1: #3e9bff;
--team-0-veil: rgba(255, 77, 79, 0.14);
--team-1-veil: rgba(62, 155, 255, 0.14);
}
```
**用色规则**
1. 强调色**不用于血量**——血量只用队伍色,低血时切 `--danger`(现有 `HealthBar` 行为,保留)。
2. 每个屏幕最多 **1 个** `--accent` 元素做主 CTA;第二个强调改用 `--line-accent` 细边。
3. 红色只出现在三处:判负、录制中、错误;**不要**用它做"取消"。
4. 直播层禁止使用 `--accent-veil` 以上的大面积橙(压缩后发脏),装饰靠线不靠面。
5. 纯黑 `#000` 只用于视频信箱区;面板底色不低于 `#0b0d10`,避免 OLED/编码 banding。
6. 对比度自检清单:所有正文 ≥ 4.5:1、所有 ≥18px 的数字 ≥ 3:1、队伍色文字必须叠 `text-shadow: 0 1px 3px rgba(0,0,0,.9)`(现有做法保留)。
### 1.3 字体与排版
```css
:root {
--font-ui: "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
"Source Han Sans SC", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Cascadia Mono",
"SF Mono", Consolas, monospace;
--fs-micro: 10px; --fs-small: 11px; --fs-body: 13px;
--fs-title: 15px; --fs-h2: 17px; --fs-h1: 24px;
--fs-hud: clamp(18px, 1.8vmin, 28px); /* 血量数字 */
--fs-clock: clamp(28px, 3.4vmin, 44px); /* 比赛计时 */
--fs-score: clamp(40px, 5vmin, 64px); /* 比分 */
--fs-display:clamp(44px, 8vmin, 96px); /* 判定大字(仅直播/结果卡) */
}
```
规则:
- **中文不加 `letter-spacing`**。`global.css` 已注明"表头是中文,加字距只会让它看起来散架"——这条继续执行,只对拉丁文(`LIVE`、`TECH PAUSE`、`SERIES`)用 `letter-spacing: .14em` + `text-transform: uppercase`。
- 所有数字(血量、时间、比分、ID、帧率)走 `--font-mono` + `font-variant-numeric: tabular-nums`,避免数字跳动时宽度变化引起布局抖动。
- 字号阶梯只有 6 档正文 + 4 档显示级;禁止在组件里写 `font-size: 12.5px` 这种一次性值。
- 字体不打包(现场离线,中文字体体积又大)。若要 Division 味的拉丁字形,可后续单独引入 OFL 的 `Barlow Condensed SemiBold`(约 40 KB),**仅用于数字与拉丁标签**,并保留系统字体作为 fallback。
- 行高:正文 1.7、标题 1.3、数字 1.0。
### 1.4 层级(z-index / elevation / 几何)
```css
:root {
--z-stage: 0; /* 视频 canvas */
--z-hud: 10; /* 画面内 HUD */
--z-overlay: 20; /* 暂停/判定覆盖层 */
--z-chrome: 30; /* 顶栏/状态行/悬浮徽标 */
--z-drawer: 40; /* 设置抽屉 */
--z-dialog: 50; /* 模态框 */
--z-toast: 60; /* 提示 */
--z-critical: 70; /* 链路断开/致命错误 */
}
```
- **面板体积感** = `1px var(--line)` + `inset 0 1px 0 rgba(255,255,255,.05)`,**不用**大阴影;阴影只用于浮动层(抽屉/对话框/toast)。
- **切角**:`--cut: 8px`。只在按钮、徽标、比分板、判定卡上用 `clip-path`;面板不要用(`clip-path` 会吃掉 border 和 box-shadow,改完还得补 SVG 边框,收益不抵成本)。
```css
.cut { clip-path: polygon(var(--cut) 0, 100% 0, 100% calc(100% - var(--cut)),
calc(100% - var(--cut)) 100%, 0 100%, 0 var(--cut)); }
```
- 圆角:`--r-xs 2 / --r-sm 4 / --r-md 8 / --r-lg 12`。Division 几乎全直角,只在小控件上保留 2–4px;`10px` 的大圆角(现有 `--radius`)改到 4px。
- 角标(bracket):面板四角用 `::before/::after` 画 8×1px 亮线,只在"当前焦点面板"上出现一个角,用来替代发光描边。
### 1.5 HUD 规格
| 元素 | 位置 | 尺寸 | 规则 |
| --- | --- | --- | --- |
| 我方血条 | 画面左上,距边 `3.5%` 宽 | 宽 36%(现 42%)、高 16px、5 格、`skewX(-15deg)` | 数字必显;`available=false` 时显示"装甲板未接入",**不显示 0** |
| 对手血条 | 画面右上,镜像 | 同上 | 同上;标签"我方/对手"保留 |
| 瞄准器 | 跟随图像 | 大小不随缩放 | 现有 `drawCrosshair` 不动,仅颜色默认值改为 `#ff7a1a`(可配置) |
| 倍率 | 画面右下角 | 11px mono | 仅在 zoom > 1 时出现 |
| 命中提示条 | 画面顶部 `top: 56px` 居中 | 高 26px | 避开准星(已踩坑,保留) |
| **比赛计时**(新) | 画面顶部居中 `top: 12px` | 32px mono | 只在 `Running/Paused/Ended` 出现;`< 30s` 变 `--danger` 且每秒脉冲一次(**不闪**) |
| **暂停/判负遮罩**(新) | 整画面 `--z-overlay` | — | 半透明 + 居中文字;不遮血条与计时(保留可读性) |
| 网格 | 画面内 | 现有 | 默认关;`opacity ≤ .22` |
| 技术读数 | 底部状态行 | 11px mono | 用户端 release 下不显示(`TEAM_DIAGNOSTICS` 已有门控) |
**HUD 熄灯规则**(新增):非关键 HUD(网格、倍率、帧率、界面数)在 3 s 无指针输入后降到 `opacity: .35`;`prefers-reduced-motion: reduce` 时**直接隐藏**而不做透明度过渡(避免持续重绘)。
### 1.6 动效原则
```css
:root {
--t-fast: 90ms;
--t-base: 140ms;
--t-slow: 220ms;
--t-emphatic: 320ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.7, 0, 1, 0.6);
--motion-scale: 1; /* 0 = 降级 */
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0; --t-fast: 1ms; --t-base: 1ms;
--t-slow: 1ms; --t-emphatic: 1ms; }
}
```
**动效只分三类,不另设表外时长**
| 类别 | 时长 | 用途 | 属性 |
| --- | --- | --- | --- |
| 状态确认 | 90–140 ms | 按钮按下、开关、页签、hover | `transform`/`opacity`/`background-color` |
| 状态迁移 | 220–320 ms | 抽屉、对话框、覆盖层、全屏切换、比分变化 | `transform`/`opacity` |
| 连续数据 | ≤ 200 ms 插值 | 血量格、数字滚动、计时 | 只改文本与宽度,不改布局 |
**动效清单**(实现时逐条对表)
| 组件 | 触发 | 动效 | 时长/曲线 |
| --- | --- | --- | --- |
| `Button` | press | `scale(.98)` | 90ms / standard |
| `Button` hover | hover | 边框转 `--line-accent` + 背景 +4% | 140ms |
| 页签 | 切换 | 底线 2px 从左滑到新页签 | 220ms / out |
| `HealthBar` 格 | 掉血 | 该格 `background` 140ms 淡出;数字 160ms 计数 | 140/160ms |
| 命中闪红 | hit | 620ms 全屏径向渐变淡出(现有 `hit-fade`) | 保留 |
| 暂停遮罩 | 进入/退出 | `opacity 0→1` + 文字 `translateY(6px)→0` | 220ms / out |
| 判定卡 | 结束 | `scale(.94)→1` + `opacity` | 320ms / out |
| 计时最后 30 s | 每秒 | 数字 `scale(1→1.06→1)` 一次 | 300ms |
| 键盘提示 | Tab 按住 | 底部面板 `translateY(8px)→0` | 220ms |
| 全屏切换 | F11 | 无自定义动画(由窗口管理器负责) | — |
**禁止清单**
1. 全屏抖动(`HitFeedbackConfig.shake` 已废弃,代码里仍然不要复活)。
2. 任何 > 3 Hz 的闪烁;任何占屏 > 25% 的红色闪烁(判负的 320 ms 一次性淡入除外)。
3. 常驻循环动画(扫描线、呼吸背景、粒子)。
4. 遮住准星中心 18% 半径的动画。
5. 会触发 layout 的动画(`width`/`height`/`top`/`left` 的 transition)——血条格子用 `background`,进度用 `transform: scaleX`。
6. 依赖 `requestAnimationFrame` 的新增长循环(现有 `VideoSurface` 的绘制循环是唯一允许的一个)。
**降级开关**:设置里加"低动效模式"(`ui.reduceMotion`),等价于强制 `prefers-reduced-motion`,给低配裁判机与"现场怕闪"的场合用。
---
## 2. 页面与组件重构方案
### 2.1 应用外壳(新增 `AppShell`)
```
┌──────────────────────────────────────────────────────────────────────┐
│ TopBar 44px cam-desktop · 裁判端 · 链路● │ 比赛条(裁判/直播) │ 工具 │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Stage:视频 + 画面内 HUD + 覆盖层(暂停/判定/校准) │
│ │
├──────────────────────────────────────────────────────────────────────┤
│ StatusBar 28px 链路 / 帧率 / 丢帧 / 提示 │
└──────────────────────────────────────────────────────────────────────┘
```
三种外壳状态(`data-shell`):
| 状态 | 触发 | 行为 |
| --- | --- | --- |
| `normal` | 默认 | 全部 chrome 可见 |
| `immersive` | F11 | chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标 |
| `broadcast` | 视图切换 `Ctrl+Shift+B` | 只渲染 `BroadcastStage`;禁用设置/维护/发现列表;不渲染任何 IP/token |
结构落点:`App.tsx` 里现有的门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码)**不动**,只在最后一段把 `TeamPage | RefereePage` 包进 `AppShell`,并新增 `BroadcastPage` 分支。
### 2.2 用户端 `TeamPage`(操作手视角)
目标:布帘后、单手、不看键盘、只有 3 分钟。
```
┌──────────────────────────────────────────────┐
│ 02:41 (计时) │ ← 仅比赛进行中出现
│ ┌────────────┐ ┌────────────┐│
│ │我方 ████ 76│ │100 ████ 对手││ ← 血条贴角,宽 36%
│ └────────────┘ └────────────┘│
│ │
│ + (准星)│
│ │
│ (命中时:上中"命中 -3" + 全屏淡红 620ms) │
└──────────────────────────────────────────────┘
│ 链路 · 28.6 fps · 丢 0.4% │ ← release 下只留"链路"
```
改动点:
1. 顶部加**计时条**(仅 `match.phase ∈ {running, paused}` 且本队已绑定);不显示比分细节,局间才显示"第 2 局 · 1:1"。
2. 底部状态行从"滚轮缩放 · 拖动平移…"改成**首次提示**:进入页面 5 s 后自动淡出,鼠标移动再出现(现在常驻,占屏且无用)。
3. 新增 `PausedOverlay`:技术暂停时整屏 86% 遮罩 + "技术暂停 03:12 · 由裁判发起",血条/计时保留可见。
4. 新增 `JudgedOverlay`:判负/结束时显示"本局判负(设备故障)"或"胜利",与 `MatchBanner` 合并成一个组件(避免两套结论 UI 同时出现)。
5. 设置入口、录制入口**不进主界面**(现在 TopBar 里有"设置/固件维护",用户端误触风险高):改为 `Ctrl+K` 命令面板或长按品牌名 2 s。
6. 校准模式(现有 `calibrating`)保留,但提示从底部横幅改到画面内角标,避免遮挡。
### 2.3 裁判端 `RefereePage`
三区 + 一条**比赛条**:
```
┌ TopBar ────────────────────────────────────────────────────────────────┐
│ cam-desktop · 裁判端 · 链路● · 录制×2 [直播] [设置] │
├ MatchBar 52px ─────────────────────────────────────────────────────────┤
│ BO3 第2局 │ 02:41 │ 红 76 ╍╍╍╍ 100 蓝 │ ⏸ 技术暂停 │ ⚑ 判负 │ ⋯ 更多 │
├────────────────────────────────────────────────────────────────────────┤
│ ┌─ 红方(左)──────────────┐ ┌─ 蓝方(右)──────────────┐ │
│ │ video + HUD │ │ video + HUD │ │
│ │ [队名 可编辑] [fps] │ │ [队名 可编辑] [fps] │ │
│ └─────────────────────────┘ └──────────────────────────┘ │
├ Dock ─────────────────────────────────────────────────────────────────┤
│ [装甲板 8] [录制与回放 3] [事件 27] [连接诊断] [刷新] [⟳] │
│ …内容(表格/列表/日志)… │
└────────────────────────────────────────────────────────────────────────┘
```
改动点:
1. **`MatchBar`(新,常驻,高 52px)**:局次、计时、比分、暂停、判负、更多菜单。这是裁判端最重要的新增组件;判定后整条切成"结果态"(红/蓝底 + 理由 + 撤销入口)。
2. `referee-stage` 保持 `flex: 1`,新增的 `MatchBar` 占固定高度;现有 `referee-bottom` 的 250px 改成 **Dock 可拖拽高度**(默认 240px,最小 120,最大 45vh),因为加一行比赛条后 900px 高的窗口会只剩 560px 给画面。
3. `ArmorTable` 改为**两队并列**(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 `主` 徽标;离线行 `opacity .55`(保留);`reportsAllPlates=false` 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。
4. 新增 `EventPanel`(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。
5. `MediaPanel` 保留全部功能,新增两个东西:**按局分组**(`matchId` 目录)与 **"回放本局"**(双路同步 + 事件定位)。
6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。
### 2.4 直播 `BroadcastPage`(新)
见第 4 节。要点:一个 1920×1080 虚拟画布 + 4 个模板 + 安全区参考线 + 只读数据。
### 2.5 维护页 `MaintenancePage`
现在是一个 2 列网格的长页面(设备卡 / 固件包卡 / 分区表 / 报告),信息密度高但"下一步做什么"不明显。改成 **5 步 Stepper**:
```
① 设备 → ② 固件包(.zip) → ③ 烧录计划 → ④ 烧录 → ⑤ 结果
──────────────────────────────────────────────────────────────
左:步骤栏(已完成 ✓ / 当前 ▶ / 未开始 ○)
右:当前步骤卡(大字号 + 一个主按钮 + 次级信息折叠)
```
- 步骤 ② 接受 **`.zip` 与目录**(目录收在"高级 / 兼容旧包"里),支持拖拽。
- 步骤 ③ 用 diff 表显示"factory: 0x070000 → 0x200000",并把 `warnings` 提到主区域(现在埋在下面)。
- 步骤 ④ 全屏进度 + 不可关闭 + `取消`(已有 `maintenance_cancel`)+ 实时日志。
- 步骤 ⑤ 报告卡:结果、坏字节/坏扇区、备份目录、`恢复旧分区表`、`复制报告`。
- 保留全部诊断细节(分区表、候选槽位、回读不一致列表),只是默认折叠。
### 2.6 组件清单
**新增**
| 文件 | 职责 |
| --- | --- |
| `components/ui/Panel.tsx` | 带切角/角标的容器(`tone`/`dense`/`flush`) |
| `components/ui/HoldButton.tsx` | 长按确认按钮(1000ms 环形进度,松手取消,Esc 取消) |
| `components/ui/KeyHint.tsx` | 键帽样式(`<KeyHint keys="F11" />`) |
| `components/ui/Stepper.tsx` | 维护页步骤栏 |
| `components/ui/EmptyState.tsx` | 统一空状态(图标 + 一句 + 一个动作) |
| `components/match/MatchBar.tsx` | 裁判端比赛条 |
| `components/match/MatchClock.tsx` | 计时显示(插值 + 最后 30 s 强调) |
| `components/match/ScoreBoard.tsx` | 比分(局/系列赛) |
| `components/match/ForfeitDialog.tsx` | 判负对话框(败方 + 理由 + 长按) |
| `components/match/TechPauseDialog.tsx` | 技术暂停(申请方 + 理由 + 已批准勾选) |
| `components/match/EventPanel.tsx` | 事件时间轴 + 导出 |
| `components/match/PausedOverlay.tsx` / `JudgedOverlay.tsx` | 覆盖层 |
| `components/replay/ReplayView.tsx` | 双路同步回放 + 事件定位 |
| `components/broadcast/BroadcastStage.tsx` | 直播画布 |
| `components/broadcast/SafeGuides.tsx` | 安全区参考线(仅预览) |
| `components/broadcast/{TitleBar,ScoreBug,RoundClock,TalentCard,LowerThird,AlertCard,SponsorRail,StandbyCard}.tsx` | 包装层 |
| `components/firmware/FirmwareDropZone.tsx` | .zip 拖拽/选择 |
| `hooks/useFullscreen.ts` / `useHotkeys.ts` / `useIdle.ts` / `useServerClock.ts` | 全屏 / 快捷键 / 空闲 / 服务器时钟 |
**修改**
`App.tsx`(Shell + 视图)、`TopBar`(比赛条位、直播入口、工具收纳)、`TeamPage`、`RefereePage`、`MaintenancePage`、`MediaPanel`(按局分组/回放)、`ArmorTable`(双队/迷你条)、`HealthBar`(宽 36%、字号 token)、`SettingsPanel`(新增"界面/动效/直播"段)、`global.css` → 拆成 `tokens.css` + `base.css` + `components.css`(或保持单文件但按 1–7 节分区)。
### 2.7 状态模型(前端)
```ts
interface AppStore {
snapshot: Snapshot | null;
connected: boolean;
lastHit: HitPulseRecord | null;
confirmQuit: number;
notes: string[];
channelError: string | null;
// 新增
ui: { shell: "normal" | "immersive" | "broadcast"; guides: boolean; idle: boolean };
events: MatchEventView[]; // 由 WireMsg::MatchEvent 累积(本地最多 500 条)
clockSkewMs: number; // serverNow - localNow,用于计时插值
}
```
**事件的传输方式(别塞进快照)**:比赛事件走**独立推送**,与现有的 `hit` 同一条路(`server::WireMsg` 新增 `MatchEvent`),前端累积;`Snapshot.match` 只带**最近 12 条**用于重连后补齐。理由:快照 5 Hz × 200 条事件会让每份快照涨到几十 KB,纯浪费;而事件本身就是"发生即推"。
**计时不许前端自己数**:`Snapshot` 增加 `nowMs: u64`(后端 Unix 毫秒),前端用 `useServerClock` 每帧算 `serverNow = localNow + clockSkewMs`,再用 `match.remainingMs - (serverNow - snapshotAt)` 插值。理由:`setInterval` 在窗口失焦时会被节流,而比赛计时绝不能因为切窗口而变慢。
---
## 3. 交互逻辑
### 3.1 F11 全屏
**实现**
```ts
// hooks/useFullscreen.ts
const win = getCurrentWindow();
const [full, setFull] = useState(false);
useEffect(() => { win.isFullscreen().then(setFull); }, []);
const toggle = useCallback(async () => {
const next = !(await win.isFullscreen());
await win.setFullscreen(next);
setFull(next);
}, []);
```
**权限**:`capabilities/default.json` 现在只有 `core:default` + `dialog:default`,**不含**全屏。需要追加(identifier 已在本仓库 `gen/schemas/acl-manifests.json` 中确认存在):
```json
"permissions": [
"core:default",
"dialog:default",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen"
]
```
**按键**
| 平台 | 按键 | 备注 |
| --- | --- | --- |
| Windows / Linux | `F11` | `keydown` capture + `preventDefault()` |
| macOS | `⌃⌘F` | 同时支持 `F11`(部分外接键盘有) |
| 全平台 | `Alt+Enter` | 备用,游戏玩家肌肉记忆 |
| 全平台 | `Esc` | 退出全屏 / 关闭顶层对话框(按层级,一次只关一个) |
**细节**
- 切换后 400 ms 内忽略再次触发(部分窗口管理器会发出重复 keydown)。
- 全屏切到 `immersive`:chrome 隐藏、`broadcast` 模式下同时隐藏安全区参考线与光标。
- 退出通道必须**看得见**:全屏时右上角保留一个 1px 高热区,指针进入显示"退出全屏 F11",3 s 后淡出。
- 不做"全屏时禁止关闭窗口"这类硬锁——现场需要能立刻退出。
- 多显示器:P2 再加 `setFullscreenOnMonitor`(`core:window:allow-set-fullscreen-on-monitor` 已存在),让直播输出指定显示器。
### 3.2 一键判负(最危险的动作)
**入口**:`MatchBar` 右侧 `⚑ 判负`,或 `Ctrl/Cmd+Shift+F`。只在裁判端、`phase ∈ {running, paused}` 时可用。
**三步,不可合并**
```
① 点按 → ForfeitDialog
┌─────────────────────────────────────────┐
│ 判定本局结果 │
│ 当前:红 76 · 蓝 100 | 剩余 02:41 | 第2局│
│ 败方: (●) 红方 ( ) 蓝方 │
│ 理由: [设备故障 ▾] │
│ 设备故障 / 违规 / 弃权 / 未到场 / 其他 │
│ 备注: [________________________] │
│ ⚠ 本操作只改变比赛判定与记录,不改变硬件血量 │
│ [取消] [长按 1 秒确认判负] │
└─────────────────────────────────────────┘
② 长按 1000ms(HoldButton,松手即取消,Esc 取消)
③ 5s 内 toast 提供「撤销」
```
**执行语义**
```rust
match_end { reason: Forfeit { loser: 0|1, kind: ForfeitKind, note: Option<String> } }
```
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
- 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。
- **不改硬件血量**:`ArmorHealthStore` 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。
- **审计**:事件 `{ at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 }`,追加到 `matches/<yyyy-mm-dd>/<matchId>/events.ndjson`,同时进 `snapshot.match.events`。
- **撤销**:`match_undo { eventId }`,5 s 内可直接撤销;之后只能 `match_reopen_round { reason }`,在历史里留下"更正"两条记录(**不删除**原记录)。
**与自动判定共存**:`resolve_outcomes` 已经能在血量归零时给出 `Victory/Defeat/Draw`。两者必须用同一字段建模:
```ts
end: { by: "auto" | "referee"; reason: "knockout" | "timeout" | "forfeit" | "equipment" | "tiebreak"; loser?: 0|1 }
```
否则"回放/统计"里会分不清这局是怎么结束的。
### 3.3 技术暂停
**规则对齐**:手册写"任意一方…可向场边裁判申请发起时长为 **5 分钟** 的技术暂停并说明理由,待主裁判批准后技术暂停开始。技术暂停期间,双方均可在该段时间内检修机器人。"
**四步流程**
```
申请方(红/蓝) → 理由 → ✅ 主裁判已批准 → 开始计时(05:00) → 恢复
```
- 入口:`MatchBar` 的 `⏸ 技术暂停`。对话框必填:申请方、理由(枚举+备注)、**"主裁判已批准"复选框**(不勾不能开始——把规则写进交互里)。
- 开始后:`phase = Paused { kind: Tech, endsAt, applicant }`,**比赛计时冻结**,技术暂停独立倒计时 5:00。
- 用户端/直播:`PausedOverlay` 显示"技术暂停 · 剩余 04:12",不遮挡血条与计时。
- 结束:
- 手动「恢复比赛」→ 3-2-1 倒数 3 s(`Resuming` 过渡态)→ `Running`。
- 05:00 到点 → 状态 `PausedTimeout`,提示"技术暂停已超时",按钮变成「恢复」/「判负」二选一,不自动判负(规则没写超时即判负)。
- 录制**不停**(证据链需要),但写入 `bookmark` 事件;视频文件里保留暂停期间的画面。
- 暂停期间的命中:硬件照常上报,客户端**如实显示**并把事件标红(不隐藏、不改血量)。理由见 R1/Q3——客户端没有写硬件的能力,做"冻结基线"只会制造两份互相矛盾的血量。
### 3.4 计时 / 比分
**两个不同的 3 分钟,界面必须分开写**
| 阶段 | 时长 | 文案 |
| --- | --- | --- |
| 赛前准备 | 3:00 | `准备 02:41`(浅色,不计入比赛) |
| 单局比赛 | 3:00 | `比赛 02:41`(主色,进入最后 30 s 变红) |
| 技术暂停 | 5:00 | `技术暂停 04:12`(青色) |
**状态机**
```
Idle ──start_round──▶ Running ──pause──▶ Paused ──resume──▶ Running
▲ │ │
│ ├──hp=0 / timeout──▶ Ended(by=auto)
│ └──forfeit─────────▶ Ended(by=referee)
└──reset/next_round─────┴──────────────────┘
Ended(timeout 且血量相等) ──▶ AwaitingTiebreak ──set_tiebreak──▶ Ended(by=referee, reason=tiebreak)
```
**到点判定**(严格照手册四条):
1. 血量先减为 0 的一方落败 → 已有 `resolve_outcomes`,直接复用。
2. 3 分钟耗尽且无人归零 → 血量多者胜。
3. 双方血量均等 → **不以"平局"结束**,进入 `AwaitingTiebreak`,由裁判选择"本局最快开始移动的一队"。
- 客户端**没有**运动遥测(协议里没有位置/速度),所以只能人工录入:`set_tiebreak { winner: 0|1 }`。
- 这条必须显眼提示裁判"需要人工判定",不能默认算平局,否则会静默改变比赛结果。
4. 两边同时归零 → `Draw`(现有 `MatchOutcome::Draw` 逻辑保留,仅用于显示)。
**比分**:`bestOf ∈ {1,3,5}`(对应手册 BO1/BO3/BO5),`score: [u8;2]`,`history: Vec<RoundResult>`。先到 `ceil(bestOf/2)` 局的一方赢下系列赛,界面显示 `2:0 · 系列赛结束`。
**计时实现**(后端为准):
```rust
pub struct MatchClock {
started: Option<Instant>,
frozen_elapsed: Duration, // 暂停累计
limit: Duration,
}
impl MatchClock {
fn remaining(&self) -> Duration;
fn pause(&mut self); fn resume(&mut self);
}
```
在 `AppState::tick()`(5 Hz)里推进;`Snapshot.match.remaining_ms` 为权威值;`Snapshot.now_ms` 用于前端插值。不允许前端 `setInterval` 自己减。
### 3.5 回放
**入口**:`Ended` 后结果卡 →「回放本局」;或 Dock 的「录制与回放 → 按局分组」。
**布局**
```
┌ 回放 · 2026-11-01 第2局 红 vs 蓝 ─────────────── [导出] [返回] ┐
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 红方录屏 │ │ 蓝方录屏 │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ ▶ ━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 01:12/03:00 │
│ ⚑ 00:00 开始 · ● 00:41 命中 · ⏸ 01:12 技术暂停 · ⚑ 02:33 判负 │
└────────────────────────────────────────────────────────────────┘
```
- 两个 `<video>` 共用主时钟(以 A 为主,B 每秒校正一次,偏差 > 120 ms 才 `seek`,避免抖动)。
- 事件时间轴可点击跳转:`currentTime = (event.atMs - roundStartAtMs) / 1000`。
- 快捷键:`空格` 播放/暂停、`←/→` ±1 s、`Shift+←/→` ±5 s、`M` 打标记。
- 截图:现有 `api.screenshot(camera)` 只能截**最新一帧**,不能截回放帧。需要新增 `save_png { data: Vec<u8>, name: String }`(限制写入 `recordings_dir/shots`),前端用 `canvas.drawImage(video)` + `toBlob` 取帧。
- 录制文件命名建议:`<date>_<round>_<红队>_vs_<蓝队>_<cameraText>.mp4`;若比赛开始时自动开启录制,需要给 `Recorder::start` 增加一个 `label: Option<&str>`。
- 录制起停由 `match_start/end` **发出事件**,是否自动则看用户配置(默认自动开、自动停;退出程序仍走现有 `stop_all` 收尾)。
---
## 4. 直播 UI:安全区与包装层
### 4.1 基准画布与安全区
基准 **1920×1080(16:9)**。相机是 **640×480(4:3)**,所以直播层必须自己决定是加信箱还是裁切,不能指望画面填满。
| 区域 | 距边距 | 1080p 像素 | 内容 |
| --- | --- | --- | --- |
| 画布 | — | 0 | 1920×1080 |
| **Action safe**(93%) | 3.5% | 左右 67px / 上下 38px | 视频、装饰边框 |
| **Title safe**(90%) | 5% | 左右 96px / 上下 54px | 比分、计时、队名、判定卡 |
| **可牺牲区**(平台遮挡) | — | 底部 120px(y ≥ 960)、右侧 60px | 只放赞助条/次要字幕;**比分、计时、判定卡一律不得进入** |
| 视频安全区(画面内 HUD) | 3.5% | 640×480 上约 22px 横 / 17px 纵 | 血条与命中提示条 |
> 若输出 720p,以上像素值 ×2/3;若必须 4:3 输出(1440×1080),安全区按宽高比各算一次,**不要**简单套 16:9 的数字。
### 4.2 分区与坐标(模板 A:双路对称,默认)
```
y=0 ┌──────────────────────────────────────────────────────────┐
│ [赛事名/校徽] ● LIVE BO3 第2局 │ 0–108 标题区
y=108 ├──────────────────────────────────────────────────────────┤
│ ┌────────────────┐ ┌────────────────┐ │
y=150 │ 红方 │ 视频 800×600 │ │ 视频 800×600 │ 蓝方 │ 150–750 媒体区
│ 队名 │ (640×480 放大)│ │ │ 队名 │ x:140–940 / 980–1780
y=750 ├──────────────────────────────────────────────────────────┤
│ 红 76 ━━━━━━ 02:41 ━━━━━━ 100 蓝 │ 770–930 比分/计时
y=930 ├──────────────────────────────────────────────────────────┤
│ [事件字幕] [赞助条] │ 940–1080 可牺牲区
y=960 │ - - - - - - - - - 平台遮挡区上沿(不放关键信息) - - - - - │
y=1080 └──────────────────────────────────────────────────────────┘
```
- 媒体区左右各留 140px:其中 100px 给队名,剩余给队伍色条(`--team-0/1` 4px 竖条)。
- **比分/计时带固定 y 770–930**,底边 930 < 960,永远不进平台遮挡区;判定卡必须在 title safe 内(x 96–1824,y 54–1026)。
- 计时居中,字号 `--fs-clock`;比分 `--fs-score`。
- 4:3 → 800×600 是 1.25× 放大,VGA 源放大后仍可读;**不要裁切**(裁切会切掉装甲板或场地边线,判罚举证时没有依据)。
**模板 B:主视角 + PIP**(一方图传丢失时自动建议)
```
┌──────────────────────────────────────────────┐
│ 标题条 │
│ ┌────────────────────────────────────────┐ │
│ │ 主画面 1600×900(居中偏上) │ │
│ └────────────────────────────────────────┘ │
│ ┌──────────┐ │
│ │ PIP 480×360│ ← 右下,避开平台遮挡区
│ 比分板 └──────────┘ │
└──────────────────────────────────────────────┘
```
**模板 C:待机/准备**(`Idle/Ready`,或信号中断)
- 中央:赛事名 + 双方队名 + `准备 03:00`(大号)+ "信号恢复中"(画面缺失时显示)。
- 背景:`--bg-void` + 2% 噪点 + 细网格(`background-image: repeating-linear-gradient`,**静态**,不滚)。
- 永不显示纯黑屏(黑屏会被平台判为离线)。
**模板 D:判定**(`Ended`)
- 中央判定卡:`红方胜` / `本局判负`(`--fs-display`)+ 理由(设备故障/超出时间/血量归零)+ 比分。
- 卡背景 `rgba(8,8,12,.72)` + 1px 亮边 + 切角,**不做**全屏红闪。
### 4.3 包装层结构(z 序)
```
z0 BackgroundLayer 底色 + 静态网格 + 噪点
z1 MediaLayer 双路 VideoSurface(复用现有组件,crosshair=null)
z2 HudLayer 画面内血条(可选显示,直播默认显示)
z3 LowerLayer 比分板 / 计时 / 队名 / 事件字幕
z4 AlertLayer 暂停 / 判负 / 判定卡
z5 BrandLayer 赛事名、校徽、赞助、LIVE 徽标
z6 GuideLayer 安全区参考线(仅预览,输出时 display:none)
```
实现要点:
- `BroadcastStage` 用 **1920×1080 的绝对坐标系** + `transform: scale(min(vw/1920, vh/1080))`,`transform-origin: top left`。这样所有设计稿坐标直接写进 CSS,不用 `%` 心算。
- 参考线由 `GuideLayer` 画(`outline: 1px dashed`),受 `props.guides` 控制;全屏/输出时自动关闭。
- 视频源仍用 `VideoSurface`(`ViewTransform` 固定 `IDENTITY_VIEW`),只是容器换成 800×600 的固定框——**不要**为直播再写一套 canvas 绘制。
### 4.4 输出方式(推荐路径与坑)
| 方案 | 做法 | 评价 |
| --- | --- | --- |
| **A(推荐)** | 同一窗口切 `broadcast` 视图 + F11 全屏;OBS「窗口采集」+ 裁到 16:9 | 零新权限、零新进程,最稳 |
| B | 第二个 `WebviewWindow`(`label: broadcast`,无边框、置顶) | 需要 `core:webview:allow-create-webview-window`;双窗口抢 GPU,P2 再做 |
| C | NDI / 虚拟摄像头 | 明确不做(依赖重、跨平台成本高) |
坑(按重要性):
1. **WebView 背景**:`transparent: true` 在 Windows WebView2 与 macOS WKWebView 行为不一致,直播场景下透明边缘会出黑边。**结论:用不透明底色 `--bg-void` 铺满,不做透明**。
2. **低帧画面 + 高帧包装**:视频是 JPEG 序列,帧率取决于固件配置(`commands.rs` 的注释里记着"The sensor 实测 22 fps",`target/firmware/BUILD-INFO.json` 里 vga30 档实测 29.7 fps);无论哪个值都远低于包装层的 60 fps,动效会"飘"在画面上。所以包装层只做**状态迁移**动画,不做连续动画(与 1.6 的禁止清单一致)。
3. **纯黑编码**:`#000` 在 H.264 里容易 banding,用 `#07080a` + 2% 噪点。
4. **OBS 窗口采集会采到鼠标**:全屏时 `cursor: none`(`immersive` 状态负责)。
5. **窗口标题**:直播窗口标题里**不要**出现队名/端口等内部信息(`setTitle` 现在会写身份;broadcast 视图下改成 `cam-desktop — 直播`)。
6. **分辨率自适应**:`transform: scale()` 后文字仍清晰(矢量),但如果 OBS 缩放窗口,字号会变成非整数像素 → 建议 OBS 采集时窗口保持 1920×1080 或 1280×720。
### 4.5 直播视图的安全边界(必须做)
- `broadcast` 视图**不渲染**:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
- 所有管理命令在广播态禁用(不是隐藏,是 `disabled` + `aria-hidden`,防止误触与 Tab 聚焦)。
- 演示模式(`demo`)在直播里显示**红色角标**"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
- 断流时 `StandbyCard` 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。
---
## 5. `.zip` 固件包:格式、打包、读取
### 5.1 为什么改成 zip
| 现状(目录) | zip |
| --- | --- |
| 选目录,容易选错上一层/子目录;缺文件要到"生成计划"才报错 | 单文件,扩展名即契约;打开即校验 |
| 分发要压缩后发,现场再解压(解压位置随便) | 分发即成品,不用解压 |
| 无法整包校验(只校验 app/分区表) | 可做整包 SHA256,便于赛后追溯"烧的是哪一版" |
| 无版本标识 | 文件名 + manifest 双版本 |
### 5.2 包格式(**保持 `format: 1`,不要翻版本**)
```
camfw-esp32s3-vga30-fb3-realtime-tx15-4794bed-20261024.zip
├── bundle.json ← 必需,沿用现有 BundleManifest(format: 1)
├── esp32s3-app.bin ← 必需,文件名由 bundle.json 的 "app" 指定
├── partition-table.bin ← 必需,必须 0xC00 字节
├── bootloader.bin ← 可选(客户端默认不写)
├── BUILD-INFO.json ← 可选,仅用于展示版本/构建信息
└── SHA256SUMS.txt ← 可选,人读用;客户端以 bundle.json 里的 sha 为准
```
理由:**`format: 1` + 新增可选字段**,旧客户端读目录包照旧,新客户端两种都能读;翻成 `format: 2` 只会让"旧客户端打不开新包",而我们本来就要同时支持两种来源,没有收益。
`bundle.json` 建议新增一个**可选**块(`deny_unknown_fields` 只出现在其他结构上,`BundleManifest` 没有,可直接加字段):
```json
{
"package": {
"kind": "zip",
"createdAt": "2026-10-24T18:02:11+08:00",
"file": "camfw-esp32s3-vga30-fb3-realtime-tx15-4794bed-20261024.zip",
"sha256": "…(zip 整包)",
"entryCount": 5
}
}
```
> 注意:`BundleManifest` 目前**没有** `deny_unknown_fields`,加字段不会破坏旧客户端;但**不要**给它会读的字段改名。
### 5.3 校验规则(客户端必须在读的时候就挡住)
| 项 | 规则 | 失败文案(面向现场) |
| --- | --- | --- |
| 容器 | 目录 / `*.zip` / `bundle.json`;zip 需 magic `PK\x03\x04` | "不是有效的固件包(既不是 .zip,也没有 bundle.json)" |
| 条目路径 | 拒绝 `..`、绝对路径、盘符、`\\` 开头、UNC、空名、重复名、符号链接 | "固件包内有非法路径:…" |
| 条目数 | ≤ 32 | "固件包条目过多" |
| 单文件 | `bundle.json` ≤ 64 KiB;app ≤ `appPartitionSize`;分区表 = 0xC00 | "bundle.json 过大 / App 超过分区大小" |
| 解压上限 | 总解压 ≤ 8 MiB(App 现 1.09 MiB) | "固件包解压后过大" |
| 完整性 | zip CRC(`zip` crate 自动校验)+ `appSha256` / `partitionTableSha256` | "App SHA256 不匹配:期望 …,实际 …" |
| 版本 | `format == 1` | "不支持的 bundle 格式版本:N" |
| 设备 | `chip` / `flashSize` / Secure Boot / Flash Encryption(现有 `check_bundle_against_device`) | 沿用现有文案 |
### 5.4 编译后打包环节
**现状链路**
```
scripts/build.ps1|build.sh → cargo build --release --locked
└─ target/xtensa-esp32s3-espidf/release/esp32(ELF)
target/firmware/ → esp32s3-app.bin / partition-table.bin / bootloader.bin
BUILD-INFO.json / bundle.json / SHA256SUMS.txt(人工或脚本产出,未进 build)
```
**新链路(在编译之后加一节,不改编译本身)**
```
build.sh|build.ps1 [--package]
├─ cargo build --release --locked
└─ python scripts/package_firmware.py
① 定位 App 镜像
优先 <esp-idf-sys out/build>/esp32s3-app.bin
否则 espflash save-image --chip esp32s3 --format esp-idf <elf> <out>/esp32s3-app.bin
否则回退 target/firmware/esp32s3-app.bin(mtime 早于 ELF 则警告"可能是旧镜像")
② 复制 partition-table.bin(必须 0xC00)、可选 bootloader.bin
③ 生成 BUILD-INFO.json(git rev / dirty / 时间 / variant / rustc / idf)
④ 生成 bundle.json(复用 make_bundle.py 的逻辑)
⑤ 打包 zip(deflate, 固定 mtime, 固定条目顺序)
⑥ 写 <zip>.sha256 + 打印摘要
```
**产物**
```
target/firmware/
├── camfw-esp32s3-<variant>-<sha7>-<yyyymmdd>.zip
├── camfw-esp32s3-<variant>-<sha7>-<yyyymmdd>.zip.sha256
├── bundle.json (保留,供旧的"选目录"路径与排障)
└── BUILD-INFO.json
```
**可复现性**:固定 zip 条目顺序、固定 mtime(用 `SOURCE_DATE_EPOCH` 或构建时间)、固定 deflate level(9),保证同一份输入产出同一个 SHA256——这样"两个 U 盘里的包是不是同一版"可以一眼核对。
**接入点**
- `scripts/build.ps1`:加 `[switch]$Package`,构建成功后调用 `python scripts/package_firmware.py`(找不到 python 时给出明确提示,不静默跳过)。
- `scripts/build.sh`:`--package` 透传(现有 `"$@"` 已经把参数传给 cargo,要单独解析 `--package` 并从 cargo 参数里剔除)。
- `scripts/make_bundle.py`:保留(不动它的 CLI),`package_firmware.py` 以 subprocess 调用它,避免两套 manifest 生成逻辑。
- CI:**先不进**。加一个固件构建 job 要装 Xtensa 工具链 + ESP-IDF(体积大、耗时长),而决赛在 11-01;等赛后再评估。若要做,放 `workflow_dispatch` 手动触发。
### 5.5 客户端读取流程(`maintenance.rs`)
**命令签名不变**(前端只改选文件对话框):
```rust
#[tauri::command]
pub fn maintenance_load_bundle(path: String) -> Result<BundleInfo, String>
```
**内部改动**
```rust
enum BundleSource { Dir(PathBuf), Manifest(PathBuf), Zip(PathBuf) }
fn sniff(path: &Path) -> Result<BundleSource>;
fn load_bundle(path: &Path) -> Result<BundleLoaded> {
match sniff(path)? {
BundleSource::Zip(zip) => load_from_zip(&zip),
dir_or_manifest => load_from_dir(dir_or_manifest), // 现有逻辑,原样保留
}
}
fn load_from_zip(path: &Path) -> Result<BundleLoaded> {
let mut archive = zip::ZipArchive::new(File::open(path)?)?;
let manifest = read_entry_json::<BundleManifest>(&mut archive, "bundle.json", 64 << 10)?;
// 路径规范化 + 大小上限 + CRC 由 zip crate 校验
let app = read_entry_limited(&mut archive, &manifest.app, manifest_parse_app_limit(&manifest))?;
let table = read_entry_limited(&mut archive, &manifest.partition_table, 0xC00)?;
// 之后与目录分支完全共用:SHA256 校验、尺寸校验、bootloader 校验
build_loaded(manifest, app, table, SourceInfo::Zip { path, sha256 })
}
```
要点:
1. **不落地解压**:`BundleLoaded.app` 本来就是全内存的 `Vec<u8>`,直接复用;只在需要备份时把 `bundle.json` 文本写进备份目录(`fs::copy` 对 zip 路径不适用,改成写文本)。
2. `BundleInfo` 新增字段(`#[serde(rename_all = "camelCase")]`):
```rust
pub source_kind: String, // "zip" | "dir"
pub source_path: String, // zip 文件路径(原 path 字段保留:展示用)
pub source_sha256: Option<String>, // zip 整包 sha256
pub entry_count: Option<u32>,
```
前端 `BundleInfo` 同步加这三个字段(`desktop/src/lib/maintenance.ts`)。
3. 依赖:`zip = { version = "2", default-features = false, features = ["deflate"] }`(约新增 `flate2`/`miniz_oxide`/`crc32fast`)。**必须同时提交更新后的 `Cargo.lock`**,否则 CI 的 `cargo test --locked` 会失败。
4. 错误信息统一成"给现场的人看的一句话"(现有风格),并且**区分"包坏了"与"包是好的、设备不对"**——后者不要让人去重新拷包。
**前端改动**
- `selectBundleDirectory()` → `selectBundlePackage()`:
```ts
open({ multiple: false, filters: [{ name: "固件包", extensions: ["zip"] }] })
```
目录选择收进"高级 / 兼容旧包(目录)"。
- 新增 `FirmwareDropZone`:拖拽 `.zip` 进来(Tauri v2 需窗口 `dragDropEnabled: true`,默认即为 true;实现时用 `getCurrentWebview().onDragDropEvent` 验证一次)。
- 展示:包名 / 版本 / chip / flash / App 大小 / 分区大小 / 整包 SHA 前 12 位 / 来源(zip/目录)。
- 维护页步骤 ② 的三态:未选 → 已加载(打勾)→ 校验失败(红框 + 一句话原因 + "重新选择")。
**CLI 兼容**
`--maintenance-plan <串口> <固件包>` 现在只接受目录;改为接受 zip 或目录,帮助文本同步更新(`main.rs` 的 `USAGE`)。
---
## 6. 校内赛规则要点与判罚流程
### 6.1 规则事实(来自官方手册,不改写)
| 条目 | 规定 | 来源 |
| --- | --- | --- |
| 队伍 | 每队严格 3 人,每人限一队;16 队取得比赛资格 | [赛事概览](https://docs.simba-robotics.com/zh-cn/competition/) |
| 赛制 | BO1 / BO3 / BO5;16→8 双败 BO3,8→4 双败 BO3,总决赛 BO5 | [对抗赛与奖项](https://docs.simba-robotics.com/zh-cn/competition/matches/) |
| 红蓝方 | 每场编号小者为红方,编号大者为蓝方 | 同上 |
| 检录 | 尺寸 / 遥控运动 / 发射 / 裁判系统安装 / 裁判系统(含图传)运作;通过后**不得再烧录或改硬件直至准备阶段** | 同上 |
| 赛前准备 | 3 分钟;双方把机器人放到与装甲板颜色相同的部署点;确认**裁判系统与操作位图传客户端连接**、遥控连接正常 | 同上 |
| 技术暂停 | 5 分钟;任意一方遇突发问题向**场边裁判**申请并说明理由,**主裁判批准后**开始;期间双方均可检修 | 同上 |
| 血量 | 初始 100;每发有效攻击扣 **3** 点 | 同上 |
| 单局时长 | **3 分钟** | 同上 |
| 胜负 | ①血量先归零者负 ②时间耗尽血量多者胜 ③双方血量均等则以**本局最快开始移动**的一队获胜 | 同上 |
| 装甲板 | 每车 2 或 4 块,其中一块为主装甲板;检测周期 100 ms;任一触发后**全车 100 ms 静默期**;供电 5 V ± 1 V;UART 可向主控发键鼠指令 | [裁判系统](https://docs.simba-robotics.com/zh-cn/competition/referee-system/) |
| 图传 | 1 个模块,回传第一人称画面到**裁判系统客户端屏幕**;供电 3.3 V(与装甲板 5 V 不同) | 同上 |
| 日程 | 10-25 适应性训练;**11-01 决赛**(9:00–11:00 / 14:00–18:00) | [赛事概览](https://docs.simba-robotics.com/zh-cn/competition/) |
### 6.2 对客户端的直接影响(不是装饰性结论)
1. **血量必须同时有数字和格子**。100 点 / 5 格 = 20 点/格,而单发只扣 3 点——一格的粒度相当于 6.7 发。只给格子,操作手无法判断"再中两发就死"。→ 保留 `showNumbers` 默认开启,且数字 ≥ 18px。
2. **100 点 = 34 发**(33 发后剩 1 点)。裁判端可以显示"已中 33 发"这类辅助读数(`100 - hp) / 3`),但**不要**把它当成权威值——静默期内的连击会被合并。
3. **100 ms 静默期**决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,`HealthTracker` 按血量差派生,已经能合并;UI 侧**不要**为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
4. **装甲板 2 块也要能显示**:`ArmorBoardDto.reportsAllPlates` / `platesSeen` 已存在,界面在 `platesSeen < 4` 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
5. **"最快开始移动"没有数据源**。图传只给画面,协议里没有位移/速度;因此这条规则**只能人工判定**(见 3.4)。不要试图从画面里推算。
6. **赛前 3 分钟**是"确认客户端连上了"的窗口,所以用户端需要一个**准备检查卡**:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
7. **红蓝配色固定**:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
### 6.3 判罚流程(客户端可执行的部分)
```
┌──────────────────────────────┐
│ 单局结束条件 │
└──────────────┬───────────────┘
┌───────────────────────────┼───────────────────────────┐
│ │ │
血量归零 时间耗尽 人工裁定
(自动,双方一致) (自动比血量) (裁判操作)
│ │ │
│ ┌──────┴──────┐ ┌─────────┼─────────┐
│ 血量不等 血量相等 设备故障 违规/弃权 平血仲裁
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
Ended(by=auto, Ended(by=auto, AwaitingTiebreak ForfeitDialog(长按 1s + 理由)
reason=knockout) reason=timeout) → set_tiebreak → Ended(by=referee)
│
▼
Ended(by=referee, reason=tiebreak)
```
**每一步都必须留痕**
```json
{
"id": "evt-0007",
"atMs": 1761998400000,
"actor": "referee",
"action": "forfeit",
"loser": 0,
"reason": "equipment",
"note": "图传模块断电",
"hp": [76, 100],
"remainingMs": 161000,
"round": 2,
"score": [1, 0]
}
```
- 落盘:`<app_data>/matches/<yyyy-mm-dd>/<matchId>/events.ndjson`(**追加写,不覆盖**),另有 `match.json` 存最终结果。
- 导出:`match_export` 把 `result.csv` 与 `events.ndjson` 放到录制目录,供组委会归档。
- 更正:`match_undo`(5 s 内)或 `match_reopen_round`(带理由);**任何更正都新增记录,不删除原记录**。
**规则缺口(手册未定义,客户端不臆造)**
| 缺口 | 客户端默认行为 | 需要谁定 |
| --- | --- | --- |
| 警告/红黄牌累计 | **不实现**累计逻辑,只有"理由 + 备注" | 组委会 |
| 判负后是否重赛 | 只记录 `reason`,不自动重开 | 主裁判 |
| 平血"最快开始移动"的判定依据 | 人工选择,必填备注 | 主裁判 |
| 设备故障(图传中断)导致无法比赛 | 人工裁定:技术暂停 / 判负 / 重赛 | 主裁判 |
| 技术暂停超时(>5 分钟) | 提示超时,不自动判负 | 主裁判 |
> 手册 1.0.0 的更新日志提到"添加了详细的扣血、判罚规则与单局时间",但当前公开页面只给出了 6.1 表列出的内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。**如果组委会另有一份判罚细则(红黄牌、犯规等级),需要提供原文**,否则界面不写死任何罚则。
---
## 7. 实施优先级、风险与待确认问题
### 7.1 时间线(关键约束:11-01 决赛)
| 日期 | 里程碑 | 交付物 |
| --- | --- | --- |
| 10-19 ~ 10-21 | **M0 + M1** 视觉基线与外壳 | `tokens.css`、`AppShell`、F11、TopBar/StatusBar 换肤、用户端/裁判端布局不回归 |
| 10-21 ~ 10-23 | **M2** 比赛状态机 + 比赛条 | `core/match.rs`、`match_*` 命令、`MatchBar`/`Clock`/`ScoreBoard`、事件落盘 |
| 10-23 ~ 10-24 | **M3** 判负 / 技术暂停 | `ForfeitDialog`、`TechPauseDialog`、覆盖层、撤销与审计 |
| 10-24 ~ 10-25 | **M4-lite** 直播最小可用 | `BroadcastPage`(模板 A + D)、安全区、F11 输出、`StandbyCard` |
| **10-25** | **适应性训练(冻结线)** | 只允许修 bug,不再进新功能 |
| 10-26 ~ 10-27 | **M6** `.zip` 固件包 | `package_firmware.py`、`maintenance_load_bundle` 支持 zip、维护页步骤 ② |
| 10-27 ~ 10-29 | **M5** 回放(双路同步)+ **M7** 维护页 Stepper | 按局分组、事件时间轴、5 步流程 |
| 10-29 ~ 10-31 | **M8** 无障碍/性能/回归/演示彩排 | `prefers-reduced-motion`、低动效开关、跨平台验证、演练脚本 |
| 11-01 | 决赛 | — |
> 现实是:**M1 的"整个界面重构"和"11-01 前必须能用"是冲突的**。建议把视觉重构限定在"token 替换 + 布局调整",不动 `VideoSurface`/`stream.ts`/`recorder` 这些已稳定的链路代码。
### 7.2 优先级矩阵
| 优先级 | 事项 | 理由 | 依赖 |
| --- | --- | --- | --- |
| **P0** | F11 全屏 + 沉浸模式 + 快捷键表 | 现场投影/操作位必需,成本低 | capabilities 权限 |
| **P0** | 比赛条(计时/比分/局次)+ 状态机 | 没有它就没有"比赛",只有"两个画面" | core/match.rs |
| **P0** | 一键判负(长按 + 理由 + 审计 + 撤销) | 用户明确要求;现场一定会用到 | M2 |
| **P0** | 技术暂停(申请/批准/5 分钟/恢复) | 规则明文;复现手册流程 | M2 |
| **P0** | 视觉基线(token/顶栏/血条/按钮/表格) | "按 Division 重构"的主诉求;纯 CSS,风险低 | — |
| **P0** | 直播安全区 + 模板 A/D + 待机卡 | 直播包装的最小闭环 | 状态机 |
| **P1** | `.zip` 固件包读取 + 打包脚本 | 用户明确要求;影响赛前烧录效率 | `zip` 依赖 |
| **P1** | 维护页 Stepper 化 | 与 zip 同批,避免两次改同一页 | M6 |
| **P1** | 装甲板表双队化 + 迷你血条 | 裁判现场核对效率 | — |
| **P1** | 事件面板 + 导出 | 判罚留痕,赛后申诉依据 | M2 |
| **P1** | 回放双路同步 + 事件定位 | 判定后复盘 | 录制 label |
| **P2** | 直播模板 B(PIP)/ C 细化 / 赞助位 | 观感提升,非比赛必需 | — |
| **P2** | 跨端同步(裁判判定 → 用户端) | 需要协议 + 信任模型(见 R2) | 协议扩展 |
| **P2** | 多显示器全屏、第二窗口输出 | 现场单屏也能用 | — |
| **P2** | 系列赛完整状态(BO5 编排/对阵表) | 赛程由人排,客户端只记比分 | — |
| **P3** | 低动效开关、字体包、深色/浅色 | 可选 | — |
### 7.3 风险
| # | 风险 | 概率/影响 | 缓解 |
| --- | --- | --- | --- |
| **R1** | **客户端判负 ≠ 硬件血量**:协议只有上行(`MsgType` 1–5),没有下行控制,判负后对方装甲板仍报 100 | 高 / **高** | 界面明确写"只改变判定与记录,不改变硬件血量";不伪造 HP;若要硬件生效必须另做固件+协议(Q2) |
| **R2** | **跨端不同步**:裁判端判负/暂停,操作位用户端看不到 | 高 / 高 | v1 只作用于裁判端与直播;v2 用 `5601` 广播新 `MsgType`(见下)并加共享码签名;现场先用对讲机兜底 |
| **R3** | Tauri 全屏权限/快捷键冲突(macOS `⌃⌘F` 与系统全屏叠加) | 中 / 中 | 显式加两个 permission;`preventDefault`;按 400 ms 去抖;三平台各验一遍 |
| **R4** | zip 恶意/畸形输入(zip slip、zip bomb) | 中 / 高 | 路径规范化白名单 + 条目数/大小上限 + 只读内存 + CRC |
| **R5** | 新增 `zip` 依赖打断离线/锁文件构建 | 中 / 中 | 提交 `Cargo.lock`;CI 用 `--locked` 会立刻暴露;备选:只支持 store(不压缩)条目 + 自研 150 行 reader |
| **R6** | 动效拖慢低配裁判机(双路 canvas + CSS 动画) | 中 / 中 | 只用 `transform/opacity`;禁连续动画;低动效开关;实测 30 min 不掉帧 |
| **R7** | 录制与比赛状态机耦合(自动起停误伤手动录制) | 中 / 中 | 状态机只发事件;"随比赛自动录制"做成开关,默认开 |
| **R8** | **直播泄密**(IP/token/密码入口出现在画面里) | 中 / **高** | broadcast 视图白名单渲染;不渲染 `addr`/`url`;只读态;截图核对清单 |
| **R9** | 规则变更/规则缺口(平血、超时、罚则) | 中 / 中 | 规则参数可配置(`MatchConfig`);缺省行为全部"人工裁定 + 记录" |
| **R10** | 中文排版回归(字距、字重、字号) | 中 / 低 | 沿用"中文不加字距";只换 token 不动排版规则;逐页截图对比 |
| **R11** | 光敏/无障碍(红闪、脉动) | 低 / 中 | ≤ 3 Hz、面积 < 25%、`prefers-reduced-motion` 降级、低动效开关 |
| **R12** | 1690 行 CSS 重构引入视觉回归 | 高 / 中 | 分两步:先"加 token + 保留旧类名"(零风险),再逐组件替换;每步截图对比 |
**R2 的可选技术方案(若用户要跨端同步)**
```rust
// protocol/src/lib.rs 新增(不改动已有枚举值)
pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHealth = 5, MatchControl = 6 }
// 载荷:version(1) op(1) team(1) seq(4) issued_unix_ms(8) mac(16)
// op: 1=pause 2=resume 3=forfeit 4=reset 5=state-sync
```
- 裁判端以 `REFEREE_ID` 广播到 `5601`;用户端在装甲板监听里增加一个 `MsgType::MatchControl` 分支(不改相机订阅逻辑)。
- **安全**:局域网里任何人都能发 UDP,所以必须带**共享码派生 MAC**(检录时裁判端生成 6 位码,双方在客户端输入),否则队伍可以伪造"对方判负"。
- **固件不受影响**:装甲板固件只认 `MsgType::ArmorHealth`,未知类型按现有 `decode_header` 逻辑返回 `None` 被忽略;但**上线前必须在实机上验证一次**"未知 MsgType 不会让板子异常"。
### 7.4 需要用户确认的问题
**比赛语义(最关键,答案决定方向)**
1. **Q1** 客户端"一键判负"要不要让**用户端**(操作位)也看到?如果需要,是否接受"局域网广播 + 共享码"的方案(R2)?
2. **Q2** 判负/技术暂停是否需要**改变装甲板硬件血量**(例如判负后立即把对方 HP 打到 0)?这需要改固件 + 协议,属于另一个工程。
3. **Q3** 技术暂停期间的命中:**如实显示**并标红(当前建议),还是**冻结/忽略**?谁来确定"暂停开始"的时间点——客户端按钮,还是场边裁判口头通知后由裁判端按下?
4. **Q4** 平血时"本局最快开始移动的一队"由谁录入?是否接受"裁判端手动点选一方 + 必填备注"?
5. **Q5** 是否需要 BO3/BO5 的**系列赛比分**与"第 N 局"编排?队伍名/编号从哪来(手工输入 vs 导入名单)?
**直播**
6. **Q6** 输出分辨率与帧率(1080p30 / 720p60?)、推流平台(B 站 / 抖音 / 校内大屏 / 录播)?安全区按哪个标准?
7. **Q7** 是否用 OBS 窗口采集(推荐,方案 A)?还是需要独立输出窗口 / 透明背景(方案 B)?
8. **Q8** 包装层需要哪些固定素材:赛事名、校徽、赞助商 LOGO、选手照片/队徽?由谁提供、什么格式?
**固件包**
9. **Q9** `.zip` 是否需要**签名**(Ed25519)?如果需要,私钥谁保管、公钥怎么随客户端发布?
10. **Q10** 是否必须同时兼容**旧的目录包**(检录现场可能只发了目录)?我建议兼容(成本几乎为零)。
11. **Q11** 打包环节只做本地脚本,还是要进 CI?(进 CI 需要 Xtensa 工具链,赛前不建议)
12. **Q12** zip 里的 `partition-table.bin` 仍只是"模板 + 校验"(客户端实际写的是设备当前分区的 patch 版本)——这个语义要不要在维护页显式写出来?
**交互与现场**
13. **Q13** 用户端是否允许 F11 全屏?是否记忆"上次是否全屏"?(操作手误触退出全屏会很慌,建议:允许,但全屏状态落盘,且退出时有 1.5 s 提示条)
14. **Q14** 录制是否随比赛状态**自动起停**?文件名要不要带"红队 vs 蓝队 + 第 N 局"?
15. **Q15** 目标平台优先级:现场是 Windows(NSIS)?macOS 只用于开发?(`desktop/.cargo/config.toml` 的默认 target 是 `aarch64-apple-darwin`,Windows 构建要显式 `--target`)
---
## 附录 A:热键总表
| 键 | 范围 | 动作 | 备注 |
| --- | --- | --- | --- |
| `F11` / `⌃⌘F` / `Alt+Enter` | 全局 | 全屏切换 | 400 ms 去抖 |
| `Esc` | 全局 | 关最上层(对话框 → 抽屉 → 全屏) | 逐层关闭 |
| `Ctrl/Cmd+K` | 全局 | 命令面板(设置/维护/直播/回放) | 替代主界面按钮 |
| `Ctrl/Cmd+Shift+B` | 全局 | 切换直播视图 | 仅裁判端 |
| `Ctrl/Cmd+Enter` | 裁判端 | 开始本局 | `phase ∈ {Idle, Ready}` |
| `Ctrl/Cmd+Shift+P` | 裁判端 | 技术暂停 | 弹对话框 |
| `Ctrl/Cmd+Shift+F` | 裁判端 | 判负 | 仍需长按确认 |
| `Ctrl/Cmd+1` / `+2` | 裁判端 | 选中红/蓝(对话框内) | — |
| `Space` | 仅回放 | 播放/暂停 | 比赛中禁止 |
| `←/→`、`Shift+←/→` | 仅回放 | ±1 s / ±5 s | — |
| `M` | 仅回放 | 打标记 | 写入事件 |
| `Tab`(按住) | 全局 | 显示当前视图快捷键 | 半透明面板 |
| `Ctrl/Cmd+R` | 全局 | 刷新快照(现有 `refresh`) | — |
## 附录 B:变更文件清单
**后端(Rust)**
| 文件 | 改动 |
| --- | --- |
| `desktop/core/src/match.rs`(新) | `MatchState` / `MatchPhase` / `MatchEvent` / `MatchConfig` / 状态迁移 + 单测 |
| `desktop/core/src/lib.rs` | `pub mod match;` |
| `desktop/core/src/snapshot.rs` | `Snapshot` 增加 `now_ms`、`match: MatchView`;`MatchView` 等 DTO |
| `desktop/src-tauri/src/server.rs` | `WireMsg` 增加 `MatchEvent`(与 `Hit` 同一条推送路径) |
| `desktop/src-tauri/src/state.rs` | 持有 `Mutex<MatchState>`;`tick()` 推进;`snapshot()` 填充;`match_*` 方法;审计写盘 |
| `desktop/src-tauri/src/commands.rs` | `match_configure/start/pause/resume/end/undo/next_round/set_tiebreak/export`;`require_referee()`;`save_png` |
| `desktop/src-tauri/src/main.rs` | 注册新命令;`USAGE` 更新(zip 包路径) |
| `desktop/src-tauri/src/maintenance.rs` | `BundleSource` / `load_from_zip` / 路径与大小校验 / `BundleInfo` 新字段 / 备份写文本 |
| `desktop/src-tauri/src/recording.rs` | `start(..., label: Option<&str>)`,文件名带局次 |
| `desktop/src-tauri/capabilities/default.json` | 加 `core:window:allow-set-fullscreen`、`core:window:allow-is-fullscreen`(直播第二窗口再加 `core:webview:allow-create-webview-window`) |
| `desktop/src-tauri/Cargo.toml` | `zip` 依赖;同时提交根目录更新后的 `Cargo.lock` |
**前端(React/CSS)**
| 文件 | 改动 |
| --- | --- |
| `styles/tokens.css`(新) | 1.2–1.6 的全部 token |
| `styles/global.css` | 保留结构,按节替换为 token;拆分可选 |
| `App.tsx` | `AppShell` + `view` 状态 + 快捷键注册 + 全屏 hook |
| `components/TopBar.tsx` | 工具收纳、直播入口、状态徽标 |
| `components/match/*`(新) | 比赛条、计时、比分、判负/暂停对话框、覆盖层、事件面板 |
| `components/broadcast/*`(新) | 直播画布与包装层 |
| `components/replay/ReplayView.tsx`(新) | 双路同步回放 |
| `pages/TeamPage.tsx` | 计时条、覆盖层、准备检查卡、底部提示淡出 |
| `pages/RefereePage.tsx` | MatchBar + 可拖拽 Dock + 双队装甲板 + 事件页签 |
| `pages/MaintenancePage.tsx` | Stepper 重构 + zip/拖拽 |
| `pages/BroadcastPage.tsx`(新) | 直播视图 |
| `components/{HealthBar,ArmorTable,MediaPanel,SettingsPanel}.tsx` | 尺寸/布局/新段 |
| `lib/types.ts` | `MatchView`、`nowMs`、`WireMsg.matchEvent`、`BundleInfo` 新字段 |
| `lib/ipc.ts` | `match*` 命令、`savePng` |
| `lib/maintenance.ts` | `selectBundlePackage`、新字段 |
| `lib/store.ts` | `ui`、`events`、`clockSkewMs` |
| `lib/dev-preview.ts` | 合成 `match` 状态(否则浏览器预览会白屏) |
**脚本/文档**
| 文件 | 改动 |
| --- | --- |
| `scripts/package_firmware.py`(新) | 编译后打包(5.4) |
| `scripts/build.ps1` / `build.sh` | `--package` 开关 |
| `docs/firmware-maintenance.md` | zip 格式、打包命令、读取流程 |
| `docs/desktop-ui-redesign.md` | 本文 |
## 附录 C:验收标准(可测量)
| 项 | 标准 | 验证方法 |
| --- | --- | --- |
| 全屏 | F11 切换 < 350 ms;切 10 次不卡死、不丢键 | 手工 |
| 计时 | 3 分钟误差 < 200 ms;切窗口/最小化不失速 | 与手机秒表对比 3 次 |
| 判负 | 必须长按 ≥ 1000 ms;< 1000 ms 不生效;撤销窗口 5 s | 自动化 + 手工 |
| 技术暂停 | 申请表单不勾"已批准"无法开始;5:00 倒计时准确 | 手工 |
| 直播 | 安全区内无越界元素;不出现 IP/token/设置入口 | 截图核对清单 |
| .zip | 5 MiB 包加载 < 300 ms;篡改 1 字节被拒且文案指出是哪个文件 | 单测 + 手工 |
| 动效 | 30 min 双路运行,主线程不掉帧(≥ 25 fps) | 性能面板 |
| 无障碍 | 关键文字对比 ≥ 4.5:1;`prefers-reduced-motion` 下无位移动画 | 对比度工具 + 开关 |
| 回归 | 视频/血量/装甲板/录制/维护五项原功能全部可用 | 回归清单 |
## 附录 D:不做的事(明确边界)
1. **不做**网络 OTA / 设备自升级(与现有 `docs/firmware-maintenance.md` 的边界一致)。
2. **不做**修改固件源码;打包只组装已有产物。
3. **不做**从画面推算"移动先后"、命中位置、弹道等规则数据。
4. **不做**直播的透明背景/虚拟摄像头/NDI。
5. **不做**多语言(中文界面为唯一语言,符合约束)。
6. **不做**用户账号/云端同步。