generator-mico-cli 0.2.27 → 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 (58) 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 +7 -4
  9. package/generators/micro-react/templates/apps/layout/config/config.prod.development.ts +2 -2
  10. package/generators/micro-react/templates/apps/layout/config/config.prod.testing.ts +2 -2
  11. package/generators/micro-react/templates/apps/layout/config/config.prod.ts +3 -3
  12. package/generators/micro-react/templates/apps/layout/docs/feat-/346/236/204/345/273/272define/344/270/216/345/205/215/350/256/244/350/257/201/345/210/235/345/247/213/346/200/201.md +44 -0
  13. 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
  14. package/generators/micro-react/templates/apps/layout/docs/feature-/345/276/256/345/211/215/347/253/257/346/250/241/345/274/217.md +11 -6
  15. 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
  16. 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
  17. 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
  18. package/generators/micro-react/templates/apps/layout/mock/api.mock.ts +23 -31
  19. package/generators/micro-react/templates/apps/layout/mock/pages.ts +5 -6
  20. package/generators/micro-react/templates/apps/layout/package.json +2 -1
  21. package/generators/micro-react/templates/apps/layout/src/app.tsx +31 -2
  22. package/generators/micro-react/templates/apps/layout/src/common/auth/type.ts +15 -27
  23. package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +148 -85
  24. package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +2 -6
  25. package/generators/micro-react/templates/apps/layout/src/common/portal-data.ts +46 -2
  26. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +5 -1
  27. package/generators/micro-react/templates/apps/layout/src/components/PermissionFilter/index.tsx +51 -0
  28. package/generators/micro-react/templates/apps/layout/src/components/RightContent/AvatarDropdown.tsx +10 -1
  29. package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +3 -3
  30. package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +105 -60
  31. package/generators/micro-react/templates/apps/layout/src/locales/en-US.ts +17 -0
  32. package/generators/micro-react/templates/apps/layout/src/locales/zh-CN.ts +16 -0
  33. package/generators/micro-react/templates/apps/layout/src/pages/404/index.tsx +7 -3
  34. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.less +5 -0
  35. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.tsx +49 -1
  36. package/generators/micro-react/templates/apps/layout/src/services/user.ts +28 -21
  37. package/generators/micro-react/templates/packages/common-intl/README.md +77 -369
  38. package/generators/micro-react/templates/packages/common-intl/package.json +3 -13
  39. package/generators/micro-react/templates/packages/common-intl/src/index.ts +3 -6
  40. package/generators/micro-react/templates/packages/common-intl/src/intl.ts +20 -23
  41. package/generators/micro-react/templates/packages/common-intl/tsconfig.json +2 -4
  42. package/generators/subapp-react/index.js +28 -22
  43. package/generators/subapp-react/templates/homepage/README.md +1 -0
  44. package/generators/subapp-react/templates/homepage/config/config.prod.development.ts +1 -0
  45. package/generators/subapp-react/templates/homepage/config/config.prod.testing.ts +1 -0
  46. package/generators/subapp-react/templates/homepage/config/config.prod.ts +1 -0
  47. package/generators/subapp-react/templates/homepage/docs/feature-PermissionFilter/346/214/211/351/222/256/346/235/203/351/231/220.md +35 -0
  48. package/generators/subapp-react/templates/homepage/package.json +2 -1
  49. package/generators/subapp-react/templates/homepage/src/app.tsx +7 -0
  50. package/generators/subapp-react/templates/homepage/src/common/mainApp.ts +39 -2
  51. package/generators/subapp-react/templates/homepage/src/components/PermissionFilter/index.tsx +48 -0
  52. package/generators/subapp-react/templates/homepage/src/pages/index.tsx +35 -1
  53. package/lib/utils.js +0 -1
  54. package/package.json +2 -2
  55. package/generators/micro-react/templates/apps/layout/docs/common-intl.md +0 -372
  56. package/generators/micro-react/templates/packages/common-intl/src/indexedDBUtils.ts +0 -51
  57. package/generators/micro-react/templates/packages/common-intl/src/utils.ts +0 -482
  58. package/generators/micro-react/templates/packages/common-intl/vite.config.ts +0 -25
@@ -5,38 +5,30 @@
5
5
  */
