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:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 裁判端的装甲板发现列表。
|
||||
*
|
||||
* 场上到底连上了几块板子、分别自报哪一队、现在多少血、多久没广播了——现场"裁判端没显示
|
||||
* 血量"时,这一张表就是第一眼看的地方:它能区分"板子根本没上线"、"上线了但队伍号烧反了"
|
||||
* 和"只有一条上报在发"。
|
||||
*/
|
||||
|
||||
import { elapsed, teamColor, teamName } from "../lib/format";
|
||||
import type { ArmorBoardDto, SlotsDto } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
boards: ArmorBoardDto[];
|
||||
slots: SlotsDto;
|
||||
}
|
||||
|
||||
export function ArmorTable({ boards, slots }: Props) {
|
||||
return (
|
||||
<div className="armor-table">
|
||||
<div className="armor-table__head">
|
||||
<span>装甲板</span>
|
||||
<span>地址</span>
|
||||
<span>自报队伍</span>
|
||||
<span>血量</span>
|
||||
<span>四条</span>
|
||||
<span>静默</span>
|
||||
<span>槽位</span>
|
||||
</div>
|
||||
|
||||
{boards.length === 0 && (
|
||||
<div className="armor-table__empty">
|
||||
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在同一个
|
||||
网络里。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{boards.map((board) => {
|
||||
const assigned =
|
||||
slots.armorA === board.id ? "左(队伍一)" : slots.armorB === board.id ? "右(队伍二)" : "—";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={board.id}
|
||||
className={`armor-table__row${board.online ? "" : " armor-table__row--offline"}`}
|
||||
>
|
||||
<span className="mono">{board.idText}</span>
|
||||
<span className="mono dim">{board.addr}</span>
|
||||
<span style={{ color: teamColor(board.team) }}>{teamName(board.team)}</span>
|
||||
<span className={`mono${board.hp === 0 ? " danger" : ""}`}>{board.hp}</span>
|
||||
<span className={board.reportsAllPlates ? "dim" : "warn"}>
|
||||
{board.platesSeen}/4
|
||||
{!board.reportsAllPlates && " ⚠"}
|
||||
</span>
|
||||
<span className="dim">{board.online ? `${elapsed(board.silenceMs)}前` : "离线"}</span>
|
||||
<span className="dim">{assigned}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行只读读数,裁判端每格下面用。 */
|
||||
export function StatsLine({
|
||||
stats,
|
||||
diagnostics,
|
||||
}: {
|
||||
stats:
|
||||
| {
|
||||
status: string;
|
||||
online: boolean;
|
||||
hasFrames: boolean;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
framesShown: number;
|
||||
framesDropped: number;
|
||||
lossPercent: number;
|
||||
datagrams: number;
|
||||
lastFrameBytes: number;
|
||||
gapMs: number | null;
|
||||
}
|
||||
| undefined;
|
||||
diagnostics: boolean;
|
||||
}) {
|
||||
if (!stats) {
|
||||
return <div className="stats-line stats-line--empty">还没有这一路的数据</div>;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (diagnostics) {
|
||||
if (stats.hasFrames) {
|
||||
parts.push(`${stats.fps.toFixed(1)} fps`);
|
||||
parts.push(`${stats.width}×${stats.height}`);
|
||||
parts.push(`${(stats.lastFrameBytes / 1024).toFixed(1)} kB/帧`);
|
||||
parts.push(`丢 ${stats.framesDropped}(${stats.lossPercent.toFixed(1)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-line">
|
||||
<span className={stats.online ? "stats-line__status" : "stats-line__status warn"}>
|
||||
{stats.status}
|
||||
</span>
|
||||
{parts.length > 0 && <span className="dim">{parts.join(" · ")}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,8 +48,15 @@ export function HealthBar({ health, config, compact = false }: Props) {
|
||||
const total = 100;
|
||||
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
|
||||
|
||||
// 宽度按配置收窄并居中。它挂在画面**下面**,所以不需要通栏——通栏反而像一条分隔线,
|
||||
// 而不是一个可以一眼扫到的仪表。
|
||||
const width = `${Math.round(config.width * 100)}%`;
|
||||
|
||||
return (
|
||||
<div className={`healthbar${compact ? " healthbar--compact" : ""}`}>
|
||||
<div
|
||||
className={`healthbar${compact ? " healthbar--compact" : ""}`}
|
||||
style={compact ? undefined : { width, margin: "0 auto" }}
|
||||
>
|
||||
<div
|
||||
className="healthbar__track"
|
||||
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* 命中反馈的整屏层:闪红 + 中央提示。
|
||||
* 命中反馈的整屏层:闪红 + 顶部一条文字。
|
||||
*
|
||||
* 抖动与伤害数字由 `VideoSurface` 画在 canvas 上(它们要跟着瞄准点走),这里只负责
|
||||
* 那一层全屏的、不需要对准任何东西的视觉反馈。
|
||||
* # 大号数字刻意不放在画面正中
|
||||
*
|
||||
* 第一版把 `-7 / 100 → 93` 放在正中,结果它正好压在瞄准点上——被打中的那一下恰恰是最
|
||||
* 需要看着准星的时候。现在数字由 `VideoSurface` 画在**准星旁边**(跟着瞄准点走,不挡
|
||||
* 目标),这里只留闪红与顶部一条信息。
|
||||
*
|
||||
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
|
||||
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
|
||||
@@ -26,9 +29,9 @@ export function HitFlash({ hit, config, relevant, label }: Props) {
|
||||
|
||||
return (
|
||||
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
|
||||
{label && <div className="hit-flash__label">{label}</div>}
|
||||
<div className="hit-flash__value">
|
||||
-{hit.damage}
|
||||
<div className="hit-flash__banner">
|
||||
{label && <span className="hit-flash__label">{label}</span>}
|
||||
<span className="hit-flash__value">-{hit.damage}</span>
|
||||
<span className="hit-flash__sub">
|
||||
{hit.from} → {hit.to}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 录制、截图与录下来的东西。
|
||||
*
|
||||
* # 回放为什么可能失败
|
||||
*
|
||||
* 界面内回放走 Tauri 的资源协议,而那条协议只对 `tauri.conf.json` 里
|
||||
* `assetProtocol.scope` 白名单的目录生效。默认录制目录在白名单里;如果现场把输出目录
|
||||
* 改到别处,`<video>` 会加载失败——那时退回到"用系统播放器打开",而不是让人以为文件坏了。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
|
||||
import { api } from "../lib/ipc";
|
||||
import { bytes, since } from "../lib/format";
|
||||
import type { MediaFileDto, Snapshot } from "../lib/types";
|
||||
import { Button } from "./controls";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
onError: (message: string) => void;
|
||||
onInfo: (message: string) => void;
|
||||
}
|
||||
|
||||
export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
const [files, setFiles] = useState<MediaFileDto[]>([]);
|
||||
const [playing, setPlaying] = useState<MediaFileDto | null>(null);
|
||||
const [playbackFailed, setPlaybackFailed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void api.mediaFiles().then(setFiles, () => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// 录制中每 2 秒刷一次列表:刚录完的那一段应该自己出现。
|
||||
const timer = window.setInterval(refresh, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const active = snapshot.recordings.filter((recording) => recording.active);
|
||||
const cameras = snapshot.cameras.filter((camera) => camera.online).map((camera) => camera.id);
|
||||
|
||||
const guard = (action: Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
void action
|
||||
.then(() => api.refresh())
|
||||
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="media-panel">
|
||||
<div className="media-panel__actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || cameras.length === 0}
|
||||
title={cameras.length === 0 ? "还没有在线的摄像头" : undefined}
|
||||
onClick={() => {
|
||||
guard(api.startRecording(cameras));
|
||||
onInfo("开始录制。停止之前请留在这台机器上——退出会先收尾再走。");
|
||||
}}
|
||||
>
|
||||
录制全部在线画面
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={busy || active.length === 0}
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([]));
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
停止全部录制
|
||||
</Button>
|
||||
|
||||
{[snapshot.slots.cameraA, snapshot.slots.cameraB]
|
||||
.filter((id): id is number => id !== null)
|
||||
.map((camera) => (
|
||||
<Button
|
||||
key={camera}
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
guard(api.screenshot(camera));
|
||||
onInfo(`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(存在录制目录的 shots/ 下)。`);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
截图 {`0x${camera.toString(16).toUpperCase().padStart(8, "0")}`}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void api
|
||||
.reveal(snapshot.config.recordingDir)
|
||||
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
|
||||
}}
|
||||
>
|
||||
打开录制目录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{active.length > 0 && (
|
||||
<div className="media-panel__active">
|
||||
{active.map((recording) => (
|
||||
<div key={recording.camera} className="recording">
|
||||
<span className="recording__dot" />
|
||||
<span className="mono">{recording.cameraText}</span>
|
||||
<span>
|
||||
{recording.framesWritten} 帧 · {since(recording.startedMs)} · {recording.inputFps.toFixed(1)} fps
|
||||
输入
|
||||
</span>
|
||||
{recording.framesDropped > 0 && (
|
||||
<span className="warn" title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。">
|
||||
丢 {recording.framesDropped} 帧
|
||||
</span>
|
||||
)}
|
||||
{recording.error && <span className="danger">ffmpeg:{recording.error}</span>}
|
||||
<Button variant="ghost" onClick={() => { guard(api.stopRecording([recording.camera])); refresh(); }}>
|
||||
停止
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="media-panel__list">
|
||||
{files.length === 0 && <p className="dim">录制目录里还没有文件。</p>}
|
||||
|
||||
{files.map((file) => (
|
||||
<div key={file.path} className="media-row">
|
||||
<span className={`media-row__kind media-row__kind--${file.kind}`}>
|
||||
{file.kind === "video" ? "MP4" : "PNG"}
|
||||
</span>
|
||||
<span className="media-row__name" title={file.path}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="dim">{bytes(file.bytes)}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPlaybackFailed(false);
|
||||
setPlaying(file.kind === "video" ? file : null);
|
||||
if (file.kind === "image") {
|
||||
void api.openFile(file.path).catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{file.kind === "video" ? "播放" : "打开"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void api
|
||||
.reveal(file.path)
|
||||
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
|
||||
}}
|
||||
>
|
||||
在访达里显示
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{playing && (
|
||||
<div className="playback">
|
||||
<div className="playback__head">
|
||||
<span className="mono">{playing.name}</span>
|
||||
<Button variant="ghost" onClick={() => setPlaying(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{playbackFailed ? (
|
||||
<div className="playback__fallback">
|
||||
<p className="warn">
|
||||
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,或者
|
||||
直接看下面这一条)。
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => void api.openFile(playing.path).catch(() => undefined)}>
|
||||
用系统播放器打开
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
className="playback__video"
|
||||
src={convertFileSrc(playing.path)}
|
||||
controls
|
||||
autoPlay
|
||||
onError={() => setPlaybackFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
/**
|
||||
* 设置抽屉。两端的"改东西"都收在这里,所以密码闸门也只有一个入口。
|
||||
*
|
||||
* # 打开面板 ≠ 解锁
|
||||
*
|
||||
* 面板本身由 `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">
|
||||
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是**画面比例**,所以换分辨率、
|
||||
换窗口大小都不影响它。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 顶部状态条:身份、连接、演示模式、说明、以及进入设置的入口。
|
||||
*
|
||||
* 演示模式的标记**必须显眼**:那一整屏画面与血量都是本机合成的,截图或验收时把它当成
|
||||
* 真实链路会得出完全错误的结论。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { pushNote, useAppStore } from "../lib/store";
|
||||
import type { Snapshot } from "../lib/types";
|
||||
import { Button } from "./controls";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
onOpenSettings: () => void;
|
||||
onOpenMedia?: () => void;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
export function TopBar({ snapshot, onOpenSettings, onOpenMedia, extra }: Props) {
|
||||
const { connected, notes } = useAppStore();
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__left">
|
||||
<span className="topbar__brand">cam-desktop</span>
|
||||
<span className={`tag tag--${snapshot.role}`}>
|
||||
{snapshot.role === "team" ? "用户端" : "裁判端"}
|
||||
</span>
|
||||
|
||||
{snapshot.demo && (
|
||||
<button
|
||||
className="tag tag--demo tag--button"
|
||||
title="所有画面与血量都是本机合成的假数据,不代表场上的任何一块板子。"
|
||||
onClick={() =>
|
||||
pushNote("演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。")
|
||||
}
|
||||
>
|
||||
演示模式 · 合成画面
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!connected && <span className="tag tag--warn">通道断开,重连中</span>}
|
||||
|
||||
{snapshot.recordings.length > 0 && (
|
||||
<span className="tag tag--recording">
|
||||
<span className="recording__dot" /> 录制中 ×{snapshot.recordings.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{extra}
|
||||
</div>
|
||||
|
||||
<div className="topbar__right">
|
||||
{notes.slice(-1).map((note, index) => (
|
||||
<span key={index} className="topbar__note">
|
||||
{note}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<span className="dim mono" title="连着这个后端的界面数">
|
||||
界面 {snapshot.clients}
|
||||
</span>
|
||||
|
||||
{onOpenMedia && (
|
||||
<Button variant="ghost" onClick={onOpenMedia}>
|
||||
录制与回放
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" onClick={onOpenSettings}>
|
||||
设置
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** 浮动提示(错误/信息)。 */
|
||||
export function Toast({
|
||||
message,
|
||||
kind,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
kind: "info" | "error";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`toast toast--${kind}`} onClick={onClose} role="alert">
|
||||
<span>{message}</span>
|
||||
<span className="toast__close">✕</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user