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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user