Vue3 - 实现动态获取菜单路由和按钮权限控制指令

GitHub Demo 地址

在线预览

前言

关于动态获取路由已在这里给出方案 Vue - vue-admin-template模板项目改造:动态获取菜单路由

这里是在此基础上升级成vue3ts,数据和网络请求是通过mock实现的
具体代码请看demo!!!
本地权限控制,具体是通过查询用户信息获取用户角色,在路由守卫中通过角色过滤本地配置的路由,把符合角色权限的路由生成一个路由数组
动态获取菜单路由其实思路是一样的,只不过路由数组变成从服务器获取,通过查询某个角色的菜单列表,然后在路由守卫中把获取到的菜单数组转成路由数组

动态路由实现是参考vue-element-admin的issues写的,相关issues:
vue-element-admin/issues/167
vue-element-admin/issues/293
vue-element-admin/issues/3326#issuecomment-832852647

关键点

主要在接口菜单列表中把父componentLayout 改为字符串 'Layout',
children的component: () => import('@/views/system/user/index.vue'), 改成 字符串'system/user/index',然后在获取到数据后再转回来
!!!!!!!!!!!! 接口格式可以根据项目需要自定义,不一定非得按照这里的来

vue3 中component使用和vue略有差异,需要加上完整路径,并且从字符串换成组件的方式也有不同
!!!!!!!!!注意文件路径

ts 复制代码
import { defineAsyncComponent } from 'vue'
const modules = import.meta.glob('../../views/**/**.vue')

// 加载路由
const loadView = (view: string) => {
  // 路由懒加载
  // return defineAsyncComponent(() => import(`/src/views/${view}.vue`))
  return modules[`../../views/${view}.vue`]
}

调用

ts 复制代码
loadView(route.component)

本地路由格式:

ts 复制代码
import { AppRouteType } from '@/router/types'

const Layout = () => import('@/layout/index.vue')

const systemRouter: AppRouteType = {
  path: '/system',
  name: 'system',
  component: Layout,
  meta: { title: 'SystemSetting', icon: 'ep:setting', roles: ['admin'] },
  children: [
    {
      path: 'user',
      name: 'user',
      component: () => import('@/views/system/user/index.vue'),
      meta: {
        title: 'SystemUser',
        icon: 'user',
        buttons: ['user-add', 'user-edit', 'user-look', 'user-export', 'user-delete', 'user-assign', 'user-resetPwd']
      }
    },
    {
      path: 'role',
      name: 'role',
      component: () => import('@/views/system/role/index.vue'),
      meta: {
        title: 'SystemRole',
        icon: 'role',
        buttons: ['role-add', 'role-edit', 'role-look', 'role-delete', 'role-setting']
      }
    },
    {
      path: 'menu',
      name: 'menu',
      component: () => import('@/views/system/menu/index.vue'),
      meta: {
        title: 'SystemMenu',
        icon: 'menu',
        buttons: ['menu-add', 'menu-edit', 'menu-look', 'menu-delete']
      }
    },
    {
      path: 'dict',
      name: 'dict',
      component: () => import('@/views/system/dict/index.vue'),
      meta: {
        title: 'SystemDict',
        icon: 'dict',
        buttons: ['dict-type-add', 'dict-type-edit', 'dict-type-delete', 'dict-item-add', 'dict-item-edit', 'dict-item-delete']
      }
    }
  ]
}
export default systemRouter

ts路由类型定义

ts 复制代码
import type { RouteRecordRaw, RouteMeta, RouteRecordRedirectOption } from 'vue-router'

export type Component<T = any> = ReturnType<typeof defineComponent> | (() => Promise<typeof import('*.vue')>) | (() => Promise<T>)

// element-plus图标
// https://icon-sets.iconify.design/ep/
// 其他的
// https://icon-sets.iconify.design/
// 动态图标
// https://icon-sets.iconify.design/line-md/
// https://icon-sets.iconify.design/svg-spinners/

export interface AppRouteMetaType extends RouteMeta {
  title?: string
  icon?: string // 设置svg图标和通过iconify使用的element-plus图标,根据 : 判断是否是iconify图标
  hidden?: boolean
  affix?: boolean
  keepAlive?: boolean
  roles?: string[]
  buttons?: string[]
}

