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

- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
2026-10-19 02:40:08 +08:00
parent cc6b82d7ec
commit 896b21fe44
332 changed files with 16729 additions and 2765 deletions
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env node
/**
* 版本号与改动日志的计算器。
*
* # 为什么要有它
*
* 版本号与改动日志原来都是**手写**的:改一次代码要记得去改三处版本号,再想想这次该升哪
* 一位,最后在 CHANGELOG 里补一段。这三件事都会忘,而忘了之后没有任何东西会报错——
* 典型表现是装包里写着 0.1.0,实际是两周后的代码。
*
* 现在它们都从 **git 历史**算出来:
*
* - 版本号 = 上一个 tag + 这之间提交的语义(conventional commits);
* - 改动日志 = 同一批提交按类型分组;
* - 构建号 = `git rev-list --count HEAD`,单调递增,无冲突。
*
* 于是"这次该升哪一位"不再是一个判断,而是一次计算。
*
* # 用法
*
* node scripts/version.mjs --write # 计算并写回所有派生文件
* node scripts/version.mjs --check # 只校验:有未归档的提交就退 1
* node scripts/version.mjs --print # 把算出来的 version.json 打到 stdout
*
* # 约定
*
* 提交标题用 Conventional Commits:`type(scope): 说明`,可加 `!` 表示破坏性改动,
* 或在正文里写 `BREAKING CHANGE:`。规则:
*
* | 有 breaking | major |
* | 有 feat | minor |
* | 有 fix / perf | patch |
* | 其它 | 不动版本,只加构建号 |
*
* 正文里以 `- ` 开头、且本身写成 `type(scope): 说明` 的行,会被当作**额外的条目**收进
* 同一份日志。这样一次 squash 提交也能把几件事分别归类,而不必为了日志好看去拆提交。
*/
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
/** 各类提交在日志里的标题与顺序。没列出的类型归入「其它」。 */
const GROUPS = [
["feat", "新增"],
["fix", "修复"],
["perf", "性能"],
["refactor", "重构"],
["build", "构建"],
["ci", "CI"],
["docs", "文档"],
["test", "测试"],
["style", "样式"],
["revert", "回滚"],
["chore", "杂项"],
["other", "其它"],
];
const TYPE_ORDER = GROUPS.map(([type]) => type);
// ---------------------------------------------------------------------------
// git
// ---------------------------------------------------------------------------
function git(args, { allowFail = false } = {}) {
try {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
} catch (err) {
if (allowFail) return "";
throw new Error(`git ${args.join(" ")} 失败:${err.message}`);
}
}
/** 最近的一个 `v*` 标签;没有标签时返回 null(第一次发布)。 */
function lastTag() {
const tag = git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"], {
allowFail: true,
});
return tag || null;
}
/** `v1.2.3` → `[1,2,3]`。 */
function parseTag(tag) {
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag ?? "");
if (!match) return [0, 0, 0];
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
/**
* 取 `since..HEAD` 的提交。
*
* 用 `%x1f` / `%x1e` 作分隔符:它们不会出现在提交信息里,比按空行切更稳。
*/
function commitsSince(since) {
const range = since ? `${since}..HEAD` : "HEAD";
const raw = git(["log", range, "--no-merges", "--pretty=format:%H%x1f%s%x1f%b%x1e"]);
if (!raw) return [];
return raw
.split("\x1e")
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const [hash, subject, body] = chunk.split("\x1f");
return { hash, subject: subject ?? "", body: body ?? "" };
});
}
// ---------------------------------------------------------------------------
// 解析
// ---------------------------------------------------------------------------
const SUBJECT = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s*(?<text>.+)$/;
/**
* 把一条提交拆成一个主条目 + 正文里的追加条目。
*
* 正文里的追加条目必须自己写成 `type(scope): 说明`,否则不猜——猜错的日志比没有更糟。
*/
function entriesOf(commit) {
const head = SUBJECT.exec(commit.subject);
const breaking = /(^|\n)BREAKING[ -]CHANGE:/.test(commit.body) || Boolean(head?.groups?.bang);
const entries = [];
if (head) {
entries.push({
type: head.groups.type,
scope: head.groups.scope ?? null,
text: head.groups.text.trim(),
breaking,
});
} else {
entries.push({ type: "other", scope: null, text: commit.subject, breaking });
}
for (const line of commit.body.split("\n")) {
const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
if (!bullet) continue;
const nested = SUBJECT.exec(bullet[1].trim());
if (!nested) continue;
entries.push({
type: nested.groups.type,
scope: nested.groups.scope ?? null,
text: nested.groups.text.trim(),
breaking: Boolean(nested.groups.bang),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// 计算
// ---------------------------------------------------------------------------
function bump(base, entries) {
if (entries.some((entry) => entry.breaking)) return [base[0] + 1, 0, 0];
if (entries.some((entry) => entry.type === "feat")) return [base[0], base[1] + 1, 0];
if (entries.some((entry) => entry.type === "fix" || entry.type === "perf")) {
return [base[0], base[1], base[2] + 1];
}
return base;
}
function compute() {
const tag = lastTag();
const base = parseTag(tag);
const commits = commitsSince(tag);
const entries = commits.flatMap(entriesOf);
const version = bump(base, entries);
const build = Number(git(["rev-list", "--count", "HEAD"]) || 0);
return {
tag,
base,
commits,
entries,
version,
build,
dirty: git(["status", "--porcelain"]).length > 0,
};
}
function metaOf(result) {
const [major, minor, patch] = result.version;
return {
version: `${major}.${minor}.${patch}`,
major,
minor,
patch,
build: result.build,
protocol: protocolVersion(),
// 生成日期取提交日期而不是"现在":同一次发布重跑两次,产物必须逐字节相同,
// 否则每次 --check 都会报一个并不存在的差异。
date: git(["log", "-1", "--pretty=format:%cs"]),
};
}
/** 线路协议版本以 protocol crate 里的常量为准,不从别处猜。 */
function protocolVersion() {
const source = readFileSync(join(ROOT, "protocol/src/lib.rs"), "utf8");
const match = /pub const PROTOCOL_VERSION: u16 = (\d+);/.exec(source);
if (!match) throw new Error("protocol/src/lib.rs 里找不到 PROTOCOL_VERSION");
return Number(match[1]);
}
// ---------------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------------
function groupEntries(entries) {
const buckets = new Map(GROUPS.map(([type]) => [type, []]));
for (const entry of entries) {
const bucket = buckets.get(entry.type) ?? buckets.get("other");
bucket.push(entry);
}
return [...buckets.entries()]
.filter(([, list]) => list.length > 0)
.sort((a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]));
}
function renderChangelog(meta, result) {
const lines = [];
lines.push("# 改动记录");
lines.push("");
lines.push(
"> 本文件由 `scripts/version.mjs` 依据 git 历史生成,**请勿手改**。",
);
lines.push("> 提交信息遵循 Conventional Commits,一条一个条目;要求见该脚本的文件头。");
lines.push("> 建立这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](docs/CHANGELOG-legacy.md)。");
lines.push("");
if (result.entries.length === 0) {
lines.push(`## v${meta.version} — ${meta.date}`);
lines.push("");
lines.push("(自上一个发布以来没有面向使用者的改动。)");
lines.push("");
return lines.join("\n");
}
const span = result.tag ? `${result.tag} → v${meta.version}` : `初始 → v${meta.version}`;
lines.push(`## v${meta.version} — ${meta.date}`);
lines.push("");
lines.push(
`构建号 **${meta.build}** · 线路协议 **${meta.protocol}** · ` +
`${result.commits.length} 个提交(${span})`,
);
lines.push("");
for (const [type, list] of groupEntries(result.entries)) {
const heading = GROUPS.find(([key]) => key === type)?.[1] ?? "其它";
lines.push(`### ${heading}`);
lines.push("");
for (const entry of list) {
const scope = entry.scope ? `**${entry.scope}** · ` : "";
const bang = entry.breaking ? "(破坏性)" : "";
lines.push(`- ${scope}${entry.text}${bang}`);
}
lines.push("");
}
return lines.join("\n");
}
function renderJson(meta, result) {
return (
JSON.stringify(
{
...meta,
baseline: result.tag,
commits: result.commits.length,
groups: Object.fromEntries(
groupEntries(result.entries).map(([type, list]) => [
type,
list.map((entry) => ({
scope: entry.scope,
text: entry.text,
breaking: entry.breaking,
})),
]),
),
},
null,
2,
) + "\n"
);
}
function renderTs(meta) {
return `/**
* 构建信息。**由 \`scripts/version.mjs\` 生成,请勿手改。**
*
* 版本号与构建号都从 git 历史算出来(见该脚本的文件头),所以界面显示的一定就是
* 这一份二进制的来源,不会出现"装包里写着 0.1.0,其实是两周后的代码"。
*/
export const BUILD = {
version: "${meta.version}",
major: ${meta.major},
minor: ${meta.minor},
patch: ${meta.patch},
/** git 提交数,单调递增。 */
build: ${meta.build},
/** 线路协议版本。与固件自报的那个数直接比对。 */
protocol: ${meta.protocol},
/** 生成时的提交日期(\`YYYY-MM-DD\`)。 */
date: "${meta.date}",
} as const;
/** \`"0.2.0+57"\` —— 界面上放得下的那一行。 */
export const VERSION_LABEL = \`v\${BUILD.version}+\${BUILD.build}\`;
`;
}
// ---------------------------------------------------------------------------
// 写回
// ---------------------------------------------------------------------------
function writeIfChanged(path, content) {
const full = join(ROOT, path);
if (existsSync(full) && readFileSync(full, "utf8") === content) {
return false;
}
writeFileSync(full, content, "utf8");
return true;
}
/** 把版本写进 JSON 文件,保留原有排版(这些文件是给人看也给人改的)。 */
function syncJsonVersion(path, version) {
const full = join(ROOT, path);
if (!existsSync(full)) return false;
const source = readFileSync(full, "utf8");
const next = source.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`);
if (next === source) return false;
writeFileSync(full, next, "utf8");
return true;
}
function write(meta, result) {
const changed = [];
const put = (path, content) => {
if (writeIfChanged(path, content)) changed.push(path);
};
put(
"version.json",
JSON.stringify(
{
...meta,
baseline: result.tag,
note: "由 scripts/version.mjs 从 git 历史算出,请勿手改。",
},
null,
2,
) + "\n",
);
put("CHANGELOG.md", renderChangelog(meta, result));
put("CHANGELOG.json", renderJson(meta, result));
put("desktop/src/lib/version.ts", renderTs(meta));
if (syncJsonVersion("desktop/package.json", meta.version)) changed.push("desktop/package.json");
if (syncJsonVersion("desktop/src-tauri/tauri.conf.json", meta.version)) {
changed.push("desktop/src-tauri/tauri.conf.json");
}
return changed;
}
// ---------------------------------------------------------------------------
// 入口
// ---------------------------------------------------------------------------
const argv = new Set(process.argv.slice(2));
const result = compute();
const meta = metaOf(result);
if (argv.has("--print")) {
process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
process.exit(0);
}
if (argv.has("--check")) {
const committed = existsSync(join(ROOT, "version.json"))
? JSON.parse(readFileSync(join(ROOT, "version.json"), "utf8"))
: null;
if (!committed) {
console.error("version.json 不存在。先跑一次 `node scripts/version.mjs --write`。");
process.exit(1);
}
if (committed.version !== meta.version || committed.build !== meta.build) {
console.error(
`版本已过期:仓库里写的是 v${committed.version}+${committed.build},` +
`按 git 历史算出来应该是 v${meta.version}+${meta.build}。\n` +
"跑 `node scripts/version.mjs --write` 再提交。",
);
process.exit(1);
}
console.log(`版本一致:v${meta.version}+${meta.build}`);
process.exit(0);
}
const changed = write(meta, result);
console.log(
`v${meta.version}+${meta.build}(协议 ${meta.protocol},基线 ${result.tag ?? "无"},` +
`${result.commits.length} 个提交,${result.entries.length} 个条目)`,
);
if (changed.length > 0) {
console.log("已更新:" + changed.join("、"));
} else {
console.log("所有派生文件都是最新的。");
}
if (result.dirty) {
console.log("提示:工作区有未提交的改动,它们不会计入上面这份日志。");
}