From da59c713f9ad49ff618e8626ff40a3d3aec49ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 24 Jul 2026 19:02:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8F=90=E5=8D=87=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E5=90=AF=E5=8A=A8=E4=B8=8E=E5=AF=BC=E8=88=AA=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 等待初始路由就绪并提供可恢复的启动失败页面 - 收敛失效登录导航并按需加载 SVG 图标资源 --- easyflow-ui-admin/app/src/bootstrap.ts | 3 ++ easyflow-ui-admin/app/src/main.ts | 21 ++++++-- easyflow-ui-admin/app/src/router/access.ts | 11 +++- easyflow-ui-admin/app/src/router/guard.ts | 37 ++++++++++--- .../src/router/navigation-user-info.test.ts | 40 ++++++++++++++ .../app/src/router/navigation-user-info.ts | 26 ++++++++++ easyflow-ui-admin/app/src/startup-error.css | 52 +++++++++++++++++++ .../app/src/startup-error.test.ts | 27 ++++++++++ easyflow-ui-admin/app/src/startup-error.ts | 45 ++++++++++++++++ easyflow-ui-admin/app/src/store/auth.ts | 5 ++ easyflow-ui-admin/packages/icons/package.json | 4 ++ easyflow-ui-admin/packages/icons/src/index.ts | 1 - 12 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 easyflow-ui-admin/app/src/router/navigation-user-info.test.ts create mode 100644 easyflow-ui-admin/app/src/router/navigation-user-info.ts create mode 100644 easyflow-ui-admin/app/src/startup-error.css create mode 100644 easyflow-ui-admin/app/src/startup-error.test.ts create mode 100644 easyflow-ui-admin/app/src/startup-error.ts diff --git a/easyflow-ui-admin/app/src/bootstrap.ts b/easyflow-ui-admin/app/src/bootstrap.ts index 1b166a6c..755c5cd4 100644 --- a/easyflow-ui-admin/app/src/bootstrap.ts +++ b/easyflow-ui-admin/app/src/bootstrap.ts @@ -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) { diff --git a/easyflow-ui-admin/app/src/main.ts b/easyflow-ui-admin/app/src/main.ts index 759f16df..8cc8545c 100644 --- a/easyflow-ui-admin/app/src/main.ts +++ b/easyflow-ui-admin/app/src/main.ts @@ -1,10 +1,19 @@ /* eslint-disable perfectionist/sort-imports -- 兼容层必须先于所有业务依赖执行。 */ import './polyfills'; +import './startup-error.css'; -import {initPreferences, preferences, updatePreferences,} from '@easyflow/preferences'; -import {unmountGlobalLoading} from '@easyflow/utils'; +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(); +}); diff --git a/easyflow-ui-admin/app/src/router/access.ts b/easyflow-ui-admin/app/src/router/access.ts index 5349bd38..2927196f 100644 --- a/easyflow-ui-admin/app/src/router/access.ts +++ b/easyflow-ui-admin/app/src/router/access.ts @@ -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页面 diff --git a/easyflow-ui-admin/app/src/router/guard.ts b/easyflow-ui-admin/app/src/router/guard.ts index 5ba44539..9dd128a3 100644 --- a/easyflow-ui-admin/app/src/router/guard.ts +++ b/easyflow-ui-admin/app/src/router/guard.ts @@ -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(); diff --git a/easyflow-ui-admin/app/src/router/navigation-user-info.test.ts b/easyflow-ui-admin/app/src/router/navigation-user-info.test.ts new file mode 100644 index 00000000..34d572d7 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/navigation-user-info.test.ts @@ -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); + }); +}); diff --git a/easyflow-ui-admin/app/src/router/navigation-user-info.ts b/easyflow-ui-admin/app/src/router/navigation-user-info.ts new file mode 100644 index 00000000..89f09178 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/navigation-user-info.ts @@ -0,0 +1,26 @@ +interface ResolveNavigationUserInfoOptions { + cachedUserInfo: null | T; + fetchUserInfo: () => Promise; + getAccessToken: () => null | string; +} + +/** + * 解析路由守卫所需的用户信息,并识别已被拦截器清理的失效登录态。 + */ +async function resolveNavigationUserInfo( + options: ResolveNavigationUserInfoOptions, +): Promise { + if (options.cachedUserInfo) { + return options.cachedUserInfo; + } + try { + return await options.fetchUserInfo(); + } catch (error) { + if (!options.getAccessToken()) { + return null; + } + throw error; + } +} + +export { resolveNavigationUserInfo }; diff --git a/easyflow-ui-admin/app/src/startup-error.css b/easyflow-ui-admin/app/src/startup-error.css new file mode 100644 index 00000000..0d09ba54 --- /dev/null +++ b/easyflow-ui-admin/app/src/startup-error.css @@ -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; +} diff --git a/easyflow-ui-admin/app/src/startup-error.test.ts b/easyflow-ui-admin/app/src/startup-error.test.ts new file mode 100644 index 00000000..817acd30 --- /dev/null +++ b/easyflow-ui-admin/app/src/startup-error.test.ts @@ -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('button'); + retryButton?.click(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('returns false when the application root is missing', () => { + expect(renderStartupError({ root: null })).toBe(false); + }); +}); diff --git a/easyflow-ui-admin/app/src/startup-error.ts b/easyflow-ui-admin/app/src/startup-error.ts new file mode 100644 index 00000000..8995eb9c --- /dev/null +++ b/easyflow-ui-admin/app/src/startup-error.ts @@ -0,0 +1,45 @@ +interface RenderStartupErrorOptions { + reload?: () => void; + root?: HTMLElement | null; +} + +/** + * 在 Vue 应用尚未挂载时展示可恢复的启动错误,避免异常后只剩白屏。 + */ +function renderStartupError(options: RenderStartupErrorOptions = {}) { + const root = options.root ?? document.querySelector('#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 }; diff --git a/easyflow-ui-admin/app/src/store/auth.ts b/easyflow-ui-admin/app/src/store/auth.ts index 649a38e5..4642935f 100644 --- a/easyflow-ui-admin/app/src/store/auth.ts +++ b/easyflow-ui-admin/app/src/store/auth.ts @@ -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, diff --git a/easyflow-ui-admin/packages/icons/package.json b/easyflow-ui-admin/packages/icons/package.json index b68610b0..8250f680 100644 --- a/easyflow-ui-admin/packages/icons/package.json +++ b/easyflow-ui-admin/packages/icons/package.json @@ -7,6 +7,10 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./svg": { + "types": "./src/svg/index.ts", + "default": "./src/svg/index.ts" } }, "dependencies": { diff --git a/easyflow-ui-admin/packages/icons/src/index.ts b/easyflow-ui-admin/packages/icons/src/index.ts index f69f28ef..d17367a6 100644 --- a/easyflow-ui-admin/packages/icons/src/index.ts +++ b/easyflow-ui-admin/packages/icons/src/index.ts @@ -1,3 +1,2 @@ export * from './iconify'; export { default as EmptyIcon } from './icons/empty-icon.vue'; -export * from './svg';