@oj-module/cli 0.2.28 → 0.2.30

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.28",
4
+ "version": "0.2.30",
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.28",
52
+ "@oj-module/runtime": "0.2.30",
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, getHostRoutes, loadAll, setupI18n, useAuthStore, usePreferences, useUserStore } from "@oj-module/runtime";
2
+ import { AntdApp, ErrorBoundary, LayoutEffects, PageError, getHostRoutes, getRoutes, loadAll, setupI18n, useAccessStore, 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";
@@ -231,14 +231,58 @@ async function hydrateUser() {
231
231
  /**
232
232
  * 登录/登出都会经 auth store reset() 清空 user store(宿主链无 AuthGuard 重新
233
233
  * 拉取),token 变化即重新定角色。
234
- *
235
- * 已知偏差:角色变化只影响渲染期判断,侧栏菜单是 Boot 期按当时角色登记的,
236
- * 不登录直接进站再登录的场景菜单需刷新一次才对(后端 403 硬兜底,不是权限泄漏)。
237
234
  */
238
235
  useAuthStore.subscribe((state, prev) => {
239
- if (state.token !== prev.token) hydrateUser();
236
+ if (state.token !== prev.token) hydrateUser().then(() => {
237
+ useAccessStore.getState().setAccessStore(getRoutes());
238
+ repatchShellRoutes();
239
+ });
240
240
  });
241
241
  /**
242
+ * 宿主路由根 id:patchRoutes 重登记的 parentId(与 runtime 的 ROOT_ROUTE_ID
243
+ * 同名无碍——各自作用于各自 router 实例)。
244
+ */
245
+ var SHELL_ROOT_ID = "shell-root";
246
+ /** Boot 建表后持有的宿主 router 实例;token 变化时按新角色快照重登记其路由表。 */
247
+ var shellRouter = null;
248
+ /**
249
+ * 宿主路由表 children:index 重定向 + 模块路由(getHostRoutes 同源重写)+
250
+ * catch-all 兜底。Boot 建表与登录后重登记共用同一构建口,保证两处同源。
251
+ */
252
+ function buildShellChildren() {
253
+ const hostRoutes = getHostRoutes();
254
+ return [
255
+ {
256
+ index: true,
257
+ element: /* @__PURE__ */ jsx(Navigate, {
258
+ to: hostRoutes[0]?.path ?? "/",
259
+ replace: true
260
+ })
261
+ },
262
+ ...hostRoutes,
263
+ {
264
+ path: "*",
265
+ element: /* @__PURE__ */ jsx(CatchAll, {})
266
+ }
267
+ ];
268
+ }
269
+ /**
270
+ * 按当前角色快照重登记宿主路由表(B16 回归,2026-10-24)。
271
+ *
272
+ * setAccessStore 内部的 router.patchRoutes 打的是 runtime 单例 router,而宿主
273
+ * 渲染的是本文件 Boot 里自建的 router——仅调 setAccessStore 只会更新菜单
274
+ * (access store),路由表保持 Boot 期快照。后果:Boot 期 user-info 失败
275
+ * (后端闪断/过期 token 刷新失败)→ 角色空 → requiredRoles 门禁模块被过滤出
276
+ * 路由表;随后 SPA 登录(不刷新页面)菜单回来了,但 /system/* 仍不在路由表里,
277
+ * 直达恒落 catch-all 404「返回首页」,须硬刷新才恢复。
278
+ */
279
+ function repatchShellRoutes() {
280
+ if (!shellRouter) return;
281
+ shellRouter.patchRoutes(SHELL_ROOT_ID, buildShellChildren());
282
+ const { pathname, search } = shellRouter.state.location;
283
+ shellRouter.navigate(`${pathname}${search}`, { replace: true }).catch(() => {});
284
+ }
285
+ /**
242
286
  * 直链兜底页(catch-all 路由元素)。无匹配路由时渲染框架 `PageError`,
243
287
  * 把 title 设为框架 404 文案(`exception.notFoundTitle` = "404"),
244
288
  * 观感与无权限面板同构,替代 React Router 默认的「Unexpected Application Error!」。
@@ -300,27 +344,14 @@ function Boot() {
300
344
  await hydrateUser();
301
345
  await loadAll(manifest);
302
346
  if (cancelled) return;
303
- const hostRoutes = getHostRoutes();
304
- setRouter(createBrowserRouter([{
347
+ shellRouter = createBrowserRouter([{
305
348
  path: "/",
349
+ id: SHELL_ROOT_ID,
306
350
  element: /* @__PURE__ */ jsxs(ErrorBoundary, {
307
351
  FallbackComponent: PageError,
308
352
  children: [/* @__PURE__ */ jsx(LayoutEffects, {}), /* @__PURE__ */ jsx(Outlet, {})]
309
353
  }),
310
- children: [
311
- {
312
- index: true,
313
- element: /* @__PURE__ */ jsx(Navigate, {
314
- to: hostRoutes[0]?.path ?? "/",
315
- replace: true
316
- })
317
- },
318
- ...hostRoutes,
319
- {
320
- path: "*",
321
- element: /* @__PURE__ */ jsx(CatchAll, {})
322
- }
323
- ]
354
+ children: buildShellChildren()
324
355
  }], {
325
356
  /**
326
357
  * 子路径部署(oj `mounts: [{ prefix: /oj, web: dist, spa: true }]`):
@@ -331,7 +362,8 @@ function Boot() {
331
362
  *
332
363
  * 浏览器 URL 不受影响:react-router 只是在匹配/生成时剥/补前缀。
333
364
  */
334
- basename: base }));
365
+ basename: base });
366
+ setRouter(shellRouter);
335
367
  } catch (e) {
336
368
  if (!cancelled) setError(e instanceof Error ? e.message : String(e));
337
369
  }
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.28",
307
+ "version": "0.2.30",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-24 18:30:15"
310
+ "lastBuildTime": "2026-10-24 23:41:26"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -28190,6 +28190,7 @@ init_layout_effects();
28190
28190
  init_locales();
