@oj-module/cli 0.2.56 → 0.2.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/devkit/README.md +7 -4
  2. package/devkit/SKILL.md +4 -1
  3. package/devkit/manual.md +4 -2
  4. package/devkit/module-dev-and-cdn-playground.md +7 -0
  5. package/devkit/publish-walkthrough.md +199 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +5 -5
  8. package/shell-dist/index.html +3 -3
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/vendor-devkit.test.ts +87 -0
  11. package/src/build/backend.ts +131 -0
  12. package/src/build/module-build.ts +364 -0
  13. package/src/build/module-def.ts +381 -0
  14. package/src/build/site-merge.ts +33 -0
  15. package/src/build.ts +16 -885
  16. package/src/bundle.ts +61 -0
  17. package/src/cli.ts +4 -1
  18. package/src/init.ts +12 -35
  19. package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
  20. package/src/lifecycle/__tests__/bundle.test.ts +6 -0
  21. package/src/vendor.ts +34 -0
  22. package/templates/openapi.json +7 -0
  23. package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
  24. package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
  25. package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
  26. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
  27. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
  28. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
  29. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
  30. package/templates/src/app/api/_platform/schema.yaml +2 -0
  31. package/templates/src/app/api/system/roles/api.ts +2 -1
  32. package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
  33. package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
  34. package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
  35. package/templates/tests/rbac-roles.spec.ts +9 -0
  36. package/vendor/host-versions.json +3 -3
package/src/bundle.ts CHANGED
@@ -308,6 +308,16 @@ async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan:
308
308
  }
309
309
  fs.mkdirSync(path.join(root, "web", "modules"), { recursive: true });
310
310
  fs.cpSync(ownWeb, path.join(root, "web", "modules", name), { recursive: true });
311
+ // R1 数据源随包(计划 docs/plans/20261103-0948):安装期角色 title 解析读
312
+ // <包>/web/modules/<name>/locales/<lang>.json——构建产物不含 locales(源码 JSON
313
+ // 被 vite 内联进 JS chunk),故从源码树复制双语;目录不存在(纯后端段缺 locales)静默跳过。
314
+ for (const lang of ["zh-CN", "en-US"]) {
315
+ const src = path.join(webSrcOf(layout, name), "locales", `${lang}.json`);
316
+ if (fs.existsSync(src)) {
317
+ fs.mkdirSync(path.join(root, "web", "modules", name, "locales"), { recursive: true });
318
+ fs.copyFileSync(src, path.join(root, "web", "modules", name, "locales", `${lang}.json`));
319
+ }
320
+ }
311
321
  const web = m.web as Record<string, unknown>;
