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
+260 -84
View File
@@ -1,5 +1,5 @@
/**
* 应用外壳:连通道、拿状态、按身份分流、管住解锁流程。
* 应用外壳:启动序列、连通道、拿状态、按身份分流、管住解锁流程。
*
* # 状态来自哪里
*
@@ -11,23 +11,39 @@
* 需要密码的动作有三处入口:打开设置面板、点关闭窗口(后端会推 `confirmQuit`)、以及
* 任何一次被后端拒绝的改动。三条路径都汇到同一个 `UnlockDialog`,解锁成功后执行挂起的
* 那个动作。后端才是真正的闸门,这里只负责把话说清楚。
*
* # 外壳管什么
*
* 顶栏、通讯带、状态行、覆盖层——这些东西两端都要有,所以提到这一层。页面只负责自己的
* 舞台(一路或两路画面 + 底部遥测),不再各自画一套顶栏;这也让"两端看起来是同一个系统
* 的两个模式"这件事在结构上成立,而不是靠两处代码各自模仿。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { SettingsPanel } from "./components/SettingsPanel";
import { Boot } from "./components/Boot";
import { SetupPassword, RolePicker } from "./components/Gate";
import { Toast } from "./components/TopBar";
import { CommStrip, TopBar } from "./components/TopBar";
import { Toasts, type ToastItem } from "./components/Toasts";
import { UnlockDialog } from "./components/UnlockDialog";
import { api } from "./lib/ipc";
import { startStream, streamClient, useAppStore } from "./lib/store";
import { useFullscreen } from "./lib/use-fullscreen";
import type { ConfigView, HudView, Snapshot } from "./lib/types";
import { MaintenancePage } from "./pages/MaintenancePage";
import { RefereePage } from "./pages/RefereePage";
import { TeamPage } from "./pages/TeamPage";
import { SettingsPanel } from "./components/SettingsPanel";
/** 解锁之后要做的那件事。 */
type Pending = { kind: "settings" } | { kind: "quit" } | { kind: "retry"; run: () => void };
/** 启动序列表最少要在屏幕上待多久。太短会像闪了一下,读不出"它在自检"。 */
const BOOT_MIN_MS = 1150;
/** 序列完成后的淡出时长,与 `.boot.is-leaving` 的动画一致。 */
const BOOT_EXIT_MS = 340;
export default function App() {
const { snapshot, connected, confirmQuit } = useAppStore();
@@ -38,11 +54,97 @@ export default function App() {
const [calibrating, setCalibrating] = useState(false);
const [unlockReason, setUnlockReason] = useState<string | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [maintenance, setMaintenance] = useState(false);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const fullscreen = useFullscreen();
const hudInitialised = useRef(false);
// ---- 提示队列 ----
const pushToast = useCallback((kind: ToastItem["kind"], message: string) => {
const id = Date.now() + Math.random();
setToasts((current) => [...current.slice(-3), { id, kind, message }]);
}, []);
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
// 每条提示自己计时。错误留得久一点——现场是要照着那句话去查的。
useEffect(() => {
if (toasts.length === 0) return;
const timers = toasts.map((toast) =>
window.setTimeout(
() => dismissToast(toast.id),
toast.kind === "error" ? 12000 : 6000,
),
);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [toasts, dismissToast]);
const onError = useCallback((message: string) => pushToast("error", message), [pushToast]);
const onInfo = useCallback((message: string) => pushToast("info", message), [pushToast]);
// ---- 启动序列 ----
const [fontsReady, setFontsReady] = useState(false);
const [minElapsed, setMinElapsed] = useState(false);
const [bootGone, setBootGone] = useState(false);
const [bootLeaving, setBootLeaving] = useState(false);
useEffect(() => {
let cancelled = false;
const done = () => {
if (!cancelled) setFontsReady(true);
};
const fonts = document.fonts;
if (fonts?.ready) {
// 字体全部落盘之后界面才出现:否则中文会先画成系统黑体再跳一次,整块布局跟着重排。
fonts.ready.then(done, done);
} else {
done();
}
const timer = window.setTimeout(() => {
if (!cancelled) setMinElapsed(true);
}, BOOT_MIN_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, []);
const milestones = [
{ key: "fonts", label: "载入字体集", code: "HARMONYOS · JETBRAINS", done: fontsReady },
{ key: "channel", label: "建立后端通道", code: "LOCAL IPC", done: url !== null || !!fatal },
{ key: "snapshot", label: "接收首份状态", code: "SNAPSHOT 200ms", done: snapshot !== null || !!fatal },
];
const sequenceDone = milestones.every((item) => item.done);
const progress =
(fontsReady ? 0.32 : 0) + (url !== null || fatal ? 0.24 : 0) + (snapshot !== null || fatal ? 0.44 : 0);
// 两个 effect 而不是一个,是有原因的:把"开始淡出"和"淡出之后卸载"写进同一个 effect
// 时,`setBootLeaving(true)` 会让它自己重跑,React 在重跑之前先调用上一次的清理函数,
// 于是那个 `setTimeout` 在设置的同一帧里就被 `clearTimeout` 掉了——启动页永远停在那里
// 半透明地盖着整个界面。拆成两段之后,第二段只在 `bootLeaving` 变真时启动。
useEffect(() => {
if (!sequenceDone || !minElapsed) return;
setBootLeaving(true);
}, [sequenceDone, minElapsed]);
useEffect(() => {
if (!bootLeaving) return;
const timer = window.setTimeout(() => setBootGone(true), BOOT_EXIT_MS);
return () => window.clearTimeout(timer);
}, [bootLeaving]);
// ---- 通道 ----
useEffect(() => {
@@ -73,26 +175,21 @@ export default function App() {
}, []);
/** 校准时拖动画面:偏移是按比例的,直接累加。 */
const onCalibrate = useCallback(
(dx: number, dy: number) => {
setHud((current) => {
if (!current) return current;
const onCalibrate = useCallback((dx: number, dy: number) => {
setHud((current) => {
if (!current) return current;
const clamp = (value: number) => Math.max(-0.5, Math.min(0.5, value));
const next: HudView = {
...current,
crosshair: {
...current.crosshair,
offsetX: clamp(current.crosshair.offsetX + dx),
offsetY: clamp(current.crosshair.offsetY + dy),
},
};
return next;
});
},
[],
);
const clamp = (value: number) => Math.max(-0.5, Math.min(0.5, value));
return {
...current,
crosshair: {
...current.crosshair,
offsetX: clamp(current.crosshair.offsetX + dx),
offsetY: clamp(current.crosshair.offsetY + dy),
},
};
});
}, []);
// 校准改动也要落盘。和设置面板里的滑杆共用同一条节流路径。
useEffect(() => {
@@ -107,13 +204,10 @@ export default function App() {
// ---- 解锁 ----
const needsUnlock = useCallback(
(reason: string, next: Pending) => {
setUnlockReason(reason);
setPending(next);
},
[],
);
const needsUnlock = useCallback((reason: string, next: Pending) => {
setUnlockReason(reason);
setPending(next);
}, []);
const openSettings = useCallback(() => {
if (!snapshot) return;
@@ -143,9 +237,7 @@ export default function App() {
setDrawer(true);
break;
case "quit":
void api.quit().catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
);
void api.quit().catch((err) => onError(err instanceof Error ? err.message : String(err)));
break;
case "retry":
action.run();
@@ -155,74 +247,152 @@ export default function App() {
}
void api.refresh();
}, [pending]);
}, [pending, onError]);
// ---- 快捷键 ----
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const typing =
target?.tagName === "INPUT" ||
target?.tagName === "TEXTAREA" ||
target?.tagName === "SELECT" ||
target?.isContentEditable;
if (event.key === "Escape") {
if (unlockReason) {
setUnlockReason(null);
setPending(null);
} else if (drawer) {
setCalibrating(false);
setDrawer(false);
}
return;
}
if (typing) return;
// Ctrl/Cmd + , 打开设置:这是桌面软件的通用约定,现场不用去点顶栏。
if ((event.ctrlKey || event.metaKey) && event.key === ",") {
event.preventDefault();
openSettings();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [unlockReason, drawer, openSettings]);
// ---- 窗口标题 ----
useEffect(() => {
if (!snapshot) return;
const suffix =
snapshot.role === "team" ? "用户端" : "裁判端";
const suffix = snapshot.role === "team" ? "用户端" : "裁判端";
const extra = snapshot.demo ? "(演示模式)" : "";
void import("@tauri-apps/api/window")
.then(({ getCurrentWindow }) => getCurrentWindow().setTitle(`cam-desktop — ${suffix}${extra}`))
.catch(() => undefined);
// `getCurrentWindow()` 在浏览器预览里会直接抛(没有 Tauri 注入),所以整段包在
// try 里——标题设不上是小事,不能让一条 effect 把界面卸掉。
try {
void getCurrentWindow()
.setTitle(`RMIC::2026 — ${suffix}${extra}`)
.catch(() => undefined);
} catch {
// 预览模式:没有窗口。
}
}, [snapshot?.role, snapshot?.demo]);
// ---- 分支 ----
if (fatal) {
// 维护页不依赖身份、摄像头绑定或后端快照;先于所有门禁渲染,烧录完再返回原页面。
if (maintenance) {
return (
<div className="gate">
<h1 className="gate__title">起不来</h1>
<p className="gate__error">{fatal}</p>
<p className="gate__note">
端口可能被另一个客户端占着(旧的 Slint 版或另一份本程序)。关掉那一份再试。
</p>
</div>
<MaintenancePage
onClose={() => setMaintenance(false)}
fullscreen={fullscreen.fullscreen}
onToggleFullscreen={() => void fullscreen.toggle()}
/>
);
}
if (!bootGone) {
return (
<Boot
milestones={milestones}
progress={progress}
fatal={fatal}
leaving={bootLeaving}
/>
);
}
// 序列走完但快照还没到:给一句话,而不是一块空白。
if (!snapshot) {
return (
<div className="gate">
<h1 className="gate__title">cam-desktop</h1>
<p className="gate__hint">
{connected ? "正在等待后端的第一份状态…" : "正在连接后端…"}
<div className="splash">
<span className="splash__mark label">RMIC · 2026</span>
<h1 className="splash__title">还没有拿到后端状态</h1>
<p className="splash__text">
{connected ? "正在等待后端的第一份快照…" : "正在连接后端…"}
</p>
<div className="scan" style={{ width: 220 }} />
</div>
);
}
if (!snapshot.config.roleChosen) {
return <RolePicker />;
return (
<>
<RolePicker onOpenMaintenance={() => setMaintenance(true)} />
<Toasts toasts={toasts} onDismiss={dismissToast} />
</>
);
}
if (snapshot.role === "team" && !snapshot.config.passwordSet) {
return <SetupPassword />;
return (
<>
<SetupPassword onOpenMaintenance={() => setMaintenance(true)} />
<Toasts toasts={toasts} onDismiss={dismissToast} />
</>
);
}
return (
<div className="app">
{snapshot.role === "team" ? (
<TeamPage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
calibrating={calibrating}
onCalibrate={onCalibrate}
/>
) : (
<RefereePage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
onError={setError}
onInfo={setInfo}
/>
)}
<div className={`app app--${snapshot.role}`}>
<TopBar
snapshot={snapshot}
connected={connected}
fullscreen={fullscreen.fullscreen}
onOpenSettings={openSettings}
onOpenMaintenance={() => setMaintenance(true)}
onToggleFullscreen={() => void fullscreen.toggle()}
/>
<CommStrip />
<div className="app__stage">
{snapshot.role === "team" ? (
<TeamPage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
calibrating={calibrating}
onCalibrate={onCalibrate}
/>
) : (
<RefereePage
snapshot={snapshot}
hud={hud ?? snapshot.config.hud}
onOpenSettings={openSettings}
onError={onError}
onInfo={onInfo}
/>
)}
</div>
<ChannelFault snapshot={snapshot} />
{drawer && (
<SettingsPanel
@@ -239,8 +409,8 @@ export default function App() {
setDrawer(false);
}}
onNeedsUnlock={(reason) => needsUnlock(reason, { kind: "settings" })}
onError={setError}
onInfo={setInfo}
onError={onError}
onInfo={onInfo}
onConfig={onConfig}
/>
)}
@@ -256,18 +426,19 @@ export default function App() {
/>
)}
{error && <Toast kind="error" message={error} onClose={() => setError(null)} />}
{info && !error && <Toast kind="info" message={info} onClose={() => setInfo(null)} />}
{/* 校准提示:视频区域在页面里,离开设置面板之后还能看到它。 */}
{calibrating && (
<div className="calibrate-banner">
校准中:拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
<button onClick={() => setCalibrating(false)}>结束校准</button>
<div className="calibrate-bar">
<span className="calibrate-bar__mark label">CALIBRATE</span>
<span className="calibrate-bar__text">
拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
</span>
<button type="button" className="btn btn--ghost" onClick={() => setCalibrating(false)}>
结束校准
</button>
</div>
)}
<ChannelWatchdog snapshot={snapshot} />
<Toasts toasts={toasts} onDismiss={dismissToast} />
</div>
);
}
@@ -278,7 +449,7 @@ export default function App() {
* 这一条存在的理由很实际:画面不动时,第一件要分清的事是"后端没推"还是"前端没接上"。
* 后端快照里有 `clients`,所以这两件事可以分开说。
*/
function ChannelWatchdog({ snapshot }: { snapshot: Snapshot }) {
function ChannelFault({ snapshot }: { snapshot: Snapshot }) {
const { connected } = useAppStore();
if (connected) return null;
@@ -295,5 +466,10 @@ function ChannelWatchdog({ snapshot }: { snapshot: Snapshot }) {
detail += " 后端报告没有任何界面连着,这一条是前端侧的问题。";
}
return <div className="channel-warning">{detail}</div>;
return (
<div className="fault" role="alert">
<span className="fault__mark label">LINK</span>
<span className="fault__text">{detail}</span>
</div>
);
}
+130 -55
View File
@@ -4,6 +4,17 @@
* 场上到底连上了几块板子、分别自报哪一队、现在多少血、多久没广播了——现场"裁判端没显示
* 血量"时,这一张表就是第一眼看的地方:它能区分"板子根本没上线"、"上线了但队伍号烧反了"
* 和"只有一条上报在发"。
*
* # 每一行都在回答三个问题
*
* 左边那四个小格是 `platesSeen` 的位图:主板一次广播四条(`index` 0..3),四条都到齐才
* 会有点亮。只有一格亮着,几乎一定是配置或固件的问题——而这一眼就能看出来,不必去翻日志。
*
* 中间是静默时间,按新鲜度上色(<0.5 s 绿、<2 s 琥珀、再久红)。数字本身不够快,颜色
* 才是"现在有没有在说话"的答案。
*
* 右边是槽位归属。手动模式烧错固件时,这一列是唯一能证明"我以为的左右,和后端以为的左右
* 不是一回事"的地方。
*/
import { elapsed, teamColor, teamName } from "../lib/format";
@@ -14,54 +25,115 @@ interface Props {
slots: SlotsDto;
/** 两边显示的名字(裁判端可改)。缺省时回落到「队伍一 / 队伍二」。 */
names?: [string, string];
/** 把注意力放在某一队上;`null` 表示全部。 */
focusTeam?: number | null;
}
export function ArmorTable({ boards, slots, names }: Props) {
/** 静默多久还算"在说话"。与后端 `STALE_AFTER` 同一量级,但这里是给人看的,可以更敏感。 */
function silenceTone(online: boolean, silenceMs: number): "good" | "warn" | "bad" {
if (!online) return "bad";
if (silenceMs < 600) return "good";
if (silenceMs < 2000) return "warn";
return "bad";
}
/** 四格位图:这一块板子的四条上报各自到没到。 */
function Plates({ seen }: { seen: number }) {
return (
<div className="armor-table">
<div className="armor-table__head">
<span>装甲板</span>
<span>地址</span>
<span>自报队伍</span>
<span>血量</span>
<span>四条</span>
<span>静默</span>
<span>槽位</span>
<span className="armor__plates" title={`见过 ${seen.toString(2).padStart(4, "0")} 四条上报`}>
{[0, 1, 2, 3].map((bit) => (
<span key={bit} className={`armor__plate${(seen >> bit) & 1 ? " is-on" : ""}`} />
))}
</span>
);
}
export function ArmorTable({ boards, slots, names, focusTeam = null }: Props) {
const shown = focusTeam === null ? boards : boards.filter((board) => board.team === focusTeam);
const hidden = boards.length - shown.length;
return (
<div className="armor">
<div className="armor__head">
<span className="armor__col armor__col--plates">四条</span>
<span className="armor__col">装甲板</span>
<span className="armor__col">地址</span>
<span className="armor__col">自报队伍</span>
<span className="armor__col armor__col--hp">血量</span>
<span className="armor__col">静默</span>
<span className="armor__col">槽位</span>
</div>
{boards.length === 0 && (
<div className="armor-table__empty">
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在同一个
网络里。
</div>
)}
{boards.map((board) => {
const assigned =
slots.armorA === board.id
? `左(${teamName(0, names)})`
: slots.armorB === board.id
? `右(${teamName(1, names)})`
: "—";
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, names)}</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 className="armor__rows">
{shown.length === 0 && (
<div className="armor__empty">
{boards.length === 0 ? (
<>
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在
同一个网络里。
</>
) : (
<>
这一队还没有装甲板上报(另有 {hidden} 块属于另一队)。按 <kbd>0</kbd> 看全部。
</>
)}
</div>
);
})}
)}
{shown.map((board) => {
const assigned =
slots.armorA === board.id
? `左 · ${teamName(0, names)}`
: slots.armorB === board.id
? `右 · ${teamName(1, names)}`
: "未入槽";
const tone = silenceTone(board.online, board.silenceMs);
return (
<div
key={board.id}
className={`armor__row${board.online ? "" : " is-offline"}${
assigned === "未入槽" ? " is-unassigned" : ""
}`}
>
<span className="armor__col armor__col--plates">
<Plates seen={board.reportsAllPlates ? 0b1111 : board.platesSeen} />
</span>
<span className="armor__col num armor__id">{board.idText}</span>
<span className="armor__col num armor__addr">{board.addr}</span>
<span className="armor__col" style={{ color: teamColor(board.team) }}>
{teamName(board.team, names)}
<span className="armor__team-bit num">T{board.team}</span>
</span>
<span className="armor__col armor__col--hp">
<span className={`num armor__hp${board.hp === 0 ? " is-dead" : ""}`}>
{board.hp}
</span>
<span className="armor__hp-track" aria-hidden="true">
<span
className="armor__hp-fill"
style={{
width: `${Math.max(0, Math.min(100, board.hp))}%`,
background: board.hp === 0 ? "var(--error)" : teamColor(board.team),
}}
/>
</span>
</span>
<span className={`armor__col armor__silence is-${tone}`}>
<span className={`dot dot--${tone === "good" ? "live" : tone === "warn" ? "warn" : "dead"}`} />
{board.online ? `${elapsed(board.silenceMs)}前` : "离线"}
</span>
<span className="armor__col armor__slot">{assigned}</span>
</div>
);
})}
</div>
</div>
);
}
@@ -90,26 +162,29 @@ export function StatsLine({
diagnostics: boolean;
}) {
if (!stats) {
return <div className="stats-line stats-line--empty">还没有这一路的数据</div>;
return (
<div className="statsline statsline--empty">
<span className="dot dot--dead" />
还没有这一路的数据
</div>
);
}
const parts: string[] = [];
parts.push(stats.hasFrames ? `${stats.fps.toFixed(1)} fps` : "—");
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)}%)`);
}
if (diagnostics && stats.hasFrames) {
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 className={`statsline${stats.online ? "" : " is-offline"}`}>
<span className={`dot ${stats.online ? "dot--live" : "dot--dead"}`} />
<span className="statsline__status">{stats.status}</span>
<span className="statsline__sep" aria-hidden="true" />
<span className="statsline__nums num">{parts.join(" · ")}</span>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 启动序列。
*
* # 它不是假的过场
*
* 每一行都对应一件**真的还没做完的事**:字体、后端通道、首份快照、身份判定。后端没起来时
* 它就停在那儿,而不是假装走到 100% 再放你进去。指南里说序列动画应当"看起来像在载入信息,
* 而不是立刻跳出来"——那是视觉要求;这里额外满足了"不许骗人"这个更硬的要求。
*
* # 为什么要等字体
*
* 中文是整个界面的主要字形,它由 82 个 unicode-range 分片按需下载。首帧如果先画成系统
* 黑体、两百毫秒后再跳成 HarmonyOS,整块布局会跟着重排一次——启动时这一个抖动比多等
* 一瞬难看得多。所以这里等 `document.fonts.ready`。
*
* # 布局
*
* 左侧是系统标记与版本,中间是逐行亮起的自检,底部一条真实进度。整屏用切角框住,
* 四角有定位括号:这是"终端在做开机自检"的画面,不是"加载中的转圈"。
*/
import { BrandMark } from "./BrandMark";
interface Milestone {
key: string;
/** 这一行显示什么(中文短语 + 一个代号)。 */
label: string;
code: string;
done: boolean;
}
interface Props {
milestones: Milestone[];
/** 走到 1 时开始淡出。 */
progress: number;
/** 起不来的那句话。有它就不显示"正在载入",而是给出原因。 */
fatal?: string | null;
/** 淡出中。 */
leaving?: boolean;
}
export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
return (
<div className={`boot${leaving ? " is-leaving" : ""}`}>
<div className="boot__grid" aria-hidden="true" />
<div className="boot__scan" aria-hidden="true" />
<div className="boot__frame plate corners">
<header className="boot__head">
<span className="boot__mark" aria-hidden="true">
<BrandMark size={30} />
</span>
<div className="boot__titles">
<span className="boot__name">
RMIC<span className="boot__slash">::</span>2026
</span>
<span className="boot__sub label">视野链路终端 · SIMBA ROBOTICS</span>
</div>
</header>
<ul className="boot__log">
{milestones.map((item) => (
<li key={item.key} className="boot__line" data-done={item.done ? "yes" : "no"}>
<span className="boot__glyph" aria-hidden="true" />
<span className="boot__label">{item.label}</span>
<span className="boot__leader" aria-hidden="true" />
<span className="boot__code num">{item.code}</span>
</li>
))}
</ul>
{fatal ? (
<div className="boot__fatal">
<span className="boot__fatal-title">启动失败</span>
<p className="boot__fatal-text">{fatal}</p>
<p className="boot__fatal-hint">
端口可能被另一个客户端占着(旧的版本或另一份本程序)。关掉那一份,再重开。
</p>
</div>
) : (
<div className="boot__foot">
<div className="boot__bar">
<span
className="boot__bar-fill"
style={{ width: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%` }}
/>
</div>
<div className="boot__meta">
<span className="label">
{progress >= 1 ? "序列完成 · 正在进入" : "自检中"}
</span>
<span className="num boot__pct">
{Math.round(Math.max(0, Math.min(1, progress)) * 100)}
<span className="boot__pct-unit">%</span>
</span>
</div>
</div>
)}
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 队标(Simba Robotics / 黑大 602)。
*
* # 为什么内联而不是放一张图片
*
* 它出现在顶栏与启动页两处,尺寸都是 16–24 px 级别。位图在那个尺寸上要么发糊、要么
* 需要两套 @2x;而这三个路径总共不到 3 kB,内联进 JS 反而更小,还能直接吃 `currentColor`
* ——深色底上它是白的,将来放到浅色底上不用换资源。
*
* # 坐标是从原 SVG 的 viewBox 里**裁**出来的
*
* 原图是 1024×1024 的画布,标志本体只占中间一条(x 16.8–254.9,y 41.3–215.5),四周
* 留白很多。这里把 viewBox 直接收到本体上,于是 `height: 18px` 拿到的就是 18px 的标志,
* 而不是 18px 的画布里面一个 12px 的形状。
*
* 中间的圆点是队标自带的红,不是这套界面的强调色——它跟着品牌走,不跟着主题走。
*/
interface Props {
/** 显示高度(像素)。宽度按标志本身的 240:177 比例算。 */
size?: number;
/** 圆点颜色。默认用队标自己的红。 */
dot?: string;
}
export function BrandMark({ size = 18, dot = "#e8232b" }: Props) {
return (
<svg
className="brandmark"
viewBox="16 40 240 177"
height={size}
width={(size * 240) / 177}
role="img"
aria-label="Simba Robotics"
focusable="false"
>
<g fill="currentColor">
<path d="m 120.18387,65.065937 -8.00358,13.862771 h 27.77765 l 0.0541,0.09409 30.76276,53.283672 -35.41234,61.33599 -0.008,0.0143 -7.99546,13.84855 -0.008,0.0143 h 16.00718 44.01701 l -5.80572,10.05592 h -66.94744 l -0.007,0.0105 h -6.48395 l 5.81384,-10.07013 8.00358,-13.86278 28.83631,-49.94524 8.0036,-13.86276 H 142.78056 114.57375 107.01421 83.392663 67.385498 l -8.004262,13.86276 -42.562363,73.72043 -8.0035837,13.86278 H 24.822458 84.211033 l -0.0914,0.15771 h 16.007157 11.36098 l 0.007,-0.0105 h 62.07111 16.00784 l 0.0105,-0.0175 h 41.16526 15.05748 16.00718 l -8.00358,-13.86278 -5.81181,-10.06608 -8.00358,-13.86276 h -16.00718 -12.5848 -16.00784 l -0.0156,0.0278 h -1.6347 -0.80551 -12.33705 l 27.39928,-47.45699 8.00358,-13.86276 -5.11392,-8.85783 -11.76441,-20.37651 h 31.64205 l 5.06519,-8.773893 2.9384,-5.088885 h -15.13465 -48.55152 l 8.00358,13.862778 h 0.0297 l 16.87765,29.23434 -35.40286,61.31975 h -13.23258 l 27.41688,-47.48812 8.00358,-13.8621 -8.00358,-13.86276 -22.75918,-39.420912 h 60.26447 l -8.00332,-13.862894 h -32.42792 v -0.09409 z M 75.389113,143.70806 h 23.62222 7.558857 28.2075 l -28.83633,49.94523 -8.003587,13.86278 -5.11257,8.85512 -5.1119,-8.85512 -8.00427,-13.86278 H 63.701872 46.553462 l 8.574205,-14.85103 8.003585,-13.86277 z" />
<path d="m 134.72691,41.313757 -8.00358,13.862771 h 62.89352 16.00718 31.56419 l -8.00358,-13.862771 h -31.5642 -16.00717 z" />
</g>
<path
d="M 243.26673,60.098895 A 11.644196,11.644196 0 0 0 231.62281,71.742811 11.644196,11.644196 0 0 0 243.26673,83.386727 11.644196,11.644196 0 0 0 254.91131,71.742811 11.644196,11.644196 0 0 0 243.26673,60.098895 Z"
fill={dot}
/>
</svg>
);
}
+167 -48
View File
@@ -4,6 +4,15 @@
* 身份必须问一次,因为 `role` 的默认值(裁判端)既可能是人选了裁判端,也可能只是还没人
* 碰过这个文件——后者让用户端的人第一次打开会直接进裁判端,然后以为这个程序只有那一种
* 界面。所以后端单独记了一个 `roleChosen`。
*
* # 为什么是"装一个模块"而不是"选一个选项"
*
* 这两条路后面接的是两套完全不同的界面与权限,是这台机器**要变成什么**,不是这次会话里
* 的一个偏好。所以它长得像在装模块:一张编号的板子、一句"它会用到什么"、一个明确的
* 确认动作。选完就锁进去,下次启动直接进这一端。
*
* 密码那一道同理:它不是"注册账号",是给这台机器配一把现场用的钥匙,所以文案讲的是
* "它挡住什么、绕不过什么",而不是"请设置一个强密码"。
*/
import { useEffect, useRef, useState } from "react";
@@ -12,7 +21,33 @@ import { api } from "../lib/ipc";
import { pushNote } from "../lib/store";
import type { Role } from "../lib/types";
export function RolePicker() {
const MODULES: {
role: Role;
index: string;
cn: string;
en: string;
summary: string;
needs: string[];
}[] = [
{
role: "team",
index: "01",
cn: "用户端",
en: "OPERATOR",
summary: "一路画面 + 双方装甲条 + 瞄准器 + 命中反馈。装在这台车上,给操作手看。",
needs: ["绑定一块图传板", "绑定一块装甲板", "设置一个现场密码"],
},
{
role: "referee",
index: "02",
cn: "裁判端",
en: "REFEREE",
summary: "两路画面并排 + 双方血量 + 装甲板发现列表 + 截图与录制。给场边的人看。",
needs: ["不需要绑定", "不需要密码", "自动按队伍分左右"],
},
];
export function RolePicker({ onOpenMaintenance }: { onOpenMaintenance?: () => void }) {
const [busy, setBusy] = useState<Role | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -32,33 +67,70 @@ export function RolePicker() {
return (
<div className="gate">
<h1 className="gate__title">这台机器是哪一端?</h1>
<p className="gate__hint">选定之后记进配置,下次启动直接进这一端。之后可以在设置里改。</p>
<div className="gate__grid" aria-hidden="true" />
<header className="gate__head">
<span className="gate__brand">
RMIC<span className="gate__slash">::</span>2026
</span>
<span className="gate__phase label">FIRST RUN · 选择运行模式</span>
</header>
<div className="gate__lede">
<h1 className="gate__title">这台机器是哪一端?</h1>
<p className="gate__hint">
选定之后记进配置,下次启动直接进这一端。之后可以在设置里改——离开用户端需要密码。
</p>
</div>
<div className="gate__choices">
<button className="choice" disabled={busy !== null} onClick={() => void choose("team")}>
<span className="choice__name">用户端</span>
<span className="choice__desc">
一路画面 + 血条 + 瞄准器。需要绑定一块图传板与一块装甲板,绑定改动要密码。
</span>
{busy === "team" && <span className="choice__busy">正在切换…</span>}
</button>
{MODULES.map((module) => (
<button
key={module.role}
type="button"
className={`module plate${busy === module.role ? " is-busy" : ""}`}
disabled={busy !== null}
onClick={() => void choose(module.role)}
>
<span className="module__index num">{module.index}</span>
<span className="module__name">
{module.cn}
<span className="module__en label">{module.en}</span>
</span>
<span className="module__summary">{module.summary}</span>
<button className="choice" disabled={busy !== null} onClick={() => void choose("referee")}>
<span className="choice__name">裁判端</span>
<span className="choice__desc">
两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。不需要绑定,也不需要密码。
</span>
{busy === "referee" && <span className="choice__busy">正在切换…</span>}
</button>
<span className="module__needs">
{module.needs.map((need) => (
<span key={need} className="module__need">
{need}
</span>
))}
</span>
<span className="module__cta">
{busy === module.role ? "正在切换…" : "选用这一端"}
<span className="module__arrow" aria-hidden="true">
→
</span>
</span>
<span className="module__corners" aria-hidden="true" />
</button>
))}
</div>
{error && <p className="gate__error">{error}</p>}
{onOpenMaintenance && (
<button type="button" className="gate__maintenance" onClick={onOpenMaintenance} disabled={busy !== null}>
固件维护 / 烧录(不进入身份选择)
</button>
)}
</div>
);
}
export function SetupPassword() {
export function SetupPassword({ onOpenMaintenance }: { onOpenMaintenance?: () => void }) {
const [first, setFirst] = useState("");
const [again, setAgain] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -84,7 +156,7 @@ export function SetupPassword() {
try {
await api.setPassword(first);
await api.refresh();
pushNote("密码已设置。改摄像头/装甲板绑定、切换队伍号、退出都需要它。");
pushNote("密码已设置。改图传板/装甲板绑定、切换队伍号、退出都需要它。");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -92,38 +164,79 @@ export function SetupPassword() {
}
};
return (
<div className="gate">
<h1 className="gate__title">先设一个密码</h1>
<p className="gate__hint">
用户端改摄像头与装甲板绑定、切换队伍号、退出都要用它。至少 4 位,本机存的是
Argon2id 哈希,不是明文。
</p>
const strength = Math.min(4, Math.floor(first.length / 2));
return (
<div className="gate gate--narrow">
<div className="gate__grid" aria-hidden="true" />
<header className="gate__head">
<span className="gate__brand">
RMIC<span className="gate__slash">::</span>2026
</span>
<span className="gate__phase label">OPERATOR · 现场密码</span>
</header>
<div className="gate__lede">
<h1 className="gate__title">先配一把钥匙</h1>
<p className="gate__hint">
用户端改图传板与装甲板绑定、切换队伍号、退出都要用它。至少 4 位;本机存的是
Argon2id 哈希,不是明文。
</p>
</div>
<div className="keypad">
<label className="keypad__field">
<span className="keypad__label label">PASSWORD</span>
<input
ref={input}
className="input input--lg"
type="password"
placeholder="密码"
autoComplete="new-password"
value={first}
onChange={(event) => setFirst(event.target.value)}
/>
</label>
<label className="keypad__field">
<span className="keypad__label label">REPEAT</span>
<input
className="input input--lg"
type="password"
placeholder="再输一次"
autoComplete="new-password"
value={again}
onChange={(event) => setAgain(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submit();
}}
/>
</label>
<div className="keypad__meter" aria-hidden="true">
{[0, 1, 2, 3].map((step) => (
<span key={step} className={`keypad__cell${strength > step ? " is-on" : ""}`} />
))}
<span className="keypad__meter-note">
{first.length === 0
? "还没有输入"
: first.length < 4
? `还需要 ${4 - first.length} 位`
: "长度够了"}
</span>
</div>
<div className="gate__form">
<input
ref={input}
className="input"
type="password"
placeholder="密码"
autoComplete="new-password"
value={first}
onChange={(event) => setFirst(event.target.value)}
/>
<input
className="input"
type="password"
placeholder="再输一次"
autoComplete="new-password"
value={again}
onChange={(event) => setAgain(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submit();
}}
/>
{error && <p className="gate__error">{error}</p>}
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
<button
type="button"
className="btn btn--primary btn--lg keypad__submit"
onClick={() => void submit()}
disabled={busy || first.length < 4}
>
{busy ? "保存中…" : "设置密码并继续"}
<span className="btn__key">⏎</span>
</button>
</div>
@@ -131,6 +244,12 @@ export function SetupPassword() {
忘了密码的处理:删掉配置文件里的 password 一行,或者删掉整个配置文件。旧客户端
(Slint 版)的配置会在首次启动时自动导入并备份成 .bak。
</p>
{onOpenMaintenance && (
<button type="button" className="gate__maintenance" onClick={onOpenMaintenance} disabled={busy}>
固件维护 / 烧录(不进入身份选择)
</button>
)}
</div>
);
}
+62 -56
View File
@@ -1,24 +1,27 @@
/**
* 画面角上的分段血条(平行四边形,一条分 5 格)。
* 画面角上的分段装甲条(斜切成 5 格的平行四边形)。
*
* # 为什么改到角上、改成斜的
* # 为什么在角上、为什么是斜的
*
* 原来它在画面**下面**一条窄带里。改成画面左上角/右上角之后:
* 原来它在画面**下面**一条窄带里。改成左上角/右上角之后:
*
* - 画面不再是"被一条横带压住的一块",整块视频区域给到取景;
* - 两边各占一角,**同时**看到自己与对手的血量,视线不用离开画面;
* - 斜切的形状不会在第一时间被误认成字幕条或进度条,扫一眼就知道是血量。
*
* 左上角给自己、右上角给对手(见 `TeamPage`)。裁判端两格各自一个角,与用户端同一套
* 左上角给自己、右上角给对手(见 `OperatorPage`)。裁判端两格各自一个角,与用户端同一套
* 视觉语言。
*
* # 一条分 5 格 = 一格 20 点
*
* 整条是一条,里面分成 **5 格**:格子少而大,隔着几米扫一眼就能数出"还剩三格",
* 比读一条连续的长度或一个百分比快。血量归零就是 0 格,那时图传中央会给出胜负。
* 格子少而大,隔着几米扫一眼就能数出"还剩三格",比读一条连续的长度或一个百分比快。
* 血少了,格子从**中心那一侧**开始灭,所以两条血一起减少时,视觉上是往两侧退——不会看成
* "条变长了"。
*
* 血少了,亮着的格子从**中心那一侧**开始灭(左条从右往左灭,右条从左往右灭),所以两条
* 血一起减少时,视觉上是往两侧退——不会看成"条变长了"。
* # 熄灭是"逐格"的,不是整条淡出
*
* 每一格都有自己的 `transition-delay`(见 JSX 里的 `--i`),于是掉 20 点血的瞬间是从内
* 侧往外依次灭掉一盏灯,而不是整条一起变暗。这个顺序本身携带信息:被点亮/熄灭的是**哪一格**。
*
* # 未接入时不显示 0
*
@@ -28,7 +31,7 @@
import { useEffect, useState } from "react";
import { teamColor, withAlpha } from "../lib/format";
import { teamColor } from "../lib/format";
import type { HealthConfig, TeamHealthDto } from "../lib/types";
interface Props {
@@ -38,24 +41,17 @@ interface Props {
align: "left" | "right";
/** 这一格是谁:用户端用「我方 / 对手」把两条同色的血区分开。 */
tag?: string;
/** 休眠态:只留数字,标签与提示退到背景里。 */
asleep?: boolean;
}
/** 整条分成几格。 */
/** 整条分成几格。一格 20 点血。 */
const CELLS = 5;
/** 一格多少血。整车一条血 100 点。 */
const CELL_HP = 100 / CELLS;
/** 脉动的周期。与 CSS 动画保持一致。 */
/** 低血脉动的周期。与 CSS 动画保持一致。 */
const PULSE_MS = 900;
interface Cell {
/** 这一格是不是亮着(这一格的血还在)。 */
on: boolean;
/** 落在低血阈值以内:这是"危险区",即使还亮着也提前给个警示色。 */
danger: boolean;
}
export function HealthBar({ health, config, align, tag }: Props) {
export function HealthBar({ health, config, align, tag, asleep = false }: Props) {
const [pulseOn, setPulseOn] = useState(true);
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
@@ -72,12 +68,12 @@ export function HealthBar({ health, config, align, tag }: Props) {
return () => window.clearInterval(timer);
}, [pulsing]);
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
const color = health.critical ? "var(--error)" : teamColor(health.team);
const raw = health.available ? Math.max(0, Math.min(100, health.hp ?? 0)) : 0;
const hp = health.available ? Math.max(0, Math.min(100, health.hp ?? 0)) : 0;
// 向上取整:还剩 1 点血的时候必须还剩一格亮着。向下取整会让"还有 10 点血"显示成空条,
// 而那正是最需要它准确的时候。
const lit = Math.ceil(hp / CELL_HP);
const lit = Math.ceil(raw / CELL_HP);
const dangerCells =
config.lowThreshold > 0 && config.lowThreshold < 100
? Math.ceil(config.lowThreshold / CELL_HP)
@@ -86,11 +82,13 @@ export function HealthBar({ health, config, align, tag }: Props) {
// 逻辑顺序:0 号格是"满血时最靠外的那一格"。
//
// **不要在这里按 `align` 反转数组**:右角那条的翻转让 CSS 的 `scaleX(-1)` 做
// (`segbars--right .segbars__track`),两边都做等于翻两次、又翻回来了——表现是右条
// (`.hp--right .hp__track`),两边都做等于翻两次、又翻回来了——表现是右条
// 从外侧开始灭,与左条相反。
const cells: Cell[] = Array.from({ length: CELLS }, (_, index) => ({
const cells = Array.from({ length: CELLS }, (_, index) => ({
on: index < lit,
danger: index >= CELLS - dangerCells,
/** 从内往外排延迟:灭的时候先熄内侧那格。 */
order: align === "right" ? index : CELLS - 1 - index,
}));
// 宽度按配置收窄。它挂在画面角上,太宽会盖住画面本身。
@@ -98,54 +96,62 @@ export function HealthBar({ health, config, align, tag }: Props) {
return (
<div
className={`segbars segbars--${align}`}
className={`hp hp--${align}${asleep ? " hp--asleep" : ""}${
pulsing ? " hp--pulsing" : ""
}${health.critical ? " hp--critical" : ""}`}
style={{ width }}
title={health.available ? undefined : health.placeholder}
>
<div className="segbars__labels">
{tag && <span className="segbars__tag">{tag}</span>}
<div className="hp__head">
{tag && <span className="hp__tag">{tag}</span>}
{config.showTeamName && (
<span className="segbars__team" style={{ color }}>
<span className="hp__name" style={{ color }}>
{health.name}
</span>
)}
{health.available ? (
config.showNumbers && (
<span className="segbars__value" style={{ color }}>
{hp}
<span className="hp__value num" style={{ color }}>
{raw}
</span>
)
) : (
<span className="segbars__missing">装甲板未接入</span>
<span className="hp__missing">装甲板未接入</span>
)}
</div>
<div
className="segbars__track"
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
aria-hidden="true"
>
{cells.map((cell, index) => (
<div className="hp__track-wrap">
<div
className="hp__track"
style={{ opacity: pulsing && !pulseOn ? 0.32 : 1 }}
aria-hidden="true"
>
{cells.map((cell, index) => (
<span
key={index}
className={`hp__cell${cell.on ? " is-on" : ""}${cell.danger ? " is-danger" : ""}`}
style={
{
"--i": cell.order,
...(cell.on ? { "--c": color } : null),
} as React.CSSProperties
}
/>
))}
{/* 一次掉血时在条上扫过一道白。它的 `key` 是血量本身——数字一变就重新挂载,
CSS 动画自然从头再跑一遍,不需要额外的状态。 */}
{health.available && <span key={raw} className="hp__impact" />}
</div>
{/* 低血阈值刻度:一直画着,让"危险区从哪开始"是看得见的,而不是等掉到那儿才出现。 */}
{dangerCells > 0 && (
<span
key={index}
className={[
"segbars__cell",
cell.on ? "segbars__cell--on" : "",
cell.danger ? "segbars__cell--danger" : "",
]
.filter(Boolean)
.join(" ")}
style={
cell.on
? {
background: color,
boxShadow: `0 0 8px ${withAlpha(color, 0.45)}`,
}
: undefined
}
className="hp__threshold"
style={{ [align === "right" ? "left" : "right"]: `${(dangerCells / CELLS) * 100}%` }}
/>
))}
)}
</div>
</div>
);
+30 -10
View File
@@ -1,11 +1,22 @@
/**
* 命中反馈的整屏层:闪红 + 顶部一条文字。
* 命中反馈的整屏层:边沿泛红 + 条上的读数。
*
* # 大号数字刻意不放在画面正中
*
* 第一版把 `-7 / 100 → 93` 放在正中,结果它正好压在瞄准点上——被打中的那一下恰恰是最
* 需要看着准星的时候。现在数字由 `VideoSurface` 画在**准星旁边**(跟着瞄准点走,不挡
* 目标),这里只留闪红与顶部一条信息。
* 目标),这里只留边沿泛红与顶部一条读数。
*
* # 泛红为什么贴着边沿
*
* 旧版是一整块径向渐变盖在画面上,等于在最要紧的一瞬间把画面调暗。现在颜色只在四条边上
* 用 `inset` 阴影渗进来,中间保持原样——"被打到"这件事从四周传来,而不是挡在眼前。
*
* # 不给方向
*
* 命中事件里的 `index` 是**哪一条上报先发现了掉血**,不是"从哪一侧打来的"(主板一次广播
* 四条、血值相同,谁先到达取决于 UDP)。把它画成方向箭头会是一条看起来很专业、实际上
* 完全错误的线索,比不画糟得多。所以这一层是对称的。
*
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
@@ -25,17 +36,26 @@ interface Props {
export function HitFlash({ hit, config, relevant, label }: Props) {
if (!hit || !relevant || !config.flash) return null;
const strength = Math.min(0.55, 0.16 * config.intensity);
// 强度换算成"边沿渗进来多深"。上限压在 70px:再深就开始吃掉画面。
const depth = Math.round(38 + 34 * Math.min(2, Math.max(0.2, config.intensity)));
return (
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
<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}
<div
key={hit.seq}
className="hit"
style={{ ["--hit-depth" as string]: `${depth}px` }}
aria-hidden="true"
>
<span className="hit__edge" />
<span className="hit__tick">
{label && <span className="hit__tick-label">{label}</span>}
<span className="hit__tick-damage num">−{hit.damage}</span>
<span className="hit__tick-hp num">
{hit.from}
<span className="hit__tick-arrow">→</span>
{hit.to}
</span>
</div>
</span>
</div>
);
}
+25 -9
View File
@@ -1,5 +1,5 @@
/**
* 图传中央的胜负横幅:一边归零之后,画面正中显示「胜利」或「击败」。
* 图传中央的胜负结算:一边归零之后,画面正中给出「胜利 / 击败」。
*
* # 为什么这次放在正中
*
@@ -13,8 +13,10 @@
* 无从对照。血量被重置(长按 BOOT / 下一局开始)时它自己消失——那个动作本来就是
* "这局翻篇了"。
*
* 用词是两边各说各的:赢的一边看到「胜利」,输的一边看到「击败」。同时归零显示「平局」,
* 因为那时候任何一边显示"胜利"都是错的。
* # 为什么要加电影黑边
*
*"结算"和"正在打"必须是两种构图。上下两条黑边把画面压成宽幅,视线自然收到中间那行字上;
* 同时它们给了一处能放次要读数(谁、输赢给谁看)的地方,不必把字塞进画面。
*/
import type { MatchOutcome } from "../lib/types";
@@ -26,6 +28,8 @@ interface Props {
mine: string;
/** 对手的名字。 */
theirs: string;
/** 比分感的补充读数,例如「对手 0 血」。 */
detail?: string;
}
const WORD: Record<MatchOutcome, string> = {
@@ -34,7 +38,13 @@ const WORD: Record<MatchOutcome, string> = {
draw: "平局",
};
export function MatchBanner({ outcome, mine, theirs }: Props) {
const CODE: Record<MatchOutcome, string> = {
victory: "MATCH · WIN",
defeat: "MATCH · LOSS",
draw: "MATCH · DRAW",
};
export function MatchBanner({ outcome, mine, theirs, detail }: Props) {
if (!outcome) return null;
const caption =
@@ -45,11 +55,17 @@ export function MatchBanner({ outcome, mine, theirs }: Props) {
: "双方装甲板同时归零";
return (
<div className={`match-banner match-banner--${outcome}`} role="status">
<div className="match-banner__card">
<span className="match-banner__team">{mine}</span>
<span className="match-banner__word">{WORD[outcome]}</span>
<span className="match-banner__caption">{caption}</span>
<div className={`verdict verdict--${outcome}`} role="status">
<span className="verdict__bar verdict__bar--top" />
<span className="verdict__bar verdict__bar--bottom" />
<div className="verdict__card">
<span className="verdict__code label">{CODE[outcome]}</span>
<span className="verdict__mine">{mine}</span>
<span className="verdict__word">{WORD[outcome]}</span>
<span className="verdict__rule" aria-hidden="true" />
<span className="verdict__caption">{caption}</span>
{detail && <span className="verdict__detail num">{detail}</span>}
</div>
</div>
);
+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
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
/**
* 底部遥测条。
*
* 它不承载操作,只回答"这一路现在怎么样":链路状态、帧率、分辨率、丢包、丢帧。
* 这些数字每一份快照(200 ms)都在变,所以排版必须是**定宽**的——值一变就换行或推动
* 邻格,会让整条一直在轻轻抖。
*
* 两侧留了两个槽给页面自己用(左边一般是状态,右边一般是操作提示),中间放读数。
*/
import type { ReactNode } from "react";
interface Props {
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
}
export function StatusBar({ left, center, right }: Props) {
return (
<footer className="statusbar">
<div className="statusbar__clamp">{left}</div>
<div className="statusbar__clamp statusbar__clamp--center">{center}</div>
<div className="statusbar__clamp statusbar__clamp--end">{right}</div>
</footer>
);
}
/**
* 一格读数:上面一个微标签,下面一个等宽值。
*
* `tone` 是有意义的:`warn` / `bad` 只在真的异常时出现,平时不抢注意力。
*/
export function Stat({
label,
value,
tone = "plain",
title,
}: {
label: string;
value: ReactNode;
tone?: "plain" | "hot" | "warn" | "bad" | "good";
title?: string;
}) {
return (
<span className={`stat stat--${tone}`} title={title}>
<span className="stat__label">{label}</span>
<span className="stat__value num">{value}</span>
</span>
);
}
+32 -10
View File
@@ -5,21 +5,25 @@
*
* 现场改名字的时机是"比赛开始前,两队报上名来"。那时候裁判盯着的是两格画面,让他在
* 设置面板的某一节里找到对应项、改完再关掉,是一次没有必要的往返。名字就放在两格画面
* 各自下面,改完直接看到血条上的名字变了。
* 各自下面,改完直接看到装甲条上的名字变了。
*
* # 回车/失焦才提交
*
* 逐字提交会在每次按键后写一次配置文件(还带一次 Argon2 无关的原子替换),而且中间态
* ("红"、"红队")会被后端截断/回落规则改写一次,光标跟着跳。失焦提交是唯一不会跟
* 打字打架的时机。
* 逐字提交会在每次按键后写一次配置文件(还带一次原子替换),而且中间态("红"、"红队")
* 会被后端截断/回落规则改写一次,光标跟着跳。失焦提交是唯一不会跟打字打架的时机。
*
* 超过上限的字数由后端统一截断(`TEAM_NAME_MAX_CHARS`),这里也限一下长度——输入框
* 里就能看到"打不进去了",比提交后被悄悄截掉诚实。
*
* # 静止时它是一条读数
*
* 没在编辑的时候,它长得和旁边的帧率、状态一样:一个暗标签加一个值。只有点进去才展开成
* 带边框的输入框,并露出 `⏎ 保存` / `⎋ 撤销`。这样两格画面下面不会永远趴着两个表单控件。
*/
import { useEffect, useRef, useState } from "react";
import { DEFAULT_TEAM_NAMES } from "../lib/format";
import { DEFAULT_TEAM_NAMES, teamColor } from "../lib/format";
/** 与 Rust 侧的 `config::TEAM_NAME_MAX_CHARS` 一致。 */
const MAX_CHARS = 12;
@@ -35,6 +39,7 @@ interface Props {
export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
const [draft, setDraft] = useState(name);
const [editing, setEditing] = useState(false);
const focused = useRef(false);
/** Esc 已经改回去了,紧接着的那次失焦不该再提交。 */
const reverted = useRef(false);
@@ -47,6 +52,7 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
const commit = () => {
focused.current = false;
setEditing(false);
if (reverted.current) {
reverted.current = false;
@@ -60,21 +66,28 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
onSave(trimmed);
};
const color = teamColor(team);
return (
<label className="team-name">
<span className="team-name__label" style={{ color: team === 0 ? "#ff5f5f" : "#5aa9ff" }}>
队名
<label
className={`tname${editing ? " is-editing" : ""}${disabled ? " is-disabled" : ""}`}
style={{ ["--team" as string]: color }}
>
<span className="tname__label" style={{ color }}>
{team === 0 ? "A" : "B"}
</span>
<input
className="input team-name__input"
className="tname__input"
value={draft}
maxLength={MAX_CHARS}
disabled={disabled}
placeholder={DEFAULT_TEAM_NAMES[team] ?? "队伍"}
title="改完按回车或点到别处生效;留空回落到默认名"
title="改完按回车或点到别处生效;Esc 撤销;留空回落到默认名"
onChange={(event) => setDraft(event.target.value)}
onFocus={() => {
focused.current = true;
setEditing(true);
}}
onBlur={commit}
onKeyDown={(event) => {
@@ -90,6 +103,15 @@ export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
}
}}
/>
<span className="tname__hint" aria-hidden="true">
<span className="tname__keys">
<kbd>⏎</kbd>存 <kbd>⎋</kbd>撤
</span>
<span className="tname__count num">
{draft.length}/{MAX_CHARS}
</span>
</span>
</label>
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 右下角的"调度单":错误与说明。
*
* # 为什么改成队列
*
* 原来只有 `error` / `info` 两个槽:后一条会把前一条顶掉。现场最容易出现的恰恰是**连着
* 两条**——比如"密码不对"紧跟着"绑定失败"。第二条盖住第一条,等于把第一句诊断吃掉了。
* 现在是一个最多四条的小队列,后来的排下面,各自计时。
*
* # 它自己会走,但也能留在那儿
*
* 说明类 6 秒、错误类 12 秒;鼠标停在上面时暂停计时(`onPointerEnter/Leave` 交给 CSS 的
* `animation-play-state` 不够用,所以计时在 JS 里)。点一下立刻收起。
*/
export interface ToastItem {
id: number;
kind: "info" | "error";
message: string;
}
interface Props {
toasts: ToastItem[];
onDismiss: (id: number) => void;
}
export function Toasts({ toasts, onDismiss }: Props) {
if (toasts.length === 0) return null;
return (
<div className="toasts" role="log" aria-live="polite">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast toast--${toast.kind}`}
role={toast.kind === "error" ? "alert" : "status"}
onClick={() => onDismiss(toast.id)}
>
<span className="toast__bar" aria-hidden="true" />
<span className="toast__code label">
{toast.kind === "error" ? "ERR" : "NOTE"}
</span>
<span className="toast__text">{toast.message}</span>
<span className="toast__close" aria-hidden="true">
✕
</span>
</div>
))}
</div>
);
}
+107 -49
View File
@@ -1,96 +1,154 @@
/**
* 顶部状态条:身份、连接、演示模式、说明、以及进入设置的入口。
* 顶部状态条:身份、链路、演练模式、录制,以及三个入口。
*
* 演示模式的标记**必须显眼**:那一整屏画面与血量都是本机合成的,截图或验收时把它当成
* 真实链路会得出完全错误的结论。
* # 它不只是导航
*
* 这一条是"终端在运行"的唯一常驻证据:左边是系统自己的身份(我是谁、跑在什么状态下),
* 中间是链路的实况,右边是能做的事。所以它按"从系统到自己"的顺序排,而不是按"按钮放哪
* 顺手"。
*
* # 演练模式的标记**必须显眼**
*
* 那一整屏画面与血量都是本机合成的。截图或验收时把它当成真实链路会得出完全错误的结论,
* 所以它是一个虚线框的琥珀标签,并且点得动——点开就说清楚这次演示合成了什么。
*/
import type { ReactNode } from "react";
import { pushNote, useAppStore } from "../lib/store";
import { dismissNote, pushNote, useAppStore } from "../lib/store";
import type { Snapshot } from "../lib/types";
import { Button } from "./controls";
interface Props {
snapshot: Snapshot;
connected: boolean;
fullscreen: boolean;
onOpenSettings: () => void;
onOpenMedia?: () => void;
extra?: ReactNode;
onOpenMaintenance?: () => void;
onToggleFullscreen: () => void;
/** 这一页自己的东西,插在中间。 */
children?: ReactNode;
}
export function TopBar({ snapshot, onOpenSettings, onOpenMedia, extra }: Props) {
const { connected, notes } = useAppStore();
export function TopBar({
snapshot,
connected,
fullscreen,
onOpenSettings,
onOpenMaintenance,
onToggleFullscreen,
children,
}: Props) {
const team = snapshot.role === "team";
const recording = snapshot.recordings.length;
return (
<header className="topbar">
<div className="topbar__left">
<span className="topbar__brand">cam-desktop</span>
<span className={`tag tag--${snapshot.role}`}>
{snapshot.role === "team" ? "用户端" : "裁判端"}
<header className={`topbar topbar--${snapshot.role}`}>
<div className="topbar__cluster">
{/* 队标只以"字标"的形态出现在顶栏:它的笔画在 17px 上只剩 1px,缩到这么小
会糊成一团反而更难看。完整的队标留给启动页(在那里它有 30px)。 */}
<span className="topbar__brand" title="RMIC::2026 · Simba Robotics">
<span className="topbar__wordmark">
RMIC<span className="topbar__slash">::</span>2026
</span>
</span>
<span className={`topbar__role role--${snapshot.role}`}>
<span className="topbar__role-cn">{team ? "用户端" : "裁判端"}</span>
<span className="topbar__role-en">{team ? "OPERATOR" : "REFEREE"}</span>
</span>
{snapshot.demo && (
<button
className="tag tag--demo tag--button"
type="button"
className="tag tag--warn tag--button"
title="所有画面与血量都是本机合成的假数据,不代表场上的任何一块板子。"
onClick={() =>
pushNote("演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。")
pushNote(
"演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。",
)
}
>
演示模式 · 合成画面
演练 · 合成数据
</button>
)}
{!connected && <span className="tag tag--warn">通道断开,重连中</span>}
{snapshot.diagnostics && <span className="tag tag--link">诊断</span>}
{snapshot.recordings.length > 0 && (
<span className="tag tag--recording">
<span className="recording__dot" /> 录制中 ×{snapshot.recordings.length}
{!connected && (
<span className="tag tag--warn">
<span className="dot dot--warn" />
通道断开 · 重连中
</span>
)}
{extra}
{recording > 0 && (
<span className="tag tag--danger">
<span className="rec-dot" />
录制 ×{recording}
</span>
)}
</div>
<div className="topbar__right">
{notes.slice(-1).map((note, index) => (
<span key={index} className="topbar__note">
{note}
</span>
))}
<div className="topbar__cluster topbar__cluster--center">{children}</div>
<span className="dim mono" title="连着这个后端的界面数">
界面 {snapshot.clients}
<div className="topbar__cluster topbar__cluster--end">
<span className="topbar__clients num" title="连着这个后端的界面数">
{snapshot.clients}
<span className="topbar__clients-unit">UI</span>
</span>
{onOpenMedia && (
<Button variant="ghost" onClick={onOpenMedia}>
录制与回放
</Button>
<span className="topbar__sep" aria-hidden="true" />
{onOpenMaintenance && (
<button type="button" className="btn btn--quiet" onClick={onOpenMaintenance}>
固件
</button>
)}
<Button variant="ghost" onClick={onOpenSettings}>
<button
type="button"
className="btn btn--quiet"
onClick={onToggleFullscreen}
title="F11 / Alt+Enter"
>
{fullscreen ? "退出全屏" : "全屏"}
</button>
<button type="button" className="btn btn--ghost topbar__settings" onClick={onOpenSettings}>
设置
</Button>
<span className="topbar__kbd">⌘,</span>
</button>
</div>
</header>
);
}
/** 浮动提示(错误/信息)。 */
export function Toast({
message,
kind,
onClose,
}: {
message: string;
kind: "info" | "error";
onClose: () => void;
}) {
/**
* 一条"通讯":后端推来的说明,或本机校验失败时塞进来的话。
*
* 只显示最新一条。它是一条会自己消失的横带,不是弹窗——现场的说法是"一句顶一句",
* 一次铺开六条谁也读不完。
*/
export function CommStrip() {
const { notes } = useAppStore();
const latest = notes.at(-1);
if (!latest) return null;
return (
<div className={`toast toast--${kind}`} onClick={onClose} role="alert">
<span>{message}</span>
<span className="toast__close">✕</span>
<div className="comm" role="status">
<span className="comm__marker label">COMM</span>
<span className="comm__text">{latest}</span>
<button
type="button"
className="comm__close"
onClick={() => dismissNote(notes.length - 1)}
title="收起"
>
✕
</button>
</div>
);
}
+43 -23
View File
@@ -3,6 +3,12 @@
*
* 它只是**入口**,不是闸门:真正拦住改绑的是后端的 `require_unlocked`。所以这里即使
* 被人绕过、甚至被改成永远返回成功,配置也改不动。
*
* # 它是"校验"而不是"弹窗"
*
* 这一层出现时,背后的一切都停了:整屏压暗、面板上扫过一条校验线、左下角挂着 `LOCKED`。
* 现场有人要改绑定,多半是在出问题的时候;那时候界面必须明确地说"现在这一步是在验证身份",
* 而不是给出一个和别处一样的灰色小框。
*/
import { useEffect, useRef, useState } from "react";
@@ -49,38 +55,52 @@ export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
};
return (
<div className="overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2 className="dialog__title">需要密码</h2>
<p className="dialog__hint">{reason}</p>
<div className="overlay" role="dialog" aria-modal="true" aria-label="需要密码">
<div className={`lock plate${error ? " is-wrong" : ""}`}>
<span className="lock__scan" aria-hidden="true" />
<span className="lock__tag label">LOCKED</span>
<input
ref={input}
className="input"
type="password"
value={password}
placeholder="密码"
autoComplete="off"
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submit();
if (event.key === "Escape") onCancel();
}}
/>
<header className="lock__head">
<h2 className="lock__title">需要密码</h2>
<p className="lock__reason">{reason}</p>
</header>
{error && <p className="dialog__error">{error}</p>}
<label className="lock__field">
<span className="lock__field-label label">ACCESS KEY</span>
<input
ref={input}
className="input input--lg"
type="password"
value={password}
placeholder="输入现场密码"
autoComplete="off"
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submit();
if (event.key === "Escape") onCancel();
}}
/>
</label>
<p className="dialog__note">
{error && <p className="lock__error">{error}</p>}
<p className="lock__note">
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
忘了密码就删掉配置里的 password 一行。
</p>
<div className="dialog__actions">
<button className="button button--ghost" onClick={onCancel}>
<div className="lock__actions">
<button type="button" className="btn btn--ghost" onClick={onCancel}>
取消
</button>
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
{busy ? "校验中…" : "解锁"}
<button
type="button"
className="btn btn--primary"
onClick={() => void submit()}
disabled={busy || password.length === 0}
>
{busy ? "校验中…" : "校验并解锁"}
<span className="btn__key">⏎</span>
</button>
</div>
</div>
+147 -171
View File
@@ -1,5 +1,5 @@
/**
* 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈。
* 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈 + 取景框。
*
* # 为什么画在 canvas 上而不是用 CSS 变换 `<img>`
*
@@ -7,7 +7,7 @@
* `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而两份实现迟早会不一致——表现
* 是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。
*
* 所以变换只算一次(`layout()`),图像、网格、瞄准器、伤害数字全都用同一组数画出来。
* 所以变换只算一次(`computeLayout`),图像、网格、瞄准器、伤害数字全都用同一组数画出来。
*
* # 尺寸与缩放的关系
*
@@ -16,12 +16,24 @@
* - **瞄准器**的**位置**跟着图像走,**大小**不跟着:像真瞄具一样,放大之后准星不会
* 跟着变粗,而是覆盖画面里更小的一块——那正是放大有用武之地的原因。
* - 镜像靠 `scale(-1, 1)`,同时把拖动方向也翻过来,否则向右拖画面会往左走。
*
* # 缩放是"追"上去的,平移是"贴"着手的
*
* 滚轮换倍率时,画面用三帧左右追到目标值:一下子跳过去会让人失去"放大了多少"的感觉。
* 反过来,拖动必须 1:1——指针走一像素,画面就走一像素,中间加任何缓动都会变成黏手。
* 两者由 `dragging` 这个 ref 分开:拖动期间平移直接跟,其余时候参与插值。
*/
import { useEffect, useRef, type ReactNode } from "react";
import { streamClient } from "../lib/store";
import { withAlpha } from "../lib/format";
import {
aimPoint,
computeLayout,
paintCrosshair,
paintGrid,
type Geometry,
} from "../lib/reticle";
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
import type { FrameInfo } from "../lib/stream";
@@ -63,9 +75,23 @@ interface Props {
showZoom: boolean;
children?: ReactNode;
/** Only used by the opt-in screen/camera calibration page. */
onFramePainted?: (canvas:HTMLCanvasElement,frame:FrameInfo,drawMs:number)=>void;
onFramePainted?: (canvas: HTMLCanvasElement, frame: FrameInfo, drawMs: number) => void;
/** 左上角的代号(`CAM · A`、队名)。缺省时不画。 */
label?: string;
/** 这一路被"选中"了(裁判端的 scope)。视觉上给一圈亮框。 */
focused?: boolean;
/** 按下左键时通知外层。裁判端用它把下方控制台切到这一路。 */
onSelect?: () => void;
}
/** 命中动画持续多久。与 CSS 里的 `hit-*` 动画保持一致。 */
const HIT_MS = 700;
/** 缩放插值系数。0.28 在 60 fps 下约 3 帧收敛,够快,又不至于看不出在放大。 */
const ZOOM_LERP = 0.28;
/** 复位时平移的插值系数。比缩放慢一点,让"收回去"这个动作看得见。 */
const PAN_LERP = 0.2;
export function VideoSurface({
camera,
emptyText,
@@ -81,13 +107,19 @@ export function VideoSurface({
showZoom,
children,
onFramePainted,
label,
focused = false,
onSelect,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted });
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted };
const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback, onFramePainted });
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback, onFramePainted };
/** 屏幕上**当前**的视图。它追着 `view` 走,但有自己的惯性。 */
const shown = useRef<ViewTransform>({ ...view });
const dragging = useRef(false);
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
const dirty = useRef(true);
const lastFrameId = useRef<number | null>(null);
@@ -131,26 +163,52 @@ export function VideoSurface({
const current = props.current;
const frame = streamClient().latest(current.camera);
const newFrame=frame && frame.frameId!==lastFrameId.current;
const newFrame = frame && frame.frameId !== lastFrameId.current;
if (frame && frame.frameId !== lastFrameId.current) {
if (newFrame) {
lastFrameId.current = frame.frameId;
dirty.current = true;
}
// 视图追上去。拖动时平移不许插值(会黏手),其余时候连平移一起缓动。
const target = current.view;
const next = shown.current;
const before = `${next.zoom}|${next.x}|${next.y}`;
if (Math.abs(next.zoom - target.zoom) > 0.0005) {
next.zoom += (target.zoom - next.zoom) * ZOOM_LERP;
} else {
next.zoom = target.zoom;
}
if (dragging.current) {
next.x = target.x;
next.y = target.y;
} else {
const k = target.zoom <= MIN_ZOOM + 0.0005 ? PAN_LERP : 1;
next.x += (target.x - next.x) * k;
next.y += (target.y - next.y) * k;
if (Math.abs(next.x - target.x) < 0.4) next.x = target.x;
if (Math.abs(next.y - target.y) < 0.4) next.y = target.y;
}
if (before !== `${next.zoom}|${next.x}|${next.y}`) dirty.current = true;
// 命中动画期间必须持续重绘,否则闪红/伤害数字只画了一帧就停住。
//
// **抖动没有了**(见 `HitFeedbackConfig::shake` 的文档):不再有"整幅图跟着晃"的
// 那一路,所以这里也不再需要因为抖动而复绘。
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
const animating =
pulseAge < 700 && (current.hitFeedback.marker || current.hitFeedback.flash);
pulseAge < HIT_MS && (current.hitFeedback.marker || current.hitFeedback.flash);
if (!dirty.current && !animating) return;
dirty.current = false;
draw(canvas, current, frame, timestamp);
if(newFrame && current.onFramePainted) current.onFramePainted(canvas,frame,performance.now());
draw(canvas, current, shown.current, frame, timestamp);
if (newFrame && current.onFramePainted) {
current.onFramePainted(canvas, frame, performance.now());
}
};
raf = requestAnimationFrame(loop);
@@ -170,7 +228,9 @@ export function VideoSurface({
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragging.current = true;
drag.current = { x: event.clientX, y: event.clientY, originX: view.x, originY: view.y };
onSelect?.();
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -189,7 +249,7 @@ export function VideoSurface({
const frame = streamClient().latest(camera);
if (!frame) return;
const { drawWidth, drawHeight } = layout(box, frame.width, frame.height, view);
const { drawWidth, drawHeight } = computeLayout(box, frame.width, frame.height, view);
if (drawWidth <= 0 || drawHeight <= 0) return;
// 一次移动一小步,然后把起点挪过来。
@@ -207,14 +267,23 @@ export function VideoSurface({
};
const endDrag = () => {
dragging.current = false;
drag.current = null;
};
const cursor = calibrating ? "crosshair" : view.zoom > MIN_ZOOM ? "grab" : "default";
const zoomed = shown.current.zoom > MIN_ZOOM + 0.05;
const moved = view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0;
// 倍率在右缘的"尺"上用一个游标表示。对数刻度:8 倍是 0 到 1 的满格,2 倍在中段——
// 线性刻度会让 1..2 挤成一条线,而那正是最常待的区间。
const zoomT = Math.log(view.zoom) / Math.log(MAX_ZOOM);
return (
<div
className="video-surface"
className={`vsurface${focused ? " vsurface--focused" : ""}${
calibrating ? " vsurface--calibrating" : ""
}`}
style={{ cursor }}
onWheel={onWheel}
onPointerDown={onPointerDown}
@@ -223,21 +292,53 @@ export function VideoSurface({
onPointerCancel={endDrag}
onDoubleClick={() => onView(IDENTITY_VIEW)}
>
<canvas ref={canvasRef} className="video-canvas" />
<canvas ref={canvasRef} className="vsurface__canvas" />
{emptyText !== "" && <div className="video-empty">{emptyText}</div>}
{/* 取景框:四角括号 + 一条从左上角伸出来指向画面中心的引线。
它们是"这是一个被观察的窗口"的唯一提示,不占像素、不挡画面。 */}
<div className="vsurface__reticle-frame" aria-hidden="true">
<span className="vsurface__corner vsurface__corner--tl" />
<span className="vsurface__corner vsurface__corner--tr" />
<span className="vsurface__corner vsurface__corner--bl" />
<span className="vsurface__corner vsurface__corner--br" />
</div>
{showZoom && view.zoom > MIN_ZOOM + 0.05 && (
<div className="video-badge video-badge--zoom">×{view.zoom.toFixed(2)}</div>
{label && <div className="vsurface__label label">{label}</div>}
{emptyText !== "" && (
<div className="vsurface__empty">
<span className="vsurface__empty-mark" aria-hidden="true" />
<span>{emptyText}</span>
</div>
)}
{(view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0) && (
<button className="video-badge video-badge--reset" onClick={() => onView(IDENTITY_VIEW)}>
{showZoom && zoomed && (
<div className="vsurface__ruler" aria-hidden="true">
<span className="vsurface__ruler-cursor" style={{ top: `${(1 - zoomT) * 100}%` }}>
<span className="num">×{view.zoom.toFixed(2)}</span>
</span>
</div>
)}
{moved && (
<button
type="button"
className="vsurface__reset"
onClick={() => onView(IDENTITY_VIEW)}
onPointerDown={(event) => event.stopPropagation()}
title="复位缩放与平移(也可以双击画面)"
>
<span className="vsurface__reset-key">⤢</span>
复位
</button>
)}
{calibrating && <div className="video-calibrating">校准中:拖动画面挪动瞄准点</div>}
{calibrating && (
<div className="vsurface__calibrating">
<span className="dot dot--live" />
校准中 · 拖动画面挪动瞄准点
</div>
)}
{children}
</div>
@@ -253,40 +354,14 @@ interface CanvasProps {
mirrored: boolean;
crosshair: CrosshairConfig | null;
grid: GridConfig | null;
view: ViewTransform;
hit: HitPulse | null;
hitFeedback: HitFeedbackConfig;
}
interface Box {
clientWidth: number;
clientHeight: number;
}
/** 图像在窗格里的落点与大小(CSS 像素)。 */
function layout(box: Box, frameWidth: number, frameHeight: number, view: ViewTransform) {
const width = box.clientWidth;
const height = box.clientHeight;
if (frameWidth <= 0 || frameHeight <= 0) {
return { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
}
const fit = Math.min(width / frameWidth, height / frameHeight);
const drawWidth = frameWidth * fit * view.zoom;
const drawHeight = frameHeight * fit * view.zoom;
return {
left: (width - drawWidth) / 2 + view.x,
top: (height - drawHeight) / 2 + view.y,
drawWidth,
drawHeight,
};
}
function draw(
canvas: HTMLCanvasElement,
current: CanvasProps,
view: ViewTransform,
frame: { bitmap: ImageBitmap; width: number; height: number } | undefined,
timestamp: number,
) {
@@ -303,10 +378,9 @@ function draw(
const box = { clientWidth: width, clientHeight: height };
// 没有画面时把瞄准器留在窗格中心:录像断了,准星不该跟着消失。
const fallback = { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
const geometry = frame
? layout(box, frame.width, frame.height, current.view)
: fallback;
const geometry: Geometry = frame
? computeLayout(box, frame.width, frame.height, view)
: { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
if (frame) {
ctx.save();
@@ -317,147 +391,49 @@ function draw(
}
// 放大超过 3 倍时关掉插值:这时候要看的是像素本身,双线性插值只会把它糊掉。
ctx.imageSmoothingEnabled = current.view.zoom < 3;
ctx.imageSmoothingEnabled = view.zoom < 3;
ctx.drawImage(frame.bitmap, geometry.left, geometry.top, geometry.drawWidth, geometry.drawHeight);
ctx.restore();
if (current.grid?.visible) {
drawGrid(ctx, geometry, current.grid);
}
if (current.grid?.visible) paintGrid(ctx, geometry, current.grid);
}
// 瞄准点的屏幕位置:图像坐标系里的 (0.5 + offsetX, 0.5 + offsetY)。
const crosshair = current.crosshair;
let aimX = width / 2;
let aimY = height / 2;
if (crosshair && frame && geometry.drawWidth > 0) {
const rawX = geometry.left + (0.5 + crosshair.offsetX) * geometry.drawWidth;
aimX = current.mirrored ? width - rawX : rawX;
aimY = geometry.top + (0.5 + crosshair.offsetY) * geometry.drawHeight;
}
const aim = aimPoint(geometry, crosshair, current.mirrored, width, height);
if (crosshair?.visible) {
drawCrosshair(ctx, aimX, aimY, Math.min(width, height), crosshair);
paintCrosshair(ctx, aim.x, aim.y, Math.min(width, height), crosshair);
}
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
// 伤害数字:画在屏幕坐标里,不跟着缩放走。位置在瞄点右上,向上飘并淡出。
if (current.hit && current.hitFeedback.marker) {
const age = timestamp - current.hit.at;
if (age >= 0 && age < 700) {
const progress = age / 700;
if (age >= 0 && age < HIT_MS) {
const progress = age / HIT_MS;
const size = Math.round(Math.max(15, Math.min(width, height) * 0.052));
ctx.save();
ctx.globalAlpha = 1 - progress;
ctx.fillStyle = "#ff8a8a";
ctx.font = `600 ${Math.round(Math.max(14, Math.min(width, height) * 0.05))}px ui-sans-serif, system-ui, sans-serif`;
ctx.globalAlpha = 1 - progress * progress;
ctx.font = `700 ${size}px "JetBrains Mono", ui-monospace, monospace`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(`-${current.hit.damage}`, aimX + 24, aimY - 26 - progress * 26);
const text = `−${current.hit.damage}`;
const x = aim.x + size * 1.4;
const y = aim.y - size * 1.2 - progress * size * 1.6;
// 两层:一层深色描边保证在任何画面上可读,一层纯色本体。
ctx.lineWidth = Math.max(2, size * 0.16);
ctx.strokeStyle = "rgba(0, 0, 0, 0.75)";
ctx.strokeText(text, x, y);
ctx.fillStyle = "#ff6a52";
ctx.fillText(text, x, y);
ctx.restore();
}
}
}
function drawGrid(
ctx: CanvasRenderingContext2D,
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
grid: GridConfig,
) {
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
ctx.save();
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
ctx.lineWidth = 1;
const x0 = geometry.left;
const y0 = geometry.top;
const x1 = x0 + geometry.drawWidth;
const y1 = y0 + geometry.drawHeight;
if (grid.style === "thirds") {
for (const fraction of [1 / 3, 2 / 3]) {
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
}
} else if (grid.style === "squares") {
const divisions = Math.max(2, grid.divisions);
for (let i = 1; i < divisions; i += 1) {
const fraction = i / divisions;
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
}
} else {
// 中心十字。
line(ctx, x0 + geometry.drawWidth / 2, y0, x0 + geometry.drawWidth / 2, y1);
line(ctx, x0, y0 + geometry.drawHeight / 2, x1, y0 + geometry.drawHeight / 2);
}
ctx.restore();
}
function line(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
// 半像素偏移,让 1px 的线落在像素中心而不是跨两个像素发虚。
ctx.beginPath();
ctx.moveTo(Math.round(x0) + 0.5, Math.round(y0) + 0.5);
ctx.lineTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
ctx.stroke();
}
function drawCrosshair(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
shortSide: number,
config: CrosshairConfig,
) {
const arm = Math.max(3, config.size * shortSide);
const gap = config.gap * shortSide;
const thickness = Math.max(1, config.thickness * shortSide);
// 外描一圈深色:浅色背景下亮绿准星会消失,这一圈让它始终看得见。
const halo = withAlpha("#000000", 0.55);
const stroke = (color: string, width: number, radius: number) => {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
if (config.style === "circle-dot") {
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
} else {
// 四臂,中心留 gap 的空隙。
ctx.moveTo(cx - radius, cy);
ctx.lineTo(cx - gap, cy);
ctx.moveTo(cx + gap, cy);
ctx.lineTo(cx + radius, cy);
ctx.moveTo(cx, cy - radius);
ctx.lineTo(cx, cy - gap);
ctx.moveTo(cx, cy + gap);
ctx.lineTo(cx, cy + radius);
}
ctx.stroke();
};
stroke(halo, thickness + 2, arm + 1);
stroke(config.color, thickness, arm);
// 中心点:`cross` 之外都画。
if (config.style !== "cross") {
const dot = Math.max(1.2, thickness * 0.9);
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, dot + 1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = config.color;
ctx.beginPath();
ctx.arc(cx, cy, dot, 0, Math.PI * 2);
ctx.fill();
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+42 -7
View File
@@ -1,13 +1,37 @@
/** 设置面板里复用的几个小控件。分成单独文件只是为了不把面板本身撑成一千行。 */
/** 设置面板里复用的几个小控件。分成单独文件只是为了不把面板本身撑成一千行。
*
* 它们的排版约定写在 `styles/base.css` 里(`.btn`、`.switch`、`.slider`、`.select`、
* `.field`、`.readout`)。这里只负责把 props 翻译成结构。
*/
import type { ChangeEvent, ReactNode } from "react";
export function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
export function Section({
title,
hint,
children,
index,
id,
}: {
title: string;
hint?: string;
children: ReactNode;
/** 小节编号。设置面板是分节滚动的,编号让"第几节"这件事在滚动时还看得见。 */
index?: number;
/** 锚点。设置面板左侧的竖向导航靠它跳转,滚动时的联动也靠它。 */
id?: string;
}) {
return (
<section className="section">
<h3 className="section__title">{title}</h3>
{hint && <p className="section__hint">{hint}</p>}
<div className="section__body">{children}</div>
<section className="sect" id={id}>
<header className="sect__head">
{index !== undefined && (
<span className="sect__index num">{String(index).padStart(2, "0")}</span>
)}
<h3 className="sect__title">{title}</h3>
<span className="sect__rule" aria-hidden="true" />
</header>
{hint && <p className="sect__hint">{hint}</p>}
<div className="sect__body">{children}</div>
</section>
);
}
@@ -74,6 +98,10 @@ export function Slider({
format?: (value: number) => string;
disabled?: boolean;
}) {
// 轨道的填充比例由 `--fill` 交给 CSS。反正要算,就一起算完,别让 CSS 再猜一次。
const span = max - min;
const ratio = span === 0 ? 0 : (value - min) / span;
return (
<span className="slider">
<input
@@ -83,6 +111,7 @@ export function Slider({
step={step}
value={value}
disabled={disabled}
style={{ ["--fill" as string]: `${Math.max(0, Math.min(1, ratio)) * 100}%` }}
onChange={(event) => onChange(Number(event.target.value))}
/>
<span className="slider__value">{format ? format(value) : value.toFixed(2)}</span>
@@ -136,7 +165,13 @@ export function Button({
title?: string;
}) {
return (
<button className={`button button--${variant}`} onClick={onClick} disabled={disabled} title={title}>
<button
type="button"
className={`btn btn--${variant}`}
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</button>
);
+15 -3
View File
@@ -47,6 +47,14 @@ export function isPreview(): boolean {
const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
const startAsReferee = params.get("preview") === "referee";
/**
* 首次启动的两道门。
*
* `?preview=first-run` 让 `roleChosen` 为假、`?preview=password` 让 `passwordSet` 为假,
* 这样不删配置也能看到那两块界面。它们只在预览里存在,正式构建里这段代码不会被打进去。
*/
const firstRun = params.get("preview") === "first-run";
const needsPassword = params.get("preview") === "password";
// ---------------------------------------------------------------------------
// 可变状态:设置面板里的改动会落到这里,界面因此是可交互的
@@ -223,7 +231,7 @@ function guard() {
function configView(): ConfigView {
return {
role: state.role,
roleChosen: true,
roleChosen: !firstRun,
team: state.team,
teamNames: [...state.teamNames] as [string, string],
cameraId: state.cameraId,
@@ -231,7 +239,7 @@ function configView(): ConfigView {
armorId: state.armorId,
armorIdText: state.armorId === null ? null : hex(state.armorId),
mirror: state.mirror,
passwordSet: state.password !== null,
passwordSet: needsPassword ? false : state.password !== null,
hud: structuredClone(state.hud),
recordingDir: "/预览模式/recordings",
ffmpeg: "ffmpeg",
@@ -273,7 +281,11 @@ function snapshot(): Snapshot {
addr: team === 0 ? "192.168.1.41" : "192.168.1.42",
online: true,
silenceMs: 120,
firmware: 3,
// 预览里模拟一块"版本对得上"的板子:界面上的固件标签因此能被看到。
firmwareVersion: "0.2.0",
firmwareBuild: 34,
protocol: 1,
compatible: true,
}));
const boards = [
+22 -20
View File
@@ -46,12 +46,6 @@ export function clockTime(ms: number | null | undefined): string {
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** 小数位固定的帧率。0 表示"还没读数",显示成 `—` 而不是 `0.0`。 */
export function fps(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "—";
return value.toFixed(1);
}
/** 队伍名的兜底值。与 Rust 侧的 `config::DEFAULT_TEAM_NAMES` 一致。 */
export const DEFAULT_TEAM_NAMES = ["队伍一", "队伍二"];
@@ -69,20 +63,28 @@ export function teamName(team: number, names?: readonly string[] | null): string
return DEFAULT_TEAM_NAMES[team] ?? "队伍";
}
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
/**
* 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。
*
* 返回的是 CSS 变量而不是十六进制:这两个颜色是设计令牌(`tokens.css`),改一处就该
* 全站跟着变。返回值直接塞进 `style` 也行,`var()` 在那里是合法的。
*/
export function teamColor(team: number): string {
return team === 0 ? "#ff5f5f" : "#5aa9ff";
return team === 0 ? "var(--team-0)" : "var(--team-1)";
}
/** 把 `#RRGGBB` 变成 `rgba(...)`。瞄准器颜色要带透明度时用。 */
export function withAlpha(hex: string, alpha: number): string {
const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) return hex;
const value = parseInt(match[1], 16);
const r = (value >> 16) & 0xff;
const g = (value >> 8) & 0xff;
const b = value & 0xff;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
/**
* 固件版本的一行标签:`v0.2.0 · 34`。
*
* 板子太老、没带版本字段时返回"版本未知"——不要拼出一个 `v0.0.0`,那是假信息。
*/
export function firmwareLabel(camera: {
firmwareVersion: string | null;
firmwareBuild: number;
}): string {
if (!camera.firmwareVersion) {
return camera.firmwareBuild > 0 ? `构建 ${camera.firmwareBuild}` : "版本未知";
}
return camera.firmwareBuild > 0
? `v${camera.firmwareVersion} · ${camera.firmwareBuild}`
: `v${camera.firmwareVersion}`;
}
+213
View File
@@ -0,0 +1,213 @@
/**
* 固件维护页的 IPC 与 DTO。
*
* 字段名与 `desktop/src-tauri/src/maintenance.rs` 的 `#[serde(rename_all = "camelCase")]`
* 一一对应;改一边必须改另一边。
*/
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
export interface PortInfo {
portName: string;
label: string;
vid: number | null;
pid: number | null;
likelyEsp: boolean;
}
export interface PartitionEntry {
name: string;
kind: number;
subtype: number;
offset: number;
size: number;
flags: number;
}
export interface FreeRange {
start: number;
size: number;
}
export interface AppInfo {
offset: number;
version: string;
projectName: string;
buildTime: string;
idfVersion: string;
}
export interface DeviceSnapshot {
port: string;
chip: string;
revision: string | null;
flashSize: number;
flashSizeText: string;
mac: string | null;
features: string[];
secureBoot: boolean;
secureDownload: boolean;
flashEncryption: boolean;
securityFlags: number;
flashCryptCnt: number;
partitionTable: PartitionEntry[];
app: AppInfo | null;
freeRanges: FreeRange[];
tableSha256: string;
}
export interface BundleInfo {
path: string;
sourceKind: string;
sourcePath: string;
sourceSha256: string | null;
entryCount: number | null;
name: string;
version: string;
chip: string;
appFile: string;
appSha256: string;
appSize: number;
partitionTableFile: string;
partitionTableSha256: string;
bootloaderFile: string | null;
bootloaderSha256: string | null;
appPartitionSize: number;
flashSize: string;
flashSizeBytes: number;
flashMode: string;
flashFrequency: string;
notes: string | null;
}
export interface SlotCandidate {
offset: number;
size: number;
}
export interface FlashPlan {
port: string;
device: DeviceSnapshot;
bundle: BundleInfo;
candidates: SlotCandidate[];
targetOffset: number;
targetSize: number;
appSize: number;
appPartitionSize: number;
currentFactoryOffset: number;
currentFactorySize: number;
currentTable: PartitionEntry[];
patchedTable: PartitionEntry[];
tableShaBefore: string;
tableShaAfter: string;
oldAppPreserved: boolean;
warnings: string[];
canFlash: boolean;
}
export interface Mismatch {
offset: number;
expected: number;
actual: number;
}
export interface SlotAttempt {
offset: number;
size: number;
writeOk: boolean;
verifyOk: boolean;
mismatches: Mismatch[];
badSectors: number[];
error: string | null;
}
export interface FlashProgress {
phase: string;
message: string;
current: number;
total: number;
}
export interface FlashReport {
ok: boolean;
port: string;
chip: string;
mac: string | null;
bundleName: string;
bundleVersion: string;
targetOffset: number;
targetSize: number;
appSize: number;
appSha256: string;
tableShaBefore: string;
tableShaAfter: string;
attempts: SlotAttempt[];
appVerified: boolean;
tableVerified: boolean;
tableRestored: boolean;
needsRollback: boolean;
bootOk: boolean | null;
bootLog: string;
backupDir: string;
note: string | null;
error: string | null;
startedAt: string;
finishedAt: string;
}
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(command, args);
} catch (raw) {
throw new Error(typeof raw === "string" ? raw : String(raw));
}
}
export const maintenanceApi = {
listPorts: () => call<PortInfo[]>("maintenance_list_ports"),
loadBundle: (path: string) => call<BundleInfo>("maintenance_load_bundle", { path }),
probe: (port: string) => call<DeviceSnapshot>("maintenance_probe", { port }),
plan: (port: string, bundlePath: string) =>
call<FlashPlan>("maintenance_plan", { port, bundlePath }),
flash: (port: string, bundlePath: string, backupApp = true) =>
call<FlashReport>("maintenance_flash", { port, bundlePath, backupApp }),
restoreTable: (port: string, backupDir: string) =>
call<string>("maintenance_restore_table", { port, backupDir }),
cancel: () => call<void>("maintenance_cancel"),
};
/** 原生 .zip 选择。返回 null 表示用户取消。 */
export async function selectBundleZip(): Promise<string | null> {
const selected = await open({
directory: false,
multiple: false,
title: "选择官方固件包(.zip)",
filters: [{ name: "固件包", extensions: ["zip"] }],
});
return typeof selected === "string" ? selected : null;
}
/** 兼容旧包:选择目录。返回 null 表示用户取消。 */
export async function selectBundleDirectory(): Promise<string | null> {
const selected = await open({
directory: true,
multiple: false,
title: "选择官方固件包目录(含 bundle.json)",
});
return typeof selected === "string" ? selected : null;
}
export function formatBytes(value: number): string {
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MiB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${value} B`;
}
export function formatOffset(value: number): string {
return `0x${value.toString(16).toUpperCase().padStart(6, "0")}`;
}
export function shortSha(value: string): string {
return value.length > 16 ? `${value.slice(0, 12)}…${value.slice(-4)}` : value;
}
+187
View File
@@ -0,0 +1,187 @@
/**
* 画面上的叠加图形:瞄准器、网格,以及"图像落在哪"的那一道换算。
*
* # 为什么单独拆出来
*
* 这三段代码现在有两个使用者:`VideoSurface` 里的真画面,和设置面板里的小预览窗。预览窗
* 存在的意义就是"改完立刻看见",如果它另写一套绘制,两边迟早会不一样——而出现差异时,
* 用户看到的是预览里对、画面上偏,这比没有预览更糟。
*
* 所以这里只放**纯函数**:给 ctx 和数字,画完就退出。不碰 React、不碰请求、不认识 store。
*/
import type { CrosshairConfig, GridConfig } from "./types";
/** 图像在窗格里的落点与大小(CSS 像素)。 */
export interface Geometry {
left: number;
top: number;
drawWidth: number;
drawHeight: number;
}
export interface Box {
clientWidth: number;
clientHeight: number;
}
/**
* 把一帧按 `contain` 放进窗格,再乘缩放。
*
* 平移是**屏幕像素**而不是画面比例:拖动时手感必须是 1:1 的,否则放大之后拖动会变黏。
*/
export function computeLayout(
box: Box,
frameWidth: number,
frameHeight: number,
view: { zoom: number; x: number; y: number },
): Geometry {
const width = box.clientWidth;
const height = box.clientHeight;
if (frameWidth <= 0 || frameHeight <= 0) {
return { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
}
const fit = Math.min(width / frameWidth, height / frameHeight);
const drawWidth = frameWidth * fit * view.zoom;
const drawHeight = frameHeight * fit * view.zoom;
return {
left: (width - drawWidth) / 2 + view.x,
top: (height - drawHeight) / 2 + view.y,
drawWidth,
drawHeight,
};
}
/**
* 瞄准点的屏幕位置。
*
* 图像坐标系里的 `(0.5 + offsetX, 0.5 + offsetY)` 再换到屏幕。镜像时横向要翻过来,
* 否则准星会站在画面的另一边——偏移量是相对**摄像头光轴**定的,光轴在镜像之后换了位置。
*/
export function aimPoint(
geometry: Geometry,
crosshair: CrosshairConfig | null,
mirrored: boolean,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
if (!crosshair || geometry.drawWidth <= 0) {
return { x: canvasWidth / 2, y: canvasHeight / 2 };
}
const rawX = geometry.left + (0.5 + crosshair.offsetX) * geometry.drawWidth;
return {
x: mirrored ? canvasWidth - rawX : rawX,
y: geometry.top + (0.5 + crosshair.offsetY) * geometry.drawHeight,
};
}
/** 画网格。网格画在**图像矩形**里:它标的是画面里的比例,用来估距离。 */
export function paintGrid(
ctx: CanvasRenderingContext2D,
geometry: Geometry,
grid: GridConfig,
): void {
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
ctx.save();
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
ctx.lineWidth = 1;
const x0 = geometry.left;
const y0 = geometry.top;
const x1 = x0 + geometry.drawWidth;
const y1 = y0 + geometry.drawHeight;
if (grid.style === "thirds") {
for (const fraction of [1 / 3, 2 / 3]) {
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
}
} else if (grid.style === "squares") {
const divisions = Math.max(2, grid.divisions);
for (let i = 1; i < divisions; i += 1) {
const fraction = i / divisions;
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
}
} else {
line(ctx, x0 + geometry.drawWidth / 2, y0, x0 + geometry.drawWidth / 2, y1);
line(ctx, x0, y0 + geometry.drawHeight / 2, x1, y0 + geometry.drawHeight / 2);
}
ctx.restore();
}
/**
* 画瞄准器。
*
* 两遍描边:先一圈半透明的深色(光环),再画本体。浅色背景下亮绿准星会消失,这一圈让
* 它在任何画面上都看得见——这是可读性要求,不是装饰。
*/
export function paintCrosshair(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
shortSide: number,
config: CrosshairConfig,
): void {
const arm = Math.max(3, config.size * shortSide);
const gap = config.gap * shortSide;
const thickness = Math.max(1, config.thickness * shortSide);
const stroke = (color: string, width: number, radius: number) => {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
if (config.style === "circle-dot") {
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
} else {
ctx.moveTo(cx - radius, cy);
ctx.lineTo(cx - gap, cy);
ctx.moveTo(cx + gap, cy);
ctx.lineTo(cx + radius, cy);
ctx.moveTo(cx, cy - radius);
ctx.lineTo(cx, cy - gap);
ctx.moveTo(cx, cy + gap);
ctx.lineTo(cx, cy + radius);
}
ctx.stroke();
};
stroke("rgba(0, 0, 0, 0.55)", thickness + 2, arm + 1);
stroke(config.color, thickness, arm);
if (config.style !== "cross") {
const dot = Math.max(1.2, thickness * 0.9);
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
ctx.beginPath();
ctx.arc(cx, cy, dot + 1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = config.color;
ctx.beginPath();
ctx.arc(cx, cy, dot, 0, Math.PI * 2);
ctx.fill();
}
}
/** 半像素偏移,让 1px 的线落在像素中心而不是跨两个像素发虚。 */
function line(
ctx: CanvasRenderingContext2D,
x0: number,
y0: number,
x1: number,
y1: number,
): void {
ctx.beginPath();
ctx.moveTo(Math.round(x0) + 0.5, Math.round(y0) + 0.5);
ctx.lineTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
ctx.stroke();
}
+16 -1
View File
@@ -102,7 +102,22 @@ export interface CameraDto {
addr: string;
online: boolean;
silenceMs: number;
firmware: number;
/**
* 固件版本,来自板子广播的 Hello。固件太老、没带这个字段时是 `null`——
* 这时界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
*/
firmwareVersion: string | null;
/** 自增构建号,0 = 未知。 */
firmwareBuild: number;
/** 板子自报的线路协议版本。 */
protocol: number;
/**
* 本客户端能不能和它通信。
*
* 为 `false` 时它不会有画面(帧被严格解析挡掉了),但界面**能说出原因**——
* 这正是把版本号放进 Hello 的全部意义。
*/
compatible: boolean;
}
/** `cam_core::snapshot::ArmorBoardDto` */
+86
View File
@@ -0,0 +1,86 @@
/**
* F11 / Alt+Enter 全屏切换。
*
* 状态由 Tauri 窗口持有,不落 localStorage:现场重启后回到窗口化更安全。
* 400 ms 去抖避免 F11 自动重复把窗口来回切;输入框内不抢键。
*
* # 为什么每一处都要先看 `__TAURI_INTERNALS__`
*
* `getCurrentWindow()` 不是一个"返回 null"的函数:没有 Tauri 注入时它会在**取
* metadata 的那一步直接抛**(`Cannot read properties of undefined`)。而抛在
* `getCurrentWindow().isFullscreen()` 里意味着抛在 effect 的同步段——React 接不住,
* 整棵树会被卸掉,浏览器里 `pnpm dev` 的预览就变成一块空白。
*
* 所以这里不写 try/catch 去兜:先判断在不在 Tauri 里,不在就整段跳过。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
/** 页面里有没有 Tauri 注入。与 `dev-preview.ts` 用同一个判据。 */
function hasTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
export function useFullscreen() {
const [fullscreen, setFullscreen] = useState(false);
const lastToggle = useRef(0);
const toggle = useCallback(async () => {
const now = Date.now();
if (now - lastToggle.current < 400) return;
lastToggle.current = now;
if (!hasTauri()) {
setFullscreen((value) => !value);
return;
}
try {
const window = getCurrentWindow();
const next = !(await window.isFullscreen());
await window.setFullscreen(next);
setFullscreen(next);
} catch {
// 窗口在切换途中被销毁之类。界面不应该为此报错。
}
}, []);
useEffect(() => {
if (!hasTauri()) return;
try {
void getCurrentWindow()
.isFullscreen()
.then(setFullscreen)
.catch(() => undefined);
} catch {
// 同上:没有窗口也要能跑。
}
}, []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const tag = target?.tagName;
if (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target?.isContentEditable
) {
return;
}
if (event.key === "F11" || (event.altKey && event.key === "Enter")) {
event.preventDefault();
void toggle();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [toggle]);
return { fullscreen, toggle };
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 几个只服务于界面节奏的小 hook。
*
* 它们都不碰后端状态——快照每 200 ms 已经推过来了,重算一次读数不需要再发命令。
*/
import { useEffect, useRef, useState } from "react";
/**
* 「多久没有新的输入了」。
*
* 用户端用它做 HUD 的休眠:没人碰、没挨打的时候,角上的标签与提示淡下去,把注意力还给
* 画面本身;一旦有事件(鼠标、滚轮、键、命中)就整块醒过来。这不是装饰——它是"朴素"
* 那一条的落地方式:不动的时候屏幕上的东西越少越好。
*/
export function useIdle(delay = 6000): boolean {
const [idle, setIdle] = useState(false);
const timer = useRef<number | undefined>(undefined);
useEffect(() => {
const wake = () => {
setIdle(false);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setIdle(true), delay);
};
wake();
const events: (keyof WindowEventMap)[] = [
"pointermove",
"pointerdown",
"wheel",
"keydown",
"focus",
];
for (const name of events) window.addEventListener(name, wake, { passive: true });
return () => {
window.clearTimeout(timer.current);
for (const name of events) window.removeEventListener(name, wake);
};
}, [delay]);
return idle;
}
/**
* 一个以固定频率重算的时钟。
*
* 录制时长、"静默 3.2 秒前"这类读数如果没有自己的节拍,就只能等下一份快照才更新——而快照
* 200 ms 一份,等于一直在跳。反过来,秒级的读数每秒动一次就够了。
*
* 返回的 `now` 是墙钟毫秒(`Date.now()`),与快照里的 `startedMs` 同源。
*/
export function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(timer);
}, [intervalMs]);
return now;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 构建信息。**由 `scripts/version.mjs` 生成,请勿手改。**
*
* 版本号与构建号都从 git 历史算出来(见该脚本的文件头),所以界面显示的一定就是
* 这一份二进制的来源,不会出现"装包里写着 0.1.0,其实是两周后的代码"。
*/
export const BUILD = {
version: "0.2.0",
major: 0,
minor: 2,
patch: 0,
/** git 提交数,单调递增。 */
build: 34,
/** 线路协议版本。与固件自报的那个数直接比对。 */
protocol: 1,
/** 生成时的提交日期(`YYYY-MM-DD`)。 */
date: "2026-10-19",
} as const;
/** `"0.2.0+57"` —— 界面上放得下的那一行。 */
export const VERSION_LABEL = `v${BUILD.version}+${BUILD.build}`;
+1 -1
View File
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import { LatencyPage } from './pages/LatencyPage';
import { invoke } from '@tauri-apps/api/core';
import "./styles/global.css";
import "./styles/index.css";
const container = document.getElementById("root");
File diff suppressed because it is too large Load Diff
+348 -82
View File
@@ -1,7 +1,7 @@
/**
* 裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
* 裁判端:两路画面 + 双方装甲条 + 装甲板发现列表 + 截图与录制。
*
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格等宽、血量各自贴在画面的
* 布局的诉求与用户端不同:这里要**一眼比出两边的状态**,所以两格并排、血量各自贴在画面的
* 上角(左格左上、右格右上,与用户端的"自己在左上、对手在右上"是同一套读法)、缩放置互相
* 独立(滚轮只作用于鼠标所在的那一路)。
*
@@ -11,26 +11,37 @@
* 统一的 `preferred-width` 加 `min-width`,文字用绝对定位不参与布局。CSS 的 flex 里
* 用 `flex: 1 1 0` + `min-width: 0` 达到同一件事。
*
* # 这一页最顺手的操作是什么
*
* 裁判的手在鼠标上,而他反复做的是三件事:**比两边**、**看某一边**、**留下证据**。所以
* 这一页的交互不是"再开一个页面",而是让鼠标停在哪里,下面那块控制台就跟着看哪里:
*
* 1. **按下左边那一路**,下方列表立刻只显示这一队的装甲板;再按一次回到全部。这不是开关,
* 是"把注意力放过去",所以它不需要确认,也不需要记住。
* 2. **中间那条竖线可以拖**:谁的那一格更重要,就把它拉宽。双击回到五五开。
* 3. **画面与下方控制台之间的横线也能拖**:录回放的时候把播放器拉大,比双看见更多帧更要紧。
*
* 三件事都不用去找按钮,这就是这一页想要的"顺手"。
*
* # 队名可以直接改
*
* 名字属于这一场比赛,所以它放在两格画面各自下面(见 `TeamNameEditor`),改完立刻反映到
* 血条、装甲板表与等待画面的文字上。命令在 Rust 侧,规范化也在那边统一做。
* 装甲条、装甲板表与等待画面的文字上。命令在 Rust 侧,规范化也在那边统一做。
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArmorTable, StatsLine } from "../components/ArmorTable";
import { HealthBar } from "../components/HealthBar";
import { HitFlash } from "../components/HitFlash";
import { MatchBanner } from "../components/MatchBanner";
import { MediaPanel } from "../components/MediaPanel";
import { StatusBar, Stat } from "../components/StatusBar";
import { TeamNameEditor } from "../components/TeamNameEditor";
import { TopBar } from "../components/TopBar";
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
import { Button } from "../components/controls";
import { teamName } from "../lib/format";
import { clockTime, firmwareLabel, teamColor, teamName } from "../lib/format";
import { api } from "../lib/ipc";
import { useAppStore } from "../lib/store";
import { useAppStore, type HitPulseRecord } from "../lib/store";
import type { HudView, Snapshot } from "../lib/types";
interface Props {
@@ -41,12 +52,25 @@ interface Props {
onInfo: (message: string) => void;
}
/** 底部区域的两个页签。 */
/** 下方控制台的两个页签。 */
type Tab = "armor" | "media";
/** 下方控制台的观察范围:全部,或只看某一路。 */
type Scope = "all" | "a" | "b";
/** 中缝能让到多窄 / 多宽(百分比)。 */
const SPLIT_MIN = 26;
const SPLIT_MAX = 74;
/** 下方控制台的高度范围(像素)。 */
const CONSOLE_MIN = 132;
const CONSOLE_MAX_RATIO = 0.62;
export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }: Props) {
const { lastHit } = useAppStore();
const [tab, setTab] = useState<Tab>("armor");
const [scope, setScope] = useState<Scope>("all");
const [split, setSplit] = useState(50);
const [consoleHeight, setConsoleHeight] = useState(210);
// 两路各自独立的缩放与平移。
const [viewA, setViewA] = useState<ViewTransform>(IDENTITY_VIEW);
@@ -55,11 +79,23 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
// 队名以快照为准(后端会规范化)。
const names = snapshot.config.teamNames;
const rootRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const dragKind = useRef<"split" | "console" | null>(null);
const dragOrigin = useRef({ value: 0, pointer: 0 });
// 命中流水:最近三次,放在控制台头部。裁判在争执时需要的是"刚才谁掉了多少"。
const [ledger, setLedger] = useState<HitPulseRecord[]>([]);
useEffect(() => {
if (!lastHit) return;
setLedger((current) => [lastHit, ...current].slice(0, 3));
}, [lastHit]);
const panes = useMemo(
() =>
[
{
key: "a",
key: "a" as const,
camera: snapshot.slots.cameraA,
armor: snapshot.slots.armorA,
team: 0,
@@ -69,7 +105,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
onView: setViewA,
},
{
key: "b",
key: "b" as const,
camera: snapshot.slots.cameraB,
armor: snapshot.slots.armorB,
team: 1,
@@ -82,6 +118,7 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
stats: snapshot.stats.find((entry) => entry.camera === pane.camera),
health: snapshot.health.find((entry) => entry.team === pane.team) ?? snapshot.health[0],
opponent: snapshot.health.find((entry) => entry.team !== pane.team) ?? null,
cameraInfo: snapshot.cameras.find((entry) => entry.id === pane.camera) ?? null,
})),
[snapshot.slots, snapshot.stats, snapshot.health, viewA, viewB],
);
@@ -98,12 +135,74 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
});
};
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
// ---- 拖拽 ----
<div className="referee-stage">
{panes.map((pane) => {
const beginDrag = (kind: "split" | "console") => (event: React.PointerEvent<HTMLElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragKind.current = kind;
dragOrigin.current = {
value: kind === "split" ? split : consoleHeight,
pointer: kind === "split" ? event.clientX : event.clientY,
};
};
const moveDrag = (event: React.PointerEvent<HTMLElement>) => {
const kind = dragKind.current;
if (!kind) return;
if (kind === "split") {
const box = stageRef.current?.getBoundingClientRect();
if (!box || box.width === 0) return;
const next = ((event.clientX - box.left) / box.width) * 100;
setSplit(Math.max(SPLIT_MIN, Math.min(SPLIT_MAX, next)));
return;
}
const height = rootRef.current?.clientHeight ?? window.innerHeight;
const max = Math.max(CONSOLE_MIN, height * CONSOLE_MAX_RATIO);
const next = dragOrigin.current.value - (event.clientY - dragOrigin.current.pointer);
setConsoleHeight(Math.max(CONSOLE_MIN, Math.min(max, next)));
};
const endDrag = () => {
dragKind.current = null;
};
// 键盘:1 / 2 把注意力放到某一路,0 收回全部。全屏看录像的人手不必离开键位。
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (
target?.tagName === "INPUT" ||
target?.tagName === "TEXTAREA" ||
target?.tagName === "SELECT" ||
event.ctrlKey ||
event.metaKey ||
event.altKey
) {
return;
}
if (event.key === "1") setScope((current) => (current === "a" ? "all" : "a"));
if (event.key === "2") setScope((current) => (current === "b" ? "all" : "b"));
if (event.key === "0") setScope("all");
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const focusTeam = scope === "a" ? 0 : scope === "b" ? 1 : null;
return (
<div className="ref" ref={rootRef}>
<div
className="ref__stage"
ref={stageRef}
style={{ ["--split" as string]: `${split}%` }}
>
{panes.map((pane, index) => {
// 这一下落在哪一格:指定了装甲板就比板子 id,否则退回比队伍号。
const hitRelevant = Boolean(
lastHit &&
@@ -111,83 +210,203 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
);
const label = teamName(pane.team, names);
const focused = scope === pane.key;
const dimmed = scope !== "all" && !focused;
return (
<section key={pane.key} className="pane">
<VideoSurface
camera={pane.camera}
emptyText={
pane.stats?.hasFrames
? ""
: pane.stats?.online
? "已连接,等待画面…"
: `等待${label}的图传`
}
mirrored={snapshot.mirrored}
// 裁判端不给瞄准器:这里没有人在瞄。网格留着,用来比对两路的取景。
crosshair={null}
grid={hud.grid}
view={pane.view}
onView={pane.onView}
hit={
hitRelevant && lastHit
? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt }
: null
}
hitFeedback={hud.hitFeedback}
showZoom
>
{hud.health.visible && (
<div className={`video-hud video-hud--${pane.align}`}>
<HealthBar health={pane.health} config={hud.health} align={pane.align} />
</div>
)}
<section key={pane.key} className="ref__pane">
{index === 1 && (
<div
className="ref__splitter"
role="separator"
aria-orientation="vertical"
title="拖动调整两格的宽度,双击回到五五开"
onPointerDown={beginDrag("split")}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => setSplit(50)}
>
<span className="ref__splitter-grip" />
</div>
)}
<MatchBanner
outcome={pane.health.outcome}
mine={pane.health.name}
theirs={
pane.opponent?.name ?? teamName(pane.team === 0 ? 1 : 0, names)
<div className={`ref__feed${dimmed ? " is-dimmed" : ""}`}>
<VideoSurface
camera={pane.camera}
label={`${pane.key.toUpperCase()} · ${label}`}
emptyText={
pane.stats?.hasFrames
? ""
: pane.stats?.online
? "已连接,等待画面…"
: `等待${label}的图传`
}
/>
mirrored={snapshot.mirrored}
// 裁判端不给瞄准器:这里没有人在瞄。网格留着,用来比对两路的取景。
crosshair={null}
grid={hud.grid}
view={pane.view}
onView={pane.onView}
hit={
hitRelevant && lastHit
? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt }
: null
}
hitFeedback={hud.hitFeedback}
showZoom
focused={focused}
onSelect={() => setScope((current) => (current === pane.key ? "all" : pane.key))}
>
{hud.health.visible && pane.health && (
<div className={`ref__bar ref__bar--${pane.align}`}>
<HealthBar health={pane.health} config={hud.health} align={pane.align} />
</div>
)}
<HitFlash
hit={lastHit}
config={hud.hitFeedback}
relevant={hitRelevant}
label={`${label}被打中`}
/>
</VideoSurface>
<MatchBanner
outcome={pane.health?.outcome ?? null}
mine={pane.health?.name ?? label}
theirs={pane.opponent?.name ?? teamName(pane.team === 0 ? 1 : 0, names)}
/>
<div className="pane__foot">
<TeamNameEditor
team={pane.team}
name={pane.health.name}
onSave={(value) => saveName(pane.team, value)}
/>
<StatsLine stats={pane.stats} diagnostics={snapshot.diagnostics} />
<HitFlash
hit={lastHit}
config={hud.hitFeedback}
relevant={hitRelevant}
label={`${label}被打中`}
/>
</VideoSurface>
<div className="ref__foot">
<TeamNameEditor
team={pane.team}
name={pane.health?.name ?? label}
onSave={(value) => saveName(pane.team, value)}
/>
<StatsLine stats={pane.stats} diagnostics={snapshot.diagnostics} />
{pane.cameraInfo && (
<span
className={`ref__fw num${
pane.cameraInfo.compatible ? "" : " is-bad"
}`}
title={
pane.cameraInfo.compatible
? `固件 ${firmwareLabel(pane.cameraInfo)}`
: `固件 ${firmwareLabel(pane.cameraInfo)},协议 ${
pane.cameraInfo.protocol
} 与本客户端不符,画面会被丢弃`
}
>
{firmwareLabel(pane.cameraInfo)}
</span>
)}
</div>
</div>
</section>
);
})}
</div>
<div className="referee-bottom">
<div className="tabs">
<div
className="ref__console-grip"
role="separator"
aria-orientation="horizontal"
title="拖动调整下方控制台的高度"
onPointerDown={beginDrag("console")}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => setConsoleHeight(210)}
>
<span className="ref__console-grip-bar" />
</div>
<div className="ref__console" style={{ height: consoleHeight }}>
<div className="ref__console-head">
<div className="seg ref__scope" style={{ ["--count" as string]: 3, ["--index" as string]: scope === "all" ? 0 : scope === "a" ? 1 : 2 }}>
<span className="seg__glider" aria-hidden="true" />
{(
[
{ key: "all", label: "全部", hint: "两侧都看" },
{ key: "a", label: "左路", hint: "键 1" },
{ key: "b", label: "右路", hint: "键 2" },
] as const
).map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={scope === item.key}
className="seg__item"
onClick={() => setScope(item.key)}
title={`观察范围:${item.hint}`}
>
<span
className="ref__scope-dot"
style={{ color: item.key === "all" ? "var(--ink-3)" : teamColor(item.key === "a" ? 0 : 1) }}
/>
{item.label}
</button>
))}
</div>
<div className="seg ref__tabs" style={{ ["--count" as string]: 2, ["--index" as string]: tab === "armor" ? 0 : 1 }}>
<span className="seg__glider" aria-hidden="true" />
<button
type="button"
role="tab"
aria-selected={tab === "armor"}
className="seg__item"
onClick={() => setTab("armor")}
>
装甲板
<span className="ref__count num">{snapshot.armorBoards.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={tab === "media"}
className="seg__item"
onClick={() => setTab("media")}
>
录制与回放
{snapshot.recordings.length > 0 && (
<span className="ref__count ref__count--live num">
{snapshot.recordings.length}
</span>
)}
</button>
</div>
<div className="ref__ledger" aria-live="off">
{ledger.length === 0 ? (
<span className="ref__ledger-empty label">NO HITS</span>
) : (
ledger.map((hit) => (
<span
key={hit.seq}
className="ref__ledger-item"
title={`${hit.board === null ? "" : `板 `}${hit.from} → ${hit.to}`}
>
<span
className="ref__ledger-team"
style={{ color: teamColor(hit.team) }}
>
{hit.team === 0 ? "A" : "B"}
</span>
<span className="num">−{hit.damage}</span>
<span className="ref__ledger-time num">{clockTime(hit.atMs)}</span>
</span>
))
)}
</div>
<span className="ref__spacer" />
<button
className={`tab${tab === "armor" ? " tab--active" : ""}`}
onClick={() => setTab("armor")}
>
装甲板({snapshot.armorBoards.length})
</button>
<button className={`tab${tab === "media" ? " tab--active" : ""}`} onClick={() => setTab("media")}>
录制与回放{snapshot.recordings.length > 0 ? ` · 进行中 ${snapshot.recordings.length}` : ""}
</button>
<span className="tabs__spacer" />
<Button
variant="ghost"
type="button"
className="btn btn--quiet"
onClick={() =>
void api
.refresh()
@@ -195,21 +414,68 @@ export function RefereePage({ snapshot, hud, onOpenSettings, onError, onInfo }:
}
>
刷新
</Button>
</button>
<button type="button" className="btn btn--ghost" onClick={onOpenSettings}>
设置
</button>
</div>
<div className="referee-bottom__body">
<div className="ref__console-body">
{tab === "armor" ? (
<ArmorTable
boards={snapshot.armorBoards}
slots={snapshot.slots}
names={names}
focusTeam={focusTeam}
/>
) : (
<MediaPanel snapshot={snapshot} onError={onError} onInfo={onInfo} />
)}
</div>
</div>
<StatusBar
left={
<span className="statusbar__state">
<span className={`dot ${snapshot.clients > 0 ? "dot--live" : "dot--warn"}`} />
两路并看 · 鼠标停在哪一路,滚轮就缩哪一路
</span>
}
center={
<>
{panes.map((pane) => (
<Stat
key={pane.key}
label={pane.key.toUpperCase()}
value={
pane.stats
? pane.stats.hasFrames
? `${pane.stats.fps.toFixed(1)} fps`
: pane.stats.online
? "无线号"
: "离线"
: "未分配"
}
tone={
pane.stats
? pane.stats.hasFrames
? "plain"
: pane.stats.online
? "warn"
: "bad"
: "bad"
}
/>
))}
</>
}
right={
<span className="statusbar__hint">
{scope === "all" ? "按 1 / 2 盯住某一路" : `正在看 ${scope === "a" ? "左路" : "右路"} · 按 0 收回全部`}
</span>
}
/>
</div>
);
}
+144 -58
View File
@@ -1,28 +1,38 @@
/**
* 用户端:一路画面 + 双方血条 + 瞄准器 + 胜负横幅。
* 用户端:一路画面 + 双方装甲条 + 瞄准器 + 胜负结算。
*
* 界面上的东西按"操作手需要什么"排:
*
* - 画面占满除底部状态行以外的全部空间(一个像素都不浪费在装饰上);
* - **血条叠在画面两个上角**:左上角是自己,右上角是对手。血量是这一屏上最需要"同时看到"
* 的两个数,放在画面之外意味着视线要离开取景;
* - 画面占满除顶栏与底部遥测以外的全部空间(一个像素都不浪费在装饰上);
* - **装甲条叠在画面两个上角**:左上角是自己,右上角是对手。血量是这一屏上最需要"同时
* 看到"的两个数,放在画面之外意味着视线要离开取景;
* - 瞄准器画在画面里(`VideoSurface` 负责),位置跟着图像走;
* - 被打中时整屏闪一下红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`);
* - 被打中时四周泛红、瞄点旁跳一个伤害数字——**画面不抖**(见 `HitFeedbackConfig`);
* - 一方归零之后,画面正中给出「胜利 / 击败」。
*
* # 这一页最顺手的操作是什么
*
* 答案是"什么都不用点"。操作手的手在遥控器上,眼睛在画面上。所以这一页的交互全部集中在
* 画面上一个**手势**:滚轮缩放、拖动平移、双击复位。没有任何需要瞄准再点的按钮。
*
* 在此之上只有一条自适应规则:六秒没有输入、也没有挨打,角上的标签与提示自己淡下去
* (`useIdle`);一旦有动静就整块醒过来。不是因为好看,而是因为"朴素"这一条要求静止时
* 屏幕上能少则少。
*/
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { IDENTITY_VIEW, VideoSurface, type ViewTransform } from "../components/VideoSurface";
import { HealthBar, healthTag } from "../components/HealthBar";
import { HitFlash } from "../components/HitFlash";
import { MatchBanner } from "../components/MatchBanner";
import { StatsLine } from "../components/ArmorTable";
import { TopBar } from "../components/TopBar";
import { Button } from "../components/controls";
import { teamName } from "../lib/format";
import { StatusBar, Stat } from "../components/StatusBar";
import { firmwareLabel, teamName, idText } from "../lib/format";
import { useAppStore } from "../lib/store";
import { BUILD } from "../lib/version";
import { useIdle } from "../lib/use-ui";
import type { HudView, Snapshot, TeamHealthDto } from "../lib/types";
import { useState } from "react";
interface Props {
snapshot: Snapshot;
@@ -34,15 +44,10 @@ interface Props {
onCalibrate: (dxFraction: number, dyFraction: number) => void;
}
export function TeamPage({
snapshot,
hud,
onOpenSettings,
calibrating,
onCalibrate,
}: Props) {
export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibrate }: Props) {
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
const { lastHit } = useAppStore();
const idle = useIdle(6000);
const camera = snapshot.config.cameraId;
const stats = useMemo(
@@ -50,9 +55,17 @@ export function TeamPage({
[snapshot.stats, camera],
);
/** 绑定的那块板子(拿不到就是没绑定/还没被发现)。 */
const boundCamera = useMemo(
() => snapshot.cameras.find((entry) => entry.id === camera) ?? null,
[snapshot.cameras, camera],
);
const cameraText = boundCamera?.idText ?? idText(camera);
// 快照里两边都有一份,**按队伍号取**而不是按下标:绑的是队伍二时 `health[0]` 是别人。
const mine: TeamHealthDto =
snapshot.health.find((entry) => entry.team === snapshot.team) ?? snapshot.health[0];
const mine: TeamHealthDto | null =
snapshot.health.find((entry) => entry.team === snapshot.team) ?? snapshot.health[0] ?? null;
const theirs: TeamHealthDto | null =
snapshot.health.find((entry) => entry.team !== snapshot.team) ?? null;
@@ -62,38 +75,52 @@ export function TeamPage({
// 这一下是不是打在我身上:绑定了装甲板就比板子 id,没绑定就退回比队伍号。
const hitIsMine = Boolean(
lastHit &&
(mine?.board != null ? lastHit.board === mine.board : lastHit.team === snapshot.team),
lastHit && (mine?.board != null ? lastHit.board === mine.board : lastHit.team === snapshot.team),
);
const pulse = hitIsMine && lastHit ? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt } : null;
const pulse = hitIsMine && lastHit
? { seq: lastHit.seq, damage: lastHit.damage, at: lastHit.receivedAt }
: null;
// 挨打之后六秒内不睡:正在打的时候,角上的东西必须一直是醒着的。
const recentlyHit = Boolean(lastHit && performance.now() - lastHit.receivedAt < 6000);
const asleep = idle && !recentlyHit && !calibrating;
if (camera === null) {
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
<div className="page__center">
<h2>还没有绑定摄像头</h2>
<p className="dim">
用户端要先选一块图传板(就是装在这台车上的那一块)。绑定会写进配置文件,重启后
仍然生效;以后要改需要密码。
</p>
<Button variant="primary" onClick={onOpenSettings}>
选择摄像头
</Button>
<div className="op">
<div className="op__stage">
<div className="standby">
<span className="standby__code label">NO CAMERA BOUND</span>
<h2 className="standby__title">还没有绑定图传板</h2>
<p className="standby__text">
用户端要先选一块图传板(就是装在这台车上的那一块)。绑定会写进配置文件,
重启后仍然生效;以后要改需要密码。
</p>
<button type="button" className="btn btn--primary btn--lg" onClick={onOpenSettings}>
选择图传板
<span className="btn__key">⌘,</span>
</button>
</div>
</div>
<StatusBar
left={<span className="statusbar__state">待绑定</span>}
right={<span className="statusbar__hint">设置 → 绑定</span>}
/>
</div>
);
}
return (
<div className="page">
<TopBar snapshot={snapshot} onOpenSettings={onOpenSettings} />
<div className="team-stage">
<div className={`op${asleep ? " op--asleep" : ""}`}>
<div className="op__stage">
<VideoSurface
camera={camera}
emptyText={stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:这块板子还没广播"}
label={`CAM · ${cameraText}`}
emptyText={
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:这块板子还没广播"
}
mirrored={snapshot.mirrored}
crosshair={hud.crosshair}
grid={hud.grid}
@@ -107,41 +134,100 @@ export function TeamPage({
>
{hud.health.visible && (
<>
<div className="video-hud video-hud--left">
<HealthBar
health={mine}
config={hud.health}
align="left"
tag={healthTag(mine, snapshot.team)}
/>
</div>
<div className="video-hud video-hud--right">
{theirs && (
{mine && (
<div className="op__bar op__bar--left">
<HealthBar
health={mine}
config={hud.health}
align="left"
tag={healthTag(mine, snapshot.team)}
asleep={asleep}
/>
</div>
)}
{theirs && (
<div className="op__bar op__bar--right">
<HealthBar
health={theirs}
config={hud.health}
align="right"
tag={healthTag(theirs, snapshot.team)}
asleep={asleep}
/>
)}
</div>
</div>
)}
</>
)}
{/* 结论只看自己这一份:后端的 outcome 就是"从这一边看"的。 */}
<MatchBanner outcome={mine?.outcome ?? null} mine={myName} theirs={theirName} />
<MatchBanner
outcome={mine?.outcome ?? null}
mine={myName}
theirs={theirName}
detail={
mine?.outcome === "defeat" && theirs?.hp != null
? `对手剩余 ${theirs.hp}`
: mine?.outcome === "victory" && mine?.hp != null
? `剩余 ${mine.hp}`
: undefined
}
/>
<HitFlash hit={lastHit} config={hud.hitFeedback} relevant={hitIsMine} />
{boundCamera && !boundCamera.compatible && (
<div className="fwfault" role="alert">
<span className="fwfault__tag label">固件不匹配</span>
<span className="fwfault__text">
这块板子自报固件 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
<b>{boundCamera.protocol}</b>;本客户端要协议 <b>{BUILD.protocol}</b>。
它的画面会被丢弃——烧录一版与之匹配的固件,或者换一份对应版本的客户端。
</span>
</div>
)}
</VideoSurface>
</div>
<footer className="statusbar">
<StatsLine stats={stats} diagnostics={snapshot.diagnostics} />
<span className="dim">
滚轮缩放 · 拖动平移(放大后) · 双击复位
{calibrating && " · 校准中:拖动画面挪动瞄准点"}
</span>
</footer>
<StatusBar
left={
<span className="statusbar__state">
<span className={`dot ${stats?.online ? "dot--live" : "dot--dead"}`} />
{stats?.status ?? "还没有这一路的数据"}
</span>
}
center={
<>
<Stat
label="FPS"
value={stats?.hasFrames ? stats.fps.toFixed(1) : "—"}
tone={stats?.hasFrames && stats.fps < 20 ? "warn" : "plain"}
/>
{hud.showStats && stats?.hasFrames && (
<>
<Stat label="RES" value={`${stats.width}×${stats.height}`} />
<Stat
label="LOSS"
value={`${stats.lossPercent.toFixed(1)}%`}
tone={stats.lossPercent > 2 ? "warn" : "plain"}
title="丢包率"
/>
<Stat
label="GAP"
value={stats.gapMs === null ? "—" : `${Math.round(stats.gapMs)}ms`}
title="两帧之间的间隔"
/>
</>
)}
</>
}
right={
<span className="statusbar__hint">
{calibrating
? "校准中 · 拖动画面挪动瞄准点"
: "滚轮 缩放 · 拖动 平移 · 双击 复位"}
</span>
}
/>
</div>
);
}
+754
View File
@@ -0,0 +1,754 @@
/* 基础层:复位、排版默认值,以及全站复用的最小控件。
*
* 这里只放"到处都在用、且长得完全一样"的东西:切角面板、按钮、标签、输入、开关、滑杆、
* 分段控件、细线尺。某一页独有的排布一律写进该页的样式文件,免得这一份又长成第二个
* global.css。
*/
/* ──────────────────────────────────────────────────────────────── 复位 */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: var(--void);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--fs-12);
line-height: 1.5;
letter-spacing: var(--track-tight);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
/* 这是一个给现场看的仪表:选中文字几乎只会是误触。需要复制的地方(日志、地址)
自己把 user-select 打开。 */
user-select: none;
cursor: default;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
cursor: pointer;
}
input,
select,
textarea {
font: inherit;
color: inherit;
}
code,
kbd {
font-family: var(--font-mono);
font-size: 0.95em;
}
::selection {
background: var(--primary);
color: #0a0203;
}
/* 滚动条:细、暗、悬停才亮。滚动条完全不画会让人以为日志只有一屏。 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--line-2);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--line-3);
background-clip: padding-box;
}
/* ──────────────────────────────────────────────────────────── 排版工具 */
/* 读数:等宽 + 等宽数字。`tabular-nums` 是关键——列里数字跳动时不能左右抖。 */
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
/* 微标签:全大写、宽字距、暗。只放代号,不放句子。 */
.label {
font-family: var(--font-mono);
font-size: var(--fs-10);
line-height: 1;
letter-spacing: var(--track-wide);
text-transform: uppercase;
color: var(--ink-3);
}
.dim {
color: var(--ink-2);
}
.faint {
color: var(--ink-3);
}
.hot {
color: var(--primary);
}
.warn {
color: var(--warn);
}
.danger {
color: var(--error);
}
.ok {
color: var(--ok);
}
.link {
color: var(--ink);
}
/* 正文里要点出来的词。JSX 里的 `**` 不会被渲染成粗体,它会原样显示——那不该出现在
现场的屏幕上,所以强调一律走这个类。 */
.em {
color: var(--ink);
font-weight: 500;
}
/* 状态点:一颗 5px 的方块。它是全站唯一表示"某物在线/在跑"的记号。 */
.dot {
display: inline-block;
flex: none;
width: 5px;
height: 5px;
background: currentColor;
}
.dot--live {
color: var(--ok);
box-shadow: 0 0 0 2px rgba(63, 201, 138, 0.18);
animation: dot-breathe 2.4s var(--e-hud) infinite;
}
.dot--warn {
color: var(--warn);
}
.dot--dead {
color: var(--ink-4);
}
@keyframes dot-breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.42;
}
}
/* ──────────────────────────────────────────────────────────── 面板 */
/* 全站唯一的"卡片":一个矩形,一圈 1px 线。没有切角、没有圆角、没有阴影。
*
* 形状上的克制是刻意的——这套界面里"斜的"只允许出现在一处,就是装甲条(平行四边形),
* 因为它要在满屏斜线与矩形里被一眼认成"这是血,不是别的东西"。别的元素一旦也开始切角,
* 那个形状就不再携带信息了。 */
.plate {
--plate-bg: var(--panel);
--plate-line: var(--line-2);
position: relative;
background: var(--plate-bg);
border: 1px solid var(--plate-line);
}
/* 四角定位括号。只在"被框住的东西"上用:取景器、结算、预览窗。 */
.corners {
position: relative;
}
.corners::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
--c: var(--line-3);
--l: 14px;
background:
linear-gradient(var(--c), var(--c)) left top / var(--l) 1px no-repeat,
linear-gradient(var(--c), var(--c)) left top / 1px var(--l) no-repeat,
linear-gradient(var(--c), var(--c)) right top / var(--l) 1px no-repeat,
linear-gradient(var(--c), var(--c)) right top / 1px var(--l) no-repeat,
linear-gradient(var(--c), var(--c)) left bottom / var(--l) 1px no-repeat,
linear-gradient(var(--c), var(--c)) left bottom / 1px var(--l) no-repeat,
linear-gradient(var(--c), var(--c)) right bottom / var(--l) 1px no-repeat,
linear-gradient(var(--c), var(--c)) right bottom / 1px var(--l) no-repeat;
}
/* 细分隔线:一排 1px 短刻线。用于"这里换了一组读数",比实线轻,比留白明确。 */
.rule {
height: 3px;
background-image: linear-gradient(90deg, var(--line-2) 0 5px, transparent 5px 9px);
background-size: 9px 1px;
background-repeat: repeat-x;
background-position: 0 1px;
}
/* ─────────────────────────────────────────────────────────────── 按钮 */
.btn {
--btn-line: var(--line-2);
--btn-bg: var(--panel-2);
--btn-ink: var(--ink);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
height: 28px;
padding: 0 var(--s3);
border: 1px solid var(--btn-line);
background: var(--btn-bg);
color: var(--btn-ink);
font-size: var(--fs-11);
letter-spacing: var(--track);
white-space: nowrap;
transition:
color var(--d-2) var(--e-out),
border-color var(--d-2) var(--e-out),
background-color var(--d-2) var(--e-out);
}
/* 悬停:从左往右铺一层极淡的底色。这不是"高亮",是"确认指针在这上面"。
用伪元素的 scaleX 而不是 background-color,因为它能在不重排的情况下走完整条按钮。 */
.btn::after {
content: "";
position: absolute;
inset: 0;
background: currentColor;
opacity: 0.07;
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--d-3) var(--e-out);
pointer-events: none;
}
.btn:hover::after {
transform: scaleX(1);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn--primary {
--btn-line: var(--primary-line);
--btn-bg: var(--primary-soft);
--btn-ink: var(--primary-hi);
}
.btn--primary:hover {
--btn-line: var(--primary);
}
.btn--danger {
--btn-line: var(--error-line);
--btn-bg: rgba(255, 84, 73, 0.1);
--btn-ink: var(--error);
}
.btn--ghost {
--btn-bg: transparent;
--btn-ink: var(--ink-2);
}
.btn--ghost:hover {
--btn-ink: var(--ink);
--btn-line: var(--line-3);
}
.btn--quiet {
--btn-bg: transparent;
--btn-line: transparent;
--btn-ink: var(--ink-3);
}
.btn--quiet:hover {
--btn-ink: var(--primary);
}
.btn--lg {
height: 34px;
padding: 0 var(--s4);
font-size: var(--fs-12);
}
.btn[aria-pressed="true"] {
--btn-line: var(--line-hot);
--btn-ink: var(--primary-hi);
}
/* ─────────────────────────────────────────────────────────────── 标签 */
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
height: 18px;
padding: 0 6px;
border: 1px solid var(--line-2);
color: var(--ink-2);
font-family: var(--font-mono);
font-size: var(--fs-10);
letter-spacing: var(--track);
text-transform: uppercase;
white-space: nowrap;
}
.tag--hot {
color: var(--primary-hi);
border-color: var(--primary-line);
background: var(--primary-soft);
}
.tag--danger {
color: var(--error);
border-color: var(--error-line);
background: var(--error-soft);
}
.tag--warn {
color: var(--warn);
border-color: var(--warn-line);
background: var(--warn-soft);
}
.tag--ok {
color: var(--ok);
border-color: var(--ok-line);
background: var(--ok-soft);
}
.tag--link {
color: var(--ink);
border-color: var(--line-3);
background: var(--primary-soft);
}
.tag--team-0 {
color: var(--team-0);
border-color: rgba(255, 107, 94, 0.44);
background: var(--team-0-soft);
}
.tag--team-1 {
color: var(--team-1);
border-color: rgba(74, 155, 255, 0.44);
background: var(--team-1-soft);
}
/* 能点的标签(演示模式、说明)。悬停给一条下划线,不改变尺寸。 */
.tag--button {
cursor: pointer;
border-style: dashed;
}
.tag--button:hover {
border-style: solid;
color: var(--ink);
}
/* ────────────────────────────────────────────────────── 输入与下拉 */
.input,
.select {
width: 100%;
height: 28px;
padding: 0 var(--s2);
background: var(--deep);
border: 1px solid var(--line-2);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--fs-12);
letter-spacing: var(--track-tight);
outline: none;
transition:
border-color var(--d-2) var(--e-out),
background-color var(--d-2) var(--e-out);
}
/* 聚焦不是"发光",是"这条通道接上了":左边多一条 2px 的橙脊。 */
.input:focus,
.select:focus {
border-color: var(--line-hot);
background: #080d16;
box-shadow: inset 2px 0 0 0 var(--primary);
}
/* WebView 会在密码框右侧画一个"显示密码"的小眼睛。它不是这个界面的一部分,
而且在现场等于一键把密码亮给旁边的人看。去掉。 */
.input::-ms-reveal,
.input::-ms-clear {
display: none;
}
.input::-webkit-credentials-auto-fill-button,
.input::-webkit-contacts-auto-fill-button {
visibility: hidden;
display: none !important;
pointer-events: none;
}
.input::placeholder {
color: var(--ink-4);
}
.input:disabled,
.select:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.select {
appearance: none;
padding-right: 24px;
cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
background-position: calc(100% - 12px) 12px, calc(100% - 8px) 12px;
background-size: 4px 4px, 4px 4px;
background-repeat: no-repeat;
}
.select:hover {
border-color: var(--line-3);
}
.select option {
background: var(--panel);
color: var(--ink);
}
/* ─────────────────────────────────────────────────────────────── 开关 */
/* 方块的二值读数,不是 iOS 药丸。左边是轨道,右边是标签;打开时轨道整体变成橙的暗调,
滑块推到右边并点亮。 */
.switch {
display: flex;
align-items: center;
gap: var(--s2);
min-height: 22px;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch__track {
position: relative;
flex: none;
width: 32px;
height: 16px;
border: 1px solid var(--line-2);
background: var(--deep);
transition:
background-color var(--d-2) var(--e-out),
border-color var(--d-2) var(--e-out);
}
.switch__knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
background: var(--ink-4);
transition:
transform var(--d-3) var(--e-hud),
background-color var(--d-2) var(--e-out);
}
.switch__label {
font-size: var(--fs-12);
color: var(--ink-2);
transition: color var(--d-2) var(--e-out);
}
.switch:hover .switch__label {
color: var(--ink);
}
.switch:hover .switch__track {
border-color: var(--line-3);
}
.switch input:checked + .switch__track {
border-color: var(--line-3);
background: var(--panel-3);
}
.switch input:checked + .switch__track .switch__knob {
transform: translateX(16px);
background: var(--ink);
}
.switch input:checked ~ .switch__label {
color: var(--ink);
}
.switch input:focus-visible + .switch__track {
border-color: var(--primary);
}
.switch--disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─────────────────────────────────────────────────────────────── 滑杆 */
/* 轨道用渐变按 `--fill` 分成"已取用/未取用"两段,滑块是一块 6×14 的橙。刻度线用
`--ticks` 决定,节奏与读数一致:拖的时候眼睛看的是刻度,不是像素。 */
.slider {
display: flex;
align-items: center;
gap: var(--s2);
width: 100%;
min-width: 0;
}
.slider input[type="range"] {
--fill: 0%;
appearance: none;
flex: 1;
min-width: 0;
height: 18px;
background: transparent;
outline: none;
cursor: ew-resize;
}
.slider input[type="range"]::-webkit-slider-runnable-track {
height: 2px;
background: linear-gradient(
to right,
var(--ink-2) 0 var(--fill),
var(--line-2) var(--fill) 100%
);
}
.slider input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 6px;
height: 14px;
margin-top: -6px;
background: var(--ink);
border: none;
transition:
transform var(--dur-short3) var(--m3-standard),
background-color var(--dur-short3) var(--m3-standard);
}
.slider input[type="range"]:hover::-webkit-slider-thumb {
transform: scaleY(1.15);
}
.slider input[type="range"]:focus-visible::-webkit-slider-thumb,
.slider input[type="range"]:active::-webkit-slider-thumb {
background: var(--primary);
}
.slider input[type="range"]:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.slider__value {
flex: none;
min-width: 56px;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: var(--fs-11);
color: var(--ink-2);
text-align: right;
}
/* ────────────────────────────────────────────────────── 分段控件 */
/* 一排互斥选项。选中的记号是**底沿一条会滑动的 2px 线**(`--index` 决定它停在哪),
不是整块红底:一组按钮里只该有一个地方被染色,而"我在哪一组"这条信息由位置本身说。 */
.seg {
position: relative;
display: inline-flex;
align-items: stretch;
border-bottom: 1px solid var(--line-2);
}
.seg__glider {
position: absolute;
left: 0;
bottom: -1px;
height: 2px;
width: calc(100% / var(--count, 1));
background: var(--primary);
transform: translateX(calc(100% * var(--index, 0)));
transition: transform var(--dur-medium2) var(--m3-emphasized);
pointer-events: none;
}
.seg__item {
position: relative;
z-index: 1;
flex: 1 1 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
height: 24px;
padding: 0 var(--s3);
font-family: var(--font-mono);
font-size: var(--fs-11);
letter-spacing: var(--track);
color: var(--ink-3);
white-space: nowrap;
transition: color var(--d-2) var(--e-out);
}
.seg__item:hover {
color: var(--ink-2);
}
.seg__item[aria-selected="true"] {
color: var(--ink);
}
/* ─────────────────────────────────────────────────────────────── 读数 */
/* 一条"标签 —— 值"的对齐行。值靠右、等宽、可换行(长的路径要能折)。 */
.readout {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s3);
padding: 3px 0;
font-size: var(--fs-11);
}
.readout__label {
flex: none;
color: var(--ink-3);
letter-spacing: var(--track);
}
.readout__value {
min-width: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--ink);
text-align: right;
overflow-wrap: anywhere;
}
/* ──────────────────────────────────────────────────────────────── 字段 */
.field {
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
gap: var(--s1) var(--s3);
align-items: center;
}
.field__label {
font-size: var(--fs-11);
color: var(--ink-2);
letter-spacing: var(--track-tight);
}
.field__control {
min-width: 0;
display: flex;
align-items: center;
}
.field__hint {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--fs-10);
color: var(--ink-4);
letter-spacing: var(--track-tight);
}
/* ──────────────────────────────────────────────────────── 加载指示 */
/* 一条从左往右反复跑的橙线。只在"真的在等"的地方用,不用来当装饰。 */
.scan {
position: relative;
overflow: hidden;
height: 1px;
background: var(--line);
}
.scan::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 34%;
background: linear-gradient(90deg, transparent, var(--primary), transparent);
animation: scan-run 1.4s var(--m3-emphasized) infinite;
}
@keyframes scan-run {
from {
transform: translateX(-100%);
}
to {
transform: translateX(320%);
}
}
/* 骨架方块:还没拿到数据时的占位。它不闪,只是比周围暗一档。 */
.skeleton {
background: var(--panel-2);
height: 8px;
}
File diff suppressed because it is too large Load Diff
+573
View File
@@ -0,0 +1,573 @@
/* 控制台:装甲板表、录制与回放、队伍名。
*
* 这些是"低头看"的东西,所以它们可以密、可以有刻度和线——画面那一层不可以。
*/
/* ───────────────────────────────────────────────────── 装甲板列表 */
.armor {
display: flex;
flex-direction: column;
min-width: 0;
}
/* 七列的宽度是固定的,除了最后一列(槽位)用剩余空间。定宽的意义在于:血量从 100 变成
7 时,后面的列不许挪位——那样每掉一次血整张表都会抖一下。 */
.armor__head,
.armor__row {
display: grid;
/* 地址列按内容吃掉自己的宽度,剩下的全给"槽位"——否则长窗口下中间会空出一大片,
而真正需要横向余量的槽位说明被挤成一截。 */
grid-template-columns:
46px 118px minmax(110px, max-content) 118px
132px 104px minmax(96px, 1fr);
gap: var(--s2);
align-items: center;
padding: 0 var(--s2);
}
.armor__head {
height: 22px;
border-bottom: 1px solid var(--line-2);
}
.armor__col {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.armor__head .armor__col {
font-family: var(--font-mono);
font-size: var(--fs-9);
letter-spacing: var(--track-wide);
color: var(--ink-4);
}
.armor__rows {
display: flex;
flex-direction: column;
}
.armor__row {
height: 26px;
border-bottom: 1px solid var(--line);
font-size: var(--fs-11);
transition: background-color var(--d-2) var(--e-out);
}
.armor__row:hover {
background: var(--panel-2);
}
.armor__row.is-offline {
opacity: 0.45;
}
.armor__row.is-unassigned .armor__slot {
color: var(--ink-4);
}
/* 四条上报的位图。它一眼回答"这块板子是不是只在报一条"。 */
.armor__plates {
display: inline-flex;
gap: 2px;
align-items: center;
}
.armor__plate {
width: 6px;
height: 10px;
background: var(--line-2);
transition: background-color var(--d-2) var(--e-out);
}
.armor__plate.is-on {
background: var(--ink-2);
}
.armor__id {
color: var(--ink);
letter-spacing: var(--track-tight);
}
.armor__addr {
color: var(--ink-3);
font-size: var(--fs-10);
}
.armor__team-bit {
margin-left: 6px;
padding: 0 4px;
border: 1px solid currentColor;
font-size: var(--fs-9);
opacity: 0.6;
}
.armor__col--hp {
display: flex;
align-items: center;
gap: var(--s2);
}
.armor__hp {
flex: none;
min-width: 26px;
color: var(--ink);
font-size: var(--fs-12);
}
.armor__hp.is-dead {
color: var(--error);
}
.armor__hp-track {
flex: 1;
min-width: 0;
height: 3px;
background: var(--line-2);
overflow: hidden;
}
.armor__hp-fill {
display: block;
height: 100%;
transition: width var(--d-3) var(--e-out);
}
.armor__silence {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: var(--fs-10);
}
.armor__silence.is-good {
color: var(--ink-2);
}
.armor__silence.is-warn {
color: var(--warn);
}
.armor__silence.is-bad {
color: var(--error);
}
.armor__slot {
color: var(--ink-2);
font-size: var(--fs-10);
}
.armor__empty {
padding: var(--s4) var(--s2);
max-width: 620px;
color: var(--ink-3);
font-size: var(--fs-11);
line-height: 1.8;
}
.armor__empty kbd {
padding: 0 4px;
border: 1px solid var(--line-3);
color: var(--ink-2);
font-size: var(--fs-10);
}
/* ───────────────────────────────────────────────────── 帧率读数行 */
.statsline {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: var(--fs-10);
color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
}
.statsline.is-offline {
color: var(--warn);
}
.statsline--empty {
color: var(--ink-4);
}
.statsline__status {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.statsline__sep {
width: 1px;
height: 9px;
background: var(--line-2);
flex: none;
}
.statsline__nums {
color: var(--ink-3);
font-size: var(--fs-10);
}
/* ───────────────────────────────────────────────────────── 队名 */
/* 静止时它是一条读数:一个队色方块 + 一个看着像文本的值。
点进去才展开边框、键位提示与字数。两格画面下面不该永远趴着两个表单控件。 */
.tname {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 0 1 auto;
padding: 2px 4px 2px 0;
border: 1px solid transparent;
transition: border-color var(--d-2) var(--e-out);
}
.tname.is-editing {
border-color: var(--line-3);
background: var(--deep);
}
.tname.is-disabled {
opacity: 0.5;
}
.tname__label {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: var(--fs-10);
font-weight: 700;
border: 1px solid currentColor;
line-height: 1;
}
.tname__input {
flex: 1;
min-width: 60px;
max-width: 150px;
padding: 1px 2px;
background: transparent;
border: none;
border-bottom: 1px dashed var(--line-2);
color: var(--ink);
font-size: var(--fs-12);
outline: none;
transition: border-color var(--d-2) var(--e-out);
}
.tname__input:hover:not(:disabled) {
border-bottom-color: var(--line-3);
}
.tname.is-editing .tname__input {
border-bottom: 1px solid var(--primary);
}
.tname__input::placeholder {
color: var(--ink-4);
}
/* 提示只在编辑时出现:平时它会把这一行撑宽,而这一行要装帧率。 */
.tname__hint {
display: none;
align-items: center;
gap: var(--s2);
flex: none;
}
.tname.is-editing .tname__hint {
display: flex;
}
.tname__keys {
font-size: var(--fs-9);
color: var(--ink-4);
letter-spacing: var(--track-tight);
}
.tname__keys kbd {
padding: 0 3px;
margin-right: 1px;
border: 1px solid var(--line-2);
color: var(--ink-3);
font-size: var(--fs-9);
}
.tname__count {
font-size: var(--fs-9);
color: var(--ink-4);
}
/* ─────────────────────────────────────────────────── 录制与回放 */
.media {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 0;
}
.media__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s2);
}
.media__rec-glyph {
width: 7px;
height: 7px;
background: currentColor;
}
.media__divider {
width: 1px;
height: 16px;
background: var(--line-2);
margin: 0 var(--s1);
}
.media__cam {
font-size: var(--fs-9);
color: var(--ink-3);
}
.media__spacer {
flex: 1;
}
/* —— 录制中的横条 —— */
.media__strips {
display: flex;
flex-direction: column;
gap: 3px;
}
.strip {
display: flex;
align-items: center;
gap: var(--s2);
padding: 4px var(--s2);
background: rgba(255, 84, 73, 0.08);
border: 1px solid var(--error-line);
border-left-width: 2px;
font-size: var(--fs-11);
}
.strip__pulse {
width: 6px;
height: 6px;
background: var(--error);
animation: rec-blink 1.1s steps(2, start) infinite;
}
.strip__label {
color: var(--error);
}
.strip__cam {
color: var(--ink);
font-size: var(--fs-10);
}
.strip__stats {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
font-size: var(--fs-10);
}
.strip__dot {
width: 2px;
height: 2px;
background: var(--ink-4);
}
.strip__warn {
color: var(--warn);
font-size: var(--fs-10);
}
.strip__error {
color: var(--error);
font-size: var(--fs-10);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.strip > .btn {
margin-left: auto;
height: 22px;
}
/* —— 文件列表 —— */
.media__list {
display: flex;
flex-direction: column;
min-width: 0;
}
.media__head,
.media__row {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) 88px 116px;
gap: var(--s2);
align-items: center;
padding: 0 var(--s2);
}
.media__head {
height: 22px;
border-bottom: 1px solid var(--line-2);
}
.media__head .media__col {
font-family: var(--font-mono);
font-size: var(--fs-9);
letter-spacing: var(--track-wide);
color: var(--ink-4);
}
.media__row {
height: 26px;
border-bottom: 1px solid var(--line);
font-size: var(--fs-11);
transition: background-color var(--d-2) var(--e-out);
}
.media__row:hover {
background: var(--panel-2);
}
.media__col {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media__col--size {
color: var(--ink-3);
font-size: var(--fs-10);
text-align: right;
}
.media__col--acts {
display: flex;
justify-content: flex-end;
gap: 2px;
}
.media__col--acts .btn {
height: 20px;
padding: 0 6px;
font-size: var(--fs-10);
}
.media__kind {
display: inline-block;
padding: 1px 5px;
border: 1px solid var(--line-3);
font-family: var(--font-mono);
font-size: var(--fs-9);
letter-spacing: var(--track);
color: var(--ink-3);
}
.media__kind--video {
color: var(--ink);
border-color: var(--line-3);
}
.media__kind--image {
color: var(--ink-2);
}
.media__name {
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--fs-10);
}
.media__empty {
padding: var(--s4) var(--s2);
color: var(--ink-3);
font-size: var(--fs-11);
}
/* —— 播放器 —— */
.playback {
margin-top: var(--s1);
border: 1px solid var(--line-2);
background: #000;
}
.playback__head {
display: flex;
align-items: center;
gap: var(--s2);
padding: 4px var(--s2);
background: var(--panel-2);
border-bottom: 1px solid var(--line-2);
}
.playback__label {
color: var(--primary);
}
.playback__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-10);
color: var(--ink-2);
}
.playback__spacer {
flex: 1;
}
.playback__head .btn {
height: 20px;
}
.playback__video {
display: block;
width: 100%;
max-height: 300px;
background: #000;
}
.playback__fallback {
display: flex;
flex-direction: column;
gap: var(--s3);
align-items: flex-start;
padding: var(--s3);
background: var(--panel);
}
.playback__warn {
color: var(--warn);
font-size: var(--fs-11);
line-height: 1.7;
}
+73
View File
@@ -0,0 +1,73 @@
/* 字体基线:拉丁文走 JetBrains Mono,中文走 HarmonyOS Sans SC。
*
* # 为什么要两套字
*
* 这套界面是"终端/仪表",拉丁字母与数字承担全部读数(帧率、血量、地址、倍率、时间码),
* 它们的首要任务是**对齐**——等宽字体里 `1` 与 `8` 占同一格,一列数字跳变时不会左右抖。
* 中文承担叙述与标签,它的首要任务是**在小字号下看得清**,这正好是 HarmonyOS Sans 的
* 长处(字面方正、撇捺收敛、11px 仍然立得住)。
*
* # 两者怎么合到一句话里
*
* 不做"语言判断",靠**逐字回退**:`--font-ui` 把 JetBrains Mono 写在前面,它里面没有汉字,
* 浏览器就逐个汉字往下找,落到 HarmonyOS Sans SC。于是"血量 87"里数字是等宽的、汉字是
* HarmonyOS 的,一件 font-family 声明同时满足两个要求。
*
* # 文件在哪
*
* 两个字体都以 woff2 自托管在 `public/fonts/`,随 Tauri 的 frontendDist 一起进安装包,
* 运行时不碰网络(CSP 里 `default-src 'self'` 也不允许)。
*
* - `public/fonts/harmonyos-sans-sc/` —— 由 cn-font-split 切片的 82 个 unicode-range 分片
* × 3 个字重。分片的好处是**只下载页面真正用到的那几十个**,而不是一上来吞 14 MB。
* 三个字重各有一份 CSS,在 `index.html` 里直接 link(它们是生成物,不该混进源码)。
* - `public/fonts/jetbrains-mono/` —— 拉丁子集,三个字重,各 ~21 kB,下面的 @font-face 声明。
*/
/* 拉丁子集只需要声明一次。unicode-range 显式写出来有两个作用:
*
* 1. 明确告诉浏览器"这一份只管这些码位",剩下的(汉字、全角标点、框线字符)交给后面的
* HarmonyOS Sans SC,不会被一个缺字的字体挡住;
* 2. 三个字重共用同一份范围,换成 `local()` 或未来的可变字体时不会漏掉某个字重。
*
* 这个范围与 Google Fonts 的 `latin` 子集一致:Basic Latin + Latin-1 补充 + 常用标点
* (U+2000-206F,也就是 `·`、`—`、`×`、`≤`、方向引号那些)+ 少量箭头与数学符号。 */
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
/* 仅拉丁(读数、代号、路径)。落到 monospace 是最后一道保险。 */
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* 仅中文(长篇叙述、按钮文字)。 */
--font-cn: "HarmonyOS Sans SC", "HarmonyOS Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
/* 界面默认:先找等宽的拉丁,缺字落到 HarmonyOS。绝大多数元素用这一个。 */
--font-ui: "JetBrains Mono", "HarmonyOS Sans SC", "HarmonyOS Sans", ui-monospace,
"PingFang SC", monospace;
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
/* 样式入口。
*
* 分成几份而不是一个文件,是按"谁会一起改"切的:
*
* - `fonts` 字体与字形回退(几乎不动)
* - `tokens` 颜色、字号、间距、动效曲线、层叠(一动全动)
* - `base` 复位与全站复用的最小控件(按钮、开关、滑杆、切角面板……)
* - `chrome` 外壳:顶栏、通讯带、状态行、启动序列、门、覆盖层
* - `stage` 舞台:取景器、装甲条、命中、结算
* - `console` 控制台:装甲板表、录制与回放、队伍名
* - `settings` 设置抽屉
* - `maintenance` 固件维护
*
* 顺序有意义:后面的文件可以用前面的令牌与原子,但不能反过来。
*/
@import "./fonts.css";
@import "./tokens.css";
@import "./base.css";
@import "./chrome.css";
@import "./stage.css";
@import "./console.css";
@import "./settings.css";
@import "./maintenance.css";
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
/* 设置抽屉。
*
* 它是唯一一处"会盖住画面"的界面,所以它必须值得这个代价:一条竖向导航 + 一块常驻的
* 实时预览。预览是这一页存在的理由,见 `SettingsPanel.tsx` 的文件头。
*/
.drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: var(--z-panel);
width: min(620px, 94vw);
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--line-3);
box-shadow: -34px 0 70px rgba(0, 0, 0, 0.55);
animation: drawer-in var(--d-3) var(--e-out) both;
}
/* 左侧一条橙脊:抽屉是从右边推进来的,这条线标记它和画面之间的那道边。 */
/* 抽屉与画面之间就是一条 1px 线。原先那条渐变强调竖线去掉了——抽屉是"我在改设置",
不是"出事了"。 */
@keyframes drawer-in {
from {
opacity: 0.4;
transform: translateX(28px);
}
}
.drawer__head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
height: var(--h-topbar);
padding: 0 var(--s4);
border-bottom: 1px solid var(--line-2);
background: linear-gradient(180deg, #0c1220, #080c15);
}
.drawer__title {
display: flex;
align-items: baseline;
gap: var(--s2);
min-width: 0;
}
.drawer__title-cn {
font-size: var(--fs-15);
font-weight: 500;
}
.drawer__title-en {
color: var(--ink-4);
}
.drawer__head-actions {
display: flex;
align-items: center;
gap: var(--s2);
}
.drawer__head-actions .btn {
height: 24px;
}
.drawer__close {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: var(--ink-3);
border: 1px solid transparent;
font-size: var(--fs-11);
transition:
color var(--d-2) var(--e-out),
border-color var(--d-2) var(--e-out);
}
.drawer__close:hover {
color: var(--ink);
border-color: var(--line-3);
}
.drawer__locked {
flex: none;
display: flex;
align-items: center;
gap: var(--s2);
padding: 6px var(--s4);
background: var(--warn-soft);
border-bottom: 1px solid var(--warn-line);
color: var(--warn);
font-size: var(--fs-11);
}
.drawer__unlock {
color: var(--primary-hi);
text-decoration: underline;
text-underline-offset: 3px;
font-size: var(--fs-11);
}
.drawer__main {
flex: 1;
min-height: 0;
display: flex;
}
/* ─────────────────────────────────────────────────── 竖向导航 */
.rail {
flex: none;
width: 108px;
display: flex;
flex-direction: column;
gap: 1px;
padding: var(--s2) 0;
overflow-y: auto;
border-right: 1px solid var(--line);
background: var(--deep);
}
.rail__item {
position: relative;
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 5px;
padding: 7px 8px;
text-align: left;
color: var(--ink-3);
transition:
background-color var(--d-2) var(--e-out),
color var(--d-2) var(--e-out);
}
.rail__item::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
background: transparent;
transition: background-color var(--d-2) var(--e-out);
}
.rail__item:hover {
background: var(--panel-2);
color: var(--ink-2);
}
.rail__item.is-active {
background: var(--panel-2);
color: var(--ink);
}
.rail__item.is-active::before {
background: var(--primary);
}
.rail__index {
font-size: var(--fs-9);
color: var(--ink-4);
letter-spacing: var(--track);
}
.rail__item.is-active .rail__index {
color: var(--ink-2);
}
.rail__body {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.rail__label {
font-size: var(--fs-11);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail__en {
font-family: var(--font-mono);
font-size: var(--fs-9);
letter-spacing: var(--track);
color: var(--ink-4);
opacity: 0.8;
}
/* ────────────────────────────────────────────────── 滚动内容 */
.drawer__scroll {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 var(--s4) var(--s5);
scroll-behavior: smooth;
}
.drawer__tail {
display: flex;
flex-direction: column;
gap: 3px;
padding: var(--s4) 0 0;
color: var(--ink-4);
}
.drawer__tail .label {
color: var(--primary-lo);
}
.drawer__tail-note {
font-size: var(--fs-10);
color: var(--ink-4);
}
/* ─────────────────────────────────────────────────────── 分节 */
.sect {
padding: var(--s4) 0 var(--s3);
border-bottom: 1px dashed var(--line);
}
.sect:last-of-type {
border-bottom: none;
}
.sect__head {
display: flex;
align-items: baseline;
gap: var(--s2);
margin-bottom: 6px;
}
.sect__index {
flex: none;
font-size: var(--fs-10);
color: var(--ink-4);
letter-spacing: var(--track);
}
.sect__title {
flex: none;
font-size: var(--fs-13);
font-weight: 500;
letter-spacing: var(--track-tight);
}
/* 标题后面那条刻线:它把"这一节的标题"和"这一节的内容"分开,而不需要再加一块底色。 */
.sect__rule {
flex: 1;
height: 1px;
background-image: linear-gradient(90deg, var(--line-2) 0 4px, transparent 4px 9px);
background-size: 9px 1px;
background-repeat: repeat-x;
opacity: 0.8;
}
.sect__hint {
margin-bottom: var(--s3);
color: var(--ink-3);
font-size: var(--fs-11);
line-height: 1.75;
}
.sect__note {
color: var(--ink-3);
font-size: var(--fs-10);
line-height: 1.75;
}
.sect__warn {
margin-top: var(--s1);
padding: 6px 8px;
background: var(--warn-soft);
border-left: 2px solid var(--warn);
color: var(--warn);
font-size: var(--fs-10);
line-height: 1.7;
}
.sect__body {
display: flex;
flex-direction: column;
gap: var(--s2);
}
.sect__row {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin-top: var(--s1);
}
/* ─────────────────────────────────────────────────── 实时预览 */
.preview {
margin: var(--s3) 0 var(--s1);
border: 1px solid var(--line-2);
background: var(--deep);
}
.preview__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
padding: 5px var(--s2);
border-bottom: 1px solid var(--line);
}
.preview__head .label {
color: var(--ink-3);
}
.preview__note {
font-size: var(--fs-9);
color: var(--ink-4);
}
.preview__frame {
position: relative;
height: 124px;
overflow: hidden;
background: #070b13;
}
.preview__frame::after {
--c: rgba(140, 170, 215, 0.3);
--l: 10px;
}
.preview__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.preview__hp {
position: absolute;
top: 10px;
left: 12px;
right: 12px;
pointer-events: none;
}
.preview__control {
display: flex;
align-items: center;
gap: var(--s3);
padding: 5px var(--s2);
border-top: 1px solid var(--line);
}
.preview__control-label {
flex: none;
font-size: var(--fs-10);
color: var(--ink-3);
}
.preview__control .slider {
flex: 1;
}
.preview__threshold {
flex: none;
font-size: var(--fs-9);
color: var(--error);
}
/* ─────────────────────────────────────────────────────── 校准 */
.calib {
display: flex;
flex-direction: column;
gap: var(--s2);
padding: var(--s2);
border: 1px dashed var(--line-2);
}
/* ─────────────────────────────────────────────────── 改密码 */
.pw {
display: flex;
flex-direction: column;
gap: var(--s2);
}
.pw__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
}
.pw__meter {
display: inline-flex;
gap: 3px;
}
.pw__cell {
width: 22px;
height: 3px;
background: var(--line-2);
transition: background var(--d-2) var(--e-out);
}
.pw__cell.is-on {
background: var(--primary);
}
/* ─────────────────────────────────────────────────── 颜色选择器 */
.color {
width: 44px;
height: 26px;
padding: 2px;
background: var(--deep);
border: 1px solid var(--line-2);
cursor: pointer;
}
.color::-webkit-color-swatch-wrapper {
padding: 0;
}
.color::-webkit-color-swatch {
border: none;
}
Binary file not shown.
+190
View File
@@ -0,0 +1,190 @@
/* 设计令牌:颜色、字号、间距、动效曲线、层叠。
*
* 这一份只有变量,没有选择器(`:root` 除外)。它存在的理由是:整套界面的"手感"必须是
* 同一套数字,而散落在各处的 #e8232b 迟早会分裂成几个近似的红。
*
* # 配色:墨黑蓝打底,只有一种强调色
*
* 底色是一组低饱和的冷蓝黑(`--void` 到 `--panel-3`),它承担全部面积;文字是同一族冷白。
* **红只有一支**(`--primary`),并且只允许出现在"必须被看见"的地方:焦点、选中、录制、
* 错误、临界血量。除此之外界面是单色的——这不是节俭,是因为当一个红色读数和一个红色
* 装饰同时存在时,现场的注意力会被后者分走。
*
* 状态色(`--error` / `--warn` / `--ok`)各有语义,不与强调色混用;`--team-0/1` 是**硬件
* 约束**(装甲板 LED 的红与蓝),不是设计选择。
*/
:root {
color-scheme: dark;
/* ── 底色 ───────────────────────────────────────────────────────────────
* 墨黑蓝:从最深到最浅五级,每一级只差一点冷色,靠 1px 线而不是阴影分层。 */
--void: #05070d; /* 最底:页面背景 */
--deep: #080b13; /* 视频窗格、日志底、输入框 */
--panel: #0b111c; /* 常规面板 */
--panel-2: #101725; /* 抬高一档(行、卡、悬停) */
--panel-3: #172032; /* 选中、按下 */
/* ── 线 ─────────────────────────────────────────────────────────────────
* 全站只有 1px 的线。粗线是分区,细线是分隔,两者都不要用色块代替。 */
--line: #1a2434;
--line-2: #26344a;
--line-3: #35475f;
--line-hot: rgba(232, 35, 43, 0.46); /* 焦点 */
/* ── 文字 ───────────────────────────────────────────────────────────────
* 四档就够。第四档是"占位与禁用",它必须暗到不像真信息。 */
--ink: #e7ecf5;
--ink-2: #94a1b8;
--ink-3: #616e86;
--ink-4: #3a4457;
/* ── 强调:红 ───────────────────────────────────────────────────────────
* 界面里唯一的强调色。`--primary` 是本体,`-hi` 用于深底上的文字(更亮才够对比),
* `-lo` 用于大面积底色的边缘,`-soft` / `-line` 是它的两块淡化。 */
--primary: #e8232b;
--primary-hi: #ff5a5f;
--primary-lo: #6d0f13;
--primary-soft: rgba(232, 35, 43, 0.12);
--primary-line: rgba(232, 35, 43, 0.44);
/* ── 状态 ───────────────────────────────────────────────────────────────
* 红用在这里表示"坏了",比 `--primary` 亮一点,好让"强调"与"故障"在同一屏上区分得开。 */
--error: #ff5449;
--error-soft: rgba(255, 84, 73, 0.13);
--error-line: rgba(255, 84, 73, 0.44);
--warn: #f2b33d;
--warn-soft: rgba(242, 179, 61, 0.12);
--warn-line: rgba(242, 179, 61, 0.42);
--ok: #3fc98a;
--ok-soft: rgba(63, 201, 138, 0.12);
--ok-line: rgba(63, 201, 138, 0.4);
/* ── 队伍色 ─────────────────────────────────────────────────────────────
* 这两个值不是设计选择,是**硬件约束**:装甲板的 LED 就是红/蓝,界面必须与车对上,
* 否则现场把"红队"这个词和"红色"这件事当两回事,血条会被读错边。不要改成别的。
* 队红刻意做得比 `--primary` 更暖更浅,免得两条并列时被误读成"系统在报警"。 */
--team-0: #ff6b5e;
--team-1: #4a9bff;
--team-0-soft: rgba(255, 107, 94, 0.14);
--team-1-soft: rgba(74, 155, 255, 0.14);
/* ── 字号 ───────────────────────────────────────────────────────────────
* 12 是正文,11 是标签,10 与 9 是"仪器上的小字"(只放代号,不放句子)。 */
--fs-9: 9px;
--fs-10: 10px;
--fs-11: 11px;
--fs-12: 12px;
--fs-13: 13px;
--fs-15: 15px;
--fs-18: 18px;
--fs-22: 22px;
--fs-28: 28px;
--fs-40: 40px;
/* 字距。全大写英文与微标签需要放宽,否则等宽字体挤成一团。 */
--track-tight: 0.02em;
--track: 0.08em;
--track-wide: 0.16em;
/* ── 间距 ───────────────────────────────────────────────────────────────
* 4 的倍数。`--gutter` 是外壳的内边距,改它就能整体收放。 */
--s1: 4px;
--s2: 8px;
--s3: 12px;
--s4: 16px;
--s5: 24px;
--s6: 32px;
--gutter: 10px;
/* ── 圆角 ───────────────────────────────────────────────────────────────
* 零。全局是矩形:切角、圆角、药丸都不再用。唯一的斜边留给装甲条(平行四边形)。 */
--radius: 0px;
/* ── 动效:Material 3 ───────────────────────────────────────────────────
* 曲线与时长照抄 Material Design 3 的 motion spec,不自己发明贝塞尔。
*
* M3 把缓动分成三组语义:
* emphasized —— 需要"存在感"的转场(面板展开、结算浮现);
* decelerate —— **进入**:快起慢收,元素像是被抛到位;
* accelerate —— **退出**:慢起快走,元素像是被抽走。
* 时长分短(short1-4)/中(medium1-4)/长(long1-4)几档,短时长配小位移、长时长配大位移。
*
* 老代码里的 `--e-*` / `--d-*` 保留为别名,全部指到 M3 上——这样一条规则都不用改,
* 而全站曲线已经是同一套。 */
--m3-emphasized: cubic-bezier(0.2, 0, 0, 1);
--m3-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
--m3-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
--m3-standard: cubic-bezier(0.2, 0, 0, 1);
--m3-standard-decelerate: cubic-bezier(0, 0, 0, 1);
--m3-standard-accelerate: cubic-bezier(0.3, 0, 1, 1);
--m3-linear: cubic-bezier(0, 0, 1, 1);
--dur-short1: 50ms;
--dur-short2: 100ms;
--dur-short3: 150ms;
--dur-short4: 200ms;
--dur-medium1: 250ms;
--dur-medium2: 300ms;
--dur-medium3: 350ms;
--dur-medium4: 400ms;
--dur-long1: 450ms;
--dur-long2: 500ms;
--dur-long3: 550ms;
--dur-long4: 600ms;
/* 别名:进入 / 退出 / 强调转场 */
--e-out: var(--m3-emphasized-decelerate);
--e-in: var(--m3-emphasized-accelerate);
--e-in-out: var(--m3-emphasized);
--e-hud: var(--m3-emphasized-decelerate);
--e-step: var(--m3-standard);
--d-1: var(--dur-short3); /* 按压反馈 */
--d-2: var(--dur-short4); /* 悬停、状态翻转 */
--d-3: var(--dur-medium2); /* 面板切换 */
--d-4: var(--dur-medium4); /* 大块进出 */
--d-5: var(--dur-long3); /* 叙事动画 */
/* ── 层叠 ───────────────────────────────────────────────────────────────
* 只列名字,不列魔法数字;组件里一律引用这里。 */
--z-stage: 1;
--z-hud: 10;
--z-overlay: 20;
--z-chrome: 30;
--z-panel: 40;
--z-dialog: 50;
--z-toast: 60;
--z-boot: 90;
/* ── 构型 ───────────────────────────────────────────────────────────────
* 顶栏与底栏的高度是**契约**:覆盖层的 top/bottom 靠它算位置。 */
--h-topbar: 46px;
--h-statusbar: 30px;
}
/* 用户把系统动效关掉时,时长压到 0(M3 的 reduced-motion 要求是"去掉位移与时长",
* 不是"换一条更快的曲线")。 */
@media (prefers-reduced-motion: reduce) {
:root {
--d-1: 0.001ms;
--d-2: 0.001ms;
--d-3: 0.001ms;
--d-4: 0.001ms;
--d-5: 0.001ms;
--dur-short1: 0.001ms;
--dur-short2: 0.001ms;
--dur-short3: 0.001ms;
--dur-short4: 0.001ms;
--dur-medium1: 0.001ms;
--dur-medium2: 0.001ms;
--dur-medium3: 0.001ms;
--dur-medium4: 0.001ms;
--dur-long1: 0.001ms;
--dur-long2: 0.001ms;
--dur-long3: 0.001ms;
--dur-long4: 0.001ms;
}
}