generator-mico-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.
Files changed (112) hide show
  1. package/README.md +7 -20
  2. package/bin/mico.js +27 -62
  3. package/generators/micro-react/index.js +25 -1
  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/CICD/start_dev.sh +11 -0
  7. package/generators/micro-react/templates/CICD/start_local.sh +9 -0
  8. package/generators/micro-react/templates/CICD/start_prod.sh +13 -0
  9. package/generators/micro-react/templates/CICD/start_test.sh +11 -0
  10. package/generators/micro-react/templates/CLAUDE.md +1 -0
  11. package/generators/micro-react/templates/README.md +1 -1
  12. package/generators/micro-react/templates/apps/layout/config/config.dev.ts +13 -5
  13. package/generators/micro-react/templates/apps/layout/config/config.prod.development.ts +12 -0
  14. package/generators/micro-react/templates/apps/layout/config/config.prod.testing.ts +12 -0
  15. package/generators/micro-react/templates/apps/layout/config/config.prod.ts +14 -0
  16. 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
  17. package/generators/micro-react/templates/apps/layout/docs/feature-/345/233/275/351/231/205/345/214/226.md +121 -0
  18. 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 +8 -0
  19. 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
  20. 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
  21. 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
  22. package/generators/micro-react/templates/apps/layout/mock/api.mock.ts +23 -31
  23. package/generators/micro-react/templates/apps/layout/mock/menus.ts +14 -0
  24. package/generators/micro-react/templates/apps/layout/mock/pages.ts +27 -8
  25. package/generators/micro-react/templates/apps/layout/package.json +2 -0
  26. package/generators/micro-react/templates/apps/layout/src/app.tsx +85 -4
  27. package/generators/micro-react/templates/apps/layout/src/common/auth/index.ts +3 -0
  28. package/generators/micro-react/templates/apps/layout/src/common/auth/tenant.ts +25 -0
  29. package/generators/micro-react/templates/apps/layout/src/common/auth/type.ts +41 -27
  30. package/generators/micro-react/templates/apps/layout/src/common/intl/formatLayoutMessage.ts +30 -0
  31. package/generators/micro-react/templates/apps/layout/src/common/intl/index.ts +6 -0
  32. package/generators/micro-react/templates/apps/layout/src/common/intl/intlRuntime.ts +14 -0
  33. package/generators/micro-react/templates/apps/layout/src/common/intl/localeMapping.ts +30 -0
  34. package/generators/micro-react/templates/apps/layout/src/common/intl/types.ts +14 -0
  35. package/generators/micro-react/templates/apps/layout/src/common/intl/useLayoutIntl.ts +40 -0
  36. package/generators/micro-react/templates/apps/layout/src/common/logger.ts +3 -4
  37. package/generators/micro-react/templates/apps/layout/src/common/menu/parser.ts +148 -85
  38. package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +29 -6
  39. package/generators/micro-react/templates/apps/layout/src/common/micro/types.ts +23 -0
  40. package/generators/micro-react/templates/apps/layout/src/common/portal-data.ts +46 -2
  41. package/generators/micro-react/templates/apps/layout/src/common/request/sso.ts +74 -15
  42. package/generators/micro-react/templates/apps/layout/src/common/request/token-refresh.ts +2 -0
  43. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +32 -6
  44. package/generators/micro-react/templates/apps/layout/src/components/PermissionFilter/index.tsx +51 -0
  45. package/generators/micro-react/templates/apps/layout/src/components/RightContent/AvatarDropdown.tsx +10 -1
  46. package/generators/micro-react/templates/apps/layout/src/components/RightContent/TenantDropdown.tsx +76 -0
  47. package/generators/micro-react/templates/apps/layout/src/components/RightContent/index.ts +1 -0
  48. package/generators/micro-react/templates/apps/layout/src/components/RightContent/tenant-dropdown.less +48 -0
  49. package/generators/micro-react/templates/apps/layout/src/constants/index.ts +1 -0
  50. package/generators/micro-react/templates/apps/layout/src/hooks/index.ts +1 -0
  51. package/generators/micro-react/templates/apps/layout/src/hooks/useMenuState.ts +18 -0
  52. package/generators/micro-react/templates/apps/layout/src/hooks/useTenant.ts +41 -0
  53. package/generators/micro-react/templates/apps/layout/src/layouts/components/header/index.tsx +4 -1
  54. package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +21 -9
  55. package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +105 -60
  56. package/generators/micro-react/templates/apps/layout/src/locales/en-US.ts +28 -0
  57. package/generators/micro-react/templates/apps/layout/src/locales/zh-CN.ts +26 -0
  58. package/generators/micro-react/templates/apps/layout/src/pages/404/index.tsx +7 -3
  59. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.less +32 -0
  60. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.tsx +148 -4
  61. package/generators/micro-react/templates/apps/layout/src/requestErrorConfig.ts +2 -1
  62. package/generators/micro-react/templates/apps/layout/src/services/user.ts +79 -21
  63. package/generators/micro-react/templates/apps/layout/typings.d.ts +16 -0
  64. package/generators/micro-react/templates/docs/package-shared.md +189 -0
  65. package/generators/micro-react/templates/package.json +1 -1
  66. package/generators/micro-react/templates/packages/common-intl/README.md +78 -368
  67. package/generators/micro-react/templates/packages/common-intl/package.json +3 -13
  68. package/generators/micro-react/templates/packages/common-intl/src/index.ts +5 -6
  69. package/generators/micro-react/templates/packages/common-intl/src/intl.ts +115 -28
  70. package/generators/micro-react/templates/packages/common-intl/src/umiLocaleBridge.ts +101 -0
  71. package/generators/micro-react/templates/packages/common-intl/tsconfig.json +2 -4
  72. package/generators/micro-react/templates/packages/shared/README.md +120 -0
  73. package/generators/micro-react/templates/packages/shared/package.json +26 -0
  74. package/generators/micro-react/templates/packages/shared/services/common/index.ts +43 -0
  75. package/generators/micro-react/templates/packages/shared/services/index.ts +21 -0
  76. package/generators/micro-react/templates/packages/shared/services/request.ts +43 -0
  77. package/generators/micro-react/templates/packages/shared/timezone/index.ts +228 -0
  78. package/generators/micro-react/templates/packages/shared/tsconfig.json +20 -0
  79. package/generators/micro-react/templates/scripts/apply-sentry-plugin.ts +6 -1
  80. package/generators/micro-react/templates/turbo.json +9 -1
  81. package/generators/subapp-react/index.js +28 -22
  82. package/generators/subapp-react/templates/homepage/README.md +1 -0
  83. package/generators/subapp-react/templates/homepage/config/config.prod.development.ts +1 -0
  84. package/generators/subapp-react/templates/homepage/config/config.prod.testing.ts +1 -0
  85. package/generators/subapp-react/templates/homepage/config/config.prod.ts +1 -0
  86. package/generators/subapp-react/templates/homepage/config/config.ts +10 -0
  87. package/generators/subapp-react/templates/homepage/docs/feature-PermissionFilter/346/214/211/351/222/256/346/235/203/351/231/220.md +35 -0
  88. package/generators/subapp-react/templates/homepage/docs/feature-/345/233/275/351/231/205/345/214/226.md +124 -0
  89. package/generators/subapp-react/templates/homepage/package.json +3 -1
  90. package/generators/subapp-react/templates/homepage/src/app.tsx +104 -2
  91. package/generators/subapp-react/templates/homepage/src/common/intl/index.ts +15 -0
  92. package/generators/subapp-react/templates/homepage/src/common/intl/intlRuntime.ts +14 -0
  93. package/generators/subapp-react/templates/homepage/src/common/intl/localeMapping.ts +24 -0
  94. package/generators/subapp-react/templates/homepage/src/common/intl/subappIntlConfig.ts +28 -0
  95. package/generators/subapp-react/templates/homepage/src/common/intl/subappLocale.ts +18 -0
  96. package/generators/subapp-react/templates/homepage/src/common/intl/subappOwnIntl.ts +63 -0
  97. package/generators/subapp-react/templates/homepage/src/common/intl/types.ts +14 -0
  98. package/generators/subapp-react/templates/homepage/src/common/intl/useSubappIntl.ts +61 -0
  99. package/generators/subapp-react/templates/homepage/src/common/locale.ts +80 -0
  100. package/generators/subapp-react/templates/homepage/src/common/mainApp.ts +41 -2
  101. package/generators/subapp-react/templates/homepage/src/components/PermissionFilter/index.tsx +48 -0
  102. package/generators/subapp-react/templates/homepage/src/locales/en-US.ts +6 -0
  103. package/generators/subapp-react/templates/homepage/src/locales/zh-CN.ts +6 -0
  104. package/generators/subapp-react/templates/homepage/src/pages/index.less +10 -0
  105. package/generators/subapp-react/templates/homepage/src/pages/index.tsx +86 -1
  106. package/generators/subapp-react/templates/homepage/typings.d.ts +12 -0
  107. package/lib/utils.js +0 -1
  108. package/package.json +2 -2
  109. package/generators/micro-react/templates/apps/layout/docs/common-intl.md +0 -372
  110. package/generators/micro-react/templates/packages/common-intl/src/indexedDBUtils.ts +0 -51
  111. package/generators/micro-react/templates/packages/common-intl/src/utils.ts +0 -482
  112. package/generators/micro-react/templates/packages/common-intl/vite.config.ts +0 -25