28191
28191
  init_module_loader();
28192
28192
  init_slots();
28193
+ init_access();
28193
28194
  init_auth();
28194
28195
  init_tabs();
28195
28196
  init_user();
@@ -28203,4 +28204,4 @@ init_ri();
28203
28204
  init_menu_icons();
28204
28205
  init_tree();
28205
28206
  //#endregion
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 };
28207
+ 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, useAccessStore, 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-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" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-6NpMECPEhWQb/Ob12W+5HQ=='; 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="mh43xEkINEUP2sZNPLqVdA==">{
10
+ <script type="importmap" nonce="6NpMECPEhWQb/Ob12W+5HQ==">{
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-CtTVbohp.js"></script>
136
+ <script type="module" crossorigin src="/assets/index-FYt9CjFd.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.28",
11
- "@oj-module/runtime/contract": "0.2.28",
12
- "@oj-module/runtime/contract/errors": "0.2.28",
10
+ "@oj-module/runtime": "0.2.30",
11
+ "@oj-module/runtime/contract": "0.2.30",
12
+ "@oj-module/runtime/contract/errors": "0.2.30",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -0,0 +1,39 @@
1
+ import type { Server } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+
4
+ import http from "node:http";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { listenOnFreePort } from "../static-handler";
8
+
9
+ // 回归:listen(port, cb) 的 cb 在重试成功后以旧端口先 resolve——
10
+ // 日志「改用 5175」与「已启动 5174」并存且实际监听在 5175(2026-10-24)。
11
+ describe("listenOnFreePort", () => {
12
+ const cleanup: Server[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const s of cleanup.splice(0))
16
+ s.close();
17
+ });
18
+
19
+ it("占用首端口时顺延,返回端口与实际监听端口一致", async () => {
20
+ const occupier = http.createServer(() => {}).listen(0);
21
+ cleanup.push(occupier);
22
+ await new Promise<void>(r => occupier.on("listening", r));
23
+ const busy = (occupier.address() as AddressInfo).port;
24
+
25
+ const server = http.createServer(() => {});
26
+ cleanup.push(server);
27
+ const port = await listenOnFreePort(server, busy);
28
+ expect(port).toBe(busy + 1);
29
+ // 旧 bug 在此暴露:resolve 的是 busy,实际却监听在 busy+1
30
+ expect((server.address() as AddressInfo).port).toBe(port);
31
+ });
32
+
33
+ it("首端口空闲时原样监听", async () => {
34
+ const server = http.createServer(() => {});
35
+ cleanup.push(server);
36
+ const port = await listenOnFreePort(server, 0);
37
+ expect((server.address() as AddressInfo).port).toBe(port);
38
+ });
39
+ });
package/src/build.ts CHANGED
@@ -106,6 +106,7 @@ export const accessControlCodes = _obj;
106
106
  export const AccessControlRoles = _obj;
