fix: 提升管理端启动与导航稳定性

- 等待初始路由就绪并提供可恢复的启动失败页面

- 收敛失效登录导航并按需加载 SVG 图标资源
This commit is contained in:
2026-07-24 19:02:28 +08:00
parent a75f7baf1b
commit da59c713f9
12 changed files with 259 additions and 13 deletions

View File

@@ -42,6 +42,15 @@ function collectMenuIcons(routes: unknown[]): string[] {
return icons;
}
async function loadMenuIcons(iconNames: string[]) {
await Promise.all([
loadMissingIconCollections(iconNames),
iconNames.some((icon) => icon.startsWith('svg:'))
? import('@easyflow/icons/svg')
: Promise.resolve(),
]);
}
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
@@ -58,7 +67,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
message: `${$t('common.loadingMenu')}...`,
});
const menuRoutes = await getAllMenusApi();
await loadMissingIconCollections(collectMenuIcons(menuRoutes));
await loadMenuIcons(collectMenuIcons(menuRoutes));
return disableUnsafeMenuRouteCaching(menuRoutes);
},
// 可以指定没有权限跳转403页面

View File

@@ -20,6 +20,7 @@ import {
removeDevLoginQuery,
shouldAttemptDevLogin,
} from './dev-login';
import { resolveNavigationUserInfo } from './navigation-user-info';
interface NetworkConnectionLike {
effectiveType?: string;
@@ -75,6 +76,13 @@ function setupChunkErrorGuard(router: Router) {
if (!isDynamicImportChunkError(error)) {
return;
}
// 开发环境保留当前页面和调试上下文,避免模块热更新期间反复整页刷新。
if (import.meta.env.DEV) {
console.warn('Dynamic route module loading failed:', error);
return;
}
const fullPath = to?.fullPath;
if (!fullPath) {
return;
@@ -156,6 +164,13 @@ function setupAccessGuard(router: Router) {
query: removeDevLoginQuery(to.query),
}).fullPath;
const resolveUserInfo = () =>
resolveNavigationUserInfo({
cachedUserInfo: userStore.userInfo,
fetchUserInfo: authStore.fetchUserInfo,
getAccessToken: () => accessStore.accessToken,
});
if (devLoginAccount && import.meta.env.DEV && accessStore.accessToken) {
return resolveCleanFullPath();
}
@@ -177,10 +192,6 @@ function setupAccessGuard(router: Router) {
});
await devLoginPromise;
const cleanFullPath = resolveCleanFullPath();
if (window.location.search.includes('devLogin=')) {
window.location.replace(cleanFullPath);
return false;
}
return cleanFullPath;
} catch {
const cleanFullPath = resolveCleanFullPath();
@@ -198,8 +209,10 @@ function setupAccessGuard(router: Router) {
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
const currentUser =
userStore.userInfo || (await authStore.fetchUserInfo());
const currentUser = await resolveUserInfo();
if (!currentUser) {
return true;
}
if (shouldForcePasswordChange(currentUser)) {
return buildForcePasswordRoute();
}
@@ -239,7 +252,17 @@ function setupAccessGuard(router: Router) {
return to;
}
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userInfo = await resolveUserInfo();
if (!userInfo) {
return {
path: LOGIN_PATH,
query:
to.fullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(to.fullPath) },
replace: true,
};
}
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
if (from.name) {
notifyForcePasswordChange();

View File

@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveNavigationUserInfo } from './navigation-user-info';
describe('navigation user info', () => {
it('uses cached user info without issuing a request', async () => {
const cachedUserInfo = { id: 1 };
const fetchUserInfo = vi.fn();
await expect(
resolveNavigationUserInfo({
cachedUserInfo,
fetchUserInfo,
getAccessToken: () => 'token',
}),
).resolves.toBe(cachedUserInfo);
expect(fetchUserInfo).not.toHaveBeenCalled();
});
it('returns null after an expired token has been cleared', async () => {
await expect(
resolveNavigationUserInfo({
cachedUserInfo: null,
fetchUserInfo: () => Promise.reject(new Error('expired')),
getAccessToken: () => null,
}),
).resolves.toBeNull();
});
it('keeps non-authentication failures visible', async () => {
const error = new Error('network unavailable');
await expect(
resolveNavigationUserInfo({
cachedUserInfo: null,
fetchUserInfo: () => Promise.reject(error),
getAccessToken: () => 'token',
}),
).rejects.toBe(error);
});
});

View File

@@ -0,0 +1,26 @@
interface ResolveNavigationUserInfoOptions<T> {
cachedUserInfo: null | T;
fetchUserInfo: () => Promise<T>;
getAccessToken: () => null | string;
}
/**
* 解析路由守卫所需的用户信息,并识别已被拦截器清理的失效登录态。
*/
async function resolveNavigationUserInfo<T>(
options: ResolveNavigationUserInfoOptions<T>,
): Promise<null | T> {
if (options.cachedUserInfo) {
return options.cachedUserInfo;
}
try {
return await options.fetchUserInfo();
} catch (error) {
if (!options.getAccessToken()) {
return null;
}
throw error;
}
}
export { resolveNavigationUserInfo };