export interface AppRouteType extends Omit<RouteRecordRaw, 'props'> {
  path: string
  name?: string
  component?: Component | string
  components?: Component
  children?: AppRouteType[]
  fullPath?: string
  meta?: AppRouteMetaType
  redirect?: string
  alias?: string | string[]
}

// 动态路由类型
export interface AppDynamicRouteType extends AppRouteType {
  id: string
  code: string
  title: string
  parentId: string
  parentTitle: string
  menuType: string
  component: string | Component
  icon: string
  sort: number
  hidden: boolean
  level: number
  children?: AppDynamicRouteType[]
  buttons?: string[]
}

接口路由格式:

ts 复制代码
{
    id: '22',
    code: '/system',
    title: '系统设置',
    parentId: '',
    parentTitle: '',
    menuType: 'catalog', // catalog | menu | button
    component: 'Layout', // "Layout" | "system/menu" (文件路径: src/views/) | ""
    // component: Layout,
    icon: 'ep:setting',
    sort: 1,
    hidden: false,
    level: 1,
    children: [
      {
        id: '22-1',
        code: 'user',
        title: '用户管理',
        parentId: '22',
        parentTitle: '系统设置',
        menuType: 'menu',
        component: 'system/user/index',
        // component: () => import('@/views/system/user'),
        icon: 'user',
        sort: 2,
        hidden: false,
        level: 2,
        children: [],
        buttons: ['user-add', 'user-edit', 'user-look', 'user-export', 'user-delete', 'user-assign', 'user-resetPwd']
      },
      {
        id: '22-2',
        code: 'role',
        title: '角色管理',
        parentId: '22',
        parentTitle: '系统设置',
        menuType: 'menu',
        component: 'system/role/index',
        icon: 'role',
        sort: 3,
        hidden: false,
        level: 2,
        children: [],
        buttons: ['role-add', 'role-edit', 'role-look', 'role-delete', 'role-setting']
      },
      {
        id: '22-3',
        code: 'menu',
        title: '菜单管理',
        parentId: '22',
        parentTitle: '系统设置',
        menuType: 'menu',
        component: 'system/menu/index',
        icon: 'menu',
        sort: 4,
        hidden: false,
        level: 2,
        children: [],
        buttons: ['menu-add', 'menu-edit', 'menu-look', 'menu-delete']
      },
      {
        id: '22-4',
        code: 'dict',
        title: '字典管理',
        parentId: '22',
        parentTitle: '系统设置',
        menuType: 'menu',
        component: 'system/dict/index',
        icon: 'dict',
        sort: 5,
        hidden: false,
        level: 2,
        children: [],
        buttons: ['dict-type-add', 'dict-type-edit', 'dict-type-delete', 'dict-item-add', 'dict-item-edit', 'dict-item-delete']
      }
    ]
  }

我这里在mock中加了个角色editor2,当editor2登录使用的从服务器获取动态路由,其他角色从本地获取路由

permission.ts 实现,其中filterAsyncRoutes2方法就是格式化菜单路由的方法

ts 复制代码
import { defineAsyncComponent } from 'vue'
import { cloneDeep } from 'lodash-es'
import { defineStore } from 'pinia'
import { store } from '@/store'
import { asyncRoutes, constantRoutes } from '@/router'

import { AppRouteType, AppDynamicRouteType } from '@/router/types'

const modules = import.meta.glob('../../views/**/**.vue')
const Layout = () => import('@/layout/index.vue')

/**
 * Use meta.role to determine if the current user has permission
 * @param roles
 * @param route
 */
const hasPermission = (roles: string[], route: AppRouteType) => {
  if (route.meta && route.meta.roles) {
    return roles.some((role) => {
      if (route.meta?.roles !== undefined) {
        return (route.meta.roles as string[]).includes(role)
      }
    })
  }
  return true
}

/**
 * Filter asynchronous routing tables by recursion
 * @param routes asyncRoutes
 * @param roles
 */
