/** * 应用外壳:连通道、拿状态、按身份分流、管住解锁流程。 * * # 状态来自哪里 * * 后端每 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(null); const [fatal, setFatal] = useState(null); const [hud, setHud] = useState(null); const [drawer, setDrawer] = useState(false); const [calibrating, setCalibrating] = useState(false); const [unlockReason, setUnlockReason] = useState(null); const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [info, setInfo] = useState(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 (

起不来

{fatal}

端口可能被另一个客户端占着(旧的 Slint 版或另一份本程序)。关掉那一份再试。

); } if (!snapshot) { return (

cam-desktop

{connected ? "正在等待后端的第一份状态…" : "正在连接后端…"}

); } if (!snapshot.config.roleChosen) { return ; } if (snapshot.role === "team" && !snapshot.config.passwordSet) { return ; } return (
{snapshot.role === "team" ? ( ) : ( )} {drawer && ( { setCalibrating(active); if (active) setDrawer(false); }} onClose={() => { setCalibrating(false); setDrawer(false); }} onNeedsUnlock={(reason) => needsUnlock(reason, { kind: "settings" })} onError={setError} onInfo={setInfo} onConfig={onConfig} /> )} {unlockReason && ( { setUnlockReason(null); setPending(null); }} /> )} {error && setError(null)} />} {info && !error && setInfo(null)} />} {/* 校准提示:视频区域在页面里,离开设置面板之后还能看到它。 */} {calibrating && (
校准中:拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
)}
); } /** * 通道看门狗:后端在推、前端没连上时给一句明确的话。 * * 这一条存在的理由很实际:画面不动时,第一件要分清的事是"后端没推"还是"前端没接上"。 * 后端快照里有 `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
{detail}
; }