# 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 判负 │ └────────────────────────────────────────────────────────────────┘ ``` - 两个 `