Vue 项目权限管理 路由 按钮权限

项目权限管理: 需要给用户分配角色,给角色分配路由、按钮权限。那么我们就需要3个管理页面:

  1. 用户管理 - 给用户添加不同的角色
  2. 角色管理 - 新建各种角色,对应不同的权限(控制路由、按钮)
  3. 菜单管理 - 配置菜单 菜单对应的路由,菜单类型(有可能是按钮、不需要在导航栏显示)

给前端一个接口,当用户登录的时候,返回用户信息,对应的权限路由。

js 复制代码
{
  data: {
    all_menu: [
      {
        id: 74,
        parent: null,
        name: "信息科技外包活动识别及处理",
        frontend_url: "/outsource/list",
        hidden: false,
        resource_type: "菜单",
        en_name: "",
        rank: 1,
        children: [
          {
            id: 120,
            parent: 74,
            name: "采购清单处理",
            frontend_url: "/outsource/process",
            hidden: true,
            resource_type: "按钮",
            en_name: "",
            rank: 1,
            children: []
          },
        ]
      },
      {
        id: 75,
        parent: null,
        name: "信息科技外包活动监控",
        frontend_url: "/monitor",
        hidden: false,
        resource_type: "菜单",
        en_name: "",
        rank: 2,
        children: [
          {
            id: 123,
            parent: 75,
            name: "采购清单监控",
            frontend_url: "/outsource/monitor",
            hidden: true,
            resource_type: "菜单",
            en_name: "",
            rank: 1,
            children: []
          }
        ]
      }
    ],
    user_menu: [74, 75, 123]
  }
}
  • all_menu 字段就是配置的所有菜单、按钮
  • user_menu 字段是用户有权限的菜单、按钮对应的ID

这个菜单数据导航栏需要 路由拦截权限控制也需要,所以我们需要把请求的数据放到Pinia/Vuex里

先写一下请求接口方法 api/menu.js

js 复制代码
import axios from 'axios'

export function fetchMenuApi() {
  // 获取菜单 权限校验
  return axios({
    method: 'get',
    url: '/api/manage/user_menu/',
    params: { app_name: '采购风险' }
  })
}

在store里去请求数据,store/modules/menu.js (Vuex写法)

js 复制代码
import { fetchMenuApi } from '@/api/menu'

// 获取菜单里的所有按钮
const getAllBtns = (list) => {
  const result = []
  function traverse(nodes) {
    for (const node of nodes) {
      if (node.resource_type === '按钮') {
        result.push(node)
      }
      if (node.children && node.children.length) {
        traverse(node.children)
      }
    }
  }
  traverse(list)
  return result
}

const state = {
  menuObj: {}
}

const mutations = {
  SET_MENU: (state, menu) => {
    state.menuObj = menu
  }
}

const actions = {
  async loadMenu({ commit }) {
    const res = await fetchMenuApi()
    // 获取所有按钮 就可以知道按钮对应的en_name 来判断对应的按钮是不是要显示
    const resData = res.data.data
    if (resData && Object.keys(resData)) {
      resData.all_btn = getAllBtns(resData.all_menu)
    }
    commit('SET_MENU', res.data.code === 0 ? resData : {})
    return resData
 }
}

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

Pinia写法

ts 复制代码
// stores/menu.ts
import { defineStore } from 'pinia'
import { fetchMenuApi } from '@/api/menu'

function getAllBtns(list: any[]) {
  const result: any[] = []
  function traverse(nodes: any[]) {
    for (const node of nodes) {
      if (node.resource_type === '按钮') {
        result.push(node)
      }
      if (node.children && node.children.length) {
        traverse(node.children)
      }
    }
  }
  traverse(list)
  return result
}

export const useMenuStore = defineStore('menu', {
  state: () => ({
    menuObj: {} as Record<string, any>
  }),

  actions: {
    async loadMenu() {
      const res = await fetchMenuApi()
      const resData = res.data.data
      if (resData && Object.keys(resData).length) {
        resData.all_btn = getAllBtns(resData.all_menu)
      } 
      // 在 Pinia 中直接改 state
      this.menuObj = res.data.code === 0 ? resData : {}
      return resData
    }
  }
})

对应使用方法

javascript 复制代码
import { useMenuStore } from '@/stores/menu'
const menuStore = useMenuStore()
// 加载菜单
await menuStore.loadMenu()
// 访问菜单对象
console.log(menuStore.menuObj)

我们要在路由拦截那里,增加路由权限方法

js 复制代码
import store from '@/store'
import { homePath, filterArray, getAllPaths, findFirstMatchingUrl } from '@/utils/route'

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('../views/layout'),
      redirect: '/outsource/list',
      children: [
        {
         path: '/outsource/list',
         name: '信息科技外包活动识别及处理',
         component: () => import('../views/outsource/index.vue')
        },
        ...
      ]
     },
     {
       path: '*',
       name: 'NotFound',
       component: () => import('../views/not_found/index.vue')
     } // 要放在外面 不然redirect 不会生效 /outsource/list
  ]
})


