路由和菜单
在 Uniboot 的模板中,系统基于 Vue Router 4 提供了一套精简的路由机制,主要用于根据路由的权限数据自动生成对应的侧边栏菜单结构。
本文将根据 Simple 模板项目的实际源码(src/router/),为您详细介绍系统路由划分、静态/动态路由处理、页面局部刷新以及标签页管理器的工作原理。
路由系统机制与目录划分
项目的路由管理全部集中在 src/router/ 目录下,其核心文件职责划分如下:
src/router/routes.ts:用于定义系统的静态核心路由(免登录、免鉴权路由,如登录、404、403等)以及主布局节点INDEX_ROUTE、LAYOUT。src/router/index.ts:包含路由实例的创建、菜单配置到路由记录的转换(createRouteRecord)、动态路由数据的权限过滤(filterAsyncRoutes)、路由组件的动态懒加载(loadRouteView)、首个有效路由查找(findFirstValidRoute)以及路由重置(resetRouter)等逻辑。src/router/guard/:存放路由全局守卫模块。guard/index.ts:利用import.meta.glob自动读取本目录下所有default导出的守卫方法并进行注册。guard/init.ts:每次路由跳转前触发,从全局 App 配置中读取web_favicon并自动设置当前页面的 Favicon 属性。
src/permission.ts:全局权限守卫的核心实现,负责登录态校验、动态拉取用户权限与菜单、递归注册动态路由(addRoutesRecursively)以及重定向到首个有效页面,完整流程可参考「权限」章节。src/config/menu/:以声明式结构维护各角色的静态菜单配置(如adminMenu.ts、merchantMenu.ts),是动态路由与侧边栏菜单生成的数据源。src/types/menu.d.ts:定义菜单配置项的MenuConfig类型。
静态路由与路由守卫配置
静态路由 (constantRoutes)
静态路由指不需要跟随后端权限过滤即可直接访问的核心路由。它们定义在 src/router/routes.ts 的 constantRoutes 数组中:
export const LAYOUT = () => Promise.resolve(Layout)
export const INDEX_ROUTE_NAME = Symbol('index-route')
/** 忘记密码 / 初始改密 / 个人中心由 `setupUnibootAdmin` 在 install 阶段注入,见 `install/plugins/uniboot-admin.ts` */
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/error/404.vue'),
},
{
path: PageEnum.ERROR_403,
component: () => import('@/views/error/403.vue'),
},
{
path: PageEnum.LOGIN,
component: LoginOAuthPlaceholder,
},
]
export const INDEX_ROUTE: RouteRecordRaw = {
path: PageEnum.INDEX,
component: LAYOUT,
name: INDEX_ROUTE_NAME,
}LAYOUT:主布局组件(src/layout/default/index.vue)的懒加载封装,登录后所有动态菜单路由都会挂载在其children之下。INDEX_ROUTE/INDEX_ROUTE_NAME:主布局的根路由记录及其唯一路由名。登录成功后由src/permission.ts动态调用router.addRoute(INDEX_ROUTE)挂载,其redirect会被设置为第一个有效的菜单路由(见下文「查找首个有效路由」);退出登录或重新登录前,会调用resetRouter()将其连同全部动态路由一并移除。- 忘记密码(
PageEnum.FORGET)、初始改密(PageEnum.MODIFY_INIT_PWD)、个人中心(PageEnum.PROFILE)等账户相关路由不在本文件中硬编码,而是由@uniboot/admin包提供的setupUnibootAdmin()在src/install/plugins/uniboot-admin.ts中于应用启动阶段统一注册,业务侧只需通过paths选项传入对应路径常量即可,无需关心其内部路由结构。 - 登录页:auth 模板固定 OAuth,
/login仅注册空占位组件(满足 Vue Router 要求);实际由权限守卫跳转 auth-service,须配置AUTH_SERVICE_URL与REDIRECT_URI。
路由初始化守卫 (initGuard)
在 src/router/guard/init.ts 中注册的守卫,会在每次路由发生改变时前置触发,读取 Pinia 的 appStore.config 状态,并动态改写 HTML 表头中的 <link rel="icon"> 节点指向。
export default function createInitGuard(router: Router) {
router.beforeEach(() => {
const appStore = useAppStore()
const data = appStore.config
let favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!favicon) {
favicon = document.createElement('link')
favicon.rel = 'icon'
document.head.appendChild(favicon)
}
favicon.href = data.web_favicon
})
}菜单配置与动态路由生成
动态路由用于在用户成功登录后,根据其角色和权限数据动态生成系统菜单与可访问的页面路由。整体链路为:
菜单配置(MenuConfig[]) → createRouteRecord 转换为路由记录 → filterAsyncRoutes 按权限递归过滤 → addRoutesRecursively 扁平化挂载到路由实例。
1. 声明式菜单配置 (src/config/menu)
不同于直接消费后端下发的、已经是 RouteRecordRaw 形状的数据,模板项目采用本地声明式菜单配置描述系统菜单,配置文件位于 src/config/menu/ 目录下,每份文件对应一套角色/端的菜单方案,例如:
adminMenu.ts:平台管理端菜单,包含目录、多级子菜单等完整示例。merchantMenu.ts:商户端菜单,字段结构与adminMenu.ts完全一致,仅菜单项不同。
每一项菜单配置需遵循 src/types/menu.d.ts 中定义的 MenuConfig 接口:
interface MenuConfig {
/** 菜单名称 */
title: string
/** 菜单图标 */
icon?: string
/** 是否缓存 */
keepAlive?: boolean
/** 是否隐藏 */
hidden?: boolean
/** 组件路径 */
component?: string
/** 实际跳转的地址 */
path?: string
/** 权限 */
perms?: string
/** 选中菜单的子菜单 */
selected?: string
/** 菜单参数 */
params?: string
/** 子菜单 */
children?: MenuConfig[]
/** 为 true 时主布局不展示面包屑(如工作台首页) */
hideLayoutBreadcrumb?: boolean
}菜单项分为两种形态:
- 叶子菜单(不含
children):必须提供component,其值对应src/views/下去除.vue后缀的组件相对路径,运行时由loadRouteView动态解析加载。 - 目录菜单(含
children):一般无需component,用于组织多级子菜单。
以 adminMenu.ts 中的片段为例:
export const adminMenu: Array<MenuConfig> = [
{
component: 'dashboard/index',
icon: 'u-icon-MenuDashboard',
keepAlive: true,
hidden: false,
hideLayoutBreadcrumb: true,
title: t('menu.dashboard'),
path: 'dashboard',
perms: '',
},
{
icon: 'u-icon-MenuUser',
keepAlive: true,
hidden: false,
title: '用户与角色',
path: 'user-role',
perms: 'menu_1',
selected: 'user-role/user', // 子页面激活时,高亮该目录节点
children: [
{
component: 'user-role/user/index',
title: '用户',
path: 'user',
perms: 'menu_1',
},
{
component: 'user-role/role/index',
title: '角色',
path: 'role',
perms: 'menu_1',
},
],
},
]TIP
path 只需填写相对路径片段(如 dashboard、user-role、user),转换阶段会结合父级路径自动拼接为绝对路径(如 /user-role/user);仅当 path 是外链地址时(isExternal() 判定)才会被原样使用。
若业务存在多角色场景,可参照 merchantMenu.ts 新增独立的菜单配置文件,再按需在 useUserStore().getUserInfo() 中根据当前登录身份(例如接口返回的角色标识)选用对应的菜单集合。模板默认仅接入了 adminMenu:
// src/stores/modules/user.ts
async getUserInfo() {
// ...
const menuRoutes = adminMenu
this.routes = filterAsyncRoutes(menuRoutes, this.perms)
}2. 菜单配置转换为路由记录 (createRouteRecord)
src/router/index.ts 中的 createRouteRecord(route, firstRoute) 负责把一条 MenuConfig 转换为 Vue Router 的 RouteRecordRaw:
export function createRouteRecord(
route: any,
firstRoute: boolean
): RouteRecordRaw {
const routeRecord: RouteRecordRaw = {
path: isExternal(route.path)
? route.path
: firstRoute
? `/${route.path}`
: route.path,
name: Symbol(route.path),
meta: {
hidden: route.hidden,
keepAlive: route.keepAlive,
title: route.title,
perms: route.perms,
query: route.params,
icon: route.icon,
activeMenu: route.selected,
...(route.hideLayoutBreadcrumb === true
? { hideLayoutBreadcrumb: true as const }
: {}),
},
}
const type = route.children ? MenuEnum.CATALOGUE : MenuEnum.MENU
switch (type) {
case MenuEnum.CATALOGUE:
routeRecord.children = route.children
break
case MenuEnum.MENU:
routeRecord.component = loadRouteView(route.component)
break
}
return routeRecord
}字段映射关系如下:
MenuConfig 字段 | 转换后对应字段 | 说明 |
|---|---|---|
path | path | 第一层节点会自动补齐前导 / |
title / icon | meta.title / meta.icon | 侧边栏与标签页展示用 |
hidden | meta.hidden | 是否在侧边栏隐藏 |
keepAlive | meta.keepAlive | 是否开启组件缓存 |
perms | meta.perms | 权限过滤依据 |
params | meta.query | 默认携带的 Query 参数 |
selected | meta.activeMenu | 子页面激活时高亮的父级菜单路径 |
hideLayoutBreadcrumb | meta.hideLayoutBreadcrumb | 是否隐藏面包屑,仅当值为 true 时才写入 meta |
component | component | 仅叶子菜单(MenuEnum.MENU)生效,交由 loadRouteView 解析 |
children | children | 仅目录菜单(MenuEnum.CATALOGUE)生效 |
路由 name 统一使用 Symbol(route.path) 生成,避免手写菜单时因重名产生路由冲突;但这也意味着无法通过固定的字符串路由名跳转,业务代码应使用 path,或使用下文的 getRoutePath(perms) 按权限反查路径。
3. 动态权限过滤 (filterAsyncRoutes)
菜单配置数组经 filterAsyncRoutes(routes, perms) 递归处理,在生成路由记录的同时完成权限过滤。
- 权限验证机制:通过
hasPermission(perms, route)比对用户的权限字符数组中是否包含*(全局管理员通配符)或者包含route.meta.perms(该路由所需要的具体权限标识)。若没有权限,对应的节点将不会生成,避免了非法越权访问。
export function hasPermission(perms: any[], route: any) {
if (route.meta && route.meta.perms) {
return perms.some((key) => {
return key === '*' || route.meta.perms.includes(key)
})
} else {
return true
}
}
export function filterAsyncRoutes(
routes: any[],
perms: any[],
firstRoute = true
) {
const res: RouteRecordRaw[] = []
routes.forEach((route) => {
const routeRecord = createRouteRecord(route, firstRoute)
if (hasPermission(perms, routeRecord)) {
if (routeRecord.children?.length) {
routeRecord.children = filterAsyncRoutes(
routeRecord.children,
perms,
false
)
}
res.push(routeRecord)
}
})
return res
}4. 动态组件按需异步加载 (loadRouteView)
模板没有写死每一个业务组件的对应关系,而是采用动态 Glob 匹配来实现灵活的组件加载。
- 视图库全局扫描: 在
src/router/index.ts中,使用 Glob 语法扫描全部前端业务组件:typescriptconst modules = import.meta.glob('/src/views/**/*.vue') - 视图动态装配:
createRouteRecord对叶子菜单调用loadRouteView(route.component)动态解析。该方法根据菜单配置中的组件路径字符串,匹配modules中的路径键,并返回其对应的异步组件加载函数。
export function loadRouteView(component: string) {
try {
const key = Object.keys(modules).find((key) => {
return key.includes(`/${component}.vue`)
})
if (key) {
return modules[key]
}
throw Error(`找不到组件${component},请确保组件路径正确`)
} catch (error) {
console.error(error)
return RouterView
}
}5. 扁平化递归挂载 (addRoutesRecursively)
filterAsyncRoutes 产出的仍然是一棵带有层级关系的路由树。为了修复 Vue Router 中三级及以上嵌套子路由在使用 keep-alive 时缓存失效的问题,src/permission.ts 在拿到过滤后的路由后,会调用 addRoutesRecursively 将其递归拼接为绝对路径并逐个扁平化注册:
const addRoutesRecursively = (routes: any, parentPath = '') => {
routes.forEach((route: any) => {
if (isExternal(route.path)) return
const fullPath = parentPath + route.path
const routerEntry = {
...route,
path: fullPath,
name: route.name || fullPath.replace(/\//g, '_').replace('_', ''),
}
if (!route.children) {
router.addRoute(INDEX_ROUTE_NAME, routerEntry)
} else {
router.addRoute(routerEntry)
}
if (route.children?.length) {
addRoutesRecursively(route.children, `${fullPath}/`)
}
})
}- 没有
children的叶子路由会被挂载为INDEX_ROUTE_NAME(主布局)的直接子路由,无论其在菜单树中处于第几层,从而保证<keep-alive>能够正确匹配到组件层级。 - 拥有
children的目录路由则以顶层路由的形式独立注册,用于承载分组信息(不会被实际渲染为页面)。
6. 查找首个有效路由 (findFirstValidRoute)
登录成功、动态路由挂载完毕后,系统需要一个默认落地页。findFirstValidRoute 会深度优先遍历过滤后的路由树,跳过隐藏(meta.hidden)与外链节点,返回第一个可用的叶子路由名称,供 INDEX_ROUTE.redirect 使用:
export function findFirstValidRoute(
routes: RouteRecordRaw[]
): string | undefined {
for (const route of routes) {
const type = route.children ? MenuEnum.CATALOGUE : MenuEnum.MENU
if (
type === MenuEnum.MENU &&
!route.meta?.hidden &&
!isExternal(route.path)
) {
return route.name as string
}
if (route.children) {
const name = findFirstValidRoute(route.children)
if (name) return name
}
}
}TIP
以上权限过滤、动态挂载与守卫拦截共同构成了完整的登录鉴权流程,src/permission.ts 中的整体控制流(含白名单、Token 校验、403 兜底等)请参考「权限」章节。
路由 Meta 常用配置项定义
在路由定义中,meta 属性控制了该路由在多标签页和侧边栏菜单中的外观与控制行为。核心支持的字段包括:
| 属性名 | 类型 | 说明 | 来源 |
|---|---|---|---|
title | string | 设置该路由在侧边栏和标签页展示的名称(通常包含多语言键) | MenuConfig.title |
icon | string | 设置侧边栏菜单项展示的图标名 | MenuConfig.icon |
activeMenu | string | 子页面或关联页面激活时,需要高亮侧边栏对应的父级菜单路径 | MenuConfig.selected |
query | string | 设定访问该路由时,默认强制携带的 URL Query 参数串 | MenuConfig.params |
hidden | boolean | 设为 true 后,该路由项将不会在侧边栏菜单中呈现 | MenuConfig.hidden |
keepAlive | boolean | 是否开启页面 Keep-Alive 组件缓存 | MenuConfig.keepAlive |
perms | string | 该路由所需的权限标识,由 hasPermission 与当前用户的权限数组逐一比对(命中或用户拥有 * 即放行) | MenuConfig.perms |
hideLayoutBreadcrumb | boolean | 是否在页面的布局头部隐藏全局面包屑导航栏 | MenuConfig.hideLayoutBreadcrumb |
hideTab | boolean | 设为 true 后,该页面将不会在多标签 Tab 栏中展现 | 需在路由对象上直接设置,MenuConfig 暂未提供该字段 |
proLayoutHideMenuAtPath | string | 特殊配置,在访问该路径时主布局自动缩起或隐藏侧边菜单栏 | 需在路由对象上直接设置,MenuConfig 暂未提供该字段 |
前 7 项均由
createRouteRecord根据菜单配置自动生成;后两项目前只能在手写的静态路由(如constantRoutes或业务自定义路由)中直接设置meta字段。
页面视图刷新机制
在很多业务场景下(如在详情页保存后),我们需要对当前页面视图进行局部刷新。在 Simple 模板中,并没有使用多余的三方钩子,而是采用一套利用 Pinia 与 Vue 底层特性的局部重载机制。
- 状态管理: 在
src/stores/modules/app.ts中定义了isRouteShow(默认true),以及refreshView方法。 - 渲染控制: 在主布局的容器视图
src/layout/default/components/main.vue中,通过v-if对路由视图实施了条件加载:html<router-view v-if="isRouteShow" v-slot="{ Component, route }"></router-view> - 刷新过程: 当调用
appStore.refreshView()时,系统首先将isRouteShow标记置为false使当前的组件实例卸载;紧接着在nextTick(待 DOM 完成本次渲染刷新)中再恢复为true触发页面组件的重建,从而实现干净的局部刷新。
// src/stores/modules/app.ts 中的 Actions
refreshView() {
this.isRouteShow = false;
nextTick(() => {
this.isRouteShow = true;
});
}多标签页 (Tabs) 控制与缓存
多标签页的管理采用 Pinia 的 useTabsStore() (定义在 src/stores/modules/multipleTabs.ts) 进行控制。
- 唯一性标识: 所有 Tab 页的开启和查询完全基于
fullPath作为全局唯一键。由于fullPath包含了 URL 中的 Query 参数,这意味当同一个路由路径传入了不同的查询参数(例如/product/detail?id=1和/product/detail?id=2)时,系统默认会作为两个不同的多标签页分别打开。 - 排除规则: 在添加标签页时,系统会执行
isCannotAddRoute检查。若路由没有合法的name、外链或者是设置了meta.hideTab: true,则不被添加到tabList中。 - 组件级 Keep-Alive 缓存: 如果路由的
meta.keepAlive被设为true,且对应视图定义了组件name,那么在标签页激活时,其对应的组件名会被收集到cacheTabList中,并传递给 Layout 层级的<keep-alive :include="cacheTabList">,从而实现细粒度的页面状态缓存控制。