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

@@ -69,6 +69,9 @@ async function bootstrap(namespace: string) {
const { MotionPlugin } = await import('@easyflow/plugins/motion');
app.use(MotionPlugin);
// 初始导航和权限守卫完成后再挂载,避免 RouterView 短暂显示为空白。
await router.isReady();
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {

View File

@@ -1,10 +1,19 @@
/* eslint-disable perfectionist/sort-imports -- 兼容层必须先于所有业务依赖执行。 */
import './polyfills';
import './startup-error.css';
import {initPreferences, preferences, updatePreferences,} from '@easyflow/preferences';
import {
initPreferences,
preferences,
updatePreferences,
} from '@easyflow/preferences';
import { unmountGlobalLoading } from '@easyflow/utils';
import {normalizeBrandAssetPreferences, overridesPreferences,} from './preferences';
import {
normalizeBrandAssetPreferences,
overridesPreferences,
} from './preferences';
import { renderStartupError } from './startup-error';
/**
* 应用初始化完成之后再进行页面加载渲染
@@ -36,4 +45,8 @@ async function initApplication() {
unmountGlobalLoading();
}
initApplication();
initApplication().catch((error) => {
console.error('Application startup failed.', error);
renderStartupError();
unmountGlobalLoading();
});

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 };

View File

@@ -0,0 +1,52 @@
.startup-error {
display: grid;
place-items: center;
min-height: 100vh;
padding: 32px;
color: CanvasText;
background: Canvas;
}
.startup-error__content {
width: min(100%, 480px);
text-align: center;
}
.startup-error__title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 1.4;
}
.startup-error__description {
margin: 16px 0 24px;
line-height: 1.6;
color: GrayText;
}
.startup-error__retry {
min-width: 112px;
min-height: 40px;
padding: 0 24px;
font: inherit;
line-height: 1.6;
color: HighlightText;
cursor: pointer;
background: Highlight;
border: 0;
border-radius: var(--radius, 8px);
}
.startup-error__retry:hover {
filter: brightness(0.96);
}
.startup-error__retry:active {
filter: brightness(0.9);
}
.startup-error__retry:focus-visible {
outline: 2px solid Highlight;
outline-offset: 3px;
}

View File

@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderStartupError } from './startup-error';
describe('startup error fallback', () => {
afterEach(() => {
document.body.replaceChildren();
});
it('renders a recoverable error state and reloads on demand', () => {
const root = document.createElement('div');
const reload = vi.fn();
document.body.append(root);
expect(renderStartupError({ reload, root })).toBe(true);
expect(root.querySelector('[role="alert"]')).not.toBeNull();
expect(root.textContent).toContain('页面加载失败');
const retryButton = root.querySelector<HTMLButtonElement>('button');
retryButton?.click();
expect(reload).toHaveBeenCalledOnce();
});
it('returns false when the application root is missing', () => {
expect(renderStartupError({ root: null })).toBe(false);
});
});

View File

@@ -0,0 +1,45 @@
interface RenderStartupErrorOptions {
reload?: () => void;
root?: HTMLElement | null;
}
/**
* 在 Vue 应用尚未挂载时展示可恢复的启动错误,避免异常后只剩白屏。
*/
function renderStartupError(options: RenderStartupErrorOptions = {}) {
const root = options.root ?? document.querySelector<HTMLElement>('#app');
if (!root) {
return false;
}
const container = document.createElement('main');
container.className = 'startup-error';
container.setAttribute('role', 'alert');
const content = document.createElement('div');
content.className = 'startup-error__content';
const title = document.createElement('h1');
title.className = 'startup-error__title';
title.textContent = '页面加载失败';
const description = document.createElement('p');
description.className = 'startup-error__description';
description.textContent = '请刷新后重试';
const retryButton = document.createElement('button');
retryButton.className = 'startup-error__retry';
retryButton.type = 'button';
retryButton.textContent = '重新加载';
retryButton.addEventListener('click', () => {
(options.reload ?? (() => window.location.reload()))();
});
content.append(title, description, retryButton);
container.append(content);
root.replaceChildren(container);
retryButton.focus();
return true;
}
export { renderStartupError };

View File

@@ -146,6 +146,11 @@ export const useAuthStore = defineStore('auth', () => {
resetAllStores();
accessStore.setLoginExpired(false);
// 初始路由守卫仍在执行时由守卫统一返回登录页,避免并发导航。
if (!router.currentRoute.value.name) {
return;
}
// 回登录页带上当前路由地址
await router.replace({
path: LOGIN_PATH,

View File

@@ -7,6 +7,10 @@
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./svg": {
"types": "./src/svg/index.ts",
"default": "./src/svg/index.ts"
}
},
"dependencies": {

View File

@@ -1,3 +1,2 @@
export * from './iconify';
export { default as EmptyIcon } from './icons/empty-icon.vue';
export * from './svg';