@@ -0,0 +1,25 @@
1
+ import { getFromStorage, removeStorage, setStorage } from '@/common/helpers';
2
+ import { STORAGE_KEYS } from '@/constants';
3
+
4
+ /**
5
+ * 当前租户 code 存储工具
6
+ *
7
+ * 多租户模式下,当前选中的租户会被持久化到 localStorage,
8
+ * 在调用 `fetchUserInfo` 时作为 `tenant` 参数传给后端,
9
+ * 以获取对应租户的 menu_perms / button_perms / region_perms。
10
+ */
11
+
12
+ export const getStoredTenantCode = (): string | null => {
13
+ return getFromStorage(STORAGE_KEYS.CURRENT_TENANT);
14
+ };
15
+
16
+ export const setStoredTenantCode = (code: string): void => {
17
+ if (!code) {
18
+ return;
19
+ }
20
+ setStorage(STORAGE_KEYS.CURRENT_TENANT, code);
21
+ };
22
+
23
+ export const clearStoredTenantCode = (): void => {
24
+ removeStorage(STORAGE_KEYS.CURRENT_TENANT);
25
+ };
@@ -8,40 +8,54 @@ export const UID = 'uid';
8
8
  /** 微应用模式下主应用传递的环境标识 */
9
9
  export const MICRO_ENV_KEY = 'micro_env';
