generator-mico-cli 0.2.28 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +5 -20
  2. package/bin/mico.js +27 -62
  3. package/generators/micro-react/index.js +8 -0
  4. package/generators/micro-react/templates/.cursor/rules/always-read-docs.mdc +3 -0
  5. package/generators/micro-react/templates/.cursor/rules/project-overview.mdc +1 -0
  6. package/generators/micro-react/templates/CLAUDE.md +1 -0
  7. package/generators/micro-react/templates/README.md +1 -1
  8. package/generators/micro-react/templates/apps/layout/config/config.dev.ts +4 -2
  9. package/generators/micro-react/templates/apps/layout/config/config.prod.development.ts +2 -0
  10. package/generators/micro-react/templates/apps/layout/config/config.prod.testing.ts +2 -0
  11. package/generators/micro-react/templates/apps/layout/config/config.prod.ts +2 -0
  12. package/generators/micro-react/templates/apps/layout/docs/feature-PermissionFilter/346/214/211/351/222/256/346/235/203/351/231/220.md +116 -0
  13. package/generators/micro-react/templates/apps/layout/docs/feature-/350/217/234/345/215/225/346/235/203/351/231/220/346/216/247/345/210/266.md +83 -77
  14. package/generators/micro-react/templates/apps/layout/docs/feature-/350/267/257/347/224/261/344/270/216/350/217/234/345/215/225/350/247/243/350/200/246.md +50 -35
  15. package/generators/micro-react/templates/apps/layout/docs/feature-/350/267/257/347/224/261/346/235/203/351/231/220/346/227/245/345/277/227.md +162 -0
  16. package/generators/micro-react/templates/apps/layout/mock/api.mock.ts +23 -31
  17. package/generators/micro-react/templates/apps/layout/mock/pages.ts +5 -6
  18. package/generators/micro-react/templates/apps/layout/package.json +2 -1
  19. package/generators/micro-react/templates/apps/layout/src/app.tsx +30 -2
  20. package/generators/micro-react/templates/apps/layout/src/common/auth/type.ts +15 -27
  21. package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +148 -85
  22. package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +2 -6
  23. package/generators/micro-react/templates/apps/layout/src/common/portal-data.ts +46 -2
  24. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +5 -1
  25. package/generators/micro-react/templates/apps/layout/src/components/PermissionFilter/index.tsx +51 -0
  26. package/generators/micro-react/templates/apps/layout/src/components/RightContent/AvatarDropdown.tsx +10 -1
  27. package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +3 -3
  28. package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +105 -60
  29. package/generators/micro-react/templates/apps/layout/src/locales/en-US.ts +17 -0
  30. package/generators/micro-react/templates/apps/layout/src/locales/zh-CN.ts +16 -0
  31. package/generators/micro-react/templates/apps/layout/src/pages/404/index.tsx +7 -3
  32. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.less +5 -0
  33. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.tsx +49 -1
  34. package/generators/micro-react/templates/apps/layout/src/services/user.ts +28 -21
  35. package/generators/micro-react/templates/packages/common-intl/README.md +77 -369
  36. package/generators/micro-react/templates/packages/common-intl/package.json +3 -13
  37. package/generators/micro-react/templates/packages/common-intl/src/index.ts +3 -6
  38. package/generators/micro-react/templates/packages/common-intl/src/intl.ts +20 -29
  39. package/generators/micro-react/templates/packages/common-intl/tsconfig.json +2 -4
  40. package/generators/subapp-react/index.js +28 -22
  41. package/generators/subapp-react/templates/homepage/README.md +1 -0
  42. package/generators/subapp-react/templates/homepage/config/config.prod.development.ts +1 -0
  43. package/generators/subapp-react/templates/homepage/config/config.prod.testing.ts +1 -0
  44. package/generators/subapp-react/templates/homepage/config/config.prod.ts +1 -0
  45. package/generators/subapp-react/templates/homepage/docs/feature-PermissionFilter/346/214/211/351/222/256/346/235/203/351/231/220.md +35 -0
  46. package/generators/subapp-react/templates/homepage/package.json +2 -1
  47. package/generators/subapp-react/templates/homepage/src/app.tsx +7 -0
  48. package/generators/subapp-react/templates/homepage/src/common/mainApp.ts +39 -2
  49. package/generators/subapp-react/templates/homepage/src/components/PermissionFilter/index.tsx +48 -0
  50. package/generators/subapp-react/templates/homepage/src/pages/index.tsx +35 -1
  51. package/lib/utils.js +0 -1
  52. package/package.json +2 -2
  53. package/generators/micro-react/templates/apps/layout/docs/common-intl.md +0 -372
  54. package/generators/micro-react/templates/packages/common-intl/src/indexedDBUtils.ts +0 -51
  55. package/generators/micro-react/templates/packages/common-intl/src/utils.ts +0 -482
  56. package/generators/micro-react/templates/packages/common-intl/vite.config.ts +0 -25
@@ -1,10 +1,10 @@
1
1
  # 菜单权限控制
2
2
 
3
- > 创建时间:2026-01-24 更新时间:2026-02-08
3
+ > 创建时间:2026-01-24 更新时间:2026-03-27(同步 `getMenuPage`:pageId 优先,path 兜底)
4
4
 
5
5
  ## 功能概述
6
6
 
