界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/ (该目录不进仓库)。 1. 客户端显示对手的血量 快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠 数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己 绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用 `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。 2. 血条改到画面两个上角,平行四边形,一条分 5 格 左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg), 右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。 条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、 拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。 注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条 从外侧开始灭(代码里写了注释钉住这件事)。 3. 受击画面不抖动 删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但 **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的 配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关, 并有单测钉住"旧配置读得进来、默认不抖"。 4. 血量归零算击败,不显示"损毁" 血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在 Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己** (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血), 两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也 没有"这局结束了"的痕迹。 5. 裁判端可以改队伍名 两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到 「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个 两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照, 紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name` (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。 名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置 面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。 测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
102 lines
4.0 KiB
TypeScript
102 lines
4.0 KiB
TypeScript
/**
|
|
* 后端命令的封装。
|
|
*
|
|
* 每个函数对应 `desktop/src-tauri/src/commands.rs` 里的一个 `#[tauri::command]`。
|
|
* Tauri 会把 JS 侧的 camelCase 参数名转成 Rust 侧的 snake_case,所以这里的参数名写成
|
|
* 前端习惯的样子即可。
|
|
*
|
|
* # 错误
|
|
*
|
|
* 后端的 `Err(String)` 会以 rejected promise 的形式回来,字符串就是给现场的人看的那句
|
|
* 话。`call()` 把 `unknown` 归一成 `Error`,调用方只管 `catch (err) { err.message }`。
|
|
*/
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
import type {
|
|
ConfigView,
|
|
HudView,
|
|
MediaFileDto,
|
|
RecordingDto,
|
|
Role,
|
|
Snapshot,
|
|
StreamInfo,
|
|
} from "./types";
|
|
|
|
/**
|
|
* 把后端的字符串错误变成真正的 `Error`。
|
|
*
|
|
* 在没有 Tauri 的 `vite dev` 会话里(`?preview=` 那种),这条走预览实现——它用
|
|
* `await import()` 引入,所以正式构建里那段代码根本不会被打进产物(见 `dev-preview.ts`)。
|
|
*/
|
|
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
|
if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) {
|
|
const preview = await import("./dev-preview");
|
|
return preview.previewInvoke<T>(command, args);
|
|
}
|
|
|
|
try {
|
|
return await invoke<T>(command, args);
|
|
} catch (raw) {
|
|
throw new Error(typeof raw === "string" ? raw : String(raw));
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
// ---- 只读 ----
|
|
streamInfo: () => call<StreamInfo>("stream_info"),
|
|
snapshot: () => call<Snapshot>("get_snapshot"),
|
|
config: () => call<ConfigView>("get_config"),
|
|
mediaFiles: () => call<MediaFileDto[]>("list_media_files"),
|
|
configPath: () => call<string>("config_path"),
|
|
|
|
// ---- 密码 ----
|
|
unlock: (password: string) => call<boolean>("unlock", { password }),
|
|
lock: () => call<void>("lock"),
|
|
setPassword: (fresh: string, current?: string) =>
|
|
call<ConfigView>("set_password", { current: current ?? null, fresh }),
|
|
setDiscoveryOpen: (open: boolean) => call<void>("set_discovery_open", { open }),
|
|
|
|
// ---- 绑定与身份 ----
|
|
bindCamera: (cameraId: number | null, password?: string) =>
|
|
call<ConfigView>("bind_camera", { cameraId, password: password ?? null }),
|
|
bindArmor: (armorId: number | null, password?: string) =>
|
|
call<ConfigView>("bind_armor", { armorId, password: password ?? null }),
|
|
setTeam: (team: number, password?: string) =>
|
|
call<ConfigView>("set_team", { team, password: password ?? null }),
|
|
setRole: (role: Role, password?: string) =>
|
|
call<ConfigView>("set_role", { role, password: password ?? null }),
|
|
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
|
|
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
|
|
/** 改一边的队名(0 = 队伍一,1 = 队伍二)。一次只改一边,避免覆盖另一边的改动。 */
|
|
setTeamName: (team: number, name: string, password?: string) =>
|
|
call<ConfigView>("set_team_name", { team, name, password: password ?? null }),
|
|
setRefereeSlots: (slots: {
|
|
cameraA: number | null;
|
|
cameraB: number | null;
|
|
armorA: number | null;
|
|
armorB: number | null;
|
|
manual: boolean;
|
|
}) => call<ConfigView>("set_referee_slots", slots),
|
|
setRecordingOptions: (options: {
|
|
dir: string | null;
|
|
ffmpeg: string | null;
|
|
crf: number | null;
|
|
preset: string | null;
|
|
}) => call<ConfigView>("set_recording_options", options),
|
|
|
|
// ---- 截图与录制 ----
|
|
screenshot: (camera: number) => call<string>("screenshot", { camera }),
|
|
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
|
|
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
|
|
reveal: (path: string) => call<void>("reveal", { path }),
|
|
openFile: (path: string) => call<void>("open_file", { path }),
|
|
|
|
// ---- 退出与重置 ----
|
|
clearConfig: (password?: string) =>
|
|
call<string>("clear_config", { password: password ?? null }),
|
|
quit: (password?: string) => call<void>("quit_app", { password: password ?? null }),
|
|
|
|
refresh: () => call<void>("refresh"),
|
|
};
|