/**
 * 路由权限控制逻辑说明:
 *
 * 1. 无限循环问题:
 *    - 必须在 await 之前设置 isMenuLoaded = true
 *      否则如果 loadMenu 接口报错:
 *        a) isMenuLoaded 永远为 false
 *        b) axios 拦截器(在 main.js 里)会把 401 错误跳到 /unauthorized
 *        c) 路由会跳到 /error
 *        d) 再次触发 beforeEach,又会去调 loadMenu,导致无限循环
 *
 * 2. frontUrlList 和 authUrlList 的区别:
 *    - authMenuList: 返回一份新菜单 用全部菜单根据用户权限去过滤
 * 
 *    - frontUrlList:系统所有"菜单"路由
 *      * 如果按钮需要权限控制,且按钮对应一个路由,就必须放进去
 *      * 如果按钮没有对应路由,不放进去,因为按钮的显示是通过 en_name 控制
 *      * 这样能保证"菜单管理页面"里只包含真正需要控制权限的菜单和按钮
 *      * 如果不区分的话 把所有按钮对应的路由都放进去 但是按钮不需要做权限控制的话 按钮不会添加到菜单管理页面 导致没权限访问按钮对应的路由
 *
 *    - authUrlList:用户有权限访问的路由(在有权限的菜单里把frontend_url提取出来)
 *      * 不区分菜单还是按钮,只要用户有权限都放进来
 *      * 因为授权控制只看是否有权限,不关心是菜单还是按钮
 *
 * 3. 访问逻辑:
 *    - 如果路由在 frontUrlList 里,但不在 authUrlList 里:
 *        → 说明该路由需要权限控制,但用户没有权限
 *        → 跳转 /unauthorized
 *
 *    - 如果路由不在 frontUrlList 里:
 *        → 说明它不是菜单路由,不需要权限控制
 *        → 直接放行
 *
 * 4. 主页跳转逻辑:
 *    - 如果用户有主页权限:
 *        → 直接进入主页
 *    - 如果没有主页权限:
 *        → 自动跳转到用户第一个有权限的路由
 *        → 该路由的 children 必须为空,且类型为菜单(确保是最后一级路由)
 *        → 如果 children 不为空,children 中的每一个路由都不能是菜单类型
 *          否则表示用户没有主页权限,避免跳到未授权页面
 *
 * 5. 安全控制:
 *    - 如果frontUrlList不完整 要控制的路由不在frontUrlList中:
 *        → 用户无论是否授权,都能访问菜单、按钮对应的路由,存在安全漏洞
 *    - frontUrlList完整:
 *        → 只有授权用户才能访问对应路由,未授权用户会提示"未授权"
 *        → 默认进入页面的逻辑和未授权拦截逻辑必须配合使用
 *          才能既保证默认页体验,又保证权限安全控制
 *
 *。  - 权限清单里不会有/unauthorized 判断是否跳转/unauthorized 要判断的对象不会是/unauthorized
 */


