Skip to content

路由与菜单

说明

前端路由基于 Vue Router 4 实现动态路由生成,菜单数据从后端获取,通过 generator-routers.ts 递归生成路由表,路由守卫控制登录状态和权限。

路由配置

路由配置位于 src/router/ 目录:

src/router/
├── index.ts             # 路由实例创建,导出 router、setupRouter
├── router-guards.ts     # 全局路由守卫(beforeEach / afterEach)
├── generator-routers.ts # 动态路由生成(后端菜单 → 路由表)
├── router-icons.ts      # 菜单图标映射
├── constant.ts          # Layout / ParentLayout 组件引用
└── base.ts              # 静态路由(ErrorPage、About、Setting)

静态路由

无需权限即可访问的路由定义在 base.ts 中,包括登录页、404 错误页等。

动态路由生成

登录后从后端获取菜单数据,由 generator-routers.tsrouterGenerator 递归生成路由表:

typescript
// src/router/generator-routers.ts

import { adminMenus } from '@/api/common/menu';
import { Layout, ParentLayout } from '@/router/constant';

const LayoutMap = new Map<string, () => Promise<typeof import('*.vue')>>();
LayoutMap.set('LAYOUT', Layout);
LayoutMap.set('IFRAME', () => import('@/views/iframe/index.vue'));

export const routerGenerator = (routerMap, parent?): any[] => {
    return routerMap.map((item) => {
        const names = item.target == 2 ? item.component : item.path.replaceAll('/', '');
        item.meta = {
            title: item.parentId == 0 && item.children.length == 0 ? '' : item.name,
            icon: item.icon,
            sort: item.sort,
            permissions: item.permission,
            hidden: item.hide ? true : false,
            isRoot: item.parentId == 0 && item.children.length == 0,
            frameSrc: item.target == 1 ? item.component : '',
        };

        let components = '';
        if (item.parentId == 0 && item.children.length == 0) {
            components = 'LAYOUT';
        } else if (item.target == 0) {
            components = item.component;
        } else if (item.target == 1) {
            components = 'IFRAME';
        }

        const currentRouter: any = {
            path: item.target != 2 ? item.path : '',
            name: names,
            component: components,
            meta: { ...item.meta, label: item.meta.title, icon: item.meta.icon || null },
        };

        if (item.children && item.children.length > 0) {
            !item.redirect && (currentRouter.redirect = `${item.children[0].path}`);
            currentRouter.children = routerGenerator(item.children, currentRouter);
        }

        return currentRouter;
    });
};

组件动态导入

asyncImportRoute 函数使用 import.meta.glob 扫描 views/ 目录下的所有 .vue.tsx 文件,根据后端返回的 component 字段(如 system/position/index)匹配并懒加载:

typescript
export const asyncImportRoute = (routes: AppRouteRecordRaw[]): void => {
    viewsModules = viewsModules || import.meta.glob('../views/**/*.{vue,tsx}');
    routes.forEach((item) => {
        const { component } = item;
        if (component) {
            const layoutFound = LayoutMap.get(component as string);
            if (layoutFound) {
                item.component = layoutFound;  // LAYOUT / IFRAME
            } else {
                item.component = dynamicImport(viewsModules, component as string);
            }
        }
        item.children && asyncImportRoute(item.children);
    });
};

export const dynamicImport = (viewsModules, component: string) => {
    const keys = Object.keys(viewsModules);
    const matchKeys = keys.filter((key) => {
        let k = key.replace('../views', '');
        k = k.substring(0, k.lastIndexOf('.'));
        return k === component;
    });
    if (matchKeys?.length === 1) return viewsModules[matchKeys[0]];
};

匹配规则:后端 component 字段值 system/position/index 会匹配 ../views/system/position/index.vue

路由守卫

router-guards.ts 中定义全局路由守卫,核心流程:

typescript
// src/router/router-guards.ts

export function createRouterGuards(router: Router) {
    const userStore = useUserStoreWidthOut();
    const asyncRouteStore = useAsyncRouteStoreWidthOut();

    router.beforeEach(async (to, from, next) => {
        // 支持 URL 参数中的 token(如 SSO 回调)
        if (to.query && 'token' in to.query) {
            storage.set(ACCESS_TOKEN, String(to.query.token));
            userStore.setToken(String(to.query.token));
        }
        NProgress.start();

        const token = storage.get(ACCESS_TOKEN);

        // 白名单(登录页)直接放行
        if (whitePathList.includes(to.path) && !token) {
            next(); return;
        }

        // 无 token 跳转登录页
        if (!token) {
            if (to.meta.ignoreAuth) { next(); return; }
            next({ path: LOGIN_PATH, replace: true, query: { redirect: to.path } });
            return;
        }

        // 已登录且路由已添加,直接放行
        if (asyncRouteStore.getIsDynamicAddedRoute) {
            next(); return;
        }

        // 首次登录:获取用户信息 → 生成动态路由 → 添加路由
        asyncRouteStore.setDynamicAddedRoute(true);
        const userInfo = await userStore.GetInfo();
        const routes = await asyncRouteStore.generateRoutes(userInfo);

        routes.forEach((item) => {
            router.addRoute(item as unknown as RouteRecordRaw);
        });

        // 添加 404 页面
        const isErrorPage = router.getRoutes().findIndex((item) => item.name === ErrorPageRoute.name);
        if (isErrorPage === -1) {
            router.addRoute(ErrorPageRoute as unknown as RouteRecordRaw);
        }

        const firstRoutePath = findFirstRoutePath(routes);
        storage.set(FIRST_ROUTE, firstRoutePath);

        // 跳转目标页或首个路由
        next({ path: redirect, replace: true });
    });

    // afterEach:设置页面标题、管理 keepAlive 组件缓存
    router.afterEach((to) => {
        document.title = to?.meta?.title || document.title;
        NProgress.done();
    });
}

