Skip to content

路由与菜单

前端菜单生成过程:

  • 通过 appRoute 计算属性,得到带有路由信息的路由树。
  • 使用上一步获取的路由信息进行权限过滤,生成用于渲染的 菜单树
  • 通过 渲染 菜单树,递归生成菜单。

服务端菜单生成过程:

  • 在 Store 中增加 api 请求的 action,用于获取服务端的路由配置。
  • 发起请求,将服务端的路由配置结果存储在 Store 中。
  • 通过 appRoute 计算属性,得到带有路由信息的路由树。
  • 使用上一步获取的路由信息进行权限过滤,生成用于渲染的 菜单树
  • 通过 渲染 菜单树,递归生成菜单。

说明

服务端菜单相对于本地菜单生成过程,仅仅是多了接口请求以及服务端路由配置信息存储的步骤。

个别公司可能会有相应的权限管理系统,以生成相应的服务端路由配置信息,并进行储存,以供前端进行接口调取。但总体大同小异,只要后端接口返回的路由配置信息,符合上述路由配置规范,并能被前端正确解析即可

首先,需要先了解一下路由表的配置。基本的路由配置请参阅 Vue-Router 官方文档

ts
import { DEFAULT_LAYOUT } from '@/layout'

// 在本例子中,页面最终路径为 /dashboard/workplace
export default {
  path: 'dashboard',
  name: 'dashboard', // 路由名称
  component: DEFAULT_LAYOUT,
  redirect: '/dashboard/console',
  meta: {
    locale: 'menu.dashboard',
    requiresAuth: true,
    icon: 'icon-dashboard',
  },
  children: [
    {
      path: 'workplace',
      name: 'workplace',
      component: () => import('@/views/dashboard/workplace/index.vue'),
      meta: {
        locale: 'menu.dashboard.workplace',
        requiresAuth: true,
        roles: ['admin'],
        hideInMenu: false,
      },
    },
  ],
}

静态路由

静态路由也可以称之为基础路由,是一个完全脱离菜单的路由,可以用于一些不需要菜单的页面,比如登录页、404页面等。

router/static-routes.ts中来配置我们的静态路由。

ts
import type { RouteRecordRaw } from 'vue-router'

const Layout = () => import('~/layouts/index.vue')

export default [
  {
    path: '/login',
    component: () => import('~/pages/common/login.vue'),
    meta: {
      title: '登录',
    },
  },
  {
    path: '/401',
    name: 'Error401',
    component: () => import('~/pages/exception/401.vue'),
    meta: {
      title: '授权已过期',
    },
  },
  {
    path: '/common',
    name: 'LayoutBasicRedirect',
    component: Layout,
    redirect: '/common/redirect',
    children: [
      {
        path: '/common/redirect',
        component: () => import('~/pages/common/route-view.vue'),
        name: 'CommonRedirect',
        redirect: '/redirect',
        children: [
          {
            path: '/redirect/:path(.*)',
            name: 'RedirectPath',
            component: () => import('~/pages/common/redirect.vue'),
          },
        ],
      },

    ],
  },
  {
    path: '/:pathMatch(.*)',
    meta: {
      title: '找不到页面',
    },
    component: () => import('~/pages/exception/error.vue'),
  },
] as RouteRecordRaw[]

路由菜单

菜单的生成完全依赖于路由文件router/dynamic-routes.ts,会自动基于这个文件下的路系统动态的去生成所需要的菜单结构。 在 src/router/modules 内的 .ts 文件会被视为一个路由模块。

所有与菜单相关的参数全部罗列到meta属性下并为菜单扩展了如下的一些参数:

ts
import 'vue-router'

declare module 'vue-router'{
  interface RouteMeta {
    title?: string
    icon?: string
    hideInMenu?: boolean
    parentKeys?: string[]
    isIframe?: boolean
    url?: string
    hideInBreadcrumb?: boolean
    hideChildrenInMenu?: boolean
    keepAlive?: boolean
    target?: '_blank' | '_self' | '_parent'
    affix?: boolean
    id?: string | number
    parentId?: string | number | null
    access?: string[]
    locale?: string
  }
}

