desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端

界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。

用户端:
- 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0;
- 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟,
  像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效;
- 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中:
  第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血");
- 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。

裁判端:
- 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放;
- 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位;
- 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。

密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的
解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。

其它:
- 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名,
  原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做);
- 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来;
- 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证);
- CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明
  JS 加载、IPC、WebSocket、React effect 四件事。

删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了
(槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名
同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。

测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
This commit is contained in:
2026-10-14 19:28:49 +08:00
parent a89d7ae5fb
commit 4f7a142a39
36 changed files with 4234 additions and 8739 deletions
+299
View File
@@ -0,0 +1,299 @@
/**
* 应用外壳:连通道、拿状态、按身份分流、管住解锁流程。
*
* # 状态来自哪里
*
* 后端每 200 ms 推一份完整快照,前端不轮询。唯一在本地维护的是 **HUD 草稿**:滑杆要即时
* 反馈,等 300 ms 的后端往返会很黏手。草稿改动后节流保存,保存的结果就是后端的配置。
*
* # 解锁流程
*
* 需要密码的动作有三处入口:打开设置面板、点关闭窗口(后端会推 `confirmQuit`)、以及
* 任何一次被后端拒绝的改动。三条路径都汇到同一个 `UnlockDialog`,解锁成功后执行挂起的
* 那个动作。后端才是真正的闸门,这里只负责把话说清楚。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { SettingsPanel } from "./components/SettingsPanel";
import { SetupPassword, RolePicker } from "./components/Gate";
import { Toast } from "./components/TopBar";
import { UnlockDialog } from "./components/UnlockDialog";
import { api } from "./lib/ipc";
import { startStream, streamClient, useAppStore } from "./lib/store";
import type { ConfigView, HudView, Snapshot } from "./lib/types";
import { RefereePage } from "./pages/RefereePage";
import { TeamPage } from "./pages/TeamPage";
/** 解锁之后要做的那件事。 */
type Pending = { kind: "settings" } | { kind: "quit" } | { kind: "retry"; run: () => void };
export default function App() {
const { snapshot, connected, confirmQuit } = useAppStore();
const [url, setUrl] = useState<string | null>(null);
const [fatal, setFatal] = useState<string | null>(null);
const [hud, setHud] = useState<HudView | null>(null);
const [drawer, setDrawer] = useState(false);
const [calibrating, setCalibrating] = useState(false);
const [unlockReason, setUnlockReason] = useState<string | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const hudInitialised = useRef(false);
// ---- 通道 ----
useEffect(() => {
void api.streamInfo().then(
(stream) => setUrl(stream.url),
(err) => setFatal(err instanceof Error ? err.message : String(err)),
);
}, []);
useEffect(() => {
if (!url) return;
const client = startStream(url);
return () => client.close();
}, [url]);
// ---- HUD 草稿 ----
useEffect(() => {
if (!snapshot || hudInitialised.current) return;
hudInitialised.current = true;
setHud(snapshot.config.hud);
}, [snapshot]);
const onConfig = useCallback((config: ConfigView) => {
// 绑定/身份改了:HUD 之外的东西都由快照带回来,这里只需要让 HUD 跟上(例如换了身份)。
setHud((current) => current ?? config.hud);
}, []);
/** 校准时拖动画面:偏移是按比例的,直接累加。 */
const onCalibrate = useCallback(
(dx: number, dy: number) => {
setHud((current) => {
if (!current) return current;
const clamp = (value: number) => Math.max(-0.5, Math.min(0.5, value));
const next: HudView = {
...current,
crosshair: {
...current.crosshair,
offsetX: clamp(current.crosshair.offsetX + dx),
offsetY: clamp(current.crosshair.offsetY + dy),
},
};
return next;
});
},
[],
);
// 校准改动也要落盘。和设置面板里的滑杆共用同一条节流路径。
useEffect(() => {
if (!hud || !calibrating) return;
const timer = window.setTimeout(() => {
void api.saveHud(hud).catch(() => undefined);
}, 600);
return () => window.clearTimeout(timer);
}, [hud, calibrating]);
// ---- 解锁 ----
const needsUnlock = useCallback(
(reason: string, next: Pending) => {
setUnlockReason(reason);
setPending(next);
},
[],
);
const openSettings = useCallback(() => {
if (!snapshot) return;
if (snapshot.role !== "referee" && !snapshot.unlocked) {
needsUnlock("改摄像头/装甲板绑定、切换队伍号都要密码。", { kind: "settings" });
return;
}
setDrawer(true);
}, [snapshot, needsUnlock]);
// 后端拦下了一次"关窗口/退出"。
useEffect(() => {
if (confirmQuit === 0) return;
needsUnlock("退出程序需要密码。", { kind: "quit" });
}, [confirmQuit, needsUnlock]);
const finishUnlock = useCallback(() => {
setUnlockReason(null);
const action = pending;
setPending(null);
switch (action?.kind) {
case "settings":
setDrawer(true);
break;
case "quit":
void api.quit().catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
);
break;
case "retry":
action.run();
break;
default:
break;
}
void api.refresh();
}, [pending]);
// ---- 窗口标题 ----
useEffect(() => {
if (!snapshot) return;
const suffix =
snapshot.role === "team" ? "用户端" : "裁判端";
const extra = snapshot.demo ? "(演示模式)" : "";
void import("@tauri-apps/api/window")
.then(({ getCurrentWindow }) => getCurrentWindow().setTitle(`cam-desktop — ${suffix}${extra}`))
.catch(() => undefined);
}, [snapshot?.role, snapshot?.demo]);
// ---- 分支 ----
if (fatal) {
return (
<div className="gate">
<h1 className="gate__title">起不来</h1>
<p className="gate__error">{fatal}</p>
<p className="gate__note">
端口可能被另一个客户端占着(旧的 Slint 版或另一份本程序)。关掉那一份再试。
</p>
</div>
);
}
if (!snapshot) {
return (
<div className="gate">
<h1 className="gate__title">cam-desktop</h1>
<p className="gate__hint">
{connected ? "正在等待后端的第一份状态…" : "正在连接后端…"}
</p>
</div>
);
}
if (!snapshot.config.roleChosen) {
return <RolePicker />;
}
if (snapshot.role === "team" && !snapshot.config.passwordSet) {
return <SetupPassword />;
}
return (
<div className="app">
{snapshot.role === "team" ? (
<TeamPage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
calibrating={calibrating}
onCalibrate={onCalibrate}
/>
) : (
<RefereePage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
onError={setError}
onInfo={setInfo}
/>
)}
{drawer && (
<SettingsPanel
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onHud={setHud}
calibrating={calibrating}
onCalibrating={(active) => {
setCalibrating(active);
if (active) setDrawer(false);
}}
onClose={() => {
setCalibrating(false);
setDrawer(false);
}}
onNeedsUnlock={(reason) => needsUnlock(reason, { kind: "settings" })}
onError={setError}
onInfo={setInfo}
onConfig={onConfig}
/>
)}
{unlockReason && (
<UnlockDialog
reason={unlockReason}
onUnlocked={finishUnlock}
onCancel={() => {
setUnlockReason(null);
setPending(null);
}}
/>
)}
{error && <Toast kind="error" message={error} onClose={() => setError(null)} />}
{info && !error && <Toast kind="info" message={info} onClose={() => setInfo(null)} />}
{/* 校准提示:视频区域在页面里,离开设置面板之后还能看到它。 */}
{calibrating && (
<div className="calibrate-banner">
校准中:拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
<button onClick={() => setCalibrating(false)}>结束校准</button>
</div>
)}
<ChannelWatchdog snapshot={snapshot} />
</div>
);
}
/**
* 通道看门狗:后端在推、前端没连上时给一句明确的话。
*
* 这一条存在的理由很实际:画面不动时,第一件要分清的事是"后端没推"还是"前端没接上"。
* 后端快照里有 `clients`,所以这两件事可以分开说。
*/
function ChannelWatchdog({ snapshot }: { snapshot: Snapshot }) {
const { connected } = useAppStore();
if (connected) return null;
let detail = "正在重连后端的视频通道…";
try {
detail += `(已收到 ${streamClient().counters.frames} 帧,重连 ${streamClient().counters.reconnects} 次)`;
} catch {
// 通道还没建起来。
}
if (snapshot.clients === 0 && snapshot.uptimeMs > 5000) {
detail += " 后端报告没有任何界面连着,这一条是前端侧的问题。";
}
return <div className="channel-warning">{detail}</div>;
}