@oj-module/cli 0.2.56 → 0.2.62

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 (50) 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/{index-CImwskAz.js → index-BKtXxIXz.js} +32 -2
  8. package/shell-dist/assets/runtime.js +6 -6
  9. package/shell-dist/index.html +3 -3
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/port-check.test.ts +40 -0
  12. package/src/__tests__/vendor-devkit.test.ts +87 -0
  13. package/src/__tests__/vendor.test.ts +39 -0
  14. package/src/build/backend.ts +131 -0
  15. package/src/build/module-build.ts +364 -0
  16. package/src/build/module-def.ts +400 -0
  17. package/src/build/site-merge.ts +33 -0
  18. package/src/build.ts +16 -885
  19. package/src/bundle.ts +61 -0
  20. package/src/cli.ts +11 -1
  21. package/src/contract/emit-stub.ts +7 -3
  22. package/src/dev-secrets.ts +315 -0
  23. package/src/dev.ts +26 -3
  24. package/src/init.ts +27 -39
  25. package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
  26. package/src/lifecycle/__tests__/bundle.test.ts +24 -1
  27. package/src/lifecycle/__tests__/deterministic-tar.test.ts +19 -2
  28. package/src/market/bootstrap.ts +5 -2
  29. package/src/new.ts +27 -0
  30. package/src/oj.ts +82 -9
  31. package/src/port-check.ts +38 -0
  32. package/src/rekey.ts +51 -0
  33. package/src/static-handler.ts +7 -2
  34. package/src/usage.ts +12 -2
  35. package/src/vendor.ts +66 -5
  36. package/templates/openapi.json +7 -0
  37. package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
  38. package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
  39. package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
  40. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
  41. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
  42. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
  43. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
  44. package/templates/src/app/api/_platform/schema.yaml +2 -0
  45. package/templates/src/app/api/system/roles/api.ts +2 -1
  46. package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
  47. package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
  48. package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
  49. package/templates/tests/rbac-roles.spec.ts +9 -0
  50. package/vendor/host-versions.json +3 -3
