feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
2026-10-19 02:40:08 +08:00
parent cc6b82d7ec
commit 896b21fe44
332 changed files with 16729 additions and 2765 deletions
+103 -4
View File
@@ -1,4 +1,4 @@
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
//! RMIC::2026 —— ESP32 图传与装甲板系统的桌面端。
//!
//! 一个进程、两套页面:
//!
@@ -23,7 +23,9 @@
mod commands;
mod demo;
mod version;
mod latency;
mod maintenance;
mod recording;
mod server;
mod state;
@@ -39,11 +41,15 @@ use tauri::{Manager, RunEvent, WindowEvent};
use state::AppState;
const USAGE: &str = "\
用法: cam-desktop [选项]
用法: RMIC::2026 [选项]
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
--config <路径> 配置文件位置
--latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告
--maintenance-probe <串口> 连接设备、打印芯片/分区表/App 信息后退出(会复位一次)
--maintenance-bundle <固件包> 只校验 .zip/目录包并打印清单,不连设备
--maintenance-plan <串口> <固件包> 打印烧录计划后退出(只读,不写 Flash)
--maintenance-write-test <串口> <固件包目录> <偏移> 写 App 并回读,不切分区表
--demo 演示模式:画面与血量都是本地合成的
--list 打印发现的摄像头与装甲板后退出
-h, --help 显示这段帮助
@@ -63,7 +69,7 @@ fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if let Err(err) = run() {
log::error!("{err}");
log::error!("{err:#}");
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
@@ -81,6 +87,10 @@ struct Args {
role: Option<Role>,
config: Option<PathBuf>,
latency_report: Option<PathBuf>,
maintenance_probe: Option<String>,
maintenance_bundle: Option<PathBuf>,
maintenance_plan: Option<(String, PathBuf)>,
maintenance_write_test: Option<(String, PathBuf, u32)>,
demo: bool,
list: bool,
help: bool,
@@ -110,6 +120,27 @@ impl Args {
"--latency-report" => {
args.latency_report = it.next().map(PathBuf::from);
}
"--maintenance-probe" => {
args.maintenance_probe = it.next();
}
"--maintenance-bundle" => {
args.maintenance_bundle = it.next().map(PathBuf::from);
}
"--maintenance-plan" => {
let port = it.next();
let bundle = it.next();
if let (Some(port), Some(bundle)) = (port, bundle) {
args.maintenance_plan = Some((port, PathBuf::from(bundle)));
}
}
"--maintenance-write-test" => {
let port = it.next();
let bundle = it.next();
let offset = it.next().and_then(|value| parse_offset(&value));
if let (Some(port), Some(bundle), Some(offset)) = (port, bundle, offset) {
args.maintenance_write_test = Some((port, PathBuf::from(bundle), offset));
}
}
other => {
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
// 但报错的代价(窗口起不来)远大于收益。
@@ -130,6 +161,39 @@ fn run() -> Result<(), Box<dyn Error>> {
return Ok(());
}
// 维护探测:不加载配置、不绑 UDP 端口,也不起窗口。现场可以用它确认串口和分区表。
if let Some(port) = &args.maintenance_probe {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let snapshot = maintenance::probe_snapshot(port)?;
println!("{}", serde_json::to_string_pretty(&snapshot)?);
return Ok(());
}
if let Some(bundle) = &args.maintenance_bundle {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let info = maintenance::inspect_bundle(bundle)?;
println!("{}", serde_json::to_string_pretty(&info)?);
return Ok(());
}
if let Some((port, bundle)) = &args.maintenance_plan {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let plan = maintenance::plan_snapshot(port, bundle)?;
println!("{}", serde_json::to_string_pretty(&plan)?);
return Ok(());
}
if let Some((port, bundle, offset)) = &args.maintenance_write_test {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let attempt = maintenance::write_test(port, bundle, *offset)?;
println!("{}", serde_json::to_string_pretty(&attempt)?);
return Ok(());
}
let config_path = args
.config
.clone()
@@ -228,11 +292,32 @@ fn run() -> Result<(), Box<dyn Error>> {
let listener_state = Arc::clone(&state);
// 日志第一行就报版本:现场拿到一台机器,最先要回答的两个问题就是"跑的是哪一版"
// 与"它和板子的协议对不对得上"。
{
let build = version::build_info();
log::info!(
"RMIC::2026 client {} (build {}, wire protocol {})",
build.version_text(),
build.build,
build.protocol
);
}
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.manage(Arc::clone(&state))
.manage(latency_recorder)
.manage(Arc::new(maintenance::MaintenanceState::default()))
.invoke_handler(tauri::generate_handler![
maintenance::maintenance_list_ports,
maintenance::maintenance_load_bundle,
maintenance::maintenance_probe,
maintenance::maintenance_plan,
maintenance::maintenance_flash,
maintenance::maintenance_restore_table,
maintenance::maintenance_cancel,
latency::latency_enabled,
latency::latency_sample,
commands::stream_info,
@@ -262,7 +347,13 @@ fn run() -> Result<(), Box<dyn Error>> {
commands::quit_app,
commands::refresh,
])
.setup(move |_app| {
.setup(move |app| {
let temp = app
.path()
.app_cache_dir()
.unwrap_or_else(|_| std::env::temp_dir())
.join("flash-tmp");
maintenance::set_temp_dir(temp);
listener_state.spawn_background();
Ok(())
})
@@ -313,6 +404,14 @@ fn run() -> Result<(), Box<dyn Error>> {
Ok(())
}
fn parse_offset(text: &str) -> Option<u32> {
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).ok()
} else {
text.parse::<u32>().ok()
}
}
/// 让 [`Config`] 在日志里可读。
///
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长