fix: 提升管理端启动与导航稳定性
- 等待初始路由就绪并提供可恢复的启动失败页面 - 收敛失效登录导航并按需加载 SVG 图标资源
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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页面
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
26
easyflow-ui-admin/app/src/router/navigation-user-info.ts
Normal file
26
easyflow-ui-admin/app/src/router/navigation-user-info.ts
Normal 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 };
|
||||
52
easyflow-ui-admin/app/src/startup-error.css
Normal file
52
easyflow-ui-admin/app/src/startup-error.css
Normal 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;
|
||||
}
|
||||
27
easyflow-ui-admin/app/src/startup-error.test.ts
Normal file
27
easyflow-ui-admin/app/src/startup-error.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
easyflow-ui-admin/app/src/startup-error.ts
Normal file
45
easyflow-ui-admin/app/src/startup-error.ts
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./svg": {
|
||||
"types": "./src/svg/index.ts",
|
||||
"default": "./src/svg/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './iconify';
|
||||
export { default as EmptyIcon } from './icons/empty-icon.vue';
|
||||
export * from './svg';
|
||||
|
||||
Reference in New Issue
Block a user