const filterAsyncRoutes = (routes: AppRouteType[], roles: string[]) => {
  const res: AppRouteType[] = []

  routes.forEach((route) => {
    const tmp = cloneDeep(route)
    // const tmp = { ...route }
    if (hasPermission(roles, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      res.push(tmp)
    }
  })

  return res
}

// 加载路由
const loadView = (view: string) => {
  // 路由懒加载
  // return defineAsyncComponent(() => import(`/src/views/${view}.vue`))
  return modules[`../../views/${view}.vue`]
}

/**
 * 通过递归格式化菜单路由 (配置项规则:https://panjiachen.github.io/vue-element-admin-site/zh/guide/essentials/router-and-nav.html#配置项)
 * @param routes
 */
export function filterAsyncRoutes2(routes: AppDynamicRouteType[]) {
  const res: AppDynamicRouteType[] = []
  routes.forEach((route) => {
    const tmp = cloneDeep(route)
    // const tmp = { ...route }
    tmp.id = route.id
    tmp.path = route.code
    tmp.name = route.code
    tmp.meta = { title: route.title, icon: route.icon, buttons: route.buttons }
    if (route.component === 'Layout') {
      tmp.component = Layout
    } else if (route.component) {
      tmp.component = loadView(route.component)
    }
    if (route.children && route.children.length > 0) {
      tmp.children = filterAsyncRoutes2(route.children)
    }
    res.push(tmp)
  })
  return res
}

// setup
export const usePermissionStore = defineStore('permission', () => {
  // state
  const routes = ref<AppRouteType[]>([])

  // actions
  function setRoutes(newRoutes: AppRouteType[]) {
    routes.value = constantRoutes.concat(newRoutes)
  }

  function generateRoutes(roles: string[]) {
    return new Promise<AppRouteType[]>((resolve, reject) => {
      let accessedRoutes: AppRouteType[] = []
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || []
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      }
      setRoutes(accessedRoutes)
      resolve(accessedRoutes)
    })
  }

  function generateDynamicRoutes(menus: AppDynamicRouteType[]) {
    return new Promise<AppRouteType[]>((resolve, reject) => {
      const accessedRoutes = filterAsyncRoutes2(menus)
      setRoutes(accessedRoutes) // Todo: 内部拼接constantRoutes,所以查出来的菜单不用包含constantRoutes
      resolve(accessedRoutes)
    })
  }

  return { routes, setRoutes, generateRoutes, generateDynamicRoutes }
})

// 非setup
export function usePermissionStoreHook() {
  return usePermissionStore(store)
}

按钮权限控制

directive文件夹,创建permission.ts指令设置路由内的按钮权限

ts 复制代码
import { useUserStoreHook } from '@/store/modules/user'
import { Directive, DirectiveBinding } from 'vue'
import router from '@/router/index'

/**
 * 按钮权限 eg: v-hasPerm="['user-add','user-edit']"
 */
export const hasPerm: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    // 「超级管理员」拥有所有的按钮权限
    const { roles, perms } = useUserStoreHook()
    if (roles.includes('admin')) {
      return true
    }

    // 「其他角色」按钮权限校验
    const buttons = router.currentRoute.value.meta.buttons as string[]
    const { value } = binding
    if (value) {
      const requiredPerms = value // DOM绑定需要的按钮权限标识
      const hasPerm = buttons?.some((perm) => {
        return requiredPerms.includes(perm)
      })

      if (!hasPerm) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    } else {
      throw new Error("need perms! Like v-has-perm=\"['user-add','user-edit']\"")
    }
  }
}

创建index.ts文件,全局注册 directive

ts 复制代码
import type { App } from 'vue'

import { hasPerm } from './permission'

// 全局注册 directive
export function setupDirective(app: App<Element>) {
  // 使 v-hasPerm 在所有组件中都可用
  app.directive('hasPerm', hasPerm)
}

在main.ts注册自定义指令

ts 复制代码
import { setupDirective } from '@/directive'

const app = createApp(App)
// 全局注册 自定义指令(directive)
setupDirective(app)

使用

ts 复制代码
<el-button v-hasPerm="['user-item-add']"> 新增 </el-button>
相关推荐
辻戋2 小时前
从零实现React Scheduler调度器
前端·react.js·前端框架
徐同保2 小时前
使用yarn@4.6.0装包,项目是react+vite搭建的,项目无法启动,报错:
前端·react.js·前端框架
Qrun3 小时前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp3 小时前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.4 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl6 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫7 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友7 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理9 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻9 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js