@@ -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"),
@@ -0,0 +1,171 @@
1
+ // BDD:solution 角色 title 落库时同步翻译(R1–R6,计划 docs/plans/20261103-0948-role-title-translate)。
2
+ // title = 解析文本(站点默认语言)、title_key = 原文(UI 实时翻译锚);解析失败回退裸 key,
3
+ // 绝不失败安装(R1);升级元数据覆盖 = 重解析(R3);迁移 0018 对存量 solution 角色回填、
4
+ // builtin 不动(R2/AC4);sys_permission 不变(R5);name/code/绑定不变(R6)。
5
+ /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写(register.spec 同约定) */
6
+ import { createRequire } from "node:module";
7
+ import { fileURLToPath } from "node:url";
8
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
9
+ import { registerSolution, resolveDeclTitles, siteLanguage } from "../../src/app/api/_platform/_lifecycle/steps/register";
10
+ import { installDb } from "../fixture";
11
+
12
+ const req = createRequire(import.meta.url);
13
+ const { DatabaseSync } = req("node:sqlite");
14
+ const { readFileSync } = req("node:fs");
15
+ const { dirname, join } = req("node:path");
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const enc = new TextEncoder();
19
+
20
+ let dbo: ReturnType<typeof installDb>;
21
+ const g = globalThis as any;
22
+
23
+ beforeAll(() => {
24
+ dbo = installDb();
25
+ g.http = {};
26
+ });
27
+
28
+ afterEach(() => {
29
+ g.http = {};
30
+ dbo.exec("DELETE FROM sys_role_permission");
31
+ dbo.exec("DELETE FROM sys_user_role");
32
+ dbo.exec("DELETE FROM sys_permission");
33
+ dbo.exec("DELETE FROM sys_role");
34
+ dbo.exec("DELETE FROM sys_solution_route");
35
+ dbo.exec("DELETE FROM sys_setting");
36
+ dbo.exec("DELETE FROM sys_oplog");
37
+ });
38
+
39
+ function roleRows() {
40
+ return dbo.prepare("SELECT name, title, title_key FROM sys_role ORDER BY name").all() as Array<{ name: string, title: string | null, title_key: string | null }>;
41
+ }
42
+
43
+ /** readFile 假面:包内 locales 按路径精确映射,未命中即「文件不存在」 */
44
+ function fakeReadFile(staging: string, files: Record<string, string>) {
45
+ return async (p: string) => {
46
+ const hit = files[p];
47
+ if (hit === undefined)
48
+ throw new Error(`ENOENT: ${p}`);
49
+ return enc.encode(hit);
50
+ };
51
+ }
52
+
53
+ const STAGING = "/staging";
54
+ const PM_LOCALES = {
55
+ [`${STAGING}/web/modules/pm/locales/zh-CN.json`]: JSON.stringify({ role: { sales: "销售", admin: "方案管理员" } }),
56
+ [`${STAGING}/web/modules/pm/locales/en-US.json`]: JSON.stringify({ role: { sales: "Sales", admin: "Solution Admin" } }),
57
+ };
58
+
59
+ function pmDecl(title: string) {
60
+ return {
61
+ module: "pm",
62
+ version: "1.0.0",
63
+ roles: [{ name: "pm.sales", title, title_key: title, permissions: ["pm.doc.create"] as string[] }],
64
+ permissions: ["pm.doc.create"],
65
+ };
66
+ }
67
+
68
+ describe("R4 语言取值", () => {
69
+ it("缺省 zh-CN(sys_setting 无 site.language 行)", async () => {
70
+ expect(await siteLanguage()).toBe("zh-CN");
71
+ });
72
+
73
+ it("站点设置 site.language 生效(R4:站点默认语言)", async () => {
74
+ await g.db.table("sys_setting").insert({ key: "site.language", module: "framework", group: "site", type: "string", value: "en-US", update_time: Date.now(), deprecated: 0 }).run();
75
+ expect(await siteLanguage()).toBe("en-US");
76
+ });
77
+ });
78
+
79
+ describe("R1 注册时解析(resolveDeclTitles)", () => {
80
+ it("AC1 首装:title_key 解析为站点语言 locale 文本", async () => {
81
+ const resolved = await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", fakeReadFile(STAGING, PM_LOCALES));
82
+ expect(resolved.roles[0]!.title).toBe("销售");
83
+ expect(resolved.roles[0]!.title_key).toBe("pm:role.sales");
84
+ });
85
+
86
+ it("R4:语言参数决定读取的 locale 文件", async () => {
87
+ const resolved = await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "en-US", fakeReadFile(STAGING, PM_LOCALES));
88
+ expect(resolved.roles[0]!.title).toBe("Sales");
89
+ });
90
+
91
+ it("AC3 缺 key:保留裸 key(title 原样),绝不抛错", async () => {
92
+ const files = { [`${STAGING}/web/modules/pm/locales/zh-CN.json`]: JSON.stringify({ role: { sales: "销售" } }) };
93
+ const resolved = await resolveDeclTitles(pmDecl("pm:role.admin"), STAGING, "zh-CN", fakeReadFile(STAGING, files));
94
+ expect(resolved.roles[0]!.title).toBe("pm:role.admin");
95
+ });
96
+
97
+ it("R1 边界:locale 文件缺失 / 坏 JSON / 非 string 值 / 空 key 形状不符 → 原文保留", async () => {
98
+ const noFile = await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", fakeReadFile(STAGING, {}));
99
+ expect(noFile.roles[0]!.title).toBe("pm:role.sales");
100
+
101
+ const badJson = await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", fakeReadFile(STAGING, { [`${STAGING}/web/modules/pm/locales/zh-CN.json`]: "{not-json" }));
102
+ expect(badJson.roles[0]!.title).toBe("pm:role.sales");
103
+
104
+ const nonString = await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", fakeReadFile(STAGING, { [`${STAGING}/web/modules/pm/locales/zh-CN.json`]: JSON.stringify({ role: { sales: 42 } }) }));
105
+ expect(nonString.roles[0]!.title).toBe("pm:role.sales");
106
+
107
+ // 非 key 形状(中文文本)不做解析尝试——已解析文本二次注册不被误改
108
+ const plain = await resolveDeclTitles(pmDecl("销售"), STAGING, "zh-CN", fakeReadFile(STAGING, PM_LOCALES));
109
+ expect(plain.roles[0]!.title).toBe("销售");
110
+ });
111
+
112
+ it("R1 边界:ns ≠ 模块名不越界解析(跨 ns 无本包 locale)", async () => {
113
+ const decl = { ...pmDecl("wiki:role.sales"), module: "pm" };
114
+ const resolved = await resolveDeclTitles(decl, STAGING, "zh-CN", fakeReadFile(STAGING, PM_LOCALES));
115
+ expect(resolved.roles[0]!.title).toBe("wiki:role.sales");
116
+ });
117
+ });
118
+
119
+ describe("R1–R3 落库(registerSolution 消费解析后的 decl)", () => {
120
+ it("AC1 首装:sys_role 落 (title=销售, title_key=pm:role.sales),权限点照常", async () => {
121
+ const resolved = await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", fakeReadFile(STAGING, PM_LOCALES));
122
+ const r = await registerSolution(resolved);
123
+ expect(r.ok).toBe(true);
124
+ const rows = roleRows();
125
+ expect(rows).toEqual([{ name: "pm.sales", title: "销售", title_key: "pm:role.sales" }]);
126
+ const perms = dbo.prepare("SELECT code FROM sys_permission").all() as Array<{ code: string }>;
127
+ expect(perms.map(p => p.code)).toEqual(["pm.doc.create"]);
128
+ });
129
+
130
+ it("AC2 升级:元数据覆盖写新解析文本 + title_key 刷新(R3 locale 改进随升级传播)", async () => {
131
+ await registerSolution(await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", fakeReadFile(STAGING, PM_LOCALES)));
132
+ const improved = fakeReadFile(STAGING, { [`${STAGING}/web/modules/pm/locales/zh-CN.json`]: JSON.stringify({ role: { sales: "销售冠军" } }) });
133
+ const r = await registerSolution(await resolveDeclTitles(pmDecl("pm:role.sales"), STAGING, "zh-CN", improved));
134
+ expect(r.ok).toBe(true);
135
+ expect(roleRows()).toEqual([{ name: "pm.sales", title: "销售冠军", title_key: "pm:role.sales" }]);
136
+ });
137
+
138
+ it("AC3 缺 key:注册不失败,title = 裸 key(现行回退形态)", async () => {
139
+ const r = await registerSolution(await resolveDeclTitles(pmDecl("pm:role.viewer"), STAGING, "zh-CN", fakeReadFile(STAGING, PM_LOCALES)));
140
+ expect(r.ok).toBe(true);
141
+ expect(roleRows()).toEqual([{ name: "pm.sales", title: "pm:role.viewer", title_key: "pm:role.viewer" }]);
142
+ });
143
+ });
144
+
145
+ describe("R2 迁移 0018(sys_role.title_key 回填)", () => {
146
+ it("AC4 夹具真实迁移后 sys_role 带 title_key 列(列进库 ↔ schema.yaml 一致守卫的库侧半边)", () => {
147
+ const cols = (dbo.prepare("PRAGMA table_info(sys_role)").all() as Array<{ name: string }>).map(c => c.name);
148
+ expect(cols).toContain("title_key");
149
+ });
150
+
151
+ it("AC4 存量 solution 角色按 key 形状回填,builtin 角色不动、title 原文不改", () => {
152
+ const raw = new DatabaseSync(":memory:");
153
+ raw.exec(`
154
+ CREATE TABLE sys_role (
155
+ name TEXT PRIMARY KEY, module TEXT NOT NULL, title TEXT, description TEXT,
156
+ builtin INTEGER NOT NULL DEFAULT 0, deprecated INTEGER NOT NULL DEFAULT 0
157
+ );
158
+ INSERT INTO sys_role (name, module, title, builtin) VALUES
159
+ ('pm.sales', 'pm', 'pm:role.sales', 0),
160
+ ('wiki.admin', 'wiki', 'wiki:role.admin', 0),
161
+ ('admin', 'framework', '系统管理员', 1);
162
+ `);
163
+ raw.exec(readFileSync(join(HERE, "../../src/app/api/_platform/migrations/0018__sys_role_title_key.sql"), "utf8"));
164
+ const rows = raw.prepare("SELECT name, title, title_key FROM sys_role ORDER BY name").all() as Array<{ name: string, title: string, title_key: string | null }>;
165
+ expect(rows).toEqual([
166
+ { name: "admin", title: "系统管理员", title_key: null },
167
+ { name: "pm.sales", title: "pm:role.sales", title_key: "pm:role.sales" },
168
+ { name: "wiki.admin", title: "wiki:role.admin", title_key: "wiki:role.admin" },
169
+ ]);
170
+ });
171
+ });
@@ -157,6 +157,15 @@ describe("列表支撑(C2 页面数据源)", () => {
157
157
  expect(perms.find(p => p.code === "wiki.new")?.deprecated).toBe(false);
158
158
  });
159
159
 
160
+ it("listRoles 带出 title_key(R2:solution 角色 UI 实时翻译锚;custom/builtin 为 null)", async () => {
161
+ // solution 角色带 title_key(0018 起);custom 角色管理员手填 title、无 key
162
+ dbo.prepare("INSERT INTO sys_role (name, module, title, title_key, builtin) VALUES ('pm.sales', 'pm', '销售', 'pm:role.sales', 0)").run();
163
+ await createRole({ name: "custom.tk", title: "手填" });
164
+ const roles = await listRoles();
165
+ expect(roles.find(x => x.name === "pm.sales")?.title_key).toBe("pm:role.sales");
166
+ expect(roles.find(x => x.name === "custom.tk")?.title_key).toBe(null);
167
+ });
168
+
160
169
  it("listRoles 原样返回 superadmin(只在 /system/roles 展示层隐去)", async () => {
161
170
  // 成员资格页(users/roles.tsx)把 superadmin 当可授予项(仅 superadmin 可勾选),
162
171
  // 页面数据源里少了它会让「授予超级管理员」直接不可用。过滤只做在展示层。
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.56",
12
- "@oj-module/runtime/contract": "0.2.56",
13
- "@oj-module/runtime/contract/errors": "0.2.56",
11
+ "@oj-module/runtime": "0.2.62",
12
+ "@oj-module/runtime/contract": "0.2.62",
13
+ "@oj-module/runtime/contract/errors": "0.2.62",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",