发布 v1.10 #5
@@ -69,6 +69,9 @@ async function bootstrap(namespace: string) {
|
|||||||
const { MotionPlugin } = await import('@easyflow/plugins/motion');
|
const { MotionPlugin } = await import('@easyflow/plugins/motion');
|
||||||
app.use(MotionPlugin);
|
app.use(MotionPlugin);
|
||||||
|
|
||||||
|
// 初始导航和权限守卫完成后再挂载,避免 RouterView 短暂显示为空白。
|
||||||
|
await router.isReady();
|
||||||
|
|
||||||
// 动态更新标题
|
// 动态更新标题
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (preferences.app.dynamicTitle) {
|
if (preferences.app.dynamicTitle) {
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
/* eslint-disable perfectionist/sort-imports -- 兼容层必须先于所有业务依赖执行。 */
|
/* eslint-disable perfectionist/sort-imports -- 兼容层必须先于所有业务依赖执行。 */
|
||||||
import './polyfills';
|
import './polyfills';
|
||||||
|
import './startup-error.css';
|
||||||
|
|
||||||
import {initPreferences, preferences, updatePreferences,} from '@easyflow/preferences';
|
import {
|
||||||
import {unmountGlobalLoading} from '@easyflow/utils';
|
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();
|
unmountGlobalLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
initApplication();
|
initApplication().catch((error) => {
|
||||||
|
console.error('Application startup failed.', error);
|
||||||
|
renderStartupError();
|
||||||
|
unmountGlobalLoading();
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ function collectMenuIcons(routes: unknown[]): string[] {
|
|||||||
return icons;
|
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) {
|
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||||
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
||||||
|
|
||||||
@@ -58,7 +67,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
|||||||
message: `${$t('common.loadingMenu')}...`,
|
message: `${$t('common.loadingMenu')}...`,
|
||||||
});
|
});
|
||||||
const menuRoutes = await getAllMenusApi();
|
const menuRoutes = await getAllMenusApi();
|
||||||
await loadMissingIconCollections(collectMenuIcons(menuRoutes));
|
await loadMenuIcons(collectMenuIcons(menuRoutes));
|
||||||
return disableUnsafeMenuRouteCaching(menuRoutes);
|
return disableUnsafeMenuRouteCaching(menuRoutes);
|
||||||
},
|
},
|
||||||
// 可以指定没有权限跳转403页面
|
// 可以指定没有权限跳转403页面
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
removeDevLoginQuery,
|
removeDevLoginQuery,
|
||||||
shouldAttemptDevLogin,
|
shouldAttemptDevLogin,
|
||||||
} from './dev-login';
|
} from './dev-login';
|
||||||
|
import { resolveNavigationUserInfo } from './navigation-user-info';
|
||||||
|
|
||||||
interface NetworkConnectionLike {
|
interface NetworkConnectionLike {
|
||||||
effectiveType?: string;
|
effectiveType?: string;
|
||||||
@@ -75,6 +76,13 @@ function setupChunkErrorGuard(router: Router) {
|
|||||||
if (!isDynamicImportChunkError(error)) {
|
if (!isDynamicImportChunkError(error)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开发环境保留当前页面和调试上下文,避免模块热更新期间反复整页刷新。
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('Dynamic route module loading failed:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fullPath = to?.fullPath;
|
const fullPath = to?.fullPath;
|
||||||
if (!fullPath) {
|
if (!fullPath) {
|
||||||
return;
|
return;
|
||||||
@@ -156,6 +164,13 @@ function setupAccessGuard(router: Router) {
|
|||||||
query: removeDevLoginQuery(to.query),
|
query: removeDevLoginQuery(to.query),
|
||||||
}).fullPath;
|
}).fullPath;
|
||||||
|
|
||||||
|
const resolveUserInfo = () =>
|
||||||
|
resolveNavigationUserInfo({
|
||||||
|
cachedUserInfo: userStore.userInfo,
|
||||||
|
fetchUserInfo: authStore.fetchUserInfo,
|
||||||
|
getAccessToken: () => accessStore.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
if (devLoginAccount && import.meta.env.DEV && accessStore.accessToken) {
|
if (devLoginAccount && import.meta.env.DEV && accessStore.accessToken) {
|
||||||
return resolveCleanFullPath();
|
return resolveCleanFullPath();
|
||||||
}
|
}
|
||||||
@@ -177,10 +192,6 @@ function setupAccessGuard(router: Router) {
|
|||||||
});
|
});
|
||||||
await devLoginPromise;
|
await devLoginPromise;
|
||||||
const cleanFullPath = resolveCleanFullPath();
|
const cleanFullPath = resolveCleanFullPath();
|
||||||
if (window.location.search.includes('devLogin=')) {
|
|
||||||
window.location.replace(cleanFullPath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return cleanFullPath;
|
return cleanFullPath;
|
||||||
} catch {
|
} catch {
|
||||||
const cleanFullPath = resolveCleanFullPath();
|
const cleanFullPath = resolveCleanFullPath();
|
||||||
@@ -198,8 +209,10 @@ function setupAccessGuard(router: Router) {
|
|||||||
// 基本路由,这些路由不需要进入权限拦截
|
// 基本路由,这些路由不需要进入权限拦截
|
||||||
if (coreRouteNames.includes(to.name as string)) {
|
if (coreRouteNames.includes(to.name as string)) {
|
||||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||||
const currentUser =
|
const currentUser = await resolveUserInfo();
|
||||||
userStore.userInfo || (await authStore.fetchUserInfo());
|
if (!currentUser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (shouldForcePasswordChange(currentUser)) {
|
if (shouldForcePasswordChange(currentUser)) {
|
||||||
return buildForcePasswordRoute();
|
return buildForcePasswordRoute();
|
||||||
}
|
}
|
||||||
@@ -239,7 +252,17 @@ function setupAccessGuard(router: Router) {
|
|||||||
return to;
|
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 (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||||
if (from.name) {
|
if (from.name) {
|
||||||
notifyForcePasswordChange();
|
notifyForcePasswordChange();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
easyflow-ui-admin/app/src/router/navigation-user-info.ts
Normal file
26
easyflow-ui-admin/app/src/router/navigation-user-info.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
interface ResolveNavigationUserInfoOptions<T> {
|
||||||
|
cachedUserInfo: null | T;
|
||||||
|
fetchUserInfo: () => Promise<T>;
|
||||||
|
getAccessToken: () => null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析路由守卫所需的用户信息,并识别已被拦截器清理的失效登录态。
|
||||||
|
*/
|
||||||
|
async function resolveNavigationUserInfo<T>(
|
||||||
|
options: ResolveNavigationUserInfoOptions<T>,
|
||||||
|
): Promise<null | T> {
|
||||||
|
if (options.cachedUserInfo) {
|
||||||
|
return options.cachedUserInfo;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await options.fetchUserInfo();
|
||||||
|
} catch (error) {
|
||||||
|
if (!options.getAccessToken()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resolveNavigationUserInfo };
|
||||||
52
easyflow-ui-admin/app/src/startup-error.css
Normal file
52
easyflow-ui-admin/app/src/startup-error.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
27
easyflow-ui-admin/app/src/startup-error.test.ts
Normal file
27
easyflow-ui-admin/app/src/startup-error.test.ts
Normal file
@@ -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<HTMLButtonElement>('button');
|
||||||
|
retryButton?.click();
|
||||||
|
expect(reload).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the application root is missing', () => {
|
||||||
|
expect(renderStartupError({ root: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
easyflow-ui-admin/app/src/startup-error.ts
Normal file
45
easyflow-ui-admin/app/src/startup-error.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
interface RenderStartupErrorOptions {
|
||||||
|
reload?: () => void;
|
||||||
|
root?: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Vue 应用尚未挂载时展示可恢复的启动错误,避免异常后只剩白屏。
|
||||||
|
*/
|
||||||
|
function renderStartupError(options: RenderStartupErrorOptions = {}) {
|
||||||
|
const root = options.root ?? document.querySelector<HTMLElement>('#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 };
|
||||||
@@ -146,6 +146,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
resetAllStores();
|
resetAllStores();
|
||||||
accessStore.setLoginExpired(false);
|
accessStore.setLoginExpired(false);
|
||||||
|
|
||||||
|
// 初始路由守卫仍在执行时由守卫统一返回登录页,避免并发导航。
|
||||||
|
if (!router.currentRoute.value.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 回登录页带上当前路由地址
|
// 回登录页带上当前路由地址
|
||||||
await router.replace({
|
await router.replace({
|
||||||
path: LOGIN_PATH,
|
path: LOGIN_PATH,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"default": "./src/index.ts"
|
"default": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./svg": {
|
||||||
|
"types": "./src/svg/index.ts",
|
||||||
|
"default": "./src/svg/index.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from './iconify';
|
export * from './iconify';
|
||||||
export { default as EmptyIcon } from './icons/empty-icon.vue';
|
export { default as EmptyIcon } from './icons/empty-icon.vue';
|
||||||
export * from './svg';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user