From 3475cdfe499a81ee31b70532ef04d9b198edd03d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 19 Sep 2026 17:02:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++ src/core/routing/navigationRecovery.test.ts | 113 +++++++++++++++++- src/core/routing/navigationRecovery.ts | 105 ++++++++++++---- src/main.ts | 63 ++++++---- src/modules/finance/api/finance.ts | 2 +- .../pages/FinanceCustomerAccountsView.vue | 6 +- src/modules/finance/types.ts | 1 - 7 files changed, 243 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ab44a2fa..6384a94d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ pnpm build pnpm test ``` +## 发布后的页面资源恢复 + +`src/core/routing/navigationRecovery.ts` 统一处理路由懒加载、Vite JS/CSS 预加载和 Vue 异步组件的资源加载失败。无未保存编辑时自动重新加载目标页面,保留路径、查询参数和页签;有未保存编辑时先确认,取消后继续编辑。普通接口错误和业务运行错误不触发刷新。 + +刷新记录写入当前标签页的 `sessionStorage`,30 秒内最多自动刷新一次,跨路由成功、重定向和整页刷新仍然保留,防止循环刷新。存储不可用或恢复后仍失败时提示手动刷新。`nginx/default.conf.template` 已对 HTML 禁用缓存,并让缺失的 `/assets/` 文件返回真实 404;使用额外 CDN 时也应保持 HTML 每次重新验证,参见 [Vite 资源加载错误处理](https://vite.dev/guide/build#load-error-handling)。 + +该机制随新版前端生效,已经打开且尚未加载本次改动的旧标签页需要先刷新一次。发布验收可在旧版页面保持打开的情况下发布新版,再打开尚未访问过的页面或异步弹窗,确认自动恢复以及未保存编辑的取消/确认行为。 + ## 源码结构 - `src/app/`:应用壳层、根路由和启动装配。 diff --git a/src/core/routing/navigationRecovery.test.ts b/src/core/routing/navigationRecovery.test.ts index fac05a03..3a145008 100644 --- a/src/core/routing/navigationRecovery.test.ts +++ b/src/core/routing/navigationRecovery.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { - clearNavigationRecoveryAttempt, + createNavigationRecoveryHandler, + installAssetRecoveryListeners, isStaleRouteAssetError, recoverNavigationError, + reloadNavigationTarget, type NavigationRecoveryDependencies, } from './navigationRecovery' @@ -29,11 +31,30 @@ function dependencies(overrides: Partial = {}) { } describe('navigationRecovery', () => { + it.each(['/orders?page=3#details', '/orders?page=3#goods'])( + 'forces a full reload for an async component failure on the current document: %s', (target) => { + const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() } + const history = { state: { position: 2 }, replaceState: vi.fn() } + reloadNavigationTarget({ location, history } as unknown as Pick, target) + expect(location.reload).toHaveBeenCalledOnce() + expect(location.assign).not.toHaveBeenCalled() + expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`) + }, + ) + + it('loads the intended new route through a document navigation', () => { + const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() } + reloadNavigationTarget({ location } as unknown as Pick, '/orders?page=3#details') + expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details') + expect(location.reload).not.toHaveBeenCalled() + }) + it.each([ 'Failed to fetch dynamically imported module: /assets/page.js', 'Importing a module script failed', 'ChunkLoadError: Loading chunk 42 failed', 'Loading CSS chunk page failed', + 'Unable to preload CSS for /assets/page-123.css', ])('recognizes stale deployed route assets: %s', (message) => { expect(isStaleRouteAssetError(new TypeError(message))).toBe(true) }) @@ -58,7 +79,7 @@ describe('navigationRecovery', () => { expect(deps.navigate).not.toHaveBeenCalled() }) - it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => { + it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => { const storage = memoryStorage() const deps = dependencies({ storage }) const error = new Error('ChunkLoadError: Loading chunk page failed') @@ -66,9 +87,12 @@ describe('navigationRecovery', () => { await recoverNavigationError(error, '/system/users', deps) expect(deps.navigate).toHaveBeenCalledOnce() expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新') - clearNavigationRecoveryAttempt(storage) - await recoverNavigationError(error, '/system/users', deps) - expect(deps.navigate).toHaveBeenCalledTimes(2) + const reloaded = dependencies({ storage }) + await recoverNavigationError(error, '/system/redirect', reloaded) + expect(reloaded.navigate).not.toHaveBeenCalled() + reloaded.now = () => 31_000 + await recoverNavigationError(error, '/system/users', reloaded) + expect(reloaded.navigate).toHaveBeenCalledOnce() }) it('reports ordinary router failures without reloading the application', async () => { @@ -77,4 +101,83 @@ describe('navigationRecovery', () => { expect(deps.navigate).not.toHaveBeenCalled() expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试') }) + + it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => { + const storage = memoryStorage() + storage[method] = () => { throw new Error('Storage unavailable') } + const deps = dependencies({ storage }) + await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false) + expect(deps.navigate).not.toHaveBeenCalled() + expect(deps.discardUnsavedWork).not.toHaveBeenCalled() + expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面') + }) + + it('preserves the target query and hash after the user confirms discarding work', async () => { + const deps = dependencies({ hasUnsavedWork: () => true }) + const target = '/orders?status=NEW&page=3#details' + await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps) + expect(deps.confirmDiscard).toHaveBeenCalledOnce() + expect(deps.navigate).toHaveBeenCalledWith(target) + }) + + it('coalesces errors while confirmation is open and permits retry after cancellation', async () => { + let finishConfirmation!: (value: boolean) => void + const deps = dependencies({ + hasUnsavedWork: () => true, + confirmDiscard: vi.fn(() => new Promise((resolve) => { finishConfirmation = resolve })), + }) + const recover = createNavigationRecoveryHandler(deps) + const cachedError = new Error('Importing a module script failed') + const first = recover(cachedError, '/orders') + const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders') + await Promise.resolve() + expect(deps.confirmDiscard).toHaveBeenCalledOnce() + finishConfirmation(false) + await Promise.all([first, duplicate]) + expect(deps.navigate).not.toHaveBeenCalled() + deps.confirmDiscard = vi.fn(async () => true) + await recover(cachedError, '/orders') + expect(deps.navigate).toHaveBeenCalledOnce() + }) + + it('deduplicates the same error reported by Vite and the router', async () => { + const deps = dependencies() + const recover = createNavigationRecoveryHandler(deps) + const error = new Error('Importing a module script failed') + await recover(error, '/orders') + await recover(error, '/orders') + expect(deps.navigate).toHaveBeenCalledOnce() + expect(deps.showError).not.toHaveBeenCalled() + }) + + it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => { + const deps = dependencies() + const recover = createNavigationRecoveryHandler(deps) + const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover) + try { + const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), { + payload: new Error('Cannot read properties of undefined'), + }) + window.dispatchEvent(ordinary) + const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), { + reason: new Error('Request failed with status code 500'), + }) + window.dispatchEvent(apiError) + await Promise.resolve() + expect(deps.navigate).not.toHaveBeenCalled() + expect(apiError.defaultPrevented).toBe(false) + + const error = new Error('Unable to preload CSS for /assets/page.css') + const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error }) + window.dispatchEvent(preload) + expect(preload.defaultPrevented).toBe(false) + const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error }) + window.dispatchEvent(rejection) + expect(rejection.defaultPrevented).toBe(true) + await recover(error, '/orders?page=2') + expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2') + } finally { + removeListeners() + } + }) }) diff --git a/src/core/routing/navigationRecovery.ts b/src/core/routing/navigationRecovery.ts index 9852ab18..1fe50e22 100644 --- a/src/core/routing/navigationRecovery.ts +++ b/src/core/routing/navigationRecovery.ts @@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [ /importing a module script failed/i, /chunkloaderror/i, /loading (css )?chunk [\w-]+ failed/i, + /unable to preload css for/i, ] export interface NavigationRecoveryDependencies { - storage: Pick + storage: Pick now: () => number hasUnsavedWork: () => boolean confirmDiscard: () => Promise @@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) { return staleAssetErrorPatterns.some((pattern) => pattern.test(message)) } -function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) { - try { - const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null - return Boolean(parsed - && parsed.target === target - && dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS) - } catch { - return false - } -} - export async function recoverNavigationError( error: unknown, target: string, @@ -54,21 +44,96 @@ export async function recoverNavigationError( dependencies.showError('页面切换失败,请重试') return false } - if (recentAttempt(dependencies, target)) { - dependencies.showError('页面资源加载失败,请稍后重试或手动刷新') + // Keep this marker across successful routing and full reloads. A redirect to + // another route must not bypass the cooldown either. + try { + const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY) + let previous: RecoveryAttempt | null = null + try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ } + if (previous && Number.isFinite(previous.attemptedAt) + && dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) { + dependencies.showError('页面资源加载失败,请稍后重试或手动刷新') + return false + } + } catch { + dependencies.showError('无法记录自动刷新次数,请手动刷新页面') return false } if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true - dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({ - target, - attemptedAt: dependencies.now(), - } satisfies RecoveryAttempt)) + try { + dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({ + target, + attemptedAt: dependencies.now(), + } satisfies RecoveryAttempt)) + } catch { + dependencies.showError('无法记录自动刷新次数,请手动刷新页面') + return false + } dependencies.discardUnsavedWork() dependencies.navigate(target) return true } -export function clearNavigationRecoveryAttempt(storage: Pick) { - storage.removeItem(RECOVERY_STORAGE_KEY) +export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) { + let handledErrors = new WeakSet() + let pending: Promise | undefined + const guardedDependencies = { + ...dependencies, + confirmDiscard: async () => { + const confirmed = await dependencies.confirmDiscard() + // Some importers cache the rejected promise/error. Cancelling must not + // prevent a later explicit retry from asking again. + if (!confirmed) handledErrors = new WeakSet() + return confirmed + }, + } + return (error: unknown, target: string): Promise => { + if (typeof error === 'object' && error !== null) { + if (handledErrors.has(error)) return pending ?? Promise.resolve(true) + handledErrors.add(error) + } + if (pending) return pending + // Defer execution so simultaneous router, Vite and Vue errors share one + // confirmation/reload, even when there is no unsaved form. + pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies)) + .finally(() => { pending = undefined }) + return pending + } +} + +export function reloadNavigationTarget(browser: Pick, target: string) { + const current = new URL(browser.location.href) + const destination = new URL(target, current) + if (destination.pathname === current.pathname && destination.search === current.search) { + // Assigning a URL that only changes the fragment does not reload assets. + browser.history.replaceState(browser.history.state, '', destination.href) + browser.location.reload() + } else { + browser.location.assign(destination.href) + } +} + +export function installAssetRecoveryListeners( + browser: Window, + target: () => string, + recover: (error: unknown, target: string) => Promise, +) { + const onPreloadError = (event: Event) => { + const error = (event as Event & { payload?: unknown }).payload + if (isStaleRouteAssetError(error)) void recover(error, target()) + // Let Vite reject the import: swallowing it would make Vue Router treat + // an undefined module as a successful navigation, including on cancel. + } + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + if (!isStaleRouteAssetError(event.reason)) return + event.preventDefault() + void recover(event.reason, target()) + } + browser.addEventListener('vite:preloadError', onPreloadError) + browser.addEventListener('unhandledrejection', onUnhandledRejection) + return () => { + browser.removeEventListener('vite:preloadError', onPreloadError) + browser.removeEventListener('unhandledrejection', onUnhandledRejection) + } } diff --git a/src/main.ts b/src/main.ts index cf229788..3a893cc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,7 @@ import 'element-plus/es/components/message-box/style/css' import App from './App.vue' import router from '@/app/router' import { sanitizeInternalPath } from '@/core/routing/accessDecision' -import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery' +import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs' @@ -22,8 +22,6 @@ const auth = useAuthStore(pinia) const unsaved = useUnsavedWorkStore(pinia) const workspace = useWorkspaceTabsStore(pinia) -await auth.restoreSession() - registerAuthSessionExpiredHandler(async () => { const currentRoute = router.currentRoute.value if (currentRoute.name === 'login') return @@ -39,31 +37,41 @@ registerAuthSessionExpiredHandler(async () => { }) }) +const recoverAssetError = createNavigationRecoveryHandler({ + storage: { + getItem: (key) => window.sessionStorage.getItem(key), + setItem: (key, value) => window.sessionStorage.setItem(key, value), + }, + now: Date.now, + hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty), + confirmDiscard: async () => { + try { + await ElMessageBox.confirm( + '页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。', + '重新加载页面?', + { confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' }, + ) + return true + } catch { + return false + } + }, + discardUnsavedWork: () => unsaved.clear(), + navigate: (target) => reloadNavigationTarget(window, target), + showError: (message) => ElMessage.error(message), +}) +let pendingRoute: string | undefined +const recoveryTarget = () => pendingRoute + ?? `${window.location.pathname}${window.location.search}${window.location.hash}` +router.beforeEach((to) => { pendingRoute = to.fullPath }) router.onError((error, to) => { - void recoverNavigationError(error, to.fullPath, { - storage: sessionStorage, - now: Date.now, - hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty), - confirmDiscard: async () => { - try { - await ElMessageBox.confirm( - '检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。', - '重新加载页面?', - { confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' }, - ) - return true - } catch { - return false - } - }, - discardUnsavedWork: () => unsaved.clear(), - navigate: (target) => window.location.assign(target), - showError: (message) => ElMessage.error(message), - }) + void recoverAssetError(error, to.fullPath) + if (pendingRoute === to.fullPath) pendingRoute = undefined }) -router.afterEach((_to, _from, failure) => { - if (!failure) clearNavigationRecoveryAttempt(sessionStorage) +router.afterEach((to) => { + if (pendingRoute === to.fullPath) pendingRoute = undefined }) +installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError) window.addEventListener('beforeunload', (event) => { if (!unsaved.entries.some((entry) => entry.dirty)) return @@ -72,5 +80,10 @@ window.addEventListener('beforeunload', (event) => { }) const app = createApp(App) +app.config.errorHandler = (error) => { + if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget()) + else console.error(error) +} app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous')) +await auth.restoreSession() app.use(pinia).use(router).mount('#app') diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 24a8e79e..037a29b8 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -173,7 +173,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag function normalizeRechargeRequest(item: RawResource): FinanceRechargeRequest { return { requestId: stringValue(item.requestId), requestNo: stringValue(item.requestNo), amount: stringValue(item.amount), - paymentMethod: stringValue(item.paymentMethod), voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName), + voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName), status: stringValue(item.status), version: numberValue(item.version), submittedByName: stringValue(item.submittedByName), reviewedByName: stringValue(item.reviewedByName), reviewRemark: stringValue(item.reviewRemark), createdAt: stringValue(item.createdAt), reviewedAt: stringValue(item.reviewedAt), diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index 76a2356a..e725cc19 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -122,7 +122,7 @@ const ledgerColumns: DataTableColumn[] = [ const rechargeColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, { key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' }, - { key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'paymentMethod', label: '付款方式', width: 130, slotName: 'paymentMethod' }, + { key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 }, { key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' }, @@ -465,7 +465,7 @@ onBeforeUnmount(clearVoucherPreview)
- +
@@ -489,7 +489,7 @@ onBeforeUnmount(clearVoucherPreview) - {{ reviewRequest.requestNo }}{{ money(reviewRequest.amount) }}{{ paymentMethodLabel(reviewRequest.paymentMethod) }}{{ reviewRequest.voucherFileName || '—' }} + {{ reviewRequest.requestNo }}{{ money(reviewRequest.amount) }}{{ reviewRequest.voucherFileName || '—' }} 通过驳回 diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts index 0d09a169..0b820910 100644 --- a/src/modules/finance/types.ts +++ b/src/modules/finance/types.ts @@ -263,7 +263,6 @@ export interface FinanceRechargeRequest { requestId: string requestNo: string amount: string - paymentMethod: string voucherFileId: string voucherFileName: string status: string