Files
FastapiAdmin/frontend/new-web/src/router/guards/beforeEach.ts
T
zhangtao 6db264bef3 feat: 删除fastapi-backend.spec文件并更新依赖项注释
- 移除不再使用的fastapi-backend.spec文件
- 更新pyproject.toml和requirements.txt中的依赖项注释,增加了对OpenAI API客户端和其他依赖的描述
- 在多个API模块中统一返回类型为单数形式,提升代码一致性
- 更新前端环境配置,调整API基础URL和WebSocket端点为本地地址
2026-05-05 07:16:13 +08:00

476 lines
13 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.
/**
* 路由全局前置守卫模块
*
* 提供完整的路由导航守卫功能
*
* ## 主要功能
*
* - 登录状态验证和重定向
* - 动态路由注册和权限控制
* - 菜单数据获取和处理(前端/后端模式)
* - 用户信息获取和缓存
* - 页面标题设置
* - 工作标签页管理
* - 进度条和加载动画控制
* - 静态路由识别和处理
* - 错误处理和异常跳转
*
* ## 使用场景
*
* - 路由跳转前的权限验证
* - 动态菜单加载和路由注册
* - 用户登录状态管理
* - 页面访问控制
* - 路由级别的加载状态管理
*
* ## 工作流程
*
* 1. 检查登录状态,未登录跳转到登录页
* 2. 首次访问时获取用户信息和菜单数据
* 3. 根据权限动态注册路由
* 4. 设置页面标题和工作标签页
* 5. 处理根路径重定向到首页
* 6. 未匹配路由跳转到 404 页面
*
* @module router/guards/beforeEach
* @author FastapiAdmin Team
*/
import type { Router, RouteLocationNormalized, NavigationGuardNext } from "vue-router";
import { nextTick } from "vue";
import NProgress from "nprogress";
import { useSettingsStore } from "@/store/modules/setting.store";
import { useUserStore } from "@/store/modules/user.store";
import { useMenuStore } from "@/store/modules/menu.store";
import { setWorktab } from "@/utils/navigation";
import { setPageTitle } from "@/utils/navigation/router";
import { RoutesAlias } from "../routesAlias";
import { staticRoutes } from "../routes/staticRoutes";
import { loadingService } from "@/utils/ui";
import { useCommon } from "@/hooks/core/useCommon";
import { useWorktabStore } from "@/store/modules/worktab.store";
import { UserAPI } from "@/api/module_system/user";
import { ApiStatus } from "@/utils/http/status";
import { isHttpError } from "@/utils/http/error";
import {
RouteRegistry,
MenuProcessor,
IframeRouteManager,
RoutePermissionValidator,
} from "../core";
// 路由注册器实例
let routeRegistry: RouteRegistry | null = null;
// 菜单处理器实例
const menuProcessor = new MenuProcessor();
// 跟踪是否需要关闭 loading
let pendingLoading = false;
// 路由初始化失败标记,防止死循环
// 一旦设置为 true,只有刷新页面或重新登录才能重置
let routeInitFailed = false;
// 路由初始化进行中标记,防止并发请求
let routeInitInProgress = false;
/**
* 获取 pendingLoading 状态
*/
export function getPendingLoading(): boolean {
return pendingLoading;
}
/**
* 重置 pendingLoading 状态
*/
export function resetPendingLoading(): void {
pendingLoading = false;
}
/**
* 获取路由初始化失败状态
*/
export function getRouteInitFailed(): boolean {
return routeInitFailed;
}
/**
* 重置路由初始化状态(用于重新登录场景)
*/
export function resetRouteInitState(): void {
routeInitFailed = false;
routeInitInProgress = false;
}
/**
* 设置路由全局前置守卫
*/
export function setupBeforeEachGuard(router: Router): void {
// 初始化路由注册器
routeRegistry = new RouteRegistry(router);
router.beforeEach(
async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
try {
await handleRouteGuard(to, from, next, router);
} catch (error) {
console.error("[RouteGuard] 路由守卫处理失败:", error);
closeLoading();
next({ name: "Exception500" });
}
}
);
}
/**
* 关闭 loading 效果
*/
function closeLoading(): void {
if (pendingLoading) {
nextTick(() => {
loadingService.hideLoading();
pendingLoading = false;
});
}
}
/**
* 处理路由守卫逻辑
*/
async function handleRouteGuard(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
const settingStore = useSettingsStore();
const userStore = useUserStore();
// 启动进度条
if (settingStore.showNprogress) {
NProgress.start();
}
// 1. 检查登录状态
if (!handleLoginStatus(to, userStore, next)) {
return;
}
// 2. 检查路由初始化是否已失败(防止死循环)
if (routeInitFailed) {
// 已经失败过,直接放行到错误页面,不再重试
if (to.matched.length > 0) {
next();
} else {
// 未匹配到路由,跳转到 500 页面
next({ name: "Exception500", replace: true });
}
return;
}
// 3. 处理动态路由注册
const menuStore = useMenuStore();
/** 未注册动态路由,或菜单已被清空(登出延迟 reset 与再登录竞态下可能出现「已注册但 menuList 为空」) */
const shouldInitRoutes =
userStore.isLogin && (!routeRegistry?.isRegistered() || menuStore.menuList.length === 0);
if (shouldInitRoutes) {
// 防止并发请求(快速连续导航场景)
if (routeInitInProgress) {
// 正在初始化中,等待完成后重新导航
next(false);
return;
}
await handleDynamicRoutes(to, next, router);
return;
}
// 4. 处理根路径重定向
if (handleRootPathRedirect(to, next)) {
return;
}
// 5. 处理已匹配的路由
if (to.matched.length > 0) {
setWorktab(to);
setPageTitle(to);
next();
return;
}
// 6. 未匹配到路由,跳转到 404
next({ name: "Exception404" });
}
/**
* 处理登录状态
* @returns true 表示可以继续,false 表示已处理跳转
*/
function handleLoginStatus(
to: RouteLocationNormalized,
userStore: ReturnType<typeof useUserStore>,
next: NavigationGuardNext
): boolean {
// 已登录 / 登录相关路由 / 明确配置的匿名白名单,放行。
// 注意:不能使用「整条 staticRoutes 都算静态」——否则 `/`、`/` 重定向到的业务页也会被当成免登录,
// 未登录用户会先被放过再跳首页(见 issue:打开站点先进首页而非登录页)。
if (userStore.isLogin || isLoginRoute(to) || isAnonymousPublicPath(to.path)) {
return true;
}
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
userStore.resetAllState();
next({
name: "Login",
query: { redirect: to.fullPath },
});
return false;
}
/** 登录页(项目里同时存在 `/login` 与 `/auth/login` 等多套入口) */
function isLoginRoute(to: RouteLocationNormalized): boolean {
return to.path === "/login" || to.path === RoutesAlias.Login || to.name === "Login";
}
/**
* 无需登录即可访问的路径(登录页由 isLoginRoute 处理,此处为错误页、重定向等)。
* 勿将挂载 Layout 的业务路由(如 `/home`、`/dashboard/*`、`/profile`)列入此处。
*/
function isAnonymousPublicPath(path: string): boolean {
if (path.startsWith("/redirect")) return true;
const allow = new Set(["/401", "/404", "/500", "/403"]);
return allow.has(path);
}
/** 将父级绝对路径与相对子 path 拼成完整路径(用于识别如 `/` + `home` → `/home` */
function resolveStaticChildFullPath(parentFullPath: string, segment: string): string {
const seg = segment.replace(/^\/+/, "");
if (!parentFullPath || parentFullPath === "/") {
return `/${seg}`;
}
return `${parentFullPath.replace(/\/$/, "")}/${seg}`;
}
/**
* 检查路由是否为静态路由
*/
function isStaticRoute(path: string): boolean {
const checkRoute = (routes: any[], targetPath: string, parentFullPath = ""): boolean => {
return routes.some((route) => {
// 404 catch-all 路由不应视为可匿名访问的静态页,
// 否则未登录时手动输入任意地址会直接落到 404,无法跳转登录页。
if (route.name === "Exception404") {
return false;
}
const routePath = route.path ?? "";
const fullPath = routePath.startsWith("/")
? routePath
: resolveStaticChildFullPath(parentFullPath, routePath);
const pattern = fullPath.replace(/:[^/]+/g, "[^/]+").replace(/\*/g, ".*");
const regex = new RegExp(`^${pattern}$`);
if (regex.test(targetPath)) {
return true;
}
if (route.children && route.children.length > 0) {
return checkRoute(route.children, targetPath, fullPath);
}
return false;
});
};
return checkRoute(staticRoutes, path);
}
/**
* 动态路由仍标记为已注册但侧边菜单已被清空时,先卸下动态路由再拉菜单。
* 典型场景:`logout` 中 `resetRouterState(500)` 延迟执行,用户在 500ms 内再次登录,
* 守卫若仅判断 `isRegistered()` 会跳过拉菜单,侧栏空白。
*/
function repairDynamicRoutesIfMenuEmpty(): void {
if (!routeRegistry?.isRegistered()) return;
const ms = useMenuStore();
if (ms.menuList.length > 0) return;
routeRegistry.unregister();
IframeRouteManager.getInstance().clear();
ms.removeAllDynamicRoutes();
resetRouteInitState();
}
/**
* 处理动态路由注册
*/
async function handleDynamicRoutes(
to: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
repairDynamicRoutesIfMenuEmpty();
// 标记初始化进行中
routeInitInProgress = true;
// 显示 loading
pendingLoading = true;
loadingService.showLoading();
try {
// 1. 获取用户信息
await fetchUserInfo();
// 2. 获取菜单数据
const menuList = await menuProcessor.getMenuList();
// 3. 验证菜单数据
if (!menuProcessor.validateMenuList(menuList)) {
throw new Error("获取菜单列表失败,请重新登录");
}
// 4. 注册动态路由
routeRegistry?.register(menuList);
// 5. 保存菜单数据到 store
const menuStore = useMenuStore();
menuStore.setMenuList(menuList);
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || []);
// 6. 保存 iframe 路由
IframeRouteManager.getInstance().save();
// 7. 验证工作标签页
useWorktabStore().validateWorktabs(router);
// 8. 静态路由不依赖菜单权限,初始化后直接恢复目标地址。
if (isStaticRoute(to.path)) {
routeInitInProgress = false;
next({
path: to.path,
query: to.query,
hash: to.hash,
replace: true,
});
return;
}
// 8. 验证目标路径权限
const { homePath } = useCommon();
const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath(
to.path,
menuList,
homePath.value || "/"
);
// 初始化成功,重置进行中标记
routeInitInProgress = false;
// 9. 重新导航到目标路由
if (!hasPermission) {
// 无权限访问,跳转到首页
closeLoading();
// 输出警告信息
console.warn(`[RouteGuard] 用户无权限访问路径: ${to.path},已跳转到首页`);
// 直接跳转到首页
next({
path: validatedPath,
replace: true,
});
} else {
// 有权限,正常导航
next({
path: to.path,
query: to.query,
hash: to.hash,
replace: true,
});
}
} catch (error) {
console.error("[RouteGuard] 动态路由注册失败:", error);
// 关闭 loading
closeLoading();
// 401 错误:axios 拦截器已处理退出登录,取消当前导航
if (isUnauthorizedError(error)) {
// 重置状态,允许重新登录后再次初始化
routeInitInProgress = false;
next(false);
return;
}
// 标记初始化失败,防止死循环
routeInitFailed = true;
routeInitInProgress = false;
// 输出详细错误信息,便于排查
if (isHttpError(error)) {
console.error(`[RouteGuard] 错误码: ${error.code}, 消息: ${error.message}`);
}
// 跳转到 500 页面,使用 replace 避免产生历史记录
next({ name: "Exception500", replace: true });
}
}
/**
* 获取用户信息
*/
async function fetchUserInfo(): Promise<void> {
const userStore = useUserStore();
const response = await UserAPI.getCurrentUserInfo();
const data = response.data.data;
userStore.setUserInfo(data);
// 检查并清理工作台标签页(如果是不同用户登录)
userStore.checkAndClearWorktabs();
}
/**
* 重置路由相关状态
*/
export function resetRouterState(delay: number): void {
setTimeout(() => {
routeRegistry?.unregister();
IframeRouteManager.getInstance().clear();
const menuStore = useMenuStore();
menuStore.removeAllDynamicRoutes();
menuStore.setMenuList([]);
// 重置路由初始化状态,允许重新登录后再次初始化
resetRouteInitState();
}, delay);
}
/**
* 处理根路径重定向到首页
* @returns true 表示已处理跳转,false 表示无需跳转
*/
function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGuardNext): boolean {
if (to.path !== "/") {
return false;
}
const { homePath } = useCommon();
if (homePath.value && homePath.value !== "/") {
next({ path: homePath.value, replace: true });
return true;
}
return false;
}
/**
* 判断是否为未授权错误(401
*/
function isUnauthorizedError(error: unknown): boolean {
return isHttpError(error) && error.code === ApiStatus.unauthorized;
}