路由通常都和菜单绑定在一起,为了减少维护的量,我们直接通过路由表生成了菜单。

ts
// 菜单配置转换成menu需要的数据结构
function formatMenu(route: RouteRecordRaw, path?: string) {
  return {
    id: route.meta?.id,
    parentId: route.meta?.parentId,
    title: () => renderTitle(route),
    icon: route.meta?.icon || '',
    path: path ?? route.path,
    hideInMenu: route.meta?.hideInMenu || false,
    parentKeys: route.meta?.parentKeys || [],
    hideInBreadcrumb: route.meta?.hideInBreadcrumb || false,
    hideChildrenInMenu: route.meta?.hideChildrenInMenu || false,
    locale: route.meta?.locale,
    keepAlive: route.meta?.keepAlive || false,
    name: route.name as string,
    url: route.meta?.url || '',
    target: route.meta?.target || '_blank',
  }
}

// 本地静态路由生成菜单的信息
export function genRoutes(routes: RouteRecordRaw[], parent?: MenuDataItem) {
  const menuData: MenuData = []
  const { hasAccess } = useAccess()

  routes.forEach((route) => {
    // 权鉴
    if (route.meta?.access) {
      const isAccess = hasAccess(route.meta?.access)
      if (!isAccess)
        return
    }

    // 路径处理
    let path = route.path
    if (!path.startsWith('/') && !isUrl(path)) {
      // 判断当前是不是以 /开头,如果不是就表示当前的路由地址为不完全的地址
      if (parent)
        path = `${parent.path}/${path}`
      else
        path = `/${path}`
    }
    // 判断是不是存在name,如果不存在name的情况下,自动补充一个自定义的name
    // 为了更容易的去实现保活的功能,name是必须的
    if (!route.name)
      route.name = getCacheKey()

    // 菜单数据处理
    const item: MenuDataItem = formatMenu(route, path)
    item.children = []
    if (route.children && route.children.length)
      item.children = genRoutes(route.children, item)
    if (item.children?.length === 0)
      delete item.children
    menuData.push(item)
  })

  return menuData
}
ts
function travel(_routes: RouteRecordRaw[], layer: number) {
  if (!_routes)
    return null

  const collector: any = _routes.map((element) => {
    // no access
    if (!permission.accessRouter(element))
      return null

    // leaf node
    if (element.meta?.hideChildrenInMenu || !element.children) {
      element.children = []
      return element
    }

    // route filter hideInMenu true
    element.children = element.children.filter(
      x => x.meta?.hideInMenu !== true,
    )

    // Associated child node
    const subItem = travel(element.children, layer + 1)

    if (subItem.length) {
      element.children = subItem
      return element
    }
    // the else logic
    if (layer > 1) {
      element.children = subItem
      return element
    }

    if (element.meta?.hideInMenu === false)
      return element

    return null
  })
  return collector.filter(Boolean)
}

后端菜单

在大多数情况下,我们的菜单都是由后端来提供的,所以我们需要将后端的菜单数据转换成我们需要的路由结构。

后端菜单的数据结构如下:

ts
interface MenuDataItem {
  // 唯一id
  id?: string | number
  // 标题
  title: string | (() => VNodeChild)
  // 图标
  icon?: string | (() => VNodeChild)
  // 地址
  path: string
  // 绑定的哪个组件,默认自带的组件类型分别是:Iframe、RouteView和ComponentError
  component?: string
  // 子集菜单
  children?: MenuDataItem[]
  // 重定向地址
  redirect?: string
  // 哪些是固定页签
  affix?: boolean
  // 父级菜单的id
  parentId?: string | number | null
  // 同路由中的name,主要是用于保活的左右
  name?: string
  // 是否隐藏当前菜单
  hideInMenu?: boolean
  // 如果使用了隐藏,那么点击当前菜单的时候,可以使用父级的key
  parentKeys?: string[]
  // 如果当前是iframe的模式,需要有一个跳转的url支撑,其不能和path重复,path还是为路由
  url?: string
  // 是否存在面包屑
  hideInBreadcrumb?: boolean
  // 是否需要显示所有的子菜单
  hideChildrenInMenu?: boolean
  // 是否保活
  keepAlive?: boolean
  // 这里包含所有的父级元素
  matched?: MenuDataItem[]
  // 全连接跳转模式
  target?: '_blank' | '_self' | '_parent'
}

