Files
EasyFlow/easyflow-ui-admin/app/src/router/guard.ts
陈子默 1bd9810518 feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
2026-08-31 16:45:48 +08:00

329 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Router } from 'vue-router';
import { LOGIN_PATH } from '@easyflow/constants';
import { preferences } from '@easyflow/preferences';
import { useAccessStore, useUserStore } from '@easyflow/stores';
import { startProgress, stopProgress } from '@easyflow/utils';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import {
buildForcePasswordRoute,
isForcePasswordRoute,
notifyForcePasswordChange,
shouldForcePasswordChange,
} from '#/utils/password-reset';
import { generateAccess } from './access';
import {
getDevLoginAccount,
removeDevLoginQuery,
shouldAttemptDevLogin,
} from './dev-login';
import { resolveNavigationUserInfo } from './navigation-user-info';
interface NetworkConnectionLike {
effectiveType?: string;
saveData?: boolean;
}
const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__';
function isExternalShareRoute(path: string) {
return (
path === '/share/knowledge' ||
path === '/share/knowledge/expired' ||
path === '/share/workflow' ||
path === '/share/workflow/expired'
);
}
function isSlowNetworkConnection() {
if (typeof navigator === 'undefined') {
return false;
}
const nav = navigator as Navigator & {
connection?: NetworkConnectionLike;
mozConnection?: NetworkConnectionLike;
webkitConnection?: NetworkConnectionLike;
};
const connection =
nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
if (!connection) {
return false;
}
if (connection.saveData) {
return true;
}
return ['2g', '3g', 'slow-2g'].includes(connection.effectiveType ?? '');
}
function isDynamicImportChunkError(error: unknown) {
const message = String((error as Error | undefined)?.message ?? error ?? '');
if (!message) {
return false;
}
return (
/Loading chunk/i.test(message) ||
/Importing a module script failed/i.test(message) ||
/Failed to fetch dynamically imported module/i.test(message) ||
/dynamically imported module/i.test(message)
);
}
function setupChunkErrorGuard(router: Router) {
router.onError((error, to) => {
if (!isDynamicImportChunkError(error)) {
return;
}
// 开发环境保留当前页面和调试上下文,避免模块热更新期间反复整页刷新。
if (import.meta.env.DEV) {
console.warn('Dynamic route module loading failed:', error);
return;
}
const fullPath = to?.fullPath;
if (!fullPath) {
return;
}
const lastReloadPath = sessionStorage.getItem(CHUNK_ERROR_RELOAD_KEY);
if (lastReloadPath === fullPath) {
sessionStorage.removeItem(CHUNK_ERROR_RELOAD_KEY);
return;
}
sessionStorage.setItem(CHUNK_ERROR_RELOAD_KEY, fullPath);
window.location.assign(fullPath);
});
router.isReady().finally(() => {
const reloadedPath = sessionStorage.getItem(CHUNK_ERROR_RELOAD_KEY);
if (reloadedPath && router.currentRoute.value.fullPath === reloadedPath) {
sessionStorage.removeItem(CHUNK_ERROR_RELOAD_KEY);
}
});
}
function shouldUseRouteProgress() {
if (preferences.transition.progress) {
return true;
}
// 普通网络下loading遮罩不显示时自动回退到顶部进度条
return preferences.transition.loading && !isSlowNetworkConnection();
}
/**
* 通用守卫配置
* @param router
*/
function setupCommonGuard(router: Router) {
// 记录已经加载的页面
const loadedPaths = new Set<string>();
router.beforeEach((to) => {
to.meta.loaded = loadedPaths.has(to.path);
// 页面加载进度条
if (!to.meta.loaded && shouldUseRouteProgress()) {
startProgress();
}
return true;
});
router.afterEach((to, _from, failure) => {
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
if (!failure) {
loadedPaths.add(to.path);
}
// 关闭页面加载进度条
if (shouldUseRouteProgress()) {
stopProgress();
}
});
}
/**
* 权限访问守卫配置
* @param router
*/
function setupAccessGuard(router: Router) {
let devLoginPromise: null | Promise<void> = null;
router.beforeEach(async (to, from) => {
// 公开路由必须在读取或刷新登录态之前短路,避免浏览器残留的过期
// token 把匿名分享页重定向到登录页。
if (to.meta.ignoreAccess) {
return true;
}
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
const devLoginAccount = getDevLoginAccount(to.query);
const resolveCleanFullPath = () =>
router.resolve({
hash: to.hash,
path: to.path,
query: removeDevLoginQuery(to.query),
}).fullPath;
const resolveUserInfo = () =>
resolveNavigationUserInfo({
cachedUserInfo: userStore.userInfo,
fetchUserInfo: authStore.fetchUserInfo,
getAccessToken: () => accessStore.accessToken,
});
if (devLoginAccount && import.meta.env.DEV && accessStore.accessToken) {
return resolveCleanFullPath();
}
if (
shouldAttemptDevLogin({
account: devLoginAccount,
hasAccessToken: !!accessStore.accessToken,
isDev: import.meta.env.DEV,
})
) {
const account = devLoginAccount;
try {
devLoginPromise ??= authStore
.authDevLogin(account ?? '')
.then(() => undefined)
.finally(() => {
devLoginPromise = null;
});
await devLoginPromise;
const cleanFullPath = resolveCleanFullPath();
return cleanFullPath;
} catch {
const cleanFullPath = resolveCleanFullPath();
return {
path: LOGIN_PATH,
query:
cleanFullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(cleanFullPath) },
replace: true,
};
}
}
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
const currentUser = await resolveUserInfo();
if (!currentUser) {
return true;
}
if (shouldForcePasswordChange(currentUser)) {
return buildForcePasswordRoute();
}
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
preferences.app.defaultHomePath,
);
}
return true;
}
// accessToken 检查
if (!accessStore.accessToken) {
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
const cleanFullPath =
devLoginAccount && import.meta.env.DEV
? resolveCleanFullPath()
: to.fullPath;
return {
path: LOGIN_PATH,
// 如不需要,直接删除 query
query:
cleanFullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(cleanFullPath) },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true,
};
}
return to;
}
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 (from.name) {
notifyForcePasswordChange();
}
return buildForcePasswordRoute();
}
if (isExternalShareRoute(to.path)) {
return true;
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
}
// 页面菜单与按钮权限码是两套数据源。每次重新构建动态菜单时,
// 同步刷新一次 accessCodes避免后端权限模型调整后页面仍持有旧按钮权限。
await authStore.fetchAccessCodes();
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
roles: userRoles,
router,
// 则会在菜单中显示但是访问会被重定向到403
routes: accessRoutes,
});
// 保存菜单信息和路由信息
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
const redirectPath = (from.query.redirect ??
(to.path === preferences.app.defaultHomePath
? userInfo.homePath || preferences.app.defaultHomePath
: to.fullPath)) as string;
return {
...router.resolve(decodeURIComponent(redirectPath)),
replace: true,
};
});
}
/**
* 项目守卫配置
* @param router
*/
function createRouterGuard(router: Router) {
/** 通用 */
setupCommonGuard(router);
/** 权限访问 */
setupAccessGuard(router);
/** chunk加载错误兜底 */
setupChunkErrorGuard(router);
}
export { createRouterGuard };