Vue 菜单权限管理的计与实现

设计理念

1. 权限模型设计

  • RBAC (基于角色的访问控制):用户-角色-权限三级模型

  • 前端路由与菜单分离:路由负责页面跳转,菜单负责导航展示

  • 数据驱动:权限数据决定菜单渲染

  • 分层控制:页面级、模块级、操作级权限控制

2. 核心原则

  • 安全性:前端权限验证是用户体验优化,核心验证应在后端

  • 可维护性:权限配置集中管理,易于修改和扩展

  • 用户体验:无权限内容对用户完全隐藏

  • 性能:按需加载权限相关资源

完整实现示例

1. 项目结构

text

src/
├── components/
│   ├── Layout/
│   │   ├── AppLayout.vue
│   │   └── Sidebar.vue
│   └── common/
├── router/
│   ├── index.js
│   └── routes.js
├── store/
│   ├── index.js
│   ├── modules/
│   │   └── user.js
│   └── types.js
├── utils/
│   └── permission.js
├── api/
│   └── user.js
└── views/
    ├── dashboard/
    ├── user/
    ├── settings/
    └── 404.vue

2. 权限数据模型

javascript

// store/types.js
export const USER_SET_ROLES = 'USER_SET_ROLES'
export const USER_SET_PERMISSIONS = 'USER_SET_PERMISSIONS'
export const USER_SET_MENUS = 'USER_SET_MENUS'

// 权限类型枚举
export const PERMISSION_TYPES = {
  MENU: 'menu',
  BUTTON: 'button',
  API: 'api'
}

3. 路由配置

javascript

// router/routes.js
// 静态路由 - 所有用户都可访问
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/Login.vue'),
    hidden: true
  },
  {
    path: '/404',
    component: () => import('@/views/404.vue'),
    hidden: true
  }
]

// 动态路由 - 根据权限动态添加
export const asyncRoutes = [
  {
    path: '/dashboard',
    component: () => import('@/views/dashboard/Index.vue'),
    name: 'Dashboard',
    meta: {
      title: '仪表盘',
      icon: 'dashboard',
      permissions: ['dashboard:view']
    }
  },
  {
    path: '/user',
    component: () => import('@/views/user/Index.vue'),
    redirect: '/user/list',
    name: 'User',
    meta: {
      title: '用户管理',
      icon: 'user',
      permissions: ['user:view']
    },
    children: [
      {
        path: 'list',
        component: () => import('@/views/user/List.vue'),
        name: 'UserList',
        meta: {
          title: '用户列表',
          permissions: ['user:list']
        }
      },
      {
        path: 'create',
        component: () => import('@/views/user/Create.vue'),
        name: 'UserCreate',
        meta: {
          title: '新增用户',
          permissions: ['user:create']
        }
      },
      {
        path: 'edit/:id',
        component: () => import('@/views/user/Edit.vue'),
        name: 'UserEdit',
        meta: {
          title: '编辑用户',
          permissions: ['user:edit'],
          hidden: true // 不在菜单显示
        }
      }
    ]
  },
  {
    path: '/system',
    component: () => import('@/views/system/Index.vue'),
    redirect: '/system/role',
    name: 'System',
    meta: {
      title: '系统管理',
      icon: 'system',
      permissions: ['system:view']
    },
    children: [
      {
        path: 'role',
        component: () => import('@/views/system/Role.vue'),
        name: 'Role',
        meta: {
          title: '角色管理',
          permissions: ['role:view']
        }
      },
      {
        path: 'permission',
        component: () => import('@/views/system/Permission.vue'),
        name: 'Permission',
        meta: {
          title: '权限管理',
          permissions: ['permission:view']
        }
      }
    ]
  },
  // 404页面必须放在最后
  { path: '*', redirect: '/404', hidden: true }
]

4. Vuex状态管理

javascript

// store/modules/user.js
import { constantRoutes, asyncRoutes } from '@/router/routes'
import { USER_SET_ROLES, USER_SET_PERMISSIONS, USER_SET_MENUS } from '../types'

const state = {
  roles: [],
  permissions: [],
  menus: [],
  routes: constantRoutes // 初始只有静态路由
}

const mutations = {
  [USER_SET_ROLES](state, roles) {
    state.roles = roles
  },
  [USER_SET_PERMISSIONS](state, permissions) {
    state.permissions = permissions
  },
  [USER_SET_MENUS](state, menus) {
    state.menus = menus
  },
  SET_ROUTES(state, routes) {
    state.routes = constantRoutes.concat(routes)
  }
}

