/** * 设置抽屉。两端的"改东西"都收在这里,所以密码闸门也只有一个入口。 * * # 打开面板 ≠ 解锁 * * 面板本身由 `App` 在**解锁之后**才挂载,但面板里的每个改动仍然各自过一次后端闸门 * (`bind_camera` 等都会校验)。所以即使有人把这里改成"不需要密码",配置也改不动。 * * # 发现列表按需放开 * * 挂载时调 `set_discovery_open(true)`,卸载时关掉。这是用户端唯一会列出别的图传板的 * 时候——**画面不会**因此进来,接收端那道闸门只管帧,不管发现(见 `cam_core::receiver`)。 */ import { useCallback, useEffect, useMemo, useState } from "react"; import { api } from "../lib/ipc"; import { teamName } from "../lib/format"; import type { ConfigView, CrosshairConfig, HudView, Snapshot, } from "../lib/types"; import { Button, Field, Section, Select, Slider, Switch } from "./controls"; interface Props { snapshot: Snapshot; hud: HudView; onHud: (hud: HudView) => void; calibrating: boolean; onCalibrating: (active: boolean) => void; onClose: () => void; onNeedsUnlock: (reason: string) => void; onError: (message: string) => void; onInfo: (message: string) => void; /** 配置变了(身份/绑定)。App 用它立刻刷新顶层状态。 */ onConfig: (config: ConfigView) => void; } /** HUD 的滑杆每动一下都会触发保存,节流一下,免得写盘几十次。 */ const SAVE_DEBOUNCE_MS = 350; export function SettingsPanel({ snapshot, hud, onHud, calibrating, onCalibrating, onClose, onNeedsUnlock, onError, onInfo, onConfig, }: Props) { const config = snapshot.config; const locked = !snapshot.unlocked && config.role !== "referee"; // 打开面板时放开发现列表(后端会校验是否已解锁)。 useEffect(() => { if (locked) return; void api.setDiscoveryOpen(true).catch((err) => { onNeedsUnlock(err instanceof Error ? err.message : String(err)); }); return () => { void api.setDiscoveryOpen(false).catch(() => undefined); }; }, [locked, onNeedsUnlock]); // HUD 的保存节流。滑杆一停就写。 const [pending, setPending] = useState(null); useEffect(() => { if (!pending) return; const timer = window.setTimeout(() => { setPending(null); void api.saveHud(pending).then(onConfig).catch((err) => { onError(err instanceof Error ? err.message : String(err)); }); }, SAVE_DEBOUNCE_MS); return () => window.clearTimeout(timer); }, [pending, onConfig, onError]); const update = useCallback( (change: (draft: HudView) => void) => { const next = structuredClone(hud); change(next); onHud(next); setPending(next); }, [hud, onHud], ); const run = async (action: () => Promise) => { try { onConfig(await action()); await api.refresh(); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("密码")) { onNeedsUnlock(message); return; } onError(message); } }; const cameraOptions = useMemo( () => snapshot.cameras.map((camera) => ({ value: camera.id, label: camera.idText, hint: `${camera.addr} · ${camera.online ? "在线" : "离线"}${ snapshot.stats.find((s) => s.camera === camera.id)?.hasFrames ? "" : " · 无画面" }`, })), [snapshot.cameras, snapshot.stats], ); const armorOptions = useMemo( () => snapshot.armorBoards.map((board) => ({ value: board.id, label: board.idText, hint: `${board.addr} · ${teamName(board.team)} · ${board.hp} 血 · ${ board.online ? "在线" : "离线" }`, })), [snapshot.armorBoards], ); return ( ); } /** 瞄准器偏移:拖画面校准 + 数值微调 + 归零。 */ function Calibration({ crosshair, calibrating, onCalibrating, onChange, }: { crosshair: CrosshairConfig; calibrating: boolean; onCalibrating: (active: boolean) => void; onChange: (offsetX: number, offsetY: number) => void; }) { return (

摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是画面比例 ,所以换分辨率、换窗口大小都不影响它。

`${(value * 100).toFixed(2)}%`} onChange={(value) => onChange(value, crosshair.offsetY)} /> `${(value * 100).toFixed(2)}%`} onChange={(value) => onChange(crosshair.offsetX, value)} />
); } /** 改密码。 */ function ChangePassword({ onDone, onError, }: { onDone: (message: string) => void; onError: (message: string) => void; }) { const [current, setCurrent] = useState(""); const [fresh, setFresh] = useState(""); const [busy, setBusy] = useState(false); return (
setCurrent(event.target.value)} /> setFresh(event.target.value)} />
); }