107
107
  export const permissionPrefix = "permission:button";
108
108
  // store
109
+ export const useAccessStore = _fn;
109
110
  export const useAuthStore = _fn;
110
111
  // 方案路由归并 store(token 就绪自动重拉,R21)
111
112
  export const useSolutionRoutes = _fn;
@@ -229,7 +229,14 @@ export function createStaticHandler(opts: StaticHandlerOptions): (req: http.Inco
229
229
  export function listenOnFreePort(server: http.Server, startPort: number, maxTries = 10): Promise<number> {
230
230
  return new Promise((resolve, reject) => {
231
231
  const attempt = (port: number) => {
232
- server.once("error", (err: NodeJS.ErrnoException) => {
232
+ // 回调手动挂/摘(listen(port, cb) 的 cb 摘不掉前次失败的滞留闭包,
233
+ // 重试成功时会以旧端口先 resolve——日志与实listen端口漂移,2026-10-24 实测)
234
+ function cleanup() {
235
+ server.off("error", onError);
236
+ server.off("listening", onListen);
237
+ }
238
+ function onError(err: NodeJS.ErrnoException) {
239
+ cleanup();
233
240
  if (err.code === "EADDRINUSE" && port - startPort < maxTries) {
234
241
  console.warn(`[ojm] 端口 ${port} 已被占用,改用 ${port + 1}`);
235
242
  attempt(port + 1);
@@ -237,8 +244,16 @@ export function listenOnFreePort(server: http.Server, startPort: number, maxTrie
237
244
  else {
238
245
  reject(err);
239
246
  }
240
- });
241
- server.listen(port, () => resolve(port));
247
+ }
248
+ function onListen() {
249
+ cleanup();
250
+ // 取实绑端口(startPort=0 时 OS 自分随机端口,闭包 port 不可信)
251
+ const addr = server.address();
252
+ resolve(typeof addr === "object" && addr ? addr.port : port);
253
+ }
254
+ server.once("error", onError);
255
+ server.once("listening", onListen);
256
+ server.listen(port);
242
257
  };
243
258
  attempt(startPort);
244
259
  });
@@ -73,8 +73,11 @@ export default defineModule({
73
73
  peerRuntime: ">=0.0.0",
74
74
  // 路由级门禁(B16):非系统管理员不注入 /system 路由、菜单不可见;
75
75
  // API 级 403 由 assertSystemAdmin + service 层硬 enforcement 兜底。
76
- // 角色口径对齐后端 assertSystemAdmin(access.ts:90)——superadmin **或**
77
- // admin 均可;seed 的 admin 账号只有 superadmin,写 ["admin"] 会把它自己挡在门外。
76
+ // 角色口径对齐后端 assertSystemAdmin(access.ts:87)——superadmin **或**
77
+ // admin 均可,两者必须同时保留:D12(2026-10-22)起账号名=角色名,admin 与
78
+ // superadmin 是两个独立账号(seed.sql),只留 ["admin"] 会把 superadmin 挡在
79
+ // 门外(注册期过滤 → 路由消失 → 登录后 404 兜底)。改这两个值前先跑
80
+ // tests/system-guard.regression.spec.ts(只留 ["admin"] 会立刻变红)。
78
81
  config: { requiredRoles: ["admin", "superadmin"] },
79
82
  routes,
80
83
  i18n: {
@@ -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.28",
12
- "@oj-module/runtime/contract": "0.2.28",
13
- "@oj-module/runtime/contract/errors": "0.2.28",
11
+ "@oj-module/runtime": "0.2.30",
12
+ "@oj-module/runtime/contract": "0.2.30",
13
+ "@oj-module/runtime/contract/errors": "0.2.30",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",