mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-11 20:05:44 +00:00
feat: 迁移前端资源文件并重构项目结构
refactor: 优化前端代码结构和资源管理 style: 调整前端代码格式和样式 chore: 更新.gitignore和构建配置 fix: 修复前端资源路径和引用问题 docs: 更新前端文档和注释 perf: 优化前端性能和加载速度 test: 更新前端测试用例 build: 调整前端构建配置 ci: 更新CI/CD配置
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 菜单处理器(含后端菜单 → AppRouteRecord 转换)
|
||||
*
|
||||
* @module router/MenuProcessor
|
||||
*/
|
||||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||||
import type { UserInfo } from "@/api/module_system/user";
|
||||
import type { MenuTable } from "@/api/module_system/menu";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { useAppMode } from "@/hooks/core/useAppMode";
|
||||
import MenuAPI from "@/api/module_system/menu";
|
||||
import {
|
||||
mergeAppRouteRecords,
|
||||
ROUTE_COMPONENT_LAYOUT,
|
||||
ROUTE_COMPONENT_NESTED_PARENT,
|
||||
} from "./staticRoutes";
|
||||
import { formatMenuTitle } from "@utils";
|
||||
import { MenuTypeEnum } from "@/enums/system/menu.enum";
|
||||
|
||||
/** 前端模式并入菜单的内置路由(扩展点,默认空) */
|
||||
export const builtinFrontendRoutes: AppRouteRecord[] = [];
|
||||
|
||||
function normalizeMenuNestedPaths(items: MenuTable[], parentAbsolutePath?: string): MenuTable[] {
|
||||
return items.map((node) => {
|
||||
const raw = (node.route_path ?? "").trim();
|
||||
let routePath: string | null | undefined = node.route_path;
|
||||
if (parentAbsolutePath && raw && raw === parentAbsolutePath) {
|
||||
routePath = "";
|
||||
}
|
||||
const canonical = raw.startsWith("/") ? raw : parentAbsolutePath;
|
||||
const children = node.children?.length
|
||||
? normalizeMenuNestedPaths(node.children, canonical)
|
||||
: undefined;
|
||||
return { ...node, route_path: routePath, children };
|
||||
});
|
||||
}
|
||||
|
||||
function joinAbsolutePath(parentAbs: string, segmentPath: string): string {
|
||||
const seg = segmentPath.replace(/^\/+/, "");
|
||||
const base = parentAbs.replace(/\/$/, "");
|
||||
if (!seg) return base;
|
||||
return `${base}/${seg}`;
|
||||
}
|
||||
|
||||
function normalizeAppRouteChildPaths(
|
||||
routes: AppRouteRecord[],
|
||||
parentAbsolutePath = ""
|
||||
): AppRouteRecord[] {
|
||||
return routes.map((route) => {
|
||||
let path = (route.path ?? "").trim();
|
||||
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return {
|
||||
...route,
|
||||
children: route.children?.length
|
||||
? normalizeAppRouteChildPaths(route.children, parentAbsolutePath)
|
||||
: route.children,
|
||||
};
|
||||
}
|
||||
|
||||
if (parentAbsolutePath && path.startsWith("/")) {
|
||||
const p = parentAbsolutePath.replace(/\/$/, "");
|
||||
if (path.startsWith(`${p}/`)) {
|
||||
path = path.slice(p.length + 1);
|
||||
} else if (path === p) {
|
||||
path = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
parentAbsolutePath &&
|
||||
path.startsWith("/") &&
|
||||
!/^https?:\/\//i.test(path) &&
|
||||
!path.startsWith("/outside/iframe/")
|
||||
) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length) path = segments[segments.length - 1]!;
|
||||
}
|
||||
|
||||
const currentAbs = !parentAbsolutePath
|
||||
? path.startsWith("/")
|
||||
? path
|
||||
: path
|
||||
? `/${path.replace(/^\/+/, "")}`
|
||||
: ""
|
||||
: joinAbsolutePath(parentAbsolutePath, path);
|
||||
|
||||
const children = route.children?.length
|
||||
? normalizeAppRouteChildPaths(route.children, currentAbs)
|
||||
: route.children;
|
||||
|
||||
return { ...route, path, children };
|
||||
});
|
||||
}
|
||||
|
||||
function toComponentImportPath(componentPath: string): string {
|
||||
const t = componentPath.trim().replace(/^\/+/, "");
|
||||
return t ? `/${t}` : "";
|
||||
}
|
||||
|
||||
function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
|
||||
const childrenRaw = item.children?.filter((c) => c.type !== MenuTypeEnum.BUTTON) ?? [];
|
||||
const children = childrenRaw.length
|
||||
? childrenRaw.map((c) => mapMenuNode(c, depth + 1))
|
||||
: undefined;
|
||||
|
||||
const path = (item.route_path ?? "").trim();
|
||||
const name = item.route_name || undefined;
|
||||
const redirect = item.redirect?.trim() || undefined;
|
||||
|
||||
const hasKids = !!(children && children.length > 0);
|
||||
const isDirectory = item.type === MenuTypeEnum.CATALOG;
|
||||
|
||||
let component: string | undefined;
|
||||
if (isDirectory || (hasKids && !(item.component_path ?? "").trim())) {
|
||||
component = depth === 0 ? ROUTE_COMPONENT_LAYOUT : ROUTE_COMPONENT_NESTED_PARENT;
|
||||
} else if ((item.component_path ?? "").trim()) {
|
||||
component = toComponentImportPath(item.component_path!);
|
||||
}
|
||||
|
||||
const meta: RouteMeta = {
|
||||
title: item.title ?? "",
|
||||
icon: item.icon || undefined,
|
||||
hidden: !!item.hidden,
|
||||
keepAlive: item.keep_alive ?? true,
|
||||
affix: !!item.affix,
|
||||
fixedTab: !!item.affix,
|
||||
alwaysShow: !!item.always_show,
|
||||
isHide: !!item.hidden,
|
||||
client: item.client,
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
component,
|
||||
redirect,
|
||||
meta,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function backendMenusToAppRoutes(menus: MenuTable[]): AppRouteRecord[] {
|
||||
const roots = menus.filter((m) => m.type !== MenuTypeEnum.BUTTON);
|
||||
const normalized = normalizeMenuNestedPaths(roots);
|
||||
const mapped = normalized.map((m) => mapMenuNode(m, 0));
|
||||
return normalizeAppRouteChildPaths(mapped);
|
||||
}
|
||||
|
||||
export class MenuProcessor {
|
||||
/**
|
||||
* 获取菜单数据
|
||||
*/
|
||||
async getMenuList(): Promise<AppRouteRecord[]> {
|
||||
const { isFrontendMode, isMixedMenuMode } = useAppMode();
|
||||
|
||||
let menuList: AppRouteRecord[];
|
||||
if (isMixedMenuMode.value) {
|
||||
menuList = await this.processMixedMenu();
|
||||
} else if (isFrontendMode.value) {
|
||||
menuList = await this.processFrontendMenu();
|
||||
} else {
|
||||
menuList = await this.processBackendMenu();
|
||||
}
|
||||
|
||||
this.validateMenuPaths(menuList);
|
||||
|
||||
return this.normalizeMenuPaths(menuList);
|
||||
}
|
||||
|
||||
private async processFrontendMenu(): Promise<AppRouteRecord[]> {
|
||||
const userStore = useUserStore();
|
||||
let menuList = [...builtinFrontendRoutes];
|
||||
|
||||
if (userStore.info?.is_superuser) {
|
||||
return this.filterEmptyMenus(menuList);
|
||||
}
|
||||
|
||||
const roles = userStore.info?.roles;
|
||||
|
||||
if (roles && roles.length > 0) {
|
||||
const roleCodes = this.extractRoleCodesFromUserRoles(roles);
|
||||
if (roleCodes.length > 0) {
|
||||
menuList = this.filterMenuByRoles(menuList, roleCodes);
|
||||
}
|
||||
}
|
||||
|
||||
return this.filterEmptyMenus(menuList);
|
||||
}
|
||||
|
||||
private extractRoleCodesFromUserRoles(roles: NonNullable<UserInfo["roles"]>): string[] {
|
||||
const codes = new Set<string>();
|
||||
for (const role of roles) {
|
||||
const r = role as { code?: string; name?: string };
|
||||
const c = r.code?.trim();
|
||||
if (c) codes.add(c);
|
||||
const n = r.name?.trim();
|
||||
if (n && /^R_[A-Z0-9_]+$/i.test(n)) codes.add(n);
|
||||
}
|
||||
return Array.from(codes);
|
||||
}
|
||||
|
||||
private async processMixedMenu(): Promise<AppRouteRecord[]> {
|
||||
let backend: AppRouteRecord[] = [];
|
||||
try {
|
||||
backend = await this.processBackendMenu();
|
||||
} catch (e) {
|
||||
console.warn("[MenuProcessor] mixed:后端菜单获取失败,本次仅挂载前端路由", e);
|
||||
}
|
||||
const frontend = await this.processFrontendMenu();
|
||||
const merged = mergeAppRouteRecords(backend, frontend);
|
||||
return this.filterEmptyMenus(merged);
|
||||
}
|
||||
|
||||
private async processBackendMenu(): Promise<AppRouteRecord[]> {
|
||||
const response = await MenuAPI.listMenu({ menu_client: "pc" });
|
||||
const list = response.data.data || [];
|
||||
const routes = backendMenusToAppRoutes(list);
|
||||
return this.filterEmptyMenus(routes);
|
||||
}
|
||||
|
||||
private filterMenuByRoles(menu: AppRouteRecord[], roleCodes: string[]): AppRouteRecord[] {
|
||||
return menu.reduce((acc: AppRouteRecord[], item) => {
|
||||
const itemRoles = item.meta?.roles;
|
||||
const hasPermission = !itemRoles || itemRoles.some((role) => roleCodes?.includes(role));
|
||||
|
||||
if (hasPermission) {
|
||||
const filteredItem = { ...item };
|
||||
if (filteredItem.children?.length) {
|
||||
filteredItem.children = this.filterMenuByRoles(filteredItem.children, roleCodes);
|
||||
}
|
||||
acc.push(filteredItem);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
private filterEmptyMenus(menuList: AppRouteRecord[]): AppRouteRecord[] {
|
||||
return menuList
|
||||
.map((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
const filteredChildren = this.filterEmptyMenus(item.children);
|
||||
return {
|
||||
...item,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item) => {
|
||||
if ("children" in item) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.meta?.isIframe === true || item.meta?.link) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.component && item.component !== "" && item.component !== ROUTE_COMPONENT_LAYOUT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
validateMenuList(menuList: AppRouteRecord[]): boolean {
|
||||
return Array.isArray(menuList) && menuList.length > 0;
|
||||
}
|
||||
|
||||
private normalizeMenuPaths(menuList: AppRouteRecord[], parentPath = ""): AppRouteRecord[] {
|
||||
return menuList.map((item) => {
|
||||
const fullPath = this.buildFullPath(item.path || "", parentPath);
|
||||
|
||||
const children = item.children?.length
|
||||
? this.normalizeMenuPaths(item.children, fullPath)
|
||||
: item.children;
|
||||
|
||||
const redirect = item.redirect || this.resolveDefaultRedirect(children);
|
||||
|
||||
return {
|
||||
...item,
|
||||
path: fullPath,
|
||||
redirect,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private resolveDefaultRedirect(children?: AppRouteRecord[]): string | undefined {
|
||||
if (!children?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (this.isNavigableRoute(child)) {
|
||||
return child.path;
|
||||
}
|
||||
|
||||
const nestedRedirect = this.resolveDefaultRedirect(child.children);
|
||||
if (nestedRedirect) {
|
||||
return nestedRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isNavigableRoute(route: AppRouteRecord): boolean {
|
||||
return Boolean(
|
||||
route.path &&
|
||||
route.path !== "/" &&
|
||||
!route.meta?.link &&
|
||||
route.meta?.isIframe !== true &&
|
||||
route.component &&
|
||||
route.component !== ""
|
||||
);
|
||||
}
|
||||
|
||||
private validateMenuPaths(menuList: AppRouteRecord[], level = 1): void {
|
||||
menuList.forEach((route) => {
|
||||
if (!route.children?.length) return;
|
||||
|
||||
const parentName = String(route.name || route.path || "未知路由");
|
||||
|
||||
route.children.forEach((child) => {
|
||||
const childPath = child.path || "";
|
||||
|
||||
if (this.isValidAbsolutePath(childPath)) return;
|
||||
|
||||
if (childPath.startsWith("/")) {
|
||||
this.logPathError(child, childPath, parentName, level);
|
||||
}
|
||||
});
|
||||
|
||||
this.validateMenuPaths(route.children, level + 1);
|
||||
});
|
||||
}
|
||||
|
||||
private isValidAbsolutePath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith("http://") ||
|
||||
path.startsWith("https://") ||
|
||||
path.startsWith("/outside/iframe/")
|
||||
);
|
||||
}
|
||||
|
||||
private logPathError(
|
||||
route: AppRouteRecord,
|
||||
path: string,
|
||||
parentName: string,
|
||||
level: number
|
||||
): void {
|
||||
const routeName = String(route.name || path || "未知路由");
|
||||
const menuTitle = route.meta?.title || routeName;
|
||||
const suggestedPath = path.split("/").pop() || path.slice(1);
|
||||
|
||||
console.error(
|
||||
`[路由配置错误] 菜单 "${formatMenuTitle(menuTitle)}" (name: ${routeName}, path: ${path}) 配置错误\n` +
|
||||
` 位置: ${parentName} > ${routeName}\n` +
|
||||
` 问题: ${level + 1}级菜单的 path 不能以 / 开头\n` +
|
||||
` 当前配置: path: '${path}'\n` +
|
||||
` 应该改为: path: '${suggestedPath}'`
|
||||
);
|
||||
}
|
||||
|
||||
private buildFullPath(path: string, parentPath: string): string {
|
||||
if (!path) return "";
|
||||
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (parentPath) {
|
||||
const cleanParent = parentPath.replace(/\/$/, "");
|
||||
const cleanChild = path.replace(/^\//, "");
|
||||
return `${cleanParent}/${cleanChild}`;
|
||||
}
|
||||
|
||||
return `/${path}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { nextTick } from "vue";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { Router } from "vue-router";
|
||||
import { NProgress } from "@utils/ui";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { loadingService } from "@utils/ui";
|
||||
import { getPendingLoading, resetPendingLoading } from "./beforeEach";
|
||||
|
||||
/** 路由全局后置守卫 */
|
||||
export function setupAfterEachGuard(router: Router) {
|
||||
const { scrollToTop } = useCommon();
|
||||
|
||||
router.afterEach(() => {
|
||||
scrollToTop();
|
||||
|
||||
// 关闭进度条
|
||||
const settingStore = useSettingsStore();
|
||||
if (settingStore.showNprogress) {
|
||||
NProgress.done();
|
||||
// 确保进度条完全移除,避免残影
|
||||
setTimeout(() => {
|
||||
NProgress.remove();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// 关闭 loading 效果
|
||||
if (getPendingLoading()) {
|
||||
nextTick(() => {
|
||||
loadingService.hideLoading();
|
||||
resetPendingLoading();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* 路由全局前置守卫模块
|
||||
*
|
||||
* 提供完整的路由导航守卫功能
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 登录状态验证和重定向
|
||||
* - 动态路由注册和权限控制
|
||||
* - 菜单数据获取和处理(前端/后端模式)
|
||||
* - 用户信息获取和缓存
|
||||
* - 页面标题设置
|
||||
* - 工作标签页管理
|
||||
* - 进度条和加载动画控制
|
||||
* - 静态路由识别和处理
|
||||
* - 错误处理和异常跳转
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 路由跳转前的权限验证
|
||||
* - 动态菜单加载和路由注册
|
||||
* - 用户登录状态管理
|
||||
* - 页面访问控制
|
||||
* - 路由级别的加载状态管理
|
||||
*
|
||||
* ## 工作流程
|
||||
*
|
||||
* 1. 检查登录状态,未登录跳转到登录页
|
||||
* 2. 首次访问时获取用户信息和菜单数据
|
||||
* 3. 根据权限动态注册路由
|
||||
* 4. 设置页面标题和工作标签页
|
||||
* 5. 处理根路径重定向到首页
|
||||
* 6. 未匹配路由跳转到 404 页面
|
||||
*
|
||||
* @module router/beforeEach
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import type { Router, RouteLocationNormalized, NavigationGuardNext } from "vue-router";
|
||||
import { nextTick } from "vue";
|
||||
import { NProgress } from "@utils/ui";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { useMenuStore } from "@stores/modules/menu.store";
|
||||
import { setWorktab } from "@utils/navigation";
|
||||
import { setPageTitle } from "@utils/navigation";
|
||||
import { IframeRouteManager, ROUTE_PATH_LOGIN_ALT, staticRoutes } from "./staticRoutes";
|
||||
import { loadingService } from "@utils/ui";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { useWorktabStore } from "@stores/modules/worktab.store";
|
||||
import { UserAPI } from "@/api/module_system/user";
|
||||
import { ApiStatus, isHttpError } from "@utils/http";
|
||||
import { RouteRegistry } from "./dynamicRoutes";
|
||||
import { MenuProcessor } from "./MenuProcessor";
|
||||
|
||||
// 路由注册器实例
|
||||
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: "500" });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 loading 效果
|
||||
*/
|
||||
function closeLoading(): void {
|
||||
if (pendingLoading) {
|
||||
nextTick(() => {
|
||||
loadingService.hideLoading();
|
||||
pendingLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 若路由带有 query/params 传入的 title,写入 meta(净化防注入与过长字符串)
|
||||
*/
|
||||
function applySafeTitleFromQuery(to: RouteLocationNormalized): void {
|
||||
const rawTitle = (to.params.title as string) || (to.query.title as string);
|
||||
if (rawTitle && typeof rawTitle === "string") {
|
||||
const safe = rawTitle.replace(/[<>]/g, "").trim().slice(0, 64);
|
||||
if (safe) {
|
||||
to.meta.title = safe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理路由守卫逻辑
|
||||
*/
|
||||
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: "500", 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) {
|
||||
applySafeTitleFromQuery(to);
|
||||
setWorktab(to);
|
||||
setPageTitle(to);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 未匹配到路由,跳转到 404
|
||||
next({ name: "404" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录状态
|
||||
* @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 === ROUTE_PATH_LOGIN_ALT || 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(pathMatch)不应视为免登录静态页;静态表里可能与 `/404` 同名,按 path 区分。
|
||||
if (route.path === "/:pathMatch(.*)*") {
|
||||
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: "500", 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即卸下动态路由与菜单缓存(与 {@link resetRouterState} 回调一致,供刷新路由等同步场景)。
|
||||
*/
|
||||
export function resetDynamicRoutesSync(): void {
|
||||
routeRegistry?.unregister();
|
||||
IframeRouteManager.getInstance().clear();
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
menuStore.removeAllDynamicRoutes();
|
||||
menuStore.setMenuList([]);
|
||||
|
||||
resetRouteInitState();
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟重置路由相关状态(登出等场景避免与导航竞态)
|
||||
*/
|
||||
export function resetRouterState(delay: number): void {
|
||||
setTimeout(() => {
|
||||
resetDynamicRoutesSync();
|
||||
}, 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;
|
||||
}
|
||||
|
||||
/** 守卫内菜单路径权限校验(扁平菜单路径集合) */
|
||||
export class RoutePermissionValidator {
|
||||
static hasPermission(targetPath: string, menuList: AppRouteRecord[]): boolean {
|
||||
if (targetPath === "/") {
|
||||
return true;
|
||||
}
|
||||
return this.matchRoute(targetPath, menuList);
|
||||
}
|
||||
|
||||
static buildMenuPathSet(
|
||||
menuList: AppRouteRecord[],
|
||||
pathSet: Set<string> = new Set()
|
||||
): Set<string> {
|
||||
if (!Array.isArray(menuList) || menuList.length === 0) {
|
||||
return pathSet;
|
||||
}
|
||||
|
||||
for (const menuItem of menuList) {
|
||||
if (!menuItem.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const menuPath = menuItem.path.startsWith("/") ? menuItem.path : `/${menuItem.path}`;
|
||||
pathSet.add(menuPath);
|
||||
|
||||
if (menuItem.children?.length) {
|
||||
this.buildMenuPathSet(menuItem.children, pathSet);
|
||||
}
|
||||
}
|
||||
|
||||
return pathSet;
|
||||
}
|
||||
|
||||
static checkPathPrefix(targetPath: string, pathSet: Set<string>): boolean {
|
||||
for (const menuPath of pathSet) {
|
||||
if (targetPath.startsWith(`${menuPath}/`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static matchRoute(targetPath: string, routes: AppRouteRecord[]): boolean {
|
||||
if (!Array.isArray(routes) || routes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
if (!route.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routePath = route.path.startsWith("/") ? route.path : `/${route.path}`;
|
||||
|
||||
if (
|
||||
routePath === targetPath ||
|
||||
this.isDynamicRouteMatch(targetPath, routePath) ||
|
||||
targetPath.startsWith(`${routePath}/`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (route.children?.length && this.matchRoute(targetPath, route.children)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static isDynamicRouteMatch(targetPath: string, routePath: string): boolean {
|
||||
if (!routePath.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = routePath
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/:([^/]+)/g, "[^/]+")
|
||||
.replace(/\\\*/g, ".*");
|
||||
|
||||
return new RegExp(`^${pattern}$`).test(targetPath);
|
||||
}
|
||||
|
||||
static validatePath(
|
||||
targetPath: string,
|
||||
menuList: AppRouteRecord[],
|
||||
homePath: string = "/"
|
||||
): { path: string; hasPermission: boolean } {
|
||||
const hasPermission = this.hasPermission(targetPath, menuList);
|
||||
|
||||
if (hasPermission) {
|
||||
return { path: targetPath, hasPermission: true };
|
||||
}
|
||||
|
||||
return { path: homePath, hasPermission: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 动态路由一站式:菜单 → 组件解析 → 挂到 RootLayout 下注册。
|
||||
*(老 web 里分散在 permission / transform 的职责,这里收进一个文件,少跳类、少找文件。)
|
||||
*/
|
||||
import { defineComponent, h, onMounted, ref } from "vue";
|
||||
import type { RouteRecordRaw, Router } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import {
|
||||
IframeRouteManager,
|
||||
NestedRouterParent,
|
||||
ROOT_LAYOUT_ROUTE_NAME,
|
||||
ROUTE_COMPONENT_LAYOUT,
|
||||
ROUTE_COMPONENT_NESTED_PARENT,
|
||||
} from "./staticRoutes";
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class RouteValidator {
|
||||
private warnedRoutes = new Set<string>();
|
||||
|
||||
validate(routes: AppRouteRecord[]): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
this.checkDuplicates(routes, errors, warnings);
|
||||
this.checkComponents(routes, errors, warnings);
|
||||
this.checkNestedIndexComponent(routes);
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private checkDuplicates(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ""
|
||||
): void {
|
||||
const routeNameMap = new Map<string, string>();
|
||||
const componentPathMap = new Map<string, string>();
|
||||
|
||||
const checkRoutes = (items: AppRouteRecord[], pPath = "") => {
|
||||
items.forEach((route) => {
|
||||
const currentPath = route.path || "";
|
||||
const fullPath = this.resolvePath(pPath, currentPath);
|
||||
|
||||
if (route.name) {
|
||||
const routeName = String(route.name);
|
||||
if (routeNameMap.has(routeName)) {
|
||||
warnings.push(`路由名称重复: "${routeName}" (${fullPath})`);
|
||||
} else {
|
||||
routeNameMap.set(routeName, fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (route.component && typeof route.component === "string") {
|
||||
const componentPath = route.component;
|
||||
if (componentPath !== ROUTE_COMPONENT_LAYOUT) {
|
||||
const componentKey = `${pPath}:${componentPath}`;
|
||||
if (componentPathMap.has(componentKey)) {
|
||||
warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`);
|
||||
} else {
|
||||
componentPathMap.set(componentKey, fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
checkRoutes(route.children, fullPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkRoutes(routes, parentPath);
|
||||
}
|
||||
|
||||
private checkComponents(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ""
|
||||
): void {
|
||||
routes.forEach((route) => {
|
||||
const hasExternalLink = !!route.meta?.link?.trim();
|
||||
const hasChildren = Array.isArray(route.children) && route.children.length > 0;
|
||||
const routePath = route.path || "[未定义路径]";
|
||||
const isIframe = route.meta?.isIframe;
|
||||
|
||||
if (route.component) {
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || "");
|
||||
this.checkComponents(route.children, errors, warnings, fullPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentPath === "" && !hasExternalLink && !isIframe) {
|
||||
errors.push(`一级菜单(${routePath}) 缺少 component,必须指向 ${ROUTE_COMPONENT_LAYOUT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasExternalLink && !isIframe && !hasChildren) {
|
||||
errors.push(`路由(${routePath}) 缺少 component 配置`);
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || "");
|
||||
this.checkComponents(route.children, errors, warnings, fullPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void {
|
||||
routes.forEach((route) => {
|
||||
if (level > 1 && route.component === ROUTE_COMPONENT_LAYOUT) {
|
||||
this.logLayoutError(route, level);
|
||||
}
|
||||
if (route.children?.length) {
|
||||
this.checkNestedIndexComponent(route.children, level + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private logLayoutError(route: AppRouteRecord, level: number): void {
|
||||
const routeName = String(route.name || route.path || "未知路由");
|
||||
const routeKey = `${routeName}_${route.path}`;
|
||||
if (this.warnedRoutes.has(routeKey)) return;
|
||||
this.warnedRoutes.add(routeKey);
|
||||
|
||||
const menuTitle = route.meta?.title || routeName;
|
||||
const routePath = route.path || "/";
|
||||
|
||||
console.error(
|
||||
`[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` +
|
||||
` 问题: ${level}级菜单不能使用 ${ROUTE_COMPONENT_LAYOUT} 作为 component\n` +
|
||||
` 说明: 只有一级菜单才能使用 ${ROUTE_COMPONENT_LAYOUT},二级及以下菜单应该指向具体的组件路径\n` +
|
||||
` 当前配置: component: '${ROUTE_COMPONENT_LAYOUT}'\n` +
|
||||
` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)`
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePath(parent: string, child: string): string {
|
||||
return [parent.replace(/\/$/, ""), child.replace(/^\//, "")].filter(Boolean).join("/");
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- ComponentLoader
|
||||
export class ComponentLoader {
|
||||
private modules: Record<string, () => Promise<any>>;
|
||||
|
||||
constructor() {
|
||||
this.modules = import.meta.glob("../views/**/*.vue");
|
||||
}
|
||||
|
||||
load(componentPath: string): () => Promise<any> {
|
||||
if (!componentPath) {
|
||||
return this.createEmptyComponent();
|
||||
}
|
||||
if (componentPath === ROUTE_COMPONENT_LAYOUT || componentPath === "/layouts/index") {
|
||||
return this.loadLayout();
|
||||
}
|
||||
if (componentPath === ROUTE_COMPONENT_NESTED_PARENT) {
|
||||
return this.loadNestedParent();
|
||||
}
|
||||
|
||||
const normalized = componentPath.startsWith("/")
|
||||
? componentPath
|
||||
: `/${componentPath.replace(/^\/+/, "")}`;
|
||||
const fullPath = `../views${normalized}.vue`;
|
||||
const fullPathWithIndex = `../views${normalized}/index.vue`;
|
||||
const module = this.modules[fullPath] || this.modules[fullPathWithIndex];
|
||||
|
||||
if (!module) {
|
||||
console.error(
|
||||
`[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}`
|
||||
);
|
||||
return this.createErrorComponent(componentPath);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
loadLayout(): () => Promise<any> {
|
||||
return () => import("@/layouts/index.vue");
|
||||
}
|
||||
|
||||
loadIframe(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve(
|
||||
defineComponent({
|
||||
name: "IframeView",
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const isLoading = ref(true);
|
||||
const iframeUrl = ref("");
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path);
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = (iframeRoute.meta as any).link || "";
|
||||
}
|
||||
});
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
return () =>
|
||||
h("div", { class: "box-border w-full h-full", "v-loading": isLoading.value }, [
|
||||
h("iframe", {
|
||||
ref: iframeRef,
|
||||
src: iframeUrl.value,
|
||||
frameborder: "0",
|
||||
class: "w-full h-full min-h-[calc(100vh-120px)] border-none",
|
||||
onLoad: handleIframeLoad,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
loadNestedParent(): () => Promise<any> {
|
||||
return () => Promise.resolve(NestedRouterParent);
|
||||
}
|
||||
|
||||
private createEmptyComponent(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h("div", {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createErrorComponent(componentPath: string): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h("div", { class: "route-error" }, `组件未找到: ${componentPath}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- RouteTransformer
|
||||
interface ConvertedRoute extends Omit<RouteRecordRaw, "children"> {
|
||||
id?: number;
|
||||
children?: ConvertedRoute[];
|
||||
component?: RouteRecordRaw["component"] | (() => Promise<any>);
|
||||
}
|
||||
|
||||
export class RouteTransformer {
|
||||
private componentLoader: ComponentLoader;
|
||||
private iframeManager: IframeRouteManager;
|
||||
private readonly shellChild: boolean;
|
||||
|
||||
constructor(componentLoader: ComponentLoader, options?: { shellChild?: boolean }) {
|
||||
this.componentLoader = componentLoader;
|
||||
this.shellChild = options?.shellChild ?? false;
|
||||
this.iframeManager = IframeRouteManager.getInstance();
|
||||
}
|
||||
|
||||
transform(route: AppRouteRecord, depth = 0, parentAbsPath = ""): ConvertedRoute {
|
||||
const absPath = (route.path || "").trim();
|
||||
const pathOut = this.routerPath(depth, parentAbsPath, absPath);
|
||||
const { component, children, ...routeConfig } = route;
|
||||
|
||||
const converted: ConvertedRoute = { ...routeConfig, path: pathOut, component: undefined };
|
||||
|
||||
if (route.meta.isIframe) {
|
||||
this.handleIframeRoute(converted, route, depth);
|
||||
} else if (this.isFirstLevelLeaf(route, depth)) {
|
||||
this.handleFirstLevelLeaf(converted, route, component as string);
|
||||
} else {
|
||||
this.handleNormalRoute(converted, component as string, depth);
|
||||
}
|
||||
|
||||
converted.path = pathOut;
|
||||
|
||||
if (children?.length) {
|
||||
converted.children = children.map((c) => this.transform(c, depth + 1, absPath));
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
private routerPath(depth: number, parentAbsPath: string, absPath: string): string {
|
||||
const firstSeg = absPath.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!this.shellChild) {
|
||||
if (depth === 0) {
|
||||
return firstSeg ? `/${firstSeg}` : "/";
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
if (depth === 0) {
|
||||
return firstSeg;
|
||||
}
|
||||
if (!parentAbsPath || !absPath) return absPath;
|
||||
const p = parentAbsPath.replace(/\/$/, "");
|
||||
if (absPath.startsWith(`${p}/`)) return absPath.slice(p.length + 1);
|
||||
return absPath.split("/").filter(Boolean).pop() ?? absPath;
|
||||
}
|
||||
|
||||
private isFirstLevelLeaf(route: AppRouteRecord, depth: number): boolean {
|
||||
return depth === 0 && (!route.children || route.children.length === 0);
|
||||
}
|
||||
|
||||
private handleIframeRoute(
|
||||
targetRoute: ConvertedRoute,
|
||||
sourceRoute: AppRouteRecord,
|
||||
depth: number
|
||||
): void {
|
||||
if (depth === 0) {
|
||||
targetRoute.component = this.shellChild
|
||||
? this.componentLoader.loadNestedParent()
|
||||
: this.componentLoader.loadLayout();
|
||||
targetRoute.name = "";
|
||||
const leafPath = this.shellChild ? "" : sourceRoute.path || "";
|
||||
targetRoute.children = [
|
||||
{
|
||||
...sourceRoute,
|
||||
path: leafPath,
|
||||
component: this.componentLoader.loadIframe(),
|
||||
} as ConvertedRoute,
|
||||
];
|
||||
} else {
|
||||
targetRoute.component = this.componentLoader.loadIframe();
|
||||
}
|
||||
this.iframeManager.add(sourceRoute);
|
||||
}
|
||||
|
||||
private handleFirstLevelLeaf(
|
||||
converted: ConvertedRoute,
|
||||
route: AppRouteRecord,
|
||||
component: string | undefined
|
||||
): void {
|
||||
converted.component = this.shellChild
|
||||
? this.componentLoader.loadNestedParent()
|
||||
: this.componentLoader.loadLayout();
|
||||
converted.name = "";
|
||||
route.meta.isFirstLevel = true;
|
||||
const leafPath = this.shellChild ? "" : route.path || "";
|
||||
converted.children = [
|
||||
{
|
||||
...route,
|
||||
path: leafPath,
|
||||
component: component ? this.componentLoader.load(component) : undefined,
|
||||
} as ConvertedRoute,
|
||||
];
|
||||
}
|
||||
|
||||
private handleNormalRoute(
|
||||
converted: ConvertedRoute,
|
||||
component: string | undefined,
|
||||
depth: number
|
||||
): void {
|
||||
if (!component) return;
|
||||
if (this.shellChild && depth === 0 && component === ROUTE_COMPONENT_LAYOUT) {
|
||||
converted.component = this.componentLoader.loadNestedParent();
|
||||
return;
|
||||
}
|
||||
converted.component = this.componentLoader.load(component);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- RouteRegistry
|
||||
/** 与静态壳层冲突的一级 path 段,后端动态挂载时跳过以免覆盖首页/仪表盘等 */
|
||||
const RESERVED_SHELL_SEGMENTS = new Set(["home", "profile", "changelog", "dashboard"]);
|
||||
|
||||
function pathFirstSegment(path: string): string {
|
||||
return (
|
||||
path
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.split("/")
|
||||
.filter(Boolean)[0] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function registrationName(route: AppRouteRecord, index: number): AppRouteRecord {
|
||||
const explicit = typeof route.name === "string" ? route.name.trim() : "";
|
||||
if (explicit) return route;
|
||||
const seg = pathFirstSegment(route.path || "") || "route";
|
||||
const safe = seg.replace(/[^\w]/g, "_");
|
||||
return { ...route, name: `Dyn_${index}_${safe}` };
|
||||
}
|
||||
|
||||
function routeNameKey(route: AppRouteRecord): string {
|
||||
return typeof route.name === "string" ? route.name : String(route.name ?? "");
|
||||
}
|
||||
|
||||
export class RouteRegistry {
|
||||
private router: Router;
|
||||
private componentLoader: ComponentLoader;
|
||||
private validator: RouteValidator;
|
||||
private transformer: RouteTransformer;
|
||||
private removeRouteFns: (() => void)[] = [];
|
||||
private registered = false;
|
||||
|
||||
constructor(router: Router) {
|
||||
this.router = router;
|
||||
this.componentLoader = new ComponentLoader();
|
||||
this.validator = new RouteValidator();
|
||||
this.transformer = new RouteTransformer(this.componentLoader, { shellChild: true });
|
||||
}
|
||||
|
||||
register(menuList: AppRouteRecord[]): void {
|
||||
if (this.registered) {
|
||||
console.warn("[RouteRegistry] 路由已注册,跳过重复注册");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = this.validator.validate(menuList);
|
||||
if (!validationResult.valid) {
|
||||
throw new Error(`路由配置验证失败: ${validationResult.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
const removeRouteFns: (() => void)[] = [];
|
||||
|
||||
menuList.forEach((route, index) => {
|
||||
const seg = pathFirstSegment(route.path || "");
|
||||
if (seg && RESERVED_SHELL_SEGMENTS.has(seg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const namedRoute = registrationName(route, index);
|
||||
if (this.router.hasRoute(routeNameKey(namedRoute))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const routeConfig = this.transformer.transform(namedRoute);
|
||||
removeRouteFns.push(
|
||||
this.router.addRoute(ROOT_LAYOUT_ROUTE_NAME, routeConfig as RouteRecordRaw)
|
||||
);
|
||||
});
|
||||
|
||||
this.removeRouteFns = removeRouteFns;
|
||||
this.registered = true;
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
this.removeRouteFns.forEach((fn) => fn());
|
||||
this.removeRouteFns = [];
|
||||
this.registered = false;
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.registered;
|
||||
}
|
||||
|
||||
getRemoveRouteFns(): (() => void)[] {
|
||||
return this.removeRouteFns;
|
||||
}
|
||||
|
||||
markAsRegistered(): void {
|
||||
this.registered = true;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+24
-82
@@ -1,94 +1,36 @@
|
||||
import type { App } from "vue";
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
|
||||
export const Layout = () => import("@/layouts/index.vue");
|
||||
/**
|
||||
* 静态路由
|
||||
*/
|
||||
export const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/redirect",
|
||||
meta: { hidden: true },
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: "/redirect/:path(.*)",
|
||||
component: () => import("@/views/redirect/index.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
meta: { hidden: true },
|
||||
component: () => import("@/views/module_system/auth/index.vue"),
|
||||
},
|
||||
{
|
||||
path: "/401",
|
||||
name: "401",
|
||||
meta: { hidden: true, title: "401" },
|
||||
component: () => import("@/views/error/401.vue"),
|
||||
},
|
||||
{
|
||||
path: "/404",
|
||||
name: "404",
|
||||
meta: { hidden: true, title: "404" },
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
},
|
||||
{
|
||||
path: "/500",
|
||||
name: "500",
|
||||
meta: { hidden: true, title: "500" },
|
||||
component: () => import("@/views/error/500.vue"),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "/",
|
||||
redirect: "/home",
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: "home",
|
||||
component: () => import("@/views/dashboard/index.vue"),
|
||||
// 用于 keep-alive 功能,需要与 SFC 中自动推导或显式声明的组件名称一致
|
||||
// 参考文档: https://cn.vuejs.org/guide/built-ins/keep-alive.html#include-exclude
|
||||
name: "Home",
|
||||
meta: {
|
||||
title: "首页",
|
||||
icon: "homepage",
|
||||
affix: true,
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
name: "Profile",
|
||||
meta: { title: "个人中心", icon: "user", hidden: true },
|
||||
component: () => import("@/views/current/profile.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 通配 404 必须置于静态路由最后,否则会抢先匹配 /、/home 等
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
meta: { hidden: true, title: "404" },
|
||||
},
|
||||
];
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { HOME_ROUTE_NAME, ROOT_LAYOUT_ROUTE_NAME, staticRoutes } from "./staticRoutes";
|
||||
import { setupBeforeEachGuard } from "./beforeEach";
|
||||
import { setupAfterEachGuard } from "./afterEach";
|
||||
import "@utils/ui";
|
||||
|
||||
/**
|
||||
* 创建路由
|
||||
* 路由分层:`staticRoutes` 一次性注册(登录页、首页、仪表盘、异常页等);
|
||||
* 业务菜单来自后端或壳层合并后再由守卫动态 `addRoute`。
|
||||
*/
|
||||
const router = createRouter({
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: constantRoutes,
|
||||
routes: staticRoutes,
|
||||
// 刷新时,滚动条位置还原
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
// 全局注册 router
|
||||
// 为了捕获并处理全局错误,在注册路由时添加错误处理
|
||||
export function initRouter(app: App<Element>) {
|
||||
// 初始化路由
|
||||
export function initRouter(app: App<Element>): void {
|
||||
setupBeforeEachGuard(router); // 路由前置守卫
|
||||
setupAfterEachGuard(router); // 路由后置守卫
|
||||
app.use(router);
|
||||
}
|
||||
|
||||
export default router;
|
||||
// 主页路径;须与 `staticRoutes` 中首页子路由一致(当前为 `/home`)
|
||||
export const HOME_PAGE_PATH = "/home";
|
||||
|
||||
export { HOME_ROUTE_NAME, ROOT_LAYOUT_ROUTE_NAME };
|
||||
|
||||
/** 供 `@/router` 一处导入的运行时模块 */
|
||||
export { RouteRegistry, ComponentLoader, RouteTransformer, RouteValidator } from "./dynamicRoutes";
|
||||
export type { ValidationResult } from "./dynamicRoutes";
|
||||
export { IframeRouteManager } from "./staticRoutes";
|
||||
export { MenuProcessor, builtinFrontendRoutes } from "./MenuProcessor";
|
||||
export { RoutePermissionValidator } from "./beforeEach";
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import type { AppRouteRecordRaw } from "@utils/navigation";
|
||||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||||
import { defineComponent, h, onMounted, ref } from "vue";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
|
||||
/** 首页 / 仪表盘父级 meta(侧栏、静态子路由共用) */
|
||||
export const HOME_MENU_META: RouteMeta = {
|
||||
title: "menus.home.title",
|
||||
icon: "ri:presentation-line",
|
||||
keepAlive: true,
|
||||
};
|
||||
|
||||
export const DASHBOARD_PARENT_META: RouteMeta = {
|
||||
title: "menus.dashboard.title",
|
||||
icon: "ri:pie-chart-line",
|
||||
alwaysShow: true,
|
||||
};
|
||||
|
||||
/** iframe 路由注册表(与动态路由、守卫共用) */
|
||||
export class IframeRouteManager {
|
||||
private static instance: IframeRouteManager;
|
||||
private iframeRoutes: AppRouteRecord[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): IframeRouteManager {
|
||||
if (!IframeRouteManager.instance) {
|
||||
IframeRouteManager.instance = new IframeRouteManager();
|
||||
}
|
||||
return IframeRouteManager.instance;
|
||||
}
|
||||
|
||||
add(route: AppRouteRecord): void {
|
||||
if (!this.iframeRoutes.find((r) => r.path === route.path)) {
|
||||
this.iframeRoutes.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): AppRouteRecord[] {
|
||||
return this.iframeRoutes;
|
||||
}
|
||||
|
||||
findByPath(path: string): AppRouteRecord | undefined {
|
||||
return this.iframeRoutes.find((route) => route.path === path);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.iframeRoutes = [];
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.iframeRoutes.length > 0) {
|
||||
sessionStorage.setItem("iframeRoutes", JSON.stringify(this.iframeRoutes));
|
||||
}
|
||||
}
|
||||
|
||||
load(): void {
|
||||
try {
|
||||
const data = sessionStorage.getItem("iframeRoutes");
|
||||
if (data) {
|
||||
this.iframeRoutes = JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[IframeRouteManager] 加载 iframe 路由失败:", error);
|
||||
this.iframeRoutes = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 根 Layout 的 route.name;动态路由 `addRoute` 父级须与此一致 */
|
||||
export const ROOT_LAYOUT_ROUTE_NAME = "RootLayout" as const;
|
||||
|
||||
/** 静态首页子路由 name(面包屑等) */
|
||||
export const HOME_ROUTE_NAME = "Home" as const;
|
||||
|
||||
/** 目录占位:仅嵌一层 RouterView(与 ComponentLoader 中占位同源) */
|
||||
export const NestedRouterParent = defineComponent({
|
||||
name: "NestedRouterParent",
|
||||
setup() {
|
||||
return () => h(RouterView);
|
||||
},
|
||||
});
|
||||
|
||||
/** 后端菜单 / 动态路由里 `component` 占位(与 ComponentLoader 约定一致) */
|
||||
export const ROUTE_COMPONENT_LAYOUT = "/index/index";
|
||||
|
||||
/** 多级目录父级占位(`views/nested/router-view-parent`) */
|
||||
export const ROUTE_COMPONENT_NESTED_PARENT = "/nested/router-view-parent";
|
||||
|
||||
/** 登录页备用 path(与静态 `/login` 并存,守卫与白名单用) */
|
||||
export const ROUTE_PATH_LOGIN_ALT = "/auth/login";
|
||||
|
||||
/**
|
||||
* 主框架布局:新版 art 体系(`src/layouts/index.vue` + `src/layouts/art-*` 组件)。
|
||||
* 旧版 Left/Top/Mix 壳子仍在 `@/layouts/index.vue`,路由不再默认使用。
|
||||
*/
|
||||
export const Layout = () => import("@/layouts/index.vue");
|
||||
|
||||
/** iframe 内跳页面:内联组件(无需 views/outside/Iframe.vue) */
|
||||
const IframeView = defineComponent({
|
||||
name: "IframeView",
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const isLoading = ref(true);
|
||||
const iframeUrl = ref("");
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path);
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = (iframeRoute.meta as any).link || "";
|
||||
}
|
||||
});
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
return () =>
|
||||
h("div", { class: "box-border w-full h-full", "v-loading": isLoading.value }, [
|
||||
h("iframe", {
|
||||
ref: iframeRef,
|
||||
src: iframeUrl.value,
|
||||
frameborder: "0",
|
||||
class: "w-full h-full min-h-[calc(100vh-120px)] border-none",
|
||||
onLoad: handleIframeLoad,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* `/dashboard` 下静态子路由(唯一数据源)。
|
||||
* 下方壳层菜单合并函数由此剥离 `component` 生成侧栏补全菜单。
|
||||
*/
|
||||
export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
|
||||
{
|
||||
path: "workplace",
|
||||
name: "DashboardWorkplace",
|
||||
component: () => import("@views/dashboard/workplace/index.vue"),
|
||||
meta: {
|
||||
title: "menus.workplace.title",
|
||||
icon: "ri:layout-grid-line",
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "console",
|
||||
name: "DashboardConsole",
|
||||
component: () => import("@views/dashboard/console/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.console",
|
||||
icon: "ri:home-smile-2-line",
|
||||
keepAlive: false,
|
||||
fixedTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "analysis",
|
||||
name: "DashboardAnalysis",
|
||||
component: () => import("@views/dashboard/analysis/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.analysis",
|
||||
icon: "ri:align-item-bottom-line",
|
||||
keepAlive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "ecommerce",
|
||||
name: "DashboardEcommerce",
|
||||
component: () => import("@views/dashboard/ecommerce/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.ecommerce",
|
||||
icon: "ri:bar-chart-box-line",
|
||||
keepAlive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "map",
|
||||
name: "DashboardMap",
|
||||
component: () => import("@views/dashboard/map/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.map",
|
||||
icon: "ri:map-pin-line",
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "pricing",
|
||||
name: "DashboardPricing",
|
||||
component: () => import("@views/dashboard/pricing/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.pricing",
|
||||
icon: "ri:money-cny-box-line",
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "article",
|
||||
name: "DashboardArticle",
|
||||
component: NestedRouterParent,
|
||||
meta: {
|
||||
title: "menus.article.title",
|
||||
icon: "ri:book-2-line",
|
||||
alwaysShow: true,
|
||||
roles: ["R_SUPER", "R_ADMIN"],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "article-list",
|
||||
name: "ArticleList",
|
||||
component: () => import("@views/dashboard/article/list/index.vue"),
|
||||
meta: {
|
||||
title: "menus.article.articleList",
|
||||
icon: "ri:article-line",
|
||||
keepAlive: true,
|
||||
authList: [
|
||||
{ title: "新增", authMark: "add" },
|
||||
{ title: "编辑", authMark: "edit" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tutorial",
|
||||
name: "DashboardTutorial",
|
||||
component: () => import("@views/dashboard/tutorial/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.tutorial",
|
||||
icon: "ri:movie-2-line",
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 静态壳层菜单:后端未下发 /home、/dashboard 时补全侧栏;混合模式路由按 name 去重合并
|
||||
|
||||
export const mergeShellHomeMenu: AppRouteRecord = {
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
meta: { ...HOME_MENU_META, shellRoute: true },
|
||||
};
|
||||
|
||||
/** 去掉组件与 redirect,供侧栏合并(菜单节点不需要懒加载引用) */
|
||||
function stripRouteRecordForShell(route: RouteRecordRaw): AppRouteRecord {
|
||||
const children = route.children?.map(stripRouteRecordForShell);
|
||||
return {
|
||||
path: route.path,
|
||||
name: route.name,
|
||||
meta: (route.meta ?? {}) as AppRouteRecord["meta"],
|
||||
...(children?.length ? { children } : {}),
|
||||
} as AppRouteRecord;
|
||||
}
|
||||
|
||||
export function getDashboardMenuTreeForMerge(): AppRouteRecord {
|
||||
return {
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
meta: DASHBOARD_PARENT_META,
|
||||
children: dashboardLayoutChildren.map(stripRouteRecordForShell),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAppRouteRecords(
|
||||
primary: AppRouteRecord[],
|
||||
secondary: AppRouteRecord[]
|
||||
): AppRouteRecord[] {
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
const collectNames = (routes: AppRouteRecord[]) => {
|
||||
for (const r of routes) {
|
||||
if (r.name) usedNames.add(String(r.name));
|
||||
if (r.children?.length) collectNames(r.children);
|
||||
}
|
||||
};
|
||||
collectNames(primary);
|
||||
|
||||
const pickFresh = (routes: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
const out: AppRouteRecord[] = [];
|
||||
for (const r of routes) {
|
||||
const n = r.name ? String(r.name) : "";
|
||||
if (n && usedNames.has(n)) continue;
|
||||
const next: AppRouteRecord = { ...r };
|
||||
if (r.children?.length) {
|
||||
next.children = pickFresh(r.children);
|
||||
}
|
||||
if (n) usedNames.add(n);
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return [...primary, ...pickFresh(secondary)];
|
||||
}
|
||||
|
||||
function normalizeMenuPath(path?: string): string {
|
||||
if (!path || !path.trim()) return "";
|
||||
const p = path.trim();
|
||||
return p.startsWith("/") ? p : `/${p}`;
|
||||
}
|
||||
|
||||
function collectPathsAndNames(items: AppRouteRecord[], paths: Set<string>, names: Set<string>) {
|
||||
for (const r of items) {
|
||||
const np = normalizeMenuPath(r.path as string);
|
||||
if (np) paths.add(np);
|
||||
if (r.name) names.add(String(r.name));
|
||||
if (r.children?.length) collectPathsAndNames(r.children, paths, names);
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardRoutesToShellMenu(route: AppRouteRecord, parentAbs = ""): AppRouteRecord {
|
||||
const raw = route.path?.trim() ?? "";
|
||||
const fullPath =
|
||||
raw.startsWith("/") && raw !== "/"
|
||||
? raw
|
||||
: parentAbs
|
||||
? `${parentAbs.replace(/\/$/, "")}/${raw.replace(/^\/+/, "")}`
|
||||
: `/${raw.replace(/^\/+/, "")}`;
|
||||
const meta = { ...route.meta, shellRoute: true as const };
|
||||
const children = route.children?.map((c) => dashboardRoutesToShellMenu(c, fullPath));
|
||||
return {
|
||||
...route,
|
||||
path: fullPath,
|
||||
meta,
|
||||
children,
|
||||
component: undefined,
|
||||
redirect: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeShellRoutesIntoMenu(menuList: AppRouteRecord[]): AppRouteRecord[] {
|
||||
const paths = new Set<string>();
|
||||
const names = new Set<string>();
|
||||
collectPathsAndNames(menuList, paths, names);
|
||||
|
||||
const additions: AppRouteRecord[] = [];
|
||||
|
||||
const tryPush = (item: AppRouteRecord) => {
|
||||
const p = normalizeMenuPath(item.path as string);
|
||||
const n = item.name ? String(item.name) : "";
|
||||
if (p && !paths.has(p) && (!n || !names.has(n))) {
|
||||
additions.push(item);
|
||||
if (p) paths.add(p);
|
||||
if (n) names.add(n);
|
||||
if (item.children?.length) {
|
||||
collectPathsAndNames(item.children, paths, names);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tryPush(mergeShellHomeMenu);
|
||||
|
||||
if (!paths.has("/dashboard")) {
|
||||
tryPush(dashboardRoutesToShellMenu(structuredClone(getDashboardMenuTreeForMerge())));
|
||||
}
|
||||
|
||||
if (additions.length === 0) return menuList;
|
||||
return [...additions, ...menuList];
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态路由配置(不需要权限就能访问的路由)
|
||||
*
|
||||
* 属性说明:
|
||||
* isHideTab: true 表示不在标签页中显示
|
||||
*
|
||||
* 注意事项:
|
||||
* 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问
|
||||
* 2、静态路由不管是否登录都可以访问
|
||||
*/
|
||||
export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
{
|
||||
path: "/redirect",
|
||||
meta: { hidden: true },
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: "/redirect/:path(.*)",
|
||||
component: () => import("@views/redirect/index.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
meta: { hidden: true, title: "menus.login.title" },
|
||||
component: () => import("@views/module_system/auth/login/index.vue"),
|
||||
},
|
||||
/** 无 Layout 全屏异常页;守卫与白名单跳转使用(勿再在 RootLayout 下重复挂载同组件) */
|
||||
{
|
||||
path: "/401",
|
||||
name: "401",
|
||||
meta: { hidden: true, title: "401" },
|
||||
component: () => import("@views/exception/401/index.vue"),
|
||||
},
|
||||
{
|
||||
path: "/403",
|
||||
name: "403",
|
||||
component: () => import("@views/exception/403/index.vue"),
|
||||
meta: { hidden: true, title: "403" },
|
||||
},
|
||||
{
|
||||
path: "/404",
|
||||
name: "404",
|
||||
meta: { hidden: true, title: "404" },
|
||||
component: () => import("@views/exception/404/index.vue"),
|
||||
},
|
||||
{
|
||||
path: "/500",
|
||||
name: "500",
|
||||
meta: { hidden: true, title: "500" },
|
||||
component: () => import("@views/exception/500/index.vue"),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: ROOT_LAYOUT_ROUTE_NAME,
|
||||
redirect: "/home",
|
||||
component: Layout,
|
||||
children: [
|
||||
/** 首页(侧栏补入逻辑见同文件 `mergeShellRoutesIntoMenu`) */
|
||||
{
|
||||
path: "home",
|
||||
name: HOME_ROUTE_NAME,
|
||||
component: () => import("@views/dashboard/index.vue"),
|
||||
meta: HOME_MENU_META,
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
name: "Profile",
|
||||
meta: { title: "个人中心", icon: "ri:user-line", hidden: true },
|
||||
component: () => import("@views/current/profile.vue"),
|
||||
},
|
||||
/** 更新日志(mock 数据):侧栏隐藏,由顶栏头像下拉进入(routeName 供 fastEnter 等使用) */
|
||||
{
|
||||
path: "changelog",
|
||||
name: "ChangeLog",
|
||||
meta: {
|
||||
title: "menus.changelog.title",
|
||||
icon: "ri:draft-line",
|
||||
hidden: true,
|
||||
keepAlive: true,
|
||||
},
|
||||
component: () => import("@views/changelog/index.vue"),
|
||||
},
|
||||
/** 仪表盘子路由定义见同文件导出的 `dashboardLayoutChildren` */
|
||||
{
|
||||
path: "dashboard",
|
||||
name: "Dashboard",
|
||||
redirect: "/dashboard/workplace",
|
||||
component: NestedRouterParent,
|
||||
meta: DASHBOARD_PARENT_META,
|
||||
children: dashboardLayoutChildren,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/outside",
|
||||
component: () => import("@/layouts/index.vue"),
|
||||
name: "Outside",
|
||||
meta: { title: "menus.outside.title" },
|
||||
children: [
|
||||
{
|
||||
path: "/outside/iframe/:path",
|
||||
name: "Iframe",
|
||||
component: IframeView,
|
||||
meta: { title: "iframe" },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 通配 404 必须置于静态路由最后(name 勿与上方 `/404` 重复,否则按名跳转不稳定)
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "CatchAll404",
|
||||
component: () => import("@views/exception/404/index.vue"),
|
||||
meta: { hidden: true, title: "404" },
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user