@oj-module/runtime 0.2.14 → 0.2.17

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/dist/index.d.ts CHANGED
@@ -50,6 +50,18 @@ export { getAllExpandedKeys } from "./utils/get-all-expanded-keys";
50
50
  export { getAppInfo } from "./utils/get-app-info";
51
51
  export { getRedirectPath } from "./utils/get-redirect-path";
52
52
  export { request } from "./utils/request";
53
+ /**
54
+ * 站点部署 base(子路径部署,2026-10-21)。
55
+ *
56
+ * 宿主把站点挂在非根前缀(oj `mounts: [{ prefix: /oj }]`)时,站内绝对 URL 必须
57
+ * 带该前缀。runtime 产物里的 `import.meta.env.BASE_URL` 是**包构建期**内联的 `/`,
58
+ * 只有部署期注入的 `window.__OJ_BASE__` 才是真值,故一律经 `resolveSiteBase()` 取值。
59
+ *
60
+ * - 模块侧拼「非路由」的站内绝对 URL(下载直链、`window.open`)用 `withSiteBase()`;
61
+ * - **路由跳转不要用它们**:`navigate` / `<Link>` 走路由空间,basename 已由
62
+ * react-router 剥离,再拼一次会得到 `/oj/oj/wiki`。
63
+ */
64
+ export { resolveSiteBase, withSiteBase } from "./utils/site-base";
53
65
  export * from "./utils/tree";
54
66
  export type { NoticeCategory, NoticeItem, PushBroadcastInput, PushInput, } from "@oj-module/runtime/notify";
55
67
  export { ErrorBoundary } from "react-error-boundary";
@@ -23,4 +23,18 @@ export interface MenuItemType {
23
23
  * @default false
24
24
  */
25
25
  disabled?: boolean;
26
+ /**
27
+ * 所属模块名(0.2.16 R1,module-loader 注入);归组据此甄别方案路由。
28
+ * 框架/后端路由无此字段。
29
+ */
30
+ module?: string;
31
+ /**
32
+ * 方案路由分类(0.2.16 R2,作者声明于 handle):main=常规组、admin=管理组、
33
+ * hidden=不进菜单。归组与隐藏判定读此字段。
34
+ */
35
+ category?: "main" | "admin" | "hidden";
36
+ /**
37
+ * 菜单排序(0.2.16 R2,作者声明于 handle);归组内子项按此升序。
38
+ */
39
+ order?: number;
26
40
  }
@@ -14,9 +14,11 @@ export declare const exceptionUnknownComponentPath = "/exception/not-found-compo
14
14
  * 判断 pathname 是否为登录页(basename 感知,P0)。
15
15
  *
16
16
  * 供 ky hooks 等 React 之外的场景使用:原始 `location.pathname` 含 BASE_URL,
17
- * 子路径部署(/app/)下直接与 loginPath 比较恒为 false,401 时会重复 goLogin。
17
+ * 子路径部署(/oj/wiki/...)下直接与 loginPath 比较恒为 false,401 时会重复 goLogin。
18
18
  * React 组件内请用 `useLocation()`(react-router 已剥离 basename),无需本函数。
19
19
  *
20
- * @param base 部署 basename,Vite 保证以 `/` 结尾
20
+ * @param base 部署 base,恒以 `/` 结尾;缺省取 `resolveSiteBase()`(部署期注入优先,
21
+ * 见 utils/site-base.ts —— runtime 产物里的 `import.meta.env.BASE_URL` 是构建期
22
+ * 内联的 `/`,子路径部署下直接用它恒错)
21
23
  */
22
24
  export declare function isLoginPathname(pathname: string, base?: string): boolean;
