Files
ESP32S3_vt/desktop/src/components/SettingsPanel.tsx
T
Horldsence 7fdebe3ec7 desktop: 修两处小问题(预览模式的命中节奏、设置面板里漏出来的 **)
- 浏览器预览的掉血脚本写成"打空就重置,然后按 to >= from 判断"——两块板子都打空之后
  每一次都是重置,命中再也不出现,盯着界面等闪红会等到超时。现在打空那一拍只重置、
  其余每一拍都出命中。
- 设置面板里"偏移存的是**画面比例**"直接写进了 JSX,`**` 原样显示出来了。JSX 不渲染
  Markdown,改成 <span class="em">。

顺带把界面截图落到 desktop/screenshots/(不进仓库):这个环境里 Page.captureScreenshot
不可用(空白页也超时),改用 Page.printToPDF + sips 转 PNG;打印路径不渲染 box-shadow
的模糊半径(会变成实心块),所以 .dev/shoot.js 在拍之前把它去掉。
2026-10-14 19:37:59 +08:00

768 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 设置抽屉。两端的"改东西"都收在这里,所以密码闸门也只有一个入口。
*
* # 打开面板 ≠ 解锁
*
* 面板本身由 `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<HudView | null>(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<ConfigView>) => {
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 (
<aside className="drawer">
<header className="drawer__head">
<h2 className="drawer__title">设置</h2>
<div className="drawer__head-actions">
{config.role === "team" && (
<Button
variant="ghost"
onClick={() => {
void api.lock().then(() => {
onInfo("已重新上锁。");
onClose();
});
}}
>
上锁
</Button>
)}
<Button variant="ghost" onClick={onClose}>
关闭
</Button>
</div>
</header>
{locked && (
<p className="drawer__locked">
这个面板的改动需要密码。<button onClick={() => onNeedsUnlock("改动设置需要密码")}>立即解锁</button>
</p>
)}
<div className="drawer__body">
<Section title="身份">
<Field label="这台机器是哪一端">
<Select
value={config.role}
options={[
{ value: "team", label: "用户端", hint: "一路画面 + 血条 + 瞄准器" },
{ value: "referee", label: "裁判端", hint: "两路 + 双方血量 + 录制" },
]}
onChange={(role) => void run(() => api.setRole(role))}
/>
</Field>
<p className="section__note">
离开用户端要密码:否则切到裁判端就能看两路画面、也能随便改配置。
</p>
</Section>
{config.role === "team" && (
<>
<Section
title="绑定"
hint="图传板与装甲板都用 MAC 派生的 id 标识,重启不变,所以绑定是稳的。改动需要密码。"
>
<Field label="摄像头(图传板)">
<Select
value={config.cameraId}
options={cameraOptions}
onChange={(id) => void run(() => api.bindCamera(id))}
/>
</Field>
<Field label="装甲板主板" hint="决定血条读谁的血量。下拉里是场上正在广播的板子。">
<Select
value={config.armorId}
options={armorOptions}
onChange={(id) => void run(() => api.bindArmor(id))}
/>
</Field>
<Field label="自己属于哪一边" hint="改错队号会让血条显示对手的血量。">
<Select
value={config.team}
options={[
{ value: 0, label: "队伍一(红)" },
{ value: 1, label: "队伍二(蓝)" },
]}
onChange={(team) => void run(() => api.setTeam(team))}
/>
</Field>
<Switch
checked={config.mirror}
label="左右镜像画面(摄像头装反了)"
onChange={(mirror) => void run(() => api.setMirror(mirror))}
/>
{cameraOptions.length === 0 && (
<p className="section__note section__note--warn">
现在没有发现任何图传板。板子每 2 秒广播一次,确认它已上电、和这台机器在同一个
网络里,然后稍等。
</p>
)}
</Section>
<Section title="瞄准器" hint="位置跟着画面走,大小不跟着缩放走——放大后准星更精确。">
<Switch
checked={hud.crosshair.visible}
label="显示瞄准器"
onChange={(visible) =>
update((draft) => {
draft.crosshair.visible = visible;
})
}
/>
<Field label="样式">
<Select
value={hud.crosshair.style}
options={[
{ value: "cross", label: "四臂十字" },
{ value: "cross-dot", label: "十字 + 中心点" },
{ value: "circle-dot", label: "圆环 + 中心点" },
{ value: "dot", label: "只有点" },
]}
onChange={(style) =>
update((draft) => {
draft.crosshair.style = style;
})
}
/>
</Field>
<Field label="大小" hint="画面短边的比例">
<Slider
value={hud.crosshair.size}
min={0.01}
max={0.2}
step={0.005}
format={(value) => `${(value * 100).toFixed(1)}%`}
onChange={(size) =>
update((draft) => {
draft.crosshair.size = size;
})
}
/>
</Field>
<Field label="线宽">
<Slider
value={hud.crosshair.thickness}
min={0.001}
max={0.02}
step={0.0005}
format={(value) => value.toFixed(4)}
onChange={(thickness) =>
update((draft) => {
draft.crosshair.thickness = thickness;
})
}
/>
</Field>
<Field label="中心空隙">
<Slider
value={hud.crosshair.gap}
min={0}
max={0.1}
step={0.002}
format={(value) => `${(value * 100).toFixed(1)}%`}
onChange={(gap) =>
update((draft) => {
draft.crosshair.gap = gap;
})
}
/>
</Field>
<Field label="颜色">
<input
className="color"
type="color"
value={hud.crosshair.color}
onChange={(event) =>
update((draft) => {
draft.crosshair.color = event.target.value;
})
}
/>
</Field>
<Calibration
crosshair={hud.crosshair}
calibrating={calibrating}
onCalibrating={onCalibrating}
onChange={(offsetX, offsetY) =>
update((draft) => {
draft.crosshair.offsetX = offsetX;
draft.crosshair.offsetY = offsetY;
})
}
/>
</Section>
<Section title="叠加与提示">
<Switch
checked={hud.grid.visible}
label="显示网格"
onChange={(visible) =>
update((draft) => {
draft.grid.visible = visible;
})
}
/>
{hud.grid.visible && (
<>
<Field label="网格样式">
<Select
value={hud.grid.style}
options={[
{ value: "thirds", label: "三分线" },
{ value: "squares", label: "等分方格" },
{ value: "cross", label: "中心十字" },
]}
onChange={(style) =>
update((draft) => {
draft.grid.style = style;
})
}
/>
</Field>
{hud.grid.style === "squares" && (
<Field label="等分数">
<Slider
value={hud.grid.divisions}
min={2}
max={16}
step={1}
format={(value) => `${value} 格`}
onChange={(divisions) =>
update((draft) => {
draft.grid.divisions = divisions;
})
}
/>
</Field>
)}
<Field label="网格明显程度">
<Slider
value={hud.grid.opacity}
min={0.05}
max={1}
step={0.01}
format={(value) => value.toFixed(2)}
onChange={(opacity) =>
update((draft) => {
draft.grid.opacity = opacity;
})
}
/>
</Field>
</>
)}
<Switch
checked={hud.showZoom}
label="显示放大倍率"
onChange={(showZoom) =>
update((draft) => {
draft.showZoom = showZoom;
})
}
/>
<Switch
checked={hud.hitFeedback.flash}
label="被打中时闪红"
onChange={(flash) =>
update((draft) => {
draft.hitFeedback.flash = flash;
})
}
/>
<Switch
checked={hud.hitFeedback.shake}
label="被打中时画面抖动"
onChange={(shake) =>
update((draft) => {
draft.hitFeedback.shake = shake;
})
}
/>
<Switch
checked={hud.hitFeedback.marker}
label="在瞄点旁显示伤害数字"
onChange={(marker) =>
update((draft) => {
draft.hitFeedback.marker = marker;
})
}
/>
<Field label="反馈强度">
<Slider
value={hud.hitFeedback.intensity}
min={0.2}
max={2}
step={0.1}
format={(value) => `${value.toFixed(1)}×`}
onChange={(intensity) =>
update((draft) => {
draft.hitFeedback.intensity = intensity;
})
}
/>
</Field>
</Section>
<Section title="血条">
<Switch
checked={hud.health.visible}
label="显示血条"
onChange={(visible) =>
update((draft) => {
draft.health.visible = visible;
})
}
/>
<Switch
checked={hud.health.showNumbers}
label="显示血量数字"
onChange={(showNumbers) =>
update((draft) => {
draft.health.showNumbers = showNumbers;
})
}
/>
<Switch
checked={hud.health.showTeamName}
label="显示队伍名"
onChange={(showTeamName) =>
update((draft) => {
draft.health.showTeamName = showTeamName;
})
}
/>
<Switch
checked={hud.health.pulse}
label="低血时脉动"
onChange={(pulse) =>
update((draft) => {
draft.health.pulse = pulse;
})
}
/>
<Field label="低血阈值">
<Slider
value={hud.health.lowThreshold}
min={5}
max={100}
step={5}
format={(value) => `${value} 血`}
onChange={(lowThreshold) =>
update((draft) => {
draft.health.lowThreshold = lowThreshold;
})
}
/>
</Field>
<Field label="血条宽度" hint="占画面宽度的比例">
<Slider
value={hud.health.width}
min={0.15}
max={0.9}
step={0.01}
format={(value) => `${(value * 100).toFixed(0)}%`}
onChange={(width) =>
update((draft) => {
draft.health.width = width;
})
}
/>
</Field>
</Section>
</>
)}
{config.role === "referee" && (
<Section
title="裁判端槽位"
hint="默认按装甲板自报的队伍自动分左右。有烧错固件的板子时打开手动指定。"
>
<Switch
checked={snapshot.slots.manual}
label="手动指定槽位"
onChange={(manual) =>
void run(() =>
api.setRefereeSlots({
cameraA: snapshot.slots.cameraA,
cameraB: snapshot.slots.cameraB,
armorA: snapshot.slots.armorA,
armorB: snapshot.slots.armorB,
manual,
}),
)
}
/>
{snapshot.slots.manual && (
<>
<Field label="左格:队伍一摄像头">
<Select
value={snapshot.slots.cameraA}
options={cameraOptions}
onChange={(cameraA) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, cameraA, manual: true }),
)
}
/>
</Field>
<Field label="左格:队伍一装甲板">
<Select
value={snapshot.slots.armorA}
options={armorOptions}
onChange={(armorA) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, armorA, manual: true }),
)
}
/>
</Field>
<Field label="右格:队伍二摄像头">
<Select
value={snapshot.slots.cameraB}
options={cameraOptions}
onChange={(cameraB) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, cameraB, manual: true }),
)
}
/>
</Field>
<Field label="右格:队伍二装甲板">
<Select
value={snapshot.slots.armorB}
options={armorOptions}
onChange={(armorB) =>
void run(() =>
api.setRefereeSlots({ ...snapshot.slots, armorB, manual: true }),
)
}
/>
</Field>
</>
)}
</Section>
)}
<Section title="录制与截图" hint="截图存 PNG,录制存 mp4(需要 ffmpeg)。">
<Field label="输出目录">
<input
className="input"
type="text"
defaultValue={config.recordingDir}
onBlur={(event) => {
const value = event.target.value.trim();
if (value !== config.recordingDir) {
void run(() => api.setRecordingOptions({ dir: value, ffmpeg: null, crf: null, preset: null }));
}
}}
/>
</Field>
<Field label="ffmpeg 路径" hint="默认从 PATH 找。找不到就录不了,但界面照常用。">
<input
className="input"
type="text"
defaultValue={config.ffmpeg}
onBlur={(event) => {
const value = event.target.value.trim();
if (value && value !== config.ffmpeg) {
void run(() => api.setRecordingOptions({ dir: null, ffmpeg: value, crf: null, preset: null }));
}
}}
/>
</Field>
<p className="section__note">
界面内回放要目录在资源协议的白名单里(默认的 ~/Movies/cam-desktop 就在)。
改到别处时回放会失败,界面上会给出"用系统播放器打开"的退路。
</p>
</Section>
<Section title="安全" hint="密码只挡现场随手改绑;配置文件与程序都在本机,愿意改的人绕得过去。">
<ChangePassword onDone={onInfo} onError={onError} />
<div className="row">
<Button
variant="danger"
onClick={() => {
if (!window.confirm("清空配置?旧文件会被改名成 .bak,然后需要重启程序。")) return;
void api.clearConfig().then(
(message) => onInfo(message),
(err) => {
const text = err instanceof Error ? err.message : String(err);
if (text.includes("密码")) onNeedsUnlock(text);
else onError(text);
},
);
}}
>
清空配置
</Button>
<Button
variant="danger"
onClick={() => {
if (!window.confirm("退出程序?正在进行的录制会先收尾。")) return;
void api.quit().catch((err) => {
const text = err instanceof Error ? err.message : String(err);
if (text.includes("密码")) onNeedsUnlock(text);
else onError(text);
});
}}
>
退出程序
</Button>
</div>
</Section>
</div>
</aside>
);
}
/** 瞄准器偏移:拖画面校准 + 数值微调 + 归零。 */
function Calibration({
crosshair,
calibrating,
onCalibrating,
onChange,
}: {
crosshair: CrosshairConfig;
calibrating: boolean;
onCalibrating: (active: boolean) => void;
onChange: (offsetX: number, offsetY: number) => void;
}) {
return (
<div className="calibration">
<Switch
checked={calibrating}
label="拖动画面校准瞄准点"
onChange={onCalibrating}
/>
<p className="section__note">
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是<span className="em">画面比例</span>
,所以换分辨率、换窗口大小都不影响它。
</p>
<Field label="水平偏移" hint={`${(crosshair.offsetX * 100).toFixed(2)}%`}>
<Slider
value={crosshair.offsetX}
min={-0.3}
max={0.3}
step={0.001}
format={(value) => `${(value * 100).toFixed(2)}%`}
onChange={(value) => onChange(value, crosshair.offsetY)}
/>
</Field>
<Field label="垂直偏移" hint={`${(crosshair.offsetY * 100).toFixed(2)}%`}>
<Slider
value={crosshair.offsetY}
min={-0.3}
max={0.3}
step={0.001}
format={(value) => `${(value * 100).toFixed(2)}%`}
onChange={(value) => onChange(crosshair.offsetX, value)}
/>
</Field>
<Button variant="ghost" onClick={() => onChange(0, 0)}>
偏移归零
</Button>
</div>
);
}
/** 改密码。 */
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 (
<div className="password-change">
<input
className="input"
type="password"
placeholder="当前密码"
autoComplete="off"
value={current}
onChange={(event) => setCurrent(event.target.value)}
/>
<input
className="input"
type="password"
placeholder="新密码(≥4 位)"
autoComplete="new-password"
value={fresh}
onChange={(event) => setFresh(event.target.value)}
/>
<Button
disabled={busy || fresh.length < 4}
onClick={() => {
setBusy(true);
void api
.setPassword(fresh, current)
.then(() => {
onDone("密码已更新。");
setCurrent("");
setFresh("");
})
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false));
}}
>
{busy ? "保存中…" : "修改密码"}
</Button>
</div>
);
}