generator-mico-cli 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/generators/micro-react/templates/apps/layout/config/config.dev.ts +1 -0
- package/generators/micro-react/templates/apps/layout/src/app.tsx +70 -2
- package/generators/micro-react/templates/apps/layout/src/common/menu/types.ts +2 -0
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/container-manager.ts +152 -20
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +53 -15
- package/generators/micro-react/templates/apps/layout/src/hooks/useRoutePermissionRefresh.ts +69 -0
- package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +10 -0
- package/generators/micro-react/templates/package.json +5 -0
- package/generators/subapp-react/templates/homepage/config/config.prod.development.ts +0 -4
- package/generators/subapp-react/templates/homepage/config/config.prod.testing.ts +0 -4
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { history, type RequestConfig } from '@umijs/max';
|
|
2
|
-
import { prefetchApps } from 'qiankun';
|
|
1
|
+
import { history, Navigate, type RequestConfig } from '@umijs/max';
|
|
2
|
+
import { addGlobalUncaughtErrorHandler, prefetchApps } from 'qiankun';
|
|
3
3
|
import { errorConfig } from './requestErrorConfig';
|
|
4
4
|
// 解决「React19 中无法使用 Message/Notification」的问题。 @see https://github.com/arco-design/arco-design/issues/2900#issuecomment-2796571653
|
|
5
5
|
import * as arco from '@arco-design/web-react';
|
|
@@ -21,6 +21,57 @@ import MicroAppLoader from './components/MicroAppLoader';
|
|
|
21
21
|
import { NO_AUTH_ROUTE_LIST } from './constants';
|
|
22
22
|
import './global.less';
|
|
23
23
|
|
|
24
|
+
// ==================== qiankun 全局错误处理 ====================
|
|
25
|
+
// 捕获子应用运行时未捕获的异常,防止页面崩溃
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 注册 qiankun 全局未捕获错误处理器
|
|
29
|
+
* 处理以下场景:
|
|
30
|
+
* 1. 子应用 JS 运行时错误
|
|
31
|
+
* 2. 子应用生命周期钩子抛出的异常
|
|
32
|
+
* 3. 子应用资源加载失败
|
|
33
|
+
*/
|
|
34
|
+
if (typeof window !== 'undefined') {
|
|
35
|
+
addGlobalUncaughtErrorHandler((event: Event | string) => {
|
|
36
|
+
// 提取错误信息
|
|
37
|
+
const error =
|
|
38
|
+
event instanceof ErrorEvent
|
|
39
|
+
? event.error
|
|
40
|
+
: event instanceof PromiseRejectionEvent
|
|
41
|
+
? event.reason
|
|
42
|
+
: event;
|
|
43
|
+
|
|
44
|
+
const errorMessage =
|
|
45
|
+
error instanceof Error
|
|
46
|
+
? error.message
|
|
47
|
+
: typeof error === 'string'
|
|
48
|
+
? error
|
|
49
|
+
: 'Unknown micro-app error';
|
|
50
|
+
|
|
51
|
+
console.error('[qiankun] Global uncaught error:', {
|
|
52
|
+
error,
|
|
53
|
+
message: errorMessage,
|
|
54
|
+
type: event instanceof Event ? event.type : 'unknown',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 检查是否是子应用加载失败(资源 404 等)
|
|
58
|
+
const isLoadError =
|
|
59
|
+
errorMessage.includes('Failed to fetch') ||
|
|
60
|
+
errorMessage.includes('Loading chunk') ||
|
|
61
|
+
errorMessage.includes('load') ||
|
|
62
|
+
errorMessage.includes('Script error');
|
|
63
|
+
|
|
64
|
+
if (isLoadError) {
|
|
65
|
+
console.error(
|
|
66
|
+
'[qiankun] Micro-app resource loading failed. Please check if the micro-app server is running.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 注意:这里不阻止错误冒泡,让控制台仍能显示原始错误
|
|
71
|
+
// 如果需要阻止,可以 return true
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
24
75
|
// ==================== 微应用预加载 ====================
|
|
25
76
|
// 预加载所有微应用资源,避免快速切换时的竞态条件
|
|
26
77
|
// 当资源已预加载时,loadMicroApp 的异步加载会快速完成,减少容器不存在的错误
|
|
@@ -226,6 +277,23 @@ export function patchClientRoutes({ routes }: { routes: UmiRoute[] }) {
|
|
|
226
277
|
});
|
|
227
278
|
}
|
|
228
279
|
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @name 路由变化处理
|
|
283
|
+
* @description 处理 defaultPath 重定向:访问 "/" 时自动跳转到配置的默认路径
|
|
284
|
+
* @doc https://umijs.org/docs/api/runtime-config#onroutechange
|
|
285
|
+
*/
|
|
286
|
+
export function onRouteChange({
|
|
287
|
+
location,
|
|
288
|
+
}: {
|
|
289
|
+
location: { pathname: string };
|
|
290
|
+
}) {
|
|
291
|
+
const defaultPath = window.__MICO_CONFIG__?.defaultPath;
|
|
292
|
+
if (location.pathname === '/' && defaultPath && defaultPath !== '/') {
|
|
293
|
+
history.replace(defaultPath);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
229
297
|
/**
|
|
230
298
|
* @name qiankun 微前端子应用生命周期
|
|
231
299
|
* @description 当应用作为 qiankun 子应用运行时,这些生命周期会被调用
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* 1. 容器在 document.body 中创建,与 React 生命周期解耦
|
|
6
6
|
* 2. 激活时移动到占位元素内,参与正常文档流
|
|
7
7
|
* 3. 停用时移回 body 并隐藏
|
|
8
|
+
* 4. 加载状态锁防止并发加载冲突
|
|
9
|
+
* 5. unmount 超时机制防止子应用卡死
|
|
8
10
|
*
|
|
9
11
|
* 这样既避免了 React unmount 时删除容器导致的竞态问题,
|
|
10
12
|
* 又能让容器在激活时参与正常的 CSS 布局。
|
|
@@ -19,12 +21,18 @@ import type { MicroApp } from 'qiankun';
|
|
|
19
21
|
// ============================================================================
|
|
20
22
|
|
|
21
23
|
type ContainerStatus = 'active' | 'hidden' | 'pending-delete';
|
|
24
|
+
type LoadingStatus = 'idle' | 'loading' | 'mounted' | 'unmounting';
|
|
22
25
|
|
|
23
26
|
interface ContainerEntry {
|
|
24
27
|
container: HTMLElement;
|
|
25
28
|
microApp: MicroApp | null;
|
|
26
29
|
status: ContainerStatus;
|
|
30
|
+
loadingStatus: LoadingStatus;
|
|
27
31
|
deleteTimer: ReturnType<typeof setTimeout> | null;
|
|
32
|
+
/** 当前加载会话 ID,用于取消过期的加载操作 */
|
|
33
|
+
loadSessionId: number;
|
|
34
|
+
/** 等待卸载完成的 Promise */
|
|
35
|
+
unmountPromise: Promise<void> | null;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
// ============================================================================
|
|
@@ -34,6 +42,9 @@ interface ContainerEntry {
|
|
|
34
42
|
/** 容器删除延迟(毫秒)- 给 qiankun 足够时间完成清理 */
|
|
35
43
|
const DELETE_DELAY = 5000;
|
|
36
44
|
|
|
45
|
+
/** unmount 超时时间(毫秒)- 防止子应用卡死 */
|
|
46
|
+
const UNMOUNT_TIMEOUT = 10000;
|
|
47
|
+
|
|
37
48
|
/** CSS 类名 */
|
|
38
49
|
const CSS_CLASS = {
|
|
39
50
|
base: 'micro-app-container-managed',
|
|
@@ -47,6 +58,40 @@ const CSS_CLASS = {
|
|
|
47
58
|
|
|
48
59
|
const containers = new Map<string, ContainerEntry>();
|
|
49
60
|
|
|
61
|
+
/** 全局会话 ID 计数器 */
|
|
62
|
+
let globalSessionId = 0;
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// 工具函数
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 带超时的 Promise
|
|
70
|
+
*/
|
|
71
|
+
function withTimeout<T>(
|
|
72
|
+
promise: Promise<T>,
|
|
73
|
+
ms: number,
|
|
74
|
+
timeoutMessage: string,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
return Promise.race([
|
|
77
|
+
promise,
|
|
78
|
+
new Promise<T>((_, reject) => {
|
|
79
|
+
setTimeout(() => reject(new Error(timeoutMessage)), ms);
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 安全执行 Promise,忽略错误
|
|
86
|
+
*/
|
|
87
|
+
async function safeAwait(promise: Promise<unknown>): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
await promise;
|
|
90
|
+
} catch {
|
|
91
|
+
// 忽略
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
// ============================================================================
|
|
51
96
|
// 私有方法
|
|
52
97
|
// ============================================================================
|
|
@@ -59,6 +104,18 @@ function createContainer(appName: string): HTMLElement {
|
|
|
59
104
|
return container;
|
|
60
105
|
}
|
|
61
106
|
|
|
107
|
+
function createEntry(container: HTMLElement): ContainerEntry {
|
|
108
|
+
return {
|
|
109
|
+
container,
|
|
110
|
+
microApp: null,
|
|
111
|
+
status: 'hidden',
|
|
112
|
+
loadingStatus: 'idle',
|
|
113
|
+
deleteTimer: null,
|
|
114
|
+
loadSessionId: 0,
|
|
115
|
+
unmountPromise: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
62
119
|
// ============================================================================
|
|
63
120
|
// 公开 API
|
|
64
121
|
// ============================================================================
|
|
@@ -81,12 +138,7 @@ export function getContainer(appName: string): HTMLElement {
|
|
|
81
138
|
|
|
82
139
|
// 创建新容器
|
|
83
140
|
const container = createContainer(appName);
|
|
84
|
-
entry =
|
|
85
|
-
container,
|
|
86
|
-
microApp: null,
|
|
87
|
-
status: 'hidden',
|
|
88
|
-
deleteTimer: null,
|
|
89
|
-
};
|
|
141
|
+
entry = createEntry(container);
|
|
90
142
|
containers.set(appName, entry);
|
|
91
143
|
|
|
92
144
|
return container;
|
|
@@ -130,6 +182,59 @@ export function deactivateContainer(appName: string): void {
|
|
|
130
182
|
entry.status = 'hidden';
|
|
131
183
|
}
|
|
132
184
|
|
|
185
|
+
/**
|
|
186
|
+
* 开始加载会话
|
|
187
|
+
* 返回会话 ID,用于后续检查该会话是否仍然有效
|
|
188
|
+
*/
|
|
189
|
+
export function startLoadSession(appName: string): number {
|
|
190
|
+
const entry = containers.get(appName);
|
|
191
|
+
if (!entry) return 0;
|
|
192
|
+
|
|
193
|
+
const sessionId = ++globalSessionId;
|
|
194
|
+
entry.loadSessionId = sessionId;
|
|
195
|
+
entry.loadingStatus = 'loading';
|
|
196
|
+
|
|
197
|
+
return sessionId;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 检查加载会话是否仍然有效
|
|
202
|
+
* 如果返回 false,说明已经有新的加载请求,当前加载应该取消
|
|
203
|
+
*/
|
|
204
|
+
export function isLoadSessionValid(appName: string, sessionId: number): boolean {
|
|
205
|
+
const entry = containers.get(appName);
|
|
206
|
+
return entry?.loadSessionId === sessionId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 标记加载完成
|
|
211
|
+
*/
|
|
212
|
+
export function markLoadComplete(appName: string, sessionId: number): boolean {
|
|
213
|
+
const entry = containers.get(appName);
|
|
214
|
+
if (!entry || entry.loadSessionId !== sessionId) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
entry.loadingStatus = 'mounted';
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 获取当前加载状态
|
|
223
|
+
*/
|
|
224
|
+
export function getLoadingStatus(appName: string): LoadingStatus {
|
|
225
|
+
return containers.get(appName)?.loadingStatus ?? 'idle';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 等待当前卸载操作完成(如果有)
|
|
230
|
+
*/
|
|
231
|
+
export async function waitForUnmount(appName: string): Promise<void> {
|
|
232
|
+
const entry = containers.get(appName);
|
|
233
|
+
if (entry?.unmountPromise) {
|
|
234
|
+
await entry.unmountPromise;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
133
238
|
/**
|
|
134
239
|
* 设置微应用实例
|
|
135
240
|
*/
|
|
@@ -149,30 +254,57 @@ export function getMicroApp(appName: string): MicroApp | null {
|
|
|
149
254
|
|
|
150
255
|
/**
|
|
151
256
|
* 安全卸载微应用并安排容器延迟删除
|
|
257
|
+
* 带超时机制,防止子应用 unmount 卡死
|
|
152
258
|
*/
|
|
153
259
|
export async function unmountApp(appName: string): Promise<void> {
|
|
154
260
|
const entry = containers.get(appName);
|
|
155
261
|
if (!entry) return;
|
|
156
262
|
|
|
263
|
+
// 如果已经在卸载中,等待卸载完成
|
|
264
|
+
if (entry.unmountPromise) {
|
|
265
|
+
await entry.unmountPromise;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
157
269
|
const { microApp } = entry;
|
|
158
270
|
|
|
159
271
|
if (microApp) {
|
|
272
|
+
entry.loadingStatus = 'unmounting';
|
|
160
273
|
entry.microApp = null;
|
|
161
274
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
275
|
+
// 创建卸载 Promise 供其他调用方等待
|
|
276
|
+
entry.unmountPromise = (async () => {
|
|
277
|
+
// 等待各阶段完成再卸载(带超时)
|
|
278
|
+
try {
|
|
279
|
+
await withTimeout(
|
|
280
|
+
Promise.all([
|
|
281
|
+
safeAwait(microApp.loadPromise),
|
|
282
|
+
safeAwait(microApp.bootstrapPromise),
|
|
283
|
+
safeAwait(microApp.mountPromise),
|
|
284
|
+
]),
|
|
285
|
+
UNMOUNT_TIMEOUT / 2,
|
|
286
|
+
'Waiting for micro-app lifecycle timeout',
|
|
287
|
+
);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.warn(`[container-manager] ${appName} lifecycle wait timeout:`, err);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 执行卸载(带超时)
|
|
293
|
+
try {
|
|
294
|
+
await withTimeout(
|
|
295
|
+
microApp.unmount(),
|
|
296
|
+
UNMOUNT_TIMEOUT / 2,
|
|
297
|
+
'Micro-app unmount timeout',
|
|
298
|
+
);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.warn(`[container-manager] ${appName} unmount error/timeout:`, err);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
entry.loadingStatus = 'idle';
|
|
304
|
+
entry.unmountPromise = null;
|
|
305
|
+
})();
|
|
306
|
+
|
|
307
|
+
await entry.unmountPromise;
|
|
176
308
|
}
|
|
177
309
|
|
|
178
310
|
// 安排延迟删除
|
package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx
CHANGED
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
deactivateContainer,
|
|
10
10
|
getContainer,
|
|
11
11
|
getMicroApp,
|
|
12
|
+
isLoadSessionValid,
|
|
13
|
+
markLoadComplete,
|
|
12
14
|
setMicroApp,
|
|
15
|
+
startLoadSession,
|
|
13
16
|
unmountApp,
|
|
17
|
+
waitForUnmount,
|
|
14
18
|
} from './container-manager';
|
|
15
19
|
import './index.less';
|
|
16
20
|
|
|
@@ -39,7 +43,6 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
39
43
|
routePath,
|
|
40
44
|
}) => {
|
|
41
45
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
42
|
-
const mountIdRef = useRef(0);
|
|
43
46
|
const [loading, setLoading] = useState(true);
|
|
44
47
|
const [error, setError] = useState<string | null>(null);
|
|
45
48
|
const isMountedRef = useRef(false);
|
|
@@ -73,7 +76,11 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
73
76
|
};
|
|
74
77
|
};
|
|
75
78
|
|
|
76
|
-
//
|
|
79
|
+
// 记录最新的 props 值,用于加载完成后应用
|
|
80
|
+
const latestPropsRef = useRef({ routePath });
|
|
81
|
+
latestPropsRef.current = { routePath };
|
|
82
|
+
|
|
83
|
+
// 当时区、路由等"无需重载"的状态更新时,通过 update() 通知子应用
|
|
77
84
|
useEffect(() => {
|
|
78
85
|
if (!isMountedRef.current) return;
|
|
79
86
|
|
|
@@ -85,7 +92,8 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
85
92
|
const wrapper = wrapperRef.current;
|
|
86
93
|
if (!wrapper) return;
|
|
87
94
|
|
|
88
|
-
|
|
95
|
+
// 开始新的加载会话,获取会话 ID
|
|
96
|
+
const sessionId = startLoadSession(appName);
|
|
89
97
|
let isCancelled = false;
|
|
90
98
|
|
|
91
99
|
setLoading(true);
|
|
@@ -95,7 +103,15 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
95
103
|
activateContainer(appName, wrapper);
|
|
96
104
|
|
|
97
105
|
const loadApp = async () => {
|
|
98
|
-
//
|
|
106
|
+
// 1. 先等待之前的卸载操作完成(如果有)
|
|
107
|
+
await waitForUnmount(appName);
|
|
108
|
+
|
|
109
|
+
// 检查会话是否仍然有效(可能在等待期间被新的加载请求覆盖)
|
|
110
|
+
if (!isLoadSessionValid(appName, sessionId) || isCancelled) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. 卸载旧实例(如果存在)
|
|
99
115
|
const existingApp = getMicroApp(appName);
|
|
100
116
|
if (existingApp) {
|
|
101
117
|
try {
|
|
@@ -105,12 +121,15 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
105
121
|
}
|
|
106
122
|
}
|
|
107
123
|
|
|
108
|
-
if (isCancelled)
|
|
124
|
+
if (!isLoadSessionValid(appName, sessionId) || isCancelled) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
109
127
|
|
|
110
128
|
try {
|
|
129
|
+
// 3. 加载新微应用
|
|
111
130
|
const microApp = loadMicroApp(
|
|
112
131
|
{
|
|
113
|
-
name: `${appName}_${
|
|
132
|
+
name: `${appName}_${sessionId}`,
|
|
114
133
|
entry,
|
|
115
134
|
container,
|
|
116
135
|
props: buildPropsRef.current(),
|
|
@@ -142,7 +161,7 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
142
161
|
// 关键:必须同时处理 loadPromise 和 bootstrapPromise/mountPromise 的错误
|
|
143
162
|
// qiankun 内部会产生多个 Promise 链,任何一个未处理的 rejection 都会导致页面崩溃
|
|
144
163
|
const handlePromiseError = (err: unknown) => {
|
|
145
|
-
if (!isCancelled) {
|
|
164
|
+
if (isLoadSessionValid(appName, sessionId) && !isCancelled) {
|
|
146
165
|
const message =
|
|
147
166
|
err instanceof Error ? err.message : 'Unknown error';
|
|
148
167
|
setError(message);
|
|
@@ -155,28 +174,45 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
155
174
|
microApp.bootstrapPromise.catch(handlePromiseError);
|
|
156
175
|
microApp.mountPromise.catch(handlePromiseError);
|
|
157
176
|
|
|
158
|
-
if (isCancelled) {
|
|
177
|
+
if (!isLoadSessionValid(appName, sessionId) || isCancelled) {
|
|
178
|
+
// 会话已过期,卸载刚加载的应用
|
|
159
179
|
unmountApp(appName);
|
|
160
180
|
return;
|
|
161
181
|
}
|
|
162
182
|
|
|
163
|
-
//
|
|
183
|
+
// 4. 等待加载完成
|
|
164
184
|
await microApp.loadPromise;
|
|
165
185
|
|
|
166
|
-
if (isCancelled) {
|
|
186
|
+
if (!isLoadSessionValid(appName, sessionId) || isCancelled) {
|
|
167
187
|
unmountApp(appName);
|
|
168
188
|
return;
|
|
169
189
|
}
|
|
170
190
|
|
|
171
|
-
// 等待挂载完成
|
|
191
|
+
// 5. 等待挂载完成
|
|
172
192
|
await microApp.mountPromise;
|
|
173
193
|
|
|
174
|
-
if (!isCancelled) {
|
|
194
|
+
if (!isLoadSessionValid(appName, sessionId) || isCancelled) {
|
|
195
|
+
unmountApp(appName);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 6. 标记加载完成
|
|
200
|
+
if (markLoadComplete(appName, sessionId)) {
|
|
175
201
|
isMountedRef.current = true;
|
|
176
202
|
setLoading(false);
|
|
203
|
+
|
|
204
|
+
// 7. 加载完成后,检查是否有 props 变化需要同步
|
|
205
|
+
// 这解决了加载期间 locale/timezone 等变化被跳过的问题
|
|
206
|
+
queueMicrotask(() => {
|
|
207
|
+
if (!isLoadSessionValid(appName, sessionId) || isCancelled) return;
|
|
208
|
+
const currentMicroApp = getMicroApp(appName);
|
|
209
|
+
if (currentMicroApp?.update) {
|
|
210
|
+
currentMicroApp.update(buildPropsRef.current());
|
|
211
|
+
}
|
|
212
|
+
});
|
|
177
213
|
}
|
|
178
214
|
} catch (err) {
|
|
179
|
-
if (!isCancelled) {
|
|
215
|
+
if (isLoadSessionValid(appName, sessionId) && !isCancelled) {
|
|
180
216
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
181
217
|
// 提取更友好的错误信息
|
|
182
218
|
const friendlyMessage = message.includes('Failed to fetch')
|
|
@@ -197,8 +233,10 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
|
|
|
197
233
|
// 同步执行,确保在下一次 activate 前移走容器
|
|
198
234
|
deactivateContainer(appName);
|
|
199
235
|
|
|
200
|
-
//
|
|
201
|
-
|
|
236
|
+
// 使用 queueMicrotask 替代 requestAnimationFrame
|
|
237
|
+
// queueMicrotask 在当前事件循环末尾执行,比 rAF 更快更可靠
|
|
238
|
+
// 这确保了卸载操作在下一个加载操作之前执行
|
|
239
|
+
queueMicrotask(() => {
|
|
202
240
|
unmountApp(appName);
|
|
203
241
|
});
|
|
204
242
|
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useLocation, useModel } from '@umijs/max';
|
|
2
|
+
import debounce from 'lodash-es/debounce';
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { layoutLogger } from '@/common/logger';
|
|
5
|
+
import { NO_AUTH_ROUTE_LIST } from '@/constants';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 路由切换时自动刷新用户权限
|
|
9
|
+
*
|
|
10
|
+
* 功能:
|
|
11
|
+
* - 路由变化时自动刷新 initialState(用户信息和权限)
|
|
12
|
+
* - 防抖处理,避免频繁切换路由时重复请求
|
|
13
|
+
* - 跳过首次渲染(getInitialState 已获取过)
|
|
14
|
+
* - 跳过免认证路由
|
|
15
|
+
* - 只响应 pathname 变化,忽略 search/hash
|
|
16
|
+
*/
|
|
17
|
+
export function useRoutePermissionRefresh() {
|
|
18
|
+
const location = useLocation();
|
|
19
|
+
const { refresh } = useModel('@@initialState');
|
|
20
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
21
|
+
|
|
22
|
+
const isFirstRender = useRef(true);
|
|
23
|
+
const prevPathRef = useRef(location.pathname);
|
|
24
|
+
// 使用 ref 持久化 refresh,避免 debounce 实例重建
|
|
25
|
+
const refreshRef = useRef(refresh);
|
|
26
|
+
refreshRef.current = refresh;
|
|
27
|
+
|
|
28
|
+
const debouncedRefresh = useMemo(
|
|
29
|
+
() =>
|
|
30
|
+
debounce(async (pathname: string) => {
|
|
31
|
+
layoutLogger.log('Route changed, refreshing user info:', pathname);
|
|
32
|
+
try {
|
|
33
|
+
await refreshRef.current?.();
|
|
34
|
+
} finally {
|
|
35
|
+
setIsRefreshing(false);
|
|
36
|
+
}
|
|
37
|
+
}, 300),
|
|
38
|
+
[],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
// 跳过首次渲染(getInitialState 已经获取过用户信息)
|
|
43
|
+
if (isFirstRender.current) {
|
|
44
|
+
isFirstRender.current = false;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 只有路径真正变化时才刷新(避免 search/hash 变化触发)
|
|
49
|
+
if (prevPathRef.current === location.pathname) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
prevPathRef.current = location.pathname;
|
|
53
|
+
|
|
54
|
+
// 免认证路由不需要刷新用户信息
|
|
55
|
+
if (NO_AUTH_ROUTE_LIST.includes(location.pathname)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 立即设置 loading 状态,阻止页面渲染和业务请求
|
|
60
|
+
setIsRefreshing(true);
|
|
61
|
+
debouncedRefresh(location.pathname);
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
debouncedRefresh.cancel();
|
|
65
|
+
};
|
|
66
|
+
}, [location.pathname, debouncedRefresh]);
|
|
67
|
+
|
|
68
|
+
return { isRefreshing };
|
|
69
|
+
}
|
|
@@ -3,6 +3,7 @@ import { extractRoutes, filterMenuItems, findRouteByPath, getWindowMenus } from
|
|
|
3
3
|
import AppTabs from '@/components/AppTabs';
|
|
4
4
|
import MicroAppLoader from '@/components/MicroAppLoader';
|
|
5
5
|
import { NO_AUTH_ROUTE_LIST } from '@/constants';
|
|
6
|
+
import { useRoutePermissionRefresh } from '@/hooks/useRoutePermissionRefresh';
|
|
6
7
|
import ForbiddenPage from '@/pages/403';
|
|
7
8
|
import { Layout, Spin } from '@arco-design/web-react';
|
|
8
9
|
import { Outlet, useLocation, useModel } from '@umijs/max';
|
|
@@ -41,6 +42,8 @@ const BasicLayout: React.FC = () => {
|
|
|
41
42
|
const location = useLocation();
|
|
42
43
|
const { initialState } = useModel('@@initialState');
|
|
43
44
|
const currentUser = initialState?.currentUser;
|
|
45
|
+
// 路由切换时自动刷新用户权限
|
|
46
|
+
const { isRefreshing } = useRoutePermissionRefresh();
|
|
44
47
|
|
|
45
48
|
const filterOptions = useMemo(
|
|
46
49
|
() => ({
|
|
@@ -49,6 +52,7 @@ const BasicLayout: React.FC = () => {
|
|
|
49
52
|
}),
|
|
50
53
|
[currentUser?.is_superuser, currentUser?.side_menus],
|
|
51
54
|
);
|
|
55
|
+
|
|
52
56
|
// 所有路由(不过滤权限,用于判断路径是否存在)
|
|
53
57
|
const allRoutes = useMemo(() => {
|
|
54
58
|
const menus = getWindowMenus();
|
|
@@ -98,8 +102,14 @@ const BasicLayout: React.FC = () => {
|
|
|
98
102
|
pathname: location.pathname,
|
|
99
103
|
currentRoute,
|
|
100
104
|
isForbidden,
|
|
105
|
+
isRefreshing,
|
|
101
106
|
});
|
|
102
107
|
|
|
108
|
+
// 权限刷新中,显示 loading 阻止用户操作
|
|
109
|
+
if (isRefreshing) {
|
|
110
|
+
return <Spin dot style={{ width: '100%', marginTop: 100, textAlign: 'center' }} />;
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
// 无权限,显示 403
|
|
104
114
|
if (isForbidden) {
|
|
105
115
|
return <ForbiddenPage />;
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
import { defineConfig } from '@umijs/max';
|
|
4
4
|
const { CDN_PUBLIC_PATH } = process.env;
|
|
5
5
|
|
|
6
|
-
const PUBLIC_PATH: string = CDN_PUBLIC_PATH
|
|
7
|
-
? `${CDN_PUBLIC_PATH.replace(/\/?$/, '/')}homepage/`
|
|
8
|
-
: '/homepage/';
|
|
9
6
|
|
|
10
7
|
const config: ReturnType<typeof defineConfig> = {
|
|
11
8
|
// 测试环境:将所有代码打包到一个文件
|
|
@@ -19,7 +16,6 @@ const config: ReturnType<typeof defineConfig> = {
|
|
|
19
16
|
memo.optimization.runtimeChunk(false);
|
|
20
17
|
return memo;
|
|
21
18
|
},
|
|
22
|
-
publicPath: PUBLIC_PATH,
|
|
23
19
|
|
|
24
20
|
/**
|
|
25
21
|
* @name 外部依赖配置
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
import { defineConfig } from '@umijs/max';
|
|
4
4
|
const { CDN_PUBLIC_PATH } = process.env;
|
|
5
5
|
|
|
6
|
-
const PUBLIC_PATH: string = CDN_PUBLIC_PATH
|
|
7
|
-
? `${CDN_PUBLIC_PATH.replace(/\/?$/, '/')}homepage/`
|
|
8
|
-
: '/homepage/';
|
|
9
6
|
|
|
10
7
|
const config: ReturnType<typeof defineConfig> = {
|
|
11
8
|
// 测试环境:将所有代码打包到一个文件
|
|
@@ -19,7 +16,6 @@ const config: ReturnType<typeof defineConfig> = {
|
|
|
19
16
|
memo.optimization.runtimeChunk(false);
|
|
20
17
|
return memo;
|
|
21
18
|
},
|
|
22
|
-
publicPath: PUBLIC_PATH,
|
|
23
19
|
|
|
24
20
|
/**
|
|
25
21
|
* @name 外部依赖配置
|