修复部署后路由资源失效并保护未保存内容
This commit is contained in:
parent
e63c359ae4
commit
a3556565a0
@ -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('页面加载失败,请刷新后重试')
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user