@oj-module/cli 0.2.26 → 0.2.28

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/README.md CHANGED
@@ -23,81 +23,107 @@
23
23
  ## 命令
24
24
 
25
25
  ```bash
26
- ojm dev [port] # 启动开发服务器(默认 5174):宿主代理 + 本地模块重建
27
- ojm build # 构建模块产物与 dist/modules.json
28
- ojm init [dir] [--yes] # 脚手架:模板工程 + 证书签发 + 联网下载 oj 到 bin/
29
- ojm vendor [tag] [--force] # 下载/重装 oj vendor(经 npm 包 @oj-bin/oj,tag 缺省取最新版本,形如 v0.1.13)
30
- ojm preview [port] # 生产形态预览:oj migrate → oj serve 单服务(API + 静态 SPA 同进程)
31
- ojm info # 输出报障所需的版本矩阵与后端观测
32
- ojm api [dir] # 契约产物生成(client/openapi/mock stub)
33
- ojm merge <out> <in...> # 合并多份 modules.json
26
+ # 工程脚手架
27
+ ojm init [dir] [--yes] # 前后端一体化脚手架(幂等补缺:重跑只补缺失,永不覆盖已有文件)
28
+ ojm new <name> [--layout container|none] [--api] # 脚手架内生成用户方案骨架(src/<name>/ + solution.yaml)
29
+
30
+ # 开发与构建
31
+ ojm dev [port] # 开发服务器:/api 反代 oj + 模块重建 + SSE 自动刷新(快捷键 r/h/c/o/q)
32
+ ojm build [--bundle [name]] [--out <dir>] # 构建后端(oj build)与模块产物;--bundle 打确定性方案包
33
+ ojm preview [port] # 生产形态预览:migrate → oj serve 单服务(API + 静态 SPA 同进程)
34
+ ojm api [dir] [--check] [--docs] [--exempt <p>] # 契约代码生成(client/routes/openapi/stub);--check 四重对账
35
+
36
+ # 清单与诊断
37
+ ojm info [--all] # 版本矩阵与模块清单(报障用)
38
+ ojm merge <out.json> <in1.json> [in2.json ...] # 合并多团队清单(清单先后 = 接管优先级)
39
+ ojm vendor [tag] [--force] # 下载/重装 oj vendor(经 npm 包 @oj-bin/oj,tag 形如 v0.1.13)
40
+
41
+ # 方案市场(站点侧生命周期)
42
+ ojm install <file.tgz | name[@version]> # 安装方案包到目标站点(file 上传 / CDN 市场源)
43
+ ojm enable|disable <name> # 启用/禁用已装方案(锁/sidecar/路由清单/模块清单四处同写)
44
+ ojm uninstall <name> # 卸载已装方案(四处同删 + 权限级联清理)
45
+ ojm trust ls|add|revoke|confirm # 发布方信任库管理(TOFU 确认)
46
+
47
+ # 发布与签名(作者侧)
48
+ ojm keygen --id <publisher.id> # 生成 Ed25519 签名密钥对(私钥缺省 ~/.ojm/keys/<id>/)
49
+ ojm sign <file.tgz> --id <id> [--check] # detached 侧车签名(产出 <tgz>.sig)
50
+ ojm publish <dir> --id <id> [--check] # 索引发布(重算 bundles/manifests + 侧车签名)
51
+
52
+ # 本地市场模拟 CDN
53
+ ojm market setup|publish|build-index|verify|trial|walkthrough
54
+
55
+ # oj 子命令透传
56
+ ojm test|exec|openapi|migrate|schema [args...] # 透传给 oj 同名子命令(自动补 -c <project>/config.yaml)
34
57
  ```
35
58
 
36
- > `ojm init` / `ojm vendor` 需要访问 npm registry(经 `npm i @oj-bin/oj` 安装 oj 到 `bin/`,走 `.npmrc` 镜像配置)。已装版本记录在 `bin/.oj-version`,`ojm info` 可查。
59
+ > 每个命令都有中英双语帮助:`ojm <cmd> --help` 看单命令详解,`ojm help` 看全量索引
60
+ > (语言按 `OJM_LANG` / `LANG` 探测,缺省 zh-CN)。`ojm --version` 显示 cli 包版本。
61
+ > 方案市场的完整链路(打包 → 签名 → 索引 → 安装 → 生命周期)见 devkit 手册 §10–§12。
37
62
 
38
63
  ### `ojm build`
39
64
 
40
- 读取 `web.config.ts`,逐个模块构建(硬切换后只认 web/src + web/dist 布局):
65
+ 构建后端与前端产物(module-root 布局为默认,模块由扫描 `src/*/web/entry.ts` 收录,
66
+ `web.config.ts` 仅作可选覆盖;历史布局仍以 `web.config.ts` 的 `modules` 为真源):
41
67
 
42
68
  ```
43
69
  web/dist/
44
- ├── modules.json # 模块清单(供宿主 fetch)
70
+ ├── index.html # 全站合并后的宿主页(仅 build 做清场合并)
71
+ ├── modules.json # 模块清单(供宿主 fetch)
45
72
  └── modules/<name>/<version>/
46
- ├── entry.js # 模块入口
47
- ├── <chunk>.js # 保留 code splitting
73
+ ├── entry.js # 模块入口
74
+ ├── <chunk>.js # 保留 code splitting
48
75
  └── *.css
49
76
  ```
50
77
 
51
78
  - 只有共享表内的依赖被 external,其余依赖会被打进模块产物(不在表内时 CLI 会告警);
52
79
  - 每个 chunk 计算 `sha384` 完整性摘要写入 `modules.json`,懒加载 chunk 标记 `lazy: true`;
53
80
  - 模块元信息(`name` / `version` / `peerRuntime`)通过 esbuild 打包后真实 `import()` entry 读取,而非正则解析——因此配置文件里可以写注释、用变量、做条件判断;
54
- - 读取元信息时 `@oj-module/runtime` 被替换为只读占位模块,避免在 Node 下加载含 Vite 专有 svg 导入的框架运行时。
81
+ - 读取元信息时 `@oj-module/runtime` 被替换为只读占位模块,避免在 Node 下加载含 Vite 专有 svg 导入的框架运行时;
82
+ - `--bundle [name]`:只组装不构建,先 `ojm build` 再打包——两段式契约校验(fail-fast)后产出确定性
83
+ `dist/<name>-<version>.tgz` + sha256(市场安装器的上游产物);无参 = 全部用户方案。
55
84
 
56
85
  ### `ojm dev`
57
86
 
58
87
  1. 直接取本包内置的预构建宿主 `shell-dist`(随 cli 发布,**不再**查询 `node_modules/@oj-module/shell`,也无 monorepo 路径回退);
59
88
  2. 先跑一次 `ojm build`;
60
- 3. 起静态服务器:
89
+ 3. 起开发服务器:
61
90
 
62
91
  | 路由 | 来源 |
63
92
  |---|---|
93
+ | `/api/*` | 反代到 oj 后端(oj 端口以 `config.yaml` 的 `server.port` 为准,默认 9778) |
64
94
  | `/`、`/index.html` | `@oj-module/cli/shell-dist/index.html`(含 importmap) |
65
95
  | `/assets/*` | `@oj-module/cli/shell-dist/assets/*` |
66
96
  | `/modules.json` | 本地 `web/dist/modules.json` |
67
97
  | `/modules/*` | 本地 `web/dist/modules/*` |
68
98
 
69
- 4. 监听 `web/src/` 变更并增量重建(防抖 300ms)。
70
-
71
- > 当前为「保存即重建 + 手动刷新」。完整 HMR(react-refresh preamble、dev-runtime 映射)在后续阶段接入。
99
+ 4. 监听 `web/src/`(module-root 为 `src/*/web/`)变更并增量重建(防抖 300ms),经 SSE 通道**自动刷新浏览器**;
100
+ 5. TTY 终端启用类 Vite 快捷键:`r` 强制重建热更新 · `h` 帮助 · `c` 清屏 · `o` 浏览器打开 · `q` 退出(后端 oj 一并终止,不留孤儿占端口)。
72
101
 
73
102
  ## 配置文件
74
103
 
75
- 工程根目录的 `web.config.ts`:
104
+ 工程根目录的 `web.config.ts`(module-root 布局下可选,仅 `baseUrl` / `siteBase` /
105
+ `htmlTransform` 等站点级配置才需要它;`modules` 登记只对历史布局必填):
76
106
 
77
107
  ```ts
78
108
  export default {
79
109
  /** 产物 URL 前缀,留空表示同源相对路径;跨源时填 CDN 绝对地址 */
80
110
  baseUrl: "",
111
+ /** 站点部署子路径(如 "/oj"),留空 = 挂在域名根;与 baseUrl 正交可同用 */
112
+ siteBase: "",
113
+ /** 历史布局的模块登记;module-root 布局由扫描 src/*/web/entry.ts 得出 */
81
114
  modules: [
82
115
  { name: "demo", entry: "./web/src/demo/entry.ts", enabled: true },
83
116
  ],
117
+ /** 按路由改写 HTML 的模块(仅 ojm dev 生效,见下) */
118
+ htmlTransform: "./html-transform.ts",
84
119
  };
85
120
  ```
86
121
 
87
- `name` 必须与 `entry.ts` 中 `defineModule({ name })` 一致,不一致会直接构建失败。`enabled: false` 的模块会被跳过。
122
+ `name` 必须与 `entry.ts` 中 `defineModule({ name})` 一致,不一致会直接构建失败。`enabled: false` 的模块会被跳过。
88
123
 
89
124
  ### 按路由改写 HTML(`htmlTransform`,可选)
90
125
 
91
- 模块联邦站点每条路由都出同一份 `index.html`,逐路由的 `<title>` / OpenGraph 无从表达——IM 链接预览与非 JS 爬虫只会看到默认 title。配 `htmlTransform` 指向一个模块(相对工程根的路径,或已安装的包名),`ojm dev` 与 `ojm preview` 会加载它,对 `/`、`/index.html` 与 SPA 回落的深链接统一生效:
92
-
93
- ```ts
94
- // web.config.ts
95
- export default {
96
- baseUrl: "",
97
- modules: [/* … */],
98
- htmlTransform: "./html-transform.ts",
99
- };
100
- ```
126
+ 模块联邦站点每条路由都出同一份 `index.html`,逐路由的 `<title>` / OpenGraph 无从表达——IM 链接预览与非 JS 爬虫只会看到默认 title。配 `htmlTransform` 指向一个模块(相对工程根的路径,或已安装的包名),`ojm dev` 会加载它,对 `/`、`/index.html` 与 SPA 回落的深链接统一生效:
101
127
 
