路由和菜单

Uniboot 的模板中,系统基于 Vue Router 4 提供了一套精简的路由机制,主要用于根据路由的权限数据自动生成对应的侧边栏菜单结构

本文将根据 Simple 模板项目的实际源码(src/router/),为您详细介绍系统路由划分、静态/动态路由处理、页面局部刷新以及标签页管理器的工作原理。


路由系统机制与目录划分

项目的路由管理全部集中在 src/router/ 目录下,其核心文件职责划分如下:

  • src/router/routes.ts:用于定义系统的静态核心路由(免登录、免鉴权路由,如登录、404、403等)以及主布局节点 INDEX_ROUTELAYOUT
  • 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.tsmerchantMenu.ts),是动态路由与侧边栏菜单生成的数据源。
  • src/types/menu.d.ts:定义菜单配置项的 MenuConfig 类型。

静态路由与路由守卫配置

静态路由 (constantRoutes)

静态路由指不需要跟随后端权限过滤即可直接访问的核心路由。它们定义在 src/router/routes.tsconstantRoutes 数组中:

typescript
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_URLREDIRECT_URI

路由初始化守卫 (initGuard)

src/router/guard/init.ts 中注册的守卫,会在每次路由发生改变时前置触发,读取 Pinia 的 appStore.config 状态,并动态改写 HTML 表头中的 <link rel="icon"> 节点指向。

typescript
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 接口:

typescript
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 中的片段为例:

typescript
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 只需填写相对路径片段(如 dashboarduser-roleuser),转换阶段会结合父级路径自动拼接为绝对路径(如 /user-role/user);仅当 path 是外链地址时(isExternal() 判定)才会被原样使用。

若业务存在多角色场景,可参照 merchantMenu.ts 新增独立的菜单配置文件,再按需在 useUserStore().getUserInfo() 中根据当前登录身份(例如接口返回的角色标识)选用对应的菜单集合。模板默认仅接入了 adminMenu

typescript
// 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

typescript
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 字段转换后对应字段说明
pathpath第一层节点会自动补齐前导 /
title / iconmeta.title / meta.icon侧边栏与标签页展示用
hiddenmeta.hidden是否在侧边栏隐藏
keepAlivemeta.keepAlive是否开启组件缓存
permsmeta.perms权限过滤依据
paramsmeta.query默认携带的 Query 参数
selectedmeta.activeMenu子页面激活时高亮的父级菜单路径
hideLayoutBreadcrumbmeta.hideLayoutBreadcrumb是否隐藏面包屑,仅当值为 true 时才写入 meta
componentcomponent仅叶子菜单(MenuEnum.MENU)生效,交由 loadRouteView 解析
childrenchildren仅目录菜单(MenuEnum.CATALOGUE)生效

路由 name 统一使用 Symbol(route.path) 生成,避免手写菜单时因重名产生路由冲突;但这也意味着无法通过固定的字符串路由名跳转,业务代码应使用 path,或使用下文的 getRoutePath(perms) 按权限反查路径。


3. 动态权限过滤 (filterAsyncRoutes)

菜单配置数组经 filterAsyncRoutes(routes, perms) 递归处理,在生成路由记录的同时完成权限过滤。

  • 权限验证机制:通过 hasPermission(perms, route) 比对用户的权限字符数组中是否包含 *(全局管理员通配符)或者包含 route.meta.perms(该路由所需要的具体权限标识)。若没有权限,对应的节点将不会生成,避免了非法越权访问。
typescript
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 语法扫描全部前端业务组件:
    typescript
    const modules = import.meta.glob('/src/views/**/*.vue')
  • 视图动态装配createRouteRecord 对叶子菜单调用 loadRouteView(route.component) 动态解析。该方法根据菜单配置中的组件路径字符串,匹配 modules 中的路径键,并返回其对应的异步组件加载函数。
typescript
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 将其递归拼接为绝对路径并逐个扁平化注册:

typescript
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 使用:

typescript
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 属性控制了该路由在多标签页和侧边栏菜单中的外观与控制行为。核心支持的字段包括:

属性名类型说明来源
titlestring设置该路由在侧边栏和标签页展示的名称(通常包含多语言键)MenuConfig.title
iconstring设置侧边栏菜单项展示的图标名MenuConfig.icon
activeMenustring子页面或关联页面激活时,需要高亮侧边栏对应的父级菜单路径MenuConfig.selected
querystring设定访问该路由时,默认强制携带的 URL Query 参数串MenuConfig.params
hiddenboolean设为 true 后,该路由项将不会在侧边栏菜单中呈现MenuConfig.hidden
keepAliveboolean是否开启页面 Keep-Alive 组件缓存MenuConfig.keepAlive
permsstring该路由所需的权限标识,由 hasPermission 与当前用户的权限数组逐一比对(命中或用户拥有 * 即放行)MenuConfig.perms
hideLayoutBreadcrumbboolean是否在页面的布局头部隐藏全局面包屑导航栏MenuConfig.hideLayoutBreadcrumb
hideTabboolean设为 true 后,该页面将不会在多标签 Tab 栏中展现需在路由对象上直接设置,MenuConfig 暂未提供该字段
proLayoutHideMenuAtPathstring特殊配置,在访问该路径时主布局自动缩起或隐藏侧边菜单栏需在路由对象上直接设置,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 触发页面组件的重建,从而实现干净的局部刷新。
typescript
// 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">,从而实现细粒度的页面状态缓存控制。