6
6
 
7
7
  export default {
8
- // 获取用户信息
9
- 'GET /api/user/info': {
10
- "code": 200,
11
- "data": {
12
- "id": 381,
13
- "last_login": "2026-03-06T02:37:10.050735Z",
14
- "is_superuser": true,
15
- "username": "本地测试mock用户",
16
- "first_name": "",
17
- "last_name": "",
18
- "email": "本地测试mock用户@micous.com",
19
- "is_staff": true,
20
- "is_active": true,
21
- "type": 1,
22
- "phone": "",
23
- "agency_id": 0,
24
- "avatar": "https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png",
25
- "user_name": "Easton",
26
- "permission_tree": [
27
- ],
28
- "miss_permissions": [],
29
- "side_menus": [
30
- '首页',
31
- '示例模块.示例页面',
32
- '微应用示例.子应用页面',
33
- '外部链接',
34
- '权限管理',
35
- ],
36
- "region_permissions": []
8
+ // 获取用户信息(OpenAPI 8 GET /user/info/,与 fetchUserInfo 一致)
9
+ 'GET /user/info/': {
10
+ code: 200,
11
+ data: {
12
+ id: 381,
13
+ avatar:
14
+ 'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png',
15
+ email: '本地测试mock用户@micous.com',
16
+ name: 'Easton',
17
+ app_perms: [],
18
+ region_perms: [],
19
+ menu_perms: [
20
+ 'cs_web_menu_home',
21
+ 'cs_web_menu_example_page',
22
+ 'cs_web_menu_subapp_page',
23
+ 'cs_web_menu_external_link',
24
+ 'cs_web_menu_permission_management',
25
+ 'cs_web_menu_group_management',
26
+ ],
27
+ button_perms: ['cs_web_btn_subapp_demo', 'cs_web_btn_home_demo'],
28
+ is_superuser: false,
37
29
  },
38
- "msg": "ok"
39
- },
30
+ msg: 'ok',
31
+ },
40
32
 
41
33
  // 获取统计数据
42
34
  // 'GET /api/dashboard/stats': {
@@ -6,6 +6,7 @@
6
6
  * - id: 页面唯一标识
7
7
  * - name: 页面名称
8
8
  * - route: 路由路径
9
+ * - base: 微应用挂载前缀(可与 route 不同)
9
10
  * - htmlUrl: 微应用 HTML 入口 URL
10
11
  * - jsUrls: 额外 JS 资源
11
12
  * - cssUrls: 额外 CSS 资源
@@ -25,10 +26,10 @@ const mockPages: PublicPageItem[] = [
25
26
  {
26
27
  id: 125,
27
28
  name: '登录页',
28
- nameEn: 'Login',
29
- nameKey: 'page.user.login',
30
29
  route: '/user/login',
31
30
  base: '/user/login',
31
+ // htmlUrl:
32
+ // 'https://cdn-portal.micoplatform.com/portal-center/common-web/0.0.2/login/index.html',
32
33
  htmlUrl:
33
34
  'https://cdn-portal.micoplatform.com/portal-center/common-web/0.0.4/login/index.html',
34
35
  jsUrls: [],
@@ -45,10 +46,10 @@ const mockPages: PublicPageItem[] = [
45
46
  {
46
47
  id: 124,
47
48
  name: '权限管理',
48
- nameEn: 'Permission Management',
49
- nameKey: 'page.permission',
50
49
  route: '/permission',
51
50
  base: '/',
51
+ // htmlUrl:
52
+ // 'https://cdn-portal.micoplatform.com/portal-center/common-web/0.0.3/permission/index.html',
52
53
  htmlUrl:
53
54
  'https://cdn-portal.micoplatform.com/portal-center/common-web/0.0.4/permission/index.html',
54
55
  jsUrls: [],
@@ -65,8 +66,6 @@ const mockPages: PublicPageItem[] = [
65
66
  {
66
67
  id: 115,
67
68
  name: '兜底',
68
- nameEn: 'Fallback',
69
- nameKey: 'page.fallback',
70
69
  route: '/*',
71
70
  base: '/',
72
71
  htmlUrl: '',
@@ -29,7 +29,8 @@
29
29
  "qiankun": "^2.10.16",
30
30
  "react": "^18.2.0",
31
31
  "react-dom": "^18.2.0",
32
- "spark-md5": "^3.0.2"
32
+ "spark-md5": "^3.0.2",
33
+ "<%= packageScope %>/common-intl": "workspace:*"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/react": "^18.0.33",
@@ -7,6 +7,13 @@ import * as micoUI from '@mico-platform/ui';
7
7
  import React from 'react';
8
8
  import ReactDOM from 'react-dom';
9
9
 
10
+ import { request as commonRequest } from './common/request';
11
+ import {
12
+ fetchMultilingualData,
13
+ getCurrentLocale as getIntlLocale,
14
+ type ILang,
15
+ } from '<%= packageScope %>/common-intl';
16
+ import * as CommonIntl from '<%= packageScope %>/common-intl';
10
17
  import { getStoredAuthToken } from './common/auth/auth-manager';
11
18
  import type { IUserInfo } from './common/auth/type';
12
19
  import { fetchUserInfo } from './services/user';
@@ -90,6 +97,7 @@ if (typeof window !== 'undefined') {
90
97
  win.React = React;
91
98
  win.ReactDOM = ReactDOM;
92
99
  win.micoUI = micoUI;
100
+ win.CommonIntl = CommonIntl;
93
101
  }
94
102
 
95
103
  // 初始化主题(在页面加载时立即执行,避免闪烁)
@@ -107,6 +115,26 @@ export const locale = {
107
115
  },
108
116
  };
109
117
 
118
+ // ==================== 国际化数据预加载 ====================
119
+
120
+ /** @see https://umijs.org/docs/api/runtime-config#render */
121
+ export function render(oldRender: () => void): void {
122
+ fetchMultilingualData({
123
+ requestInstance: commonRequest,
124
+ messageInstance: {
125
+ error: micoUI.Message.error,
126
+ warning: micoUI.Message.warning,
127
+ },
128
+ lang: getIntlLocale() as ILang,
129
+ localeRequestUrl: process.env.LOCALE_REQUEST_URL,
130
+ })
131
+ .then(oldRender)
132
+ .catch((error: Error) => {
133
+ console.error('获取多语言文案失败', error);
134
+ oldRender();
135
+ });
136
+ }
137
+
110
138
  /**
111
139
  * @see https://umijs.org/docs/api/runtime-config#getinitialstate
112
140
  */
@@ -137,8 +165,9 @@ export async function getInitialState(): Promise<{
137
165
  await ensureSsoSession();
138
166
  }
139
167
 
140
- // 有 token 就获取用户信息(无论在哪个页面,支持登录后 refresh 场景)
141
- if (getStoredAuthToken()) {
168
+ // 有 token 就获取用户信息(登录后 refresh、免认证页同样需要 currentUser:
169
+ // PermissionFilter / MicroAppLoader 注入子应用的 button_perms)
170
+ if (getStoredAuthToken() && !skipAuth) {
142
171
  const userInfo = await fetchUserInfoFn();
143
172
  if (userInfo) {
144
173
  clearRedirectCount();
@@ -9,39 +9,27 @@ export const UID = 'uid';
9
9
  export const MICRO_ENV_KEY = 'micro_env';
10
10
 
11
11
  /**
12
- * 权限树节点
12
+ * OpenAPI 8 — GET /user/info/ 响应 `data`(与 Apifox 导出 8 一致)
13
13
  */
14
- export interface IPermissionNode {
14
+ export interface IUserInfoApiData {
15
15
  id: number;
16
+ avatar: string;
17
+ email: string;
16
18
  name: string;
17
- sort: number;
18
- level: number;
19
- parent: number | null;
20
- codename: string;
21
- sub_menu?: IPermissionNode[];
19
+ app_perms: string[];
20
+ region_perms: string[];
21
+ menu_perms: string[];
22
+ button_perms: string[];
23
+ is_superuser: boolean;
22
24
  }
23
25
 
24
26
  /**
25
- * 完整用户信息(/api/user/info 接口返回的 data 字段)
27
+ * 应用内用户信息(initialState.currentUser)
28
+ * 在 `IUserInfoApiData` 基础上补充展示用字段(由 fetchUserInfo normalize)
26
29
  */
27
- export interface IUserInfo {
28
- id: number;
29
- username: string;
30
- email: string;
30
+ export interface IUserInfo extends IUserInfoApiData {
31
+ /** 展示名,通常来自接口 `name` */
31
32
  user_name: string;
32
- avatar: string;
33
- phone: string;
34
- first_name: string;
35
- last_name: string;
36
- is_superuser: boolean;
37
- is_staff: boolean;
38
- is_active: boolean;
39
- type: number;
40
- agency_id: number;
41
- last_login: string;
42
- permission_tree: IPermissionNode[];
43
- miss_permissions: string[];
44
- side_menus: string[];
45
- app_permissions: string[];
46
- groups: string[];
33
+ /** 登录名/账号展示,可与 `name` 或 `email` 一致 */
34
+ username: string;
47
35
  }
@@ -1,5 +1,12 @@
1
+ import { layoutLogger } from '@/common/logger';
1
2
  import { getCurrentLocale, LOCALE } from '@/common/locale';
2
- import { getMenuPage, getPages } from '@/common/portal-data';
3
+ import {
4
+ findPageByPath,
5
+ getMenuPage,
6
+ getPages,
7
+ } from '@/common/portal-data';
8
+
9
+ export { findPageByPath };
3
10
  import { isAuthDisabled, isNoPermissionRoute } from '@/constants';
4
11
  import { getIntl } from '@umijs/max';
5
12
  import type {
@@ -28,6 +35,7 @@ export const extractRoutesFromPages = (
28
35
  | 'internal'
29
36
  | 'microapp',
30
37
  entry: page.htmlUrl || page.jsUrls?.[0] || undefined,
38
+ pageConfig: page,
31
39
  };
32
40
  });
33
41
  };
@@ -39,54 +47,54 @@ export const getDynamicRoutes = (): ParsedRoute[] => {
39
47
  return extractRoutesFromPages(getPages());
40
48
  };
41
49
 
42
- /**
43
- * 根据路径查找对应的页面配置
44
- * 匹配逻辑与 findRouteByPath 一致(精确匹配 + 通配符匹配)
45
- */
46
50
  /**
47
51
  * 判断指定路径的页面是否为免认证页面
48
52
  * 当 accessControlEnabled === false 时,跳过 SSO 认证和权限校验
53
+ * (页面解析与 `findPageByPath` 一致:精确匹配 + `/*` 通配)
49
54
  */
50
55
  export const isPageAuthFree = (pathname: string): boolean => {
51
56
  const page = findPageByPath(getPages(), pathname);
52
57
  return page?.accessControlEnabled === false;
53
58
  };
54
59
 
55
- export const findPageByPath = (
56
- pages: PublicPageItem[],
57
- pathname: string,
58
- ): PublicPageItem | undefined => {
59
- let exact: PublicPageItem | undefined;
60
- let bestWildcard: { page: PublicPageItem; basePath: string } | undefined;
61
-
62
- for (const page of pages) {
63
- if (!page.enabled) continue;
64
-
65
- if (page.route === pathname) {
66
- exact = page;
67
- continue;
68
- }
69
-
70
- if (page.route.endsWith('/*')) {
71
- const basePath = page.route.slice(0, -2);
72
- if (pathname === basePath || pathname.startsWith(`${basePath}/`)) {
73
- if (!bestWildcard || basePath.length > bestWildcard.basePath.length) {
74
- bestWildcard = { page, basePath };
75
- }
76
- }
77
- }
78
- }
79
-
80
- return exact || bestWildcard?.page;
81
- };
82
-
83
60
  export interface MenuFilterOptions {
84
61
  /** 是否是超级用户 */
85
62
  isSuperuser?: boolean | number;
86
- /** 允许访问的菜单路径列表(白名单) */
87
- sideMenus?: string[];
63
+ /** 用户拥有的菜单权限 code(与页面 `routeKey`、菜单项一致) */
64
+ menuPerms?: string[];
88
65
  }
89
66
 
67
+ /**
68
+ * 菜单关联页是否需要走 `menu_perms` 校验
69
+ * - 未开启访问控制:否(菜单始终可展示,由路由侧处理公开页)
70
+ * - 已开启访问控制但未配置 `routeKey`:否(无 code 可验,菜单展示)
71
+ * - 已开启访问控制且配置了 `routeKey`:是
72
+ */
73
+ export const isMenuPageRequiringPermCode = (
74
+ page: PublicPageItem | undefined,
75
+ ): boolean => {
76
+ if (!page) return false;
77
+ if (page.accessControlEnabled === false) return false;
78
+ const rk = page.routeKey;
79
+ if (rk === null || rk === undefined || rk === '') return false;
80
+ return true;
81
+ };
82
+
83
+ /**
84
+ * 用于 `menu_perms` 匹配的权限 code(仅当 `isMenuPageRequiringPermCode(page)` 为真时有效)
85
+ */
86
+ export const getMenuItemPermCode = (item: MenuItem): string | null => {
87
+ if (item.type === 'page') {
88
+ const page = getMenuPage(item);
89
+ if (!page || !isMenuPageRequiringPermCode(page)) return null;
90
+ return page.routeKey;
91
+ }
92
+ if (item.type === 'link') {
93
+ return item.nameKey || null;
94
+ }
95
+ return null;
96
+ };
97
+
90
98
  export const isSuperuserUser = (value?: boolean | number): boolean => {
91
99
  return value === true || value === 1;
92
100
  };
@@ -107,11 +115,9 @@ export const isChineseLocale = (): boolean => {
107
115
  };
108
116
 
109
117
  /**
110
- * 获取菜单标识符(用于构建权限路径)
111
- * - 中文格式:优先使用 name,兜底使用 nameKey
112
- * - 英文格式:优先使用 nameEn,兜底使用 nameKey
113
- * @param item 菜单项
114
- * @param isChinese 是否中文格式
118
+ * 获取菜单标识符(用于内部路径拼接等,非侧栏展示)
119
+ * - 中文:优先 `name`,兜底 `nameKey`
120
+ * - 英文:优先 `nameEn`,兜底 `nameKey`
115
121
  */
116
122
  export const getMenuIdentifier = (
117
123
  item: MenuItem,
@@ -123,45 +129,96 @@ export const getMenuIdentifier = (
123
129
  return item.nameEn || item.nameKey || '';
124
130
  };
125
131
 
132
+ /** 菜单项被过滤时的原因(用于开发日志) */
133
+ export type MenuFilterDenyReason =
134
+ | 'adminOnly'
135
+ | 'groupContainer'
136
+ | 'missingPermCode'
137
+ | 'menuPermsEmpty'
138
+ | 'permCodeNotInMenuPerms'
139
+ | 'groupNoVisibleChildren'
140
+ | 'allChildrenFilteredOut';
141
+
142
+ export interface IMenuFilterOutcome {
143
+ allowed: boolean;
144
+ /** 当 allowed === false 时,单项校验的拒绝原因(不含容器类、子项被滤空等结构原因) */
145
+ denyReason?: MenuFilterDenyReason;
146
+ }
147
+
126
148
  /**
127
- * 检查菜单路径是否允许访问(白名单逻辑)
128
- * - 免权限校验路由,始终允许访问
129
- * - 超级用户可以访问所有菜单
130
- * - 非超级用户不能访问 adminOnly 菜单
131
- * - 菜单路径在 sideMenus 中,或是 sideMenus 中某项的前缀(父级菜单)
149
+ * 单项是否允许展示(不含「子项全被过滤」等结构判断)
150
+ * - 免权限校验路由、超管、关闭权限:允许
151
+ * - `group`:自身不作为叶子展示,allowed 恒为 false,`denyReason` 为 `groupContainer`
152
+ * - `page` 且关联页未要求 `menu_perms`(`accessControlEnabled === false`,或已开启但未配置 `routeKey`):允许
153
+ * - `page` / `link` code 时:权限 code 须在 `menu_perms` 中
132
154
  */
133
- const isMenuAllowed = (
134
- menuPath: string,
155
+ export const getMenuItemFilterOutcome = (
135
156
  item: MenuItem,
136
157
  options: MenuFilterOptions,
137
- ): boolean => {
138
- // 关闭权限控制时,所有菜单都允许访问
139
- if (isAuthDisabled()) return true;
158
+ ): IMenuFilterOutcome => {
159
+ if (isAuthDisabled()) return { allowed: true };
140
160
 
141
- // 免权限校验路由,始终允许访问
142
161
  const itemRoute = item.path ?? getMenuPage(item)?.route;
143
162
  if (itemRoute && isNoPermissionRoute(itemRoute)) {
144
- return true;
163
+ return { allowed: true };
145
164
  }
146
165
 
147
- // 超级用户可以访问所有菜单
148
- if (isSuperuserUser(options.isSuperuser)) return true;
166
+ if (isSuperuserUser(options.isSuperuser)) return { allowed: true };
149
167
 
150
- // 非超级用户不能访问 adminOnly 菜单
151
- if (item.adminOnly) return false;
168
+ if (item.adminOnly) {
169
+ return { allowed: false, denyReason: 'adminOnly' };
170
+ }
152
171
 
153
- const sideMenus = options.sideMenus || [];
172
+ if (item.type === 'group') {
173
+ return { allowed: false, denyReason: 'groupContainer' };
174
+ }
154
175
 
155
- // 如果没有配置 sideMenus,非超级用户没有任何菜单权限
156
- if (sideMenus.length === 0) return false;
176
+ if (item.type === 'page') {
177
+ const page = getMenuPage(item);
178
+ if (page && !isMenuPageRequiringPermCode(page)) {
179
+ return { allowed: true };
180
+ }
181
+ }
157
182
 
158
- // 检查是否在白名单中(精确匹配或前缀匹配)
159
- return sideMenus.some((allowedPath) => {
160
- // 精确匹配:菜单路径完全等于白名单中的路径
161
- if (menuPath === allowedPath) return true;
162
- // 前缀匹配:白名单路径以菜单路径开头(说明菜单是父级)
163
- if (allowedPath.startsWith(menuPath + '.')) return true;
164
- return false;
183
+ const menuPerms = options.menuPerms || [];
184
+ const code = getMenuItemPermCode(item);
185
+
186
+ if (!code) {
187
+ return { allowed: false, denyReason: 'missingPermCode' };
188
+ }
189
+ if (menuPerms.length === 0) {
190
+ return { allowed: false, denyReason: 'menuPermsEmpty' };
191
+ }
192
+ if (!menuPerms.includes(code)) {
193
+ return { allowed: false, denyReason: 'permCodeNotInMenuPerms' };
194
+ }
195
+ return { allowed: true };
196
+ };
197
+
198
+ const shouldLogMenuFilter = (options: MenuFilterOptions): boolean => {
199
+ if (isAuthDisabled()) return false;
200
+ if (isSuperuserUser(options.isSuperuser)) return false;
201
+ return true;
202
+ };
203
+
204
+ const logMenuItemHidden = (
205
+ item: MenuItem,
206
+ options: MenuFilterOptions,
207
+ reason: MenuFilterDenyReason,
208
+ ): void => {
209
+ if (!shouldLogMenuFilter(options)) return;
210
+
211
+ const permCode = getMenuItemPermCode(item);
212
+ layoutLogger.log('menuFilter', {
213
+ verdict: 'hidden',
214
+ reason,
215
+ menuId: item.id,
216
+ menuType: item.type,
217
+ name: item.name,
218
+ nameKey: item.nameKey,
219
+ permCode,
220
+ menuPermsCount: options.menuPerms?.length ?? 0,
221
+ path: item.path ?? getMenuPage(item)?.route ?? null,
165
222
  });
166
223
  };
167
224
 
@@ -171,26 +228,20 @@ const isMenuAllowed = (
171
228
  export const filterMenuItems = (
172
229
  items: MenuItem[],
173
230
  options: MenuFilterOptions = {},
174
- parentPath = '',
175
231
  ): MenuItem[] => {
176
- // 根据当前语言环境判断菜单标识符格式
177
- const isChinese = isChineseLocale();
178
232
  return items
179
233
  .filter((item) => item.enabled)
180
234
  .map((item) => {
181
- const menuPath = buildMenuPath(
182
- parentPath,
183
- getMenuIdentifier(item, isChinese),
184
- );
185
- const isAllowed = isMenuAllowed(menuPath, item, options);
235
+ const outcome = getMenuItemFilterOutcome(item, options);
236
+ const isAllowed = outcome.allowed;
186
237
 
187
- // 递归处理子菜单
188
238
  const nextChildren = item.children?.length
189
- ? filterMenuItems(item.children, options, menuPath)
239
+ ? filterMenuItems(item.children, options)
190
240
  : [];
191
241
 
192
242
  // 分组类型:如果没有子菜单,不显示
193
243
  if (item.type === 'group' && nextChildren.length === 0) {
244
+ logMenuItemHidden(item, options, 'groupNoVisibleChildren');
194
245
  return null;
195
246
  }
196
247
 
@@ -200,6 +251,9 @@ export const filterMenuItems = (
200
251
  if (nextChildren.length > 0) {
201
252
  return { ...item, children: nextChildren };
202
253
  }
254
+ if (outcome.denyReason) {
255
+ logMenuItemHidden(item, options, outcome.denyReason);
256
+ }
203
257
  return null;
204
258
  }
205
259
 
@@ -211,6 +265,7 @@ export const filterMenuItems = (
211
265
 
212
266
  // 有子菜单但过滤后为空,不显示
213
267
  if (nextChildren.length === 0) {
268
+ logMenuItemHidden(item, options, 'allChildrenFilteredOut');
214
269
  return null;
215
270
  }
216
271
 
@@ -265,7 +320,7 @@ export const extractRoutes = (
265
320
 
266
321
  const menuPath = buildMenuPath(parentPath, getMenuIdentifierDefault(item));
267
322
 
268
- if (item.type === 'page' && item.pageId) {
323
+ if (item.type === 'page') {
269
324
  const page = getMenuPage(item);
270
325
  if (page && page.enabled) {
271
326
  routes.push({
@@ -273,6 +328,7 @@ export const extractRoutes = (
273
328
  base: page.base || '/',
274
329
  name: item.name,
275
330
  nameEn: item.nameEn,
331
+ nameKey: item.nameKey,
276
332
  icon: item.icon,
277
333
  loadType: getLoadType(page),
278
334
  entry: getEntry(page),
@@ -290,24 +346,28 @@ export const extractRoutes = (
290
346
  };
291
347
 
292
348
  /**
293
- * 获取菜单项的显示名称
294
- * 优先级:name/nameEn > nameKey(走 intl 国际化) > 兜底 name
349
+ * 获取菜单项的显示名称(侧栏、Tab 等)
350
+ * - `nameKey` 时优先走 intl,**`defaultMessage` 使用 `name`(中文)或 `nameEn`/`name`(英文)作为兜底文案**
351
+ * - 无 `nameKey` 时直接使用 `name` / `nameEn`
295
352
  */
296
353
  export const getMenuLabel = (
297
354
  fields: { name?: string; nameEn?: string; nameKey?: string },
298
355
  isChinese: boolean,
299
356
  ): string => {
300
- const directName = isChinese ? fields.name : fields.nameEn;
301
- if (directName) return directName;
302
-
303
357
  if (fields.nameKey) {
304
358
  const intl = getIntl();
359
+ const defaultMessage = isChinese
360
+ ? fields.name || fields.nameKey
361
+ : fields.nameEn || fields.name || fields.nameKey;
305
362
  return intl.formatMessage({
306
363
  id: fields.nameKey,
307
- defaultMessage: fields.nameKey,
364
+ defaultMessage,
308
365
  });
309
366
  }
310
367
 
368
+ const directName = isChinese ? fields.name : fields.nameEn;
369
+ if (directName) return directName;
370
+
311
371
  return fields.name || '';
312
372
  };
313
373
 
@@ -354,8 +414,8 @@ export const findRouteByPath = (
354
414
  routes: ParsedRoute[],
355
415
  pathname: string,
356
416
  ): ParsedRoute | undefined => {
357
- const normalizedPathname = stripTrailingSlash(pathname);
358
417
  let exact: ParsedRoute | undefined;
418
+ const normalizedPathname = stripTrailingSlash(pathname);
359
419
  let bestWildcard: { route: ParsedRoute; basePath: string } | undefined;
360
420
 
361
421
  for (const route of routes) {
@@ -366,7 +426,10 @@ export const findRouteByPath = (
366
426
 
367
427
  if (route.path.endsWith('/*')) {
368
428
  const basePath = route.path.slice(0, -2);
369
- if (normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
429
+ if (
430
+ normalizedPathname === basePath ||
431
+ normalizedPathname.startsWith(`${basePath}/`)
432
+ ) {
370
433
  if (!bestWildcard || basePath.length > bestWildcard.basePath.length) {
371
434
  bestWildcard = { route, basePath };
372
435
  }
@@ -10,13 +10,9 @@ export interface PageConfig {
10
10
  id: number;
11
11
  /** 页面名称 */
12
12
  name: string;
13
- /** 英文名称(用于英文环境权限匹配) */
14
- nameEn?: string;
15
- /** 菜单唯一标识符(用于权限匹配的兜底) */
16
- nameKey?: string;
17
13
  /** 路由路径 */
18
14
  route: string;
19
- /** 路由前缀路径 */
15
+ /** 路由前缀路径(微应用在主应用中的挂载前缀,可与 route 不同) */
20
16
  base: string;
21
17
  /** 是否启用 */
22
18
  enabled: boolean;
@@ -34,7 +30,7 @@ export interface PageConfig {
34
30
  adminOnly?: boolean;
35
31
  /** 是否开启权限控制 */
36
32
  accessControlEnabled: boolean;
37
- /** 路由权限标识(用于匹配 sideMenus) */
33
+ /** 路由权限标识(与用户信息 `menu_perms`、菜单项 code 一致) */
38
34
  routeKey: string | null;
39
35
  /** 关联的主文档 ID */
40
36
  mainDocumentId: number;
@@ -7,6 +7,37 @@
7
7
  */
8
8
  import type { MenuItem, PublicPageItem } from './menu/types';
9
9
 
10
+ /**
11
+ * 根据路径查找对应的页面配置(与路由侧 `findRouteByPath` 规则一致:精确匹配 + 最长前缀通配 `/*`)
12
+ */
13
+ export const findPageByPath = (
14
+ pages: PublicPageItem[],
15
+ pathname: string,
16
+ ): PublicPageItem | undefined => {
17
+ let exact: PublicPageItem | undefined;
18
+ let bestWildcard: { page: PublicPageItem; basePath: string } | undefined;
19
+
20
+ for (const page of pages) {
21
+ if (!page.enabled) continue;
22
+
23
+ if (page.route === pathname) {
24
+ exact = page;
25
+ continue;
26
+ }
27
+
28
+ if (page.route.endsWith('/*')) {
29
+ const basePath = page.route.slice(0, -2);
30
+ if (pathname === basePath || pathname.startsWith(`${basePath}/`)) {
31
+ if (!bestWildcard || basePath.length > bestWildcard.basePath.length) {
32
+ bestWildcard = { page, basePath };
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ return exact || bestWildcard?.page;
39
+ };
40
+
10
41
  /** 获取页面列表 (window.__MICO_PAGES__) */
11
42
  export const getPages = (): PublicPageItem[] => {
12
43
  if (typeof window === 'undefined') return [];
@@ -39,7 +70,20 @@ export const getPageById = (pageId: number): PublicPageItem | undefined => {
39
70
  return getPageIdIndex().get(pageId);
40
71
  };
41
72
 
42
- /** 获取菜单项关联的页面 */
73
+ /**
74
+ * 获取菜单项关联的页面
75
+ * - 优先按 `pageId` 在页面列表中查找
76
+ * - 无匹配时再用菜单跳转路径 `path` 与页面 `route` 匹配(同 `findPageByPath`)
77
+ */
43
78
  export const getMenuPage = (item: MenuItem): PublicPageItem | undefined => {
44
- return item.pageId ? getPageById(item.pageId) : undefined;
79
+ if (item.type !== 'page') return undefined;
80
+ if (item.pageId) {
81
+ const byId = getPageById(item.pageId);
82
+ if (byId) return byId;
83
+ }
84
+ const path = item.path;
85
+ if (path) {
86
+ return findPageByPath(getPages(), path);
87
+ }
88
+ return undefined;
45
89
  };