diff --git a/frontend/src/main-app.js b/frontend/src/main-app.js index 1efd5172..cecdaacf 100644 --- a/frontend/src/main-app.js +++ b/frontend/src/main-app.js @@ -3,6 +3,8 @@ import { createPinia } from 'pinia' import App from './App.vue' import { router } from './router' import { useGlobalStatusStore } from './stores/useGlobalStatusStore' +import { ElMessage } from 'element-plus' +import { createRouteAssetRecovery, isReadOnlyRoute, isRouteAssetLoadError } from './utils/navigation.mjs' import './styles/element-services.css' import './assets/main.css' @@ -34,8 +36,27 @@ const globalStatus = useGlobalStatusStore(pinia) app.use(pinia) -router.onError((error) => { +const recoverRouteAsset = createRouteAssetRecovery({ + buildID: import.meta.url, + getStorage: () => window.sessionStorage, + reload: fullPath => { + // 只恢复页面导航,不重放发送邮件或保存配置等业务请求。 + window.history.replaceState(window.history.state, '', `#${fullPath}`) + window.location.reload() + } +}) + +router.onError((error, to, from) => { console.error('Router navigation failed', error) + const editing = globalStatus.settingsDrawerVisible || globalStatus.userInfos.securitySetupRequired + if (recoverRouteAsset(error, to, from, {editing, online: window.navigator.onLine})) { + return + } + if (isRouteAssetLoadError(error) && (editing || !isReadOnlyRoute(from)) && from?.name) { + // 保留编辑器或配置表单,不能用错误页卸载尚未保存的内容。 + ElMessage.error('页面资源加载失败,请先保存当前内容,再刷新页面。') + return + } globalStatus.setStartupError('页面加载失败,请刷新后重试') }) diff --git a/frontend/src/utils/navigation.mjs b/frontend/src/utils/navigation.mjs index fa9c5287..f852aa53 100644 --- a/frontend/src/utils/navigation.mjs +++ b/frontend/src/utils/navigation.mjs @@ -1,5 +1,54 @@ const BLOCKED_REDIRECT_PATHS = ["/login", "/login-verification", "/setup"]; +const ASSET_RECOVERY_KEY = 'smail:route-asset-recovery'; +const READ_ONLY_ROUTES = new Set(['home', 'list', 'detail']); +const RECOVERABLE_ROUTES = new Set([...READ_ONLY_ROUTES, 'editer']); + +function isRouteAssetLoadError(error) { + const message = typeof error?.message === 'string' ? error.message : ''; + return /^Unable to preload CSS for /.test(message) + || /^Failed to fetch dynamically imported module:/.test(message) + || /^error loading dynamically imported module/i.test(message) + || /^Importing a module script failed/.test(message) + || (error?.name === 'ChunkLoadError' && /^Loading chunk /.test(message)); +} + +function isReadOnlyRoute(route) { + return READ_ONLY_ROUTES.has(route?.name); +} + +function createRouteAssetRecovery({buildID, getStorage, reload}) { + let recovering = false; + + return function recoverRouteAsset(error, to, from, {editing = false, online = true} = {}) { + if (!isRouteAssetLoadError(error) || !isReadOnlyRoute(from) || editing || !online) { + return false; + } + if (!RECOVERABLE_ROUTES.has(to?.name) || !to.fullPath + || getSafeLoginRedirect(to.fullPath, '') !== to.fullPath) { + return false; + } + if (recovering) { + return true; + } + try { + const storage = getStorage(); + if (!buildID || storage.getItem(ASSET_RECOVERY_KEY) === buildID) { + return false; + } + // 先持久化重试标记;同一标签页的同一构建最多自动恢复一次。 + storage.setItem(ASSET_RECOVERY_KEY, buildID); + recovering = true; + reload(to.fullPath); + return true; + } catch { + // 浏览器禁用存储或导航失败时保留手动恢复入口,不能无限刷新。 + recovering = false; + return false; + } + }; +} + function decodeRedirect(value) { let decoded = value; for (let attempt = 0; attempt < 2; attempt += 1) { @@ -92,6 +141,9 @@ function createLoginRedirectHandler(router) { } export { + createRouteAssetRecovery, + isReadOnlyRoute, + isRouteAssetLoadError, createLoginLocation, createLoginRedirectHandler, createSetupLocation, diff --git a/test/frontend/navigation.test.mjs b/test/frontend/navigation.test.mjs index 5701c251..fd0f3e4c 100644 --- a/test/frontend/navigation.test.mjs +++ b/test/frontend/navigation.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + createRouteAssetRecovery, + isRouteAssetLoadError, createLoginRedirectHandler, getCurrentLoginRedirect, getSafeLoginRedirect, @@ -18,6 +20,74 @@ test("登录重定向只接受非登录页的站内路径", () => { assert.equal(getSafeLoginRedirect("/login/extra"), "/"); }); +test('模块与样式加载错误才进入资源恢复流程', () => { + for (const message of [ + 'Unable to preload CSS for /assets/old.css', + 'Failed to fetch dynamically imported module: https://example.com/assets/old.js', + 'error loading dynamically imported module', + 'Importing a module script failed.' + ]) assert.equal(isRouteAssetLoadError(new Error(message)), true); + assert.equal(isRouteAssetLoadError(new Error('Network Error')), false); + assert.equal(isRouteAssetLoadError(new Error('邮件发送失败')), false); + assert.equal(isRouteAssetLoadError(null), false); +}); + +test('资源恢复保留目标路径且同一构建只自动刷新一次', () => { + const values = new Map(); + const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)}; + const paths = []; + const options = {buildID: 'release-a', getStorage: () => storage, reload: path => paths.push(path)}; + const recover = createRouteAssetRecovery(options); + const error = new Error('Unable to preload CSS for /assets/old.css'); + const from = {name: 'list'}; + const to = {name: 'editer', fullPath: '/editer?draft=20'}; + assert.equal(recover(error, to, from), true); + assert.equal(recover(error, to, from), true); + assert.deepEqual(paths, ['/editer?draft=20']); + assert.equal(createRouteAssetRecovery(options)(error, to, from), false); + assert.equal(createRouteAssetRecovery({...options, buildID: 'release-b'})(error, to, from), true); + assert.equal(paths.length, 2); +}); + +test('编辑页、设置表单、离线和非法目标不会自动刷新', () => { + let storageCalls = 0; + const recover = createRouteAssetRecovery({ + buildID: 'release-a', + getStorage: () => { storageCalls += 1; throw new Error('不应访问存储'); }, + reload: () => assert.fail('不应刷新') + }); + const error = new Error('Unable to preload CSS for /assets/old.css'); + const to = {name: 'detail', fullPath: '/detail/21'}; + for (const name of ['editer', 'setup', 'login', 'force-password-change', undefined]) { + assert.equal(recover(error, to, {name}), false); + } + assert.equal(recover(error, to, {name: 'list'}, {editing: true}), false); + assert.equal(recover(error, to, {name: 'list'}, {online: false}), false); + for (const fullPath of ['//example.com', '/%2fexample.com', 'https://example.com', '/login', '']) { + assert.equal(recover(error, {...to, fullPath}, {name: 'list'}), false); + } + assert.equal(recover(new Error('Network Error'), to, {name: 'list'}), false); + assert.equal(storageCalls, 0); +}); + +test('存储不可用或导航异常时交回手动处理', () => { + const error = new Error('Unable to preload CSS for /assets/old.css'); + const to = {name: 'detail', fullPath: '/detail/21'}; + for (const getStorage of [ + () => { throw new Error('存储不可用'); }, + () => ({getItem: () => null, setItem: () => { throw new Error('写入失败'); }}) + ]) { + const recover = createRouteAssetRecovery({buildID: 'release-a', getStorage, reload: () => assert.fail('不应刷新')}); + assert.equal(recover(error, to, {name: 'list'}), false); + } + const recover = createRouteAssetRecovery({ + buildID: 'release-a', + getStorage: () => ({getItem: () => null, setItem: () => {}}), + reload: () => { throw new Error('导航失败'); } + }); + assert.equal(recover(error, to, {name: 'list'}), false); +}); + test("已在登录页时保留原有安全重定向", () => { assert.equal(getCurrentLoginRedirect({ name: "login",