@oj-module/cli 0.2.20 → 0.2.23

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 (39) hide show
  1. package/devkit/manual.md +93 -7
  2. package/package.json +2 -2
  3. package/shell-dist/assets/runtime.js +59 -34
  4. package/shell-dist/index.html +2 -2
  5. package/shell-dist/versions.json +3 -3
  6. package/src/__tests__/init-packagejson.test.ts +29 -0
  7. package/src/args.ts +54 -4
  8. package/src/builtin.ts +93 -0
  9. package/src/bundle.ts +230 -26
  10. package/src/contract/__tests__/market-client.integration.test.ts +12 -12
  11. package/src/dev.ts +58 -2
  12. package/src/index.ts +26 -6
  13. package/src/info.ts +49 -7
  14. package/src/init.ts +23 -7
  15. package/src/lifecycle/__tests__/args.test.ts +20 -0
  16. package/src/lifecycle/__tests__/builtin.test.ts +97 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +168 -5
  18. package/src/lifecycle/__tests__/new.test.ts +129 -0
  19. package/src/lifecycle/__tests__/smoke.test.ts +2 -1
  20. package/src/manifest-contract.ts +16 -3
  21. package/src/market/publish.ts +10 -2
  22. package/src/new.ts +208 -0
  23. package/src/usage.ts +18 -6
  24. package/templates/package.json +3 -1
  25. package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
  26. package/templates/src/_platform/api/seed.mysql.sql +11 -3
  27. package/templates/src/_platform/api/seed.pg.sql +11 -3
  28. package/templates/src/_platform/api/seed.sql +16 -5
  29. package/templates/src/oj-market/web/api/context.ts +2 -2
  30. package/templates/src/oj-market/web/components/InstallDialog.tsx +13 -6
  31. package/templates/src/oj-market/web/components/RollbackDialog.tsx +7 -3
  32. package/templates/src/oj-market/web/components/UninstallConfirm.tsx +18 -7
  33. package/templates/src/oj-market/web/hooks/useMarket.ts +30 -18
  34. package/templates/src/system/web/locales/en-US.json +1 -1
  35. package/templates/src/system/web/locales/zh-CN.json +1 -1
  36. package/templates/tests/l1-builder.test.ts +2 -2
  37. package/templates/tests/rbac-seed.spec.ts +12 -10
  38. package/templates/tests/seed-role-accounts.spec.ts +87 -0
  39. package/vendor/host-versions.json +3 -3
package/devkit/manual.md CHANGED
@@ -39,6 +39,7 @@
39
39
  ├── tests/ # 后端 BDD 测试(node:sqlite 内存库 + 真实迁移建表)
40
40
  ├── src/
41
41
  │ ├── tsconfig.json # 后端 typecheck 工程(extends 上两者,exclude */web)
42
+ │ ├── .ojm-builtin.json # 框架自带方案名单(init 派生,可手改/删除;打包排除 + info 折叠,见 §11)
42
43
  │ ├── styles.css
43
44
  │ ├── _platform/ # 框架级:users 表 / rbac / 市场编排 / 生命周期
44
45
  │ │ ├── api/ # + _shared/(respond、service/、site-facade)
@@ -74,7 +75,19 @@
74
75
  > **历史布局仍被识别**:`web/src/<name>`(uni-dev)与 `web/<name>`(框架根仓)照旧可用,
75
76
  > 只是 `ojm init` 不再产出。两套源码树**互斥**——同时存在时 `ojm` 直接报错,而不是静默选一个。
76
77
 
77
- 登录账号 `admin / 123456`。端口:前端 devServer **5174**,oj 后端 **9778**
78
+ 登录账号:**账号名=内置角色名**一一对应,口令均 `123456`,且全部**免首登改密**(must_change=0,纯开发便利,勿用于生产):
79
+
80
+ | 账号 | 角色 | 能干什么 |
81
+ |---|---|---|
82
+ | `superadmin` | superadmin | 全部能力(含安全组设置、成员资格管理) |
83
+ | `admin` | admin | 日常管理(不含 superadmin 专属项) |
84
+ | `editor` | editor | 页面门编辑级 |
85
+ | `viewer` | viewer | 页面门访客级 |
86
+
87
+ > 旧脚手架库(admin 绑 superadmin / must_change=1)**无需手工迁移**:seed 每次启动幂等重放,
88
+ > 自动收敛为账号名=角色名并清零 must_change(`DELETE` 旧绑定 + `UPDATE`,见 `_platform/api/seed.sql`)。
89
+
90
+ 端口:前端 devServer **5174**,oj 后端 **9778**
78
91
  (以工程根 `config.yaml` 的 `server.port` 为准)。
79
92
 
80
93
  ### 1.2 两个模块体系,同名不同物
@@ -93,6 +106,33 @@
93
106
  历史布局下两侧分居 `web/src/<模块>/` 与 `api/src/<模块>/`——**同一个名字、两个目录**,
94
107
  是布局迁移时最容易对不上的地方。
95
108
 