7
- 基于用户信息中的 `side_menus` 字段实现菜单和路由的白名单权限控制。非超级用户只能看到和访问 `side_menus` 中配置的菜单项,访问无权限路由时显示 403 页面。
7
+ 基于用户信息中的 **`menu_perms`**(菜单权限 code 数组,与 OpenAPI 8 `GET /user/info/` 一致)实现菜单和路由权限。非超级用户仅当 **`menu_perms` 包含对应 code** 时可见菜单项、可访问受控动态路由(`accessControlEnabled && routeKey` 时要求 `routeKey menu_perms`)。访问无权限路由时显示 403 页面。
8
8
 
9
9
  **v2 更新**:支持认证(Authentication)与授权(Authorization)分离配置,可独立控制"跳过 SSO 登录"和"跳过菜单权限校验"。
10
10
 
@@ -54,22 +54,21 @@
54
54
  │ 2. 权限校验 (layouts/index.tsx) │
55
55
  │ isNoPermissionRoute(pathname)? │
56
56
  │ ├── 是 → 跳过权限校验 │
57
- │ └── 否 → 双层权限判断
58
- Tier 1: 页面在菜单中?
59
- ├── 沿用 sideMenus
60
- │ │ 白名单逻辑 │
61
- │ └── 否 → Tier 2: 页面级权限 │
62
- │ adminOnly / routeKey│
57
+ │ └── 否 → PAGES 页面:adminOnly /
58
+ accessControlEnabled+routeKey
59
+ menu_perms;无 page
60
+ 按菜单过滤结果兜底
63
61
  │ 详见:路由与菜单解耦文档 │
64
62
  └──────────────────────────────────────────┘
65
63
 
66
64
 
67
65
  ┌──────────────────────────────────────────┐
68
66
  │ 3. 菜单渲染 (menu/index.tsx) │
69
- │ 按 side_menus 过滤
67
+ │ 按 menu_perms 过滤(菜单项 code
68
+ │ 与关联页 routeKey / nameKey 一致) │
70
69
  │ 每个菜单项独立检查 isNoPermissionRoute│
71
70
  │ ├── 匹配 → 该项始终显示 │
72
- │ └── 不匹配 → sideMenus 白名单过滤
71
+ │ └── 不匹配 → code menu_perms
73
72
  └──────────────────────────────────────────┘
74
73
 
75
74
 
@@ -111,23 +110,20 @@ page.accessControlEnabled === false?
111
110
  ├── 是 → 允许访问所有页面
112
111
 
113
112
 
114
- Tier 1: 页面在菜单中?
115
- ├── 是 → 沿用菜单权限逻辑:
116
- 菜单项 adminOnly === true?
117
- ├── → 禁止访问
118
- 检查 side_menus 白名单
119
- │ ├── 匹配允许访问
120
- │ └── 不匹配显示 403
113
+ 能关联到 PAGES 页面配置?
114
+ ├── 是 → adminOnly === true?
115
+ ├── 403
116
+ accessControlEnabled === true?
117
+ ├── → routeKey ∈ menu_perms?
118
+ ├── 允许
119
+ └── → 403
120
+ │ └── 否 → 允许
121
121
 
122
122
 
123
- Tier 2: 隐藏页面(不在菜单中)
124
- adminOnly === true?
125
- ├── 是 → 显示 403
126
- accessControlEnabled === true?
127
- ├── 是 → 检查 routeKey ∈ side_menus
128
- │ ├── 匹配 → 允许访问
129
- │ └── 不匹配 → 显示 403
130
- └── 否 → 允许访问
123
+ 路径仅在菜单路由中、无 page 元数据
124
+ └── filterMenuItems 后的路由集中?
125
+ ├── 是 → 允许
126
+ └── → 403
131
127
  ```
132
128
 
133
129
  ## 文件清单
@@ -144,7 +140,8 @@ Tier 2: 隐藏页面(不在菜单中)
144
140
  | --- | --- |
145
141
  | `src/common/menu/types.ts` | 新增 `noPermissionRouteList` 类型定义 |
146
142
  | `src/constants/index.ts` | 新增 `isNoPermissionRoute`、`getNoPermissionRouteList` 函数 |
147
- | `src/common/menu/parser.ts` | 新增 `filterMenuItems`、`isMenuAllowed` 等权限过滤函数 |
143
+ | `src/common/portal-data.ts` | `getMenuPage`:pageId 优先,无匹配再用 path 与页面 route |
144
+ | `src/common/menu/parser.ts` | `filterMenuItems`、`getMenuItemPermCode` 等,消费 `getMenuPage` |
148
145
  | `src/layouts/index.tsx` | 新增 `isForbidden` 判断,集成 `isNoPermissionRoute` |
149
146
  | `src/layouts/components/menu/index.tsx` | 集成菜单过滤,支持免权限校验路由 |
150
147
  | `src/components/MicroAppLoader/index.tsx` | 集成 `isNoPermissionRoute`,免权限路由直接加载 |
@@ -194,33 +191,43 @@ function isAuthDisabled(): boolean;
194
191
  interface MenuFilterOptions {
195
192
  /** 是否是超级用户 */
196
193
  isSuperuser?: boolean | number;
197
- /** 允许访问的菜单路径列表(白名单) */
198
- sideMenus?: string[];
194
+ /** 用户拥有的菜单权限 code(与页面 routeKey、菜单项一致) */
195
+ menuPerms?: string[];
199
196
  }
200
197
  ```
201
198
 