const actions = {
  // 生成用户权限路由
  generateRoutes({ commit, state }, permissions) {
    return new Promise(resolve => {
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions)
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  },
  
  // 获取用户信息
  async getUserInfo({ commit, dispatch }) {
    try {
      // 模拟API调用
      const userInfo = await getUserInfoAPI()
      const { roles, permissions } = userInfo
      
      commit(USER_SET_ROLES, roles)
      commit(USER_SET_PERMISSIONS, permissions)
      
      // 根据权限生成路由
      const accessedRoutes = await dispatch('generateRoutes', permissions)
      
      // 生成菜单(过滤掉hidden为true的路由)
      const menus = generateMenus(accessedRoutes)
      commit(USER_SET_MENUS, menus)
      
      return {
        roles,
        permissions,
        menus
      }
    } catch (error) {
      console.error('获取用户信息失败:', error)
      throw error
    }
  }
}

// 工具函数:根据权限过滤路由
function filterAsyncRoutes(routes, permissions) {
  const res = []
  
  routes.forEach(route => {
    const tmp = { ...route }
    
    // 检查路由权限
    if (hasPermission(permissions, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, permissions)
        // 如果过滤后子路由不为空,才保留父路由
        if (tmp.children.length > 0) {
          res.push(tmp)
        }
      } else {
        res.push(tmp)
      }
    }
  })
  
  return res
}

// 检查是否有权限
function hasPermission(permissions, route) {
  if (route.meta && route.meta.permissions) {
    return permissions.some(permission => 
      route.meta.permissions.includes(permission)
    )
  }
  return true // 没有设置权限要求的路由默认允许访问
}

// 生成菜单
function generateMenus(routes) {
  const menus = []
  
  routes.forEach(route => {
    // 跳过隐藏的路由
    if (route.hidden) return
    
    const menu = {
      path: route.path,
      name: route.name,
      meta: { ...route.meta },
      children: []
    }
    
    if (route.children && route.children.length > 0) {
      menu.children = generateMenus(route.children)
      // 如果子菜单为空,则不显示父菜单
      if (menu.children.length === 0) return
    }
    
    menus.push(menu)
  })
  
  return menus
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

5. 权限工具函数

javascript

// utils/permission.js
import store from '@/store'

/**
 * 检查是否有权限
 * @param {Array} needPermissions 需要的权限
 * @returns {Boolean}
 */
export function hasPermission(needPermissions) {
  if (!needPermissions || needPermissions.length === 0) {
    return true
  }
  
  const userPermissions = store.getters.permissions
  return userPermissions.some(permission => 
    needPermissions.includes(permission)
  )
}

/**
 * 检查是否有角色
 * @param {Array} needRoles 需要的角色
 * @returns {Boolean}
 */
export function hasRole(needRoles) {
  if (!needRoles || needRoles.length === 0) {
    return true
  }
  
  const userRoles = store.getters.roles
  return userRoles.some(role => needRoles.includes(role))
}

// 权限指令
export const permissionDirective = {
  inserted(el, binding) {
    const { value } = binding
    const permissions = store.getters.permissions
    
    if (value && value instanceof Array && value.length > 0) {
      const hasPerm = permissions.some(permission => 
        value.includes(permission)
      )
      
      if (!hasPerm) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    } else {
      throw new Error(`需要权限数组! 如: v-permission="['user:create']"`)
    }
  }
}

// 角色指令
export const roleDirective = {
  inserted(el, binding) {
    const { value } = binding
    const roles = store.getters.roles
    
    if (value && value instanceof Array && value.length > 0) {
      const hasRole = roles.some(role => value.includes(role))
      
      if (!hasRole) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    } else {
      throw new Error(`需要角色数组! 如: v-role="['admin']"`)
    }
  }
}

6. 侧边栏菜单组件

vue

<!-- components/Layout/Sidebar.vue -->
<template>
  <el-scrollbar wrap-class="scrollbar-wrapper">
    <el-menu
      :default-active="activeMenu"
      :collapse="isCollapse"
      :background-color="variables.menuBg"
      :text-color="variables.menuText"
      :active-text-color="variables.menuActiveText"
      :unique-opened="false"
      :collapse-transition="false"
      mode="vertical"
    >
      <sidebar-item
        v-for="route in menus"
        :key="route.path"
        :item="route"
        :base-path="route.path"
      />
    </el-menu>
  </el-scrollbar>
</template>

<script>
import { mapGetters } from 'vuex'
import SidebarItem from './SidebarItem.vue'
import variables from '@/styles/variables.scss'

export default {
  name: 'Sidebar',
  components: { SidebarItem },
  computed: {
    ...mapGetters(['menus']),
    activeMenu() {
      const route = this.$route
      const { meta, path } = route
      if (meta.activeMenu) {
        return meta.activeMenu
      }
      return path
    },
    isCollapse() {
      return !this.$store.state.app.sidebar.opened
    },
    variables() {
      return variables
    }
  }
}
</script>

7. 菜单项组件

vue

<!-- components/Layout/SidebarItem.vue -->
<template>
  <div v-if="!item.hidden">
    <template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren)">
      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
        <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
          <item :icon="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" :title="onlyOneChild.meta.title" />
        </el-menu-item>
      </app-link>
    </template>

    <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
      <template slot="title">
        <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
      </template>
      <sidebar-item
        v-for="child in item.children"
        :key="child.path"
        :is-nest="true"
        :item="child"
        :base-path="resolvePath(child.path)"
        class="nest-menu"
      />
    </el-submenu>
  </div>