109
+ #### manifest.yaml 与 solution.yaml:两种身份,别混淆
110
+
111
+ 工程里会出现两个名字里带 "manifest" 的文件,用途**完全不同**,混用会出构建/运行期怪问题:
112
+
113
+ | 文件 | 层级 | 身份 | 谁读它 | 写什么 |
114
+ |---|---|---|---|---|
115
+ | `src/<模块>/api/manifest.yaml` | **模块级**(每个后端模块一份) | oj **原生七键**清单(`name`/`desc`/`version`/`tables`/…) | **oj 运行时**——按 `manifest.yaml` 枚举发现并校验 api 后端模块 | 模块名、版本、表清单(与 `schema.yaml` 双向一致,§1.5) |
116
+ | `solution.yaml`(`src/<模块>/` 或工程根) | **方案级**(整个方案一份) | 方案包契约(身份 / 路由 / api 段 / web 段 / 权限 / …) | **`ojm build --bundle`**——打包入口与契约校验 | 方案名、版本、`api` 段(模块+前缀+tables+deps+anonymous)、`web` 段(entry/css/integrity)、`routes`、`health` |
117
+
118
+ 关键区别与红线:
119
+
120
+ - **模块级 `manifest.yaml` 是 oj 发现 api 后端的唯一真源**:`ojm dev/build` 递归扫描
121
+ `src/<模块>/api/manifest.yaml` 认领后端模块;oj 启动也用它做强校验(`name ==` 目录名、版本同源)。
122
+ 它**只能有 oj 原生七键**,写任何方案级字段(`routes` / `permissions` / …)都会被拒启。
123
+ - **方案级契约以前也叫 `manifest.yaml`(工程根)**,但它会和上面的「oj 按 `manifest.yaml` 枚举 api 模块」
124
+ 撞名:**工程根再放一份 `manifest.yaml` 当 bundle 契约,会被 oj 的搜索枚举误当成 api 模块**,污染后端发现。
125
+ 所以解决方案统一改名为 **`solution.yaml`**,`ojm build --bundle` 优先认它(`manifest.yaml` 仅作旧方案回退)——
126
+ **后端按 `manifest.yaml` 枚举 api、打包按 `solution.yaml` 取契约**,两条枚举互不干扰。
127
+ **位置**:单模块方案推荐放 `src/<模块>/solution.yaml`(契约与所打包模块同位);`ojm build --bundle`
128
+ 也兼容工程根 `solution.yaml`,以及两处的 `manifest.yaml` 回退。**模块级契约只认 `src/<模块>/` 根下、
129
+ 绝不进 `<模块>/api/`**——否则照样会和 oj 的 api 枚举撞车。
130
+ - 进包后文件名固定回到 `manifest.yaml`(安装侧只认这个名字):`ojm build --bundle` 把工程根或
131
+ `src/<模块>/` 下的 `solution.yaml`(或旧 `manifest.yaml`)拷贝进 tgz 内的 `<name>-<version>/manifest.yaml`,安装器/市场仍按老名字消费。
132
+
133
+ > 一句话:**`manifest.yaml` 是给 oj 认模块用的(模块级、七键、oj 枚举);`solution.yaml` 是给 `ojm build --bundle`
134
+ > 打包用的(方案级、工程根)。两者文件名不同、层级不同、读者不同,别写错层。**
135
+
96
136
  ### 1.3 后端路由 = 目录镜像
97
137
 
98
138
  ```
@@ -1377,12 +1417,15 @@ useEffect(() => {
1377
1417
  pnpm dev # 开发:devServer + oj 后端(src/<模块>/api 保存即热更)
1378
1418
  # 交互快捷键(仅 TTY 终端启用,类 Vite):
1379
1419
  # r 强制重建并热更新 h 显示帮助 c 清屏 o 浏览器打开 q 退出(oj 一并终止)
1420
+ # 提示行常驻终端最末行(滚动区),日志在其上方滚动不会被顶走(ojm ≥ 0.2.22)
1380
1421
  # Ctrl+C(SIGINT)/ kill(SIGTERM)终止 dev 时,前端与后端 oj 一起终止:
1381
1422
  # 先关 SSE 通道 → 停 oj 子进程 → 打印「后端 oj 已随前端一起终止」,不留 oj 孤儿占端口
1382
1423
  pnpm build # = ojm build:oj build(生成 routes.js)+ 前端全站合并到 web/dist
1383
1424
  pnpm preview # = ojm preview:oj migrate(verify 闸)→ oj serve 单服务(API + 静态 SPA 同进程)
1384
1425
  pnpm typecheck # = tsc --noEmit(前端工程)
1385
1426
  pnpm typecheck:api # = tsc --noEmit -p src/tsconfig.json(后端工程,见 §6.5)
1427
+ pnpm new # = ojm new <name>:脚手架内生成用户方案骨架(见 §10.1)
1428
+ pnpm bundle # = ojm build --bundle:打包全部用户方案(自带方案自动排除,见 §11)
1386
1429
  pnpm exec ojm info # 版本矩阵 + 模块清单(报障用)
1387
1430
  pnpm exec ojm vendor # 下载/重装 oj 二进制
1388
1431
  ```
