# 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` | 键帽样式(``) |
| `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 } }
```
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
- 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。
- **不改硬件血量**:`ArmorHealthStore` 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。
- **审计**:事件 `{ at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 }`,追加到 `matches///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`。先到 `ceil(bestOf/2)` 局的一方赢下系列赛,界面显示 `2:0 · 系列赛结束`。
**计时实现**(后端为准):
```rust
pub struct MatchClock {
started: Option,
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 判负 │
└────────────────────────────────────────────────────────────────┘
```
- 两个 `