@@ -24,6 +24,7 @@ declare const baseRoutes: ({
24
24
  internal?: boolean;
25
25
  hideInMenu?: boolean;
26
26
  category?: "main" | "admin" | "hidden";
27
+ module?: string;
27
28
  iframeLink?: string;
28
29
  externalLink?: string;
29
30
  ignoreAccess?: boolean;
@@ -73,6 +74,7 @@ declare const baseRoutes: ({
73
74
  internal?: boolean;
74
75
  hideInMenu?: boolean;
75
76
  category?: "main" | "admin" | "hidden";
77
+ module?: string;
76
78
  iframeLink?: string;
77
79
  externalLink?: string;
78
80
  ignoreAccess?: boolean;
@@ -62,11 +62,16 @@ export interface RouteMeta {
62
62
  */
63
63
  hideInMenu?: boolean;
64
64
  /**
65
- * 方案路由分类(P1-14/R21 三值归并,源自 sys_solution_route.category,经
66
- * mergeSolutionCategories 注写):main=常规组、admin=管理组、hidden=不进菜单。
67
- * 框架/后端路由未归并时缺省。
65
+ * 方案路由分类(P1-14/R21 三值归并):main=常规组、admin=管理组、hidden=不进菜单。
66
+ * 作者显式声明在 entry.ts handle(运行时单一真源);框架/后端路由缺省。
68
67
  */
69
68
  category?: "main" | "admin" | "hidden";
69
+ /**
70
+ * 所属模块名(module-loader 在 `getRoutes()` 注册时按 `definition.name` 注入,
71
+ * 非作者手写)。归组逻辑据此甄别方案路由、生成 `solgroup:<category>:<module>` 父键,
72
+ * 不再依赖宿主 DB 行(0.2.16 起;此前由 sys_solution_route.module 提供)。
73
+ */
74
+ module?: string;
70
75
  /**
71
76
  * iframe链接,如果路由需要在iframe中加载外部页面时使用
72
77
  */
@@ -21,6 +21,7 @@ export declare function ascending(arr: AppRouteRecordRaw[]): ({
21
21
  internal?: boolean;
22
22
  hideInMenu?: boolean;
23
23
  category?: "main" | "admin" | "hidden";
24
+ module?: string;
24
25
  iframeLink?: string;
25
26
  externalLink?: string;
26
27
  ignoreAccess?: boolean;
@@ -70,6 +71,7 @@ export declare function ascending(arr: AppRouteRecordRaw[]): ({
70
71
  internal?: boolean;
71
72
  hideInMenu?: boolean;
72
73
  category?: "main" | "admin" | "hidden";
74
+ module?: string;
73
75
  iframeLink?: string;
74
76
  externalLink?: string;
75
77
  ignoreAccess?: boolean;
@@ -0,0 +1,37 @@
1
+ import type { SolutionRouteRow } from "../../api/rbac/types";
2
+ import type { MenuItemType } from "../../layout/layout-menu/types";
3
+ /**
4
+ * 方案路由注册菜单归组(2026-10-21 设计;0.2.17 起):
5
+ *
6
+ * - flat main 归组已取消:category==="main" 不再按模块建父菜单,模块根路由本身即
7
+ * 第一级菜单(entry.ts 单根约定),其子项直出(visible 由 category/hideInMenu 决定)。
8
+ * - admin 真重写:路由 PATH 已由 rewriteAdminPaths(access.ts)在注入前改写为
9
+ * /system/<模块 root path>/*,故此处收到的 admin 菜单项 key 已是 /system/...;
10
+ * 本函数只做"视觉层归组"——递归收集 category==="admin" 项,按 module 建子组,
11
+ * 挂入 /system children 末尾,组标题存裸 i18n key `<module>:menu.main`(渲染层翻译)。
12
+ *
13
+ * 纯函数、单一职责(SRP):只把已生成的菜单做"视觉层归组",不动路由树、不触碰翻译。
14
+ * 跨列表操作(admin 项可能在 wholeMenus、`/system` 在 bottomMenus),故一次调用吃两段菜单。
15
+ *
16
+ * 依赖倒置(DIP):告警通道 onWarn 由调用方注入,缺省为模块级 warnOnce,便于单测断言。
17
+ * 无副作用、不 mutate 入参(未变动项复用原引用,返回新数组)。
18
+ */
19
+ export interface GroupSolutionMenusOptions {
20
+ /** 宿主系统设置菜单 key,默认 "/system" */
21
+ systemKey?: string;
22
+ /** 告警回调;缺省走模块级 warnOnce(同消息只打一次) */
23
+ onWarn?: (msg: string) => void;
24
+ }
25
+ /**
26
+ * 归组入口。
27
+ *
28
+ * @param top 主区菜单项(order >= 0)
29
+ * @param bottom 底部固定区菜单项(order < 0,含 /system)
30
+ * @param rows 宿主方案路由行(可选):缺省或空时归组完全依赖 handle 元数据(dev 形态);
31
+ * 提供时仅作 category 的向后兼容回退(0.2.15 已装站点),不参与新 bundle 的归组真源。
32
+ * @param opts 选项(systemKey / onWarn)
33
+ */
34
+ export declare function groupSolutionMenus(top: MenuItemType[], bottom: MenuItemType[], rows?: SolutionRouteRow[], opts?: GroupSolutionMenusOptions): {
35
+ top: MenuItemType[];
36
+ bottom: MenuItemType[];
37
+ };
@@ -6,18 +6,15 @@ import type { AppRouteRecordRaw } from "../types";
6
6
  */
7
7
  export type AnnotatedRoute = AppRouteRecordRaw;
8
8
  /**
9
- * 方案路由归并(纯函数,R21):按 path 把 sys_solution_route 行注记到已注册路由上。
9
+ * 方案路由归并(纯函数,R21 / 0.2.16 收窄):DB 行仅作为「管理覆写层」。
10
10
  *
11
- * - 三值 category 原样落 handle.category(main=常规组、admin=管理组、hidden=不进菜单);
12
- * - hidden 与 enabled=0(禁用)复用既有 hideInMenu 通路——只过滤菜单层,路由仍可达
13
- * (禁用的路由摘除归装载层三处同写,D12);
14
- * - order 覆写为声明序(2026-10-20 双段序轴):框架常规项 ≥0 升序、管理面负数入
15
- * 底部固定区(pin 侧边栏底部,ascending 双段排序)、方案项从 200 起编,共用一条序轴;
16
- * 同 order 按根 path 字母序;
17
- * - 未匹配路由(框架/后端)原样保留(同一对象引用),入参不突变;
18
- * - solRoutes 为空(端点失败/未登录降级空表)时原样返回——降级不阻断,路由照常。
11
+ * 0.2.16 起 `category`/`order`/`module` 已是路由 handle 真源(作者声明 + module-loader 注入,
12
+ * 见 R1/R2),不再由本函数从行注写。本函数只保留一项管理语义:
19
13
  *
20
- * title 不在此覆写:entry.ts 是模块元数据唯一来源(module-loader 既有惯例),
21
- * title_key 随行下发仅供深检对比。
14
+ * - **禁用覆写**:行 `enabled === false`(或 deprecated)时追加 `hideInMenu: true`,
15
+ * 使该菜单项不可见(路由仍可达,D12 口径——禁用在装载层三处同写);
16
+ * - 其余字段(category/order/module/title)一律不覆写,保持 handle 原值;
17
+ * - 入参不突变(未命中行或无需覆写时返回同一对象引用);
18
+ * - solRoutes 为空(端点失败/未登录/dev 零行降级)时原样返回——降级不阻断,路由照常。
22
19
  */
23
20
  export declare function mergeSolutionCategories(routes: AppRouteRecordRaw[], solRoutes: SolutionRouteRow[]): AnnotatedRoute[];
@@ -0,0 +1,2 @@
1
+ import type { AppRouteRecordRaw } from "../types";
2
+ export declare function rewriteAdminPaths(routes: AppRouteRecordRaw[], systemKey?: string): AppRouteRecordRaw[];
package/dist/runtime.js CHANGED
@@ -152,21 +152,68 @@ var notify = {
152
152
  }
153
153
  };
154
154
  //#endregion
155
+ //#region src/utils/site-base.ts
156
+ /**
157
+ * 归一化为「以 `/` 开头且以 `/` 结尾」的形式。
158
+ *
159
+ * 接受 `oj` / `/oj` / `/oj/` / `oj/` 四种写法(配置手写极易带或不带斜杠),
160
+ * 空值与 `"/"` 一律归为根路径。
161
+ */
162
+ function normalizeSiteBase(raw) {
163
+ if (!raw) return ROOT_BASE;
164
+ const trimmed = raw.trim();
165
+ if (!trimmed || trimmed === ROOT_BASE) return ROOT_BASE;
166
+ const withLeading = trimmed.startsWith(ROOT_BASE) ? trimmed : `${ROOT_BASE}${trimmed}`;
167
+ return withLeading.endsWith(ROOT_BASE) ? withLeading : `${withLeading}${ROOT_BASE}`;
168
+ }
169
+ /**
170
+ * 当前站点的部署 base(恒以 `/` 结尾)。
171
+ *
172
+ * 优先级:`window.__OJ_BASE__`(部署期注入)> `import.meta.env.BASE_URL`(构建期值)。
173
+ *
174
+ * 每次调用都重新读取——不做模块级缓存。原因:测试需要在不重启模块的情况下模拟
175
+ * 不同部署前缀(`vi.stubGlobal`),而字符串归一化的成本可忽略。
176
+ */
177
+ function resolveSiteBase() {
178
+ const injected = globalThis.__OJ_BASE__;
179
+ if (typeof injected === "string") return normalizeSiteBase(injected);
180
+ return normalizeSiteBase("/");
181
+ }
182
+ /**
183
+ * 把站内绝对路径(`/assets/x.js`)拼到站点 base 上(`/oj/assets/x.js`)。
184
+ *
185
+ * 已带 base 的路径幂等;`http(s)://`、`//cdn`、`data:` 等一切非站内绝对路径原样返回
186
+ * (模块 entry 允许是 CDN 绝对域,见 `web.config.ts` 的 `baseUrl`)。
187
+ */
188
+ function withSiteBase(url, base = resolveSiteBase()) {
189
+ if (!url.startsWith(ROOT_BASE) || url.startsWith("//")) return url;
190
+ const normalized = normalizeSiteBase(base);
191
+ if (normalized === ROOT_BASE || url.startsWith(normalized)) return url;
192
+ return `${normalized.slice(0, -1)}${url}`;
193
+ }
194
+ var ROOT_BASE;
195
+ var init_site_base = __esmMin((() => {
196
+ ROOT_BASE = "/";
197
+ }));
198
+ //#endregion
155
199
  //#region src/router/extra-info/route-path.ts
156
200
  /**
157
201
  * 判断 pathname 是否为登录页(basename 感知,P0)。
158
202
  *
159
203
  * 供 ky hooks 等 React 之外的场景使用:原始 `location.pathname` 含 BASE_URL,
160
- * 子路径部署(/app/)下直接与 loginPath 比较恒为 false,401 时会重复 goLogin。
204
+ * 子路径部署(/oj/wiki/...)下直接与 loginPath 比较恒为 false,401 时会重复 goLogin。
161
205
  * React 组件内请用 `useLocation()`(react-router 已剥离 basename),无需本函数。
162
206
  *
163
- * @param base 部署 basename,Vite 保证以 `/` 结尾
207
+ * @param base 部署 base,恒以 `/` 结尾;缺省取 `resolveSiteBase()`(部署期注入优先,
208
+ * 见 utils/site-base.ts —— runtime 产物里的 `import.meta.env.BASE_URL` 是构建期
209
+ * 内联的 `/`,子路径部署下直接用它恒错)
164
210
  */
165
- function isLoginPathname(pathname, base = "/") {
211
+ function isLoginPathname(pathname, base = resolveSiteBase()) {
166
212
  return pathname === `${base}${loginPath.slice(1)}`;
167
213
  }
168
214
  var loginPath, exceptionPath, exception403Path, exception404Path, exception500Path;
169
215
  var init_route_path = __esmMin((() => {
216
+ init_site_base();
170
217
  loginPath = "/login";
171
218
  exceptionPath = "/exception";
172
219
  exception403Path = `${exceptionPath}/403`;
@@ -256,10 +303,10 @@ function getAppInfo() {
256
303
  return {
257
304
  "pkg": {
258
305
  "name": "@oj-module/runtime",
259
- "version": "0.2.14",
306
+ "version": "0.2.17",
260
307
  "license": "MIT"
261
308
  },
262
- "lastBuildTime": "2026-10-21 09:05:39"
309
+ "lastBuildTime": "2026-10-21 21:54:09"
263
310
  };
264
311
  }
265
312
  var init_get_app_info = __esmMin((() => {}));
@@ -3300,7 +3347,7 @@ function openMustChangeModal() {
3300
3347
  maskClosable: false,
3301
3348
  cancelButtonProps: { style: { display: "none" } },
3302
3349
  onOk: () => {
3303
- window.location.href = `/change-password`;
3350
+ window.location.href = `${resolveSiteBase()}change-password`;
3304
3351
  },
3305
3352
  afterClose: () => {
3306
3353
  mustChangeModalOpen = false;
@@ -3310,6 +3357,7 @@ function openMustChangeModal() {
3310
3357
  var mustChangeModalOpen;
3311
3358
  var init_must_change = __esmMin((() => {
3312
3359
  init_is();
3360
+ init_site_base();
3313
3361
  init_static_antd();
3314
3362
  mustChangeModalOpen = false;
3315
3363
  }));
@@ -6077,6 +6125,7 @@ function useMenu() {
6077
6125
  * 菜单点击事件处理
6078
6126
  */
6079
6127
  const handleMenuSelect = (key, mode) => {
6128
+ if (key.startsWith("solgroup:")) return;
6080
6129
  if (key === removeTrailingSlash(pathname)) return;
6081
6130
  if (!shouldSplitMenuItems || mode !== "horizontal") {
6082
6131
  if (/http(s)?:/.test(key)) window.open(key);
@@ -7477,6 +7526,24 @@ function getModules() {
7477
7526
  function getModule(name) {
7478
7527
  return modules.get(name);
7479
7528
  }
7529
+ /**
7530
+ * 给模块路由打 module 标(R1,0.2.16):递归 children,非突变返回新对象。
7531
+ * module 来自 `definition.name`,作者无需手写——归组据此甄别方案路由、
7532
+ * 生成 `solgroup:<category>:<module>` 父键,不再依赖宿主 DB 行。
7533
+ */
7534
+ function tagModuleRoutes(routes, name) {
7535
+ return routes.map((route) => {
7536
+ const tagged = {
7537
+ ...route,
7538
+ handle: {
7539
+ ...route.handle,
7540
+ module: name
7541
+ }
7542
+ };
7543
+ if (Array.isArray(route.children) && route.children.length > 0) tagged.children = tagModuleRoutes(route.children, name);
7544
+ return tagged;
7545
+ });
7546
+ }
7480
7547
  function getRoutes() {
7481
7548
  const { roles, permissions = [] } = useUserStore.getState();
7482
7549
  const routes = [];
@@ -7486,7 +7553,7 @@ function getRoutes() {
7486
7553
  if (requiredRoles?.length && !requiredRoles.some((role) => roles.includes(role))) continue;
7487
7554
  const requiredPermissions = instance.definition.config?.requiredPermissions;
7488
7555
  if (requiredPermissions?.length && !requiredPermissions.every((perm) => permissions.includes(perm))) continue;
7489
- if (instance.definition.routes.length > 0) routes.push(...resolveRouteLayouts(instance.definition.routes));
7556
+ if (instance.definition.routes.length > 0) routes.push(...resolveRouteLayouts(tagModuleRoutes(instance.definition.routes, instance.definition.name)));
7490
7557
  }
7491
7558
  return addRouteIdByPath(routes);
7492
7559
  }
@@ -8293,13 +8360,23 @@ var init_constants = __esmMin((() => {
8293
8360
  //#endregion
8294
8361
  //#region src/router/index.ts
8295
8362
  function createRouter() {
8296
- return createBrowserRouter(rootRoute, { basename: "/" });
8363
+ return createBrowserRouter(rootRoute, {
8364
+ /**
8365
+ * @zh 子路径部署(oj `mounts: [{ prefix: /oj }]`)下 basename 必须是部署前缀,
8366
+ * 否则 react-router 不剥前缀,深链 `/oj/wiki/...` 匹配不到任何路由(落到 404 兜底)。
8367
+ * 取值走 `resolveSiteBase()`:优先宿主注入的 `window.__OJ_BASE__`,回退构建期
8368
+ * `import.meta.env.BASE_URL`——后者在预构建产物里被内联成 `/`,单独用不可靠。
8369
+ * @en Under sub-path deployment the basename must be the deploy prefix; read it
8370
+ * through `resolveSiteBase()` instead of the build-time `import.meta.env.BASE_URL`.
8371
+ */
8372
+ basename: resolveSiteBase() });
8297
8373
  }
8298
8374
  var loadedPaths, rootRoute, router;
8299
8375
  var init_router = __esmMin((() => {
8300
8376
  init_layout_root();
8301
8377
  init_preferences$3();
8302
8378
  init_progress();
8379
+ init_site_base();
8303
8380
  init_constants();
8304
8381
  init_routes();
8305
8382
  loadedPaths = /* @__PURE__ */ new Set();
@@ -8360,9 +8437,10 @@ function resolveMenuKey(path, parentPath) {
8360
8437
  */
8361
8438
  function generateMenuItemsFromRoutes(routeList, parentPath = "/") {
8362
8439
  return routeList.reduce((acc, item) => {
8363
- const label = item.handle?.title;
8364
- const externalLink = item?.handle?.externalLink;
8365
- const icon = item?.handle?.icon;
8440
+ const handle = item.handle;
8441
+ const label = handle?.title;
8442
+ const externalLink = handle?.externalLink;
8443
+ const icon = handle?.icon;
8366
8444
  const absolutePath = resolveMenuKey(item.path, parentPath);
8367
8445
  const menuItem = {
8368
8446
  key: absolutePath,
@@ -8376,45 +8454,151 @@ function generateMenuItemsFromRoutes(routeList, parentPath = "/") {
8376
8454
  }, label) : label
8377
8455
  };
8378
8456
  if (icon) menuItem.icon = icon;
8457
+ if (handle?.module) menuItem.module = handle.module;
8458
+ if (handle?.category) menuItem.category = handle.category;
8459
+ if (handle?.order !== void 0) menuItem.order = handle.order;
8379
8460
  if (Array.isArray(item.children) && item.children.length > 0) {
8380
- const noIndexRoute = item.children.filter((route) => !route.index && !route?.handle?.hideInMenu);
8461
+ const noIndexRoute = item.children.filter((route) => !route.index && !route?.handle?.hideInMenu && route?.handle?.category !== "hidden");
8381
8462
  if (noIndexRoute.length > 0) menuItem.children = generateMenuItemsFromRoutes(noIndexRoute, absolutePath);
8382
8463
  }
8383
- if (item?.handle?.hideInMenu) return acc;
8464
+ if (handle?.hideInMenu || handle?.category === "hidden") return acc;
8384
8465
  return [...acc, menuItem];
8385
8466
  }, []);
8386
8467
  }
8387
8468
  var init_generate_menu_items_from_routes = __esmMin((() => {}));
8388
8469
  //#endregion
8470
+ //#region src/router/utils/group-solution-menus.ts
8471
+ function defaultWarn(msg) {
8472
+ if (warnedOnce.has(msg)) return;
8473
+ warnedOnce.add(msg);
8474
+ console.warn(msg);
8475
+ }
8476
+ /** 取某 item 的有效归组元数据(handle 优先,行回退) */
8477
+ function effective(item, row) {
8478
+ return {
8479
+ module: item.module ?? row?.module,
8480
+ category: item.category ?? row?.category
8481
+ };
8482
+ }
8483
+ /**
8484
+ * 递归摘除 category==="admin" 的菜单项(handle 优先、行回退兜底),按 module 分桶收集;
8485
+ * 收集时压平 admin 项自身的层级——直接取 admin 项的 children,使"管理"中间级消失、
8486
+ * 子项直挂 `<module>:menu.main` 子组下,无 children 时退回 admin 项本身。
8487
+ * 其余项原样保留(children 递归同样处理),非突变;无变动的子树复用原引用。
8488
+ *
8489
+ * 返回新数组(有项被摘除/被重写父级时)或原数组引用(整段无 admin 时),便于调用方
8490
+ * 判断是否需重建 /system children(避免无 admin 项时给 /system 强加空 children)。
8491
+ */
8492
+ function stripAdmin(list, byPath, buckets) {
8493
+ let changed = false;
8494
+ const out = [];
8495
+ for (const it of list) {
8496
+ const { module, category } = effective(it, byPath.get(it.key));
8497
+ if (category === "admin" && module) {
8498
+ const subItems = it.children && it.children.length > 0 ? it.children : [it];
8499
+ const bucket = buckets.get(module) ?? {
8500
+ items: [],
8501
+ module,
8502
+ minOrder: Infinity
8503
+ };
8504
+ for (const s of subItems) bucket.items.push(s);
8505
+ bucket.minOrder = Math.min(bucket.minOrder, it.order ?? 0);
8506
+ buckets.set(module, bucket);
8507
+ changed = true;
8508
+ continue;
8509
+ }
8510
+ if (it.children && it.children.length > 0) {
8511
+ const newChildren = stripAdmin(it.children, byPath, buckets);
8512
+ if (newChildren !== it.children) {
8513
+ changed = true;
8514
+ out.push({
8515
+ ...it,
8516
+ children: newChildren
8517
+ });
8518
+ } else out.push(it);
8519
+ } else out.push(it);
8520
+ }
8521
+ return changed ? out : list;
8522
+ }
8523
+ function makeAdminGroup(module, items) {
8524
+ const paired = items.map((item) => ({
8525
+ item,
8526
+ order: item.order ?? 0
8527
+ })).sort((a, b) => a.order - b.order);
8528
+ return {
8529
+ key: `${ADMIN_PREFIX}${module}`,
8530
+ label: `${module}:menu.main`,
8531
+ children: paired.map((p) => p.item)
8532
+ };
8533
+ }
8534
+ /**
8535
+ * 归组入口。
8536
+ *
8537
+ * @param top 主区菜单项(order >= 0)
8538
+ * @param bottom 底部固定区菜单项(order < 0,含 /system)
8539
+ * @param rows 宿主方案路由行(可选):缺省或空时归组完全依赖 handle 元数据(dev 形态);
8540
+ * 提供时仅作 category 的向后兼容回退(0.2.15 已装站点),不参与新 bundle 的归组真源。
8541
+ * @param opts 选项(systemKey / onWarn)
8542
+ */
8543
+ function groupSolutionMenus(top, bottom, rows, opts = {}) {
8544
+ const systemKey = opts.systemKey ?? "/system";
8545
+ const onWarn = opts.onWarn ?? defaultWarn;
8546
+ const byPath = /* @__PURE__ */ new Map();
8547
+ for (const r of rows ?? []) if (r.category === "admin" && r.enabled !== false) byPath.set(r.path, r);
8548
+ const buckets = /* @__PURE__ */ new Map();
8549
+ const newTop = stripAdmin(top, byPath, buckets);
8550
+ const newBottom = stripAdmin(bottom, byPath, buckets);
8551
+ const adminGroups = [...buckets.entries()].map(([module, b]) => ({
8552
+ minOrder: b.minOrder,
8553
+ group: makeAdminGroup(module, b.items)
8554
+ })).sort((a, b) => a.minOrder - b.minOrder);
8555
+ let resultBottom = newBottom;
8556
+ const sysIdx = newBottom.findIndex((it) => it.key === systemKey);
8557
+ if (adminGroups.length > 0) {
8558
+ if (sysIdx !== -1) {
8559
+ const system = newBottom[sysIdx];
8560
+ resultBottom = newBottom.slice();
8561
+ resultBottom[sysIdx] = {
8562
+ ...system,
8563
+ children: [...system.children ?? [], ...adminGroups.map((g) => g.group)]
8564
+ };
8565
+ } else onWarn(`[solution-menu-grouping] system menu "${systemKey}" not found; admin items dropped from menu.`);
8566
+ }
8567
+ return {
8568
+ top: newTop,
8569
+ bottom: resultBottom
8570
+ };
8571
+ }
8572
+ var ADMIN_PREFIX, warnedOnce;
8573
+ var init_group_solution_menus = __esmMin((() => {
8574
+ ADMIN_PREFIX = "solgroup:admin:";
8575
+ warnedOnce = /* @__PURE__ */ new Set();
8576
+ }));
8577
+ //#endregion
8389
8578
  //#region src/router/utils/merge-solution-categories.ts
8390
8579
  /**
8391
- * 方案路由归并(纯函数,R21):按 path 把 sys_solution_route 行注记到已注册路由上。
8580
+ * 方案路由归并(纯函数,R21 / 0.2.16 收窄):DB 行仅作为「管理覆写层」。
8392
8581
  *
8393
- * - 三值 category 原样落 handle.category(main=常规组、admin=管理组、hidden=不进菜单);
8394
- * - hidden 与 enabled=0(禁用)复用既有 hideInMenu 通路——只过滤菜单层,路由仍可达
8395
- * (禁用的路由摘除归装载层三处同写,D12);
8396
- * - order 覆写为声明序(2026-10-20 双段序轴):框架常规项 ≥0 升序、管理面负数入
8397
- * 底部固定区(pin 侧边栏底部,ascending 双段排序)、方案项从 200 起编,共用一条序轴;
8398
- * 同 order 按根 path 字母序;
8399
- * - 未匹配路由(框架/后端)原样保留(同一对象引用),入参不突变;
8400
- * - solRoutes 为空(端点失败/未登录降级空表)时原样返回——降级不阻断,路由照常。
8582
+ * 0.2.16 起 `category`/`order`/`module` 已是路由 handle 真源(作者声明 + module-loader 注入,
8583
+ * 见 R1/R2),不再由本函数从行注写。本函数只保留一项管理语义:
8401
8584
  *
8402
- * title 不在此覆写:entry.ts 是模块元数据唯一来源(module-loader 既有惯例),
8403
- * title_key 随行下发仅供深检对比。
8585
+ * - **禁用覆写**:行 `enabled === false`(或 deprecated)时追加 `hideInMenu: true`,
8586
+ * 使该菜单项不可见(路由仍可达,D12 口径——禁用在装载层三处同写);
8587
+ * - 其余字段(category/order/module/title)一律不覆写,保持 handle 原值;
8588
+ * - 入参不突变(未命中行或无需覆写时返回同一对象引用);
8589
+ * - solRoutes 为空(端点失败/未登录/dev 零行降级)时原样返回——降级不阻断,路由照常。
8404
8590
  */
8405
8591
  function mergeSolutionCategories(routes, solRoutes) {
8406
8592
  if (solRoutes.length === 0) return routes;
8407
8593
  const byPath = new Map(solRoutes.map((r) => [r.path, r]));
8408
8594
  return routes.map((route) => {
8409
8595
  const row = route.path ? byPath.get(route.path) : void 0;
8410
- if (!row) return route;
8596
+ if (!row || row.enabled !== false) return route;
8411
8597
  return {
8412
8598
  ...route,
8413
8599
  handle: {
8414
8600
  ...route.handle,
8415
- category: row.category,
8416
- order: row.order,
8417
- hideInMenu: row.category === "hidden" || !row.enabled || (route.handle.hideInMenu ?? false)
8601
+ hideInMenu: true
8418
8602
  }
8419
8603
  };
8420
8604
  });
@@ -8471,6 +8655,42 @@ var init_resolve_login_route = __esmMin((() => {
8471
8655
  isInternalLoginRoute = (r) => r?.handle?.login === true && r?.handle?.internal === true;
8472
8656
  }));
8473
8657
  //#endregion
8658
+ //#region src/router/utils/rewrite-admin-paths.ts
8659
+ /** 解析路由绝对路径(与 generate-menu-items-from-routes 同语义) */
8660
+ function resolveAbs(path, parentAbs) {
8661
+ if (!path) return parentAbs;
8662
+ return path.startsWith("/") ? path : `${parentAbs.replace(/\/+$/, "")}/${path}`;
8663
+ }
8664
+ function rewriteAdminPaths(routes, systemKey = SYSTEM_KEY) {
8665
+ const hoisted = [];
8666
+ return [...routes.map((r) => processRoute(r, "/", systemKey, hoisted, void 0)).filter((r) => r !== null), ...hoisted];
8667
+ }
8668
+ function processRoute(route, parentAbs, systemKey, hoisted, moduleRootAbs) {
8669
+ const abs = resolveAbs(route.path, parentAbs);
8670
+ const rootAbs = moduleRootAbs ?? abs;
8671
+ if (route.handle?.category === "admin") {
8672
+ const newAbs = `${systemKey}${rootAbs}${abs.slice(rootAbs.length) || ""}`;
8673
+ hoisted.push({
8674
+ ...route,
8675
+ path: newAbs
8676
+ });
8677
+ return null;
8678
+ }
8679
+ if (!route.children || route.children.length === 0) return { ...route };
8680
+ const newChildren = [];
8681
+ for (const c of route.children) {
8682
+ const processed = processRoute(c, abs, systemKey, hoisted, rootAbs);
8683
+ if (processed !== null) newChildren.push(processed);
8684
+ }
8685
+ const result = { ...route };
8686
+ result.children = newChildren;
8687
+ return result;
8688
+ }
8689
+ var SYSTEM_KEY;
8690
+ var init_rewrite_admin_paths = __esmMin((() => {
8691
+ SYSTEM_KEY = "/system";
8692
+ }));
8693
+ //#endregion
8474
8694
  //#region src/store/access.ts
8475
8695
  /**
8476
8696
  * 切分主区/底部固定区路由(ascending 已双段排序:负 order 段整体在后)。
@@ -8495,8 +8715,10 @@ var init_access = __esmMin((() => {
8495
8715
  init_ascending();
8496
8716
  init_flatten_routes();
8497
8717
  init_generate_menu_items_from_routes();
8718
+ init_group_solution_menus();
8498
8719
  init_merge_solution_categories();
8499
8720
  init_resolve_login_route();
8721
+ init_rewrite_admin_paths();
8500
8722
  baseMenuRoutes = splitMenuRoutes(baseRoutes);
8501
8723
  initialState$1 = {
8502
8724
  wholeMenus: generateMenuItemsFromRoutes(baseMenuRoutes.top),
@@ -8510,13 +8732,15 @@ var init_access = __esmMin((() => {
8510
8732
  useAccessStore = create((set, get) => ({
8511
8733
  ...initialState$1,
8512
8734
  setAccessStore: (routes) => {
8513
- const newRoutes = ascending([...resolveLoginRoute(baseRoutes, routes, router, rootRoute), ...mergeSolutionCategories(routes, get().solutionRoutes)]);
8514
- router.patchRoutes(ROOT_ROUTE_ID, routes);
8735
+ const rewrittenRoutes = rewriteAdminPaths(routes);
8736
+ const newRoutes = ascending([...resolveLoginRoute(baseRoutes, rewrittenRoutes, router, rootRoute), ...mergeSolutionCategories(rewrittenRoutes, get().solutionRoutes)]);
8737
+ router.patchRoutes(ROOT_ROUTE_ID, rewrittenRoutes);
8515
8738
  const flatRouteList = flattenRoutes(newRoutes);
8516
8739
  const { top, bottom } = splitMenuRoutes(newRoutes);
8740
+ const grouped = groupSolutionMenus(generateMenuItemsFromRoutes(top), generateMenuItemsFromRoutes(bottom), get().solutionRoutes);
8517
8741
  const newState = {
8518
- wholeMenus: generateMenuItemsFromRoutes(top),
8519
- bottomMenus: generateMenuItemsFromRoutes(bottom),
8742
+ wholeMenus: grouped.top,
8743
+ bottomMenus: grouped.bottom,
8520
8744
  routeList: newRoutes,
8521
8745
  flatRouteList,
8522
8746
  isAccessChecked: true
@@ -8691,15 +8915,20 @@ var init_remember_route = __esmMin((() => {
8691
8915
  /**
8692
8916
  * 跳转到登录页面
8693
8917
  *
8918
+ * 必须带上部署 base(`resolveSiteBase()`):子路径部署下 `location.href = "/login"`
8919
+ * 会跳到宿主机根,而不是 `/oj/login`。不能用 `import.meta.env.BASE_URL`——预构建
8920
+ * 产物里它是构建期内联的 `/`。
8921
+ *
8694
8922
  * @returns 无返回值
8695
8923
  */
8696
8924
  function goLogin() {
8697
8925
  useAuthStore.getState().reset();
8698
- window.location.href = `/login${rememberRoute()}`;
8926
+ window.location.href = `${resolveSiteBase()}login${rememberRoute()}`;
8699
8927
  }
8700
8928
  var init_go_login = __esmMin((() => {
8701
8929
  init_auth();
8702
8930
  init_remember_route();
8931
+ init_site_base();
8703
8932
  }));
8704
8933
  //#endregion
8705
8934
  //#region src/utils/request/header-safe.ts
@@ -27814,10 +28043,11 @@ init_user();
27814
28043
  init_get_app_info();
27815
28044
  init_get_redirect_path();
27816
28045
  init_request();
28046
+ init_site_base();
27817
28047
  init_user$1();
27818
28048
  init_local_icons();
27819
28049
  init_ri();
27820
28050
  init_menu_icons();
27821
28051
  init_tree();
27822
28052
  //#endregion
27823
- 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, roleLabel, setupI18n, traverseTreeValues, unloadModule, useAccess, useAuthStore, usePreferences, useSlotNodes, useSolutionRoutes, useTabKey, useTabsStore, useUserStore, external_exports as z };
28053
+ 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 };
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * 跳转到登录页面
3
3
  *
4
+ * 必须带上部署 base(`resolveSiteBase()`):子路径部署下 `location.href = "/login"`
5
+ * 会跳到宿主机根,而不是 `/oj/login`。不能用 `import.meta.env.BASE_URL`——预构建
6
+ * 产物里它是构建期内联的 `/`。
7
+ *
4
8
  * @returns 无返回值
5
9
  */
6
10
  export declare function goLogin(): void;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 站点部署 base 的唯一取值口(子路径部署支持,2026-10-21)。
3
+ *
4
+ * 背景:runtime 作为**预构建产物**分发(`dist/runtime.js`),`import.meta.env.BASE_URL`
5
+ * 在 `packages/runtime/vite.config.ts` 的构建期就被内联成 `"/"`。于是当站点被部署到
6
+ * 宿主机子路径(如 oj 的 `mounts: [{ prefix: /oj, web: dist, spa: true }]`)时:
7
+ *
8
+ * - `createBrowserRouter({ basename: "/" })` 剥不掉 `/oj`,深链刷新一律 404 兜底页;
9
+ * - `goLogin()` / `must-change` 拼出 `/login`,落在宿主机根而不是 `/oj/login`;
10
+ * - `isLoginPathname(location.pathname)` 恒为 false,401 会重复跳登录。
11
+ *
12
+ * 解法:真正的 base 只有**部署期**才知道(构建产物被挂到哪个前缀,由 oj 的 config
13
+ * 决定),所以由宿主在 index.html 里注入 `window.__OJ_BASE__`(`ojm build` 按
14
+ * `web.config.ts` 的 `siteBase` 写入),runtime 运行期优先读它,读不到再回退到
15
+ * 构建期值。这样:
16
+ *
17
+ * - 根路径部署(绝大多数工程,以及 `ojm dev` / `ojm preview` 默认)行为完全不变;
18
+ * - 子路径部署只需改一个配置项,无需重新构建 runtime。
19
+ *
20
+ * 契约:
21
+ * - 返回值**恒以 `/` 结尾**(`import.meta.env.BASE_URL` 的既有约定,`isLoginPathname`
22
+ * 的字符串拼接依赖它),根路径恒为 `"/"`;
23
+ * - 只处理「站点 base」,**不要**拿它拼 API 前缀——API 前缀是 `VITE_API_BASE_URL`
24
+ * 与模块 scoped request 的职责,两者互相独立;
25
+ * - React 组件内不要用它拼跳转,`navigate` / `<Link to>` 走的是**路由空间**
26
+ * (basename 已被 react-router 剥离),拼上 base 反而会变成 `/oj/oj/wiki`。
27
+ */
28
+ /**
29
+ * 归一化为「以 `/` 开头且以 `/` 结尾」的形式。
30
+ *
31
+ * 接受 `oj` / `/oj` / `/oj/` / `oj/` 四种写法(配置手写极易带或不带斜杠),
32
+ * 空值与 `"/"` 一律归为根路径。
33
+ */
34
+ export declare function normalizeSiteBase(raw: string | null | undefined): string;
35
+ /**
36
+ * 当前站点的部署 base(恒以 `/` 结尾)。
37
+ *
38
+ * 优先级:`window.__OJ_BASE__`(部署期注入)> `import.meta.env.BASE_URL`(构建期值)。
39
+ *
40
+ * 每次调用都重新读取——不做模块级缓存。原因:测试需要在不重启模块的情况下模拟
41
+ * 不同部署前缀(`vi.stubGlobal`),而字符串归一化的成本可忽略。
42
+ */
43
+ export declare function resolveSiteBase(): string;
44
+ /**
45
+ * 把站内绝对路径(`/assets/x.js`)拼到站点 base 上(`/oj/assets/x.js`)。
46
+ *
47
+ * 已带 base 的路径幂等;`http(s)://`、`//cdn`、`data:` 等一切非站内绝对路径原样返回
48
+ * (模块 entry 允许是 CDN 绝对域,见 `web.config.ts` 的 `baseUrl`)。
49
+ */
50
+ export declare function withSiteBase(url: string, base?: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/runtime",
3
3
  "type": "module",
4
- "version": "0.2.14",
4
+ "version": "0.2.17",
5
5
  "description": "React Antd Admin 框架运行时,供模块工程消费",
6
6
  "license": "MIT",
7
7
  "imports": {