生成路由中的component的方法是通过,读取pages文件夹下的所有vue文件来实现的:

ts
const routerModules = import.meta.glob([
  '~/pages/**/*.vue',
  '!~/pages/**/*copy.vue',
  '!~/pages/**/component',
  '!~/pages/**/components',
  '!~/pages/**/composables',
  '!~/pages/**/hooks',
  '!~/pages/**/modules',
  '!~/pages/**/plugins',
  '!~/pages/**/tests',
  '!~/pages/**/test',
  '!~/pages/**/locales',
  '!~/pages/common',
  '!~/pages/exception',
])

带有!的表示排除的文件,这些文件都是一些公共的文件,不需要作为路由来生成,如果你还有其他的文件夹需要排除,可以在router/router-modules.ts中进行配置。

配置规则

在后台添加一个页面的的时候,可以以pages作为根目录,比如pages/system/user/index.vue,那么在后台添加的时候,只需要给component属性一个system/user即可。

如果当前页面是一个父级页面的话,那么可以在后台添加的时候,component需要填写一个RouteView,父级页面需要有一个children属性,用于存放子页面,并且需要配置一个redirect作为重定向的地址。

如果当前是一个iframe页面的话,我们需要配置一个url属性,这个属性是一个全连接的地址,其中component需要配置为IFrame,比如https://www.baidu.com

如果当前的页面是一个外链的话,我们不需要填写component的值或者你可以填写一个RouteView,然后在path属性中配置一个全连接的地址,比如https://www.baidu.com

保活功能

版本开始,保活功能不依赖路由的name名称。

新增一个菜单项的步骤

了解完路由和菜单的生成,就可以上手配置一个新的菜单项了,以新增一个监控页面为例。

  • 在 views/dashboard 中新增一个 monitor 文件夹,并在其中新增 index.vue
vue
<script lang="ts" setup></script>
  • 在路由表中新增监控页的路由配置
ts
export default {
  path: 'dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/index.vue'),
  meta: {
    locale: 'menu.dashboard',
    requiresAuth: true,
    icon: 'icon-dashboard',
  },
  children: [
    {
      path: 'workplace',
      name: 'workplace',
      component: () => import('@/views/dashboard/workplace/index.vue'),
      meta: {
        locale: 'menu.dashboard.workplace',
        requiresAuth: true,
      },
    },
    {
      path: 'monitor',
      name: 'monitor',
      component: () => import('@/views/dashboard/monitor/index.vue'),
      meta: {
        locale: 'menu.dashboard.monitor',
        requiresAuth: true,
        roles: ['admin'],
      },
    },
  ],
};
  • 在语言包中新增菜单名

以下是中文语言包,其他语言包不赘述。

ts
// src/locale/zh-CN.ts
export default {
  'menu.dashboard': '仪表盘',
  'menu.dashboard.workplace': '工作台',
  'menu.dashboard.monitor': '实时监控',
};

以上,就完成了一个菜单项的配置。现在刷新一下页面,就能看到新的菜单项。

安装 vue-router

sh
pnpm add vue-router

创建/src/router/index.ts文件

ts
import { createRouter, createWebHistory } from 'vue-router'

export const constantRoutes = [
  {
    path: '/',
    component: () => import('@/views/home.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
})

export default router

app.ts 中使用 router

ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'

const app = createApp(App)

app.use(router).mount('#app')

修改/src/App.Vue

vue
<script>
export default {
  name: 'App',
}
</script>

<template>
  <div id="app">
    <router-view />
  </div>
</template>

Reference