199
+ ### 菜单项如何关联到页面(与 `routeKey` / `menu_perms`)
200
+
201
+ `page` 类型菜单项的权限 code 来自关联页的 **`routeKey`**(当该页 `accessControlEnabled && routeKey` 时)。关联页由 **`getMenuPage(item)`**(`src/common/portal-data.ts`)解析:
202
+
203
+ 1. **`pageId`**:在 `__MICO_PAGES__` 中按 `id` 查找,**命中则使用该页面**(与菜单 `path` 是否一致无关)。
204
+ 2. **`path` + 页面 `route`**:上一步无结果时,用 `findPageByPath` 将菜单 `path` 与页面 `route` 匹配(精确 + `/*` 通配)。
205
+
206
+ 详见 [路由与菜单解耦](./feature-路由与菜单解耦.md) 文档内「菜单项关联页面(getMenuPage)」小节。
207
+
202
208
  ### filterMenuItems
203
209
 
204
210
  ```typescript
205
211
  /**
206
- * 根据权限过滤菜单项(白名单逻辑)
212
+ * 根据 menu_perms 过滤菜单项
213
+ * - page:关联页由 getMenuPage → routeKey(需校验时);link:nameKey
207
214
  */
208
- function filterMenuItems(
209
- items: MenuItem[],
210
- options?: MenuFilterOptions,
211
- parentPath?: string,
212
- ): MenuItem[];
215
+ function filterMenuItems(items: MenuItem[], options?: MenuFilterOptions): MenuItem[];
213
216
  ```
214
217
 
215
218
  ### 用户信息相关字段
216
219
 
217
220
  ```typescript
218
221
  interface IUserInfo {
219
- is_superuser: boolean | number;
220
- /** 允许访问的菜单路径列表 */
221
- side_menus: string[];
222
- /** 缺失的操作权限(用于按钮级别控制,非菜单) */
223
- miss_permissions: string[];
222
+ is_superuser: boolean;
223
+ menu_perms: string[];
224
+ button_perms: string[];
225
+ app_perms: string[];
226
+ region_perms: string[];
227
+ name: string;
228
+ user_name: string;
229
+ username: string;
230
+ // …见 src/common/auth/type.ts IUserInfo / IUserInfoApiData
224
231
  }
225
232
  ```
226
233
 
