@oj-module/cli 0.2.27 → 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/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
@@ -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.27",
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.27",
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.27",
307
+ "version": "0.2.28",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-24 00:06:50"
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) {
@@ -7378,11 +7414,12 @@ function satisfiesSemver(version, range) {
7378
7414
  var init_semver = __esmMin((() => {}));
7379
7415
  //#endregion
7380
7416
  //#region src/module-loader/index.ts
7381
- function createModuleContext(definition) {
7417
+ function createModuleContext(definition, routePrefix) {
7382
7418
  return {
7383
7419
  module: {
7384
7420
  name: definition.name,
7385
- version: definition.version
7421
+ version: definition.version,
7422
+ routePrefix
7386
7423
  },
7387
7424
  utils: { request: createScopedRequest(definition.name, () => registeredApiPrefixes.get(definition.name)) },
7388
7425
  register: {
@@ -7485,6 +7522,7 @@ async function loadAll(manifest) {
7485
7522
  console.error(`[module-loader] 模块 "${entry.name}" 与宿主 runtime 版本不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}。已跳过加载。修复建议:升级宿主 runtime 或按兼容范围重新构建该模块(US-5)。`);
7486
7523
  modules.set(entry.name, {
7487
7524
  definition,
7525
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7488
7526
  status: "error",
7489
7527
  error: /* @__PURE__ */ new Error(`模块 "${entry.name}" peerRuntime 不兼容:期望 ${peerRuntime},实际 ${manifest.runtimeVersion}`)
7490
7528
  });
@@ -7494,6 +7532,7 @@ async function loadAll(manifest) {
7494
7532
  validEntries.push(entry);
7495
7533
  modules.set(entry.name, {
7496
7534
  definition,
7535
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7497
7536
  status: "loaded"
7498
7537
  });
7499
7538
  } else modules.set(entry.name, {
@@ -7503,6 +7542,7 @@ async function loadAll(manifest) {
7503
7542
  version: "0.0.0",
7504
7543
  routes: []
7505
7544
  },
7545
+ routePrefix: entry.routePrefix ?? entry.name,
7506
7546
  status: "error",
7507
7547
  error: /* @__PURE__ */ new Error("Failed to load module entry")
7508
7548
  });
@@ -7514,6 +7554,7 @@ async function loadAll(manifest) {
7514
7554
  console.error(`[module-loader] 模块 "${entry.name}" 依赖缺失:${missing.join(", ")} 未加载。已跳过该模块(不执行生命周期、不注册路由)。修复建议:先部署依赖模块,或在清单中移除该依赖(US-9)。`);
7515
7555
  modules.set(entry.name, {
7516
7556
  definition,
7557
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7517
7558
  status: "missing-deps",
7518
7559
  error: /* @__PURE__ */ new Error(`模块 "${entry.name}" 依赖缺失:${missing.join(", ")}`)
7519
7560
  });
@@ -7524,7 +7565,7 @@ async function loadAll(manifest) {
7524
7565
  const sortedEntries = topologicalSort(validEntries, definitions);
7525
7566
  for (const entry of sortedEntries) {
7526
7567
  const definition = definitions.get(entry.name);
7527
- const ctx = createModuleContext(definition);
7568
+ const ctx = createModuleContext(definition, entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name);
7528
7569
  try {
7529
7570
  if (definition.lifecycle?.beforeInit) await definition.lifecycle.beforeInit(ctx);
7530
7571
  if (definition.lifecycle?.onInit) await definition.lifecycle.onInit(ctx);
@@ -7533,6 +7574,7 @@ async function loadAll(manifest) {
7533
7574
  console.error(`[module-loader] Lifecycle error for "${entry.name}":`, error);
7534
7575
  modules.set(entry.name, {
7535
7576
  definition,
7577
+ routePrefix: entry.routePrefix ?? rootSegmentOf(definition.routes) ?? entry.name,
7536
7578
  status: "error",
7537
7579
  error: error instanceof Error ? error : new Error(String(error))
7538
7580
  });
@@ -7556,6 +7598,19 @@ function getModule(name) {
7556
7598
  return modules.get(name);
7557
7599
  }
7558
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
+ /**
7559
7614
  * 给模块路由打 module 标(R1,0.2.16):递归 children,非突变返回新对象。
7560
7615
  * module 来自 `definition.name`,作者无需手写——归组据此甄别方案路由、
7561
7616
  * 生成 `solgroup:<category>:<module>` 父键,不再依赖宿主 DB 行。
@@ -7573,6 +7628,35 @@ function tagModuleRoutes(routes, name) {
7573
7628
  return tagged;
7574
7629
  });
7575
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
+ }
7576
7660
  function getRoutes() {
7577
7661
  const { roles, permissions = [] } = useUserStore.getState();
7578
7662
  const routes = [];
@@ -7586,6 +7670,22 @@ function getRoutes() {
7586
7670
  }
7587
7671
  return addRouteIdByPath(routes);
7588
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
+ }
7589
7689
  function getRegisteredStore(name) {
7590
7690
  return registeredStores.get(name);
7591
7691
  }
@@ -7600,7 +7700,7 @@ function getRegisteredApiPrefix(moduleName) {
7600
7700
  async function unloadModule(name) {
7601
7701
  const instance = modules.get(name);
7602
7702
  if (instance) {
7603
- const ctx = createModuleContext(instance.definition);
7703
+ const ctx = createModuleContext(instance.definition, instance.routePrefix);
7604
7704
  if (instance.definition.lifecycle?.onDestroy) await instance.definition.lifecycle.onDestroy(ctx);
7605
7705
  }
7606
7706
  removeModuleSlots(name);
@@ -7632,6 +7732,7 @@ var init_module_loader = __esmMin((() => {
7632
7732
  init_layout_registry();
7633
7733
  init_add_route_id_by_path();
7634
7734
  init_resolve_layout();
7735
+ init_rewrite_admin_paths();
7635
7736
  init_access();
7636
7737
  init_api_provider();
7637
7738
  init_auth_provider();
@@ -8702,42 +8803,6 @@ var init_resolve_login_route = __esmMin((() => {
8702
8803
  isInternalLoginRoute = (r) => r?.handle?.login === true && r?.handle?.internal === true;
8703
8804
  }));
8704
8805
  //#endregion
8705
- //#region src/router/utils/rewrite-admin-paths.ts
8706
- /** 解析路由绝对路径(与 generate-menu-items-from-routes 同语义) */
8707
- function resolveAbs(path, parentAbs) {
8708
- if (!path) return parentAbs;
8709
- return path.startsWith("/") ? path : `${parentAbs.replace(/\/+$/, "")}/${path}`;
8710
- }
8711
- function rewriteAdminPaths(routes, systemKey = SYSTEM_KEY) {
8712
- const hoisted = [];
8713
- return [...routes.map((r) => processRoute(r, "/", systemKey, hoisted, void 0)).filter((r) => r !== null), ...hoisted];
8714
- }
8715
- function processRoute(route, parentAbs, systemKey, hoisted, moduleRootAbs) {
8716
- const abs = resolveAbs(route.path, parentAbs);
8717
- const rootAbs = moduleRootAbs ?? abs;
8718
- if (route.handle?.category === "admin") {
8719
- const newAbs = `${systemKey}${rootAbs}${abs.slice(rootAbs.length) || ""}`;
8720
- hoisted.push({
8721
- ...route,
8722
- path: newAbs
8723
- });
8724
- return null;
8725
- }
8726
- if (!route.children || route.children.length === 0) return { ...route };
8727
- const newChildren = [];
8728
- for (const c of route.children) {
8729
- const processed = processRoute(c, abs, systemKey, hoisted, rootAbs);
8730
- if (processed !== null) newChildren.push(processed);
8731
- }
8732
- const result = { ...route };
8733
- result.children = newChildren;
8734
- return result;
8735
- }
8736
- var SYSTEM_KEY;
8737
- var init_rewrite_admin_paths = __esmMin((() => {
8738
- SYSTEM_KEY = "/system";
8739
- }));
8740
- //#endregion
8741
8806
  //#region src/store/access.ts
8742
8807
  /**
8743
8808
  * 切分主区/底部固定区路由(ascending 已双段排序:负 order 段整体在后)。
@@ -8877,6 +8942,29 @@ var init_auth = __esmMin((() => {
8877
8942
  }), { name: getAppNamespace("access-token") }));
8878
8943
  }));
8879
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
8880
8968
  //#region src/utils/request/error-response.ts
8881
8969
  /**
8882
8970
  * 处理错误响应
@@ -8950,13 +9038,24 @@ var init_global_progress = __esmMin((() => {
8950
9038
  }));
8951
9039
  //#endregion
8952
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
+ */
8953
9049
  function rememberRoute() {
8954
9050
  const { pathname, search } = window.location;
8955
- 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}`;
8956
9054
  return "";
8957
9055
  }
8958
9056
  var init_remember_route = __esmMin((() => {
8959
9057
  init_extra_info();
9058
+ init_site_base();
8960
9059
  }));
8961
9060
  //#endregion
8962
9061
  //#region src/utils/request/go-login.ts
@@ -9102,6 +9201,7 @@ var init_request = __esmMin((() => {
9102
9201
  init_auth();
9103
9202
  init_header_provider();
9104
9203
  init_preferences$3();
9204
+ init_api_base();
9105
9205
  init_constants$3();
9106
9206
  init_error_response();
9107
9207
  init_global_progress();
@@ -9110,7 +9210,7 @@ var init_request = __esmMin((() => {
9110
9210
  init_refresh();
9111
9211
  init_whitelist();
9112
9212
  defaultConfig = {
9113
- prefix: "/api",
9213
+ prefix: resolveApiBase(),
9114
9214
  timeout: 1e4,
9115
9215
  retry: { limit: 3 },
9116
9216
  hooks: {
@@ -9678,9 +9778,10 @@ function BasicContent(props) {
9678
9778
  //#endregion
9679
9779
  //#region src/components/basic-form/form-items/form-avatar-item.tsx
9680
9780
  init_api_provider();
9781
+ init_api_base();
9681
9782
  function FormAvatarItem({ value, onChange }) {
9682
9783
  const uploadProvider = getUploadApiProvider();
9683
- const uploadAction = uploadProvider?.action ?? `/api/upload`;
9784
+ const uploadAction = uploadProvider?.action ?? `${resolveApiBase()}/upload`;
9684
9785
  const uploadHeaders = uploadProvider?.headers() ?? { authorization: "authorization-text" };
9685
9786
  return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", {
9686
9787
  className: "flex items-center gap-5",
@@ -28102,4 +28203,4 @@ init_ri();
28102
28203
  init_menu_icons();
28103
28204
  init_tree();
28104
28205
  //#endregion
28105
- 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-LoiG1HDggaNmP/FtNKL2EQ=='; 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="LoiG1HDggaNmP/FtNKL2EQ==">{
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.27",
11
- "@oj-module/runtime/contract": "0.2.27",
12
- "@oj-module/runtime/contract/errors": "0.2.27",
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.27",
12
- "@oj-module/runtime/contract": "0.2.27",
13
- "@oj-module/runtime/contract/errors": "0.2.27",
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",