docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。 - fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示 - test(client): 按文件名核对媒体类型,避免依赖时间戳排序 验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
This commit is contained in:
+9
-673
@@ -1,681 +1,17 @@
|
||||
# RMIC::2026
|
||||
# RMIC::2026 桌面端
|
||||
|
||||
黑龙江大学 602 实验室 · Simba Robotics 的**视野链路终端**,桌面端。
|
||||
两种身份、两套页面,都在同一个进程里。
|
||||
Tauri v2 + React + Rust 客户端。用户端显示本队图传、双方装甲条、瞄准器与命中反馈;裁判端显示双路画面、装甲板列表,并支持队名编辑、槽位分配、截图与录制回放。
|
||||
|
||||
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||||
|---|---|---|---|---|
|
||||
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||||
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||||
|
||||
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。它不是把旧客户端套进
|
||||
一个窗口:网络、重组、发现、配置、密码、录制全部重写成可单测的模块。
|
||||
|
||||
> **名字与数据目录**:显示名是 `RMIC::2026`;安装包与可执行文件叫 `RMIC-2026`(Windows
|
||||
> 文件名不允许 `:`)。配置、录制、日志仍落在旧的 `cam-desktop` 目录下——这是**故意的**,
|
||||
> 换名字不该顺手把现场的密码哈希与绑定丢掉。见「配置」一节。
|
||||
|
||||
```text
|
||||
desktop/
|
||||
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
|
||||
src-tauri/ Tauri 后端:推流、命令、录制(40 个测试)
|
||||
src/ React 前端:两套页面 + HUD
|
||||
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化、准星绘制、界面小节拍
|
||||
src/components/ 取景器、装甲条、命中、结算、装甲板表、队名、录制、设置抽屉、启动序列、门、提示
|
||||
src/pages/ 用户端 / 裁判端 / 固件维护 / 延迟校准
|
||||
src/styles/ tokens(令牌)、base(原子)、chrome(外壳)、stage(舞台)、console、settings、maintenance
|
||||
public/fonts/ 自托管的 HarmonyOS Sans SC 与 JetBrains Mono
|
||||
src-tauri/icons/ 队标(由 simba-robotics.com/logo.svg 生成,含光学尺寸分级)
|
||||
```
|
||||
|
||||
## 界面:一套终端语言
|
||||
|
||||
### 配色:墨黑蓝打底,只有一支红
|
||||
|
||||
| 角色 | 值 | 用在哪 |
|
||||
|---|---|---|
|
||||
| 底色 | `--void #05070d` → `--panel-3 #172032` | 全部面积。五级低饱和冷蓝黑,靠 1px 线分层,不靠阴影 |
|
||||
| 文字 | `--ink #e7ecf5` → `--ink-4 #3a4457` | 四档,第四档暗到不像真信息 |
|
||||
| **强调** | `--primary #e8232b` | **只有**:焦点、选中、录制中、错误、临界血量、结算 |
|
||||
| 状态 | `--error` / `--warn #f2b33d` / `--ok #3fc98a` | 各有语义,不与强调色混用 |
|
||||
| 队伍 | `--team-0 #ff6b5e` / `--team-1 #4a9bff` | **硬件约束**:装甲板 LED 就是红与蓝 |
|
||||
|
||||
一条硬规则:**红只给必须被看见的东西**。开关合上是白的、滑杆填充是钢灰、分段控件的选中
|
||||
记号是底沿一条 2px 线、分区编号是灰的、顶栏没有任何强调线。红色出现的地方,后面都有一
|
||||
件真事。
|
||||
|
||||
队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。
|
||||
|
||||
### 形状:矩形,唯一的斜边给装甲条
|
||||
|
||||
全局是矩形:没有圆角、切角、药丸,也没有阴影卡片。**唯一的斜边是装甲条那排平行四边形**
|
||||
——它必须在一屏矩形里被一眼认成"这是血,不是别的东西"。别的元素一旦也开始斜切,那个形状
|
||||
就不再携带信息了。
|
||||
|
||||
### 动效:严格照 Material 3 的曲线与时长
|
||||
|
||||
`styles/tokens.css` 里直接写着 M3 的七条缓动与 short/medium/long 四组时长:
|
||||
|
||||
| 令牌 | 曲线 | 语义 |
|
||||
|---|---|---|
|
||||
| `--m3-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | 需要存在感的转场 |
|
||||
| `--m3-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | **进入**(快起慢收) |
|
||||
| `--m3-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | **退出**(慢起快走) |
|
||||
| `--m3-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
|
||||
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
|
||||
|
||||
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为**指向 M3 的别名**,所以没有一条规则落在自造
|
||||
的贝塞尔上。两个例外是刻意的:`steps()` 用于录制红点与终端光标(离散闪烁),`linear` 用于
|
||||
"没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。
|
||||
|
||||
### 动效:每一页只有一个签名动作
|
||||
|
||||
重复使用同一种效果会让界面显得机械,所以只共享底层曲线与时长,**每页各有一个属于自己的
|
||||
动作**:
|
||||
|
||||
| 界面 | 签名动作 | 它解决什么 |
|
||||
|---|---|---|
|
||||
| 启动序列 | 一行行亮起的自检 + 真实进度 | 让"还在等"与"卡住了"分得开 |
|
||||
| 首启门 | 两张模块卡,悬停浮起并露出角括号 | 说明这件选择是要"装"进这台机器的 |
|
||||
| 用户端 | 逐格熄灭的装甲条、四周泛红、六秒休眠 | 静止时屏幕上的东西越少越好 |
|
||||
| 裁判端 | 中缝可拖、鼠标停在哪一路下方就看哪一路 | 不用再开第三个页面去"看某一边" |
|
||||
| 设置 | 常驻实时预览 + 竖向分节导航 | 改准星/网格/装甲条时立刻看得见结果 |
|
||||
| 固件维护 | 步骤轨 + 环形进度 + 终端日志 | 烧录的哪一步、到哪儿了,一眼可读 |
|
||||
| 解锁 | 一次性的校验扫描 + 错误时的一次抖动 | 明确"现在这一步是在验证身份" |
|
||||
|
||||
`prefers-reduced-motion` 会把这些时长压到 0,不是关掉功能。
|
||||
|
||||
### 字体:中文一套、拉丁一套,逐字回退
|
||||
|
||||
| | 字体 | 承担什么 |
|
||||
|---|---|---|
|
||||
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
|
||||
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
|
||||
|
||||
两者都以 woff2 自托管在 `public/fonts/`,随 `frontendDist` 进安装包,**运行时不碰网络**
|
||||
(CSP 里 `default-src 'self'` 也不允许)。中文由 `cn-font-split` 切成 82 个 unicode-range
|
||||
分片 × 3 个字重,浏览器只下载页面真正用到的那几十个,而不是一上来吞 14 MB。
|
||||
|
||||
合到一句话里靠**逐字回退**,不做语言判断:`--font-ui` 把 JetBrains Mono 写在前面,它里面
|
||||
没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是
|
||||
HarmonyOS 的,一条 `font-family` 同时满足两个要求。
|
||||
|
||||
启动序列会等 `document.fonts.ready`:先画成系统黑体、两百毫秒后再跳成 HarmonyOS,会让整块
|
||||
布局重排一次——那一个抖动比多等一瞬难看得多。
|
||||
|
||||
### 队标
|
||||
|
||||
应用图标与启动页的标记是队标(`https://simba-robotics.com/logo.svg`),由它生成整套
|
||||
`src-tauri/icons/`。原图是 1024×1024 的画布、标志只占中间一条,所以脚本把 viewBox 收到本体
|
||||
上再居中;**小尺寸用更大的缩放比**(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),
|
||||
否则 16/24/32 上那些 1px 笔画会糊成一团。`icon.ico` 是手工拼的多尺寸容器——Pillow 的写入器
|
||||
只能从一张主图缩出所有档位,而 Windows 任务栏恰恰只取最小的那几档。
|
||||
|
||||
笔画在 17px 以下就只剩 1px,所以顶栏只放字标 `RMIC::2026`(`::` 是界面里唯一被染红的两个
|
||||
字符),完整队标留给启动页的 30px。
|
||||
|
||||
### 每页最顺手的那个操作
|
||||
|
||||
- **用户端不用点。** 操作手的手在遥控器上,所以这一页的交互全部收在画面上一个手势里:
|
||||
滚轮缩放、拖动平移、双击复位。没有任何"瞄准再点"的按钮。六秒没有输入也没有挨打时,角上
|
||||
的标签与代号自己淡下去;一有动静整块醒来。
|
||||
- **裁判端让控制台跟着鼠标走。** 按下左格,下方列表就只显示那一边的装甲板(再按一次回到
|
||||
全部,键盘 `1` / `2` / `0` 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到
|
||||
五五开。画面与下方控制台之间的横线也能拖:看回放的时候把播放器拉大。
|
||||
- **设置是一块带预览的控制台。** 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里
|
||||
**同一个**绘制函数(`lib/reticle.ts`),所以"预览里对、画面上偏"这种最坏情况不可能发生。
|
||||
预览旁边那根血量滑杆是刻意加的:低血阈值与脉动只有真的把血拖下去才看得见。
|
||||
|
||||
## 版本与改动日志:算出来的,不是写出来的
|
||||
|
||||
版本号和改动日志都从 **git 历史**算,不靠人记着去改。
|
||||
在本目录执行:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write # 计算并写回所有派生文件
|
||||
node scripts/version.mjs --check # 只校验:有未归档的提交就退 1(CI 里跑这个)
|
||||
node scripts/version.mjs --print # 把算出来的版本打到 stdout
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
提交标题用 Conventional Commits(`type(scope): 说明`):
|
||||
|
||||
| 这批提交里有 | 升哪一位 |
|
||||
|---|---|
|
||||
| `BREAKING CHANGE:` 或 `type!:` | major |
|
||||
| `feat` | minor |
|
||||
| `fix` / `perf` | patch |
|
||||
| 其它 | 不动版本,只加构建号 |
|
||||
|
||||
构建号是 `git rev-list --count HEAD`:单调递增、不会撞车、不用手工维护。正文里以 `- `
|
||||
开头且本身写成 `type(scope): 说明` 的行算**额外条目**——这样一次 squash 提交也能把几件事
|
||||
分别归类,不必为了日志好看去拆提交。
|
||||
|
||||
### 派生出来的东西
|
||||
|
||||
| 文件 | 谁用 |
|
||||
|---|---|
|
||||
| `version.json`(仓库根) | 固件 `build.rs` 读它,编译期注入 `RMIC_VERSION*` |
|
||||
| `CHANGELOG.md` / `CHANGELOG.json` | 人读的那份与机器读的那份,同一次计算产出 |
|
||||
| `desktop/src/lib/version.ts` | 界面上的版本标签与协议号 |
|
||||
| `desktop/package.json`、`tauri.conf.json` 的 `version` | 装包文件名与 Windows 的「应用和功能」 |
|
||||
|
||||
建这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](../docs/CHANGELOG-legacy.md)。
|
||||
|
||||
### 固件与客户端怎么对上
|
||||
|
||||
同一条链:`git → version.json → 固件二进制 → Hello 报文 → 客户端界面`。
|
||||
|
||||
板子每 2 秒广播的 `Hello` 里带着**语义化版本、构建号、以及它编译进去的线路协议号**
|
||||
(布局见 `protocol/README.md`)。客户端读出来之后:
|
||||
|
||||
- 设置面板的绑定下拉里显示 `固件 v0.2.0 · 34`;
|
||||
- 裁判端每一格下方显示这块板子的固件版本;
|
||||
- 协议对不上时,用户端画面正中给出**具体原因**("它是协议 2,本客户端是协议 1"),
|
||||
而不是只表现为没有画面——它的视频帧会被严格解析丢弃,但那句解释正是靠通告读出来的。
|
||||
|
||||
**为什么值得占这 10 个字节**:一块烧错固件的板子,原来只表现为"没画面",现场会去查网线、
|
||||
AP、供电。现在同一个现象会直接指向"重新烧固件"。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
pnpm install
|
||||
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
||||
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
||||
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
|
||||
pnpm app
|
||||
```
|
||||
|
||||
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||||
`pnpm build` 构建前端,`pnpm tauri build` 打包应用。默认 Rust target 是 macOS ARM;Windows 和 Linux 需显式指定宿主 target。
|
||||
|
||||
```sh
|
||||
cd desktop/core && cargo test # 83
|
||||
cd desktop/src-tauri && cargo test # 40,其中几条真的会起 ffmpeg
|
||||
```
|
||||
|
||||
### Windows x64 构建
|
||||
|
||||
需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。
|
||||
|
||||
在 `desktop/` 中执行:
|
||||
|
||||
```powershell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。
|
||||
|
||||
- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/RMIC-2026.exe`(名字由 `tauri.conf.json` 的 `productName` 决定;直接 `cargo build` 出来的原始产物仍叫 `cam-desktop.exe`,那是内部构建物,不是交付物)。
|
||||
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
|
||||
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
|
||||
|
||||
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
|
||||
|
||||
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免"能发现并绑定,但始终等待画面"的情况。队伍端的绑定和画面过滤仍然生效。
|
||||
|
||||
### 演示模式:没有板子也能把整条链路跑起来
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
CAM_DEMO=1 pnpm app
|
||||
```
|
||||
|
||||
它不伪造数据给界面,而是合成协议数据报,从 `Receiver::datagram` 与
|
||||
`ArmorHealthStore::datagram` 进去。演示模式跑的是同一条真实路径:
|
||||
|
||||
```text
|
||||
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||
```
|
||||
|
||||
分片、重组、发现、统计、掉血派生、两道闸门全在跑真代码,截图与录制也不需要特殊分支。
|
||||
界面上会有一条 **演示模式** 标记,板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出是人造
|
||||
的形状——**别把它当成现场数据**。
|
||||
|
||||
### 命令行与环境变量
|
||||
|
||||
```text
|
||||
--role <team|referee> 身份。默认看配置文件
|
||||
--config <路径> 配置文件位置
|
||||
--demo 演示模式
|
||||
--list 打印发现的摄像头与装甲板后退出
|
||||
-h, --help 帮助
|
||||
|
||||
CAM_DEMO=1 等价于 --demo
|
||||
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||||
```
|
||||
|
||||
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||||
(`tauri dev -- -- --demo`),写在一行环境变量里不会有人记错。
|
||||
|
||||
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
|
||||
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
|
||||
|
||||
## 为什么换成 Tauri,代价是什么
|
||||
|
||||
旧客户端是 Rust + Slint 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、
|
||||
瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是
|
||||
几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心
|
||||
在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。
|
||||
|
||||
代价说清楚:
|
||||
|
||||
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
|
||||
- **要联网装依赖**(前端 npm、Rust crates)。
|
||||
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
|
||||
路径更短。
|
||||
|
||||
换来的另一样东西是**可测性**:`core/` 不认识界面,所以重组、发现、帧率窗口、槽位分配、
|
||||
配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||||
|
||||
## 视频怎么进 WebView
|
||||
|
||||
视频走一个只监听回环地址的 WebSocket,推二进制 JPEG。界面从 `stream_info` 命令拿到
|
||||
`ws://127.0.0.1:<随机端口>/?token=...`。
|
||||
|
||||
### 为什么不是 Tauri 的 IPC
|
||||
|
||||
两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨
|
||||
1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 WebSocket 帧把
|
||||
原始字节直接交给前端,浏览器用**原生解码器**解,**后端连 JPEG 都不用解**。
|
||||
|
||||
### 二进制帧的布局
|
||||
|
||||
```text
|
||||
0 4 8 10 12 16
|
||||
+--------------+--------------+------+------+--------------+---------+
|
||||
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||||
+--------------+--------------+------+------+--------------+---------+
|
||||
```
|
||||
|
||||
小端,16 字节头。前后端对这段布局的约定由一个测试盯着(`server.rs` 与 `stream.ts`
|
||||
两侧都有),长度不一致的帧宁可丢掉,也不要让前端拿到一段不完整的 JPEG。
|
||||
|
||||
### 前端侧的两个陷阱
|
||||
|
||||
**解码是异步的,所以帧号要看着用。** `createImageBitmap` 返回 Promise,两个相邻帧的解码
|
||||
可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔
|
||||
往回跳一帧,看起来像"卡了一下",极难归因。
|
||||
|
||||
**`ImageBitmap` 必须显式 `close()`。** 它持有解码后的像素,不吃 GC 的账。每收到新帧就
|
||||
替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。
|
||||
|
||||
## 防作弊:两道闸门,不是一个
|
||||
|
||||
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
|
||||
|
||||
关键是这件事做在接收端,不是界面里。广播的 Hello 里就有 MAC 派生的 `sender_id` 与
|
||||
IP,藏起来毫无意义;数据也可能已经收下并解码了,CSS 只控制显示。
|
||||
|
||||
| 闸门 | 管什么 | 什么时候开 |
|
||||
|---|---|---|
|
||||
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
||||
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||||
|
||||
旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑
|
||||
只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但**画面进不来**:
|
||||
对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。
|
||||
|
||||
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
|
||||
|
||||
### 遥测按编译模式门控
|
||||
|
||||
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
|
||||
数字——不是"界面藏起来",是 DTO 里就不存在(`CameraStatsDto::build` 直接把它们置零)。
|
||||
由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。
|
||||
|
||||
### 这挡不住什么
|
||||
|
||||
**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后可以自己发一个
|
||||
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住
|
||||
需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就
|
||||
失配。
|
||||
|
||||
回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要
|
||||
先读到那个 token 的事。
|
||||
|
||||
## 密码:它挡的是谁
|
||||
|
||||
- **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观
|
||||
的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会
|
||||
要求先解锁。**退出/关闭程序**同样要密码,因为"随手关掉"也是一种要挡的误操作。
|
||||
- **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希
|
||||
或者直接改源码都行。真要封住得回固件加配对 token。
|
||||
|
||||
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
|
||||
|
||||
实现上:
|
||||
|
||||
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
|
||||
一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有
|
||||
保护。
|
||||
- 首次启动**强制**设置密码(`Config::needs_password_setup`),最短 4 位。长度下限只要
|
||||
4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏
|
||||
手机查一遍。
|
||||
- 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。
|
||||
- **裁判端不要密码**:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改
|
||||
槽位、开录。
|
||||
- **离开用户端要密码**:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。
|
||||
|
||||
执行点在 Rust 侧:每个改变配置的命令都过 `AppState::require_unlocked`。界面上的解锁
|
||||
对话框只是入口——即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。
|
||||
|
||||
**故意没有 `--camera-id` 这类命令行覆盖。** 旧客户端有一个,用来"临时把一台机器当另一台
|
||||
用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改
|
||||
(要密码),或者用 `--config` 指一份另外的配置文件。
|
||||
|
||||
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
|
||||
导入并备份成 `.bak`。
|
||||
|
||||
## 用户端 HUD
|
||||
|
||||
### 血条:两个上角,平行四边形,一条分 5 格
|
||||
|
||||
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
|
||||
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
|
||||
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
|
||||
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
|
||||
|
||||
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
|
||||
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
|
||||
|
||||
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
|
||||
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
|
||||
|
||||
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
|
||||
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||
|
||||
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
|
||||
一个 0 与灭掉的格子。
|
||||
|
||||
### 胜负横幅
|
||||
|
||||
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
|
||||
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
|
||||
看出是哪一边赢了。
|
||||
|
||||
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
|
||||
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
|
||||
自家主板会让人"凭空胜利"。
|
||||
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
|
||||
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
|
||||
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
|
||||
它是一局的结束,此刻不再需要瞄准。
|
||||
|
||||
### 瞄准器
|
||||
|
||||
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
|
||||
用 DOM 摆一个 `<img>` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而
|
||||
两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换
|
||||
只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||||
|
||||
尺寸与缩放的关系也是刻意分开的:
|
||||
|
||||
| | 跟着缩放走吗 | 为什么 |
|
||||
|---|---|---|
|
||||
| 位置 | **是** | 它指的是画面里的一个点 |
|
||||
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
||||
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
||||
|
||||
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
|
||||
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
|
||||
|
||||
### 命中反馈
|
||||
|
||||
主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的
|
||||
(`HealthTracker`):
|
||||
|
||||
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
|
||||
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
||||
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
||||
|
||||
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
|
||||
基线会让一次掉血被报成四次命中。
|
||||
|
||||
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
||||
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
|
||||
|
||||
反馈有**两样**:画面四周泛红(颜色只从四条边渗进来,中间保持原样)、准星旁的伤害数字。
|
||||
**大号数字不放在画面正中**——第一版放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最
|
||||
需要看着准星的时候。
|
||||
|
||||
**它不给方向。** 命中事件里的 `index` 是哪一条上报先发现了掉血,不是"从哪一侧打来的"(主板
|
||||
一次广播四条、血值相同,谁先到达取决于 UDP)。把它画成方向箭头会是一条看起来很专业、实际上
|
||||
完全错误的线索,比不画糟得多。
|
||||
|
||||
**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了,
|
||||
理由有两条:
|
||||
|
||||
- 抖动恰好发生在最需要看清画面的那一下;
|
||||
- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个
|
||||
只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。
|
||||
|
||||
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
|
||||
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
|
||||
面板里也没有这个开关了。
|
||||
|
||||
## 裁判端
|
||||
|
||||
### 队名可以直接改
|
||||
|
||||
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
|
||||
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
|
||||
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
|
||||
|
||||
规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符**
|
||||
截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里
|
||||
就限制了,比提交后被悄悄截掉诚实。
|
||||
|
||||
命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样
|
||||
每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边
|
||||
就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password`
|
||||
为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天
|
||||
改的是名字,明天有人顺手把绑定也挪进来。
|
||||
|
||||
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
|
||||
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
|
||||
而且不会报错。
|
||||
|
||||
### 两格等宽
|
||||
|
||||
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
|
||||
|
||||
旧客户端在这里翻过车:`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按 stretch
|
||||
分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要
|
||||
十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。
|
||||
|
||||
### 槽位分配:修掉了一个真 bug
|
||||
|
||||
规则:
|
||||
|
||||
1. 还在线的槽位不换人;
|
||||
2. **候选里排除已经被另一个槽位占着的 id**;
|
||||
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
||||
4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。
|
||||
|
||||
规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是**队伍一的板子一离线,它的
|
||||
槽位就会去拿队伍二那块,两格显示同一块板子**——看起来像图传串了,现场的排查方向会是网线、
|
||||
AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。
|
||||
|
||||
有烧错固件的板子(`team` 字段是反的)时可以在设置里改成手动指定槽位。
|
||||
|
||||
### 装甲板发现列表
|
||||
|
||||
场上连上了几块板子、各自自报哪一队、现在多少血、多久没广播、四条上报齐不齐、分到了哪个
|
||||
槽位——"裁判端没显示血量"时第一眼看的就是这张表。它能区分"板子根本没上线"、"上线了但队
|
||||
伍号烧反了"和"只有一条上报在发"。
|
||||
|
||||
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
|
||||
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
|
||||
|
||||
### 截图与录制
|
||||
|
||||
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
|
||||
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
|
||||
喂帧,x264 编码,`+faststart`。
|
||||
- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来
|
||||
就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放
|
||||
时第一个要看的就是它。
|
||||
- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是
|
||||
链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
|
||||
- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的
|
||||
`-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。
|
||||
|
||||
界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的
|
||||
目录生效。默认录制目录在名单里;改到别处时 `<video>` 会加载失败,界面上给出"用系统播放器
|
||||
打开"的退路——而不是让人以为录下来的文件坏了。
|
||||
|
||||
## 配置
|
||||
|
||||
`~/.config/cam-desktop/config.toml`,`--config` 可改。**目录名沿用旧的 `cam-desktop`**(原因见开头)。要干净重来就删掉这个目录。
|
||||
|
||||
```toml
|
||||
role = "team"
|
||||
roleChosen = true
|
||||
team = 0
|
||||
teamNames = ["队伍一", "队伍二"]
|
||||
cameraId = "0x0DE00001"
|
||||
armorId = "0x0A200001"
|
||||
mirror = false
|
||||
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||
|
||||
[recording]
|
||||
dir = "/Users/you/Movies/cam-desktop"
|
||||
ffmpeg = "ffmpeg"
|
||||
crf = 20
|
||||
preset = "veryfast"
|
||||
|
||||
[hud.crosshair]
|
||||
style = "cross-dot"
|
||||
size = 0.05
|
||||
offsetX = 0.0
|
||||
```
|
||||
|
||||
几个决定:
|
||||
|
||||
- **手写解析换成 serde + toml。** 旧客户端的配置只有四个标量字段,手写换来零依赖是划算
|
||||
的;现在有 HUD 的嵌套结构、密码哈希、录制参数,对称性成本超过了收益。
|
||||
- **未知键名仍然报错**(`deny_unknown_fields`)。打错一个键名却"看起来生效了",在现场是
|
||||
要浪费时间的。
|
||||
- **字段名统一 camelCase**,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮,
|
||||
但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置
|
||||
看起来"改了没生效"。
|
||||
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
|
||||
文件会让"解锁"永久失败。
|
||||
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
||||
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
|
||||
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
|
||||
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
|
||||
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
|
||||
|
||||
旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 `.bak`(不删:现场
|
||||
出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用**默认**路径时才做这件事——
|
||||
`--config` 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。
|
||||
|
||||
## 浏览器预览(仅开发)
|
||||
|
||||
Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面
|
||||
里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由
|
||||
`src/lib/dev-preview.ts` 合成。
|
||||
|
||||
```sh
|
||||
pnpm dev # 用户端
|
||||
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
||||
open 'http://127.0.0.1:1420/?preview=first-run' # 首启的身份选择
|
||||
open 'http://127.0.0.1:1420/?preview=password' # 首启的设密码
|
||||
```
|
||||
|
||||
后两个参数只是让对应的门禁强制显示出来,不用为了看一眼就去删配置文件。
|
||||
|
||||
两重门保证它进不了正式构建:`import.meta.env.DEV` 为真**且**没有 `__TAURI_INTERNALS__`,
|
||||
并且调用点是 `await import(...)`——条件为假时整个模块不会被打进产物。
|
||||
|
||||
## 测试
|
||||
|
||||
```sh
|
||||
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||||
cd desktop/src-tauri && cargo test # 40:帧头布局、录制、截图、演示画面、命令行
|
||||
cd protocol && cargo test # 23
|
||||
```
|
||||
|
||||
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,5 项测试覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复与设备重启。它不替代实际 WebView 的画面验证。
|
||||
|
||||
三条值得单独说的:
|
||||
|
||||
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
|
||||
存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的
|
||||
一种失败。
|
||||
- **截图**那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
|
||||
- **帧率窗口**那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下"
|
||||
改回错误算法。
|
||||
|
||||
CSP 的验证不靠"看一眼没报错",而是靠**前端能不能连上后端**:前端的第一步是先 `invoke`
|
||||
拿通道地址,再连 WebSocket。所以快照里的 `clients` 从 0 变成 1 同时证明了四件事——JS 加载
|
||||
成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在
|
||||
0。手动核对的办法:
|
||||
|
||||
```sh
|
||||
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
||||
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
|
||||
```
|
||||
|
||||
## 线程与数据流
|
||||
|
||||
| 线程 | 做什么 | 为什么单独 |
|
||||
|---|---|---|
|
||||
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
|
||||
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
|
||||
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
|
||||
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
||||
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
||||
|
||||
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
|
||||
视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。
|
||||
前端每路最多保留一次正在进行的解码和一张最新待解码帧。
|
||||
|
||||
每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。
|
||||
|
||||
## 已知限制与没做的
|
||||
|
||||
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
|
||||
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
|
||||
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
|
||||
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
|
||||
DTO,所以至少能解释清楚。
|
||||
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
|
||||
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
|
||||
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端",
|
||||
见"测试"一节最后一条。
|
||||
- **证书与公证**没做,`.dmg` 只在本机可用。
|
||||
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
|
||||
|
||||
## 排查
|
||||
|
||||
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
|
||||
|
||||
```powershell
|
||||
.\RMIC-2026.exe --config .\referee.toml --latency-report .\latency-run.ndjson
|
||||
```
|
||||
|
||||
报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。
|
||||
|
||||
`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。
|
||||
|
||||
| 现象 | 原因 |
|
||||
|---|---|
|
||||
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
|
||||
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
||||
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
||||
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
||||
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
|
||||
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
|
||||
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
|
||||
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
|
||||
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
||||
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
||||
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
|
||||
| 两块画面串了 | 检查是不是手动指定了槽位,或者两块板子烧了同一个 `ARMOR_TEAM` |
|
||||
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
|
||||
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||
- [客户端说明](../docs/desktop/client.md):构建、配置、界面行为、网络通道、权限与排查。
|
||||
- [固件维护](../docs/maintenance/firmware.md):`.zip` 固件包、坏块规避、备份与回读校验。
|
||||
- [版本维护](../docs/development/versioning.md):版本计算与派生文件。
|
||||
- [文档首页](../docs/index.md):仓库的其他说明。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rmic-2026",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"description": "RMIC::2026 —— ESP32 图传与装甲板系统的桌面端:用户端(装甲条 + 瞄准器)与裁判端(双路 + 装甲板 + 录制)",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- RMIC::2026 应用图标(矢量源)。
|
||||
由 https://simba-robotics.com/logo.svg 生成:把原图 1024 画布的 viewBox 收窄、标志居中,
|
||||
底色用界面的 --deep。位图(PNG / ICO / ICNS)由这一份光栅化得到;
|
||||
小尺寸另有一版更大的缩放比(见 desktop/README.md 的「队标」一节)。 -->
|
||||
小尺寸另有一版更大的缩放比(见 docs/desktop/client.md 的「队标」一节)。 -->
|
||||
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93333" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="270.93333" height="270.93333" fill="#080b12"/>
|
||||
<g transform="translate(135.4667 135.4667) scale(0.82) translate(-135.28 -136.22)">
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -76,7 +76,7 @@ pub fn config_path(state: Shared<'_>) -> String {
|
||||
pub fn unlock(password: String, state: Shared<'_>) -> Result<bool, String> {
|
||||
state
|
||||
.unlock(&password)
|
||||
.map_err(|err| format!("存下来的密码哈希读不出来:{err}"))
|
||||
.map_err(|err| format!("无法读取已保存的密码哈希:{err}"))
|
||||
}
|
||||
|
||||
/// 手动重新上锁(并顺手把发现列表关掉)。
|
||||
@@ -305,7 +305,7 @@ pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
||||
let frame = state
|
||||
.latest_frames()
|
||||
.latest(camera)
|
||||
.ok_or_else(|| format!("还没有收到 0x{camera:08X} 的画面,暂时截不了"))?;
|
||||
.ok_or_else(|| format!("尚未收到 0x{camera:08X} 的画面,暂时无法截图"))?;
|
||||
|
||||
let config = state.config();
|
||||
let dir = config.recordings_dir().join("shots");
|
||||
@@ -329,7 +329,7 @@ pub fn start_recording(
|
||||
state: Shared<'_>,
|
||||
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
|
||||
if cameras.is_empty() {
|
||||
return Err("没有指定要录哪一路".into());
|
||||
return Err("请先选择要录制的画面".into());
|
||||
}
|
||||
|
||||
let config = state.config();
|
||||
@@ -347,8 +347,8 @@ pub fn start_recording(
|
||||
Some(measured) if measured > 1.0 => measured,
|
||||
_ => {
|
||||
log::warn!(
|
||||
"0x{camera:08X} 还没有测到帧率,录制先按 {FALLBACK_INPUT_FPS} fps 走;\
|
||||
回放速度可能不准,可以改用 [recording] fps 明确指定"
|
||||
"尚未测得 0x{camera:08X} 的帧率,录制暂用 {FALLBACK_INPUT_FPS} fps;\
|
||||
回放速度可能不准确,可在 [recording] 中设置 fps"
|
||||
);
|
||||
FALLBACK_INPUT_FPS
|
||||
}
|
||||
@@ -365,7 +365,7 @@ pub fn start_recording(
|
||||
config.recording.crf,
|
||||
&config.recording.preset,
|
||||
)
|
||||
.map_err(|err| format!("起不了录制:{err:#}"))?;
|
||||
.map_err(|err| format!("无法开始录制:{err:#}"))?;
|
||||
|
||||
started.push(dto);
|
||||
}
|
||||
@@ -405,7 +405,7 @@ pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(format!("{} 不存在了", target.display()));
|
||||
return Err(format!("文件不存在:{}", target.display()));
|
||||
}
|
||||
|
||||
tauri_plugin_opener::reveal_item_in_dir(&target)
|
||||
@@ -427,7 +427,7 @@ pub fn open_file(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(format!("{} 不存在了", target.display()));
|
||||
return Err(format!("文件不存在:{}", target.display()));
|
||||
}
|
||||
|
||||
tauri_plugin_opener::open_path(&target, None::<&str>)
|
||||
@@ -470,7 +470,7 @@ pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<Strin
|
||||
if path.exists() {
|
||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||
std::fs::rename(&path, &backup)
|
||||
.map_err(|err| format!("挪走 {} 失败:{err}", path.display()))?;
|
||||
.map_err(|err| format!("移动文件 {} 失败:{err}", path.display()))?;
|
||||
|
||||
log::warn!("配置已重置,旧文件留在 {}", backup.display());
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
log::debug!("stream: {}", state.stream_info().url);
|
||||
|
||||
if args.demo {
|
||||
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
||||
state.note("演示模式已开启:画面与血量由本机合成。");
|
||||
demo::spawn(Arc::clone(&state));
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ impl Recorder {
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| {
|
||||
format!("could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径")
|
||||
format!("无法启动 ffmpeg({ffmpeg}),请在 [recording] 中设置 ffmpeg 路径")
|
||||
})?;
|
||||
|
||||
let stdin = child
|
||||
@@ -268,11 +268,11 @@ pub fn save_screenshot(jpeg: &[u8], dir: &Path, camera: u32) -> Result<PathBuf>
|
||||
);
|
||||
|
||||
let image = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg)
|
||||
.map_err(|err| anyhow!("这一帧不是能解码的 JPEG:{err}"))?;
|
||||
.map_err(|err| anyhow!("无法解码当前 JPEG 帧:{err}"))?;
|
||||
|
||||
image
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(|err| anyhow!("写不了 {}:{err}", path.display()))?;
|
||||
.map_err(|err| anyhow!("无法写入文件 {}:{err}", path.display()))?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
@@ -290,9 +290,9 @@ fn pump(
|
||||
// 读出来拼进错误里:只说"Broken pipe"对现场毫无帮助。
|
||||
let reason = child_stderr(child);
|
||||
return Err(anyhow!(
|
||||
"writing to ffmpeg failed: {err}{}",
|
||||
"向 ffmpeg 写入画面失败:{err}{}",
|
||||
reason
|
||||
.map(|r| format!(";ffmpeg 说:{r}"))
|
||||
.map(|r| format!(";ffmpeg 输出:{r}"))
|
||||
.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
@@ -776,7 +776,10 @@ mod tests {
|
||||
names.contains(&"b.PNG"),
|
||||
"后缀大小写不该决定它是不是一张截图"
|
||||
);
|
||||
assert_eq!(files[0].kind, "video");
|
||||
let video = files.iter().find(|file| file.name == "a.mp4").unwrap();
|
||||
let image = files.iter().find(|file| file.name == "b.PNG").unwrap();
|
||||
assert_eq!(video.kind, "video");
|
||||
assert_eq!(image.kind, "image");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -375,13 +375,13 @@ impl AppState {
|
||||
match self.unlock(presented) {
|
||||
Ok(true) => return Ok(()),
|
||||
Ok(false) => {
|
||||
return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into())
|
||||
return Err("密码错误。修改图传板、装甲板绑定或队伍号需要密码。".into())
|
||||
}
|
||||
Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")),
|
||||
Err(err) => return Err(format!("无法读取已保存的密码哈希:{err}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into())
|
||||
Err("修改设置需要密码,请先解锁。".into())
|
||||
}
|
||||
|
||||
/// 打开/关闭设置面板带来的发现列表。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "RMIC-2026",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"identifier": "com.simbarobotics.rmic2026",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+9
-9
@@ -120,9 +120,9 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const milestones = [
|
||||
{ key: "fonts", label: "载入字体集", code: "HARMONYOS · JETBRAINS", done: fontsReady },
|
||||
{ key: "channel", label: "建立后端通道", code: "LOCAL IPC", done: url !== null || !!fatal },
|
||||
{ key: "snapshot", label: "接收首份状态", code: "SNAPSHOT 200ms", done: snapshot !== null || !!fatal },
|
||||
{ key: "fonts", label: "加载字体", code: "HARMONYOS · JETBRAINS", done: fontsReady },
|
||||
{ key: "channel", label: "连接本机服务", code: "LOCAL IPC", done: url !== null || !!fatal },
|
||||
{ key: "snapshot", label: "读取运行状态", code: "SNAPSHOT 200ms", done: snapshot !== null || !!fatal },
|
||||
];
|
||||
|
||||
const sequenceDone = milestones.every((item) => item.done);
|
||||
@@ -213,7 +213,7 @@ export default function App() {
|
||||
if (!snapshot) return;
|
||||
|
||||
if (snapshot.role !== "referee" && !snapshot.unlocked) {
|
||||
needsUnlock("改摄像头/装甲板绑定、切换队伍号都要密码。", { kind: "settings" });
|
||||
needsUnlock("修改图传板、装甲板绑定或队伍号需要密码。", { kind: "settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,9 +332,9 @@ export default function App() {
|
||||
return (
|
||||
<div className="splash">
|
||||
<span className="splash__mark label">RMIC · 2026</span>
|
||||
<h1 className="splash__title">还没有拿到后端状态</h1>
|
||||
<h1 className="splash__title">正在读取运行状态</h1>
|
||||
<p className="splash__text">
|
||||
{connected ? "正在等待后端的第一份快照…" : "正在连接后端…"}
|
||||
{connected ? "已连接,等待状态数据…" : "正在连接本机服务…"}
|
||||
</p>
|
||||
<div className="scan" style={{ width: 220 }} />
|
||||
</div>
|
||||
@@ -430,7 +430,7 @@ export default function App() {
|
||||
<div className="calibrate-bar">
|
||||
<span className="calibrate-bar__mark label">CALIBRATE</span>
|
||||
<span className="calibrate-bar__text">
|
||||
拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
|
||||
拖动画面调整瞄准点,完成后点击“结束校准”。
|
||||
</span>
|
||||
<button type="button" className="btn btn--ghost" onClick={() => setCalibrating(false)}>
|
||||
结束校准
|
||||
@@ -454,7 +454,7 @@ function ChannelFault({ snapshot }: { snapshot: Snapshot }) {
|
||||
|
||||
if (connected) return null;
|
||||
|
||||
let detail = "正在重连后端的视频通道…";
|
||||
let detail = "视频通道已断开,正在重连…";
|
||||
|
||||
try {
|
||||
detail += `(已收到 ${streamClient().counters.frames} 帧,重连 ${streamClient().counters.reconnects} 次)`;
|
||||
@@ -463,7 +463,7 @@ function ChannelFault({ snapshot }: { snapshot: Snapshot }) {
|
||||
}
|
||||
|
||||
if (snapshot.clients === 0 && snapshot.uptimeMs > 5000) {
|
||||
detail += " 后端报告没有任何界面连着,这一条是前端侧的问题。";
|
||||
detail += " 本机服务尚未检测到界面连接。";
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,7 +40,7 @@ function silenceTone(online: boolean, silenceMs: number): "good" | "warn" | "bad
|
||||
/** 四格位图:这一块板子的四条上报各自到没到。 */
|
||||
function Plates({ seen }: { seen: number }) {
|
||||
return (
|
||||
<span className="armor__plates" title={`见过 ${seen.toString(2).padStart(4, "0")} 四条上报`}>
|
||||
<span className="armor__plates" title={`四路上报位图:${seen.toString(2).padStart(4, "0")}`}>
|
||||
{[0, 1, 2, 3].map((bit) => (
|
||||
<span key={bit} className={`armor__plate${(seen >> bit) & 1 ? " is-on" : ""}`} />
|
||||
))}
|
||||
@@ -55,7 +55,7 @@ export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
|
||||
return (
|
||||
<div className="armor">
|
||||
<div className="armor__head">
|
||||
<span className="armor__col armor__col--plates">四条</span>
|
||||
<span className="armor__col armor__col--plates">四路</span>
|
||||
<span className="armor__col">装甲板</span>
|
||||
<span className="armor__col">地址</span>
|
||||
<span className="armor__col">自报队伍</span>
|
||||
@@ -69,12 +69,12 @@ export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
|
||||
<div className="armor__empty">
|
||||
{boards.length === 0 ? (
|
||||
<>
|
||||
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在
|
||||
同一个网络里。
|
||||
尚未收到装甲板上报。主板每 200 ms 广播一次;请检查供电,并确认主板与本机
|
||||
处于同一网络。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
这一队还没有装甲板上报(另有 {hidden} 块属于另一队)。按 <kbd>0</kbd> 看全部。
|
||||
本队暂无装甲板上报,另一队有 {hidden} 块。按 <kbd>0</kbd> 显示全部。
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -86,14 +86,14 @@ export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
|
||||
? `左 · ${teamName(0, names)}`
|
||||
: slots.armorB === board.id
|
||||
? `右 · ${teamName(1, names)}`
|
||||
: "未入槽";
|
||||
: "未分配";
|
||||
const tone = silenceTone(board.online, board.silenceMs);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={board.id}
|
||||
className={`armor__row${board.online ? "" : " is-offline"}${
|
||||
assigned === "未入槽" ? " is-unassigned" : ""
|
||||
assigned === "未分配" ? " is-unassigned" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="armor__col armor__col--plates">
|
||||
@@ -165,7 +165,7 @@ export function StatsLine({
|
||||
return (
|
||||
<div className="statsline statsline--empty">
|
||||
<span className="dot dot--dead" />
|
||||
还没有这一路的数据
|
||||
尚未收到这一路的数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
|
||||
<span className="boot__fatal-title">启动失败</span>
|
||||
<p className="boot__fatal-text">{fatal}</p>
|
||||
<p className="boot__fatal-hint">
|
||||
端口可能被另一个客户端占着(旧的版本或另一份本程序)。关掉那一份,再重开。
|
||||
端口可能被其他客户端占用。请关闭正在运行的客户端,再重新启动。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -87,7 +87,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
|
||||
</div>
|
||||
<div className="boot__meta">
|
||||
<span className="label">
|
||||
{progress >= 1 ? "序列完成 · 正在进入" : "自检中"}
|
||||
{progress >= 1 ? "加载完成 · 正在进入" : "正在加载"}
|
||||
</span>
|
||||
<span className="num boot__pct">
|
||||
{Math.round(Math.max(0, Math.min(1, progress)) * 100)}
|
||||
|
||||
@@ -34,7 +34,7 @@ const MODULES: {
|
||||
index: "01",
|
||||
cn: "用户端",
|
||||
en: "OPERATOR",
|
||||
summary: "一路画面 + 双方装甲条 + 瞄准器 + 命中反馈。装在这台车上,给操作手看。",
|
||||
summary: "供操作手使用:本队画面、双方装甲条、瞄准器和命中反馈。",
|
||||
needs: ["绑定一块图传板", "绑定一块装甲板", "设置一个现场密码"],
|
||||
},
|
||||
{
|
||||
@@ -42,8 +42,8 @@ const MODULES: {
|
||||
index: "02",
|
||||
cn: "裁判端",
|
||||
en: "REFEREE",
|
||||
summary: "两路画面并排 + 双方血量 + 装甲板发现列表 + 截图与录制。给场边的人看。",
|
||||
needs: ["不需要绑定", "不需要密码", "自动按队伍分左右"],
|
||||
summary: "供场边裁判使用:双路画面、双方血量、装甲板列表、截图和录制。",
|
||||
needs: ["不需要绑定", "不需要密码", "自动分配左右槽位"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -77,9 +77,9 @@ export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => vo
|
||||
</header>
|
||||
|
||||
<div className="gate__lede">
|
||||
<h1 className="gate__title">这台机器是哪一端?</h1>
|
||||
<h1 className="gate__title">选择运行模式</h1>
|
||||
<p className="gate__hint">
|
||||
选定之后记进配置,下次启动直接进这一端。之后可以在设置里改——离开用户端需要密码。
|
||||
选择会保存到配置,下次启动直接进入。可在设置中切换;离开用户端需要密码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => vo
|
||||
</span>
|
||||
|
||||
<span className="module__cta">
|
||||
{busy === module.role ? "正在切换…" : "选用这一端"}
|
||||
{busy === module.role ? "正在切换…" : "使用此模式"}
|
||||
<span className="module__arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
@@ -123,7 +123,7 @@ export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => vo
|
||||
|
||||
{onOpenMaintenance && (
|
||||
<button type="button" className="gate__maintenance" onClick={onOpenMaintenance} disabled={busy !== null}>
|
||||
固件维护 / 烧录(不进入身份选择)
|
||||
打开固件维护
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
if (busy) return;
|
||||
|
||||
if (first !== again) {
|
||||
setError("两次输入不一致");
|
||||
setError("两次输入的密码不一致");
|
||||
setAgain("");
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
try {
|
||||
await api.setPassword(first);
|
||||
await api.refresh();
|
||||
pushNote("密码已设置。改图传板/装甲板绑定、切换队伍号、退出都需要它。");
|
||||
pushNote("密码已设置。修改图传板或装甲板绑定、切换队伍号、退出程序时需要输入密码。");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -178,10 +178,10 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
</header>
|
||||
|
||||
<div className="gate__lede">
|
||||
<h1 className="gate__title">先配一把钥匙</h1>
|
||||
<h1 className="gate__title">设置现场密码</h1>
|
||||
<p className="gate__hint">
|
||||
用户端改图传板与装甲板绑定、切换队伍号、退出都要用它。至少 4 位;本机存的是
|
||||
Argon2id 哈希,不是明文。
|
||||
修改用户端的图传板或装甲板绑定、切换队伍号、退出程序时需要密码。至少 4 位;
|
||||
本机保存 Argon2id 哈希,不保存明文。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
<input
|
||||
className="input input--lg"
|
||||
type="password"
|
||||
placeholder="再输一次"
|
||||
placeholder="再次输入密码"
|
||||
autoComplete="new-password"
|
||||
value={again}
|
||||
onChange={(event) => setAgain(event.target.value)}
|
||||
@@ -220,10 +220,10 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
))}
|
||||
<span className="keypad__meter-note">
|
||||
{first.length === 0
|
||||
? "还没有输入"
|
||||
? "请输入密码"
|
||||
: first.length < 4
|
||||
? `还需要 ${4 - first.length} 位`
|
||||
: "长度够了"}
|
||||
: "长度符合要求"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -241,13 +241,13 @@ export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () =>
|
||||
</div>
|
||||
|
||||
<p className="gate__note">
|
||||
忘了密码的处理:删掉配置文件里的 password 一行,或者删掉整个配置文件。旧客户端
|
||||
(Slint 版)的配置会在首次启动时自动导入并备份成 .bak。
|
||||
忘记密码时,可删除配置文件中的 password 一行,或删除整个配置文件。旧客户端
|
||||
(Slint 版)的配置会在首次启动时自动导入,并备份为 .bak。
|
||||
</p>
|
||||
|
||||
{onOpenMaintenance && (
|
||||
<button type="button" className="gate__maintenance" onClick={onOpenMaintenance} disabled={busy}>
|
||||
固件维护 / 烧录(不进入身份选择)
|
||||
打开固件维护
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 图传中央的胜负结算:一边归零之后,画面正中给出「胜利 / 击败」。
|
||||
* 图传中央的胜负结算:一边归零之后,画面正中给出「胜利 / 落败」。
|
||||
*
|
||||
* # 为什么这次放在正中
|
||||
*
|
||||
@@ -34,7 +34,7 @@ interface Props {
|
||||
|
||||
const WORD: Record<MatchOutcome, string> = {
|
||||
victory: "胜利",
|
||||
defeat: "击败",
|
||||
defeat: "落败",
|
||||
draw: "平局",
|
||||
};
|
||||
|
||||
|
||||
@@ -47,10 +47,14 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
const active = snapshot.recordings.filter((recording) => recording.active);
|
||||
const cameras = snapshot.cameras.filter((camera) => camera.online).map((camera) => camera.id);
|
||||
|
||||
const guard = (action: Promise<unknown>) => {
|
||||
const guard = (action: Promise<unknown>, onSuccess?: () => void) => {
|
||||
setBusy(true);
|
||||
void action
|
||||
.then(() => api.refresh())
|
||||
.then(() => {
|
||||
onSuccess?.();
|
||||
refresh();
|
||||
return api.refresh();
|
||||
})
|
||||
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
@@ -66,10 +70,11 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={busy || cameras.length === 0}
|
||||
title={cameras.length === 0 ? "还没有在线的摄像头" : undefined}
|
||||
title={cameras.length === 0 ? "暂无在线图传板" : undefined}
|
||||
onClick={() => {
|
||||
guard(api.startRecording(cameras));
|
||||
onInfo("开始录制。停止之前请留在这台机器上——退出会先收尾再走。");
|
||||
guard(api.startRecording(cameras), () =>
|
||||
onInfo("已开始录制。退出程序时会先结束录制并保存文件。"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="media__rec-glyph" aria-hidden="true" />
|
||||
@@ -82,7 +87,6 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
disabled={busy || active.length === 0}
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([]));
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
停止全部录制
|
||||
@@ -97,11 +101,11 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
className="btn btn--ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
guard(api.screenshot(camera));
|
||||
onInfo(
|
||||
`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(存在录制目录的 shots/ 下)。`,
|
||||
guard(api.screenshot(camera), () =>
|
||||
onInfo(
|
||||
`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(已保存到录制目录的 shots/)。`,
|
||||
),
|
||||
);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
截图
|
||||
@@ -141,7 +145,7 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
{recording.framesDropped > 0 && (
|
||||
<span
|
||||
className="strip__warn"
|
||||
title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。"
|
||||
title="写入速度不足时丢弃的帧数,可能影响回放速度。"
|
||||
>
|
||||
丢 {recording.framesDropped} 帧
|
||||
</span>
|
||||
@@ -152,7 +156,6 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
className="btn btn--ghost"
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([recording.camera]));
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
停止
|
||||
@@ -232,8 +235,8 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
{playbackFailed ? (
|
||||
<div className="playback__fallback">
|
||||
<p className="playback__warn">
|
||||
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,
|
||||
或者直接看下面这一条)。
|
||||
无法在界面内回放,可能是录制目录不在允许访问的范围内。可改用默认录制目录,
|
||||
或用系统播放器打开。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -245,7 +245,7 @@ export function SettingsPanel({
|
||||
onClose();
|
||||
});
|
||||
}}
|
||||
title="立刻收回改动权限"
|
||||
title="锁定设置,修改时需重新输入密码"
|
||||
>
|
||||
上锁
|
||||
</Button>
|
||||
@@ -259,13 +259,13 @@ export function SettingsPanel({
|
||||
{locked && (
|
||||
<div className="drawer__locked">
|
||||
<span className="dot dot--warn" />
|
||||
面板的改动需要密码。
|
||||
修改设置前请先输入密码解锁。
|
||||
<button
|
||||
type="button"
|
||||
className="drawer__unlock"
|
||||
onClick={() => onNeedsUnlock("改动设置需要密码")}
|
||||
onClick={() => onNeedsUnlock("修改设置需要密码")}
|
||||
>
|
||||
立即解锁
|
||||
解锁设置
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -291,8 +291,8 @@ export function SettingsPanel({
|
||||
<div className="drawer__scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
{isTeam && <Preview hud={hud} team={snapshot.team} />}
|
||||
|
||||
<Section id="sect-role" index={1} title="身份" hint="身份决定这一端能做什么。离开用户端要密码:否则切到裁判端就能看两路画面。">
|
||||
<Field label="这台机器是哪一端">
|
||||
<Section id="sect-role" index={1} title="身份" hint="用户端显示绑定的图传,裁判端显示双路画面。离开用户端需要密码。">
|
||||
<Field label="运行模式">
|
||||
<Select
|
||||
value={config.role}
|
||||
options={[
|
||||
@@ -310,7 +310,7 @@ export function SettingsPanel({
|
||||
id="sect-bind"
|
||||
index={2}
|
||||
title="绑定"
|
||||
hint="图传板与装甲板都用 MAC 派生的 id 标识,重启不变,所以绑定是稳的。改动需要密码。"
|
||||
hint="图传板和装甲板使用由 MAC 派生的 ID,重启后绑定仍有效。修改绑定需要密码。"
|
||||
>
|
||||
<Field label="图传板">
|
||||
<Select
|
||||
@@ -320,7 +320,7 @@ export function SettingsPanel({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="装甲板主板" hint="决定装甲条读谁的血量。下拉里是场上正在广播的板子。">
|
||||
<Field label="装甲板主板" hint="选择装甲条使用的血量来源。列表显示已发现的装甲板。">
|
||||
<Select
|
||||
value={config.armorId}
|
||||
options={armorOptions}
|
||||
@@ -329,8 +329,8 @@ export function SettingsPanel({
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="自己属于哪一边"
|
||||
hint="改错队号会把对手的血条当成自己的:画面左上角那一条就会是对手的血。"
|
||||
label="所属队伍"
|
||||
hint="左上角显示本队血量,右上角显示对手血量。请核对队伍号。"
|
||||
>
|
||||
<Select
|
||||
value={config.team}
|
||||
@@ -344,14 +344,14 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={config.mirror}
|
||||
label="左右镜像画面(摄像头装反了)"
|
||||
label="左右镜像画面"
|
||||
onChange={(mirror) => void run(() => api.setMirror(mirror))}
|
||||
/>
|
||||
|
||||
{cameraOptions.length === 0 && (
|
||||
<p className="sect__warn">
|
||||
现在没有发现任何图传板。板子每 2 秒广播一次,确认它已上电、和这台机器在
|
||||
同一个网络里,然后稍等。
|
||||
尚未发现图传板。板卡每 2 秒广播一次;请检查供电,确认与本机处于
|
||||
同一网络后稍候。
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
@@ -360,7 +360,7 @@ export function SettingsPanel({
|
||||
id="sect-reticle"
|
||||
index={3}
|
||||
title="瞄准器"
|
||||
hint="位置跟着画面走,大小不跟着缩放走——放大后准星更精确。"
|
||||
hint="瞄准点随画面移动,准星大小保持不变;放大后覆盖的画面范围更小。"
|
||||
>
|
||||
<Switch
|
||||
checked={hud.crosshair.visible}
|
||||
@@ -379,7 +379,7 @@ export function SettingsPanel({
|
||||
{ value: "cross", label: "四臂十字" },
|
||||
{ value: "cross-dot", label: "十字 + 中心点" },
|
||||
{ value: "circle-dot", label: "圆环 + 中心点" },
|
||||
{ value: "dot", label: "只有点" },
|
||||
{ value: "dot", label: "中心点" },
|
||||
]}
|
||||
onChange={(style) =>
|
||||
update((draft) => {
|
||||
@@ -506,7 +506,7 @@ export function SettingsPanel({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="网格明显程度">
|
||||
<Field label="网格不透明度">
|
||||
<Slider
|
||||
value={hud.grid.opacity}
|
||||
min={0.05}
|
||||
@@ -535,7 +535,7 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={hud.showStats}
|
||||
label="状态行显示详细读数(分辨率、丢包、帧间隔)"
|
||||
label="状态栏显示详细读数(分辨率、丢包、帧间隔)"
|
||||
onChange={(showStats) =>
|
||||
update((draft) => {
|
||||
draft.showStats = showStats;
|
||||
@@ -545,7 +545,7 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={hud.hitFeedback.flash}
|
||||
label="被打中时四周泛红"
|
||||
label="受击时画面四周泛红"
|
||||
onChange={(flash) =>
|
||||
update((draft) => {
|
||||
draft.hitFeedback.flash = flash;
|
||||
@@ -555,7 +555,7 @@ export function SettingsPanel({
|
||||
|
||||
<Switch
|
||||
checked={hud.hitFeedback.marker}
|
||||
label="在瞄点旁显示伤害数字"
|
||||
label="在瞄准点旁显示伤害数字"
|
||||
onChange={(marker) =>
|
||||
update((draft) => {
|
||||
draft.hitFeedback.marker = marker;
|
||||
@@ -583,7 +583,7 @@ export function SettingsPanel({
|
||||
id="sect-armor"
|
||||
index={5}
|
||||
title="装甲条"
|
||||
hint="条挂在画面两个上角:左上角是自己、右上角是对手。一条分 5 格,一格 20 点血。"
|
||||
hint="左上角显示本队血量,右上角显示对手血量。每条分 5 格,每格 20 点血。"
|
||||
>
|
||||
<Switch
|
||||
checked={hud.health.visible}
|
||||
@@ -621,7 +621,7 @@ export function SettingsPanel({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Field label="低血阈值" hint="低于这个值时条会转红并脉动">
|
||||
<Field label="低血阈值" hint="达到此值或更低时显示低血状态;启用脉动后会同时闪动">
|
||||
<Slider
|
||||
value={hud.health.lowThreshold}
|
||||
min={5}
|
||||
@@ -658,7 +658,7 @@ export function SettingsPanel({
|
||||
id="sect-slots"
|
||||
index={2}
|
||||
title="裁判端槽位"
|
||||
hint="默认按装甲板自报的队伍自动分左右。有烧错固件的板子时打开手动指定。"
|
||||
hint="图传板自动分配左右画面,装甲板按上报队伍分配。对应关系不符时,可手动指定。"
|
||||
>
|
||||
<Switch
|
||||
checked={snapshot.slots.manual}
|
||||
@@ -731,7 +731,7 @@ export function SettingsPanel({
|
||||
id="sect-media"
|
||||
index={isTeam ? 6 : 3}
|
||||
title="录制与截图"
|
||||
hint="截图存 PNG,录制存 mp4(需要 ffmpeg)。"
|
||||
hint="截图保存为 PNG;录制保存为 MP4,需要 ffmpeg。"
|
||||
>
|
||||
<Field label="输出目录">
|
||||
<input
|
||||
@@ -748,7 +748,7 @@ export function SettingsPanel({
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="ffmpeg 路径" hint="默认从 PATH 找。找不到就录不了,但界面照常用。">
|
||||
<Field label="ffmpeg 路径" hint="默认从 PATH 查找。未找到时无法录制,其他功能仍可使用。">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
@@ -764,8 +764,8 @@ export function SettingsPanel({
|
||||
/>
|
||||
</Field>
|
||||
<p className="sect__note">
|
||||
界面内回放要目录在资源协议的白名单里(默认的 ~/Movies/cam-desktop 就在)。
|
||||
改到别处时回放会失败,界面上会给出"用系统播放器打开"的退路。
|
||||
界面内回放仅支持允许访问的目录,默认的 ~/Movies/cam-desktop 已在其中。
|
||||
其他目录中的视频可用系统播放器打开。
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
@@ -773,14 +773,14 @@ export function SettingsPanel({
|
||||
id="sect-safety"
|
||||
index={isTeam ? 7 : 4}
|
||||
title="安全"
|
||||
hint="密码只挡现场随手改绑;配置文件与程序都在本机,愿意改的人绕得过去。"
|
||||
hint="密码用于防止现场误改设置;能修改本机配置或程序的人仍可绕过。"
|
||||
>
|
||||
<ChangePassword onDone={onInfo} onError={onError} />
|
||||
<div className="sect__row">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (!window.confirm("清空配置?旧文件会被改名成 .bak,然后需要重启程序。")) return;
|
||||
if (!window.confirm("清空配置并重启设置流程?旧配置将备份为 .bak,操作后需重启程序。")) return;
|
||||
void api.clearConfig().then(
|
||||
(message) => onInfo(message),
|
||||
(err) => {
|
||||
@@ -797,7 +797,7 @@ export function SettingsPanel({
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (!window.confirm("退出程序?正在进行的录制会先收尾。")) return;
|
||||
if (!window.confirm("退出程序?正在进行的录制将先结束并保存。")) return;
|
||||
void api.quit().catch((err) => {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
if (text.includes("密码")) onNeedsUnlock(text);
|
||||
@@ -813,7 +813,7 @@ export function SettingsPanel({
|
||||
<div className="drawer__tail">
|
||||
<span className="label">RMIC · 2026</span>
|
||||
<span className="drawer__tail-note">
|
||||
改完即生效并写盘;滑杆停手 350 ms 后落盘。
|
||||
修改立即生效并保存;滑杆停止调整 350 ms 后保存。
|
||||
</span>
|
||||
<span className="drawer__tail-version num">
|
||||
{VERSION_LABEL} · 线路协议 {BUILD.protocol}
|
||||
@@ -922,7 +922,7 @@ function Preview({ hud, team }: { hud: HudView; team: number }) {
|
||||
<div className="preview">
|
||||
<div className="preview__head">
|
||||
<span className="label">LIVE PREVIEW</span>
|
||||
<span className="preview__note">以下几项改完立刻在这里生效</span>
|
||||
<span className="preview__note">实时预览准星、网格与装甲条</span>
|
||||
</div>
|
||||
|
||||
<div className="preview__frame corners">
|
||||
@@ -975,8 +975,8 @@ function Calibration({
|
||||
<Switch checked={calibrating} label="拖动画面校准瞄准点" onChange={onCalibrating} />
|
||||
|
||||
<p className="sect__note">
|
||||
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是
|
||||
<span className="em">画面比例</span>,所以换分辨率、换窗口大小都不影响它。
|
||||
摄像头与枪管不同轴时,可将瞄准点调整到弹着点。偏移按
|
||||
<span className="em">画面比例</span>保存,更换分辨率或调整窗口大小后仍有效。
|
||||
</p>
|
||||
|
||||
<Field label="水平偏移">
|
||||
|
||||
@@ -83,7 +83,7 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
|
||||
maxLength={MAX_CHARS}
|
||||
disabled={disabled}
|
||||
placeholder={DEFAULT_TEAM_NAMES[team] ?? "队伍"}
|
||||
title="改完按回车或点到别处生效;Esc 撤销;留空回落到默认名"
|
||||
title="回车或移开焦点保存;Esc 取消;留空使用默认队名"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onFocus={() => {
|
||||
focused.current = true;
|
||||
@@ -106,7 +106,7 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
|
||||
|
||||
<span className="tname__hint" aria-hidden="true">
|
||||
<span className="tname__keys">
|
||||
<kbd>⏎</kbd>存 <kbd>⎋</kbd>撤
|
||||
<kbd>⏎</kbd>保存 <kbd>⎋</kbd>取消
|
||||
</span>
|
||||
<span className="tname__count num">
|
||||
{draft.length}/{MAX_CHARS}
|
||||
|
||||
@@ -63,14 +63,14 @@ export function TopBar({
|
||||
<button
|
||||
type="button"
|
||||
className="tag tag--warn tag--button"
|
||||
title="所有画面与血量都是本机合成的假数据,不代表场上的任何一块板子。"
|
||||
title="画面与血量由本机合成,不代表现场板卡状态。"
|
||||
onClick={() =>
|
||||
pushNote(
|
||||
"演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。",
|
||||
"演示模式:画面与血量由本机合成。截图和验证时请注明为演示数据。",
|
||||
)
|
||||
}
|
||||
>
|
||||
演练 · 合成数据
|
||||
演示 · 合成数据
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function TopBar({
|
||||
<div className="topbar__cluster topbar__cluster--center">{children}</div>
|
||||
|
||||
<div className="topbar__cluster topbar__cluster--end">
|
||||
<span className="topbar__clients num" title="连着这个后端的界面数">
|
||||
<span className="topbar__clients num" title="连接本机服务的界面数">
|
||||
{snapshot.clients}
|
||||
<span className="topbar__clients-unit">UI</span>
|
||||
</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
if (ok) {
|
||||
onUnlocked();
|
||||
} else {
|
||||
setError("密码不对");
|
||||
setError("密码错误");
|
||||
setPassword("");
|
||||
input.current?.focus();
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
{error && <p className="lock__error">{error}</p>}
|
||||
|
||||
<p className="lock__note">
|
||||
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
|
||||
忘了密码就删掉配置里的 password 一行。
|
||||
密码用于防止现场误改设置;能修改本机配置或程序的人仍可绕过。
|
||||
忘记密码时,可删除配置文件中的 password 一行。
|
||||
</p>
|
||||
|
||||
<div className="lock__actions">
|
||||
@@ -99,7 +99,7 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || password.length === 0}
|
||||
>
|
||||
{busy ? "校验中…" : "校验并解锁"}
|
||||
{busy ? "校验中…" : "解锁"}
|
||||
<span className="btn__key">⏎</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -326,7 +326,7 @@ export function VideoSurface({
|
||||
className="vsurface__reset"
|
||||
onClick={() => onView(IDENTITY_VIEW)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title="复位缩放与平移(也可以双击画面)"
|
||||
title="重置缩放与平移(也可双击画面)"
|
||||
>
|
||||
<span className="vsurface__reset-key">⤢</span>
|
||||
复位
|
||||
@@ -336,7 +336,7 @@ export function VideoSurface({
|
||||
{calibrating && (
|
||||
<div className="vsurface__calibrating">
|
||||
<span className="dot dot--live" />
|
||||
校准中 · 拖动画面挪动瞄准点
|
||||
校准中 · 拖动画面调整瞄准点
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ function handle(command: string, args: Record<string, unknown>): unknown {
|
||||
/** 预览里模拟后端的密码闸门,好让解锁流程也能被看到。 */
|
||||
function guard() {
|
||||
if (state.role === "team" && state.password !== null && !state.unlocked) {
|
||||
throw new Error("这个改动需要密码。先解锁(预览模式里密码是 1234)。");
|
||||
throw new Error("修改设置需要密码。请先解锁,预览模式密码为 1234。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ function snapshot(): Snapshot {
|
||||
// 两边都给:用户端也要显示对手的血量(画面右上角那一条)。
|
||||
health: [healthFor(0), healthFor(1)],
|
||||
recordings: recordings(),
|
||||
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
|
||||
notes: ["浏览器预览:状态与画面均为合成数据,不代表真实链路。"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
*/
|
||||
|
||||
export const BUILD = {
|
||||
version: "0.2.1",
|
||||
version: "0.2.2",
|
||||
major: 0,
|
||||
minor: 2,
|
||||
patch: 1,
|
||||
patch: 2,
|
||||
/** git 提交数,单调递增。 */
|
||||
build: 37,
|
||||
build: 38,
|
||||
/** 线路协议版本。与固件自报的那个数直接比对。 */
|
||||
protocol: 1,
|
||||
/** 生成时的提交日期(`YYYY-MM-DD`)。 */
|
||||
|
||||
@@ -299,7 +299,7 @@ function FlashGauge({
|
||||
<span className={`mnt-gauge__hint ${plan.canFlash ? "dim" : "danger"}`}>
|
||||
{plan.canFlash
|
||||
? `计划就绪 · ${plan.candidates.length} 个候选槽位,按顺序尝试`
|
||||
: "计划不可烧录,请先处理左侧警告。"}
|
||||
: "当前计划无法烧录,请先处理计划中的警告。"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mnt-gauge__hint dim">生成计划后,这里显示目标槽位与实时进度。</span>
|
||||
@@ -791,7 +791,7 @@ export function MaintenancePage({
|
||||
</div>
|
||||
{device && (
|
||||
<Tag tone={securityEnabled ? "danger" : "ok"}>
|
||||
{securityEnabled ? "安全启动/加密已启用" : "可安全烧录"}
|
||||
{securityEnabled ? "安全启动/加密已启用" : "安全启动/加密未启用"}
|
||||
</Tag>
|
||||
)}
|
||||
</header>
|
||||
@@ -894,7 +894,7 @@ export function MaintenancePage({
|
||||
<h2 className="mnt-sec__title">官方固件包</h2>
|
||||
<span className="mnt-sec__caption label">BUNDLE / PACKAGE</span>
|
||||
</div>
|
||||
{bundle && <Tag tone="ok">SHA 已校验</Tag>}
|
||||
{bundle && <Tag tone="ok">完整性已校验</Tag>}
|
||||
</header>
|
||||
|
||||
<div className="mnt-sec__body">
|
||||
@@ -909,7 +909,7 @@ export function MaintenancePage({
|
||||
|
||||
{!bundle && (
|
||||
<p className="mnt-hint dim">
|
||||
选择编译后打包的 <code>.zip</code> 固件包。客户端只接受带 SHA256 清单的官方包;旧目录包仍可读取。
|
||||
选择编译后生成的 <code>.zip</code> 官方固件包,包内需包含清单和 SHA256 校验值。也可读取旧目录包。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export function MaintenancePage({
|
||||
/>
|
||||
<span className="mnt-check__box" aria-hidden="true" />
|
||||
<span className="mnt-check__text">
|
||||
备份旧 App(多读约{" "}
|
||||
备份旧 App(额外读取约{" "}
|
||||
{device
|
||||
? formatBytes(
|
||||
device.partitionTable.find((entry) => entry.name === "factory")?.size ??
|
||||
@@ -1145,7 +1145,7 @@ export function MaintenancePage({
|
||||
{logs.length === 0 ? (
|
||||
<div className="mnt-terminal__line">
|
||||
<span className="mnt-terminal__no num">00</span>
|
||||
<span className="mnt-terminal__text mnt-terminal__empty dim">(还没有操作)</span>
|
||||
<span className="mnt-terminal__text mnt-terminal__empty dim">尚未执行操作</span>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((line, index) => {
|
||||
|
||||
@@ -220,7 +220,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
||||
className="ref__splitter"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="拖动调整两格的宽度,双击回到五五开"
|
||||
title="拖动调整两路画面宽度,双击恢复等宽"
|
||||
onPointerDown={beginDrag("split")}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
@@ -439,7 +439,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
||||
left={
|
||||
<span className="statusbar__state">
|
||||
<span className={`dot ${snapshot.clients > 0 ? "dot--live" : "dot--warn"}`} />
|
||||
两路并看 · 鼠标停在哪一路,滚轮就缩哪一路
|
||||
同时查看两路画面 · 滚轮缩放鼠标所在的画面
|
||||
</span>
|
||||
}
|
||||
center={
|
||||
@@ -472,7 +472,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
|
||||
}
|
||||
right={
|
||||
<span className="statusbar__hint">
|
||||
{scope === "all" ? "按 1 / 2 盯住某一路" : `正在看 ${scope === "a" ? "左路" : "右路"} · 按 0 收回全部`}
|
||||
{scope === "all" ? "按 1 / 2 查看单路" : `正在看 ${scope === "a" ? "左路" : "右路"} · 按 0 显示全部`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 看到"的两个数,放在画面之外意味着视线要离开取景;
|
||||
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
|
||||
* - 被打中时四周泛红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`);
|
||||
* - 一方归零之后,画面正中给出「胜利 / 击败」。
|
||||
* - 一方归零之后,画面正中给出「胜利 / 落败」。
|
||||
*
|
||||
* # 这一页最顺手的操作是什么
|
||||
*
|
||||
@@ -94,8 +94,8 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
<span className="standby__code label">NO CAMERA BOUND</span>
|
||||
<h2 className="standby__title">还没有绑定图传板</h2>
|
||||
<p className="standby__text">
|
||||
用户端要先选一块图传板(就是装在这台车上的那一块)。绑定会写进配置文件,
|
||||
重启后仍然生效;以后要改需要密码。
|
||||
请选择安装在本车上的图传板。绑定会保存到配置文件,
|
||||
重启后仍有效;修改绑定需要密码。
|
||||
</p>
|
||||
<button type="button" className="btn btn--primary btn--lg" onClick={onOpenSettings}>
|
||||
选择图传板
|
||||
@@ -119,7 +119,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
camera={camera}
|
||||
label={`CAM · ${cameraText}`}
|
||||
emptyText={
|
||||
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:这块板子还没广播"
|
||||
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:尚未收到板卡广播"
|
||||
}
|
||||
mirrored={snapshot.mirrored}
|
||||
crosshair={hud.crosshair}
|
||||
@@ -179,9 +179,9 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
<div className="fwfault" role="alert">
|
||||
<span className="fwfault__tag label">固件不匹配</span>
|
||||
<span className="fwfault__text">
|
||||
这块板子自报固件 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
|
||||
<b>{boundCamera.protocol}</b>;本客户端要协议 <b>{BUILD.protocol}</b>。
|
||||
它的画面会被丢弃——烧录一版与之匹配的固件,或者换一份对应版本的客户端。
|
||||
板卡固件版本为 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
|
||||
<b>{boundCamera.protocol}</b>;本客户端使用协议 <b>{BUILD.protocol}</b>。
|
||||
画面无法接收。请烧录协议匹配的固件,或使用对应版本的客户端。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -192,7 +192,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
left={
|
||||
<span className="statusbar__state">
|
||||
<span className={`dot ${stats?.online ? "dot--live" : "dot--dead"}`} />
|
||||
{stats?.status ?? "还没有这一路的数据"}
|
||||
{stats?.status ?? "尚未收到这一路的数据"}
|
||||
</span>
|
||||
}
|
||||
center={
|
||||
@@ -223,7 +223,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
||||
right={
|
||||
<span className="statusbar__hint">
|
||||
{calibrating
|
||||
? "校准中 · 拖动画面挪动瞄准点"
|
||||
? "校准中 · 拖动画面调整瞄准点"
|
||||
: "滚轮 缩放 · 拖动 平移 · 双击 复位"}
|
||||
</span>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user