generator-mico-cli 0.2.29 → 0.2.31

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 (96) hide show
  1. package/README.md +10 -7
  2. package/generators/micro-react/README.md +34 -0
  3. package/generators/micro-react/index.js +19 -1
  4. package/generators/micro-react/templates/CICD/start_dev.sh +11 -0
  5. package/generators/micro-react/templates/CICD/start_local.sh +9 -0
  6. package/generators/micro-react/templates/CICD/start_prod.sh +13 -0
  7. package/generators/micro-react/templates/CICD/start_test.sh +11 -0
  8. package/generators/micro-react/templates/apps/layout/config/config.dev.ts +29 -3
  9. package/generators/micro-react/templates/apps/layout/config/config.prod.development.ts +10 -0
  10. package/generators/micro-react/templates/apps/layout/config/config.prod.testing.ts +10 -0
  11. package/generators/micro-react/templates/apps/layout/config/config.prod.ts +12 -0
  12. package/generators/micro-react/templates/apps/layout/config/config.ts +0 -15
  13. package/generators/micro-react/templates/apps/layout/docs/arch-/346/227/245/345/277/227/344/270/216/345/270/270/351/207/217.md +16 -8
  14. 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 +49 -3
  15. package/generators/micro-react/templates/apps/layout/docs/feature-/345/233/275/351/231/205/345/214/226.md +121 -0
  16. 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
  17. 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 +3 -1
  18. 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 +4 -4
  19. package/generators/micro-react/templates/apps/layout/mock/menus.ts +14 -0
  20. package/generators/micro-react/templates/apps/layout/mock/pages.ts +22 -2
  21. package/generators/micro-react/templates/apps/layout/package.json +3 -2
  22. package/generators/micro-react/templates/apps/layout/src/app.tsx +68 -9
  23. package/generators/micro-react/templates/apps/layout/src/common/auth/auth-check-path.ts +14 -0
  24. package/generators/micro-react/templates/apps/layout/src/common/auth/index.ts +4 -0
  25. package/generators/micro-react/templates/apps/layout/src/common/auth/tenant.ts +25 -0
  26. package/generators/micro-react/templates/apps/layout/src/common/auth/type.ts +28 -2
  27. package/generators/micro-react/templates/apps/layout/src/common/intl/formatLayoutMessage.ts +30 -0
  28. package/generators/micro-react/templates/apps/layout/src/common/intl/index.ts +6 -0
  29. package/generators/micro-react/templates/apps/layout/src/common/intl/intlRuntime.ts +14 -0
  30. package/generators/micro-react/templates/apps/layout/src/common/intl/localeMapping.ts +30 -0
  31. package/generators/micro-react/templates/apps/layout/src/common/intl/types.ts +14 -0
  32. package/generators/micro-react/templates/apps/layout/src/common/intl/useLayoutIntl.ts +40 -0
  33. package/generators/micro-react/templates/apps/layout/src/common/logger.ts +50 -18
  34. package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +27 -0
  35. package/generators/micro-react/templates/apps/layout/src/common/micro/types.ts +23 -0
  36. package/generators/micro-react/templates/apps/layout/src/common/request/sso.ts +82 -20
  37. package/generators/micro-react/templates/apps/layout/src/common/request/token-refresh.ts +2 -0
  38. package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +28 -6
  39. package/generators/micro-react/templates/apps/layout/src/components/RightContent/TenantDropdown.tsx +76 -0
  40. package/generators/micro-react/templates/apps/layout/src/components/RightContent/index.ts +1 -0
  41. package/generators/micro-react/templates/apps/layout/src/components/RightContent/tenant-dropdown.less +48 -0
  42. package/generators/micro-react/templates/apps/layout/src/constants/index.ts +1 -0
  43. package/generators/micro-react/templates/apps/layout/src/hooks/index.ts +1 -0
  44. package/generators/micro-react/templates/apps/layout/src/hooks/useMenuState.ts +18 -0
  45. package/generators/micro-react/templates/apps/layout/src/hooks/useTenant.ts +41 -0
  46. package/generators/micro-react/templates/apps/layout/src/layouts/components/header/index.tsx +4 -1
  47. package/generators/micro-react/templates/apps/layout/src/layouts/components/menu/index.tsx +18 -6
  48. package/generators/micro-react/templates/apps/layout/src/locales/en-US.ts +11 -0
  49. package/generators/micro-react/templates/apps/layout/src/locales/zh-CN.ts +10 -0
  50. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.less +27 -0
  51. package/generators/micro-react/templates/apps/layout/src/pages/Home/index.tsx +108 -12
  52. package/generators/micro-react/templates/apps/layout/src/requestErrorConfig.ts +2 -1
  53. package/generators/micro-react/templates/apps/layout/src/services/user.ts +53 -2
  54. package/generators/micro-react/templates/apps/layout/typings.d.ts +16 -0
  55. package/generators/micro-react/templates/docs/package-shared.md +189 -0
  56. package/generators/micro-react/templates/package.json +1 -1
  57. package/generators/micro-react/templates/packages/common-intl/README.md +3 -1
  58. package/generators/micro-react/templates/packages/common-intl/package.json +1 -1
  59. package/generators/micro-react/templates/packages/common-intl/src/index.ts +4 -2
  60. package/generators/micro-react/templates/packages/common-intl/src/intl.ts +104 -8
  61. package/generators/micro-react/templates/packages/common-intl/src/umiLocaleBridge.ts +101 -0
  62. package/generators/micro-react/templates/packages/shared/README.md +120 -0
  63. package/generators/micro-react/templates/packages/shared/package.json +26 -0
  64. package/generators/micro-react/templates/packages/shared/services/common/index.ts +43 -0
  65. package/generators/micro-react/templates/packages/shared/services/index.ts +21 -0
  66. package/generators/micro-react/templates/packages/shared/services/request.ts +43 -0
  67. package/generators/micro-react/templates/packages/shared/timezone/index.ts +228 -0
  68. package/generators/micro-react/templates/packages/shared/tsconfig.json +20 -0
  69. package/generators/micro-react/templates/scripts/apply-sentry-plugin.ts +6 -1
  70. package/generators/micro-react/templates/turbo.json +9 -1
  71. package/generators/subapp-react/README.md +43 -0
  72. package/generators/subapp-react/index.js +2 -0
  73. package/generators/subapp-react/templates/homepage/README.md +5 -1
  74. package/generators/subapp-react/templates/homepage/config/config.dev.ts +20 -0
  75. package/generators/subapp-react/templates/homepage/config/config.ts +10 -15
  76. package/generators/subapp-react/templates/homepage/docs/feature-/345/233/275/351/231/205/345/214/226.md +124 -0
  77. package/generators/subapp-react/templates/homepage/package.json +2 -1
  78. package/generators/subapp-react/templates/homepage/src/app.tsx +100 -5
  79. package/generators/subapp-react/templates/homepage/src/common/intl/index.ts +15 -0
  80. package/generators/subapp-react/templates/homepage/src/common/intl/intlRuntime.ts +14 -0
  81. package/generators/subapp-react/templates/homepage/src/common/intl/localeMapping.ts +24 -0
  82. package/generators/subapp-react/templates/homepage/src/common/intl/subappIntlConfig.ts +28 -0
  83. package/generators/subapp-react/templates/homepage/src/common/intl/subappLocale.ts +18 -0
  84. package/generators/subapp-react/templates/homepage/src/common/intl/subappOwnIntl.ts +63 -0
  85. package/generators/subapp-react/templates/homepage/src/common/intl/types.ts +14 -0
  86. package/generators/subapp-react/templates/homepage/src/common/intl/useSubappIntl.ts +61 -0
  87. package/generators/subapp-react/templates/homepage/src/common/locale.ts +80 -0
  88. package/generators/subapp-react/templates/homepage/src/common/logger.ts +50 -18
  89. package/generators/subapp-react/templates/homepage/src/common/mainApp.ts +2 -0
  90. package/generators/subapp-react/templates/homepage/src/locales/en-US.ts +6 -0
  91. package/generators/subapp-react/templates/homepage/src/locales/zh-CN.ts +6 -0
  92. package/generators/subapp-react/templates/homepage/src/pages/index.less +10 -0
  93. package/generators/subapp-react/templates/homepage/src/pages/index.tsx +51 -0
  94. package/generators/subapp-react/templates/homepage/typings.d.ts +12 -0
  95. package/generators/subapp-umd/README.md +37 -0
  96. package/package.json +1 -1
