feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- 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:
2026-10-19 02:40:08 +08:00
parent cc6b82d7ec
commit 896b21fe44
332 changed files with 16729 additions and 2765 deletions
+139 -81
View File
@@ -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