102
128
  ```ts
103
129
  // html-transform.ts —— default 导出即变换函数
@@ -109,10 +135,11 @@ export default async function (path: string, html: string) {
109
135
  ```
110
136
 
111
137
  - `path` 是解码后的请求路径(查询串已剥离),`html` 是宿主原文;
112
- - 先变换、后注入 dev 刷新脚本,故 `ojm dev` 的热刷新不受影响;
138
+ - 先变换、后注入 dev 刷新脚本,故热刷新不受影响;
113
139
  - 变换抛错或返回非字符串 → 回退原文,站点不 500;
114
140
  - 模块缺失或 default 不是函数 → 启动即人话报错(fail-fast);
115
- - 非 HTML 资产(`/assets/*`、`/modules/*`)不受影响。
141
+ - 非 HTML 资产(`/assets/*`、`/modules/*`)不受影响;
142
+ - **仅 `ojm dev` 生效**:单服务 `ojm preview` 由 oj 直出 HTML,钩子无从介入(启动 WARN 并指向 oj 的 `server.html_meta` / `server.html_meta_handler`)。
116
143
 
117
144
  ## 子出口
118
145
 
@@ -122,6 +149,15 @@ import { generateImportmap, generateShellEntries, isSharedDep, SHARED_DEPS } fro
122
149
 
123
150
  // 配置加载
124
151
  import { loadModulesConfig, resolveModuleEntry } from "@oj-module/cli/config";
152
+
153
+ // 构建 / 诊断 / 校验(Node 侧工具链复用)
154
+ import { buildModules } from "@oj-module/cli/build";
155
+ import { printInfo, mergeManifests } from "@oj-module/cli/info";
156
+ import { collectExportGaps } from "@oj-module/cli/esm-exports";
157
+ import { mergeModuleManifests } from "@oj-module/cli/manifest";
158
+
159
+ // 预构建宿主资产(随包发布)
160
+ import "@oj-module/cli/shell-dist/index.html";
125
161
  ```
126
162
 
127
163
  **硬共享**(`react` / `react-router` / `@tanstack/react-query` / `@oj-module/runtime` …)破坏即崩溃,必须由宿主提供,模块不得自带;**软共享**(`antd` / `dayjs` / `i18next` …)允许版本漂移。
package/devkit/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
  | 文件 | 用途 |
25
25
  |---|---|
26
26
  | `SKILL.md` | Claude Code 等 agent 的 skill 入口:工作流、红线、新模块 checklist、陷阱速查,按章节号引用手册 |
27
- | `manual.md` | 模块开发手册(13 章):工程结构(module-root 单树 + 两处产物根;含 §1.7 handler 四红线——oj 运行时实测钉出,后端必读;§1.8 `#` 别名与 TS 投影)、写模块、契约与代码生成(含 §3.10 旧 contract.ts 工程迁移指南)、provider 接管(覆盖框架内置功能)、布局、i18n、构建发布、排查、CLI 子命令参考(ojm 原生命令 vs oj 透传命令)、方案包打包与分发、签名与信任 |
27
+ | `manual.md` | 模块开发手册(13 章):工程结构(module-root 单树 + 两处产物根;含 §1.7 handler 四红线——oj 运行时实测钉出,后端必读;§1.8 `#` 别名与 TS 投影)、写模块、契约与代码生成(含 §3.10 旧 contract.ts 工程迁移指南)、provider 接管(覆盖框架内置功能)、布局、i18n、构建发布、排查、CLI 子命令参考(ojm 原生命令 vs oj 透传命令)、方案包打包与分发(含 §11.1.1 引用式方案)、签名与信任;含 §13.1.1 web.route_prefix 自定义根路径 |
28
28
  | `framework-dev.md` | 框架贡献者路线(**仅框架仓库**):改 `packages/runtime`、`packages/cli` 时的入口与红线 |
29
29
  | `module-dev-and-cdn-playground.md` | 教学文档:开发态 ≠ 分发态、「怎么安装自己的模块 / 会不会重复」、本地 CDN 市场链路五步跑通(setup → publish → build-index → verify → 安装) |
30
30
  | `sql-builder-missing-capabilities-pr.md` | 构造器能力缺口 PR 参考(**仅框架仓库**):7 项诉求的闭环记录 |