@@ -215,6 +215,33 @@ declare global {
215
215
  disableAuth?: boolean;
216
216
  /** 获取时区列表的 API 地址 */
217
217
  timezoneListUrl?: string;
218
+ /**
219
+ * 多语言中台 initIntl 参数(优先于下方 commonIntl;与 packages/common-intl/src/intl.ts 合并默认项)
220
+ */
221
+ intl?: {
222
+ tag: string;
223
+ app_name: string;
224
+ indexedDBParams?: {
225
+ dbName?: string;
226
+ dbVersion?: number;
227
+ storeName?: string;
228
+ keyPathKey?: string;
229
+ };
230
+ };
231
+ /**
232
+ * 多语言中台:配置有效 `tag`、`app_name` 时主应用首屏拉取中台文案(见 packages/common-intl/README.md)
233
+ * @deprecated 优先使用 `intl`;仍保留以兼容旧 head 注入
234
+ */
235
+ commonIntl?: {
236
+ tag: string;
237
+ app_name: string;
238
+ indexedDBParams?: {
239
+ dbName?: string;
240
+ dbVersion?: number;
241
+ storeName?: string;
242
+ keyPathKey?: string;
243
+ };
244
+ };
218
245
  [key: string]: unknown;
219
246
  };
220
247
  __MICO_WORKSPACE__?: WorkspaceConfig | null;
