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>
相关推荐
如若12335 分钟前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~1 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语1 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport1 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg1 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww2 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
m0_748254882 小时前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui
星就前端叭2 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
m0_748234522 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
Web阿成2 小时前
3.学习webpack配置 尝试打包ts文件
前端·学习·webpack·typescript