package/devkit/manual.md CHANGED
@@ -355,7 +355,7 @@ export default defineModule({
355
355
  | `order` | 菜单排序值(双段序轴):`≥0` 主菜单区升序;`<0` 底部固定区(pin 在侧边栏底部,区内仍按 order 升序);未声明按登记下标兜底;同 order 按根 path 字母序 |
356
356
  | `title` | 菜单标题,用 `"<模块名>:menu.<key>"` |
357
357
  | `icon` | `createElement(XxxOutlined)` |
358
- | `keepAlive` | 页面缓存(**必须挂在 `layout: "container"` 下才生效**) |
358
+ | `keepAlive` | 页面缓存(多 tab 开启时有效;与 layout 解绑;相对/绝对声明均可,key 取路由绝对路径) |
359
359
  | `hideInMenu` | 不在菜单显示 |
360
360
  | `roles` / `permissions` | 路由级权限(页面级 / 按钮级) |
361
361
  | `iframeLink` | 内嵌 iframe 的外链地址 |
@@ -822,7 +822,8 @@ export default { get };
822
822
  逐个改 import 与调用;`tsc --noEmit` 应收敛到 0 错误。
823
823
  6. **删旧文件**:删除各 `contract.ts`,以及旧流水线产物 `routes.json` /
824
824
  `openapi.yaml`(模块目录下;ojm 不再自产。`ojm api --docs` 渲染的静态文档站另放:
825
- module-root → 工程根 `docs/api/`,历史布局 → `api/docs/`,保留)。
825
+ `<projectRoot>/src/docs/<name>/api`(与模块树同位、树外,不参与模块枚举与 handler 路由扫描,
826
+ 杜绝 docs 被当 handler 镜像导致端点翻倍;legacy 布局同形、落在 `api/src` 扫描树外,保留)。
826
827
  7. **对账**:`ojm api --check` 应 `0 error / 0 warn`;内置端点(`auth/*`、`web/*`、
827
828
  `notification/*`)经 `.ojm-api-exempt.json` 豁免(init 已内置)。
828
829
 
@@ -1681,6 +1682,9 @@ ojm build --bundle crm # 打包
1681
1682
  ```
1682
1683
 
1683
1684
 
1685
+ > 生成的 `src/<name>/solution.yaml` 不再写出 `web.route_prefix: /<name>`(与缺省重复),只在
1686
+ > `web` 段留一行注释;要自定义根路径时取消注释改值即可(见 §13.1.1)。
1687
+
1684
1688
  #### `ojm preview [port]`
1685
1689
 
1686
1690
  生产形态预览(设计 §6,2026-10-19 单服务化):
@@ -1705,8 +1709,11 @@ ojm build --bundle crm # 打包
1705
1709
  - `dir`:项目目录(缺省 `cwd`;显式目录必须先存在,否则报错)。
1706
1710
  - `--check`:**四重对账**(只读、绝不写盘):① 生成物同步 ② route 双向对账
1707
1711
  ③ `routes.js` diff ④ WS 路由鉴权(§3.6)。
1708
- - `--docs`:聚合 OpenAPI → redoc 自包含静态文档站(离线可看),落 `<cwd>/docs/api/`
1709
- (历史布局 `<cwd>/api/docs/`)。
1712
+ - `--docs`:聚合 OpenAPI → redoc 自包含静态文档站(离线可看),落
1713
+ `<projectRoot>/src/docs/<name>/api`(与模块树同位、树外——不参与模块枚举与 handler 路由扫描,
1714
+ 杜绝 docs 被当 handler 镜像导致端点翻倍 `wikiDocsDocs*`)。`name` 推导:cwd 为 `src/<name>/api`
1715
+ 时取父目录名;module-root 单模块时取该模块名;否则回退工程目录名(legacy 布局同形,
1716
+ 落在 `api/src` 扫描树外)。
1710
1717
  - `--exempt <path>`:指定豁免清单,覆盖默认探测顺序 `.ojm-api-exempt.json` →
1711
1718
  `api/.ojm-api-exempt.json` → `api/.ram-api-exempt.json`(旧名回退),§3.8。
1712
1719
 
@@ -1830,6 +1837,24 @@ ojm build --bundle crm # 只打 src/crm/ 这一份;--bundle=crm 等价
1830
1837
  - **入包红线**:`*.pem` / `*.key` / `*.p12` / `*.pfx` / `.env` 系 / `id_ed25519` / `id_rsa`
1831
1838
  等私钥形态文件禁入包(与 `modules.json` 禁入同款自检,0.2.23 起扩面)——私钥入包即信任根基烧毁。
1832
1839
 
1840
+ #### 11.1.1 引用式方案:`api` 段省略 `tables` / `deps`
1841
+
1842
+ 方案包契约的 `api` 段有两种合法形态:
1843
+
1844
+ - **全量形态**:给出 `tables` 与/或 `deps`(外加 `module` / `prefix` / `anonymous` 等),
1845
+ 契约按全量校验,`api.tables` 须与 `api/src/<name>/schema.yaml` 表集逐一相等(oj 冗余校验同源)。
1846
+ - **引用形态**:`api` 段只给 `module` / `prefix` 等、**省略 `tables` 与 `deps`**——表与依赖清单
1847
+ **引用包内** oj 原生 `<module>/manifest.yaml`(七键清单,tgz 打包时已随包携带)。契约**显式承认**
1848
+ 此形态(不再仅靠字段可选性漏过),安装期 `extract` 阶段读回该原生 manifest 做占用检测与依赖装配
1849
+ (`_platform` 除外);两字段任一给出即按全量优先。
1850
+
1851
+ `ojm build --bundle` 校验通过后会打印一行提示,避免把省略误读成漏配:
1852
+
1853
+ ```
1854
+ [ojm] 引用式方案:api.tables/api.deps 缺省,引用包内 api/<name>-<version>/manifest.yaml
1855
+ (安装期复核其存在,依赖装配据其回读,非漏配)
1856
+ ```
1857
+
1833
1858
  ### 11.2 两种分发源
1834
1859
 
1835
1860
  | 源 | 形态 | 说明 |
@@ -2011,6 +2036,45 @@ export default defineModule({
2011
2036
  });
2012
2037
  ```
2013
2038
 
2039
+ #### 13.1.1 自定义模块根路径:`web.route_prefix`
2040
+
2041
+ 单根约定下,模块根路由首段默认就是模块名 `name`(即 `web.route_prefix` 缺省 `/<name>`)。
2042
+ 要换根(比如让 `wiki` 模块挂在 `/kb`),在 `src/<name>/solution.yaml` 的 `web` 段声明:
2043
+
2044
+ ```yaml
2045
+ web:
2046
+ route_prefix: /kb # 形如 /<小写字母开头,字母数字短横线>
2047
+ ```
2048
+
2049
+ 构建期 `ojm build` 会把**生效前缀段**(`kb`,去首斜杠)写进 `modules.json` 每个模块条目的
2050
+ `routePrefix` 字段;运行时模块加载器据此**只改写路由根段**(单根约定下仅首段——
2051
+ `applyRoutePrefix` 保持原 path 的前导斜杠风格:相对 `wiki`→`kb`、绝对 `/wiki`→`/kb`,
2052
+ children 不变)。因此**缺省(不声明)= name 时路径字段逐字节不变(零漂移)**,已有模块升级无感。
2053
+
2054
+ 改写后 `rewriteAdminPaths` 会自动跟随(它从顶层路径推导根),`category: "admin"` 的路由落到
2055
+ `/system/<前缀>/admin`,菜单归组一并跟随——菜单、深链、模块内跳转、tab 键、admin 提升全部落在
2056
+ 同一个根。
2057
+
2058
+ 模块内路径**不要硬编码根段**,统一经 `getModuleRoutePrefix(name)` 单点消费(运行时导出,读
2059
+ `modules.json` 的 `routePrefix`,未声明回落 `name`):
2060
+
2061
+ ```ts
2062
+ // paths.ts:把根段参数化,所有 navigate / Link / matchPath 都从它派生
2063
+ export const createWikiPaths = (root: string) => ({
2064
+ home: `/${root}`,
2065
+ space: `/${root}/space`,
2066
+ admin: `/${root}/admin`,
2067
+ });
2068
+
2069
+ // 使用时懒取(组件渲染 / 事件处理内),不要缓存到模块顶层常量——
2070
+ // 模块静态求值期模块表尚未就绪,须等 loadAll 之后才取值
2071
+ import { getModuleRoutePrefix } from "@oj-module/runtime";
2072
+ const paths = createWikiPaths(getModuleRoutePrefix("wiki"));
2073
+ ```
2074
+
2075
+ > `ojm new` 生成的骨架不再写出 `route_prefix: /<name>`(与缺省重复),只留注释,需要时取消
2076
+ > 注释改值即可(见 §10.1)。
2077
+
2014
2078
  ### 13.2 category 三值语义
2015
2079
 
2016
2080
  `handle.category`:
@@ -2025,6 +2089,7 @@ export default defineModule({
2025
2089
 
2026
2090
  - 模块根菜单标题与 /system 下的管理子组标题**共用** `<模块>:menu.main` 一个 i18n key(嵌套在 `menu.main`,是模块级标题的唯一真源,缺则构建期 fail-fast)。
2027
2091
  - 模块内所有前端路径——`navigate` / `Link to` / `useMatch`·`matchPath` / `currentActiveMenu` / SEO 正则——统一从模块基路径助手(如 `paths.ts` 的 `wikiPath(...)` / `wikiBase`)派生,不散落 `/wiki/...` 字面量;`currentActiveMenu` 必须写绝对路径(指向真实菜单 key)。`tree/bus.ts` 的 room 名是后端 WS 契约,非前端路由,不动。
2092
+ - 模块基路径的真源是 `getModuleRoutePrefix(name)`(运行时导出,读 `modules.json` 的 `routePrefix`,缺省 `name`)——`paths.ts` 助手应以其为参数,而非写死 `/<name>`;自定义根路径见 §13.1.1。
2028
2093
 
2029
2094
  ### 13.4 开发者的该做 / 别做
2030
2095
 
@@ -2060,6 +2125,6 @@ export default defineModule({
2060
2125
  | §8 排查 | 五步排查法、症状速查表 | 任何「没生效」 |
2061
2126
  | §9 参考 | 可抄的工程内文件、oj-api-dev 手册指引 | 找范本 |
2062
2127
  | §10 CLI 子命令参考 | ojm 原生命令 vs oj 透传命令;`market` 子命令(本地模拟 CDN) | 查命令用法;`ojm <cmd> --help` 看单命令帮助 |
2063
- | §11 方案包:打包与分发 | build --bundle、Form A/B、分发源、索引规范 | 发布方案包 |
2128
+ | §11 方案包:打包与分发 | build --bundle、Form A/B、分发源、索引规范、引用式方案(api 段省略 tables/deps,引用包内原生 manifest) | 发布方案包 |
2064
2129
  | §12 签名与信任 | keygen/sign/install 发布流程、信任库、TOFU、bootstrap key | 首次签名发布;管理信任 |
2065
- | §13 方案菜单归组规则 | 单根模块约定(根路由=第一级菜单)、category 三值(普通/hidden/admin)、admin 真重写 `/system/<模块 root>/*` + 视觉归组、menu.main 统一、模块内路径助手、开发者 DO/DON'T | 写方案要让菜单按模块分组、admin 归系统设置 |
2130
+ | §13 方案菜单归组规则 | 单根模块约定(根路由=第一级菜单)、自定义根路径 `web.route_prefix`(构建写 modules.json、运行时只改根段、getModuleRoutePrefix 单点消费)、category 三值(普通/hidden/admin)、admin 真重写 `/system/<模块 root>/*` + 视觉归组、menu.main 统一、模块内路径助手、开发者 DO/DON'T | 写方案要让菜单按模块分组、admin 归系统设置 |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.26",
4
+ "version": "0.2.28",
5
5
  "description": "React Antd 模块工程命令行工具(含预构建宿主 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.26",
52
+ "@oj-module/runtime": "0.2.28",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -1,5 +1,5 @@
1
1
  import { StyleProvider } from "@ant-design/cssinjs";
2
- import { AntdApp, ErrorBoundary, LayoutEffects, PageError, getRoutes, loadAll, setupI18n, useAuthStore, usePreferences, useUserStore } from "@oj-module/runtime";
2
+ import { AntdApp, ErrorBoundary, LayoutEffects, PageError, getHostRoutes, loadAll, setupI18n, useAuthStore, usePreferences, useUserStore } from "@oj-module/runtime";
3
3
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import { ConfigProvider, theme } from "antd";
5
5
  import { useEffect, useState } from "react";
@@ -300,6 +300,7 @@ function Boot() {
300
300
  await hydrateUser();
301
301
  await loadAll(manifest);
302
302
  if (cancelled) return;
303
+ const hostRoutes = getHostRoutes();
303
304
  setRouter(createBrowserRouter([{
304
305
  path: "/",
305
306
  element: /* @__PURE__ */ jsxs(ErrorBoundary, {
@@ -310,11 +311,11 @@ function Boot() {
310
311
  {
311
312
  index: true,
312
313
  element: /* @__PURE__ */ jsx(Navigate, {
313
- to: getRoutes()[0]?.path ?? "/",
314
+ to: hostRoutes[0]?.path ?? "/",
314
315
  replace: true
315
316
  })
316
317
  },
317
- ...getRoutes(),
318
+ ...hostRoutes,
318
319
  {
319
320
  path: "*",
320
321
  element: /* @__PURE__ */ jsx(CatchAll, {})
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.26",
307
+ "version": "0.2.28",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-23 21:49:09"
310
+ "lastBuildTime": "2026-10-24 18:30:15"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -7203,6 +7203,42 @@ var init_resolve_layout = __esmMin((() => {
7203
7203
  warnedUnknownLayouts = /* @__PURE__ */ new Set();
7204
7204
  }));
7205
7205
  //#endregion
7206
+ //#region src/router/utils/rewrite-admin-paths.ts
7207
+ /** 解析路由绝对路径(与 generate-menu-items-from-routes 同语义) */
7208
+ function resolveAbs(path, parentAbs) {
7209
+ if (!path) return parentAbs;
7210
+ return path.startsWith("/") ? path : `${parentAbs.replace(/\/+$/, "")}/${path}`;
7211
+ }
7212
+ function rewriteAdminPaths(routes, systemKey = SYSTEM_KEY) {
7213
+ const hoisted = [];
7214
+ return [...routes.map((r) => processRoute(r, "/", systemKey, hoisted, void 0)).filter((r) => r !== null), ...hoisted];
7215
+ }
7216
+ function processRoute(route, parentAbs, systemKey, hoisted, moduleRootAbs) {
7217
+ const abs = resolveAbs(route.path, parentAbs);
7218
+ const rootAbs = moduleRootAbs ?? abs;
7219
+ if (route.handle?.category === "admin") {
7220
+ const newAbs = `${systemKey}${rootAbs}${abs.slice(rootAbs.length) || ""}`;
7221
+ hoisted.push({
7222
+ ...route,
7223
+ path: newAbs
7224
+ });
7225
+ return null;
7226
+ }
7227
+ if (!route.children || route.children.length === 0) return { ...route };
7228
+ const newChildren = [];
7229
+ for (const c of route.children) {
7230
+ const processed = processRoute(c, abs, systemKey, hoisted, rootAbs);
7231
+ if (processed !== null) newChildren.push(processed);
7232
+ }
7233
+ const result = { ...route };
7234
+ result.children = newChildren;
7235
+ return result;
7236
+ }
7237
+ var SYSTEM_KEY;
7238
+ var init_rewrite_admin_paths = __esmMin((() => {
7239
+ SYSTEM_KEY = "/system";
7240
+ }));
7241
+ //#endregion
7206
7242
  //#region src/store/header-provider.ts
7207
7243
  function registerHeaderProvider(moduleName, provider) {
7208
7244
  if (current) {
@@ -7272,24 +7308,6 @@ var init_scoped = __esmMin((() => {
7272
7308
  init_request();
7273
7309
  }));
7274
7310
  //#endregion
7275
- //#region src/router/utils/flatten-routes.ts
7276
- /**
7277
- * 将路由扁平化为一个对象,键为路由的 path,值为路由对象
7278
- */
7279
- function flattenRoutes(routes) {
7280
- const result = {};
7281
- function traverse(items, parent) {
7282
- items.forEach((item) => {
7283
- if (item.index && parent?.path) result[`${parent.path}/`] = item;
7284
- if (item.path) result[item.path] = item;
7285
- if (item.children && item.children.length > 0) traverse(item.children, item);
7286
- });
7287
- }
7288
- traverse(routes);
7289
- return result;
7290
- }
7291
- var init_flatten_routes = __esmMin((() => {}));
7292
- //#endregion
7293
7311
  //#region src/module-loader/keep-alive.ts
7294
7312
  /** 汇总一组模块定义里的全部路由(供扁平化与 exclude 计算) */
7295
7313
  function collectAllRoutes(definitions) {
@@ -7298,24 +7316,53 @@ function collectAllRoutes(definitions) {
7298
7316
  return routes;
7299
7317
  }
7300
7318
  /**
7319
+ * 绝对路径求解:绝对 path 原样;相对 path 拼父层;index(无 path)即父层本身。
7320
+ * 规则同 addRouteIdByPath / generateMenuItemsFromRoutes / rewriteAdminPaths 的
7321
+ * resolveAbs——解决方案模块一律相对路径声明(安装时方可加 prefix 于顶层),
7322
+ * 运行时绝对路径由此规则涌现,exclude key 必须落进同一空间才能与
7323
+ * activeCacheKey(= pathname)匹配。
7324
+ */
7325
+ function resolveAbsPath(path, parentAbs) {
7326
+ if (!path) return parentAbs;
7327
+ return path.startsWith("/") ? path : `${parentAbs.replace(/\/+$/, "")}/${path}`;
7328
+ }
7329
+ /**
7301
7330
  * 纯函数:从一组路由中收集 `handle.keepAlive === false` 的路由 key。
7302
- * key 与 `flattenRoutes` 产出的键一致,从而能和 KeepAlive 的 `activeCacheKey`
7303
- * (= pathname)精确匹配。
7331
+ * key = 路由绝对路径,与 activeCacheKey(= pathname)匹配。
7304
7332
  *
7305
7333
  * 这是 P2.1 把 exclude 计算从 access store 的 `flatRouteList` 反转到
7306
7334
  * module-loader 的核心:缓存是否排除只取决于「模块声明」,不再依赖某条路由
7307
7335
  * 是否套了 ContainerLayout(见设计文档 B13 / R9)。
7308
7336
  */
7309
7337
  function collectKeepAliveExcludes(routes) {
7310
- const flat = flattenRoutes(routes);
7311
- return Object.entries(flat).reduce((acc, [key, value]) => {
7312
- if (value.handle?.keepAlive === false) acc.push(key);
7313
- return acc;
7314
- }, []);
7338
+ const out = [];
7339
+ const walk = (items, parentAbs) => {
7340
+ for (const item of items) {
7341
+ const abs = resolveAbsPath(item.path, parentAbs);
7342
+ const key = item.index ? parentAbs : abs;
7343
+ if (item.handle?.keepAlive === false) out.push(key);
7344
+ if (item.children?.length) walk(item.children, abs);
7345
+ }
7346
+ };
7347
+ walk(routes, "");
7348
+ return out;
7315
7349
  }
7316
- /** 纯函数:收集所有路由 key(关闭多 tab 时用于整体排除,仅保留切换动画) */
7350
+ /**
7351
+ * 纯函数:收集所有路由 key(关闭多 tab 时用于整体排除,仅保留切换动画)。
7352
+ * 与 collectKeepAliveExcludes 同一套绝对 key 口径(tab 关闭分支的 exclude
7353
+ * 自此真正有意义),按出现序去重。
7354
+ */
7317
7355
  function collectAllRoutePaths(routes) {
7318
- return Object.keys(flattenRoutes(routes));
7356
+ const seen = /* @__PURE__ */ new Set();
7357
+ const walk = (items, parentAbs) => {
7358
+ for (const item of items) {
7359
+ const abs = resolveAbsPath(item.path, parentAbs);
7360
+ seen.add(item.index ? parentAbs : abs);
7361
+ if (item.children?.length) walk(item.children, abs);
7362
+ }
7363
+ };
7364
+ walk(routes, "");
7365
+ return [...seen];
7319
7366
  }
7320
7367
  /** 基于一组模块定义计算 exclude key(供单元测试) */
7321
7368
  function getKeepAliveExcludes(definitions) {
@@ -7325,9 +7372,7 @@ function getKeepAliveExcludes(definitions) {
7325
7372
  function getAllRoutePaths(definitions) {
7326
7373
  return collectAllRoutePaths(collectAllRoutes(definitions));
7327
7374
  }
7328
- var init_keep_alive = __esmMin((() => {
7329
- init_flatten_routes();
7330
- }));
7375
+ var init_keep_alive = __esmMin((() => {}));
7331
7376
  //#endregion
7332
7377
  //#region src/module-loader/semver.ts
7333
7378
  function parse$2(version) {
@@ -7369,11 +7414,12 @@ function satisfiesSemver(version, range) {
7369
7414
  var init_semver = __esmMin((() => {}));
7370
7415
  //#endregion
7371
7416
  //#region src/module-loader/index.ts
7372
- function createModuleContext(definition) {
7417
+ function createModuleContext(definition, routePrefix) {
7373
7418
  return {
7374
7419
  module: {
7375
7420
  name: definition.name,
7376
- version: definition.version
7421
+ version: definition.version,
7422
+ routePrefix
7377
7423
  },
7378
7424
  utils: { request: createScopedRequest(definition.name, () => registeredApiPrefixes.get(definition.name)) },
7379
7425
  register: {
@@ -7476,6 +7522,7 @@ async function loadAll(manifest) {
7476
7522
  console.error(`[module-loader] 模块 "${entry.name}" 与宿主 runtime 版本不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}。已跳过加载。修复建议:升级宿主 runtime 或按兼容范围重新构建该模块(US-5)。`);
7477
7523
  modules.set(entry.name, {
7478
7524
  definition,
7525
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7479
7526
  status: "error",
7480
7527
  error: /* @__PURE__ */ new Error(`模块 "${entry.name}" peerRuntime 不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}`)
7481
7528
  });
@@ -7485,6 +7532,7 @@ async function loadAll(manifest) {
7485
7532
  validEntries.push(entry);
7486
7533
  modules.set(entry.name, {
7487
7534
  definition,
7535
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7488
7536
  status: "loaded"
7489
7537
  });
7490
7538
  } else modules.set(entry.name, {
@@ -7494,6 +7542,7 @@ async function loadAll(manifest) {
7494
7542
  version: "0.0.0",
7495
7543
  routes: []
7496
7544
  },
7545
+ routePrefix: entry.routePrefix ?? entry.name,
7497
7546
  status: "error",
7498
7547
  error: /* @__PURE__ */ new Error("Failed to load module entry")
7499
7548
  });
@@ -7505,6 +7554,7 @@ async function loadAll(manifest) {
7505
7554
  console.error(`[module-loader] 模块 "${entry.name}" 依赖缺失:${missing.join(", ")} 未加载。已跳过该模块(不执行生命周期、不注册路由)。修复建议:先部署依赖模块,或在清单中移除该依赖(US-9)。`);
7506
7555
  modules.set(entry.name, {
7507
7556
  definition,
7557
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7508
7558
  status: "missing-deps",
7509
7559
  error: /* @__PURE__ */ new Error(`模块 "${entry.name}" 依赖缺失:${missing.join(", ")}`)
7510
7560
  });
@@ -7515,7 +7565,7 @@ async function loadAll(manifest) {
7515
7565
  const sortedEntries = topologicalSort(validEntries, definitions);
7516
7566
  for (const entry of sortedEntries) {
7517
7567
  const definition = definitions.get(entry.name);
7518
- const ctx = createModuleContext(definition);
7568
+ const ctx = createModuleContext(definition, entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name);
7519
7569
  try {
7520
7570
  if (definition.lifecycle?.beforeInit) await definition.lifecycle.beforeInit(ctx);
7521
7571
  if (definition.lifecycle?.onInit) await definition.lifecycle.onInit(ctx);
@@ -7524,6 +7574,7 @@ async function loadAll(manifest) {
7524
7574
  console.error(`[module-loader] Lifecycle error for "${entry.name}":`, error);
7525
7575
  modules.set(entry.name, {
7526
7576
  definition,
7577
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7527
7578
  status: "error",
7528
7579
  error: error instanceof Error ? error : new Error(String(error))
7529
7580
  });
@@ -7547,6 +7598,19 @@ function getModule(name) {
7547
7598
  return modules.get(name);
7548
7599
  }
7549
7600
  /**
7601
+ * 模块生效 web 路由前缀段取值口(R1.3)。
7602
+ *
7603
+ * 模块内深链/跳转应经此单点消费,勿硬编码根段:
7604
+ * `createWikiPaths(getModuleRoutePrefix("wiki") ?? "wiki")`。
7605
+ *
7606
+ * 注意时序:须在 `loadAll` 之后调用——模块静态求值期 `modules` 未就绪。
7607
+ * 模块内导航应懒取(组件渲染 / 事件处理器内),不要缓存于模块顶层常量。
7608
+ * 未加载模块回落 `name`(与缺省语义一致)。
7609
+ */
7610
+ function getModuleRoutePrefix(name) {
7611
+ return modules.get(name)?.routePrefix ?? name;
7612
+ }
7613
+ /**
7550
7614
  * 给模块路由打 module 标(R1,0.2.16):递归 children,非突变返回新对象。
7551
7615
  * module 来自 `definition.name`,作者无需手写——归组据此甄别方案路由、
7552
7616
  * 生成 `solgroup:<category>:<module>` 父键,不再依赖宿主 DB 行。
@@ -7564,6 +7628,35 @@ function tagModuleRoutes(routes, name) {
7564
7628
  return tagged;
7565
7629
  });
7566
7630
  }
7631
+ /**
7632
+ * 按生效前缀改写模块路由根(R1.2,单根约定下仅改首段/根段)。
7633
+ * 顶层路由 path 的首段替换为 prefix;children 为相对路径不变。
7634
+ * 保持原 path 的「前导斜杠」风格:相对 `wiki` → `kb`、绝对 `/wiki` → `/kb`,
7635
+ * 因此缺省 prefix = name 时路径字段逐字节不变(零漂移),仅段值一致。
7636
+ */
7637
+ function applyRoutePrefix(routes, prefix) {
7638
+ return routes.map((route) => {
7639
+ const p = typeof route.path === "string" ? route.path : "";
7640
+ const hadLeadingSlash = p.startsWith("/");
7641
+ const firstSeg = p.replace(/^\/?/, "").split("/")[0] ?? "";
7642
+ const afterFirst = hadLeadingSlash ? p.slice(1 + firstSeg.length) : p.slice(firstSeg.length);
7643
+ const newPath = hadLeadingSlash ? `/${prefix}${afterFirst}` : `${prefix}${afterFirst}`;
7644
+ return {
7645
+ ...route,
7646
+ path: newPath
7647
+ };
7648
+ });
7649
+ }
7650
+ /**
7651
+ * 取模块路由根段(首段,去前导斜杠);无路由返回 undefined。
7652
+ * 用于 `routePrefix` 缺省:单根约定下根段即模块身份,故缺省前缀 = 根段
7653
+ * (约定等于 name;fixture/异常态下 name 与根段不一致时回落根段而非 name,避免改写真实根)。
7654
+ */
7655
+ function rootSegmentOf(routes) {
7656
+ const p = routes[0]?.path;
7657
+ if (typeof p !== "string" || p.length === 0) return void 0;
7658
+ return p.replace(/^\/?/, "").split("/")[0] || void 0;
7659
+ }
7567
7660
  function getRoutes() {
7568
7661
  const { roles, permissions = [] } = useUserStore.getState();
7569
7662
  const routes = [];
@@ -7577,6 +7670,22 @@ function getRoutes() {
7577
7670
  }
7578
7671
  return addRouteIdByPath(routes);
7579
7672
  }
7673
+ /**
7674
+ * 宿主链建路由表的唯一取口:与菜单管线同源套 rewriteAdminPaths。
7675
+ *
7676
+ * 0.2.17 起方案 admin 路由被真重写为 /system/<模块根>/*(菜单项 key 已随之改写),
7677
+ * 宿主若直接用 getRoutes() 建表(cli/shell host.tsx 回归,2026-10),渲染路由表
7678
+ * 停留在 /wiki/admin/* 而菜单指向 /system/wiki/admin/* → 方案管理页菜单点击必 404。
7679
+ * getRoutes() 只该给 access store 登记、keep-alive 聚合等「无需重写」的场景用。
7680
+ */
7681
+ function getHostRoutes() {
7682
+ return rewriteAdminPaths(getRoutes().map((route) => {
7683
+ const mod = route.handle?.module;
7684
+ if (!mod) return route;
7685
+ const prefix = modules.get(mod)?.routePrefix ?? mod;
7686
+ return applyRoutePrefix([route], prefix)[0] ?? route;
7687
+ }));
7688
+ }
7580
7689
  function getRegisteredStore(name) {
7581
7690
  return registeredStores.get(name);
7582
7691
  }
@@ -7591,7 +7700,7 @@ function getRegisteredApiPrefix(moduleName) {
7591
7700
  async function unloadModule(name) {
7592
7701
  const instance = modules.get(name);
7593
7702
  if (instance) {
7594
- const ctx = createModuleContext(instance.definition);
7703
+ const ctx = createModuleContext(instance.definition, instance.routePrefix);
7595
7704
  if (instance.definition.lifecycle?.onDestroy) await instance.definition.lifecycle.onDestroy(ctx);
7596
7705
  }
7597
7706
  removeModuleSlots(name);
@@ -7623,6 +7732,7 @@ var init_module_loader = __esmMin((() => {
7623
7732
  init_layout_registry();
7624
7733
  init_add_route_id_by_path();
7625
7734
  init_resolve_layout();
7735
+ init_rewrite_admin_paths();
7626
7736
  init_access();
7627
7737
  init_api_provider();
7628
7738
  init_auth_provider();
@@ -8436,6 +8546,24 @@ var init_router = __esmMin((() => {
8436
8546
  router = createRouter();
8437
8547
  }));
8438
8548
  //#endregion
8549
+ //#region src/router/utils/flatten-routes.ts
8550
+ /**
8551
+ * 将路由扁平化为一个对象,键为路由的 path,值为路由对象
8552
+ */
8553
+ function flattenRoutes(routes) {
8554
+ const result = {};
8555
+ function traverse(items, parent) {
8556
+ items.forEach((item) => {
8557
+ if (item.index && parent?.path) result[`${parent.path}/`] = item;
8558
+ if (item.path) result[item.path] = item;
8559
+ if (item.children && item.children.length > 0) traverse(item.children, item);
8560
+ });
8561
+ }
8562
+ traverse(routes);
8563
+ return result;
8564
+ }
8565
+ var init_flatten_routes = __esmMin((() => {}));
8566
+ //#endregion
8439
8567
  //#region src/router/utils/generate-menu-items-from-routes.ts
8440
8568
  /**
8441
8569
  * 解析菜单 key 为绝对路径。
@@ -8675,42 +8803,6 @@ var init_resolve_login_route = __esmMin((() => {
8675
8803
  isInternalLoginRoute = (r) => r?.handle?.login === true && r?.handle?.internal === true;
8676
8804
  }));
8677
8805
  //#endregion
8678
- //#region src/router/utils/rewrite-admin-paths.ts
8679
- /** 解析路由绝对路径(与 generate-menu-items-from-routes 同语义) */
8680
- function resolveAbs(path, parentAbs) {
8681
- if (!path) return parentAbs;
8682
- return path.startsWith("/") ? path : `${parentAbs.replace(/\/+$/, "")}/${path}`;
8683
- }
8684
- function rewriteAdminPaths(routes, systemKey = SYSTEM_KEY) {
8685
- const hoisted = [];
8686
- return [...routes.map((r) => processRoute(r, "/", systemKey, hoisted, void 0)).filter((r) => r !== null), ...hoisted];
8687
- }
8688
- function processRoute(route, parentAbs, systemKey, hoisted, moduleRootAbs) {
8689
- const abs = resolveAbs(route.path, parentAbs);
8690
- const rootAbs = moduleRootAbs ?? abs;
8691
- if (route.handle?.category === "admin") {
8692
- const newAbs = `${systemKey}${rootAbs}${abs.slice(rootAbs.length) || ""}`;
8693
- hoisted.push({
8694
- ...route,
8695
- path: newAbs
8696
- });
8697
- return null;
8698
- }
8699
- if (!route.children || route.children.length === 0) return { ...route };
8700
- const newChildren = [];
8701
- for (const c of route.children) {
8702
- const processed = processRoute(c, abs, systemKey, hoisted, rootAbs);
8703
- if (processed !== null) newChildren.push(processed);
8704
- }
8705
- const result = { ...route };
8706
- result.children = newChildren;
8707
- return result;
8708
- }
8709
- var SYSTEM_KEY;
8710
- var init_rewrite_admin_paths = __esmMin((() => {
8711
- SYSTEM_KEY = "/system";
8712
- }));
8713
- //#endregion
8714
8806
  //#region src/store/access.ts
8715
8807
  /**
8716
8808
  * 切分主区/底部固定区路由(ascending 已双段排序:负 order 段整体在后)。
@@ -8850,6 +8942,29 @@ var init_auth = __esmMin((() => {
8850
8942
  }), { name: getAppNamespace("access-token") }));
8851
8943
  }));
8852
8944
  //#endregion
8945
+ //#region src/utils/api-base.ts
8946
+ /** 归一为不以 `/` 结尾;绝对域(http(s)://)原样剥尾斜杠;空值回落默认 */
8947
+ function normalizeApiBase(raw) {
8948
+ const trimmed = raw?.trim();
8949
+ if (!trimmed) return DEFAULT_API_BASE;
8950
+ return trimmed.replace(/\/+$/, "") || DEFAULT_API_BASE;
8951
+ }
8952
+ /**
8953
+ * 当前 API 端点 base(ky `prefix`,恒不以 `/` 结尾)。
8954
+ *
8955
+ * 每次调用都重新读取——不做模块级缓存(与 `resolveSiteBase()` 同理:
8956
+ * 测试需要在不重启模块的情况下模拟不同部署)。
8957
+ */
8958
+ function resolveApiBase() {
8959
+ const injected = globalThis.__OJ_API_BASE__;
8960
+ if (typeof injected === "string") return normalizeApiBase(injected);
8961
+ return normalizeApiBase("/api");
8962
+ }
8963
+ var DEFAULT_API_BASE;
8964
+ var init_api_base = __esmMin((() => {
8965
+ DEFAULT_API_BASE = "/api";
8966
+ }));
8967
+ //#endregion
8853
8968
  //#region src/utils/request/error-response.ts
8854
8969
  /**
8855
8970
  * 处理错误响应
@@ -8923,13 +9038,24 @@ var init_global_progress = __esmMin((() => {
8923
9038
  }));
8924
9039
  //#endregion
8925
9040
  //#region src/utils/remember-route/index.ts
9041
+ /**
9042
+ * 回跳参数取**路由空间**路径(剥掉站点 base)。
9043
+ *
9044
+ * 消费端(login 模块 `navigate(getRedirectPath(search))`)走的是 react-router
9045
+ * navigate——basename 会被再补一次。这里若直接用 `window.location.pathname`
9046
+ * (URL 空间,含 `/oj` 前缀),子路径部署下登录后落 `/oj/oj/home`(base 双拼,
9047
+ * 2026-10 子路径部署实测回归)。goLogin 的硬跳转负责补 base,本函数只管剥。
9048
+ */
8926
9049
  function rememberRoute() {
8927
9050
  const { pathname, search } = window.location;
8928
- if (pathname.length > 1 && pathname !== "/login") return `?redirect=${pathname}${search}`;
9051
+ const base = resolveSiteBase();
9052
+ const routePath = base === "/" ? pathname : `/${pathname.slice(base.length)}`;
9053
+ if (routePath.length > 1 && routePath !== "/login") return `?redirect=${routePath}${search}`;
8929
9054
  return "";
8930
9055
  }
8931
9056
  var init_remember_route = __esmMin((() => {
8932
9057
  init_extra_info();
9058
+ init_site_base();
8933
9059
  }));
8934
9060
  //#endregion
8935
9061
  //#region src/utils/request/go-login.ts
@@ -9075,6 +9201,7 @@ var init_request = __esmMin((() => {
9075
9201
  init_auth();
9076
9202
  init_header_provider();
9077
9203
  init_preferences$3();
9204
+ init_api_base();
9078
9205
  init_constants$3();
9079
9206
  init_error_response();
9080
9207
  init_global_progress();
@@ -9083,7 +9210,7 @@ var init_request = __esmMin((() => {
9083
9210
  init_refresh();
9084
9211
  init_whitelist();
9085
9212
  defaultConfig = {
9086
- prefix: "/api",
9213
+ prefix: resolveApiBase(),
9087
9214
  timeout: 1e4,
9088
9215
  retry: { limit: 3 },
9089
9216
  hooks: {
@@ -9651,9 +9778,10 @@ function BasicContent(props) {
9651
9778
  //#endregion
9652
9779
  //#region src/components/basic-form/form-items/form-avatar-item.tsx
9653
9780
  init_api_provider();
9781
+ init_api_base();
9654
9782
  function FormAvatarItem({ value, onChange }) {
9655
9783
  const uploadProvider = getUploadApiProvider();
9656
- const uploadAction = uploadProvider?.action ?? `/api/upload`;
9784
+ const uploadAction = uploadProvider?.action ?? `${resolveApiBase()}/upload`;
9657
9785
  const uploadHeaders = uploadProvider?.headers() ?? { authorization: "authorization-text" };
9658
9786
  return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", {
9659
9787
  className: "flex items-center gap-5",
@@ -28075,4 +28203,4 @@ init_ri();
28075
28203
  init_menu_icons();
28076
28204
  init_tree();
28077
28205
  //#endregion
28078
- export { AccessControl, AccessControlRoles, AntdApp, BasicButton, BasicContent, BasicTable, EmbeddedIcon, ErrorBoundary, ExternalIcon, FormAvatarItem, FormTreeItem, Iframe, LayoutCenterIcon, LayoutEffects, LayoutLeftIcon, LayoutRightIcon, MixedNavigationIcon, OutsidePageIcon, PageError, ProfileCardIcon, RiAccountCircleLine, RiContrastFill, RiFullscreenExitLine, RiFullscreenLine, RiMailCheckLine, RiMoonIcon, RiReactjsLine, RiSunIcon, RiUserSettingsLine, ServerErrorIcon, SideNavigationIcon, TopNavigationIcon, TwoColumnNavigationIcon, accessControlCodes, bindingDiff, canAssignRole, canDeleteRole, canEditRoleBindings, defineModule, fetchAddMenuItem, fetchAddRoleItem, fetchAsyncRoutes, fetchChangePassword, fetchDeleteMenuItem, fetchDeleteRoleItem, fetchLdapLogin, fetchLogin, fetchLoginMethods, fetchLogout, fetchMenuByRoleId, fetchMenuList, fetchRbacAdminResetPassword, fetchRbacContactDetail, fetchRbacContacts, fetchRbacCreateRole, fetchRbacCreateUser, fetchRbacDeleteRole, fetchRbacOplog, fetchRbacOplogPost, fetchRbacPermissionList, fetchRbacRoleList, fetchRbacSetForbidden, fetchRbacSettings, fetchRbacUpdateBindings, fetchRbacUpdateSetting, fetchRbacUpdateUserRoles, fetchRbacUserList, fetchRefreshToken, fetchRoleList, fetchRoleMenu, fetchSolutionRoutes, fetchUpdateMenuItem, fetchUpdateRoleItem, fetchUserInfo, filterTree, getAllExpandedKeys, getAppInfo, getBooleanOptions, getModule, getModules, getRedirectPath, getRegisteredApiPrefix, getRegisteredStore, getRoutes, getYesNoOptions, groupByModule, groupSettingsByModule, handleTree, loadAll, mapTree, menuIcons, notify, permissionPrefix, request, resolveSiteBase, roleLabel, setupI18n, traverseTreeValues, unloadModule, useAccess, useAuthStore, usePreferences, useSlotNodes, useSolutionRoutes, useTabKey, useTabsStore, useUserStore, withSiteBase, external_exports as z };
28206
+ export { AccessControl, AccessControlRoles, AntdApp, BasicButton, BasicContent, BasicTable, EmbeddedIcon, ErrorBoundary, ExternalIcon, FormAvatarItem, FormTreeItem, Iframe, LayoutCenterIcon, LayoutEffects, LayoutLeftIcon, LayoutRightIcon, MixedNavigationIcon, OutsidePageIcon, PageError, ProfileCardIcon, RiAccountCircleLine, RiContrastFill, RiFullscreenExitLine, RiFullscreenLine, RiMailCheckLine, RiMoonIcon, RiReactjsLine, RiSunIcon, RiUserSettingsLine, ServerErrorIcon, SideNavigationIcon, TopNavigationIcon, TwoColumnNavigationIcon, accessControlCodes, bindingDiff, canAssignRole, canDeleteRole, canEditRoleBindings, defineModule, fetchAddMenuItem, fetchAddRoleItem, fetchAsyncRoutes, fetchChangePassword, fetchDeleteMenuItem, fetchDeleteRoleItem, fetchLdapLogin, fetchLogin, fetchLoginMethods, fetchLogout, fetchMenuByRoleId, fetchMenuList, fetchRbacAdminResetPassword, fetchRbacContactDetail, fetchRbacContacts, fetchRbacCreateRole, fetchRbacCreateUser, fetchRbacDeleteRole, fetchRbacOplog, fetchRbacOplogPost, fetchRbacPermissionList, fetchRbacRoleList, fetchRbacSetForbidden, fetchRbacSettings, fetchRbacUpdateBindings, fetchRbacUpdateSetting, fetchRbacUpdateUserRoles, fetchRbacUserList, fetchRefreshToken, fetchRoleList, fetchRoleMenu, fetchSolutionRoutes, fetchUpdateMenuItem, fetchUpdateRoleItem, fetchUserInfo, filterTree, getAllExpandedKeys, getAppInfo, getBooleanOptions, getHostRoutes, getModule, getModuleRoutePrefix, getModules, getRedirectPath, getRegisteredApiPrefix, getRegisteredStore, getRoutes, getYesNoOptions, groupByModule, groupSettingsByModule, handleTree, loadAll, mapTree, menuIcons, notify, permissionPrefix, request, resolveSiteBase, roleLabel, setupI18n, traverseTreeValues, unloadModule, useAccess, useAuthStore, usePreferences, useSlotNodes, useSolutionRoutes, useTabKey, useTabsStore, useUserStore, withSiteBase, external_exports as z };
@@ -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-yoOEdSzDoQ4YJ5XCof4eFQ=='; 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-mh43xEkINEUP2sZNPLqVdA=='; 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="yoOEdSzDoQ4YJ5XCof4eFQ==">{
10
+ <script type="importmap" nonce="mh43xEkINEUP2sZNPLqVdA==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -133,7 +133,7 @@
133
133
  "dayjs/locale/zh-cn": "/assets/ojm-dayjs-locale-zh-cn.js"
134
134
  }
135
135
  }</script>
136
- <script type="module" crossorigin src="/assets/index-B3g19Uet.js"></script>
136
+ <script type="module" crossorigin src="/assets/index-CtTVbohp.js"></script>
137
137
  </head>
138
138
  <body>
139
139
  <div id="root"></div>
@@ -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.26",
11
- "@oj-module/runtime/contract": "0.2.26",
12
- "@oj-module/runtime/contract/errors": "0.2.26",
10
+ "@oj-module/runtime": "0.2.28",
11
+ "@oj-module/runtime/contract": "0.2.28",
12
+ "@oj-module/runtime/contract/errors": "0.2.28",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
package/src/build.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Plugin as EsbuildPlugin } from "esbuild";
2
2
  import type { Plugin } from "vite";
3
+ import type { ProjectLayout } from "./layout";
3
4
  import type { RouteDefinitionLike } from "./manifest-routes-derive";
4
5
  import { execFileSync } from "node:child_process";
5
6
  import crypto from "node:crypto";
@@ -10,9 +11,10 @@ import { pathToFileURL } from "node:url";
10
11
  import tailwindcss from "@tailwindcss/vite";
11
12
  import { build as esbuild } from "esbuild";
12
13
  import { build } from "vite";
14
+ import { parse as parseYaml } from "yaml";
13
15
  import { ALIAS_PATHS_FILE, syncAliasPaths } from "./alias-paths";
14
16
  import { assertModuleList, loadSiteConfig, mergeModuleList, resolveModuleEntry } from "./config";
15
- import { resolveConfigPath, resolveLayout, scanModules, tryResolveLayout } from "./layout";
17
+ import { moduleDir, resolveConfigPath, resolveLayout, scanModules, tryResolveLayout } from "./layout";
16
18
  import { requireModuleRootCapability } from "./oj-caps";
17
19
  import { isSharedDep, SHARED_DEPS } from "./shared-deps";
18
20
  import { normalizeSiteBase, rewriteIndexHtml } from "./site-base";
@@ -154,6 +156,9 @@ export const defineGuard = (...a) => a[a.length - 1];
154
156
  export const getModules = () => [];
155
157
  export const getModule = () => undefined;
156
158
  export const getRoutes = () => [];
159
+ export const getHostRoutes = () => [];
160
+ // 模块生效 web 路由前缀段取值口(R1.3:写进 modules.json 的 routePrefix,未声明回落 name)
161
+ export const getModuleRoutePrefix = _fn;
157
162
  export const getRegisteredStore = () => undefined;
158
163
  export const getRegisteredApiPrefix = () => undefined;
159
164
  export const loadAll = async () => [];
@@ -341,6 +346,37 @@ export interface BuiltModule {
341
346
  integrity: string
342
347
  css: string[]
343
348
  chunks: ChunkEntry[]
349
+ /**
350
+ * 生效的 web 路由前缀段(契约 C2-14:`web.route_prefix` 缺省 `/<name>`)。
351
+ * 运行期据其改写模块路由根(单根约定下仅根段),缺省 = name 保证零漂移。
352
+ */
353
+ routePrefix: string
354
+ }
355
+
356
+ /**
357
+ * 解析模块生效的 web 路由前缀段(R1.1)。
358
+ * module-root:读 `src/<name>/solution.yaml` 的 `web.route_prefix`(契约 C2-14,缺省 `/<name>`);
359
+ * 未配置/非法/非 module-root → 缺省 `name`,与现有路由根(顶层段即 name)逐字节一致。
360
+ */
361
+ function resolveRoutePrefix(layout: ProjectLayout, name: string): string {
362
+ if (layout.kind !== "module-root")
363
+ return name;
364
+ const solPath = path.join(moduleDir(layout, name), "solution.yaml");
365
+ if (!fs.existsSync(solPath))
366
+ return name;
367
+ try {
368
+ const doc = parseYaml(fs.readFileSync(solPath, "utf-8")) as Record<string, unknown>;
369
+ const web = doc.web;
370
+ if (web && typeof web === "object" && !Array.isArray(web)) {
371
+ const rp = (web as Record<string, unknown>).route_prefix;
372
+ if (typeof rp === "string" && /^\/[a-z][a-z0-9-]{0,63}$/.test(rp))
373
+ return rp.slice(1);
374
+ }
375
+ }
376
+ catch {
377
+ // YAML 解析失败 → 缺省,不阻断构建(契约校验会在别处暴露)
378
+ }
379
+ return name;
344
380
  }
345
381
 
346
382
  const ALGORITHM = "sha384";
@@ -565,19 +601,22 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
565
601
  * dev 的 / 与 /assets/* 直接服务 shell dist,合并产物无人消费。
566
602
  *
567
603
  * siteBase 非空时(站点挂载在 `/oj` 这类子路径)改写 index.html 的站内绝对
568
- * URL 并注入 `window.__OJ_BASE__`(见 site-base.ts);为空则产物与既有行为
569
- * 逐字节一致。
604
+ * URL;apiBase 非空时注入 `window.__OJ_API_BASE__`(API 挂载前缀部署期可配,
605
+ * 见 site-base.ts)。两者都为空则产物与既有行为逐字节一致。
570
606
  */
571
- function mergeShellSite(distDir: string, siteBase: string): void {
607
+ function mergeShellSite(distDir: string, siteBase: string, apiBase: string): void {
572
608
  const shellDist = resolveShellDist();
573
609
  fs.rmSync(distDir, { recursive: true, force: true });
574
610
  fs.mkdirSync(path.dirname(distDir), { recursive: true });
575
611
  fs.cpSync(shellDist, distDir, { recursive: true });
576
612
 
577
613
  const indexPath = path.join(distDir, "index.html");
578
- if (normalizeSiteBase(siteBase) !== "/") {
579
- fs.writeFileSync(indexPath, rewriteIndexHtml(fs.readFileSync(indexPath, "utf-8"), siteBase));
580
- console.log(`[ojm] 站点 index.html 已按 siteBase=${normalizeSiteBase(siteBase)} 重写(子路径部署)`);
614
+ if (normalizeSiteBase(siteBase) !== "/" || apiBase.trim()) {
615
+ fs.writeFileSync(indexPath, rewriteIndexHtml(fs.readFileSync(indexPath, "utf-8"), siteBase, apiBase));
616
+ if (normalizeSiteBase(siteBase) !== "/")
617
+ console.log(`[ojm] 站点 index.html 已按 siteBase=${normalizeSiteBase(siteBase)} 重写(子路径部署)`);
618
+ if (apiBase.trim())
619
+ console.log(`[ojm] API 前缀已注入 index.html:${apiBase.trim().replace(/\/+$/, "")}(apiBase)`);
581
620
  }
582
621
  console.log(`[ojm] 已合并宿主站点 → ${distDir}`);
583
622
  }
@@ -607,7 +646,7 @@ export async function buildModules(
607
646
  const baseUrl = config.baseUrl ?? "";
608
647
 
609
648
  if (opts.mergeSite)
610
- mergeShellSite(outDir, config.siteBase ?? "");
649
+ mergeShellSite(outDir, config.siteBase ?? "", config.apiBase ?? "");
611
650
 
612
651
  // P4.5 / C4 / D12:构建前先过版本矩阵门禁,版本漂移直接拒绝
613
652
  checkSharedVersions(projectRoot, resolveShellDist());
@@ -756,6 +795,7 @@ export async function buildModules(
756
795
  integrity: sha384(path.join(moduleOutDir, "entry.js")),
757
796
  css,
758
797
  chunks,
798
+ routePrefix: resolveRoutePrefix(layout, definition.name),
759
799
  });
760
800
  }
761
801
 
package/src/bundle.ts CHANGED
@@ -37,7 +37,7 @@ import { readModuleDefinition } from "./build";
37
37
  import { readBuiltinModules } from "./builtin";
38
38
  import { createDeterministicTgz } from "./deterministic-tar";
39
39
  import { apiSrcOf, resolveLayout, webSrcOf } from "./layout";
40
- import { assertValidSolutionName, checkLocalesComplete, checkTablesAgainstSchema, NAME_RE, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
40
+ import { assertValidSolutionName, checkLocalesComplete, checkTablesAgainstSchema, isReferenceApiForm, NAME_RE, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
41
41
  import { deriveManifestRoutes, reconcileManifestRoutes } from "./manifest-routes-derive";
42
42
 
43
43
  export interface BundleBuildResult {
@@ -232,6 +232,13 @@ async function planBundle(projectRoot: string, layout: ProjectLayout, target: Bu
232
232
  if (violations.length > 0) {
233
233
  throw new Error(`[ojm] 方案包构建失败:manifest.yaml 契约校验未通过(${violations.length} 项违规,全量列出):\n${violations.map(x => `- ${x}`).join("\n")}`);
234
234
  }
235
+ // R2.2:引用式 solution 提示——api.tables/api.deps 缺省 = 引用包内 manifest.yaml(非漏配)
236
+ if (isReferenceApiForm(m.api)) {
237
+ console.log(
238
+ `[ojm] 引用式方案:api.tables/api.deps 缺省,引用包内 api/${String(m.name)}-${String(m.version)}/manifest.yaml`
239
+ + "(安装期 C2-31 复核其存在,依赖装配据其回读,非漏配)",
240
+ );
241
+ }
235
242
  const version = m.version as string;
236
243
 
237
244
  // 文件面契约(§4.1 构建列专属执行点;manifest 内在规则已在 validateContract 全量通过)
package/src/config.ts CHANGED
@@ -26,6 +26,18 @@ export interface ModulesConfig {
26
26
  * 绝对域),`siteBase` 是**站点自身**的部署位置;两者正交,可同时使用。
27
27
  */
28
28
  siteBase?: string
29
+ /**
30
+ * API 端点前缀(如 `"/gw"`),留空 = 构建期默认 `/api`。
31
+ *
32
+ * oj 的 API 挂载前缀是部署期配置(`config.yaml` 的 `mounts` api 条),
33
+ * 而 runtime 是预构建产物——构建期内联的默认前缀改不了。API 挂载不在
34
+ * `/api` 时**必须**配它:`ojm build` 注入 `window.__OJ_API_BASE__`,
35
+ * runtime 的 ky `prefix` 运行期读它(`resolveApiBase()`),端点仍一律相对。
36
+ *
37
+ * 与 `siteBase` 正交:站点可挂 `/oj` 而 API 恒在 `/api`;也可只换 API
38
+ * 前缀而站点在根。允许绝对域(如 `https://api.example.com`)。
39
+ */
40
+ apiBase?: string
29
41
  modules?: ModuleConfigEntry[]
30
42
  /**
31
43
  * 站点 HTML 变换模块(可选):相对工程根的路径(`./html-transform.ts`)
@@ -4,11 +4,11 @@ import type { OpenApiDoc } from "./openapi-ir";
4
4
  import { execFile as execFileCb } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { createRequire } from "node:module";
7
- import { dirname, join } from "node:path";
7
+ import { basename, dirname, join } from "node:path";
8
8
  import process from "node:process";
9
9
  import { promisify } from "node:util";
10
10
  import { stringify as stringifyYaml } from "yaml";
11
- import { apiModuleRoots, apiModulesSrc, apiSrcOf, legacyApiModuleRoots, tryResolveLayout, webSrcOf } from "../layout";
11
+ import { apiModuleRoots, apiModulesSrc, apiSrcOf, legacyApiModuleRoots, scanModules, tryResolveLayout, webSrcOf } from "../layout";
12
12
  import { emitClient } from "./emit-client";
13
13
  import { applyStubWrites, planStubWrites } from "./emit-stub";
14
14
  import { implementedRouteKeys } from "./handler-scan";
@@ -125,7 +125,10 @@ export async function runApi(opts: { cwd: string, openapi?: string, rawPaths?: S
125
125
 
126
126
  /**
127
127
  * R5:`ojm api --docs`——聚合 openapi.json 的 paths → redoc 渲染静态站。
128
- * uni-dev 形态(存在 api/src)落 `api/docs/index.html`,纯前端落 `docs/api/index.html`。
128
+ * 文档统一落 `<projectRoot>/src/docs/<name>/api`(与模块树同位、树外——不参与模块
129
+ * 枚举与 handler 路由扫描,杜绝 docs 被当 handler 镜像导致端点翻倍 wikiDocsDocs*)。
130
+ * `name` 推导:cwd 为 `src/<name>/api` 时取父目录名;module-root 单模块时取该模块名;
131
+ * 否则回退工程目录名。legacy 布局同样落 `src/docs/<name>/api`(位于 api/src 扫描树外)。
129
132
  * redoc CLI 缺失时人话报错。
130
133
  */
131
134
  export async function runApiDocs(cwd: string, opts: { redocBin?: string, fetchJs?: (url: string) => Promise<string> } = {}): Promise<string> {
@@ -136,9 +139,30 @@ export async function runApiDocs(cwd: string, opts: { redocBin?: string, fetchJs
136
139
  const doc = JSON.parse(readFileSync(openapiPath, "utf8")) as { paths?: Record<string, Record<string, unknown>> };
137
140
  const paths = doc.paths ?? {};
138
141
  const layout = tryResolveLayout(cwd);
139
- const isUniDev = existsSync(layout ? apiModulesSrc(layout) : join(cwd, "api/src"));
140
142
 
141
- const outDir = isUniDev ? join(cwd, "api/docs") : join(cwd, "docs/api");
143
+ // 修正B:文档统一落 <projectRoot>/src/docs/<name>/api(与模块树同位、树外——
144
+ // 无 manifest.yaml、顶层不被 scanModuleRoot 枚举,walkApiFiles 也不命中,
145
+ // 不会把 docs 当 handler 路由镜像,杜绝 wikiDocsDocs* 类端点翻倍)。
146
+ const projectRoot = layout?.projectRoot ?? cwd;
147
+ let name = basename(projectRoot);
148
+ if (basename(cwd) === "api") {
149
+ // cwd 为模块 api 目录(src/<name>/api)时取父目录名为模块名
150
+ const candidate = basename(dirname(cwd));
151
+ if (layout?.kind === "module-root") {
152
+ const mods = scanModules(layout).map(m => m.name);
153
+ if (mods.includes(candidate))
154
+ name = candidate;
155
+ }
156
+ else {
157
+ name = candidate;
158
+ }
159
+ }
160
+ else if (layout?.kind === "module-root") {
161
+ const mods = scanModules(layout).map(m => m.name);
162
+ if (mods.length === 1)
163
+ name = mods[0]!;
164
+ }
165
+ const outDir = join(projectRoot, "src", "docs", name, "api");
142
166
  mkdirSync(outDir, { recursive: true });
143
167
  const specPath = join(outDir, "openapi.yaml");
144
168
  writeFileSync(specPath, stringifyYaml({ openapi: "3.1.0", info: { title: "API 文档", version: "0.0.0" }, paths }));
@@ -38,6 +38,19 @@ export const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market",
38
38
  /** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
39
39
  const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
40
40
 
41
+ /**
42
+ * 引用式 api 段判定(R2.1):`api` 段存在但 `tables` 与 `deps` 均缺省 =
43
+ * 引用包内 `<module>/manifest.yaml`(oj 原生七键清单,tgz 已随包携带,安装期 C2-31 复核)。
44
+ * 契约**显式承认**此形态(不再仅靠字段可选性「漏过」通过):安装器据 native manifest
45
+ * 回读做占用检测/装配(R2.2),全量 form(两字段任一给出)优先。
46
+ */
47
+ export function isReferenceApiForm(api: unknown): boolean {
48
+ if (!isObj(api))
49
+ return false;
50
+ const a = api as Record<string, unknown>;
51
+ return a.tables === undefined && a.deps === undefined;
52
+ }
53
+
41
54
  /** §2.2 范围算子语法(peer 四字段/api.deps 值共用) */
42
55
  const RANGE_RE = /^(>=|<=|[><^=])?([0-9A-Z.-]+)$/i;
43
56
 
@@ -253,7 +266,7 @@ export function validateContract(m: unknown): string[] {
253
266
  }
254
267
  }
255
268
 
256
- // ── api 段(C2-12/12a/12b/13/26)──
269
+ // ── api 段(C2-12/12a/12b/13/26;R2.1 引用式:tables/deps 均缺省 = 引用包内 manifest.yaml)──
257
270
  let apiSeg = name;
258
271
  if (m.api !== undefined) {
259
272
  const api = m.api;
package/src/new.ts CHANGED
@@ -130,7 +130,8 @@ peerRuntime: ">=0.0.0"
130
130
  archive:
131
131
  file: ${name}-0.1.0.tgz
132
132
  ${api}web:
133
- route_prefix: /${name}
133
+ # route_prefix 缺省 = /<name>(契约 C2-14);需自定义根路径时取消下一行注释修改
134
+ # route_prefix: /${name}
134
135
  entry: modules/${name}/0.1.0/entry.js
135
136
  `;
136
137
  }
package/src/site-base.ts CHANGED
@@ -71,31 +71,45 @@ function rewriteImportmap(html: string, base: string): string {
71
71
  /**
72
72
  * 重写全站 index.html:
73
73
  * - importmap 映射值、`<script src>`、`<link href>` 统一加 `siteBase`;
74
- * - `<head>` 内注入 `window.__OJ_BASE__`(**复用 importmap 的 nonce**,
75
- * CSP 是 `script-src 'self' … 'nonce-xxx'`,无 nonce 的内联脚本会被直接拦掉)。
74
+ * - `<head>` 内注入 `window.__OJ_BASE__` / `window.__OJ_API_BASE__`
75
+ * (**复用 importmap 的 nonce**,CSP 是 `script-src 'self' … 'nonce-xxx'`,
76
+ * 无 nonce 的内联脚本会被直接拦掉)。
77
+ *
78
+ * 两者独立:siteBase 非根才重写 URL;apiBase 配了就注入(API 挂载前缀是
79
+ * 部署期事实,与站点位置正交)。都不涉及 → 逐字节原样返回(零回归)。
76
80
  */
77
- export function rewriteIndexHtml(html: string, siteBase: string | undefined): string {
81
+ export function rewriteIndexHtml(html: string, siteBase: string | undefined, apiBase?: string): string {
78
82
  const base = normalizeSiteBase(siteBase);
79
- // 根路径部署:原样返回(保持与既有产物逐字节一致)
80
- if (base === ROOT_BASE)
83
+ const api = apiBase?.trim();
84
+ // 根路径部署且未配 API 前缀:原样返回(保持与既有产物逐字节一致)
85
+ if (base === ROOT_BASE && !api)
81
86
  return html;
82
87
 
83
88
  const nonce = html.match(/<script type="importmap"[^>]*\snonce="([^"]*)"/)?.[1];
84
89
  if (!nonce) {
85
90
  throw new Error(
86
- "[ojm] 站点 index.html 缺少带 nonce 的 importmap,无法注入 window.__OJ_BASE__"
91
+ "[ojm] 站点 index.html 缺少带 nonce 的 importmap,无法注入 window.__OJ_BASE__ / window.__OJ_API_BASE__"
87
92
  + "(CSP 会拦掉无 nonce 的内联脚本,子路径部署下路由 basename 会退化成 \"/\")。"
88
93
  + "请确认 shell-dist 是最新构建产物(pnpm build:shell)。",
89
94
  );
90
95
  }
91
96
 
92
- let out = rewriteImportmap(html, base);
93
- // 属性值:只要以单个 "/" 开头(排除 "//host")。importmap 是脚本正文、非属性,
94
- // 上一步已处理,二者不重叠。
95
- out = out.replace(/(\b(?:href|src)=")(\/[^/"][^"]*|\/")/g, (_m, attr: string, url: string) =>
96
- `${attr}${withSiteBase(url, base)}`);
97
+ const globals: string[] = [];
98
+ let out = html;
99
+ if (base !== ROOT_BASE) {
100
+ out = rewriteImportmap(out, base);
101
+ // 属性值:只要以单个 "/" 开头(排除 "//host")。importmap 是脚本正文、非属性,
102
+ // 上一步已处理,二者不重叠。
103
+ out = out.replace(/(\b(?:href|src)=")(\/[^/"][^"]*|\/")/g, (_m, attr: string, url: string) =>
104
+ `${attr}${withSiteBase(url, base)}`);
105
+ globals.push(`window.__OJ_BASE__=${JSON.stringify(base)};`);
106
+ }
107
+ if (api) {
108
+ // 归一为不以 / 结尾(ky v2 的 prefix 自剥尾斜杠,这里归一保证注入值确定)
109
+ globals.push(`window.__OJ_API_BASE__=${JSON.stringify(api.replace(/\/+$/, ""))};`);
110
+ }
97
111
 
98
- const injected = `<script nonce="${nonce}">window.__OJ_BASE__=${JSON.stringify(base)};</script>`;
112
+ const injected = `<script nonce="${nonce}">${globals.join("")}</script>`;
99
113
  // 注入点选在 importmap 之前:importmap 本身必须在任何 module script 之前,
100
114
  // 而内联 classic script 是解析期同步执行,因此宿主 main 与模块 entry 读
101
115
  // `window.__OJ_BASE__` 时它一定已经在位。
@@ -40,9 +40,16 @@ function isObj(v: unknown): v is Record<string, unknown> {
40
40
  return typeof v === "object" && v !== null && !Array.isArray(v);
41
41
  }
42
42
 
43
- /** manifest api.deps 的模块依赖键(排除 _platform——平台非 web 模块依赖,P2-15② extras) */
44
- function apiDepsOf(m: Record<string, unknown>): string[] {
45
- const deps = isObj(m.api) && isObj((m.api as Record<string, unknown>).deps) ? (m.api as Record<string, unknown>).deps as Record<string, unknown> : {};
43
+ /**
44
+ * manifest api.deps 的模块依赖键(排除 _platform——平台非 web 模块依赖,P2-15② extras)。
45
+ * 全量 form(C2 `api.deps` 给出)优先;引用式(R2.2)回退包内 native manifest.deps
46
+ * (oj 原生七键清单,tgz 随包携带,extract 段已解析留存 ctx.nativeManifest)。
47
+ */
48
+ function apiDepsOf(m: Record<string, unknown>, native?: unknown): string[] {
49
+ const c2 = isObj(m.api) && isObj((m.api as Record<string, unknown>).deps)
50
+ ? (m.api as Record<string, unknown>).deps as Record<string, unknown>
51
+ : undefined;
52
+ const deps = c2 ?? (isObj(native) && isObj((native as Record<string, unknown>).deps) ? (native as Record<string, unknown>).deps as Record<string, unknown> : {});
46
53
  return Object.keys(deps).filter(k => k !== "_platform").sort();
47
54
  }
48
55
 
@@ -157,7 +164,8 @@ export async function deployWeb(ctx: DeployWebContext): Promise<StageResult> {
157
164
  // P2-15②(G7 defer-P2 承载):完整条目 extras——version/dependencies/chunks 补齐
158
165
  // (deployWeb 全新生成 entry,构建器条目不透传的缺口;宿主 HostModule 全字段消费面)
159
166
  version: ctx.version,
160
- ...(apiDepsOf(m).length > 0 ? { dependencies: apiDepsOf(m) } : {}),
167
+ // R2.2:引用式 solution 的 dependencies 据包内 native manifest 回读装配
168
+ ...(apiDepsOf(m, ctx.nativeManifest).length > 0 ? { dependencies: apiDepsOf(m, ctx.nativeManifest) } : {}),
161
169
  chunks: [], // 单 entry 产物无懒加载 chunk(宿主 collectPreloads 消费 `?? []`,字段形状齐备)
162
170
  ...(Array.isArray(web.css) ? { css: web.css.map(c => posixPath(String(c))) } : {}),
163
171
  ...(integrity !== undefined ? { integrity } : {}),
@@ -189,6 +189,8 @@ async function checkApiSegment(ctx: ExtractContext): Promise<{ ok: true } | { ok
189
189
  catch (e) {
190
190
  return { ok: false, message: `解压复核失败:api/${ctx.name}-${ctx.version}/manifest.yaml 不可解析(${String(e)})——C2-31` };
191
191
  }
192
+ // R2.2:引用式 solution 的 tables/deps 真源——保留 native manifest 供 deployWeb 回读装配。
193
+ ctx.nativeManifest = native;
192
194
  return checkModuleManifest(ctx.manifest, native, ctx.name, ctx.version);
193
195
  }
194
196
 
@@ -9,6 +9,11 @@ import type { StateStore } from "./state";
9
9
  export interface InstallContext {
10
10
  /** 已解析的 manifest.yaml(安装器读盘解析产物) */
11
11
  manifest: unknown
12
+ /**
13
+ * 包内 oj 原生清单(C2-31 复核解析产物;引用式 solution 的 tables/deps 真源)。
14
+ * web-only 包(无 api 段)保持 undefined;引用式方案的依赖装配据其回读(R2.2)。
15
+ */
16
+ nativeManifest?: unknown
12
17
  /** 安装目标身份(与 manifest.name/version 同源;manifest 字段缺失/非字符串时兜底) */
13
18
  name: string
14
19
  version: string
@@ -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.26",
12
- "@oj-module/runtime/contract": "0.2.26",
13
- "@oj-module/runtime/contract/errors": "0.2.26",
11
+ "@oj-module/runtime": "0.2.28",
12
+ "@oj-module/runtime/contract": "0.2.28",
13
+ "@oj-module/runtime/contract/errors": "0.2.28",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",