312
322
  for (const rel of [web.entry, ...(Array.isArray(web.css) ? web.css : [])]) {
313
323
  if (typeof rel === "string" && !fs.existsSync(path.join(root, "web", rel))) {
@@ -365,6 +375,57 @@ export async function buildBundle(projectRoot: string, opts: { out?: string, nam
365
375
  return results;
366
376
  }
367
377
 
378
+ /**
379
+ * `ojm build` 末尾的用户方案摘要(2026-11-03,计划 docs/plans/20261103-0925):
380
+ * 明确回答「我的方案构建进了吗、能不能打包」。判定口径与 resolveContractTargets 同源
381
+ * (模块级契约 ∪ 根契约,减内置名单)——摘要里列出的就是 --bundle 将要打包的那批。
382
+ * 只报告不裁决:不调 resolveLayout(构建末尾不因摘要崩溃),log 可注入供测试断言。
383
+ */
384
+ export function summarizeBuiltSolutions(projectRoot: string, built: readonly { name: string, version: string }[], log: (line: string) => void = console.log): void {
385
+ const builtin = readBuiltinModules(projectRoot);
386
+ const rootSolution = path.join(projectRoot, "solution.yaml");
387
+ const rootManifest = path.join(projectRoot, "manifest.yaml");
388
+ const rootContract = fs.existsSync(rootSolution) ? rootSolution : fs.existsSync(rootManifest) ? rootManifest : undefined;
389
+ const rootName = rootContract ? peekContractName(rootContract) : undefined;
390
+
391
+ // 用户方案候选:模块级契约(非内置)∪ 根契约 name(按名去重——「根压 src」只是打包取哪份契约,摘要里是同一个方案)
392
+ const names = new Set<string>();
393
+ for (const c of collectModuleContracts(projectRoot)) {
394
+ if (!builtin.has(c.moduleDir!))
395
+ names.add(c.moduleDir!);
396
+ }
397
+ if (rootName !== undefined && !builtin.has(rootName))
398
+ names.add(rootName);
399
+
400
+ const builtByName = new Map(built.map(b => [b.name, b]));
401
+ if (names.size === 0) {
402
+ log("[ojm] 用户方案 0 份(ojm new <name> 创建后即可 ojm build --bundle 打包)");
403
+ }
404
+ else {
405
+ const all = [...names].sort();
406
+ const ok = all.filter(n => builtByName.has(n));
407
+ const missing = all.filter(n => !builtByName.has(n));
408
+ const builtList = ok.map(n => `${n}@${builtByName.get(n)!.version}`).join("、") || "无";
409
+ if (missing.length === 0)
410
+ log(`[ojm] 用户方案 ${all.length} 份已构建:${builtList}(ojm build --bundle 打包)`);
411
+ else
412
+ log(`[ojm] 用户方案 ${all.length} 份中 ${ok.length} 份已构建:${builtList};未构建:${missing.join("、")}(查 web.config.ts 登记/enabled)`);
413
+ }
414
+
415
+ const builtinBuilt = built.filter(b => builtin.has(b.name));
416
+ if (builtinBuilt.length > 0)
417
+ log(`[ojm] 框架自带模块 ${builtinBuilt.length} 份已构建(不参与用户打包)`);
418
+
419
+ // module-root 口径(src/ 树在即适用;flat/new 工程模块不在 src/):非内置且无契约的模块不会被打包——提前点名,别等 --bundle 才发现
420
+ if (fs.existsSync(path.join(projectRoot, "src"))) {
421
+ for (const b of built) {
422
+ if (builtin.has(b.name) || names.has(b.name))
423
+ continue;
424
+ log(`[ojm] ⚠️ 模块 ${b.name}@${b.version} 缺方案契约(src/${b.name}/solution.yaml),不会被打包`);
425
+ }
426
+ }
427
+ }
428
+
368
429
  /**
369
430
  * C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
370
431
  * (oj 启动强校验同源)。
package/src/cli.ts CHANGED
@@ -135,7 +135,10 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
135
135
  }
136
136
  const { buildBackend, buildModules } = await import("./build");
137
137
  await buildBackend(projectRoot);
138
- await buildModules(projectRoot, { mergeSite: true });
138
+ const built = await buildModules(projectRoot, { mergeSite: true });
139
+ // 用户方案摘要(计划 20261103-0925):构建末尾点名「我的方案」是否进构建、能否打包
140
+ const { summarizeBuiltSolutions } = await import("./bundle");
141
+ summarizeBuiltSolutions(projectRoot, built);
139
142
  };
140
143
  case "new":
141
144
  return async (name: string | undefined, extra: string[] = [], opts: { layout?: string, api?: boolean } = {}) => {
package/src/init.ts CHANGED
@@ -21,7 +21,7 @@ import { BUILTIN_LIST_FILE, writeBuiltinList } from "./builtin";
21
21
  import { resolveCertDir, resolveConfigPath } from "./layout";
22
22
  import { probeOjCaps } from "./oj-caps";
23
23
  import { mintDevCert } from "./oj-cert";
24
- import { isSupportedOjVersion, MIN_OJ_VERSION, resolveOjBin, vendorCommand } from "./vendor";
24
+ import { isSupportedOjVersion, MIN_OJ_VERSION, resolveOjBin, syncOjDevkit, vendorCommand } from "./vendor";
25
25
  import { readHostVersions } from "./versions";
26
26
 
27
27
  export interface InitOptions {
@@ -167,25 +167,16 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
167
167
  // oj 不可用/不支持 secret → 保留明文并告警(init 不因加密失败而失败)
168
168
  await sealJwtSecret(destDir, report, opts.seal);
169
169
 
170
- // 4) devkit 分发:agent skill + 全局类型
171
- const devkit = path.join(binDir, "devkit");
172
- if (fs.existsSync(path.join(devkit, "SKILL.md"))) {
173
- const skillDir = path.join(destDir, ".claude/skills/oj-api-dev");
174
- fs.mkdirSync(skillDir, { recursive: true });
175
- for (const name of ["SKILL.md", "api-manual.md"])
176
- copyIfAbsent(path.join(devkit, name), path.join(skillDir, name), report);
177
- // v0.1.82 起 devkit 新增两份专题指南(api-manual/SKILL 均引用它们):
178
- // SQL 构造器能力矩阵/方言差异、oj test L1 测试指南。它们是**增量文档**、
179
- // 不影响 ojm 运行,故「有则拷、无则记跳过」——bin/devkit 来自用户本地
180
- // oj 版本(MIN_OJ_VERSION 只钉到 0.1.73),旧版 devkit 没有这两个文件,
181
- // 为两份文档把整个 init 打挂不值当(用户升 oj 后重跑 init 即补上)。
182
- for (const name of ["sql-builder-guide.md", "oj-test-guide.md"])
183
- copyIfPresent(path.join(devkit, name), path.join(skillDir, name), report);
184
- copyIfAbsent(path.join(devkit, "global.d.ts"), path.join(destDir, "global.d.ts"), report);
185
- }
186
- else {
170
+ // 4) devkit 分发:agent skill(与 vendor 同一同步出口 syncOjDevkit:bin/devkit 全量
171
+ // .md **覆盖**进 .claude/skills/oj-api-dev——文档随 oj 版本同步,见计划 20261103-1020)
172
+ // + 全局类型(模板已落则 copyIfAbsent 跳过,模板变体优先、用户可编辑)
173
+ const devkitStatus = syncOjDevkit(destDir, binDir);
174
+ if (devkitStatus.ok)
175
+ report.created.push(`.claude/skills/oj-api-dev(oj devkit 文档已同步 ${devkitStatus.files.length} 个文件)`);
176
+ else
187
177
  report.skipped.push(".claude/skills/oj-api-dev(bin/devkit 缺失,跳过)");
188
- }
178
+ if (fs.existsSync(path.join(binDir, "devkit", "global.d.ts")))
179
+ copyIfAbsent(path.join(binDir, "devkit", "global.d.ts"), path.join(destDir, "global.d.ts"), report);
189
180
 
190
181
  // 4b) ojm 模块开发 skill 分发:从 cli 包内置 devkit(prepack 同步的
191
182
  // docs/devkit)拷到 .claude/skills/ojm-module-dev/;已存在则幂等跳过。
@@ -197,7 +188,8 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
197
188
  // 业务工程拿到模板后自助完成对接要用,不能只留本仓:
198
189
  // · `wecom-daikaifa.md` 企业微信代开发(服务商模板):凭据清单 + 用户/开发/部署三视角;
199
190
  // · `wecom-internal-app.md` 企业内部应用(自建):五项 corp 凭据 + 验证并接入 + 共存。
200
- for (const name of ["SKILL.md", "manual.md", "README.md", "wecom-daikaifa.md", "wecom-internal-app.md"])
191
+ // · `publish-walkthrough.md` 方案包发布全流程(新手照做版):打包/签名/CDN/安装一条链。
192
+ for (const name of ["SKILL.md", "manual.md", "README.md", "wecom-daikaifa.md", "wecom-internal-app.md", "publish-walkthrough.md"])
201
193
  copyIfAbsent(path.join(cliDevkit, name), path.join(ojmSkillDir, name), report);
202
194
  }
203
195
  else {
@@ -483,21 +475,6 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
483
475
  report.created.push(path.relative(process.cwd(), dest) || path.basename(dest));
484
476
  }
485
477
 
486
- /**
487
- * 同 copyIfAbsent,但**源缺失**时记一条跳过而非抛错。
488
- *
489
- * 用于从 bin/devkit 拷第三方版本化内容(两份专题指南):devkit 随用户本地 oj
490
- * 版本而变,旧版没有新文件属正常版本 skew,不是安装包损坏——缺了只少份文档,
491
- * 不该让 init 失败。源在而目标已存在仍走 copyIfAbsent 的幂等跳过。
492
- */
493
- function copyIfPresent(src: string, dest: string, report: InitReport): void {
494
- if (!fs.existsSync(src)) {
495
- report.skipped.push(`${path.basename(src)}(bin/devkit 无此文件,跳过)`);
496
- return;
497
- }
498
- copyIfAbsent(src, dest, report);
499
- }
500
-
501
478
  /**
502
479
  * 新工程 .gitignore:不存在则生成;已存在则幂等跳过(用户规则永不覆盖)。
503
480
  *
@@ -0,0 +1,106 @@
1
+ // BDD:`ojm build` 末尾用户方案摘要(计划 docs/plans/20261103-0925-ojm-build-solution-summary.md)。
2
+ // 摘要只报告不裁决:判定口径与 --bundle 打包对象同源(模块级契约 ∪ 根契约,减内置名单);
3
+ // log 注入断言,夹具只摆契约/名单/构建结果三样,不需要真实工程结构。
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import { summarizeBuiltSolutions } from "../../bundle";
10
+
11
+ let project: string;
12
+ let lines: string[];
13
+
14
+ beforeEach(() => {
15
+ project = mkdtempSync(join(tmpdir(), "ojm-build-summary-"));
16
+ lines = [];
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(project, { recursive: true, force: true });
21
+ });
22
+
23
+ /** 模块级契约:src/<name>/solution.yaml(+ web/entry.ts 保持真实模块目录形态) */
24
+ function moduleContract(name: string): void {
25
+ const dir = join(project, "src", name);
26
+ mkdirSync(join(dir, "web"), { recursive: true });
27
+ writeFileSync(join(dir, "web", "entry.ts"), "export default {};\n");
28
+ writeFileSync(join(dir, "solution.yaml"), `name: ${name}\nversion: 1.0.0\nsummary: ${name}\n`);
29
+ }
30
+
31
+ function builtinList(names: string[]): void {
32
+ mkdirSync(join(project, "src"), { recursive: true });
33
+ writeFileSync(join(project, "src", ".ojm-builtin.json"), JSON.stringify({ modules: names }));
34
+ }
35
+
36
+ function summaryOf(built: { name: string, version: string }[]): string[] {
37
+ summarizeBuiltSolutions(project, built, l => lines.push(l));
38
+ return lines;
39
+ }
40
+
41
+ describe("summarizeBuiltSolutions", () => {
42
+ it("场景:全部构建成功 → 一行列出可打包方案 + 一行列出内置模块", () => {
43
+ moduleContract("crm");
44
+ moduleContract("erp");
45
+ builtinList(["home", "login"]);
46
+ const out = summaryOf([
47
+ { name: "crm", version: "1.0.0" },
48
+ { name: "erp", version: "0.2.0" },
49
+ { name: "home", version: "9.9.9" },
50
+ { name: "login", version: "9.9.9" },
51
+ ]);
52
+ expect(out.some(l => l.includes("用户方案 2 份已构建") && l.includes("crm@1.0.0") && l.includes("erp@0.2.0"))).toBe(true);
53
+ expect(out.some(l => l.includes("框架自带模块 2 份已构建"))).toBe(true);
54
+ });
55
+
56
+ it("场景:方案没进构建 → 点名未构建方案与排查方向", () => {
57
+ moduleContract("crm");
58
+ moduleContract("erp");
59
+ builtinList(["home"]);
60
+ const out = summaryOf([
61
+ { name: "crm", version: "1.0.0" },
62
+ { name: "home", version: "9.9.9" },
63
+ ]);
64
+ expect(out.some(l => l.includes("crm@1.0.0"))).toBe(true);
65
+ expect(out.filter(l => l.includes("erp")).join("\n")).toContain("未构建");
66
+ });
67
+
68
+ it("场景:零用户方案 → 提示 ojm new", () => {
69
+ moduleContract("home");
70
+ builtinList(["home"]);
71
+ const out = summaryOf([{ name: "home", version: "9.9.9" }]);
72
+ expect(out.some(l => l.includes("用户方案 0 份") && l.includes("ojm new"))).toBe(true);
73
+ });
74
+
75
+ it("场景:非内置模块缺契约 → 警告不会被打包", () => {
76
+ builtinList(["home"]);
77
+ const out = summaryOf([
78
+ { name: "foo", version: "0.1.0" },
79
+ { name: "home", version: "9.9.9" },
80
+ ]);
81
+ expect(out.some(l => l.includes("缺方案契约") && l.includes("foo@0.1.0"))).toBe(true);
82
+ });
83
+
84
+ it("场景:旧单方案工程(根契约、无 src/ 树)→ 列出根契约方案,不误报缺契约", () => {
85
+ writeFileSync(join(project, "manifest.yaml"), "name: demo-solution\nversion: 0.1.0\nsummary: 旧写法\n");
86
+ const out = summaryOf([{ name: "demo-solution", version: "0.1.0" }]);
87
+ expect(out.some(l => l.includes("用户方案 1 份已构建") && l.includes("demo-solution@0.1.0"))).toBe(true);
88
+ expect(out.every(l => !l.includes("缺方案契约"))).toBe(true);
89
+ });
90
+
91
+ it("场景:新脚手架形态(内置集中 src/app,无用户方案)→ 0 份提示,src/app 不被误判", () => {
92
+ // 脚手架 init 后:src/ 下只有 app(平台聚合树,无契约)与 oj-market,均在内置名单
93
+ mkdirSync(join(project, "src", "app", "web"), { recursive: true });
94
+ writeFileSync(join(project, "src", "app", "web", "entry.ts"), "export default {};\n");
95
+ mkdirSync(join(project, "src", "oj-market", "web"), { recursive: true });
96
+ writeFileSync(join(project, "src", "oj-market", "web", "entry.ts"), "export default {};\n");
97
+ builtinList(["app", "oj-market", "_platform", "auth", "notification", "personal-center", "system", "web"]);
98
+ const out = summaryOf([
99
+ { name: "app", version: "0.2.56" },
100
+ { name: "oj-market", version: "0.2.56" },
101
+ ]);
102
+ expect(out.some(l => l.includes("用户方案 0 份") && l.includes("ojm new"))).toBe(true);
103
+ expect(out.some(l => l.includes("框架自带模块 2 份已构建"))).toBe(true);
104
+ expect(out.every(l => !l.includes("缺方案契约"))).toBe(true);
105
+ });
106
+ });
@@ -104,6 +104,12 @@ describe("buildBundle · 正路径", () => {
104
104
  expect(tarList(r.tgz).some(n => n.endsWith("/modules.json"))).toBe(false);
105
105
  });
106
106
 
107
+ it("r1 前提:模块源码 locales 双语随包(安装期角色 title 解析数据源,计划 20261103-0948)", async () => {
108
+ const names = tarList((await pack()).tgz);
109
+ expect(names).toContain("demo-solution-0.1.0/web/modules/demo-solution/locales/zh-CN.json");
110
+ expect(names).toContain("demo-solution-0.1.0/web/modules/demo-solution/locales/en-US.json");
111
+ });
112
+
107
113
  it("solution.yaml 作为 bundle 契约(优先于 manifest.yaml,避开 oj 按 manifest.yaml 枚举 api 后端的冲突)", async () => {
108
114
  // 根目录若用 manifest.yaml 当 bundle 契约,会被 oj 的 api 模块搜索枚举误判为 api 模块;
109
115
  // 故解决方案改用 solution.yaml,bundle 工具优先认它(manifest.yaml 仅旧方案回退)。
package/src/vendor.ts CHANGED
@@ -79,6 +79,30 @@ export function ojBinName(platform: string = process.platform): string {
79
79
  return platform === "win32" ? "oj.exe" : "oj";
80
80
  }
81
81
 
82
+ /**
83
+ * oj devkit 文档同步(计划 docs/plans/20261103-1020-vendor-devkit-sync):bin/devkit
84
+ * (@oj-bin/oj postinstall 落盘,与 bin/oj 同版本)→ 工程 `.claude/skills/oj-api-dev/`,
85
+ * 全部 `.md` **覆盖**拷入——skill 文档始终与已装 oj 版本一致(vendor/init 共用出口)。
86
+ * bin/devkit 缺失(旧版 oj/异常安装)→ ok:false,调用方记跳过、不失败。
87
+ * global.d.ts 不在此同步:工程根那份是模板自有变体、用户可编辑(init 首装 copyIfAbsent)。
88
+ */
89
+ export function syncOjDevkit(projectRoot: string, binDir: string, log: (msg: string) => void = () => {}): { ok: boolean, files: string[] } {
90
+ const devkitDir = path.join(binDir, "devkit");
91
+ if (!fs.existsSync(path.join(devkitDir, "SKILL.md")))
92
+ return { ok: false, files: [] };
93
+ const skillDir = path.join(projectRoot, ".claude", "skills", "oj-api-dev");
94
+ fs.mkdirSync(skillDir, { recursive: true });
95
+ const files: string[] = [];
96
+ for (const name of fs.readdirSync(devkitDir)) {
97
+ if (!name.endsWith(".md"))
98
+ continue;
99
+ fs.copyFileSync(path.join(devkitDir, name), path.join(skillDir, name));
100
+ files.push(name);
101
+ }
102
+ log(`[ojm] oj devkit 文档已同步 → .claude/skills/oj-api-dev/(${files.length} 个文件,与已装 oj 同版本)`);
103
+ return { ok: true, files };
104
+ }
105
+
82
106
  /**
83
107
  * 工程内 oj 二进制路径的**唯一口径**(<projectRoot>/bin/oj)。
84
108
  *
@@ -222,6 +246,12 @@ function installFromNpm(version: string, binDir: string, run: NpmRun, log: (msg:
222
246
  }
223
247
  }
224
248
 
249
+ /** devkit 同步 + 缺失时的人话跳过行(vendorCommand 两个调用点共用) */
250
+ function syncDevkitOrSkip(projectRoot: string, binDir: string, log: (msg: string) => void): void {
251
+ if (!syncOjDevkit(projectRoot, binDir, log).ok)
252
+ log("[ojm] bin/devkit 缺失,跳过 oj devkit 文档同步(旧版 oj 或异常安装;skill 文档不更新)");
253
+ }
254
+
225
255
  /**
226
256
  * US-1..US-5 编排:解析目标版本(tag 缺省经 npm view 追 latest,显式 tag 去 v 前缀)
227
257
  * → 本地已是该版本则跳过(--force 除外)→ 临时目录安装 + 拷贝 → 报告版本 → 探针冒烟。
@@ -243,6 +273,9 @@ export async function vendorCommand(
243
273
  const binDir = path.join(projectRoot, "bin");
244
274
  const local = readLocalVersion(binDir);
245
275
  if (!opts.force && local === `v${version}`) {
276
+ // 文档同步不随版本跳过:用户删过 skill 目录后重跑 vendor 即补齐(R4)。
277
+ // 先同步再打跳过行——「已是 vX」保持命令收尾行(既有门禁断言末行)。
278
+ syncDevkitOrSkip(projectRoot, binDir, log);
246
279
  log(`[ojm] oj 已是 v${version},跳过。`);
247
280
  const result = probe(binDir);
248
281
  if (!result.ok)
@@ -255,6 +288,7 @@ export async function vendorCommand(
255
288
  log(`[ojm] 本地 ${local ?? "未安装"} → v${version}`);
256
289
  installFromNpm(version, binDir, run, log);
257
290
  log(`[ojm] oj ${local ? `${local} → ` : ""}v${version} 安装完成:${path.join(binDir, ojBinName(process.platform))}`);
291
+ syncDevkitOrSkip(projectRoot, binDir, log);
258
292
  // 安装后冒烟:发布二进制可能因构建机路径被烤进而不可用(见 probeOjRuntime)
259
293
  const result = probe(binDir);
260
294
  if (!result.ok)
@@ -5679,6 +5679,7 @@
5679
5679
  "name",
5680
5680
  "module",
5681
5681
  "title",
5682
+ "title_key",
5682
5683
  "description",
5683
5684
  "builtin",
5684
5685
  "deprecated",
@@ -5698,6 +5699,12 @@
5698
5699
  "null"
5699
5700
  ]
5700
5701
  },
5702
+ "title_key": {
5703
+ "type": [
5704
+ "string",
5705
+ "null"
5706
+ ]
5707
+ },
5701
5708
  "description": {
5702
5709
  "type": [
5703
5710
  "string",
@@ -31,7 +31,7 @@ import { download } from "./steps/download";
31
31
  import { extract } from "./steps/extract";
32
32
  import { maxAppliedSeq, migrate, rollbackGuard } from "./steps/migrate";
33
33
  import { preflight } from "./steps/preflight";
34
- import { registerSolution } from "./steps/register";
34
+ import { registerSolution, resolveDeclTitles, siteLanguage } from "./steps/register";
35
35
  import { reloadAndAssert } from "./steps/reload";
36
36
  import { smoke } from "./steps/smoke";
37
37
  import { verify } from "./steps/verify";
@@ -261,7 +261,8 @@ async function lockCommitStage(req: InstallRequest): Promise<StageResult> {
261
261
  }
262
262
 
263
263
  /**
264
- * manifest.yaml(C2 形状)→ SolutionDecl(D10 出口入参):roles[].title_key → title、
264
+ * manifest.yaml(C2 形状)→ SolutionDecl(D10 出口入参):roles[].title/title_key 透传
265
+ * (title 此刻仍是原始 key,registering 段落库前经 resolveDeclTitles 按站点语言解析,R1/R4)、
265
266
  * settings.groups[].items[] 摊平、enum options 序列化为 JSON 字符串(sys_setting.options 形态)。
266
267
  */
267
268
  export function declOf(req: InstallRequest): SolutionDecl {
@@ -297,6 +298,7 @@ export function declOf(req: InstallRequest): SolutionDecl {
297
298
  roles: (Array.isArray(m.roles) ? m.roles : []).filter(isObj).map(r => ({
298
299
  name: typeof r.name === "string" ? r.name : "",
299
300
  title: typeof r.title_key === "string" ? r.title_key : "",
301
+ title_key: typeof r.title_key === "string" ? r.title_key : undefined,
300
302
  permissions: Array.isArray(r.permissions) ? r.permissions.map(String) : [],
301
303
  })),
302
304
  // 顶层 permissions[] 的 code:无角色权限点(系统预置点)唯一能落库的来源,别在此丢弃
@@ -316,7 +318,10 @@ export function declOf(req: InstallRequest): SolutionDecl {
316
318
  }
317
319
 
318
320
  async function registerStage(req: InstallRequest): Promise<StageResult> {
319
- return registerSolution(declOf(req));
321
+ // R1/R4:落库前按站点默认语言解析角色 title(数据源 = 包内 locales,staging 此刻仍在;
322
+ // 解析失败回退原文绝不失败安装)。fsFace 注入读文件面(测试 stub 同 DIP 纪律)。
323
+ const decl = await resolveDeclTitles(declOf(req), req.staging, await siteLanguage(), p => fsFace().readFile(p));
324
+ return registerSolution(decl);
320
325
  }
321
326
 
322
327
  async function deployWebStage(req: InstallRequest): Promise<StageResult> {
@@ -65,6 +65,59 @@ export interface SolutionSnapshot {
65
65
  permissions: string[]
66
66
  }
67
67
 
68
+ /** 角色 title 的裸 i18n key 形状(`pm:role.sales`;0018 迁移回填同口径) */
69
+ const TITLE_KEY_SHAPE = /^[a-z][\w-]*:/;
70
+
71
+ /**
72
+ * 站点默认语言(R4):sys_setting `site.language`,无行/空值回退 zh-CN
73
+ * (与前端 preference 缺省同值;未来设置面落库即自动生效)。
74
+ */
75
+ export async function siteLanguage(): Promise<string> {
76
+ const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: "site.language" }).all() as Array<{ value: string | null }>;
77
+ const v = rows[0]?.value;
78
+ return typeof v === "string" && v.trim() ? v.trim() : "zh-CN";
79
+ }
80
+
81
+ /**
82
+ * 角色 title 落库前解析(R1,registering 段消费;升级重装同路径 = R3 重解析)。
83
+ * 仅 title 命中 key 形状才尝试;ns(冒号前段)=== 模块名才读本包 locale(跨 ns 不越界);
84
+ * dotted path 取值须为非空 string。数据源 = 方案包 `web/modules/<ns>/locales/<lang>.json`
85
+ * (bundle 侧随包,见 cli bundle.ts)。任何失败(无文件/坏 JSON/缺 key/非 string)保留原文
86
+ * 并继续——翻译永不失败安装(R1 红线)。
87
+ */
88
+ export async function resolveDeclTitles(
89
+ decl: SolutionDecl,
90
+ stagingDir: string,
91
+ lang: string,
92
+ readFile: (path: string) => Promise<Uint8Array>,
93
+ ): Promise<SolutionDecl> {
94
+ const roles = [...decl.roles];
95
+ for (let i = 0; i < roles.length; i++) {
96
+ const title = roles[i]!.title;
97
+ if (!TITLE_KEY_SHAPE.test(title)) {
98
+ continue;
99
+ }
100
+ const ns = title.slice(0, title.indexOf(":"));
101
+ if (ns !== decl.module) {
102
+ continue;
103
+ }
104
+ try {
105
+ const bytes = await readFile(`${stagingDir}/web/modules/${ns}/locales/${lang}.json`);
106
+ let node: unknown = JSON.parse(new TextDecoder().decode(bytes));
107
+ for (const part of title.slice(title.indexOf(":") + 1).split(".")) {
108
+ node = node == null ? undefined : (node as Record<string, unknown>)[part];
109
+ }
110
+ if (typeof node === "string" && node.trim()) {
111
+ roles[i] = { ...roles[i]!, title: node };
112
+ }
113
+ }
114
+ catch {
115
+ // 解析失败 = 回退原文(R1)
116
+ }
117
+ }
118
+ return { ...decl, roles };
119
+ }
120
+
68
121
  /** 首段提取(/wiki/docs → wiki)——占用判定粒度:前缀段冲突即整段首段比较(P1-5 备案③) */
69
122
  function firstSeg(path: string): string {
70
123
  return path.split("/")[1] ?? "";
@@ -9,6 +9,8 @@ export interface RoleRow {
9
9
  name: string
10
10
  module: string
11
11
  title: string | null
12
+ /** 原始 i18n key(R2:solution 角色安装期解析锚;custom/builtin 为 null,UI 据此实时翻译) */
13
+ title_key: string | null
12
14
  description: string | null
13
15
  builtin: boolean
14
16
  deprecated: boolean
@@ -60,7 +62,7 @@ async function invalidateHolders(roleName: string): Promise<void> {
60
62
  }
61
63
 
62
64
  export async function listRoles(): Promise<RoleRow[]> {
63
- const roles = await db.table("sys_role").select(["name", "module", "title", "description", "builtin", "deprecated"]).orderBy([{ field: "name" }]).all() as Array<Record<string, unknown>>;
65
+ const roles = await db.table("sys_role").select(["name", "module", "title", "title_key", "description", "builtin", "deprecated"]).orderBy([{ field: "name" }]).all() as Array<Record<string, unknown>>;
64
66
  const holders = await db.table("sys_user_role").select(["role_name", { fn: "count", as: "c" }]).groupBy(["role_name"]).all() as Array<{ role_name: string, c: number }>;
65
67
  const binds = await db.table("sys_role_permission").select(["role_name", "permission_code"]).orderBy([{ field: "permission_code" }]).all() as Array<{ role_name: string, permission_code: string }>;
66
68
  const holderMap = new Map(holders.map(h => [h.role_name, Number(h.c)]));
@@ -71,6 +73,7 @@ export async function listRoles(): Promise<RoleRow[]> {
71
73
  name: String(r.name),
72
74
  module: String(r.module),
73
75
  title: r.title == null ? null : String(r.title),
76
+ title_key: r.title_key == null ? null : String(r.title_key),
74
77
  description: r.description == null ? null : String(r.description),
75
78
  builtin: Number(r.builtin) === 1,
76
79
  deprecated: Number(r.deprecated) === 1,
@@ -7,7 +7,10 @@
7
7
  export interface SolutionRoleDecl {
8
8
  /** 完整角色名(含 `<module>.` 前缀,AC1-11 由安装器侧前缀校验) */
9
9
  name: string
10
+ /** 解析后的可读文本(站点默认语言;解析失败 = 原始 key,R1 回退) */
10
11
  title: string
12
+ /** 原始 i18n key(R2:UI 实时翻译锚,对齐 sys_permission.title_key 先例;解析失败与 title 同值) */
13
+ title_key?: string
11
14
  /** 声明绑定:insert-if-absent 写入,管理员调整永不被覆写(AC1-15) */
12
15
  permissions?: string[]
13
16
  }
@@ -37,12 +40,13 @@ export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertRe
37
40
 
38
41
  for (const role of m.roles) {
39
42
  const existing = await db.table("sys_role").select(["name"]).where({ field: "name", op: "eq", value: role.name }).all() as unknown[];
40
- // upsert:行存在只覆盖元数据(title),name/builtin 不动(AC2-2 元数据覆盖)
43
+ // upsert:行存在只覆盖元数据(title/title_key——R3 升级即重解析:新包 locale 改进随升级传播),
44
+ // name/builtin 不动(AC2-2 元数据覆盖)
41
45
  if (existing.length) {
42
- await db.table("sys_role").update({ title: role.title }).where({ field: "name", op: "eq", value: role.name }).run();
46
+ await db.table("sys_role").update({ title: role.title, title_key: role.title_key ?? null }).where({ field: "name", op: "eq", value: role.name }).run();
43
47
  }
44
48
  else {
45
- await db.table("sys_role").insert({ name: role.name, module: m.module, title: role.title, builtin: 0 }).run();
49
+ await db.table("sys_role").insert({ name: role.name, module: m.module, title: role.title, title_key: role.title_key ?? null, builtin: 0 }).run();
46
50
  // 声明绑定只在角色创建时写一次(角色级 insert-if-absent,AC1-15 红线):
47
51
  // 升级/重装对已存在角色永不补写声明绑定——管理员撤过的绑定不复活;
48
52
  // 新版本给既有角色新增权限点时,由管理员显式勾选(insert-if-absent 的取舍边界,§9.5)。
@@ -0,0 +1,3 @@
1
+ -- 0018__sys_role_title_key(mysql 方言):列语义同 0018__sys_role_title_key.sql 头注释(R2 回填)。
2
+ ALTER TABLE sys_role ADD COLUMN title_key TEXT NULL;
3
+ UPDATE sys_role SET title_key = title WHERE builtin = 0 AND title REGEXP '^[a-z][A-Za-z0-9_-]*:';
@@ -0,0 +1,3 @@
1
+ -- 0018__sys_role_title_key(postgres 方言):列语义同 0018__sys_role_title_key.sql 头注释(R2 回填)。
2
+ ALTER TABLE sys_role ADD COLUMN title_key TEXT;
3
+ UPDATE sys_role SET title_key = title WHERE builtin = 0 AND title ~ '^[a-z][A-Za-z0-9_-]*:';
@@ -0,0 +1,8 @@
1
+ -- 0018__sys_role_title_key(sqlite 方言):角色显示名双列(R2,计划 docs/plans/20261103-0948-role-title-translate)。
2
+ -- title = 安装期解析文本(站点默认语言);title_key = 原始 i18n key(UI 实时翻译锚,
3
+ -- 对齐 sys_permission.title_key 先例)。可空:内置角色与 custom 角色无 key。
4
+ -- 回填口径:builtin=0 且 title 命中裸 key 形状。sqlite 无 REGEXP → GLOB '[a-z]*:*'
5
+ -- (首字符小写字母 + 含冒号的近似);真实数据里含冒号的 title 只可能是裸 key
6
+ -- (内置中文/管理员文本不含冒号),pg/mysql 两侧用完整正则同义钉死。
7
+ ALTER TABLE sys_role ADD COLUMN title_key TEXT;
8
+ UPDATE sys_role SET title_key = title WHERE builtin = 0 AND title GLOB '[a-z]*:*';
@@ -100,6 +100,8 @@ tables:
100
100
  name: {type: text, null: false}
101
101
  module: {type: text, null: false}
102
102
  title: {type: text}
103
+ # 原始 i18n key(R2,0018):title = 安装期解析文本,本列存原文供 UI 实时翻译(sys_permission 先例)
104
+ title_key: {type: text}
103
105
  description: {type: text}
104
106
  builtin: {type: integer, null: false}
105
107
  deprecated: {type: integer, null: false}
@@ -19,11 +19,12 @@ get.response = {
19
19
  type: "array",
20
20
  items: {
21
21
  type: "object",
22
- required: ["name", "module", "title", "description", "builtin", "deprecated", "holders", "permissions"],
22
+ required: ["name", "module", "title", "title_key", "description", "builtin", "deprecated", "holders", "permissions"],
23
23
  properties: {
24
24
  name: { type: "string" },
25
25
  module: { type: "string" },
26
26
  title: { type: ["string", "null"] },
27
+ title_key: { type: ["string", "null"] },
27
28
  description: { type: ["string", "null"] },
28
29
  builtin: { type: "boolean" },
29
30
  deprecated: { type: "boolean" },
@@ -175,6 +175,7 @@ export const schemas = {
175
175
  "name": z.string(),
176
176
  "module": z.string(),
177
177
  "title": z.string().nullable(),
178
+ "title_key": z.string().nullable(),
178
179
  "description": z.string().nullable(),
179
180
  "builtin": z.boolean(),
180
181
  "deprecated": z.boolean(),
@@ -38,7 +38,13 @@ export default function RolesPage() {
38
38
  ),
39
39
  },
40
40
  { title: "module", dataIndex: "module" },
41
- { title: "title", dataIndex: "title" },
41
+ {
42
+ // R2:solution 角色带 title_key → 按当前语言实时翻译,title(安装期解析文本)兜底;
43
+ // custom/builtin 无 key 原样显示 title(对齐下方权限点 title_key 的渲染先例)
44
+ title: "title",
45
+ dataIndex: "title",
46
+ render: (_: string, row: RoleRow) => (row.title_key ? t(row.title_key, { defaultValue: row.title ?? "" }) : row.title),
47
+ },
42
48
  { title: t("system:rolesPage.holders"), dataIndex: "holders" },
43
49
  {
44
50
  title: t("system:rolesPage.permissions"),