const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1' const RECOVERY_WINDOW_MS = 30_000 const staleAssetErrorPatterns = [ /failed to fetch dynamically imported module/i, /error loading dynamically imported module/i, /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 now: () => number hasUnsavedWork: () => boolean confirmDiscard: () => Promise discardUnsavedWork: () => void navigate: (target: string) => void showError: (message: string) => void } interface RecoveryAttempt { target: string attemptedAt: number } function errorMessage(error: unknown) { if (error instanceof Error) return error.message return typeof error === 'string' ? error : '' } export function isStaleRouteAssetError(error: unknown) { const message = errorMessage(error) return staleAssetErrorPatterns.some((pattern) => pattern.test(message)) } export async function recoverNavigationError( error: unknown, target: string, dependencies: NavigationRecoveryDependencies, ) { if (!isStaleRouteAssetError(error)) { dependencies.showError('页面切换失败,请重试') return false } // 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 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 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) } }