feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形 - feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退 - feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页 - feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路 - feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码 - feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔 - feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写 - feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容 - feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因 - feat(firmware): 固件包版本与客户端同源,都从 version.json 读 - fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载 - fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页 - fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系 - fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失 - fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径 - fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录 - build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO - build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-* - docs: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
+260
-84
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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` */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user