feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形 - feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退 - feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页 - feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路 - feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码 - feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔 - feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写 - feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容 - feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因 - feat(firmware): 固件包版本与客户端同源,都从 version.json 读 - fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载 - fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页 - fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系 - fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失 - fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径 - fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录 - build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO - build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-* - docs: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
@@ -6,6 +6,12 @@
|
||||
* 界面内回放走 Tauri 的资源协议,而那条协议只对 `tauri.conf.json` 里
|
||||
* `assetProtocol.scope` 白名单的目录生效。默认录制目录在白名单里;如果现场把输出目录
|
||||
* 改到别处,`<video>` 会加载失败——那时退回到"用系统播放器打开",而不是让人以为文件坏了。
|
||||
*
|
||||
* # 为什么录制中是"条"而不是一行字
|
||||
*
|
||||
* 录制这件事最贵的错误是"以为在录,其实没在录"。所以正在录的每一路都占一条醒目的横条:
|
||||
* 跳动的红点、已写帧数、已经录了多久、写盘跟不上时丢了几帧。这些数字在录完之后就再也
|
||||
* 拿不到了,只能在当下看。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -14,7 +20,6 @@ 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;
|
||||
@@ -50,11 +55,16 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const shotTargets = [snapshot.slots.cameraA, snapshot.slots.cameraB].filter(
|
||||
(id): id is number => id !== null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="media-panel">
|
||||
<div className="media-panel__actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
<div className="media">
|
||||
<div className="media__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={busy || cameras.length === 0}
|
||||
title={cameras.length === 0 ? "还没有在线的摄像头" : undefined}
|
||||
onClick={() => {
|
||||
@@ -62,10 +72,13 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
onInfo("开始录制。停止之前请留在这台机器上——退出会先收尾再走。");
|
||||
}}
|
||||
>
|
||||
<span className="media__rec-glyph" aria-hidden="true" />
|
||||
录制全部在线画面
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || active.length === 0}
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([]));
|
||||
@@ -73,27 +86,34 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
}}
|
||||
>
|
||||
停止全部录制
|
||||
</Button>
|
||||
</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>
|
||||
))}
|
||||
<span className="media__divider" aria-hidden="true" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
{shotTargets.map((camera) => (
|
||||
<button
|
||||
key={camera}
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
guard(api.screenshot(camera));
|
||||
onInfo(
|
||||
`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(存在录制目录的 shots/ 下)。`,
|
||||
);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
截图
|
||||
<span className="num media__cam">0x{camera.toString(16).toUpperCase().padStart(8, "0")}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<span className="media__spacer" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--quiet"
|
||||
onClick={() => {
|
||||
void api
|
||||
.reveal(snapshot.config.recordingDir)
|
||||
@@ -101,89 +121,127 @@ export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||
}}
|
||||
>
|
||||
打开录制目录
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{active.length > 0 && (
|
||||
<div className="media-panel__active">
|
||||
<div className="media__strips">
|
||||
{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
|
||||
输入
|
||||
<div key={recording.camera} className="strip">
|
||||
<span className="strip__pulse" aria-hidden="true" />
|
||||
<span className="strip__label label">REC</span>
|
||||
<span className="strip__cam num">{recording.cameraText}</span>
|
||||
<span className="strip__stats num">
|
||||
{recording.framesWritten} 帧
|
||||
<span className="strip__dot" />
|
||||
{since(recording.startedMs)}
|
||||
<span className="strip__dot" />
|
||||
{recording.inputFps.toFixed(1)} fps 输入
|
||||
</span>
|
||||
{recording.framesDropped > 0 && (
|
||||
<span className="warn" title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。">
|
||||
<span
|
||||
className="strip__warn"
|
||||
title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。"
|
||||
>
|
||||
丢 {recording.framesDropped} 帧
|
||||
</span>
|
||||
)}
|
||||
{recording.error && <span className="danger">ffmpeg:{recording.error}</span>}
|
||||
<Button variant="ghost" onClick={() => { guard(api.stopRecording([recording.camera])); refresh(); }}>
|
||||
{recording.error && <span className="strip__error">ffmpeg:{recording.error}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={() => {
|
||||
guard(api.stopRecording([recording.camera]));
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="media-panel__list">
|
||||
{files.length === 0 && <p className="dim">录制目录里还没有文件。</p>}
|
||||
<div className="media__list">
|
||||
<div className="media__head">
|
||||
<span className="media__col media__col--kind">类型</span>
|
||||
<span className="media__col">文件</span>
|
||||
<span className="media__col media__col--size">大小</span>
|
||||
<span className="media__col media__col--acts">操作</span>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
))}
|
||||
{files.length === 0 ? (
|
||||
<div className="media__empty">录制目录里还没有文件。</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<div key={file.path} className="media__row">
|
||||
<span className="media__col media__col--kind">
|
||||
<span className={`media__kind media__kind--${file.kind}`}>
|
||||
{file.kind === "video" ? "MP4" : "PNG"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="media__col media__name" title={file.path}>
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
<span className="media__col media__col--size num">{bytes(file.bytes)}</span>
|
||||
|
||||
<span className="media__col media__col--acts">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--quiet"
|
||||
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
|
||||
type="button"
|
||||
className="btn btn--quiet"
|
||||
onClick={() => {
|
||||
void api
|
||||
.reveal(file.path)
|
||||
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
|
||||
}}
|
||||
>
|
||||
定位
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playing && (
|
||||
<div className="playback">
|
||||
<div className="playback__head">
|
||||
<span className="mono">{playing.name}</span>
|
||||
<Button variant="ghost" onClick={() => setPlaying(null)}>
|
||||
<span className="playback__label label">PLAYBACK</span>
|
||||
<span className="playback__name num">{playing.name}</span>
|
||||
<span className="playback__spacer" />
|
||||
<button type="button" className="btn btn--quiet" onClick={() => setPlaying(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{playbackFailed ? (
|
||||
<div className="playback__fallback">
|
||||
<p className="warn">
|
||||
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,或者
|
||||
直接看下面这一条)。
|
||||
<p className="playback__warn">
|
||||
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,
|
||||
或者直接看下面这一条)。
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => void api.openFile(playing.path).catch(() => undefined)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void api.openFile(playing.path).catch(() => undefined)}
|
||||
>
|
||||
用系统播放器打开
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
|
||||
Reference in New Issue
Block a user