守卫流程图:

访问页面
 ├── 有 token 查询参数?→ 存储 token
 ├── 白名单?→ 放行
 ├── 无 token?→ 跳转登录页(携带 redirect 参数)
 ├── 路由已添加?→ 放行
 └── 首次登录 → GetInfo → generateRoutes → addRoute → 跳转

动态路由生成(asyncRoute Store)

asyncRouteStoregenerateRoutes 方法负责从后端获取菜单并生成路由:

typescript
// src/store/modules/asyncRoute.ts

async generateRoutes(data) {
    let accessedRouters;
    const permissionsList = data.permissions || [];
    const { getPermissionMode } = useProjectSetting();
    const permissionMode = unref(getPermissionMode);

    if (permissionMode === 'BACK') {
        // 后端控制模式:调用 adminMenus() 获取菜单
        accessedRouters = await generatorDynamicRouter();
    } else {
        // 前端控制模式:根据权限过滤静态路由表
        accessedRouters = filter(asyncRoutes, routeFilter);
    }

    this.setRouters(accessedRouters);
    this.setMenus(accessedRouters);
    return toRaw(accessedRouters);
},

使用 BACK 模式(后端控制菜单),调用 adminMenus() 接口获取菜单数据。

菜单数据结构

后端返回的菜单数据结构:

json
[
    {
        "id": 1,
        "name": "系统管理",
        "path": "/system",
        "component": "",
        "icon": "icon-system",
        "target": 0,
        "parentId": 0,
        "sort": 1,
        "hide": false,
        "permission": "",
        "children": [
            {
                "id": 10,
                "name": "岗位管理",
                "path": "/system/position",
                "component": "system/position/index",
                "icon": "icon-position",
                "target": 0,
                "parentId": 1,
                "sort": 3,
                "hide": false,
                "permission": "sys:position:page",
                "children": []
            }
        ]
    }
]
字段说明
name菜单名称,同时作为 meta.title
path路由地址,如 /system/position
component组件路径,如 system/position/index,对应 views/system/position/index.vue
target打开方式:0=内部组件,1=内嵌 iframe,2=外部链接
parentId父菜单 ID,0 表示顶级
permission权限节点,如 sys:position:page
hide是否隐藏菜单
icon菜单图标

菜单渲染

菜单数据与路由数据同源,侧边栏组件递归渲染菜单树。meta 中的关键字段控制渲染行为:

  • hidden: true:隐藏该菜单项
  • isRoot: true:顶级单页菜单(无子菜单的顶级项自动包裹 Layout)
  • frameSrc:iframe 内嵌地址
  • permissions:权限字符串,用于菜单级别的权限过滤

面包屑导航

根据当前路由的 matched 自动生成面包屑,读取每级路由的 meta.title

KeepAlive 缓存

路由守卫的 afterEach 中自动管理组件缓存:

typescript
router.afterEach((to) => {
    const keepAliveComponents = asyncRouteStore.keepAliveComponents;
    const currentComName = to.matched.find((item) => item.name == to.name)?.name;

    if (currentComName && !keepAliveComponents.includes(currentComName) && to.meta?.keepAlive) {
        keepAliveComponents.push(currentComName);  // 需要缓存
    } else if (!to.meta?.keepAlive) {
        const index = keepAliveComponents.findIndex((name) => name == currentComName);
        if (index != -1) keepAliveComponents.splice(index, 1);  // 移除缓存
    }
});

温馨提示

菜单的 component 字段对应 src/views/ 下的组件路径,如 system/position/index 对应 src/views/system/position/index.vueimport.meta.glob('../views/**/*.{vue,tsx}') 会在构建时扫描所有视图文件,实现按需懒加载。

总结

前端路由采用动态生成方式,登录后通过 adminMenus() 接口获取后端菜单数据,routerGenerator 递归生成路由表,asyncImportRoute 使用 import.meta.glob 懒加载组件。路由守卫控制登录状态和首次路由添加,asyncRouteStore 管理路由状态和组件缓存。

小蚂蚁云团队 · 提供技术支持