</template>

<script>
import path from 'path'
import { isExternal } from '@/utils/validate'
import Item from './Item'
import AppLink from './Link'

export default {
  name: 'SidebarItem',
  components: { Item, AppLink },
  props: {
    item: {
      type: Object,
      required: true
    },
    isNest: {
      type: Boolean,
      default: false
    },
    basePath: {
      type: String,
      default: ''
    }
  },
  data() {
    this.onlyOneChild = null
    return {}
  },
  methods: {
    hasOneShowingChild(children = [], parent) {
      const showingChildren = children.filter(item => {
        if (item.hidden) {
          return false
        } else {
          this.onlyOneChild = item
          return true
        }
      })

      if (showingChildren.length === 1) {
        return true
      }

      if (showingChildren.length === 0) {
        this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
        return true
      }

      return false
    },
    
    resolvePath(routePath) {
      if (isExternal(routePath)) {
        return routePath
      }
      if (isExternal(this.basePath)) {
        return this.basePath
      }
      return path.resolve(this.basePath, routePath)
    }
  }
}
</script>

8. 权限指令注册

javascript

// main.js
import Vue from 'vue'
import { permissionDirective, roleDirective } from '@/utils/permission'

// 注册权限指令
Vue.directive('permission', permissionDirective)
Vue.directive('role', roleDirective)

9. 路由守卫

javascript

// router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import { constantRoutes } from './routes'
import store from '@/store'
import { Message } from 'element-ui'

Vue.use(Router)

const router = new Router({
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRoutes
})

// 白名单
const whiteList = ['/login', '/auth-redirect']

router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  if (to.meta.title) {
    document.title = to.meta.title
  }

  // 判断是否有token
  const hasToken = store.getters.token

  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        next()
      } else {
        try {
          // 获取用户信息
          const { roles } = await store.dispatch('user/getUserInfo')
          
          // 根据角色生成可访问路由
          const accessRoutes = await store.dispatch('user/generateRoutes', roles)
          
          // 动态添加路由
          router.addRoutes(accessRoutes)
          
          // 确保addRoutes完成后next
          next({ ...to, replace: true })
        } catch (error) {
          // 失败则重置token并跳转到登录页
          await store.dispatch('user/resetToken')
          Message.error(error || 'Has Error')
          next(`/login?redirect=${to.path}`)
        }
      }
    }
  } else {
    // 没有token
    if (whiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

router.afterEach(() => {
  // 完成进度条
})

export default router

10. 在页面中使用权限控制

vue

<!-- views/user/List.vue -->
<template>
  <div class="user-list">
    <div class="header">
      <el-button 
        v-permission="['user:create']"
        type="primary" 
        @click="handleCreate"
      >
        新增用户
      </el-button>
      
      <el-button 
        v-permission="['user:export']"
        @click="handleExport"
      >
        导出数据
      </el-button>
    </div>

    <el-table :data="userList">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column prop="role" label="角色" />
      <el-table-column label="操作">
        <template slot-scope="scope">
          <el-button 
            v-permission="['user:edit']"
            size="mini" 
            @click="handleEdit(scope.row)"
          >
            编辑
          </el-button>
          <el-button 
            v-permission="['user:delete']"
            size="mini" 
            type="danger" 
            @click="handleDelete(scope.row)"
          >
            删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
export default {
  name: 'UserList',
  data() {
    return {
      userList: []
    }
  },
  methods: {
    handleCreate() {
      // 新增用户逻辑
    },
    handleEdit(user) {
      // 编辑用户逻辑
    },
    handleDelete(user) {
      // 删除用户逻辑
    },
    handleExport() {
      // 导出数据逻辑
    }
  }
}
</script>

设计要点说明

1. 权限粒度控制

  • 页面级:通过路由守卫控制

  • 菜单级:通过动态菜单生成控制

  • 操作级:通过指令控制按钮显示

2. 性能优化

  • 路由懒加载

  • 按需生成权限路由

  • 菜单数据缓存

3. 安全性

  • 前端权限验证是用户体验优化

  • 关键接口必须在后端验证权限

  • 敏感操作需要二次确认

4. 扩展性

  • 支持多级嵌套路由

  • 支持多种权限验证方式

  • 易于集成新的权限类型

这个权限管理系统提供了完整的解决方案,从路由控制到按钮级别的权限管理,具有良好的可维护性和扩展性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值