把桌面端从 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
77 lines
2.6 KiB
TypeScript
77 lines
2.6 KiB
TypeScript
/** 纯格式化函数。界面里所有"给人看的数字"都从这里过一遍。 */
|
|
|
|
/** `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})`;
|
|
}
|