@@ -1616,6 +1659,28 @@ export default async (path: string, html: string) => {
1616
1659
  > release 下只有 `routes.js` 里的路由存在,目录镜像路由不生效——所以参数路由要先
1617
1660
  > `ojm build` 再测(见 §7)。
1618
1661
 
1662
+ #### `ojm build --bundle [name]`
1663
+
1664
+ 打包**方案包**(solution bundle,可分发产物;语义细节见 §11)。只组装不构建,先 `ojm build` 再打包。
1665
+
1666
+ - `--bundle` 无参:工程根有 `solution.yaml` 则只打根契约(旧单方案行为零变化);
1667
+ 否则收集 `src/*/` 下全部**用户方案**契约逐个打包(框架自带方案自动排除,日志注明「框架自带,跳过」)。
1668
+ - `--bundle <name>` / `--bundle=<name>`:只打 `src/<name>/solution.yaml` 这一份(manifest.yaml 回退)。
1669
+ - 两段式:全部契约校验通过才开始写盘;零用户方案时报错指路 `ojm new`。
1670
+
1671
+ #### `ojm new <name> [--layout container|none] [--api]`
1672
+
1673
+ 脚手架内生成一份用户方案骨架(module-root 布局):`src/<name>/solution.yaml` +
1674
+ `web/{entry.ts,pages/,locales/}`(`--api` 再加 `api/{manifest.yaml,api.ts}`)。
1675
+ 生成即被扫描收录,补产物后 `ojm build --bundle <name>` 直接可打(round-trip)。
1676
+ 非 module-root 布局拒绝(会先要求迁移);名字撞内置方案 / 保留名 / 已存在目录都会拒绝。
1677
+ ```bash
1678
+ ojm new crm --layout container # 建 src/crm/ 骨架
1679
+ pnpm dev # 看效果
1680
+ ojm build --bundle crm # 打包
1681
+ ```
1682
+
1683
+
1619
1684
  #### `ojm preview [port]`
1620
1685
 
1621
1686
  生产形态预览(设计 §6,2026-10-19 单服务化):
@@ -1647,9 +1712,11 @@ export default async (path: string, html: string) => {
1647
1712
 
1648
1713
  生成物重跑即覆盖(eslint 已忽略);唯一例外是带 `// ojm-api:stub` 指纹的 stub,人碰过则工具永不写删。
1649
1714
 
1650
- #### `ojm info`
1715
+ #### `ojm info [--all]`
1651
1716
 
1652
1717
  输出版本矩阵(`@oj-module/cli` / `@oj-module/runtime` / 钉版 `@plane/*` / `oj` 二进制)与模块清单。
1718
+ module-root 下数据源是「扫描 + `web.config.ts` 登记」并集(未登记的用户模块也可见)。
1719
+ 框架自带方案默认折叠为一行计数(`--all` 展开平铺)。
1653
1720
  **报障用**:拿不准「工程里到底装了什么版本、哪些模块被登记」时,先 `ojm info` 贴出来。
1654
1721
 
1655
1722
  #### `ojm vendor [tag] [--force]`
@@ -1722,25 +1789,44 @@ export default async (path: string, html: string) => {
1722
1789
  ## §11 方案包:打包与分发
1723
1790
 
1724
1791
  「站点级构建」见 §7;本节讲**方案包**(solution bundle)——单方案的可分发产物与到达站点的方式。
1792
+ 一个脚手架可放多份用户方案(`ojm new` 逐个生成,各自一份 `src/<name>/solution.yaml`),
1793
+ `ojm build --bundle` 无参时全部打包、逐个校验独立出包;框架自带方案自动排除(名单见
1794
+ `src/.ojm-builtin.json`,`ojm info` 默认折叠自带模块)。
1795
+ **从开发到发布的完整过程视角(含签名与私钥保存纪律)见**
1796
+ [`docs/guides/solution-dev-build-publish.md`](../guides/solution-dev-build-publish.md)。
1725
1797
 
1726
1798
  ### 11.1 打包:`ojm build --bundle`
1727
1799
 
1728
1800
  ```bash
1729
1801
  ojm build --bundle # --out <dir> 缺省工程根 dist/(方案包输出目录,与后端产物 api/dist 无关)
1730
- # [ojm] 方案包已生成 → dist/<name>-<version>.tgz
1802
+ ojm build --bundle crm # 只打 src/crm/ 这一份;--bundle=crm 等价
1803
+ # [ojm] 方案包已生成 → dist/<name>-<version>.tgz(多份用户方案时逐份输出,共打包 N 份)
1731
1804
  # [ojm] sha256:…(确定性打包:同源同字节)
1805
+ # 没有可打包的用户方案时:报错指向 ojm new(不静默产出空批次)
1732
1806
  ```
1733
1807
 
1734
1808
  > `--bundle` 只**组装**,不重跑构建:入包内容取自 `api/dist/<name>-<version>/` 与
1735
1809
  > `web/dist/modules/<name>/`——先 `ojm build` 再打包。
1736
1810
 
1737
- - 打包即校验(fail-fast,坏清单出不了包):方案 manifest 契约校验(字段齐全 / 类型白名单 /
1738
- 保留名 / 键空间 → 可读违规清单)→ 文件面校验(`api.tables` ↔ `schema.yaml`、locales 双语齐备)。
1811
+ - **契约入口(按优先级)**:`src/<模块>/solution.yaml` ≻ 工程根 `solution.yaml` ≻
1812
+ `src/<模块>/manifest.yaml` ≻ 工程根 `manifest.yaml`(方案级;`manifest.yaml` 为旧方案回退)。
1813
+ **单模块方案推荐把契约放在 `src/<模块>/solution.yaml`**——契约与它所打包的模块同位,最直观。
1814
+ 文件名刻意避开 `manifest.yaml`:oj 按 `manifest.yaml` 枚举发现 api 后端模块(§1.2),工程根若放一份
1815
+ bundle 契约会污染那条搜索枚举;故用 `solution.yaml` 隔开两条枚举——**后端按 `manifest.yaml` 枚举 api、
1816
+ 打包按 `solution.yaml` 取契约**,不再冲突。模块级契约同样**只认 `src/<模块>/` 根下、绝不进 `<模块>/api/`**,
1817
+ 否则会与 oj 的 api 枚举撞车。tgz 内固定写回 `manifest.yaml`(安装侧只认这个名字)。
1818
+ - **两段式**:先对全部目标契约跑完校验(纯读零写盘),全绿才开始逐个写 tgz——任一校验失败
1819
+ dist/ 零增量,报错指名失败方案(发布者不会把半截批次扫进 CDN 索引)。校验项(fail-fast,
1820
+ 坏清单出不了包):方案 solution 契约(字段齐全 / 类型白名单 / 保留名 / 键空间 → 可读违规清单)
1821
+ → 文件面(`api.tables` ↔ `schema.yaml`、locales 双语齐备)→ 模块级契约 `name` 必须 === 目录名。
1822
+ - 框架自带方案不参与打包:`src/.ojm-builtin.json` 名单内的模块契约被跳过(逐条日志);
1823
+ 该文件由 `ojm init` 从模板白名单派生,**可手改/删除**(删名 = 恢复参与打包),重跑 init 缺失才补写。
1824
+ 它只是打包排除 / info 折叠提示,**不是安全边界**:文件永不入包,安装侧信任链(签名 + 信任库 + TOFU)与它无关。
1739
1825
  - **只出包不签名**——签名见 §12(关注点分离:作者签名是显式动作,不是构建副产品)。
1740
1826
  - 产物内 `manifest.yaml` 的 `archive` 段是 **Form A**(仅 `file`);`sha256`/`size` 由
1741
1827
  file 源上传时点补齐(宿主安装器)或发布通道 Form B 自带。
1742
- - **入包红线**:`*.pem` / `*.key` / `.env` / `id_ed25519` / `id_rsa` 等私钥形态文件禁入包
1743
- (与 `modules.json` 禁入同款自检)——私钥入包即信任根基烧毁。
1828
+ - **入包红线**:`*.pem` / `*.key` / `*.p12` / `*.pfx` / `.env` 系 / `id_ed25519` / `id_rsa`
1829
+ 等私钥形态文件禁入包(与 `modules.json` 禁入同款自检,0.2.23 起扩面)——私钥入包即信任根基烧毁。
1744
1830
 
1745
1831
  ### 11.2 两种分发源
1746
1832
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.20",
4
+ "version": "0.2.23",
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.20",
52
+ "@oj-module/runtime": "0.2.23",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "esbuild": "^0.28.2",
@@ -204,6 +204,7 @@ var init_site_base = __esmMin((() => {
204
204
  * 子路径部署(/oj/wiki/...)下直接与 loginPath 比较恒为 false,401 时会重复 goLogin。
205
205
  * React 组件内请用 `useLocation()`(react-router 已剥离 basename),无需本函数。
206
206
  *
207
+ * @param pathname 原始路径(含部署 base,如 `location.pathname`)
207
208
  * @param base 部署 base,恒以 `/` 结尾;缺省取 `resolveSiteBase()`(部署期注入优先,
208
209
  * 见 utils/site-base.ts —— runtime 产物里的 `import.meta.env.BASE_URL` 是构建期
209
210
  * 内联的 `/`,子路径部署下直接用它恒错)
@@ -303,10 +304,10 @@ function getAppInfo() {
303
304
  return {
304
305
  "pkg": {
305
306
  "name": "@oj-module/runtime",
306
- "version": "0.2.20",
307
+ "version": "0.2.23",
307
308
  "license": "MIT"
308
309
  },
309
- "lastBuildTime": "2026-10-22 12:17:23"
310
+ "lastBuildTime": "2026-10-22 18:50:31"
310
311
  };
311
312
  }
312
313
  var init_get_app_info = __esmMin((() => {}));
@@ -429,7 +430,7 @@ function PageError({ error, resetErrorBoundary }) {
429
430
  };
430
431
  useEffect(() => {
431
432
  if (enableDynamicTitle) document.title = t("exception.pageErrorTitle");
432
- }, [enableDynamicTitle]);
433
+ }, [enableDynamicTitle, t]);
433
434
  return /* @__PURE__ */ jsx(Result, {
434
435
  status: "error",
435
436
  icon: /* @__PURE__ */ jsx("div", {
@@ -3495,7 +3496,7 @@ function useCurrentRoute() {
3495
3496
  const matches = useMatches();
3496
3497
  return useMemo(() => {
3497
3498
  return matches[matches.length - 1];
3498
- }, [matches, location]);
3499
+ }, [matches]);
3499
3500
  }
3500
3501
  var init_use_current_route = __esmMin((() => {}));
3501
3502
  //#endregion
@@ -3595,7 +3596,7 @@ function useCssVar(name, root = defaultRoot, options = {}) {
3595
3596
  }), [name, root]);
3596
3597
  useEffect(() => {
3597
3598
  if (options?.initialValue) controls.set(options.initialValue);
3598
- }, [options?.initialValue]);
3599
+ }, [options?.initialValue, controls]);
3599
3600
  useEffect(() => {
3600
3601
  return () => controls.remove();
3601
3602
  }, [controls]);
@@ -3656,7 +3657,7 @@ var init_dom = __esmMin((() => {}));
3656
3657
  * @en Get the style of the layout content area
3657
3658
  */
3658
3659
  function useLayoutContentStyle() {
3659
- const contentElement = useRef(null);
3660
+ const contentElementRef = useRef(null);
3660
3661
  const [visibleDomRect, setVisibleDomRect] = useState(null);
3661
3662
  const resizeObserverRef = useRef(null);
3662
3663
  const contentHeightControls = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
@@ -3673,7 +3674,7 @@ function useLayoutContentStyle() {
3673
3674
  };
3674
3675
  }, [visibleDomRect]);
3675
3676
  const debouncedCalcHeight = useDebounceFn((_entries) => {
3676
- const rect = getElementVisibleRect(contentElement.current);
3677
+ const rect = getElementVisibleRect(contentElementRef.current);
3677
3678
  setVisibleDomRect(rect);
3678
3679
  if (rect) {
3679
3680
  contentHeightControls.set(`${rect.height}px`);
@@ -3681,10 +3682,10 @@ function useLayoutContentStyle() {
3681
3682
  }
3682
3683
  }, { wait: 16 });
3683
3684
  useEffect(() => {
3684
- if (contentElement.current && !resizeObserverRef.current) {
3685
+ if (contentElementRef.current && !resizeObserverRef.current) {
3685
3686
  const resizeObserver = new ResizeObserver(debouncedCalcHeight.run);
3686
3687
  resizeObserverRef.current = resizeObserver;
3687
- resizeObserver.observe(contentElement.current);
3688
+ resizeObserver.observe(contentElementRef.current);
3688
3689
  }
3689
3690
  return () => {
3690
3691
  resizeObserverRef.current?.disconnect();
@@ -3692,7 +3693,7 @@ function useLayoutContentStyle() {
3692
3693
  };
3693
3694
  }, [debouncedCalcHeight]);
3694
3695
  return {
3695
- contentElement,
3696
+ contentElementRef,
3696
3697
  overlayStyle,
3697
3698
  visibleDomRect
3698
3699
  };
@@ -4173,7 +4174,7 @@ function KeepAliveLayer() {
4173
4174
  (aliveRef.current?.getCacheNodes?.())?.forEach((node) => {
4174
4175
  if (!openTabs.has(node.cacheKey)) aliveRef.current?.destroy(node.cacheKey);
4175
4176
  });
4176
- }, [openTabs]);
4177
+ }, [openTabs, aliveRef]);
4177
4178
  /**
4178
4179
  * 关闭多 tab 功能时,清空所有非当前页面缓存
4179
4180
  */
@@ -4181,10 +4182,18 @@ function KeepAliveLayer() {
4181
4182
  if (!tabbarEnable) (aliveRef.current?.getCacheNodes?.())?.forEach((node) => {
4182
4183
  if (node.cacheKey !== cacheKey) aliveRef.current?.destroy(node.cacheKey);
4183
4184
  });
4184
- }, [tabbarEnable]);
4185
+ }, [
4186
+ tabbarEnable,
4187
+ aliveRef,
4188
+ cacheKey
4189
+ ]);
4185
4190
  useEffect(() => {
4186
4191
  if (tabbarEnable && isRefresh) aliveRef.current?.refresh();
4187
- }, [isRefresh]);
4192
+ }, [
4193
+ isRefresh,
4194
+ aliveRef,
4195
+ tabbarEnable
4196
+ ]);
4188
4197
  const keepAliveExclude = useMemo(() => {
4189
4198
  /**
4190
4199
  * 不开启多 tab 时不需要 KeepAlive,把所有路由放进 exclude 仅保留切换动画
@@ -4212,7 +4221,7 @@ var init_keep_alive_layer = __esmMin((() => {
4212
4221
  //#region src/layout/layout-content/index.tsx
4213
4222
  function LayoutContent() {
4214
4223
  const { token: { colorBgLayout } } = theme.useToken();
4215
- const { contentElement } = useLayoutContentStyle();
4224
+ const { contentElementRef } = useLayoutContentStyle();
4216
4225
  const enableFooter = usePreferencesStore((state) => state.enableFooter);
4217
4226
  const fixedFooter = usePreferencesStore((state) => state.fixedFooter);
4218
4227
  /**
@@ -4221,7 +4230,7 @@ function LayoutContent() {
4221
4230
  */
4222
4231
  return /* @__PURE__ */ jsx("main", {
4223
4232
  id: ELEMENT_ID_MAIN_CONTENT,
4224
- ref: contentElement,
4233
+ ref: contentElementRef,
4225
4234
  className: "relative overflow-y-auto overflow-x-hidden grow",
4226
4235
  style: { backgroundColor: colorBgLayout },
4227
4236
  children: /* @__PURE__ */ jsx(Scrollbar, { children: /* @__PURE__ */ jsx(GlobalSpin, { children: /* @__PURE__ */ jsxs("div", {
@@ -4447,7 +4456,11 @@ function GlobalSearch() {
4447
4456
  useKeyPress("downarrow", handleDown);
4448
4457
  useEffect(() => {
4449
4458
  if (!keyword.length && Array.isArray(searchHistory)) setResultOptions(searchMenuList.filter((item) => searchHistory?.includes(item.key)));
4450
- }, [keyword, searchHistory]);
4459
+ }, [
4460
+ keyword,
4461
+ searchHistory,
4462
+ searchMenuList
4463
+ ]);
4451
4464
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
4452
4465
  onClick: () => setOpen((open) => !open),
4453
4466
  className: "group flex justify-center items-center gap-2 md:bg-colorBgLayout px-3 py-1.5 rounded-full cursor-pointer text-colorTextSecondary hover:text-colorText md:mr-2.5",
@@ -5113,18 +5126,18 @@ function Sidebar() {
5113
5126
  name: item.name,
5114
5127
  checked: item.value,
5115
5128
  onChange: handleChange,
5116
- children: item.label,
5117
- disabled: item.disabled
5129
+ disabled: item.disabled,
5130
+ children: item.label
5118
5131
  }, item.name);
5119
5132
  }),
5120
5133
  /* @__PURE__ */ jsx(SwitchItem, {
5121
5134
  name: "sidebarTheme",
5122
5135
  checked: sidebarTheme === "light",
5123
5136
  onChange: (name, value) => setPreferences(name, value ? "light" : "dark"),
5124
- children: t("preferences.sidebar.sidebarTheme"),
5125
5137
  disabled: !sidebarEnable,
5126
5138
  unCheckedChildren: /* @__PURE__ */ jsx(RiContrastFill, {}),
5127
- checkedChildren: /* @__PURE__ */ jsx(RiMoonIcon, {})
5139
+ checkedChildren: /* @__PURE__ */ jsx(RiMoonIcon, {}),
5140
+ children: t("preferences.sidebar.sidebarTheme")
5128
5141
  }),
5129
5142
  /* @__PURE__ */ jsx(NumberInputSpinner, {
5130
5143
  min: 40,
@@ -5702,7 +5715,7 @@ function UserMenu({ ...restProps }) {
5702
5715
  }
5703
5716
  if (key === "personal-center") navigate("/personal-center/my-profile");
5704
5717
  };
5705
- const altView = useMemo(() => isWindowsOs() ? "Alt" : "⌥", [isWindowsOs]);
5718
+ const altView = useMemo(() => isWindowsOs() ? "Alt" : "⌥", []);
5706
5719
  const items = [{
5707
5720
  label: t("personal-center:menu.personalCenter"),
5708
5721
  key: "personal-center",
@@ -5973,7 +5986,7 @@ function LayoutMenu({ mode = "inline", autoExpandCurrentMenu, handleMenuSelect,
5973
5986
  isMobile,
5974
5987
  sidebarCollapsed
5975
5988
  ]);
5976
- const handleOpenChange = (keys) => {
5989
+ const handleOpenChange = useCallback((keys) => {
5977
5990
  /**
5978
5991
  * 1. 手风琴模式,点击菜单项,自动关闭其他菜单
5979
5992
  * 2. 非手风琴模式且菜单是收起的,鼠标悬浮菜单自动关闭其他菜单
@@ -5992,7 +6005,12 @@ function LayoutMenu({ mode = "inline", autoExpandCurrentMenu, handleMenuSelect,
5992
6005
  if (currentCloseKey) setOpenKeys(menuParentKeys[currentCloseKey]);
5993
6006
  }
5994
6007
  } else setOpenKeys(keys);
5995
- };
6008
+ }, [
6009
+ accordion,
6010
+ sidebarCollapsed,
6011
+ openKeys,
6012
+ menuParentKeys
6013
+ ]);
5996
6014
  const menuOpenProps = useMemo(() => {
5997
6015
  if (autoExpandCurrentMenu) return {
5998
6016
  openKeys,
@@ -6019,7 +6037,8 @@ function LayoutMenu({ mode = "inline", autoExpandCurrentMenu, handleMenuSelect,
6019
6037
  }, [
6020
6038
  matches,
6021
6039
  sidebarCollapsed,
6022
- getSelectedKeys
6040
+ getSelectedKeys,
6041
+ accordion
6023
6042
  ]);
6024
6043
  return /* @__PURE__ */ jsx(Menu, {
6025
6044
  /**
@@ -6552,7 +6571,6 @@ function useDropdownMenu() {
6552
6571
  }, [
6553
6572
  t,
6554
6573
  activeKey,
6555
- homePath,
6556
6574
  openTabs
6557
6575
  ]);
6558
6576
  /**
@@ -6569,6 +6587,8 @@ function useDropdownMenu() {
6569
6587
  [TabActionKeys.CLOSE_OTHERS]: closeOtherTabs,
6570
6588
  [TabActionKeys.CLOSE_ALL]: closeAllTabs
6571
6589
  }), [
6590
+ refresh,
6591
+ setIsRefresh,
6572
6592
  removeTab,
6573
6593
  closeRightTabs,
6574
6594
  closeLeftTabs,
@@ -6814,7 +6834,7 @@ function LayoutTabbar() {
6814
6834
  hash: ""
6815
6835
  };
6816
6836
  navigate(key + historyState.search + historyState.hash);
6817
- }, [openTabs]);
6837
+ }, [openTabs, navigate]);
6818
6838
  /**
6819
6839
  * 处理标签页编辑(关闭)
6820
6840
  * @param {React.MouseEvent | React.KeyboardEvent | string} key - 被编辑的标签页的key
@@ -6941,7 +6961,8 @@ function LayoutTabbar() {
6941
6961
  location,
6942
6962
  currentRoute,
6943
6963
  setActiveKey,
6944
- addTab
6964
+ addTab,
6965
+ activeKey
6945
6966
  ]);
6946
6967
  return /* @__PURE__ */ jsx("div", {
6947
6968
  className: classes.tabsContainer,
@@ -7023,7 +7044,7 @@ function ContainerLayout() {
7023
7044
  if (screens.lg && !screens.xl) setPreferences("sidebarCollapsed", true);
7024
7045
  else if (screens.xl) setPreferences("sidebarCollapsed", false);
7025
7046
  else if (screens.xs || screens.sm && !screens.md) setPreferences("sidebarCollapsed", false);
7026
- }, [screens]);
7047
+ }, [screens, setPreferences]);
7027
7048
  const sidebarEnableState = useMemo(() => !isTopNav && sidebarEnable, [isTopNav, sidebarEnable]);
7028
7049
  const computedSidebarWidth = useMemo(() => {
7029
7050
  if (isMaximize || isMobile) return 0;
@@ -7049,7 +7070,7 @@ function ContainerLayout() {
7049
7070
  let height = 48;
7050
7071
  if (tabbarEnable) height += 35;
7051
7072
  return height;
7052
- }, [tabbarEnable, 35]);
7073
+ }, [tabbarEnable]);
7053
7074
  useEffect(() => {
7054
7075
  setLayoutHeaderHeight(isMaximize ? 35 : headerWrapperHeight);
7055
7076
  }, [headerWrapperHeight, isMaximize]);
@@ -7934,7 +7955,7 @@ async function generateRoutesFromBackend(backendRoutes) {
7934
7955
  /**
7935
7956
  * 转换路由配置
7936
7957
  * @param route 原始路由配置
7937
- * @param parentPath 父级路径(用于嵌套路由)
7958
+ * @param parentComponentPath 父级组件路径(用于嵌套路由)
7938
7959
  * @returns 转换后的路由配置
7939
7960
  */
7940
7961
  const transformRoute = async (route, parentComponentPath) => {
@@ -8918,8 +8939,6 @@ var init_remember_route = __esmMin((() => {
8918
8939
  * 必须带上部署 base(`resolveSiteBase()`):子路径部署下 `location.href = "/login"`
8919
8940
  * 会跳到宿主机根,而不是 `/oj/login`。不能用 `import.meta.env.BASE_URL`——预构建
8920
8941
  * 产物里它是构建期内联的 `/`。
8921
- *
8922
- * @returns 无返回值
8923
8942
  */
8924
8943
  function goLogin() {
8925
8944
  useAuthStore.getState().reset();
@@ -9022,6 +9041,8 @@ function onRefreshFailed(error) {
9022
9041
  * 订阅者对象应包含 resolve 和 reject 方法。
9023
9042
  *
9024
9043
  * @param subscriber 订阅者对象,包含 resolve 和 reject 方法
9044
+ * @param subscriber.resolve 当 token 刷新成功时调用的函数,传入新的 token
9045
+ * @param subscriber.reject 当 token 刷新失败时调用的函数,传入错误信息
9025
9046
  */
9026
9047
  function addRefreshSubscriber(subscriber) {
9027
9048
  refreshSubscribers.push(subscriber);
@@ -9447,6 +9468,10 @@ function useAccess() {
9447
9468
  * 权限验证组件
9448
9469
  *
9449
9470
  * @param AccessControlProps 权限验证组件的属性
9471
+ * @param AccessControlProps.type 权限类型,默认为 code
9472
+ * @param AccessControlProps.codes 权限值,可以是字符串或字符串数组
9473
+ * @param AccessControlProps.children 有权限时渲染的子组件
9474
+ * @param AccessControlProps.fallback 无权限时显示的内容
9450
9475
  * @returns 若子组件存在,并且传入的权限值有效,则返回子组件;否则返回 null
9451
9476
  */
9452
9477
  function AccessControl({ type = "code", codes, children, fallback }) {
@@ -9800,7 +9825,7 @@ function BasicTable(props) {
9800
9825
  if (enableFooter && fixedFooter) return 40;
9801
9826
  return 0;
9802
9827
  }, [enableFooter, fixedFooter]);
9803
- const getPaginationProps = () => {
9828
+ const getPaginationProps = useCallback(() => {
9804
9829
  if (props.pagination === false) return false;
9805
9830
  return {
9806
9831
  placement: ["bottomStart"],
@@ -9810,7 +9835,7 @@ function BasicTable(props) {
9810
9835
  showTotal: (total) => t("common.pagination", { total }),
9811
9836
  ...props.pagination
9812
9837
  };
9813
- };
9838
+ }, [props.pagination, t]);
9814
9839
  /**
9815
9840
  * @description 计算分页器的高度
9816
9841
  * 如果分页器被禁用,则返回 0,否则根据分页器的大小返回相应的高度
@@ -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-oCauQheXzDS2E/yd7smCtg=='; 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" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-KUwgkL5p/qZWtW8VUNiLzg=='; 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="oCauQheXzDS2E/yd7smCtg==">{
10
+ <script type="importmap" nonce="KUwgkL5p/qZWtW8VUNiLzg==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -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.20",
11
- "@oj-module/runtime/contract": "0.2.20",
12
- "@oj-module/runtime/contract/errors": "0.2.20",
10
+ "@oj-module/runtime": "0.2.23",
11
+ "@oj-module/runtime/contract": "0.2.23",
12
+ "@oj-module/runtime/contract/errors": "0.2.23",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -0,0 +1,29 @@
1
+ // BDD:脚手架 package.json scripts 与模板的双写守卫(T3,发布工程师评审建议)。
2
+ // 生成器(init.ts generatePackageJson)与模板(templates/package.json)各写一份 scripts,
3
+ // 人肉同步必漂移(实证:typecheck:api 曾漏发生成器)——钉成测试:生成 scripts 的每个键
4
+ // 都必须存在于模板 scripts(值一致),新增 script 先改模板再改生成器。
5
+ import { readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { describe, expect, it } from "vitest";
9
+ import { generatePackageJson } from "../init";
10
+
11
+ const cliRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
12
+
13
+ describe("脚手架 package.json scripts 双写守卫(T3)", () => {
14
+ it("生成 scripts 的每个键都存在于模板且值一致", () => {
15
+ const template = JSON.parse(readFileSync(join(cliRoot, "templates/package.json"), "utf-8"));
16
+ const generated = generatePackageJson(cliRoot, "guard-check");
17
+ for (const [key, value] of Object.entries(generated.scripts)) {
18
+ expect(template.scripts[key], `生成器有而模板无的 script:${key}(先改 templates/package.json)`)
19
+ .toBe(value);
20
+ }
21
+ });
22
+
23
+ it("多方案 scripts 齐备:new / bundle / typecheck:api", () => {
24
+ const generated = generatePackageJson(cliRoot, "guard-check");
25
+ expect(generated.scripts.new).toBe("ojm new");
26
+ expect(generated.scripts.bundle).toBe("ojm build --bundle");
27
+ expect(generated.scripts["typecheck:api"]).toBe("tsc --noEmit -p src/tsconfig.json");
28
+ });
29
+ });
package/src/args.ts CHANGED
@@ -40,13 +40,16 @@ export function parseApiArgs(argv: string[]): { dir: string, check: boolean, doc
40
40
 
41
41
  /**
42
42
  * `ojm build [--bundle] [--out <dir>]`。
43
- * `--bundle`:方案包构建(manifest.yaml 契约校验 → 确定性 tgz + sha256),
44
- * 与站点级 `ojm build`(后端 oj build + 模块产物合并)互斥分工(devkit 手册「打包与分发」)。
43
+ * `--bundle`:方案包构建(契约校验 → 确定性 tgz + sha256)。bundle 级契约位置:优先工程根
44
+ * `solution.yaml`、或 `src/<模块>/solution.yaml`(模块级方案,契约与所打包模块同位);旧方案回退
45
+ * `manifest.yaml`(同两处位置)。与站点级 `ojm build`(后端 oj build + 模块产物合并)互斥分工
46
+ * (devkit 手册「打包与分发」)。
45
47
  * `--out` 的值不得以 `--` 开头(同 parseApiArgs 的消费式扫描,P6 同款坑)。
46
48
  */
47
- export function parseBuildArgs(argv: string[]): { bundle: boolean, out?: string } {
49
+ export function parseBuildArgs(argv: string[]): { bundle: boolean, bundleName?: string, out?: string } {
48
50
  const flags = new Set<string>();
49
51
  let out: string | undefined;
52
+ let bundleName: string | undefined;
50
53
  for (let i = 0; i < argv.length; i++) {
51
54
  const a = argv[i];
52
55
  if (a === "--out") {
@@ -54,11 +57,58 @@ export function parseBuildArgs(argv: string[]): { bundle: boolean, out?: string
54
57
  i++; // 消费紧随的值,不被当 flag
55
58
  continue;
56
59
  }
60
+ // --bundle <name> / --bundle=<name>:只打这一份方案(无值 = 全部用户方案)
61
+ if (a === "--bundle") {
62
+ flags.add(a);
63
+ const next = argv[i + 1];
64
+ if (next !== undefined && !next.startsWith("--")) {
65
+ bundleName = next;
66
+ i++;
67
+ }
68
+ continue;
69
+ }
70
+ if (a.startsWith("--bundle=")) {
71
+ flags.add("--bundle");
72
+ bundleName = a.slice("--bundle=".length);
73
+ continue;
74
+ }
57
75
  if (a.startsWith("--")) {
58
76
  flags.add(a);
59
77
  }
60
78
  }
61
- return { bundle: flags.has("--bundle"), out };
79
+ return { bundle: flags.has("--bundle"), bundleName, out };
80
+ }
81
+
82
+ /**
83
+ * `ojm new <name> [--layout container|none] [--api]`:新方案骨架生成参数。
84
+ * name 为首个非 flag 位置参数(必填,由调用方判空——非交互/CI 友好)。
85
+ */
86
+ export function parseNewArgs(argv: string[]): { name?: string, layout?: "container" | "none", api?: boolean } {
87
+ let name: string | undefined;
88
+ let layout: "container" | "none" | undefined;
89
+ let api = false;
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const a = argv[i];
92
+ if (a === "--layout") {
93
+ const v = argv[i + 1];
94
+ if (v !== "container" && v !== "none")
95
+ throw new Error(`[ojm] --layout 只接受 container | none(实际:${v})`);
96
+ layout = v;
97
+ i++;
98
+ continue;
99
+ }
100
+ if (a === "--api") {
101
+ api = true;
102
+ continue;
103
+ }
104
+ if (a !== undefined && !a.startsWith("--")) {
105
+ if (name !== undefined)
106
+ throw new Error(`[ojm] ojm new 只接受一个方案名(多余参数:${a})`);
107
+ name = a;
108
+ continue;
109
+ }
110
+ }
111
+ return { name, layout, api };
62
112
  }
63
113
 
64
114
  /**