10
10
 
11
+ export interface IAppPerms {
12
+ key: string;
13
+ value: string;
14
+ }
15
+
16
+ export interface ITenantItem {
17
+ /** 租户名 */
18
+ name: string;
19
+ /** 租户 code */
20
+ code: string;
21
+ }
22
+
23
+ /** 租户模式枚举 */
24
+ export enum ETenantModel {
25
+ Single = 1,
26
+ Multi = 2,
27
+ }
28
+
11
29
  /**
12
- * 权限树节点
30
+ * OpenAPI — GET /user/info/ 响应 `data`
13
31
  */
14
- export interface IPermissionNode {
32
+ export interface IUserInfoApiData {
15
33
  id: number;
34
+ avatar: string;
35
+ email: string;
16
36
  name: string;
17
- sort: number;
18
- level: number;
19
- parent: number | null;
20
- codename: string;
21
- sub_menu?: IPermissionNode[];
37
+ app_perms: IAppPerms[];
38
+ region_perms: string[];
39
+ menu_perms: string[];
40
+ button_perms: string[];
41
+ is_superuser: boolean;
42
+ /** 租户模式:1 单租户,2 多租户 */
43
+ tenant_model: number;
44
+ /** 用户所属租户列表 */
45
+ tenant: ITenantItem[];
46
+ /** 当前租户 code,对应 ITenantItem.code */
47
+ current_tenant: string;
22
48
  }
23
49
 
24
50
  /**
25
- * 完整用户信息(/api/user/info 接口返回的 data 字段)
51
+ * 应用内用户信息(initialState.currentUser)
52
+ * 在 `IUserInfoApiData` 基础上补充展示用字段(由 fetchUserInfo normalize)
26
53
  */
27
- export interface IUserInfo {
28
- id: number;
29
- username: string;
30
- email: string;
54
+ export interface IUserInfo extends IUserInfoApiData {
55
+ /** 展示名,通常来自接口 `name` */
31
56
  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[];
57
+ /** 登录名/账号展示,可与 `name` 或 `email` 一致 */
58
+ username: string;
59
+ /** 是否多租户模式(tenant_model === 2 的语义化快捷字段) */
60
+ isMultiTenant: boolean;
47
61
  }
@@ -0,0 +1,30 @@
1
+ import { i18n } from '<%= packageScope %>/common-intl';
2
+ import { getIntl } from '@umijs/max';
3
+ import { isCommonIntlEnabled } from './intlRuntime';
4
+
5
+ /**
6
+ * 非组件上下文(如 request 拦截器、工具函数)的国际化方法。
7
+ *
8
+ * 与 useLayoutIntl.formatMessage 逻辑一致:
9
+ * - commonIntl 未启用 → 直接走 Umi getIntl()
10
+ * - commonIntl 启用 → 远程 i18n() 优先,返回 defaultMessage 时回退到 Umi
11
+ */
12
+ export function formatLayoutMessage(
13
+ descriptor: { id: string; defaultMessage?: string },
14
+ values?: Record<string, string | number | boolean | Date | null | undefined>,
15
+ ): string {
16
+ const umiIntl = getIntl();
17
+ const dm = descriptor.defaultMessage ?? descriptor.id;
18
+
19
+ if (!isCommonIntlEnabled()) {
20
+ return umiIntl.formatMessage(descriptor, values);
21
+ }
22
+
23
+ const fromIntl = i18n({ key: descriptor.id, defaultMessage: dm });
24
+ const fromUmi = umiIntl.formatMessage(descriptor, values);
25
+
26
+ if (fromIntl === dm && fromUmi !== fromIntl) {
27
+ return fromUmi || dm;
28
+ }
29
+ return fromIntl;
30
+ }
@@ -0,0 +1,6 @@
1
+ export { intl } from '<%= packageScope %>/common-intl';
2
+ export { getCommonIntlConfig, isCommonIntlEnabled } from './intlRuntime';
3
+ export { ilangToUmiLocale, umiLocaleToILang } from './localeMapping';
4
+ export type { IMicoConfigCommonIntl } from './types';
5
+ export { useLayoutIntl } from './useLayoutIntl';
6
+ export { formatLayoutMessage } from './formatLayoutMessage';
@@ -0,0 +1,14 @@
1
+ import type { IMicoConfigCommonIntl } from './types';
2
+
3
+ /**
4
+ * 是否启用多语言中台拉取:以 __MICO_CONFIG__.intl 或 commonIntl 含有效 tag、app_name 为准
5
+ */
6
+ export function isCommonIntlEnabled(): boolean {
7
+ const c = getCommonIntlConfig();
8
+ return !!(c?.tag && c?.app_name);
9
+ }
10
+
11
+ export function getCommonIntlConfig(): IMicoConfigCommonIntl | undefined {
12
+ const w = window.__MICO_CONFIG__;
13
+ return w?.intl ?? w?.commonIntl;
14
+ }
@@ -0,0 +1,30 @@
1
+ import { LANG, type ILang } from '<%= packageScope %>/common-intl';
2
+ import type { SupportedLocale } from '@/common/locale';
3
+ import { LOCALE } from '@/common/locale';
4
+
5
+ /** 与 packages/common-intl/src/umiLocaleBridge.ts 保持一致 */
6
+ const UMI_TO_ILANG: Record<SupportedLocale, ILang> = {
7
+ [LOCALE.ZH_CN]: LANG.ZH_CN,
8
+ [LOCALE.EN_US]: LANG.EN,
9
+ };
10
+
11
+ /**
12
+ * Umi 权威 locale → common-intl 中台 / 缓存键(ILang)
13
+ */
14
+ export function umiLocaleToILang(locale: SupportedLocale): ILang {
15
+ return UMI_TO_ILANG[locale] ?? LANG.EN;
16
+ }
17
+
18
+ const ILANG_TO_UMI: Partial<Record<ILang, SupportedLocale>> = {
19
+ [LANG.ZH_CN]: LOCALE.ZH_CN,
20
+ [LANG.EN]: LOCALE.EN_US,
21
+ [LANG.AR]: LOCALE.EN_US,
22
+ [LANG.TR]: LOCALE.EN_US,
23
+ };
24
+
25
+ /**
26
+ * ILang → Umi 存储用 locale(layout 仅支持 zh-CN / en-US 时,其余语系落到 en-US)
27
+ */
28
+ export function ilangToUmiLocale(lang: ILang): SupportedLocale {
29
+ return ILANG_TO_UMI[lang] ?? LOCALE.EN_US;
30
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * layout 运行时 common-intl 配置(见 window.__MICO_CONFIG__.intl,兼容 commonIntl)
3
+ */
4
+ export interface IMicoConfigCommonIntl {
5
+ /** 多语言中台 tag */
6
+ tag: string;
7
+ app_name: string;
8
+ indexedDBParams?: {
9
+ dbName?: string;
10
+ dbVersion?: number;
11
+ storeName?: string;
12
+ keyPathKey?: string;
13
+ };
14
+ }
@@ -0,0 +1,40 @@
1
+ import { i18n, intl } from '<%= packageScope %>/common-intl';
2
+ import { useIntl } from '@umijs/max';
3
+ import { getCurrentLocale } from '@/common/locale';
4
+ import { isCommonIntlEnabled } from './intlRuntime';
5
+
6
+ /**
7
+ * layout 统一国际化 Hook:未配置 commonIntl 时仅 Umi;配置后 common-intl 优先,无命中时 Umi 兜底。
8
+ */
9
+ export function useLayoutIntl() {
10
+ const umiIntl = useIntl();
11
+ const commonIntlEnabled = isCommonIntlEnabled();
12
+ const locale = getCurrentLocale();
13
+
14
+ const formatMessage = (
15
+ descriptor: { id: string; defaultMessage?: string },
16
+ values?: Record<string, string | number | boolean | Date | null | undefined>,
17
+ ): string => {
18
+ if (!commonIntlEnabled) {
19
+ return umiIntl.formatMessage(descriptor, values);
20
+ }
21
+ const dm = descriptor.defaultMessage ?? descriptor.id;
22
+ const fromIntl = i18n({
23
+ key: descriptor.id,
24
+ defaultMessage: dm,
25
+ });
26
+ const fromUmi = umiIntl.formatMessage(descriptor, values);
27
+ if (fromIntl === dm && fromUmi !== fromIntl) {
28
+ return fromUmi;
29
+ }
30
+ return fromIntl;
31
+ };
32
+
33
+ return {
34
+ commonIntlEnabled,
35
+ locale,
36
+ formatMessage,
37
+ /** 仅 common-intl 启用时有值 */
38
+ intl: commonIntlEnabled ? intl : undefined,
39
+ };
40
+ }
@@ -15,18 +15,17 @@ const noop = (): void => {};
15
15
  * 使用 bind 绑定前缀,保持控制台显示正确的调用位置
16
16
  */
17
17
  const createLogger = (prefix: string) => {
18
+ const formattedPrefix = `[${prefix}]`;
18
19
  if (!isDev) {
19
- // 生产环境返回空操作
20
+ // 生产环境返回空操作,但 error 仍然输出(便于生产排查)
20
21
  return {
21
22
  log: noop,
22
23
  info: noop,
23
24
  warn: noop,
24
- error: noop,
25
+ error: console.error.bind(console, new Error(formattedPrefix)),
25
26
  };
26
27
  }
27
28
 
28
- const formattedPrefix = `[${prefix}]`;
29
-
30
29
  return {
31
30
  log: console.log.bind(console, formattedPrefix),
32
31
  info: console.info.bind(console, formattedPrefix),
@@ -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
  }