desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层: - desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按 发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。 - desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、 每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。 - protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来—— 装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选" 不需要改固件。 几个改对了的旧问题: - 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位 会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。 - 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出 几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。 - 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了" 会被读成"密码错了"。现在要求盐与摘要都在。 - 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。 现在先收尾上一段再开新的,并且重名往后排号。 - 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面** 不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。 测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用 ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/** 纯格式化函数。界面里所有"给人看的数字"都从这里过一遍。 */
|
||||
|
||||
/** `0x3D4D8A9A`。与 Rust 侧的 `snapshot::id_text` 输出一致。 */
|
||||
export function idText(id: number | null | undefined): string {
|
||||
if (id === null || id === undefined) return "—";
|
||||
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
/** 22000 → `21.5 kB`。 */
|
||||
export function bytes(value: number): string {
|
||||
if (value <= 0) return "—";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} kB`;
|
||||
return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** 毫秒 → 人话。 */
|
||||
export function elapsed(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined) return "—";
|
||||
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes < 60) return `${minutes} 分 ${seconds} 秒`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} 小时 ${minutes % 60} 分`;
|
||||
}
|
||||
|
||||
/** 录制时长:从开始到现在的墙钟。 */
|
||||
export function since(ms: number | null | undefined, now = Date.now()): string {
|
||||
if (!ms) return "—";
|
||||
return elapsed(now - ms);
|
||||
}
|
||||
|
||||
/** Unix 毫秒 → `14:03:22`。 */
|
||||
export function clockTime(ms: number | null | undefined): string {
|
||||
if (!ms) return "—";
|
||||
|
||||
const date = new Date(ms);
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 小数位固定的帧率。0 表示"还没读数",显示成 `—` 而不是 `0.0`。 */
|
||||
export function fps(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "—";
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
/** 队伍名。与 Rust 侧的 `team_name` 一致。 */
|
||||
export function teamName(team: number): string {
|
||||
return team === 0 ? "队伍一" : "队伍二";
|
||||
}
|
||||
|
||||
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
|
||||
export function teamColor(team: number): string {
|
||||
return team === 0 ? "#ff5f5f" : "#5aa9ff";
|
||||
}
|
||||
|
||||
/** 把 `#RRGGBB` 变成 `rgba(...)`。瞄准器颜色要带透明度时用。 */
|
||||
export function withAlpha(hex: string, alpha: number): string {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) return hex;
|
||||
|
||||
const value = parseInt(match[1], 16);
|
||||
const r = (value >> 16) & 0xff;
|
||||
const g = (value >> 8) & 0xff;
|
||||
const b = value & 0xff;
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
Reference in New Issue
Block a user