@@ -264,43 +271,41 @@ export const NO_AUTH_ROUTE_LIST: string[] = [
264
271
  export const NO_PERMISSION_ROUTE_LIST: string[] = ['/403', '/404'];
265
272
  ```
266
273
 
267
- ### 菜单过滤结果
274
+ ### 菜单过滤结果(示意)
268
275
 
269
276
  ```
270
- 原始菜单:
271
- ├── 工作台 精确匹配 "工作台"
272
- ├── 列队管理 ✅ 前缀匹配 "列队管理.配置队列"
273
- │ └── 配置队列 精确匹配 "列队管理.配置队列"
274
- ├── 质量管理 不在白名单
275
- │ └── 抽样检查 ❌ 不在白名单
276
- └── 权限管理 ❌ adminOnly=true,非超级用户不可见
277
+ menu_perms 含 code "a"、"b.c"
278
+ ├── 页面菜单(routeKey=a)
279
+ ├── 页面菜单(routeKey 不在列表)
280
+ ├── 分组(仅容器,有可见子项) 作为父级保留
281
+ └── adminOnly 菜单 非超管
277
282
  ```
278
283
 
279
284
  ### Layout 中的权限判断
280
285
 
281
286
  ```tsx
282
- // layouts/index.tsx — 双层权限判断
287
+ // layouts/index.tsx — 以 PAGES + menu_perms 为主,无 page 时菜单路由兜底
283
288
  const isForbidden = useMemo(() => {
284
289
  if (isAuthDisabled()) return false;
285
290
  if (isNoPermissionRoute(location.pathname)) return false;
286
- if (!currentRoute) return false; // 非动态路由
291
+ if (!currentRoute) return false;
287
292
  if (isSuperuserUser(currentUser?.is_superuser)) return false;
288
293
 
289
- // Tier 1: 菜单权限交叉引用
294
+ const page =
295
+ currentRoute.pageConfig ??
296
+ (hasPages() ? findPageByPath(getPages(), location.pathname) : undefined);
297
+
298
+ if (page) {
299
+ if (page.adminOnly) return true;
300
+ if (!page.accessControlEnabled) return false;
301
+ const menuPerms = currentUser?.menu_perms || [];
302
+ return !page.routeKey || !menuPerms.includes(page.routeKey);
303
+ }
304
+
290
305
  const inAllMenu = findRouteByPath(allMenuRoutes, location.pathname);
291
306
  if (inAllMenu) {
292
307
  return !findRouteByPath(allowedMenuRoutes, location.pathname);
293
308
  }
294
-
295
- // Tier 2: 隐藏页面级权限
296
- if (!hasWindowPages()) return false;
297
- const page = findPageByPath(getWindowPages(), location.pathname);
298
- if (!page) return false;
299
- if (page.adminOnly) return true;
300
- if (page.accessControlEnabled) {
301
- const sideMenus = (currentUser?.side_menus || []) as string[];
302
- return !page.routeKey || !sideMenus.includes(page.routeKey);
303
- }
304
309
  return false;
305
310
  }, [...]);
306
311
  ```
@@ -323,6 +328,7 @@ if (!isAuthReady) {
323
328
  ```
324
329
 
325
330
  判断优先级:
331
+
326
332
  1. `isAuthDisabled()` — 全局关闭权限,直接放行
327
333
  2. `isPageAuthFree` — **PAGES 数据驱动**,页面 `accessControlEnabled === false` 时跳过认证和权限校验
328
334
  3. `isNoAuthRoute()` — 静态配置兜底,免认证路由(PAGES 未注入时的降级保护)
@@ -341,9 +347,9 @@ if (!isAuthReady) {
341
347
  | --- | --- | --- |
342
348
  | accessControlEnabled 统一控制 | `false` 同时跳过认证和授权 | PAGES 数据驱动,一个字段即可标记公开页面,无需重复配置 noAuthRouteList |
343
349
  | 认证与授权分离 | 两个独立配置项 | 不同场景需要不同组合,如"需要登录但不需要权限"的个人设置页 |
344
- | 权限模型 | 白名单 (`side_menus`) | 后端返回的 `side_menus` 是允许列表,比黑名单更安全 |
350
+ | 权限模型 | 白名单 (`menu_perms`) | 后端返回的 code 列表为允许集合;与 `routeKey` / 菜单项一一对应 |
345
351
  | 403 处理 | 原地渲染组件 | 保持 URL 不变,用户体验更好,便于分享链接 |
346
- | 父级菜单显示 | 前缀匹配 | 子菜单有权限时,父级菜单需要作为容器显示 |
352
+ | 父级菜单显示 | 子项驱动 | `group` 无独立 code,有可见子项时保留为容器 |
347
353
  | 超级用户 | 跳过所有检查 | 管理员需要完整访问权限 |
348
354
  | 免权限路由的菜单 | 按菜单项独立判断 | 每个菜单项根据自身路由是否匹配 noPermissionRouteList 独立决定显示,避免菜单随当前页面变化 |
349
355
  | 免认证路由的子应用 | 直接加载 | 不等待 currentUser,免认证路由本身不需要登录 |
@@ -370,9 +376,9 @@ if (!isAuthReady) {
370
376
  2. 检查 isNoPermissionRoute() → 在免权限列表中则允许
371
377
  3. 检查 currentRoute(PAGES 中的路由)→ 不存在则非动态路由,交给 Umi
372
378
  4. 检查 isSuperuserUser() → 超级用户放行
373
- 5. Tier 1: 在菜单中检查 allowedMenuRoutes
374
- 6. Tier 2: 不在菜单(隐藏页面)→ 检查 adminOnly + accessControlEnabled + routeKey
375
- 7. 都不匹配 → 放行
379
+ 5. PAGES 页面元数据adminOnly / accessControlEnabled + routeKey ∈ menu_perms
380
+ 6. 无页面元数据但在菜单路由中 检查 allowedMenuRoutes(已按 menu_perms 过滤)
381
+ 7. 其他 → 放行
376
382
  ```
377
383
 
378
384
  ### 设计理由
@@ -384,15 +390,15 @@ if (!isAuthReady) {
384
390
 
385
391
  ### 影响
386
392
 
387
- 当用户 `is_superuser=false` 且 `side_menus=[]` 时:
393
+ 当用户 `is_superuser=false` 且 `menu_perms=[]` 时:
388
394
 
389
- | 路由 | 结果 | 原因 |
390
- | --- | --- | --- |
391
- | `/` (Home) | ✅ 正常显示 | 静态路由,不受权限控制 |
392
- | `/403` | ✅ 正常显示 | 在 `noPermissionRouteList` 中 |
393
- | `/404` | ✅ 正常显示 | 在 `noPermissionRouteList` 中 |
394
- | `/user/login` | ✅ 正常显示 | 静态路由 + 在 `noAuthRouteList` 中 |
395
- | `/queue-management` | ❌ 显示 403 | 动态路由,无权限 |
395
+ | 路由 | 结果 | 原因 |
396
+ | ------------------- | ----------- | ---------------------------------- |
397
+ | `/` (Home) | ✅ 正常显示 | 静态路由,不受权限控制 |
398
+ | `/403` | ✅ 正常显示 | 在 `noPermissionRouteList` 中 |
399
+ | `/404` | ✅ 正常显示 | 在 `noPermissionRouteList` 中 |
400
+ | `/user/login` | ✅ 正常显示 | 静态路由 + 在 `noAuthRouteList` 中 |
401
+ | `/queue-management` | ❌ 显示 403 | 动态路由,无权限 |
396
402
 
397
403
  ### 如需控制静态路由
398
404
 
@@ -406,11 +412,11 @@ if (!isAuthReady) {
406
412
 
407
413
  - `noAuthRouteList` 和 `noPermissionRouteList` 是独立的,需要根据场景分别配置
408
414
  - 两个列表都支持 `/*` 后缀进行前缀匹配
409
- - `side_menus` 为空时,非超级用户没有任何菜单权限
410
- - `side_menus` 格式为菜单路径,如 `"列队管理.配置队列"`
411
- - `miss_permissions` 用于按钮级别权限控制,不影响菜单显示
415
+ - `menu_perms` 为空时,非超级用户没有任何菜单权限(`page`/`link` 项)
416
+ - 菜单项权限 code 与页面 `routeKey` 一致;`page` 类型先通过 **`getMenuPage`** 得到关联页再取 `routeKey`,否则回退 `nameKey`(link)
417
+ - `button_perms` 可用于按钮级权限(layout 侧栏不直接消费)
412
418
  - 403 页面在 Layout 内渲染,不会触发路由跳转
413
- - 调试时可在控制台搜索 `isForbidden (menu check)` `isForbidden (hidden page` 查看权限判断日志
419
+ - 调试时可在控制台过滤 `routePermission` 查看结构化权限日志(详见 [路由权限日志](./feature-路由权限日志.md))
414
420
  - **常见问题**:配置了 `noAuthRouteList` 但页面仍显示 403,需同时配置 `noPermissionRouteList`
415
421
 
416
422
  ## 相关文档
@@ -1,10 +1,10 @@
1
1
  # 路由与菜单解耦
2
2
 
3
- > 创建时间:2026-02-25
3
+ > 创建时间:2026-02-25 更新时间:2026-03-27
4
4
 
5
5
  ## 功能概述
6
6
 
7
- 将动态路由注册与菜单导航的数据源解耦。路由注册消费 `window.__MICO_PAGES__`(页面列表),菜单栏消费 `window.__MICO_MENUS__`(菜单树)。两者独立运作,权限通过菜单交叉引用 + 页面级兜底的双层策略控制。
7
+ 将动态路由注册与菜单导航的数据源解耦。路由注册消费 `window.__MICO_PAGES__`(页面列表),菜单栏消费 `window.__MICO_MENUS__`(菜单树)。两者独立运作,**路由与侧栏权限**统一以用户信息中的 **`menu_perms`(菜单 code 列表)** 为准,与页面 **`routeKey`**、菜单项权限 code **一一对应**;无页面元数据时仍可按「菜单中是否可见」兜底。
8
8
 
9
9
  ## 技术方案
10
10
 
@@ -22,9 +22,20 @@ __MICO_PAGES__ (扁平列表,所有页面)
22
22
  └── 隐藏页面(不在菜单中显示,但路由可访问)
23
23
 
24
24
  __MICO_MENUS__ (菜单树,用于导航)
25
- └── page 字段 引用 __MICO_PAGES__ 中的某个页面
25
+ └── `page` 类型项通过 pageId / path 关联到 __MICO_PAGES__(见下方「菜单项关联页面」)
26
26
  ```
27
27
 
28
+ ### 菜单项关联页面(`getMenuPage`)
29
+
30
+ 侧栏过滤、从菜单树提取路由(`extractRoutes`)、`routeKey` / `menu_perms` 等需要「菜单项 → 页面配置」时,统一由 `src/common/portal-data.ts` 的 **`getMenuPage(item)`** 解析:
31
+
32
+ | 顺序 | 规则 | 说明 |
33
+ | --- | --- | --- |
34
+ | 1 | **`pageId` → `getPageById`** | 菜单项带 `pageId` 且在 `__MICO_PAGES__` 中存在对应 `id` 时,**优先使用该页面** |
35
+ | 2 | **`path` + `findPageByPath`** | 上一步无结果时(无 `pageId`、或 id 在列表中不存在),用菜单 **`path`** 与页面 **`route`** 匹配(精确 + 最长前缀 `/*`),与路由侧解析规则一致 |
36
+
37
+ 仅 `type === 'page'` 的菜单项会走上述逻辑;`findPageByPath` 仅匹配 **`enabled === true`** 的页面。
38
+
28
39
  ### 数据流
29
40
 
30
41
  ```
@@ -35,7 +46,7 @@ Before:
35
46
 
36
47
  After:
37
48
  patchClientRoutes ← getDynamicRoutes() → PAGES 优先,降级 MENUS
38
- layouts 权限校验 ← getDynamicRoutes() + extractRoutes(MENUS) + filterMenuItems(MENUS)
49
+ layouts 权限校验 ← PAGES routeKey menu_perms;无 page 时 allowedMenuRoutes 兜底
39
50
  菜单渲染 ← parseMenuItems(MENUS) ← 不变
40
51
  ```
41
52
 
@@ -53,26 +64,22 @@ page.accessControlEnabled === false?
53
64
  ├── 是 → 跳过 SSO 认证 + 跳过权限校验(公开页面)
54
65
 
55
66
 
56
- 非动态路由(不在 PAGES 中)?
67
+ 非动态路由(currentRoute 不在 PAGES 动态列表中)?
57
68
  ├── 是 → 交给 Umi 处理(404 等)
58
69
 
59
70
 
60
- ┌─────────────────────────────────────────────┐
61
- │ Tier 1: 菜单权限交叉引用 │
62
- 页面在 __MICO_MENUS__ 中? │
63
- ├── 沿用 sideMenus 白名单逻辑 │
64
- allowedMenuRoutes 中? │
65
- ├── 是 → 放行 │
66
- │ └── 否 → 403 │
67
- │ └── → 进入 Tier 2 │
68
- ├─────────────────────────────────────────────┤
69
- Tier 2: 隐藏页面级权限 │
70
- (仅 PAGES 数据可用时生效) │
71
- │ adminOnly: true 403 │
72
- │ accessControlEnabled: true │
73
- │ → routeKey ∈ sideMenus ? 放行 : 403 │
74
- │ 其他 → 放行 │
75
- └─────────────────────────────────────────────┘
71
+ 能解析到 PAGES 中的页面配置?
72
+ ├── → adminOnly → 403
73
+ accessControlEnabled && routeKey
74
+ routeKey menu_perms ? 放行 : 403
75
+ 其他 放行
76
+
77
+
78
+ 路径仅在菜单路由中出现(无 page 元数据)?
79
+ ├── 是 → 在 filterMenuItems(menu_perms) 后的 allowedMenuRoutes 中?
80
+ ├── → 放行
81
+ └── → 403
82
+ └── 放行(非本逻辑覆盖)
76
83
  ```
77
84
 
78
85
  ### 降级策略
@@ -93,9 +100,10 @@ getDynamicRoutes():
93
100
  | 文件路径 | 修改内容 |
94
101
  | --- | --- |
95
102
  | `src/common/menu/types.ts` | `PageConfig` 新增 `accessControlEnabled`、`routeKey` 字段;新增 `PublicPageItem` 类型(`Omit<PageConfig, ...>`);Window 声明新增 `__MICO_PAGES__` |
96
- | `src/common/menu/parser.ts` | 新增 `getWindowPages`、`hasWindowPages`、`extractRoutesFromPages`、`getDynamicRoutes`、`findPageByPath` 函数;导出 `isSuperuserUser` |
103
+ | `src/common/portal-data.ts` | `findPageByPath`、`getMenuPage`(pageId 优先,其次 path 匹配页面 route) |
104
+ | `src/common/menu/parser.ts` | `extractRoutesFromPages`、`getDynamicRoutes`;从 portal-data 再导出 `findPageByPath`;菜单过滤与 `extractRoutes` 消费 `getMenuPage` |
97
105
  | `src/app.tsx` | `patchClientRoutes` 改为调用 `getDynamicRoutes()` |
98
- | `src/layouts/index.tsx` | 路由匹配改用 `allPageRoutes`;权限判断改为双层逻辑(菜单交叉引用 + 隐藏页面级兜底) |
106
+ | `src/layouts/index.tsx` | 路由匹配 `allPageRoutes`;权限以 `menu_perms` + `routeKey` 为主,无 page 时菜单路由兜底 |
99
107
 
100
108
  ### 未修改文件
101
109
 
@@ -119,16 +127,19 @@ type PublicPageItem = Omit<
119
127
  | 字段 | 类型 | 说明 |
120
128
  | --- | --- | --- |
121
129
  | `accessControlEnabled` | `boolean` | 是否开启访问控制。`false` 时跳过 SSO 认证和权限校验(公开页面);`true` 时需要登录且通过 `routeKey` 校验权限 |
122
- | `routeKey` | `string \| null` | 路由权限标识(用于匹配 sideMenus) |
130
+ | `routeKey` | `string \| null` | 路由权限标识,与用户信息 `menu_perms`、菜单项权限 code 一致 |
123
131
 
124
- ### 页面数据函数
132
+ ### 页面数据函数(`portal-data.ts` / `parser.ts`)
125
133
 
126
134
  ```typescript
127
135
  /** 获取 window.__MICO_PAGES__ */
128
- function getWindowPages(): PublicPageItem[];
136
+ function getPages(): PublicPageItem[];
129
137
 
130
138
  /** 判断页面数据是否可用 */
131
- function hasWindowPages(): boolean;
139
+ function hasPages(): boolean;
140
+
141
+ /** 菜单项 → 关联页面:pageId 优先,无匹配再用 path 与页面 route 匹配 */
142
+ function getMenuPage(item: MenuItem): PublicPageItem | undefined;
132
143
 
133
144
  /** 从页面数据提取路由配置 */
134
145
  function extractRoutesFromPages(pages: PublicPageItem[]): ParsedRoute[];
@@ -136,8 +147,11 @@ function extractRoutesFromPages(pages: PublicPageItem[]): ParsedRoute[];
136
147
  /** 获取动态路由(PAGES 优先,降级 MENUS) */
137
148
  function getDynamicRoutes(): ParsedRoute[];
138
149
 
139
- /** 根据路径查找页面配置(精确匹配 + 通配符) */
140
- function findPageByPath(pages: PublicPageItem[], pathname: string): PublicPageItem | undefined;
150
+ /** 根据路径查找页面配置(精确匹配 + 最长前缀 `/*`) */
151
+ function findPageByPath(
152
+ pages: PublicPageItem[],
153
+ pathname: string,
154
+ ): PublicPageItem | undefined;
141
155
  ```
142
156
 
143
157
  ### Window 全局变量
@@ -156,24 +170,25 @@ interface Window {
156
170
  | 决策点 | 选择 | 理由 |
157
171
  | --- | --- | --- |
158
172
  | 路由数据源 | `__MICO_PAGES__` 独立于菜单 | 页面和菜单是不同维度:页面定义"有什么路由",菜单定义"导航怎么组织";解耦后支持隐藏页面 |
159
- | 权限方案 | 菜单交叉引用 + 页面级兜底 | 菜单中的页面复用现有 sideMenus 白名单逻辑,改动最小;隐藏页面用 adminOnly + accessControlEnabled 兜底 |
173
+ | 权限方案 | `menu_perms` + `routeKey` | 与后端新用户信息接口一致;菜单项 code `menu_perms` 一一对应 |
160
174
  | 降级策略 | `getDynamicRoutes()` 自动降级 | `__MICO_PAGES__` 未注入时回退到旧行为,零配置向后兼容 |
161
175
  | PublicPageItem 定义 | `Omit<PageConfig, ...>` 派生 | 保持与 PageConfig 的类型继承关系,避免字段重复定义和类型漂移 |
162
176
  | accessControlEnabled 语义 | `false` = 公开页面(跳过认证+授权),`true` = 需要权限检查 | 一个字段统一控制 SSO 认证、权限校验、子应用加载等待,减少配置冗余 |
163
177
  | 隐藏页面权限 | 先 adminOnly 再 accessControlEnabled | adminOnly 是硬拦截,accessControlEnabled + routeKey 提供用户级粒度控制 |
178
+ | 菜单 → 页面解析 | `pageId` 优先,再 `path` | 中台以 id 为准;路径匹配用于无 id 或 id 失效时的兜底,与 `findPageByPath` 一致 |
164
179
 
165
180
  ## 已知限制与待改进
166
181
 
167
- - `routeKey` 当前复用 `sideMenus` 做权限匹配,后续可能独立为专门的权限字段
168
- - 隐藏页面的权限控制依赖 `routeKey` 存在于 `sideMenus` 中,需要后端在下发用户权限时包含隐藏页面的 `routeKey`
182
+ - 用户信息接口为 **`GET /user/info/`**(OpenAPI 8),与旧 `/api/user/info` 非同一后端;`fetchUserInfo` 已切换
183
+ - 外链类型菜单项需配置 `nameKey` 作为权限 code,否则无法匹配 `menu_perms`
169
184
 
170
185
  ## 注意事项
171
186
 
172
187
  - `__MICO_PAGES__` 只包含用户在后台配置的微应用页面,404/403 等静态页面不在其中
173
- - `__MICO_MENUS__` 中菜单项的 `page` 字段引用的是 `__MICO_PAGES__` 中的某个页面
174
- - 调试时可在控制台搜索 `isForbidden (menu check)` `isForbidden (hidden page` 查看权限判断日志
188
+ - `__MICO_MENUS__` `page` 类型项通过 **`pageId`**(优先)或 **`path` 与页面 `route` 匹配** 关联到 `__MICO_PAGES__` 中的页面配置
189
+ - 调试时可在控制台过滤 `routePermission` 查看结构化权限日志(详见 [路由权限日志](./feature-路由权限日志.md))
175
190
 
176
191
  ## 相关文档
177
192
 
178
193
  - [微前端模式](./feature-微前端模式.md) - 微应用加载机制
179
- - [菜单权限控制](./feature-菜单权限控制.md) - sideMenus 白名单权限逻辑
194
+ - [菜单权限控制](./feature-菜单权限控制.md) - `menu_perms` 与菜单过滤
@@ -0,0 +1,162 @@
1
+ # 路由权限日志
2
+
3
+ > 创建时间:2026-03-27
4
+
5
+ ## 功能概述
6
+
7
+ 在 layout 中输出两类结构化开发日志,便于本地排查权限问题:
8
+
9
+ 1. **`routePermission`**:`BasicLayout` 中 **`isForbidden` 路由权限判定**(为何放行 / 为何 403)。
10
+ 2. **`menuFilter`**:`filterMenuItems` 中**被隐藏的菜单项**及**隐藏原因**(侧栏不可见条目)。
11
+
12
+ 日志均经 `layoutLogger` 输出,**仅开发环境生效**,生产构建无控制台输出。
13
+
14
+ ## 技术方案
15
+
16
+ ### 技术栈
17
+
18
+ - 框架:React 18 + @umijs/max
19
+ - 日志:`apps/layout/src/common/logger.ts` 的 `layoutLogger`(`NODE_ENV === 'development'` 时 `console.log`)
20
+
21
+ ### 核心实现
22
+
23
+ 1. 在 `BasicLayout` 的 `useMemo`(`isForbidden`)中,于各分支返回前调用 `layoutLogger.log('routePermission', payload)`。
24
+ 2. 统一使用 **`routePermission`** 作为首参,便于控制台过滤;`payload` 为结构化对象,包含 `verdict`、`reason`、`branch`(部分分支)及路径、页面上下文。
25
+ 3. **不记录**「无动态路由 `currentRoute`」与「无页面元数据且未命中菜单兜底」的常见放行路径,避免刷屏。
26
+ 4. 在 `parser.ts` 的 `filterMenuItems` 中,于菜单项被剔除时输出 **`menuFilter`**;**关闭权限**或**超级用户**时不打菜单隐藏日志(此时无实质过滤)。
27
+
28
+ ## 文件清单
29
+
30
+ ### 修改文件
31
+
32
+ | 文件路径 | 说明 |
33
+ |----------|------|
34
+ | `apps/layout/src/layouts/index.tsx` | `isForbidden` 内各关键分支的 `routePermission` 日志 |
35
+ | `apps/layout/src/common/menu/parser.ts` | `getMenuItemFilterOutcome`、`filterMenuItems` 内 `menuFilter` 隐藏日志 |
36
+
37
+ ## 日志字段说明
38
+
39
+ ### 通用字段
40
+
41
+ | 字段 | 说明 |
42
+ |------|------|
43
+ | `verdict` | `skip`:跳过权限校验;`allow`:允许访问;`deny`:403 |
44
+ | `reason` | 见下表 |
45
+ | `pathname` | 当前 `location.pathname` |
46
+ | `routePath` | 匹配到的动态路由 `currentRoute.path`(有 `currentRoute` 时) |
47
+ | `userId` | `currentUser.id` |
48
+ | `isSuperuser` | 是否超管 |
49
+ | `menuPermsCount` | `menu_perms` 长度 |
50
+
51
+ ### `reason` 取值
52
+
53
+ | reason | verdict | 含义 |
54
+ |--------|---------|------|
55
+ | `disableAuth` | skip | `__MICO_CONFIG__.disableAuth` 等效关闭权限 |
56
+ | `noPermissionRoute` | skip | 命中免权限路由列表 |
57
+ | `superuser` | skip | 超级用户不校验 |
58
+ | `adminOnly` | deny | 页面 `adminOnly`,非超管 |
59
+ | `publicPage` | allow | `accessControlEnabled === false` |
60
+ | `accessControlNoRouteKey` | allow | 已开启访问控制但未配置 `routeKey`,不做 `menu_perms` 校验(与侧栏 `isMenuPageRequiringPermCode` 一致) |
61
+ | `routeKeyNotInMenuPerms` | deny | `routeKey` 不在 `menu_perms` |
62
+ | `routeKeyInMenuPerms` | allow | `routeKey` 在 `menu_perms` |
63
+ | `notInFilteredMenuRoutes` | deny | 菜单兜底分支:过滤后路由不包含当前路径 |
64
+ | `inFilteredMenuRoutes` | allow | 菜单兜底分支:过滤后仍包含 |
65
+
66
+ ### 分支字段 `branch`
67
+
68
+ | branch | 含义 |
69
+ |--------|------|
70
+ | `pageMeta` | 已解析到 `__MICO_PAGES__` 页面配置时的 `routeKey` / `menu_perms` 判定 |
71
+ | `menuRouteFallback` | 无页面元数据时,用「全量菜单路由 vs `filterMenuItems` 后菜单路由」比对 |
72
+
73
+ ### 页面元数据分支额外字段
74
+
75
+ | 字段 | 说明 |
76
+ |------|------|
77
+ | `pageId` | 页面配置 id |
78
+ | `routeKey` | 页面 `routeKey` |
79
+ | `keyInMenuPerms` | `routeKey` 是否在 `menu_perms` 中(仅当需校验 `routeKey` 时输出) |
80
+
81
+ ### 菜单兜底分支额外字段
82
+
83
+ | 字段 | 说明 |
84
+ |------|------|
85
+ | `inAllowed` | 当前路径是否在过滤后的允许菜单路由中 |
86
+
87
+ ---
88
+
89
+ ## 菜单过滤日志 `menuFilter`
90
+
91
+ ### 通用字段
92
+
93
+ | 字段 | 说明 |
94
+ |------|------|
95
+ | `verdict` | 固定 `hidden` |
96
+ | `reason` | 见下表 |
97
+ | `menuId` | 菜单项 `id` |
98
+ | `menuType` | `group` / `page` / `link` |
99
+ | `name` | 菜单展示名(配置中的 `name`) |
100
+ | `nameKey` | 菜单 `nameKey`(若有) |
101
+ | `permCode` | `getMenuItemPermCode` 解析出的权限 code(可能为 `null`) |
102
+ | `menuPermsCount` | 当前用户 `menu_perms` 长度 |
103
+ | `path` | 菜单路由或关联页路由 |
104
+
105
+ ### `menuFilter.reason` 取值
106
+
107
+ | reason | 含义 |
108
+ |--------|------|
109
+ | `adminOnly` | 菜单项 `adminOnly`,非超管不可见 |
110
+ | `missingPermCode` | `page` 无可用 `routeKey`、`link` 无 `nameKey`,无法匹配 `menu_perms` |
111
+ | `menuPermsEmpty` | `menu_perms` 为空数组 |
112
+ | `permCodeNotInMenuPerms` | 权限 code 不在 `menu_perms` 中 |
113
+ | `groupNoVisibleChildren` | 分组下子菜单全部过滤后无可见项 |
114
+ | `allChildrenFilteredOut` | 父级单项通过(`page`/`link` 有权限),但子级全部过滤后无子项可展示 |
115
+
116
+ 说明:`group` 作为容器在子项仍可见时**不会**被记为 `hidden`;仅当整组被剔除时打日志。
117
+
118
+ ## 使用示例
119
+
120
+ 在浏览器控制台过滤:
121
+
122
+ ```text
123
+ routePermission
124
+ ```
125
+
126
+ 或:
127
+
128
+ ```text
129
+ menuFilter
130
+ ```
131
+
132
+ 或:
133
+
134
+ ```text
135
+ [Layout]
136
+ ```
137
+
138
+ (具体前缀以 `layoutLogger` 实现为准。)
139
+
140
+ ## 设计决策
141
+
142
+ | 决策点 | 选择 | 理由 |
143
+ |--------|------|------|
144
+ | 日志前缀 | 固定 `'routePermission'` + 对象载荷 | 可过滤、可结构化,便于 AI/人工检索 |
145
+ | 生产环境 | 静默 | `layoutLogger` 在 `production` 下不输出 |
146
+ | 无 `currentRoute` 不打日志 | 跳过 | 静态路由访问频繁,避免噪音 |
147
+ | `menuFilter` 与超管/关权限 | 不打 | 无过滤效果时无隐藏项可记 |
148
+
149
+ ## 已知限制与待改进
150
+
151
+ - 日志仅在**开发环境**可见;线上问题需依赖 Sentry 或其它监控,不在本功能内。
152
+ - `renderContent` 另有 `renderContent:` 日志,与 `routePermission` 独立,排查时可同时参考。
153
+
154
+ ## 注意事项
155
+
156
+ - 权限判定逻辑以 `layouts/index.tsx` 与 [菜单权限控制](./feature-菜单权限控制.md)、[路由与菜单解耦](./feature-路由与菜单解耦.md) 为准;本文档**仅描述日志字段**,不替代权限语义说明。
157
+
158
+ ## 相关文档
159
+
160
+ - [菜单权限控制](./feature-菜单权限控制.md)
161
+ - [路由与菜单解耦](./feature-路由与菜单解耦.md)
162
+ - [日志与常量](./arch-日志与常量.md)(`layoutLogger` 行为)