@@ -1,3 +1,6 @@
1
+ import type { ITenantItem } from '@/common/auth/type';
2
+ import type { SupportedLocale } from '@/common/locale';
3
+
1
4
  /**
2
5
  * 微前端 Props 类型定义
3
6
  * 主应用通过 qiankun 传递给子应用的配置数据
@@ -8,6 +11,7 @@
8
11
  * - 1: 鉴权失败(token 无效/过期),需要主应用重新获取 token
9
12
  * - 2: 业务错误(参数错误、逻辑拒绝、网络超时、权限不足等)
10
13
  */
14
+
11
15
  export enum EMicroAppErrorStatus {
12
16
  /** 鉴权失败,需重获 token */
13
17
  AUTH_FAILURE = 1,
@@ -163,6 +167,25 @@ export interface IMicroAppProps {
163
167
  * 子应用可以直接使用这些服务,无需重复初始化
164
168
  */
165
169
  services?: ISharedServices;
170
+ /** 主应用标识(固定为当前主应用名) */
171
+ mainApp?: string;
172
+ /**
173
+ * 主应用的 request 实例(umi-request/axios 封装)
174
+ * 子应用可直接使用,复用主应用的拦截器与鉴权信息
175
+ */
176
+ request?: (...args: any[]) => Promise<any>;
177
+ /** 当前多语言类型(zh-CN / en-US) */
178
+ locale?: SupportedLocale;
179
+ /** 按钮级权限列表(与 currentUser.button_perms 一致,便于子应用直接消费) */
180
+ button_perms?: string[];
181
+ /** 菜单级权限列表 */
182
+ menu_perms?: string[];
183
+ /** 区域级权限列表 */
184
+ region_perms?: string[];
185
+ /** 当前租户 code(多租户模式下由主应用控制,单租户时为默认租户 code) */
186
+ current_tenant?: string;
187
+ /** 当前用户有权限的租户列表(已按 app_perms 过滤) */
188
+ allowed_tenants?: ITenantItem[];
166
189
  /** 其他扩展字段 */
167
190
  [key: string]: unknown;
168
191
  }
@@ -3,12 +3,16 @@
3
3
  */
4
4
 
5
5
  import { request as rawRequest } from '@umijs/max';
6
+ import { resolveAuthCheckPath } from '@/common/auth/auth-check-path';
6
7
  import {
7
8
  maybePersistTokens,
8
9
  setStoredAuthToken,
9
10
  } from '@/common/auth/auth-manager';
10
11
  import { isPageAuthFree } from '@/common/menu';
11
12
  import { isNoAuthRoute, ROUTES } from '@/constants';
13
+ import { captureError } from '@common-web/sentry';
14
+ import { formatLayoutMessage } from '@/common/intl';
15
+ import { Modal } from '@mico-platform/ui';
12
16
  import {
13
17
  getTicketParam,
14
18
  resolveAuthToken,
@@ -26,20 +30,46 @@ import { removeParamFromUrl } from './url-resolver';
26
30
 
27
31
  let ticketPromise: Promise<void> | null = null;
28
32
 
33
+ /** SSO 换取 token 的时间戳(毫秒),null 表示本次会话未经过 SSO 换取 */
34
+ let tokenAcquiredAt: number | null = null;
35
+
36
+ /** 登录失败弹窗是否已显示,防止多个请求同时失败时重复弹出 */
37
+ let redirectLimitModalShown = false;
38
+
39
+ /**
40
+ * 执行 SSO 重定向跳转,回跳地址中固定携带 redirect_count=1
41
+ */
42
+ const performSsoRedirect = (): void => {
43
+ const externalLoginPath = resolveExternalLoginPath();
44
+ console.log('[SSO] 执行 SSO 重定向', { externalLoginPath });
45
+
46
+ const redirectUrl = new URL(window.location.href);
47
+ redirectUrl.searchParams.delete('redirect_count');
48
+ redirectUrl.searchParams.set('redirect_count', '1');
49
+ redirectUrl.searchParams.delete('ticket');
50
+ const serviceUrl = redirectUrl.toString();
51
+
52
+ window.location.href = `${
53
+ externalLoginPath ?? ROUTES.LOGIN
54
+ }?service=${encodeURIComponent(serviceUrl)}`;
55
+ };
56
+
29
57
  /**
30
58
  * 处理认证失败后的重定向
31
59
  */
32
60
  export const handleAuthFailureRedirect = (): void => {
33
61
  if (typeof window === 'undefined') return;
34
62
 
35
- // 如果当前路由在免认证列表中,不触发 SSO 重定向
36
- if (isNoAuthRoute(window.location.pathname)) {
37
- console.log('[SSO] 当前路由在免认证列表中,跳过 SSO 重定向');
63
+ const authCheckPath = resolveAuthCheckPath(window.location.pathname);
64
+
65
+ // getInitialState 一致:用 authCheckPath 判断,避免「/ + defaultPath」首屏仍按 / 误判为免认证
66
+ if (isNoAuthRoute(authCheckPath)) {
67
+ console.log('[SSO] 当前路由在免认证列表中,跳过 SSO 重定向', { authCheckPath });
38
68
  return;
39
69
  }
40
70
 
41
- if (isPageAuthFree(window.location.pathname)) {
42
- console.log('[SSO] 页面 accessControlEnabled=false,跳过 SSO 重定向');
71
+ if (isPageAuthFree(authCheckPath)) {
72
+ console.log('[SSO] 页面 accessControlEnabled=false,跳过 SSO 重定向', { authCheckPath });
43
73
  return;
44
74
  }
45
75
 
@@ -50,23 +80,54 @@ export const handleAuthFailureRedirect = (): void => {
50
80
  ? parseInt(redirectCountParam, 10)
51
81
  : 0;
52
82
 
53
- // 如果 redirect 次数小于 1 次,则执行 redirect 跳转登录
83
+ console.log('[SSO] handleAuthFailureRedirect', { redirectCount });
84
+
85
+ // 如果 redirect 次数小于 1 次,则执行自动 redirect 跳转登录
54
86
  if (redirectCount < 1) {
55
- const externalLoginPath = resolveExternalLoginPath();
56
- console.log('handleAuthFailureRedirect', externalLoginPath);
57
-
58
- // 构建回跳地址
59
- const redirectUrl = new URL(window.location.href);
60
- redirectUrl.searchParams.delete('redirect_count');
61
- redirectUrl.searchParams.set('redirect_count', '1');
62
- const serviceUrl = redirectUrl.toString();
63
-
64
- window.location.href = `${
65
- externalLoginPath ?? ROUTES.LOGIN
66
- }?service=${encodeURIComponent(serviceUrl)}`;
67
- } else {
68
- console.warn('认证失败,但已达到最大重定向次数,停止重定向');
87
+ performSsoRedirect();
88
+ return;
89
+ }
90
+
91
+ // redirect 次数已达上限,停止自动重定向,记录日志并弹窗询问用户是否手动重试
92
+ console.warn('[SSO] 认证失败,已达到最大重定向次数,停止重定向', {
93
+ redirectCount,
94
+ });
95
+
96
+ const tokenAge =
97
+ tokenAcquiredAt !== null
98
+ ? Math.round((Date.now() - tokenAcquiredAt) / 1000)
99
+ : null;
100
+
101
+ captureError(new Error('认证失败:redirect_count 已达上限,停止重定向'), {
102
+ tags: { scene: 'auth_redirect_limit' },
103
+ extra: {
104
+ tokenAcquiredAt: tokenAcquiredAt
105
+ ? new Date(tokenAcquiredAt).toISOString()
106
+ : null,
107
+ tokenAgeSeconds: tokenAge,
108
+ },
109
+ });
110
+
111
+ if (redirectLimitModalShown) {
112
+ console.log('[SSO] 登录失败弹窗已显示,跳过重复弹出');
113
+ return;
69
114
  }
115
+
116
+ redirectLimitModalShown = true;
117
+ Modal.confirm({
118
+ title: formatLayoutMessage({ id: 'sso_auth_failure_modal_title', defaultMessage: '登录提示' }),
119
+ content: formatLayoutMessage({ id: 'sso_auth_failure_modal_content', defaultMessage: '自动登录失败,是否重新尝试登录?' }),
120
+ okText: formatLayoutMessage({ id: 'sso_auth_failure_modal_ok', defaultMessage: '重新登录' }),
121
+ cancelText: formatLayoutMessage({ id: 'sso_auth_failure_modal_cancel', defaultMessage: '取消' }),
122
+ onOk: () => {
123
+ console.log('[SSO] 用户确认重新登录,执行手动重试重定向');
124
+ redirectLimitModalShown = false;
125
+ performSsoRedirect();
126
+ },
127
+ onCancel: () => {
128
+ redirectLimitModalShown = false;
129
+ },
130
+ });
70
131
  };
71
132
 
72
133
  /**
@@ -113,6 +174,7 @@ export const ensureSsoSession = async (): Promise<void> => {
113
174
  setFetchingToken(false);
114
175
 
115
176
  if (resolveAuthToken()) {
177
+ tokenAcquiredAt = Date.now();
116
178
  processPendingRequests();
117
179
  } else {
118
180
  throw new Error('SSO 认证失败');
@@ -10,6 +10,7 @@ import {
10
10
  setStoredRefreshToken,
11
11
  } from '@/common/auth/auth-manager';
12
12
  import { resolveRefreshEndpoint } from './config';
13
+ import { buildDefaultHeaders } from './interceptors';
13
14
  import type { PendingRequest, RequestContext } from './types';
14
15
 
15
16
  // 当前是否在请求用ticket换取token
@@ -82,6 +83,7 @@ export const refreshAuthToken = async (): Promise<string | null> => {
82
83
  }>(endpoint, {
83
84
  method: 'POST',
84
85
  data: { refresh: refreshToken },
86
+ headers: buildDefaultHeaders(),
85
87
  });
86
88
 
87
89
  maybePersistTokens(result);
@@ -1,3 +1,4 @@
1
+ import type { IUserInfo } from '@/common/auth/type';
1
2
  import { getAuthInfo } from '@/common/auth/auth-manager';
2
3
  import { EEnv, getEnv } from '@/common/env';
3
4
  import { getCurrentLocale } from '@/common/locale';
@@ -75,16 +76,24 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
75
76
  : 'production';
76
77
  }, []);
77
78
 
78
- // 构建传递给子应用的 props
79
+ // 构建传递给子应用的 props(用户信息以 initialState.currentUser 为准,token 仍读存储)
79
80
  const buildProps = useCallback(() => {
80
81
  const authInfo = getAuthInfo();
81
- return {
82
+ const displayName = currentUser?.user_name || currentUser?.name || '';
83
+ // 用户有权限的租户列表:tenant ∩ app_perms(按 app_perm.key === tenant.code 匹配)
84
+ const allowedTenantCodes = new Set(
85
+ (currentUser?.app_perms ?? []).map((p) => p.key),
86
+ );
87
+ const allowedTenants = (currentUser?.tenant ?? []).filter((t) =>
88
+ allowedTenantCodes.has(t.code),
89
+ );
90
+ const props = {
82
91
  mainApp: '<%= projectName %>',
83
92
  env,
84
93
  authToken: authInfo.token,
85
- uid: authInfo.uid,
86
- avatar: authInfo.avatar,
87
- nickname: authInfo.nickname,
94
+ uid: currentUser ? String(currentUser.id) : authInfo.uid,
95
+ avatar: currentUser?.avatar ?? authInfo.avatar,
96
+ nickname: displayName || authInfo.nickname,
88
97
  base: normalizeMicroAppBase(base || '/'),
89
98
  // 传递当前路由路径,让子应用进行内部路由切换
90
99
  routePath,
@@ -94,9 +103,22 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
94
103
  locale: getCurrentLocale(),
95
104
  // 与 fetchUserInfo 一致:子应用用于按钮级权限(如 PermissionFilter)
96
105
  button_perms: currentUser?.button_perms ?? [],
106
+ menu_perms: currentUser?.menu_perms ?? [],
107
+ region_perms: currentUser?.region_perms ?? [],
97
108
  is_superuser: currentUser?.is_superuser,
109
+ // 将当前用户信息传递给子应用,仅传使用到的字段
110
+ currentUser: {
111
+ tenant: currentUser?.tenant,
112
+ isMultiTenant: currentUser?.isMultiTenant,
113
+ email: currentUser?.email,
114
+ },
115
+ current_tenant: currentUser?.current_tenant,
116
+ allowed_tenants: allowedTenants,
98
117
  };
99
- }, [base, env, routePath, currentUser]);
118
+ const { request: _request, ...loggableProps } = props;
119
+ console.log('[MicroAppLoader] buildProps', { appName, ...loggableProps });
120
+ return props;
121
+ }, [base, env, routePath, currentUser, appName]);
100
122
 
101
123
  // ref 持有最新的 buildProps,避免加载 effect 依赖它导致子应用被重载
102
124
  const buildPropsRef = useRef(buildProps);
@@ -0,0 +1,76 @@
1
+ import { setStoredTenantCode } from '@/common/auth/tenant';
2
+ import IconFont from '@/components/IconFont';
3
+ import useTenant from '@/hooks/useTenant';
4
+ import { Menu } from '@mico-platform/ui';
5
+ import { useModel } from '@umijs/max';
6
+ import React, { useMemo } from 'react';
7
+ import HeaderDropdown from '../HeaderDropdown';
8
+ import './tenant-dropdown.less';
9
+
10
+ const MenuItem = Menu.Item;
11
+
12
+ export const TenantDropdown: React.FC = () => {
13
+ const { isMultiTenant, tenants } = useTenant();
14
+ const { initialState } = useModel('@@initialState');
15
+ const currentUser = initialState?.currentUser;
16
+ const currentTenantCode = currentUser?.current_tenant;
17
+
18
+ // 仅保留 app_perms 中授权的租户(按 app_perm.key === tenant.code 匹配)
19
+ const allowedTenants = useMemo(() => {
20
+ const allowedCodes = new Set(
21
+ (currentUser?.app_perms ?? []).map((p) => p.key),
22
+ );
23
+ return tenants.filter((t) => allowedCodes.has(t.code));
24
+ }, [tenants, currentUser?.app_perms]);
25
+
26
+ const currentTenantName =
27
+ allowedTenants.find((t) => t.code === currentTenantCode)?.name || '';
28
+
29
+ if (!isMultiTenant || allowedTenants.length === 0) {
30
+ return null;
31
+ }
32
+
33
+ const onClickTenant = (code: string) => {
34
+ if (!code || code === currentTenantCode) {
35
+ return;
36
+ }
37
+ setStoredTenantCode(code);
38
+ // 参考语言/时区切换:整页 reload,确保菜单、路由、权限全部重建
39
+ window.location.reload();
40
+ };
41
+
42
+ return (
43
+ <HeaderDropdown
44
+ position="br"
45
+ triggerProps={{ popupAlign: { bottom: [0, 20] } }}
46
+ droplist={
47
+ <Menu className="tenant-dropdown-menu">
48
+ {allowedTenants.map((tenant) => {
49
+ const selected = tenant.code === currentTenantCode;
50
+ return (
51
+ <MenuItem
52
+ key={tenant.code}
53
+ onClick={() => onClickTenant(tenant.code)}
54
+ >
55
+ <span className={selected ? 'selected-label' : ''}>
56
+ {tenant.name}
57
+ </span>
58
+ </MenuItem>
59
+ );
60
+ })}
61
+ </Menu>
62
+ }
63
+ >
64
+ <div className="flex items-center tenant-dropdown-trigger">
65
+ <span className="tenant-name">{currentTenantName}</span>
66
+ <IconFont
67
+ type="webcs-outline_down1"
68
+ className="tenant-dropdown-icon"
69
+ fontSize={12}
70
+ />
71
+ </div>
72
+ </HeaderDropdown>
73
+ );
74
+ };
75
+
76
+ export default TenantDropdown;
@@ -1,2 +1,3 @@
1
1
  export { AvatarDropdown, AvatarName } from './AvatarDropdown';
2
2
  export type { GlobalHeaderRightProps } from './AvatarDropdown';
3
+ export { TenantDropdown } from './TenantDropdown';
@@ -0,0 +1,48 @@
1
+ @import '@mico-platform/theme/variables';
2
+
3
+ @tenant-dropdown-width: 140px;
4
+
5
+ .tenant-dropdown-trigger {
6
+ display: inline-flex;
7
+ justify-content: space-between;
8
+ width: @tenant-dropdown-width;
9
+ background-color: @color-fill-2;
10
+ border-radius: @border-radius-button;
11
+ height: 32px;
12
+ padding: 1px 10px;
13
+ color: @color-text-1;
14
+ cursor: pointer;
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ .tenant-dropdown-trigger:hover {
19
+ background: @color-fill-3;
20
+ }
21
+
22
+ .tenant-dropdown-trigger .tenant-name {
23
+ flex: 1;
24
+ min-width: 0;
25
+ overflow: hidden;
26
+ text-overflow: ellipsis;
27
+ white-space: nowrap;
28
+ }
29
+
30
+ .tenant-dropdown-trigger.arco-dropdown-popup-visible .arco-icon-down {
31
+ transform: rotate(180deg);
32
+ }
33
+
34
+ .tenant-dropdown-menu.arco-dropdown-menu {
35
+ width: @tenant-dropdown-width;
36
+ max-height: 320px;
37
+ overflow-y: auto;
38
+ box-sizing: border-box;
39
+ }
40
+
41
+ .tenant-dropdown-icon {
42
+ margin-left: 4px;
43
+ transition: transform 0.2s;
44
+ }
45
+
46
+ .tenant-dropdown-menu .selected-label {
47
+ color: @Brand1-6;
48
+ }
@@ -171,6 +171,7 @@ export const STORAGE_KEYS = {
171
171
  GROUPS: 'groups',
172
172
  TIMEZONE: 'mico-cs-timezone',
173
173
  TIMEZONE_REGION: 'mico-cs-timezone-region',
174
+ CURRENT_TENANT: 'mico-current-tenant',
174
175
  } as const;
175
176
 
176
177
  /**
@@ -1,3 +1,4 @@
1
1
  export { useAuth } from './useAuth';
2
2
  export { useMenu } from './useMenu';
3
+ export { useTenant } from './useTenant';
3
4
  export { useTheme } from './useTheme';
@@ -22,6 +22,8 @@ interface UseMenuStateReturn {
22
22
  isCollapsed: boolean,
23
23
  type: 'clickTrigger' | 'responsive',
24
24
  ) => void;
25
+ handleMouseEnter: () => void;
26
+ handleMouseLeave: () => void;
25
27
  setOpenKeys: (keys: string[]) => void;
26
28
  }
27
29
 
@@ -98,6 +100,20 @@ export const useMenuState = ({
98
100
  [triggerMode],
99
101
  );
100
102
 
103
+ // hover 模式下鼠标进入自动展开
104
+ const handleMouseEnter = useCallback(() => {
105
+ if (triggerMode === ETriggerMode.HOVER_TRIGGER) {
106
+ setCollapsed(false);
107
+ }
108
+ }, [triggerMode]);
109
+
110
+ // hover 模式下鼠标离开自动收起
111
+ const handleMouseLeave = useCallback(() => {
112
+ if (triggerMode === ETriggerMode.HOVER_TRIGGER) {
113
+ setCollapsed(true);
114
+ }
115
+ }, [triggerMode]);
116
+
101
117
  return {
102
118
  selectedKeys,
103
119
  openKeys,
@@ -105,6 +121,8 @@ export const useMenuState = ({
105
121
  triggerMode,
106
122
  handleClickMenuItem,
107
123
  handleCollapsed,
124
+ handleMouseEnter,
125
+ handleMouseLeave,
108
126
  setOpenKeys,
109
127
  };
110
128
  };
@@ -0,0 +1,41 @@
1
+ import { ETenantModel, type ITenantItem } from '@/common/auth/type';
2
+ import { useModel } from '@umijs/max';
3
+
4
+ export interface IUseTenantResult {
5
+ /** 是否多租户模式 */
6
+ isMultiTenant: boolean;
7
+ /** 当前租户模式原始值(1 单租户,2 多租户) */
8
+ tenantModel: number;
9
+ /** 用户所属租户列表 */
10
+ tenants: ITenantItem[];
11
+ }
12
+
13
+ /**
14
+ * 租户模式 Hook
15
+ *
16
+ * 从 initialState.currentUser 读取 tenant_model / tenant 字段,
17
+ * 对外暴露语义化的 isMultiTenant 标志和 tenants 列表,供业务组件直接使用。
18
+ *
19
+ * @example
20
+ * const { isMultiTenant, tenants } = useTenant();
21
+ * if (isMultiTenant) {
22
+ * // 渲染租户选择器
23
+ * }
24
+ */
25
+ export function useTenant(): IUseTenantResult {
26
+ const { initialState } = useModel('@@initialState');
27
+ const currentUser = initialState?.currentUser;
28
+
29
+ const tenantModel = currentUser?.tenant_model ?? ETenantModel.Single;
30
+ const tenants = currentUser?.tenant ?? [];
31
+ const isMultiTenant =
32
+ currentUser?.isMultiTenant ?? tenantModel === ETenantModel.Multi;
33
+
34
+ return {
35
+ isMultiTenant,
36
+ tenantModel,
37
+ tenants,
38
+ };
39
+ }
40
+
41
+ export default useTenant;
@@ -1,4 +1,4 @@
1
- import { AvatarDropdown } from '@/components/RightContent';
1
+ import { AvatarDropdown, TenantDropdown } from '@/components/RightContent';
2
2
  import { DEFAULT_NAME } from '@/constants';
3
3
  import { Layout, Space } from '@mico-platform/ui';
4
4
  import React from 'react';
@@ -28,6 +28,9 @@ const LayoutHeader: React.FC = () => {
28
28
  {theme === 'dark' ? <IconSunFill /> : <IconMoonFill />}
29
29
  </div> */}
30
30
 
31
+ {/* Tenant switcher (仅多租户模式可见) */}
32
+ <TenantDropdown />
33
+
31
34
  <span className="split-line" />
32
35
 
33
36
  {/* User info */}
@@ -140,19 +140,31 @@ const LayoutMenu: React.FC<LayoutMenuProps> = () => {
140
140
  collapsed,
141
141
  handleClickMenuItem,
142
142
  handleCollapsed,
143
+ handleMouseEnter,
144
+ handleMouseLeave,
143
145
  setOpenKeys,
144
146
  } = useMenuState({ menuItems });
145
147
 
146
148
  useEffect(() => {
147
- document.documentElement.style.setProperty(
148
- '--sider-width',
149
- collapsed ? '48px' : '200px',
150
- );
149
+ const layoutContent = document.querySelector('.layout-content');
150
+ if (layoutContent) {
151
+ (layoutContent as HTMLElement).style.setProperty(
152
+ '--sider-width',
153
+ collapsed ? '48px' : '200px',
154
+ );
155
+ }
156
+ }, [collapsed]);
151
157
 
158
+ useEffect(() => {
159
+ const sider = siderRef.current;
160
+ if (!sider) return;
161
+ sider.addEventListener('mouseenter', handleMouseEnter);
162
+ sider.addEventListener('mouseleave', handleMouseLeave);
152
163
  return () => {
153
- document.documentElement.style.removeProperty('--sider-width');
164
+ sider.removeEventListener('mouseenter', handleMouseEnter);
165
+ sider.removeEventListener('mouseleave', handleMouseLeave);
154
166
  };
155
- }, [collapsed]);
167
+ }, [handleMouseEnter, handleMouseLeave]);
156
168
 
157
169
  // 点击触发按钮图标
158
170
  const clickTriggerBtnIcon = collapsed
@@ -38,6 +38,17 @@ export default {
38
38
  'cs_web_menu_permission_management': 'Permission Management',
39
39
  'cs_web_menu_group_management': 'Group Management',
40
40
 
41
+ // SSO auth failure modal
42
+ 'sso.auth.failure.modal.title': 'Login Required',
43
+ 'sso.auth.failure.modal.content':
44
+ 'Auto login failed. Would you like to try logging in again?',
45
+ 'sso.auth.failure.modal.ok': 'Re-login',
46
+ 'sso.auth.failure.modal.cancel': 'Cancel',
47
+ sso_auth_failure_modal_title: 'Login Required',
48
+ sso_auth_failure_modal_content: 'Auto login failed. Would you like to try logging in again?',
49
+ sso_auth_failure_modal_ok: 'Re-login',
50
+ sso_auth_failure_modal_cancel: 'Cancel',
51
+
41
52
  // AvatarDropdown
42
53
  'avatar.language': 'Language',
43
54
  'avatar.language.zh_CN': 'Simplified Chinese',
@@ -36,6 +36,16 @@ export default {
36
36
  'cs_web_menu_permission_management': '权限管理',
37
37
  'cs_web_menu_group_management': '小组管理',
38
38
 
39
+ // SSO 认证失败弹框
40
+ 'sso.auth.failure.modal.title': '登录提示',
41
+ 'sso.auth.failure.modal.content': '自动登录失败,是否重新尝试登录?',
42
+ 'sso.auth.failure.modal.ok': '重新登录',
43
+ 'sso.auth.failure.modal.cancel': '取消',
44
+ sso_auth_failure_modal_title: '登录提示',
45
+ sso_auth_failure_modal_content: '自动登录失败,是否重新尝试登录?',
46
+ sso_auth_failure_modal_ok: '重新登录',
47
+ sso_auth_failure_modal_cancel: '取消',
48
+
39
49
  // AvatarDropdown
40
50
  'avatar.language': '语言',
41
51
  'avatar.language.zh_CN': '简体中文',
@@ -5,7 +5,34 @@
5
5
  color: @color-text-1;
6
6
  }
7
7
 
8
+ .title {
9
+ margin: 0 0 12px;
10
+ font-size: 22px;
11
+ font-weight: 600;
12
+ }
13
+
14
+ .hint {
15
+ margin: 0 0 8px;
16
+ font-size: 14px;
17
+ line-height: 1.6;
18
+ color: @color-text-2;
19
+ }
20
+
21
+ .meta {
22
+ margin: 0;
23
+ font-size: 12px;
24
+ font-family: ui-monospace, monospace;
25
+ color: @color-text-3;
26
+ }
27
+
8
28
  .demoCard {
9
29
  margin-top: 16px;
10
30
  max-width: 720px;
11
31
  }
32
+
33
+ .tzDemoList {
34
+ width: 100%;
35
+ font-family: ui-monospace, monospace;
36
+ font-size: 12px;
37
+ line-height: 1.8;
38
+ }