@oj-module/cli 0.2.14 → 0.2.15
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.
- package/devkit/manual.md +60 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +136 -4
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +2 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +2 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +70 -1
- package/src/manifest-contract.ts +25 -2
- package/vendor/host-versions.json +3 -3
package/devkit/manual.md
CHANGED
|
@@ -1742,6 +1742,65 @@ ojm trust revoke <key_id> # 吊销:阻断新装/升级,不动已装
|
|
|
1742
1742
|
|
|
1743
1743
|
---
|
|
1744
1744
|
|
|
1745
|
+
## §13 方案菜单归组规则
|
|
1746
|
+
|
|
1747
|
+
> 适用:写方案(前后端一体模块)时让路由菜单按模块自动分组。归组是**纯视觉层**——
|
|
1748
|
+
> 开发态(`ojm dev` 直接扫源码)不归组;装进宿主且方案路由行就绪后才归组,开发形态不变。
|
|
1749
|
+
|
|
1750
|
+
### 13.1 归组规则
|
|
1751
|
+
|
|
1752
|
+
方案路由分 `main` / `admin` / `hidden` 三类。归组只动 `main` 和 `admin`:
|
|
1753
|
+
|
|
1754
|
+
- **`main` 路由 ≥ 2 条(同模块)** → 收进一个合成父菜单:
|
|
1755
|
+
- 父项 key:`solgroup:main:<模块名>`
|
|
1756
|
+
- 父项标题:裸 i18n key `<方案名>:menu.group`(翻译归渲染层,本仓只存裸 key)
|
|
1757
|
+
- 子项按各自 `order` 升序;只有 1 条 `main` 路由时不归组,原样平铺
|
|
1758
|
+
- **`admin` 路由** → 挂进宿主底部「系统设置」(`/system`)菜单的 children 末尾,同样按模块
|
|
1759
|
+
归成 `solgroup:admin:<模块名>` 父项
|
|
1760
|
+
- 若宿主没有 `/system`(极少数自定义宿主):`admin` 路由不归组、回落平铺,并告警一次
|
|
1761
|
+
(warnOnce,同消息不重复)
|
|
1762
|
+
|
|
1763
|
+
> `/system` 是框架侧断言,不是方案 manifest 能声明或验证的。方案只需把 `admin` 路由标
|
|
1764
|
+
> `category: "admin"`,挂载点交给框架。
|
|
1765
|
+
|
|
1766
|
+
### 13.2 组标题 i18n key 约定(最容易踩的坑)
|
|
1767
|
+
|
|
1768
|
+
归组父项标题是 `<方案名>:menu.group`(`<方案名>` = 你的方案 manifest 的 `name`)。这条 key
|
|
1769
|
+
**必须**存在于双语 locale 文件,且必须是**嵌套**结构:
|
|
1770
|
+
|
|
1771
|
+
```jsonc
|
|
1772
|
+
// web/src/<模块>/locales/zh-CN.json
|
|
1773
|
+
{ "menu": { "group": "Wiki" } } // 命中 <方案名>:menu.group
|
|
1774
|
+
|
|
1775
|
+
// web/src/<模块>/locales/en-US.json
|
|
1776
|
+
{ "menu": { "group": "Wiki" } }
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
- 只声明一条就够:无论多少模块、多少 `main`/`admin` 路由,框架只认一个 `<方案名>:menu.group`(按 name 去重)
|
|
1780
|
+
- 必须嵌套在 `menu.group`,不能平铺成 `"<方案名>:menu.group": "..."`(i18next 点路径取值)
|
|
1781
|
+
- 缺这条 key → 构建期契约校验直接 fail(构建期 fail-fast)
|
|
1782
|
+
|
|
1783
|
+
### 13.3 开发者的该做 / 别做
|
|
1784
|
+
|
|
1785
|
+
该做(DO):
|
|
1786
|
+
|
|
1787
|
+
- 路由 `handle.title` 写裸 key:`{ title: "wiki:menu.space", order: 200, layout: "container" }`
|
|
1788
|
+
- manifest `routes[]` 每条标 `category`(main/admin/hidden)+ `order` 整数 + `title_key`
|
|
1789
|
+
(main/admin 必填,须匹配 `<方案名>:menu.*`)
|
|
1790
|
+
- 在 `locales/{zh-CN,en-US}.json` 里补 `menu.group`
|
|
1791
|
+
- 多个 `main` 路由想归到一组 → 让它们 `module` 字段相同、`category: "main"`
|
|
1792
|
+
- `admin` 路由统一 `category: "admin"`,框架自动塞进 `/system`
|
|
1793
|
+
|
|
1794
|
+
别做(DON'T):
|
|
1795
|
+
|
|
1796
|
+
- 为归组父项写路由 / 配 handle —— 父项是合成的,没有真实路由
|
|
1797
|
+
- 直接写中文 `label` / `title` —— 必须走 i18n key,否则切语言不动
|
|
1798
|
+
- 在方案里假设 `/system` 的 key 或结构 —— 那是框架断言,自定义宿主会回落平铺
|
|
1799
|
+
- 给隐藏(hidden)或禁用(`enabled: false`)路由指望归组 —— 它们不参与
|
|
1800
|
+
- 一个模块只挂 1 条 `main` 路由还想要父菜单 —— 规则门槛是 ≥2,单条保持平铺
|
|
1801
|
+
|
|
1802
|
+
---
|
|
1803
|
+
|
|
1745
1804
|
## 附录:章节目录
|
|
1746
1805
|
|
|
1747
1806
|
| 章 | 内容 | 什么时候读 |
|
|
@@ -1758,3 +1817,4 @@ ojm trust revoke <key_id> # 吊销:阻断新装/升级,不动已装
|
|
|
1758
1817
|
| §10 CLI 子命令参考 | ojm 原生命令 vs oj 透传命令 | 查命令用法 |
|
|
1759
1818
|
| §11 方案包:打包与分发 | build --bundle、Form A/B、分发源、索引规范 | 发布方案包 |
|
|
1760
1819
|
| §12 签名与信任 | keygen/sign/install 发布流程、信任库、TOFU、bootstrap key | 首次签名发布;管理信任 |
|
|
1820
|
+
| §13 方案菜单归组规则 | main ≥2 按模块归父菜单、admin 挂进系统设置、组标题 i18n key `<方案名>:menu.group` 约定、开发者 DO/DON'T | 写方案要让菜单按模块分组 |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oj-module/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.15",
|
|
5
5
|
"description": "React Antd Admin 模块工程命令行工具(含预构建宿主 shell-dist)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@faker-js/faker": "^10.4.0",
|
|
52
|
-
"@oj-module/runtime": "0.2.
|
|
52
|
+
"@oj-module/runtime": "0.2.15",
|
|
53
53
|
"@redocly/cli": "^2.51.0",
|
|
54
54
|
"@tailwindcss/vite": "^4.3.0",
|
|
55
55
|
"esbuild": "^0.28.2",
|
|
@@ -256,10 +256,10 @@ function getAppInfo() {
|
|
|
256
256
|
return {
|
|
257
257
|
"pkg": {
|
|
258
258
|
"name": "@oj-module/runtime",
|
|
259
|
-
"version": "0.2.
|
|
259
|
+
"version": "0.2.15",
|
|
260
260
|
"license": "MIT"
|
|
261
261
|
},
|
|
262
|
-
"lastBuildTime": "2026-10-21
|
|
262
|
+
"lastBuildTime": "2026-10-21 12:22:16"
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
var init_get_app_info = __esmMin((() => {}));
|
|
@@ -6077,6 +6077,7 @@ function useMenu() {
|
|
|
6077
6077
|
* 菜单点击事件处理
|
|
6078
6078
|
*/
|
|
6079
6079
|
const handleMenuSelect = (key, mode) => {
|
|
6080
|
+
if (key.startsWith("solgroup:")) return;
|
|
6080
6081
|
if (key === removeTrailingSlash(pathname)) return;
|
|
6081
6082
|
if (!shouldSplitMenuItems || mode !== "horizontal") {
|
|
6082
6083
|
if (/http(s)?:/.test(key)) window.open(key);
|
|
@@ -8386,6 +8387,135 @@ function generateMenuItemsFromRoutes(routeList, parentPath = "/") {
|
|
|
8386
8387
|
}
|
|
8387
8388
|
var init_generate_menu_items_from_routes = __esmMin((() => {}));
|
|
8388
8389
|
//#endregion
|
|
8390
|
+
//#region src/router/utils/group-solution-menus.ts
|
|
8391
|
+
function defaultWarn(msg) {
|
|
8392
|
+
if (warnedOnce.has(msg)) return;
|
|
8393
|
+
warnedOnce.add(msg);
|
|
8394
|
+
console.warn(msg);
|
|
8395
|
+
}
|
|
8396
|
+
/**
|
|
8397
|
+
* 处理单段菜单(顶层扫描,不递归 children):
|
|
8398
|
+
* - main 路由:模块可见项 ≥2 则延迟建组(组插到该模块首个被摘项原序位);否则原样直出;
|
|
8399
|
+
* - admin 路由:可挂载 `/system` 时摘除(交由调用方挂入 bottom 的 /system children),
|
|
8400
|
+
* 否则原样直出(回落顶层平铺,保持原序);
|
|
8401
|
+
* - 无对应行项:原样直出。
|
|
8402
|
+
*/
|
|
8403
|
+
function processList(list, byPath, groupedModules, mountPossible) {
|
|
8404
|
+
const out = [];
|
|
8405
|
+
const groupsByModule = /* @__PURE__ */ new Map();
|
|
8406
|
+
const adminItems = [];
|
|
8407
|
+
list.forEach((it, idx) => {
|
|
8408
|
+
const row = byPath.get(it.key);
|
|
8409
|
+
if (!row) {
|
|
8410
|
+
out.push(it);
|
|
8411
|
+
return;
|
|
8412
|
+
}
|
|
8413
|
+
if (row.category === "admin") {
|
|
8414
|
+
adminItems.push({
|
|
8415
|
+
item: it,
|
|
8416
|
+
row
|
|
8417
|
+
});
|
|
8418
|
+
if (mountPossible) return;
|
|
8419
|
+
out.push(it);
|
|
8420
|
+
return;
|
|
8421
|
+
}
|
|
8422
|
+
if (groupedModules.has(row.module)) {
|
|
8423
|
+
let g = groupsByModule.get(row.module);
|
|
8424
|
+
if (!g) {
|
|
8425
|
+
g = {
|
|
8426
|
+
items: [],
|
|
8427
|
+
rows: [],
|
|
8428
|
+
firstIdx: idx
|
|
8429
|
+
};
|
|
8430
|
+
groupsByModule.set(row.module, g);
|
|
8431
|
+
}
|
|
8432
|
+
g.items.push(it);
|
|
8433
|
+
g.rows.push(row);
|
|
8434
|
+
return;
|
|
8435
|
+
}
|
|
8436
|
+
out.push(it);
|
|
8437
|
+
});
|
|
8438
|
+
const inserts = [...groupsByModule.values()].map((g) => ({
|
|
8439
|
+
index: g.firstIdx,
|
|
8440
|
+
group: makeGroup(MAIN_PREFIX, g.rows[0].module, g.items, g.rows)
|
|
8441
|
+
})).sort((a, b) => b.index - a.index);
|
|
8442
|
+
for (const ins of inserts) out.splice(ins.index, 0, ins.group);
|
|
8443
|
+
return {
|
|
8444
|
+
out,
|
|
8445
|
+
adminItems
|
|
8446
|
+
};
|
|
8447
|
+
}
|
|
8448
|
+
function makeGroup(prefix, module, items, rows) {
|
|
8449
|
+
const paired = items.map((item, i) => ({
|
|
8450
|
+
item,
|
|
8451
|
+
order: rows[i].order
|
|
8452
|
+
})).sort((a, b) => a.order - b.order);
|
|
8453
|
+
return {
|
|
8454
|
+
key: `${prefix}${module}`,
|
|
8455
|
+
label: `${module}:menu.group`,
|
|
8456
|
+
children: paired.map((p) => p.item)
|
|
8457
|
+
};
|
|
8458
|
+
}
|
|
8459
|
+
/**
|
|
8460
|
+
* 归组入口。
|
|
8461
|
+
*
|
|
8462
|
+
* - 空表(未安装/端点失败)或无命中行 → 返回新数组(内容相等),开发形态不变;
|
|
8463
|
+
* - 不 mutate 入参对象,未变动项复用原 menuItem 引用(仅重组数组与合成父项)。
|
|
8464
|
+
*/
|
|
8465
|
+
function groupSolutionMenus(top, bottom, rows, opts = {}) {
|
|
8466
|
+
const systemKey = opts.systemKey ?? "/system";
|
|
8467
|
+
const onWarn = opts.onWarn ?? defaultWarn;
|
|
8468
|
+
if (rows.length === 0) return {
|
|
8469
|
+
top: [...top],
|
|
8470
|
+
bottom: [...bottom]
|
|
8471
|
+
};
|
|
8472
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
8473
|
+
for (const r of rows) if ((r.category === "main" || r.category === "admin") && r.enabled !== false) byPath.set(r.path, r);
|
|
8474
|
+
if (byPath.size === 0) return {
|
|
8475
|
+
top: [...top],
|
|
8476
|
+
bottom: [...bottom]
|
|
8477
|
+
};
|
|
8478
|
+
const mainCount = /* @__PURE__ */ new Map();
|
|
8479
|
+
for (const list of [top, bottom]) for (const it of list) {
|
|
8480
|
+
const row = byPath.get(it.key);
|
|
8481
|
+
if (row?.category === "main") mainCount.set(row.module, (mainCount.get(row.module) ?? 0) + 1);
|
|
8482
|
+
}
|
|
8483
|
+
const groupedModules = new Set([...mainCount].filter(([, n]) => n >= 2).map(([module]) => module));
|
|
8484
|
+
const mountPossible = bottom.some((it) => it.key === systemKey);
|
|
8485
|
+
const topRes = processList(top, byPath, groupedModules, mountPossible);
|
|
8486
|
+
const bottomRes = processList(bottom, byPath, groupedModules, mountPossible);
|
|
8487
|
+
const adminGroupsMap = /* @__PURE__ */ new Map();
|
|
8488
|
+
for (const a of [...topRes.adminItems, ...bottomRes.adminItems]) {
|
|
8489
|
+
const arr = adminGroupsMap.get(a.row.module) ?? [];
|
|
8490
|
+
arr.push(a);
|
|
8491
|
+
adminGroupsMap.set(a.row.module, arr);
|
|
8492
|
+
}
|
|
8493
|
+
const adminGroups = [...adminGroupsMap.entries()].map(([module, items]) => ({
|
|
8494
|
+
minOrder: Math.min(...items.map((i) => i.row.order)),
|
|
8495
|
+
group: makeGroup(ADMIN_PREFIX, module, items.map((i) => i.item), items.map((i) => i.row))
|
|
8496
|
+
})).sort((a, b) => a.minOrder - b.minOrder);
|
|
8497
|
+
let newBottom = bottomRes.out;
|
|
8498
|
+
const sysIdx = newBottom.findIndex((it) => it.key === systemKey);
|
|
8499
|
+
if (mountPossible && sysIdx !== -1) {
|
|
8500
|
+
const system = newBottom[sysIdx];
|
|
8501
|
+
newBottom = newBottom.slice();
|
|
8502
|
+
newBottom[sysIdx] = {
|
|
8503
|
+
...system,
|
|
8504
|
+
children: [...system.children ?? [], ...adminGroups.map((g) => g.group)]
|
|
8505
|
+
};
|
|
8506
|
+
} else if (adminGroups.length > 0) onWarn(`[solution-menu-grouping] system menu "${systemKey}" not found; admin items keep top-level (grouping degraded).`);
|
|
8507
|
+
return {
|
|
8508
|
+
top: topRes.out,
|
|
8509
|
+
bottom: newBottom
|
|
8510
|
+
};
|
|
8511
|
+
}
|
|
8512
|
+
var MAIN_PREFIX, ADMIN_PREFIX, warnedOnce;
|
|
8513
|
+
var init_group_solution_menus = __esmMin((() => {
|
|
8514
|
+
MAIN_PREFIX = "solgroup:main:";
|
|
8515
|
+
ADMIN_PREFIX = "solgroup:admin:";
|
|
8516
|
+
warnedOnce = /* @__PURE__ */ new Set();
|
|
8517
|
+
}));
|
|
8518
|
+
//#endregion
|
|
8389
8519
|
//#region src/router/utils/merge-solution-categories.ts
|
|
8390
8520
|
/**
|
|
8391
8521
|
* 方案路由归并(纯函数,R21):按 path 把 sys_solution_route 行注记到已注册路由上。
|
|
@@ -8495,6 +8625,7 @@ var init_access = __esmMin((() => {
|
|
|
8495
8625
|
init_ascending();
|
|
8496
8626
|
init_flatten_routes();
|
|
8497
8627
|
init_generate_menu_items_from_routes();
|
|
8628
|
+
init_group_solution_menus();
|
|
8498
8629
|
init_merge_solution_categories();
|
|
8499
8630
|
init_resolve_login_route();
|
|
8500
8631
|
baseMenuRoutes = splitMenuRoutes(baseRoutes);
|
|
@@ -8514,9 +8645,10 @@ var init_access = __esmMin((() => {
|
|
|
8514
8645
|
router.patchRoutes(ROOT_ROUTE_ID, routes);
|
|
8515
8646
|
const flatRouteList = flattenRoutes(newRoutes);
|
|
8516
8647
|
const { top, bottom } = splitMenuRoutes(newRoutes);
|
|
8648
|
+
const grouped = groupSolutionMenus(generateMenuItemsFromRoutes(top), generateMenuItemsFromRoutes(bottom), get().solutionRoutes);
|
|
8517
8649
|
const newState = {
|
|
8518
|
-
wholeMenus:
|
|
8519
|
-
bottomMenus:
|
|
8650
|
+
wholeMenus: grouped.top,
|
|
8651
|
+
bottomMenus: grouped.bottom,
|
|
8520
8652
|
routeList: newRoutes,
|
|
8521
8653
|
flatRouteList,
|
|
8522
8654
|
isAccessChecked: true
|
package/shell-dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-+
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-+MhU9b9nCIgdxcVv98WPgQ=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
9
|
|
|
10
|
-
<script type="importmap" nonce="+
|
|
10
|
+
<script type="importmap" nonce="+MhU9b9nCIgdxcVv98WPgQ==">{
|
|
11
11
|
"imports": {
|
|
12
12
|
"react": "/assets/react.js",
|
|
13
13
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.2.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.15",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.15",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.15",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -14,7 +14,7 @@ import { parse } from "yaml";
|
|
|
14
14
|
// 跨端引用(双端一致钉):被测实现同仓分居两运行时(计划 §1「两运行时取舍」),
|
|
15
15
|
// 测试直接互调,比「两套件各读同一夹具」更强——漂移即本文件红。
|
|
16
16
|
import { preflight } from "../../../templates/api/src/_platform/_lifecycle/steps/preflight";
|
|
17
|
-
import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, validateContract } from "../../manifest-contract";
|
|
17
|
+
import { checkLocalesComplete, checkTablesAgainstSchema, groupI18nKeys, parseManifest, validateContract } from "../../manifest-contract";
|
|
18
18
|
|
|
19
19
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
20
20
|
const readText = (p: string) => readFileSync(join(FIXTURES, p), "utf8");
|
|
@@ -214,6 +214,75 @@ describe("checkLocalesComplete(C2-25:构建期缺 key fail-fast)", () => {
|
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
// ── C2-43 归组父标题 i18n(solution menu grouping;纯内容入参)──
|
|
218
|
+
|
|
219
|
+
describe("c2-43 归组父标题 i18n(solution menu grouping)", () => {
|
|
220
|
+
const baseManifest = (): any => ({
|
|
221
|
+
name: "demo-solution",
|
|
222
|
+
version: "0.1.0",
|
|
223
|
+
routes: [
|
|
224
|
+
{ path: "/demo-solution", category: "main", order: 200, title_key: "demo-solution:menu.main" },
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
const ZH_FULL = JSON.stringify({ menu: { main: "主", group: "分组" } });
|
|
228
|
+
const EN_FULL = JSON.stringify({ menu: { main: "Main", group: "Group" } });
|
|
229
|
+
|
|
230
|
+
it("groupI18nKeys:声明 main/admin 路由才产一条 <name>:menu.group", () => {
|
|
231
|
+
expect(groupI18nKeys(baseManifest())).toEqual(["demo-solution:menu.group"]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("groupI18nKeys:多条 main 路由去重只产一条(C5)", () => {
|
|
235
|
+
const m = baseManifest();
|
|
236
|
+
m.routes = [
|
|
237
|
+
{ path: "/a", category: "main", order: 200, title_key: "demo-solution:menu.a" },
|
|
238
|
+
{ path: "/b", category: "main", order: 210, title_key: "demo-solution:menu.b" },
|
|
239
|
+
];
|
|
240
|
+
expect(groupI18nKeys(m)).toEqual(["demo-solution:menu.group"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("groupI18nKeys:仅 hidden 路由不产 group key(C3)", () => {
|
|
244
|
+
const m = baseManifest();
|
|
245
|
+
m.routes = [{ path: "/x", category: "hidden", order: 210, title_key: "demo-solution:menu.x" }];
|
|
246
|
+
expect(groupI18nKeys(m)).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("c1 正例:menu.group 双语齐备 → 零违规", () => {
|
|
250
|
+
expect(checkLocalesComplete(baseManifest(), ZH_FULL, EN_FULL)).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("c2 反例:zh-CN 缺 menu.group → C2-43(非 C2-25,去重生效)", () => {
|
|
254
|
+
const zh = JSON.stringify({ menu: { main: "主" } });
|
|
255
|
+
const v = checkLocalesComplete(baseManifest(), zh, EN_FULL);
|
|
256
|
+
const hit = v.find(x => x.includes("C2-43"));
|
|
257
|
+
expect(hit, `期望 C2-43,实际:\n${v.join("\n")}`).toBeTruthy();
|
|
258
|
+
expect(hit).not.toContain("C2-25");
|
|
259
|
+
expect(hit).toContain("menu.group");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("c4 反例:en-US 缺 menu.group → C2-43 指明 en-US", () => {
|
|
263
|
+
const en = JSON.stringify({ menu: { main: "Main" } });
|
|
264
|
+
const v = checkLocalesComplete(baseManifest(), ZH_FULL, en);
|
|
265
|
+
const hit = v.find(x => x.includes("C2-43"));
|
|
266
|
+
expect(hit).toBeTruthy();
|
|
267
|
+
expect(hit).toContain("en-US");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("去重:多条 main 路由只产一条 group key,缺失时按语言各报一次(非按路由重复)", () => {
|
|
271
|
+
const m = baseManifest();
|
|
272
|
+
m.routes = [
|
|
273
|
+
{ path: "/a", category: "main", order: 200, title_key: "demo-solution:menu.a" },
|
|
274
|
+
{ path: "/b", category: "main", order: 210, title_key: "demo-solution:menu.b" },
|
|
275
|
+
];
|
|
276
|
+
const v = checkLocalesComplete(m, JSON.stringify({ menu: { main: "主" } }), JSON.stringify({ menu: { main: "Main" } }));
|
|
277
|
+
const c243 = v.filter(x => x.includes("C2-43"));
|
|
278
|
+
// 关键:2 条路由只产 1 个 group key → 缺失时按语言各报 1 次 = 2 条 C2-43,
|
|
279
|
+
// 而非 2 路由 × 2 语言 = 4 条(去重生效的判据是「< 路由数 × 语言数」)
|
|
280
|
+
expect(c243.length).toBe(2);
|
|
281
|
+
expect(c243.some(x => x.includes("zh-CN 缺少归组父标题 key「demo-solution:menu.group」"))).toBe(true);
|
|
282
|
+
expect(c243.some(x => x.includes("en-US 缺少归组父标题 key「demo-solution:menu.group」"))).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
217
286
|
// ── isolate 侧 preflight 复用助手(与 templates/api/tests/lifecycle/preflight.spec.ts 同构)──
|
|
218
287
|
|
|
219
288
|
function makeCtx(manifest: unknown): InstallContext {
|
package/src/manifest-contract.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// bundle 级 manifest.yaml 契约校验(P1-11,`ojm build --bundle` 构建期执行点)。
|
|
2
|
-
// 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md §2/§4(C2-01~33
|
|
2
|
+
// 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md §2/§4(C2-01~33 构建列;C2-34~42 为归档签名与信任,见 sign.ts;本文件新增 C2-43);
|
|
3
3
|
// 期望真源:src/lifecycle/__tests__/fixtures/(fixture-valid 零违规 / fixture-invalid 七码齐报)。
|
|
4
4
|
// 双端一致:本实现与 isolate 侧 preflight(templates/api/.../steps/preflight.ts)共享同一组夹具,
|
|
5
5
|
// 正则与规则**逐字一致**(§4.1 矩阵构建列 vs 安装列的执行点差异见各规则注释)——
|
|
@@ -581,6 +581,15 @@ export function validateContract(m: unknown): string[] {
|
|
|
581
581
|
return v0;
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
/** C2-43 引用面:声明 main/admin 路由的方案须提供归组父标题 `<name>:menu.group`(单 bundle 单 name,去重只产一条) */
|
|
585
|
+
export function groupI18nKeys(m: BundleManifest): string[] {
|
|
586
|
+
if (!isObj(m) || typeof m.name !== "string")
|
|
587
|
+
return [];
|
|
588
|
+
const routes = Array.isArray(m.routes) ? m.routes : [];
|
|
589
|
+
const hasGroupable = routes.some(r => isObj(r) && (r.category === "main" || r.category === "admin"));
|
|
590
|
+
return hasGroupable ? [`${m.name}:menu.group`] : [];
|
|
591
|
+
}
|
|
592
|
+
|
|
584
593
|
/** 收集 manifest 引用的全部 i18n key(C2-25 locales 齐备检查的引用面;bundle.ts 以此判定 locales 文件是否必查) */
|
|
585
594
|
export function referencedI18nKeys(m: BundleManifest): string[] {
|
|
586
595
|
const keys: string[] = [];
|
|
@@ -611,6 +620,10 @@ export function referencedI18nKeys(m: BundleManifest): string[] {
|
|
|
611
620
|
}
|
|
612
621
|
}
|
|
613
622
|
}
|
|
623
|
+
// C2-43:归组父标题纳入引用面,交由 checkLocalesComplete 双语齐备(改报 C2-43)
|
|
624
|
+
for (const k of groupI18nKeys(m)) {
|
|
625
|
+
keys.push(k);
|
|
626
|
+
}
|
|
614
627
|
return keys;
|
|
615
628
|
}
|
|
616
629
|
|
|
@@ -633,9 +646,12 @@ export function checkLocalesComplete(m: unknown, zhText: string, enText: string)
|
|
|
633
646
|
if (!isObj(m))
|
|
634
647
|
return [v("C2-25", "manifest 不是键值对象,无法核对 locales 引用")];
|
|
635
648
|
const locales: [string, Record<string, unknown> | null][] = [["zh-CN", parseJson(zhText)], ["en-US", parseJson(enText)]];
|
|
649
|
+
// group key 识别(C2-43):归组父标题缺失须归因 C2-43 而非笼统 C2-25,避免重复报错
|
|
650
|
+
const groupKeys = new Set(groupI18nKeys(m));
|
|
636
651
|
const out: string[] = [];
|
|
637
652
|
for (const key of referencedI18nKeys(m)) {
|
|
638
653
|
const nsKey = key.includes(":") ? key.slice(key.indexOf(":") + 1) : key;
|
|
654
|
+
const isGroup = groupKeys.has(key);
|
|
639
655
|
for (const [lang, tree] of locales) {
|
|
640
656
|
if (tree === null) {
|
|
641
657
|
out.push(v("C2-25", `${lang} locale 不是合法 JSON 对象,无法核对引用 key`));
|
|
@@ -643,7 +659,14 @@ export function checkLocalesComplete(m: unknown, zhText: string, enText: string)
|
|
|
643
659
|
}
|
|
644
660
|
const val = lookupKey(tree, nsKey);
|
|
645
661
|
if (typeof val !== "string" || val === "") {
|
|
646
|
-
out.push(
|
|
662
|
+
out.push(
|
|
663
|
+
v(
|
|
664
|
+
isGroup ? "C2-43" : "C2-25",
|
|
665
|
+
isGroup
|
|
666
|
+
? `${lang} 缺少归组父标题 key「${key}」(须为嵌套 menu.group,C2-43;声明 main/admin 路由即须提供)`
|
|
667
|
+
: `${lang} 缺少 manifest 引用的 key「${key}」(双语齐备,D4;构建期 fail-fast)`,
|
|
668
|
+
),
|
|
669
|
+
);
|
|
647
670
|
}
|
|
648
671
|
}
|
|
649
672
|
}
|
|
@@ -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.
|
|
12
|
-
"@oj-module/runtime/contract": "0.2.
|
|
13
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
11
|
+
"@oj-module/runtime": "0.2.15",
|
|
12
|
+
"@oj-module/runtime/contract": "0.2.15",
|
|
13
|
+
"@oj-module/runtime/contract/errors": "0.2.15",
|
|
14
14
|
"@plane/kit": "0.1.20",
|
|
15
15
|
"@plane/editor": "0.1.20",
|
|
16
16
|
"antd": "6.6.1",
|