let isMenuLoaded = false
router.beforeEach(async (to, from, next) => {
  if (to.path === '/error') {
    return next()
  }
  if (!isMenuLoaded) {
    try {
      isMenuLoaded = true
      await store.dispatch('menu/loadMenu')
    } catch (e) {
      return next('/error')
    }
  }
  
  let {all_menu, user_menu} = store.state.menu.menuObj
  const authMenuList = filterArray(all_menu, user_menu)
  const authUrlList = getAllPaths(authMenuList, 'auth')
  const frontUrlList = getAllPaths(all_menu, 'menu')
  if (from.path === '/' && to.path === homePath && authUrlList.length) {
    let childUrl = findFirstMatchingUrl(authUrlList, all_menu)
    if (authUrlList.includes(homePath) {
      return next()
    }
    // console.log('authUrlList', authUrlList, 'childUrl', childUrl)
    return next(childUrl)
  }
  
  if(frontUrlList.includes(to.path) && !authUrlList.includes(to.path)) {
    next('/unauthorized')
  } else {
    next()
  }
})

看下上面用到的方法

js 复制代码
import { homePath, filterArray, getAllPaths, findFirstMatchingUrl } from '@/utils/route'

路由拦截用到的方法

js 复制代码
export const homePath = '/outsource/list'
/**
 * 递归过滤菜单数组,只保留用户有权限的菜单项
 *
 * @param arr - 原始菜单数组,每个菜单项可能包含 children
 * @param ids - 用户有权限的菜单 ID 列表
 * @returns 返回一个新的菜单数组,只包含用户有权限的菜单项及其子菜单
 *
 * 用法示例:
 * const authMenuList = filterArray(all_menu, user_menu)
 */
export const filterArray = (arr, ids, isMenuSpecific = false) => {
  return arr
    .map(menuItem => {
      // 检查用户是否有权限访问当前菜单项
      if (ids.includes(menuItem.id) && (!isMenuSpecific || menuItem.resource_type === '菜单')) {
        // 克隆当前菜单项,避免修改原数组
        const newItem = { 
          ...menuItem, 
          ...(isMenuSpecific && { path: menuItem.frontend_url || menuItem.backend_url }) 
        };
        // 如果有子菜单,递归过滤子菜单
        if (menuItem.children && menuItem.children.length > 0) {
          newItem.children = filterArray(menuItem.children, ids, isMenuSpecific);
        }
        // 返回处理后的菜单项
        return newItem;
      }
      // 用户无权限访问,返回 null
      return null;
    })
    // 过滤掉 null 值,即没有权限的菜单项
    .filter(Boolean);
};


/**
* getAllPaths(routes, type)如果type为菜单 
* 那么就要包含需要权限控制的按钮跳转的url
* 只所以注释掉导出总览 导出明细 不加进去
* 因为这2个按钮跳转的不是链接
* 如果按钮跳转的是链接 那么就要在
* node.resource_type === '菜单' 加上对应按钮的URL
*/
export function getAllPaths(routes, type) {
  let paths = []
  function traverse(nodes, type) {
    for (const node of nodes) {
      if (type === 'menu') {
        if (node.frontend_url) {
          if (node.resource_type === '菜单') {
            paths.push(node.frontend_url)
          }
        }
      } else {
        if (node.frontend_url) {
         paths.push(node.frontend_url)
        }
      }
      
      if (node.children) {
        traverse(node.children, type)
      }
    }  
  }
  traverse(routes, type)
  return paths
}


/**
 * 递归检查单个权限项及其子项,判断是否存在匹配的URL且自身children为空
 * @param {object} authItem - 权限项
 * @param {string} targetUrl - 目标URL
 * @returns {boolean} 是否匹配
 */
function checkAuthItem(authItem, targetUrl) {
    // 检查当前项的URL是否匹配(注意字段名:front_url 或 frontend)
    const currentUrl = authItem.front_url || authItem.frontend;
    if (currentUrl === targetUrl) {
        // 若匹配,检查自身children是否为空
        return Array.isArray(authItem.children) && authItem.children.length === 0;
    }

    // 若当前项不匹配,递归检查其子项
    if (Array.isArray(authItem.children) && authItem.children.length > 0) {
        for (const child of authItem.children) {
            if (checkAuthItem(child, targetUrl)) {
                return true;
            }
        }
    }

    return false;
}

/**
 * 找出urlList中第一个满足条件的URL
 * @param {string[]} urlList - 待检查的URL数组
 * @param {object[]} authList - 权限列表(可能多级嵌套)
 * @returns {string|null} 第一个满足条件的URL
 */
function findFirstMatchingUrl(urlList, authList) {
    // 遍历urlList,按顺序检查每个URL
    for (const url of urlList) {
        // 遍历authList中的所有权限项
        for (const authItem of authList) {
            // 递归检查当前权限项及其子项
            if (checkAuthItem(authItem, url)) {
                return url; // 找到第一个匹配项,立即返回
            }
        }
    }
    return null; // 无匹配项
}
导航栏需要菜单数据

layout/index.vue

javascript 复制代码
<el-menu class="admin-menu" text-color="#ffffff" mode="horizontal" unique-opened :default-active="$route.path" router>
  <!-- 一级菜单 -->
  <template v-for="item in authList">
  
</el-menu>


computed: {
  menuObj() {
    return this.$store.state.menu.menuObj
  }
},
methods: {
  getMenu() {
    this.authList = []
    let {all_menu, user_menu} = this.menuObj
    // 返回有权限的菜单 就是说user_menu里有对应的ID且id对应的类型是菜单
    this.authList = filterArray(all_menu, user_menu, true)
  }
},
watch: {
  menuObj: {
    handler(val) {
      if(val?.all_menu?.length) {
        this.getMenu()
      }
    },
    immediate: true
  }
}
按钮权限校验 自定义指令

src/directives/index.js

js 复制代码
import permi from './permission'
export default {
 permi
}

在main.js里引入

vbnet 复制代码
import directives from '@/directives'

Object.keys(directives).forEach(key => {
  Vue.directive(key, directives[key])
})

在permission.js里

javascript 复制代码
import store from '@/store'
export default {
  bind(el, binding) {
    /*
     * 需要一个唯一标识 为什么不用id 因为id是自增的 删除了 那个id就不存在了 前端代码得改
     * 为什么不用路由 可能2个按钮跳转的链接是一样的 那就不可以区分是哪一个按钮 做校验 要显示/隐藏
     * 为什么不用中文名称 因为中文名称可能重复 可能都叫导出
     * 所以用唯一标识 en_name 这样配置的时候要确保en_name唯一 即使把配置的菜单删掉了 看下代码那个对应的en_name叫什么 重新配上 不用改前端代码
    */
    const menuObj = store.state.menu.menuObj
    if (menuObj && Object.keys(menuObj).length) {
      let {all_btn, user_menu} = menuObj
      let item = all_btn.find(item => item.en_name === binding.value)
      if (!item) el.style.display = 'none'
      if (item && !user_menu.includes(item.id)) el.style.display = 'none'
    } else {
      el.style.display = 'none'
    }
  }
}

在组件里应用

ini 复制代码
<el-button v-permi="清单及管理#导出总览">导出总览</el-button>
相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax