📋 目录


背景介绍

在企业管理后台中,权限控制是最核心的功能之一。不同的用户角色需要看到不同的菜单、访问不同的页面、操作不同的按钮。

典型的权限场景

管理员(Admin):
├── 可以看到所有菜单(系统管理、用户管理、角色管理...)
├── 可以访问所有页面
└── 可以操作所有按钮(新增、编辑、删除、导出...)

普通用户(User):
├── 只能看到部分菜单(个人中心、数据查询...)
├── 只能访问授权页面
└── 只能操作部分按钮(查询、导出,不能删除)

vue-admin-better提供了两种权限控制模式

  1. 前端控制路由(intelligence模式):前端维护路由表,根据用户角色过滤
  2. 后端控制路由(all模式):后端返回用户的路由配置,前端动态生成

这两种模式各有优劣,适用于不同的安全需求和业务场景。


问题分析

典型症状

在实际项目中,权限控制不当通常表现为:

症状1:未授权用户能访问敏感页面

// 用户直接在浏览器输入URL
http://localhost:8080/system/user-management

// ❌ 即使是普通用户,也能访问系统管理页面
// 因为路由是静态注册的,没有权限校验

症状2:菜单显示与实际权限不一致

// 左侧菜单隐藏了"用户管理"
// 但用户可以直接通过URL访问
// 或者通过浏览器开发者工具找到隐藏的路由

症状3:按钮权限失效

<template>
  <!-- 理论上应该只有管理员能看到删除按钮 -->
  <el-button @click="handleDelete">删除</el-button>
  
  <!-- ❌ 但普通用户也能看到并点击 -->
</template>

症状4:权限变更后仍使用旧权限

// 用户在登录后,管理员将其角色从Admin改为User
// 但用户刷新页面后,仍然拥有Admin的所有权限
// 因为权限信息缓存在前端,没有实时校验

根本原因

通过深入分析,可以发现这些问题的根本原因是:

1. 权限信息来源不明确

前端不知道用户的真实权限:
├── 角色信息存储在哪里?(Vuex/Pinia、localStorage、Cookie)
├── 如何获取用户的角色?(登录接口、单独的角色接口)
├── 角色变更后如何同步?(轮询、WebSocket、手动刷新)
└── 谁来负责权限校验?(前端、后端、还是两者都有)

2. 路由注册时机不当

// ❌ 错误做法:应用启动时注册所有路由
const router = createRouter({
  routes: [
    { path: '/dashboard', component: Dashboard },
    { path: '/system/user', component: UserManagement },  // 所有人都能访问
    { path: '/system/role', component: RoleManagement }   // 所有人都能访问
  ]
})

// 正确做法:登录后根据角色动态添加路由
router.addRoute({
  path: '/system/user',
  component: UserManagement,
  meta: { roles: ['admin'] }  // 只有admin角色能访问
})

3. 权限粒度不统一

不同层级的权限控制缺失:
├── 路由级:能否访问某个页面
├── 菜单级:能否看到某个菜单项
├── 按钮级:能否点击某个操作按钮
└── 数据级:能否查看某条数据记录

如果只控制了路由级,用户仍能通过直接调用API操作数据

影响范围

权限控制失效的影响非常严重:

影响维度 具体表现
安全性 未授权用户访问敏感数据,造成数据泄露
合规性 违反最小权限原则,无法通过安全审计
用户体验 看到无法访问的菜单,点击后报错
可维护性 权限逻辑分散在各处,难以统一管理

解决方案:RBAC权限模型

核心设计原则

为了解决上述问题,我们采用RBAC(Role-Based Access Control)权限模型,遵循以下设计原则:

原则1:单一事实来源(Single Source of Truth)

用户的权限信息只有一个来源:
├── intelligence模式:前端维护的角色-路由映射表
├── all模式:后端返回的用户路由配置
└── 前端不自行猜测或硬编码权限

原则2:动态路由注册(Dynamic Route Registration)

路由不是一次性注册,而是根据用户权限动态添加:
├── 登录前:只有公开路由(登录页、404页)
├── 登录后:根据角色添加授权路由
└── 登出后:移除所有动态路由

原则3:多层防护(Multi-layer Protection)

权限控制在多个层级同时生效:
├── 路由级:路由守卫拦截未授权访问
├── 菜单级:只显示有权限的菜单项
├── 按钮级:隐藏或禁用无权限的操作按钮
└── API级:后端再次校验权限(最终防线)

原则4:权限缓存与同步(Cache & Sync)

权限信息需要合理缓存并及时同步:
├── 登录后缓存到Pinia/Vuex和localStorage
├── 刷新页面时从localStorage恢复
├── 提供强制刷新接口(管理员变更角色后)
└── 可选:WebSocket推送权限变更通知

两种模式对比

维度 intelligence模式(前端控制) all模式(后端控制)
权限来源 前端维护路由表和角色映射 后端返回用户的路由配置
安全性 ⭐⭐(较低,前端可篡改) ⭐⭐⭐⭐⭐(高,后端控制)
灵活性 ⭐⭐⭐⭐(前端自由定制) ⭐⭐⭐(依赖后端接口)
性能 ⭐⭐⭐⭐⭐(无需请求后端) ⭐⭐⭐⭐(需额外请求)
适用场景 内部系统、低风险场景 金融系统、高风险场景
开发成本 低(前端独立完成) 中(需后端配合)
维护成本 中(前后端路由表需同步) 低(后端统一管理)
典型应用 企业内部管理系统 银行、电商后台

架构设计图

┌─────────────────────────────────────────────────┐
│                  UI Components                    │
│  (Menu, Button, Page, Dialog)                    │
└──────────────┬──────────────────────────────────┘
               │  v-permission="['admin']"
               │  checkPermission('user:delete')
               ▼
┌─────────────────────────────────────────────────┐
│          Permission Controller                   │
│                                                   │
│  ┌──────────────────────────────────────────┐   │
│  │  路由守卫(Router Guard)                 │   │
│  │  - 检查token                             │   │
│  │  - 检查角色                              │   │
│  │  - 动态添加路由                          │   │
│  ├──────────────────────────────────────────┤   │
│  │  菜单过滤(Menu Filter)                  │   │
│  │  - 递归过滤路由树                        │   │
│  │  - 生成可见菜单                          │   │
│  ├──────────────────────────────────────────┤   │
│  │  按钮权限(Button Permission)            │   │
│  │  - 自定义指令v-permission                │   │
│  │  - 工具函数checkPermission               │   │
│  └──────────────────────────────────────────┘   │
└──────────────┬──────────────────────────────────┘
               │
       ┌───────┴────────┐
       │                │
       ▼                ▼
┌──────────────┐  ┌──────────────┐
│ Intelligence │  │     All      │
│   Mode       │  │    Mode      │
│              │  │              │
│ 前端维护路由  │  │ 后端返回路由  │
│ 角色映射表    │  │ 配置JSON     │
└──────────────┘  └──────────────┘

完整实现

方案A:前端控制路由(intelligence模式)

核心思路:前端维护完整的路由表和角色映射关系,登录后根据用户角色过滤路由。

步骤1:定义路由表与角色映射

src/router/constantRoutes.js

/**
 * 公开路由(无需登录即可访问)
 */
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/Login.vue'),
    hidden: true
  },
  {
    path: '/404',
    component: () => import('@/views/404.vue'),
    hidden: true
  },
  {
    path: '/',
    redirect: '/dashboard'
  }
];

/**
 * 动态路由(根据角色动态添加)
 * 
 * meta.roles: 允许访问该路由的角色列表
 * - admin: 管理员
 * - editor: 编辑
 * - user: 普通用户
 */
export const asyncRoutes = [
  {
    path: '/dashboard',
    component: () => import('@/layouts/MainLayout.vue'),
    redirect: '/dashboard/index',
    meta: {
      title: '仪表盘',
      icon: 'Dashboard'
    },
    children: [
      {
        path: 'index',
        component: () => import('@/views/Dashboard.vue'),
        meta: {
          title: '工作台',
          icon: 'HomeFilled',
          roles: ['admin', 'editor', 'user']  // 所有角色可访问
        }
      }
    ]
  },
  {
    path: '/system',
    component: () => import('@/layouts/MainLayout.vue'),
    redirect: '/system/user',
    meta: {
      title: '系统管理',
      icon: 'Setting',
      roles: ['admin']  // 仅管理员可访问
    },
    children: [
      {
        path: 'user',
        component: () => import('@/views/System/UserList.vue'),
        meta: {
          title: '用户管理',
          icon: 'User',
          roles: ['admin'],
          permissions: ['user:view', 'user:create', 'user:update', 'user:delete']
        }
      },
      {
        path: 'role',
        component: () => import('@/views/System/RoleList.vue'),
        meta: {
          title: '角色管理',
          icon: 'Lock',
          roles: ['admin'],
          permissions: ['role:view', 'role:create', 'role:update', 'role:delete']
        }
      },
      {
        path: 'menu',
        component: () => import('@/views/System/MenuList.vue'),
        meta: {
          title: '菜单管理',
          icon: 'Menu',
          roles: ['admin'],
          permissions: ['menu:view', 'menu:create', 'menu:update', 'menu:delete']
        }
      }
    ]
  },
  {
    path: '/data',
    component: () => import('@/layouts/MainLayout.vue'),
    redirect: '/data/list',
    meta: {
      title: '数据管理',
      icon: 'DataAnalysis'
    },
    children: [
      {
        path: 'list',
        component: () => import('@/views/Data/DataList.vue'),
        meta: {
          title: '数据列表',
          icon: 'List',
          roles: ['admin', 'editor'],  // 管理员和编辑可访问
          permissions: ['data:view']
        }
      },
      {
        path: 'export',
        component: () => import('@/views/Data/DataExport.vue'),
        meta: {
          title: '数据导出',
          icon: 'Download',
          roles: ['admin', 'editor'],
          permissions: ['data:export']
        }
      }
    ]
  },
  {
    path: '/profile',
    component: () => import('@/layouts/MainLayout.vue'),
    redirect: '/profile/index',
    meta: {
      title: '个人中心',
      icon: 'UserFilled'
    },
    children: [
      {
        path: 'index',
        component: () => import('@/views/Profile.vue'),
        meta: {
          title: '个人信息',
          icon: 'InfoFilled',
          roles: ['admin', 'editor', 'user'],  // 所有角色可访问
          hidden: true  // 不在菜单中显示
        }
      }
    ]
  },
  // 通配符路由(必须最后添加)
  {
    path: '/:pathMatch(.*)*',
    redirect: '/404',
    hidden: true
  }
];
步骤2:实现路由过滤逻辑

src/router/permission.js

import { constantRoutes, asyncRoutes } from './constantRoutes';

/**
 * 判断用户是否有访问某个路由的权限
 * @param {Array} roles - 用户拥有的角色列表
 * @param {Object} route - 路由配置
 * @returns {Boolean}
 */
function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role));
  }
  // 没有配置roles,默认所有人可访问
  return true;
}

/**
 * 递归过滤路由表
 * @param {Array} routes - 原始路由表
 * @param {Array} roles - 用户角色列表
 * @returns {Array} 过滤后的路由表
 */
export function filterAsyncRoutes(routes, roles) {
  const result = [];

  routes.forEach(route => {
    // 创建路由副本,避免修改原始数据
    const tmp = { ...route };

    if (hasPermission(roles, tmp)) {
      // 如果有子路由,递归过滤
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles);
      }
      result.push(tmp);
    }
  });

  return result;
}

/**
 * 根据用户角色生成可访问的路由表
 * @param {Array} roles - 用户角色列表
 * @returns {Array} 可访问的路由表
 */
export function generateAccessibleRoutes(roles) {
  let accessedRoutes;

  // admin角色拥有所有权限
  if (roles.includes('admin')) {
    accessedRoutes = asyncRoutes || [];
  } else {
    // 其他角色根据roles字段过滤
    accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
  }

  return accessedRoutes;
}
步骤3:路由守卫与动态路由注册

src/permission.js

import router from './router';
import store from './store';
import { ElMessage } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';

// 白名单(无需登录即可访问)
const whiteList = ['/login', '/404'];

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 开启进度条
  NProgress.start();

  // 设置页面标题
  document.title = to.meta.title || 'Vue Admin Better';

  // 获取token
  const hasToken = store.getters.token;

  if (hasToken) {
    // 已登录
    if (to.path === '/login') {
      // 如果已登录,跳转到首页
      next({ path: '/' });
      NProgress.done();
    } 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('permission/generateRoutes', roles);

          // 动态添加路由
          accessRoutes.forEach(route => {
            router.addRoute(route);
          });

          // 确保添加的路由生效,重新导航
          next({ ...to, replace: true });
        } catch (error) {
          // 获取用户信息失败,清除token并跳转登录页
          await store.dispatch('user/resetToken');
          ElMessage.error(error.message || 'Has Error');
          next(`/login?redirect=${to.path}`);
          NProgress.done();
        }
      }
    }
  } else {
    // 未登录
    if (whiteList.indexOf(to.path) !== -1) {
      // 在白名单中,直接进入
      next();
    } else {
      // 不在白名单中,跳转登录页
      next(`/login?redirect=${to.path}`);
      NProgress.done();
    }
  }
});

// 全局后置钩子
router.afterEach(() => {
  // 关闭进度条
  NProgress.done();
});
步骤4:Pinia状态管理

src/store/modules/permission.js

import { defineStore } from 'pinia';
import { asyncRoutes, constantRoutes } from '@/router/constantRoutes';
import { generateAccessibleRoutes } from '@/router/permission';

/**
 * 过滤路由的辅助函数
 */
function hasPermission(route, roles) {
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role));
  }
  return true;
}

function filterAsyncRoutes(routes, roles) {
  const res = [];

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

  return res;
}

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],           // 完整路由表(包含公开路由和动态路由)
    addRoutes: [],        // 动态添加的路由表
    sidebarRoutes: []     // 侧边栏菜单路由
  }),

  getters: {
    // 获取完整路由表
    getRoutes: (state) => state.routes,
    
    // 获取侧边栏路由
    getSidebarRoutes: (state) => state.sidebarRoutes
  },

  actions: {
    /**
     * 根据用户角色生成路由表
     * @param {Array} roles - 用户角色列表
     * @returns {Promise<Array>}
     */
    generateRoutes(roles) {
      return new Promise(resolve => {
        let accessedRoutes;

        // admin角色拥有所有权限
        if (roles.includes('admin')) {
          accessedRoutes = asyncRoutes || [];
        } else {
          // 其他角色根据roles字段过滤
          accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
        }

        // 保存动态路由
        this.addRoutes = accessedRoutes;
        
        // 合并公开路由和动态路由
        this.routes = constantRoutes.concat(accessedRoutes);
        
        // 提取用于侧边栏的路由(排除hidden的路由)
        this.sidebarRoutes = accessedRoutes.filter(route => !route.hidden);

        resolve(accessedRoutes);
      });
    },

    /**
     * 重置路由
     */
    resetRoutes() {
      this.routes = [];
      this.addRoutes = [];
      this.sidebarRoutes = [];
    }
  }
});

src/store/modules/user.js

import { defineStore } from 'pinia';
import { login, logout, getUserInfo } from '@/api/user';
import { getToken, setToken, removeToken } from '@/utils/auth';

export const useUserStore = defineStore('user', {
  state: () => ({
    token: getToken(),
    name: '',
    avatar: '',
    roles: []  // 用户角色列表
  }),

  getters: {
    getToken: (state) => state.token,
    getRoles: (state) => state.roles
  },

  actions: {
    /**
     * 登录
     * @param {Object} userInfo - 登录信息
     * @returns {Promise<void>}
     */
    async login(userInfo) {
      const { username, password } = userInfo;
      
      try {
        const { data } = await login({ username, password: password.trim() });
        
        // 保存token
        this.token = data.token;
        setToken(data.token);
      } catch (error) {
        throw error;
      }
    },

    /**
     * 获取用户信息
     * @returns {Promise<Object>}
     */
    async getUserInfo() {
      try {
        const { data } = await getUserInfo();

        if (!data) {
          throw new Error('Verification failed, please Login again.');
        }

        const { roles, name, avatar } = data;

        // roles必须是非空数组
        if (!roles || roles.length <= 0) {
          throw new Error('getUserInfo: roles must be a non-null array!');
        }

        this.roles = roles;
        this.name = name;
        this.avatar = avatar;

        return { roles, name, avatar };
      } catch (error) {
        throw error;
      }
    },

    /**
     * 登出
     * @returns {Promise<void>}
     */
    async logout() {
      try {
        await logout(this.token);
        
        // 清除本地状态
        this.token = '';
        this.roles = [];
        this.name = '';
        this.avatar = '';
        
        // 清除token
        removeToken();
      } catch (error) {
        throw error;
      }
    },

    /**
     * 重置token
     */
    resetToken() {
      return new Promise(resolve => {
        this.token = '';
        this.roles = [];
        removeToken();
        resolve();
      });
    }
  }
});

方案B:后端控制路由(all模式)

核心思路:后端根据用户角色返回该用户可访问的路由配置(JSON格式),前端解析并动态生成路由。

步骤1:后端返回的路由配置格式

后端接口响应示例

{
  "code": 200,
  "message": "success",
  "data": {
    "routes": [
      {
        "path": "/dashboard",
        "component": "Layout",
        "redirect": "/dashboard/index",
        "meta": {
          "title": "仪表盘",
          "icon": "Dashboard"
        },
        "children": [
          {
            "path": "index",
            "component": "dashboard/index",
            "name": "Dashboard",
            "meta": {
              "title": "工作台",
              "icon": "HomeFilled"
            }
          }
        ]
      },
      {
        "path": "/system",
        "component": "Layout",
        "redirect": "/system/user",
        "meta": {
          "title": "系统管理",
          "icon": "Setting"
        },
        "children": [
          {
            "path": "user",
            "component": "system/user/index",
            "name": "User",
            "meta": {
              "title": "用户管理",
              "icon": "User"
            }
          },
          {
            "path": "role",
            "component": "system/role/index",
            "name": "Role",
            "meta": {
              "title": "角色管理",
              "icon": "Lock"
            }
          }
        ]
      }
    ]
  }
}
步骤2:前端解析后端路由配置

src/router/permission.js(all模式版本)

import Layout from '@/layouts/MainLayout.vue';

/**
 * 组件映射表(用于动态导入)
 */
const modules = import.meta.glob('../views/**/*.vue');

/**
 * 将后端返回的component字符串转换为实际的组件对象
 * @param {String} component - 组件路径字符串
 * @returns {Component}
 */
function loadComponent(component) {
  // 特殊处理:Layout
  if (component === 'Layout') {
    return Layout;
  }

  // 动态导入组件
  const componentPath = `../views/${component}.vue`;
  const module = modules[componentPath];

  if (!module) {
    console.warn(`组件不存在: ${componentPath}`);
    return () => import('@/views/404.vue');
  }

  return module;
}

/**
 * 将后端返回的路由配置转换为Vue Router可用的路由对象
 * @param {Array} routes - 后端返回的路由配置
 * @returns {Array} Vue Router路由对象
 */
export function transformRoutes(routes) {
  const result = [];

  routes.forEach(route => {
    const tmp = {
      path: route.path,
      component: loadComponent(route.component),
      redirect: route.redirect,
      name: route.name,
      meta: route.meta
    };

    // 递归处理子路由
    if (route.children && route.children.length > 0) {
      tmp.children = transformRoutes(route.children);
    }

    result.push(tmp);
  });

  return result;
}

src/store/modules/permission.js(all模式版本)

import { defineStore } from 'pinia';
import { constantRoutes } from '@/router/constantRoutes';
import { transformRoutes } from '@/router/permission';
import { getRoutes } from '@/api/user';

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],
    addRoutes: [],
    sidebarRoutes: []
  }),

  getters: {
    getRoutes: (state) => state.routes,
    getSidebarRoutes: (state) => state.sidebarRoutes
  },

  actions: {
    /**
     * 从后端获取用户的路由配置
     * @returns {Promise<Array>}
     */
    async generateRoutes() {
      try {
        // 调用后端接口获取路由配置
        const { data } = await getRoutes();

        // 转换路由配置
        const accessedRoutes = transformRoutes(data);

        // 保存动态路由
        this.addRoutes = accessedRoutes;
        
        // 合并公开路由和动态路由
        this.routes = constantRoutes.concat(accessedRoutes);
        
        // 提取用于侧边栏的路由
        this.sidebarRoutes = accessedRoutes.filter(route => !route.meta?.hidden);

        return accessedRoutes;
      } catch (error) {
        throw error;
      }
    },

    /**
     * 重置路由
     */
    resetRoutes() {
      this.routes = [];
      this.addRoutes = [];
      this.sidebarRoutes = [];
    }
  }
});

src/permission.js(all模式版本)

import router from './router';
import store from './store';
import { ElMessage } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';

const whiteList = ['/login', '/404'];

router.beforeEach(async (to, from, next) => {
  NProgress.start();
  document.title = to.meta.title || 'Vue Admin Better';

  const hasToken = store.getters.token;

  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' });
      NProgress.done();
    } 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('permission/generateRoutes');

          // 动态添加路由
          accessRoutes.forEach(route => {
            router.addRoute(route);
          });

          next({ ...to, replace: true });
        } catch (error) {
          await store.dispatch('user/resetToken');
          ElMessage.error(error.message || 'Has Error');
          next(`/login?redirect=${to.path}`);
          NProgress.done();
        }
      }
    }
  } else {
    if (whiteList.indexOf(to.path) !== -1) {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
      NProgress.done();
    }
  }
});

router.afterEach(() => {
  NProgress.done();
});

按钮级权限控制

除了路由级和菜单级权限,还需要实现按钮级权限控制。

方案1:自定义指令v-permission

src/directives/permission/index.js

import { useUserStore } from '@/store/modules/user';

/**
 * 检查用户是否有指定权限
 * @param {Array|String} value - 权限标识或权限列表
 * @returns {Boolean}
 */
function checkPermission(value) {
  const userStore = useUserStore();
  const { roles, permissions } = userStore;

  // admin角色拥有所有权限
  if (roles.includes('admin')) {
    return true;
  }

  // 如果没有传入权限值,默认不允许
  if (!value || value.length === 0) {
    return false;
  }

  // 支持数组和字符串两种格式
  const requiredPermissions = Array.isArray(value) ? value : [value];

  // 检查用户是否拥有至少一个所需权限
  return requiredPermissions.some(permission => {
    return permissions.includes(permission);
  });
}

/**
 * v-permission指令
 * 用法:
 * <el-button v-permission="['user:create']">新增</el-button>
 * <el-button v-permission="'user:delete'">删除</el-button>
 */
export default {
  mounted(el, binding) {
    const { value } = binding;

    if (!checkPermission(value)) {
      // 没有权限,移除元素
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
};

注册指令(main.js)

import { createApp } from 'vue';
import App from './App.vue';
import permission from './directives/permission';

const app = createApp(App);

// 注册自定义指令
app.directive('permission', permission);

app.mount('#app');

使用示例

<template>
  <div class="user-list">
    <!-- 只有拥有user:create权限的用户能看到 -->
    <el-button 
      type="primary" 
      v-permission="['user:create']"
      @click="handleCreate"
    >
      新增用户
    </el-button>

    <!-- 只有拥有user:update权限的用户能看到 -->
    <el-button 
      type="warning" 
      v-permission="['user:update']"
      @click="handleUpdate"
    >
      编辑
    </el-button>

    <!-- 只有拥有user:delete权限的用户能看到 -->
    <el-button 
      type="danger" 
      v-permission="['user:delete']"
      @click="handleDelete"
    >
      删除
    </el-button>

    <!-- 拥有任意一个权限就能看到 -->
    <el-button 
      v-permission="['user:export', 'data:export']"
      @click="handleExport"
    >
      导出
    </el-button>
  </div>
</template>
方案2:工具函数checkPermission

src/utils/permission.js

import { useUserStore } from '@/store/modules/user';

/**
 * 检查用户是否有指定权限(用于模板中)
 * @param {Array|String} value - 权限标识或权限列表
 * @returns {Boolean}
 */
export function checkPermission(value) {
  const userStore = useUserStore();
  const { roles, permissions } = userStore;

  // admin角色拥有所有权限
  if (roles.includes('admin')) {
    return true;
  }

  if (!value || value.length === 0) {
    return false;
  }

  const requiredPermissions = Array.isArray(value) ? value : [value];

  return requiredPermissions.some(permission => {
    return permissions.includes(permission);
  });
}

使用示例

<template>
  <div class="user-list">
    <!-- 在模板中使用 -->
    <el-button 
      v-if="checkPermission(['user:create'])"
      type="primary"
      @click="handleCreate"
    >
      新增用户
    </el-button>

    <!-- 结合多个条件 -->
    <el-dropdown v-if="checkPermission(['user:update', 'user:delete'])">
      <el-button>
        更多操作
        <el-icon><arrow-down /></el-icon>
      </el-button>
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item v-if="checkPermission('user:update')">
            编辑
          </el-dropdown-item>
          <el-dropdown-item v-if="checkPermission('user:delete')">
            删除
          </el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
  </div>
</template>

<script setup>
import { checkPermission } from '@/utils/permission';

function handleCreate() {
  // ...
}

function handleUpdate() {
  // ...
}

function handleDelete() {
  // ...
}
</script>

路由守卫中的权限校验

高级路由守卫(支持权限变更检测)

import router from './router';
import store from './store';
import { ElMessage, ElMessageBox } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';

const whiteList = ['/login', '/404'];

// 标记是否正在刷新权限
let isRefreshing = false;

router.beforeEach(async (to, from, next) => {
  NProgress.start();
  document.title = to.meta.title || 'Vue Admin Better';

  const hasToken = store.getters.token;

  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' });
      NProgress.done();
    } else {
      const hasRoles = store.getters.roles && store.getters.roles.length > 0;

      if (hasRoles) {
        // 已获取角色,进行权限校验
        const hasPermission = validatePermission(to);

        if (hasPermission) {
          next();
        } else {
          // 没有权限,提示用户
          ElMessage.error('您没有权限访问该页面');
          next('/403');  // 跳转到403页面
          NProgress.done();
        }
      } else {
        try {
          // 防止重复刷新
          if (isRefreshing) {
            next();
            return;
          }

          isRefreshing = true;

          // 获取用户信息
          const { roles } = await store.dispatch('user/getUserInfo');

          // 生成路由表
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles);

          // 动态添加路由
          accessRoutes.forEach(route => {
            router.addRoute(route);
          });

          isRefreshing = false;

          // 重新导航
          next({ ...to, replace: true });
        } catch (error) {
          isRefreshing = false;

          // 清除token并跳转登录页
          await store.dispatch('user/resetToken');
          ElMessage.error(error.message || 'Has Error');
          next(`/login?redirect=${to.path}`);
          NProgress.done();
        }
      }
    }
  } else {
    if (whiteList.indexOf(to.path) !== -1) {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
      NProgress.done();
    }
  }
});

/**
 * 验证用户是否有权限访问目标路由
 * @param {Route} to - 目标路由
 * @returns {Boolean}
 */
function validatePermission(to) {
  const userStore = useUserStore();
  const { roles } = userStore;

  // admin角色拥有所有权限
  if (roles.includes('admin')) {
    return true;
  }

  // 检查路由配置的roles
  if (to.matched && to.matched.length > 0) {
    for (const record of to.matched) {
      if (record.meta && record.meta.roles) {
        const hasRole = record.meta.roles.some(role => roles.includes(role));
        if (!hasRole) {
          return false;
        }
      }
    }
  }

  return true;
}

router.afterEach(() => {
  NProgress.done();
});

框架集成

Vue Router集成

动态路由的最佳实践

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { constantRoutes } from './constantRoutes';

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  // 只注册公开路由,动态路由在登录后添加
  routes: constantRoutes
});

export default router;

Pinia状态管理集成

完整的权限Store

import { defineStore } from 'pinia';
import { useUserStore } from './user';

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],
    addRoutes: [],
    sidebarRoutes: [],
    // 缓存已访问的页面
    visitedViews: [],
    // 缓存已打开的标签页
    cachedViews: []
  }),

  getters: {
    // 获取完整路由表
    getRoutes: (state) => state.routes,
    
    // 获取侧边栏路由
    getSidebarRoutes: (state) => state.sidebarRoutes,
    
    // 获取已访问的视图
    getVisitedViews: (state) => state.visitedViews,
    
    // 获取缓存的视图
    getCachedViews: (state) => state.cachedViews
  },

  actions: {
    /**
     * 生成路由
     */
    async generateRoutes(roles) {
      // ... 前面已实现
    },

    /**
     * 添加访问记录
     */
    addView(view) {
      // 避免重复添加
      if (this.visitedViews.some(v => v.path === view.path)) {
        return;
      }

      this.visitedViews.push({
        name: view.name,
        title: view.meta.title || 'no-name',
        path: view.path,
        fullPath: view.fullPath
      });

      // 添加到缓存
      if (view.meta.keepAlive) {
        this.cachedViews.push(view.name);
      }
    },

    /**
     * 删除访问记录
     */
    delView(view) {
      const index = this.visitedViews.findIndex(v => v.path === view.path);
      if (index > -1) {
        this.visitedViews.splice(index, 1);
      }

      const cacheIndex = this.cachedViews.indexOf(view.name);
      if (cacheIndex > -1) {
        this.cachedViews.splice(cacheIndex, 1);
      }
    },

    /**
     * 清空所有访问记录
     */
    delAllViews() {
      this.visitedViews = [];
      this.cachedViews = [];
    },

    /**
     * 重置
     */
    resetRoutes() {
      this.routes = [];
      this.addRoutes = [];
      this.sidebarRoutes = [];
      this.visitedViews = [];
      this.cachedViews = [];
    }
  }
});

自定义指令v-permission

增强版指令(支持禁用而非隐藏)

export default {
  mounted(el, binding) {
    const { value, modifiers } = binding;

    if (!checkPermission(value)) {
      if (modifiers.disabled) {
        // 禁用模式:保留元素但禁用交互
        el.disabled = true;
        el.classList.add('is-disabled');
        el.style.cursor = 'not-allowed';
        el.style.opacity = '0.5';
        
        // 阻止点击事件
        el.addEventListener('click', (e) => {
          e.stopPropagation();
          e.preventDefault();
        }, true);
      } else {
        // 隐藏模式:移除元素
        el.parentNode && el.parentNode.removeChild(el);
      }
    }
  },

  updated(el, binding) {
    // 权限变化时重新检查
    if (binding.value !== binding.oldValue) {
      this.mounted(el, binding);
    }
  }
};

使用示例

<template>
  <!-- 隐藏模式(默认) -->
  <el-button v-permission="['user:delete']">删除</el-button>

  <!-- 禁用模式 -->
  <el-button v-permission.disabled="['user:delete']">删除</el-button>
</template>

TypeScript类型安全封装

类型定义

// types/permission.ts

/**
 * 角色类型
 */
export type Role = 'admin' | 'editor' | 'user';

/**
 * 权限标识
 */
export type Permission = 
  | 'user:view' | 'user:create' | 'user:update' | 'user:delete'
  | 'role:view' | 'role:create' | 'role:update' | 'role:delete'
  | 'menu:view' | 'menu:create' | 'menu:update' | 'menu:delete'
  | 'data:view' | 'data:export';

/**
 * 路由元数据
 */
export interface RouteMeta {
  title?: string;
  icon?: string;
  roles?: Role[];
  permissions?: Permission[];
  hidden?: boolean;
  keepAlive?: boolean;
}

/**
 * 路由配置
 */
export interface AppRouteRecordRaw {
  path: string;
  component?: any;
  redirect?: string;
  name?: string;
  meta?: RouteMeta;
  children?: AppRouteRecordRaw[];
  hidden?: boolean;
}

/**
 * 用户信息
 */
export interface UserInfo {
  id: number;
  username: string;
  roles: Role[];
  permissions: Permission[];
  name: string;
  avatar: string;
}

类型安全的权限检查

// utils/permission.ts
import { useUserStore } from '@/store/modules/user';
import type { Role, Permission } from '@/types/permission';

/**
 * 检查用户是否有指定角色
 */
export function hasRole(role: Role | Role[]): boolean {
  const userStore = useUserStore();
  const { roles } = userStore;

  if (roles.includes('admin')) {
    return true;
  }

  const requiredRoles = Array.isArray(role) ? role : [role];
  return requiredRoles.some(r => roles.includes(r));
}

/**
 * 检查用户是否有指定权限
 */
export function hasPermission(permission: Permission | Permission[]): boolean {
  const userStore = useUserStore();
  const { roles, permissions } = userStore;

  if (roles.includes('admin')) {
    return true;
  }

  const requiredPermissions = Array.isArray(permission) ? permission : [permission];
  return requiredPermissions.some(p => permissions.includes(p));
}

/**
 * 检查用户是否有任意一个权限
 */
export function hasAnyPermission(permissions: Permission[]): boolean {
  return hasPermission(permissions);
}

/**
 * 检查用户是否有所有权限
 */
export function hasAllPermissions(permissions: Permission[]): boolean {
  const userStore = useUserStore();
  const { roles, permissions: userPermissions } = userStore;

  if (roles.includes('admin')) {
    return true;
  }

  return permissions.every(p => userPermissions.includes(p));
}

测试策略

单元测试

权限过滤逻辑测试

import { describe, it, expect } from 'vitest';
import { filterAsyncRoutes, hasPermission } from '@/router/permission';
import { asyncRoutes } from '@/router/constantRoutes';

describe('Permission Filter', () => {
  it('应该允许admin访问所有路由', () => {
    const roles = ['admin'];
    const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
    
    // admin应该能看到所有路由
    expect(filteredRoutes.length).toBeGreaterThan(0);
  });

  it('应该拒绝普通用户访问系统管理', () => {
    const roles = ['user'];
    const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
    
    // 普通用户不应该看到系统管理菜单
    const systemRoute = filteredRoutes.find(r => r.path === '/system');
    expect(systemRoute).toBeUndefined();
  });

  it('应该允许编辑访问数据管理', () => {
    const roles = ['editor'];
    const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
    
    // 编辑应该能看到数据管理
    const dataRoute = filteredRoutes.find(r => r.path === '/data');
    expect(dataRoute).toBeDefined();
  });

  it('应该正确处理嵌套子路由', () => {
    const roles = ['editor'];
    const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
    
    const dataRoute = filteredRoutes.find(r => r.path === '/data');
    expect(dataRoute.children).toBeDefined();
    
    // 编辑不能看到数据导出
    const exportChild = dataRoute.children.find(c => c.path === 'export');
    expect(exportChild).toBeUndefined();
  });
});

按钮权限测试

import { describe, it, expect, vi } from 'vitest';
import { checkPermission } from '@/utils/permission';
import { useUserStore } from '@/store/modules/user';

vi.mock('@/store/modules/user');

describe('Button Permission', () => {
  it('admin应该拥有所有权限', () => {
    useUserStore.mockReturnValue({
      roles: ['admin'],
      permissions: []
    });

    expect(checkPermission(['user:delete'])).toBe(true);
  });

  it('普通用户只能访问授权的按钮', () => {
    useUserStore.mockReturnValue({
      roles: ['user'],
      permissions: ['user:view', 'data:view']
    });

    expect(checkPermission('user:view')).toBe(true);
    expect(checkPermission('user:delete')).toBe(false);
  });

  it('应该支持多个权限OR逻辑', () => {
    useUserStore.mockReturnValue({
      roles: ['user'],
      permissions: ['user:view', 'data:export']
    });

    // 拥有其中一个权限即可
    expect(checkPermission(['user:delete', 'data:export'])).toBe(true);
  });
});

集成测试

完整登录流程测试

import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import App from '@/App.vue';
import { useUserStore } from '@/store/modules/user';
import { usePermissionStore } from '@/store/modules/permission';

describe('Permission Integration', () => {
  let wrapper;
  let router;
  let pinia;

  beforeEach(() => {
    pinia = createPinia();
    router = createRouter({
      history: createWebHistory(),
      routes: []
    });

    wrapper = mount(App, {
      global: {
        plugins: [router, pinia]
      }
    });
  });

  it('登录后应该动态添加路由', async () => {
    const userStore = useUserStore(pinia);
    const permissionStore = usePermissionStore(pinia);

    // 模拟登录
    await userStore.login({ username: 'admin', password: '123456' });
    await userStore.getUserInfo();
    await permissionStore.generateRoutes(userStore.roles);

    // 检查路由是否已添加
    expect(permissionStore.addRoutes.length).toBeGreaterThan(0);
    expect(router.getRoutes().length).toBeGreaterThan(2);
  });

  it('登出后应该移除动态路由', async () => {
    const userStore = useUserStore(pinia);
    const permissionStore = usePermissionStore(pinia);

    // 先登录
    await userStore.login({ username: 'admin', password: '123456' });
    await userStore.getUserInfo();
    await permissionStore.generateRoutes(userStore.roles);

    // 再登出
    await userStore.logout();
    permissionStore.resetRoutes();

    // 检查路由是否已移除
    expect(permissionStore.addRoutes.length).toBe(0);
    expect(router.getRoutes().length).toBeLessThan(5);
  });
});

边界条件测试

异常场景测试

describe('Edge Cases', () => {
  it('应该处理空角色数组', () => {
    const roles = [];
    const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
    
    expect(filteredRoutes.length).toBe(0);
  });

  it('应该处理undefined角色', () => {
    const roles = undefined;
    
    expect(() => {
      filterAsyncRoutes(asyncRoutes, roles);
    }).toThrow();
  });

  it('应该处理不存在的路由配置', () => {
    const invalidRoutes = [
      {
        path: '/test',
        component: null,
        children: undefined
      }
    ];

    expect(() => {
      transformRoutes(invalidRoutes);
    }).not.toThrow();
  });

  it('应该处理循环嵌套的路由', () => {
    const circularRoutes = [
      {
        path: '/parent',
        children: [
          {
            path: 'child',
            children: [
              {
                path: 'grandchild',
                children: []  // 空子路由
              }
            ]
          }
        ]
      }
    ];

    expect(() => {
      transformRoutes(circularRoutes);
    }).not.toThrow();
  });
});

最容易踩的5个坑

坑1:忘记在路由守卫中调用next()

错误示例

router.beforeEach(async (to, from, next) => {
  if (hasToken) {
    if (hasRoles) {
      // ❌ 忘记调用next()
      console.log('已登录且有角色');
    } else {
      const { roles } = await getUserInfo();
      const accessRoutes = generateRoutes(roles);
      accessRoutes.forEach(route => router.addRoute(route));
      next({ ...to, replace: true });
    }
  } else {
    next('/login');
  }
});

后果:路由守卫挂起,页面无法加载

正确做法

router.beforeEach(async (to, from, next) => {
  if (hasToken) {
    if (hasRoles) {
      next();  // ✅ 必须调用next()
    } else {
      try {
        const { roles } = await getUserInfo();
        const accessRoutes = generateRoutes(roles);
        accessRoutes.forEach(route => router.addRoute(route));
        next({ ...to, replace: true });
      } catch (error) {
        await resetToken();
        next('/login');  // ✅ 错误时也要调用next()
      }
    }
  } else {
    next('/login');
  }
});

坑2:动态路由添加后未重新导航

错误示例

const accessRoutes = generateRoutes(roles);
accessRoutes.forEach(route => router.addRoute(route));
// ❌ 没有重新导航,当前路由仍然是404
next();

后果:添加路由后,用户访问的URL找不到对应路由

正确做法

const accessRoutes = generateRoutes(roles);
accessRoutes.forEach(route => router.addRoute(route));
next({ ...to, replace: true });  // ✅ 重新导航到目标路由

坑3:admin角色未特殊处理

错误示例

function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role));
  }
  return true;
}

后果:admin角色也需要在每个路由中配置,失去管理员的意义

正确做法

function hasPermission(roles, route) {
  // ✅ admin角色拥有所有权限
  if (roles.includes('admin')) {
    return true;
  }
  
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role));
  }
  return true;
}

坑4:按钮权限指令未处理权限变化

错误示例

export default {
  mounted(el, binding) {
    if (!checkPermission(binding.value)) {
      el.parentNode.removeChild(el);
    }
  }
  // ❌ 没有updated钩子,权限变化时不更新
};

后果:用户角色变更后,按钮权限不会实时更新

正确做法

export default {
  mounted(el, binding) {
    if (!checkPermission(binding.value)) {
      el.parentNode.removeChild(el);
    }
  },
  
  updated(el, binding) {
    // ✅ 权限变化时重新检查
    if (binding.value !== binding.oldValue) {
      this.mounted(el, binding);
    }
  }
};

坑5:通配符路由过早注册

错误示例

// ❌ 在初始化时就注册所有路由(包括通配符)
const router = createRouter({
  routes: [
    { path: '/login', component: Login },
    { path: '/dashboard', component: Dashboard },
    { path: '/:pathMatch(.*)*', redirect: '/404' }  // 通配符
  ]
});

后果:通配符路由会匹配所有路径,导致动态路由失效

正确做法

// ✅ 初始化时只注册公开路由
const router = createRouter({
  routes: constantRoutes  // 不包含通配符
});

// 登录后再添加动态路由和通配符
accessRoutes.forEach(route => {
  router.addRoute(route);
});

面试高频考点

考点1:intelligence模式和all模式的区别

回答要点

  1. 权限来源:前端维护 vs 后端返回
  2. 安全性:较低(前端可篡改)vs 较高(后端控制)
  3. 灵活性:前端独立开发 vs 需后端配合
  4. 适用场景:内部系统 vs 金融/电商后台
  5. 性能:无需请求 vs 需额外请求一次

考点2:如何实现动态路由?

回答要点

  1. 路由表设计:区分公开路由和动态路由
  2. 登录后流程:获取角色 → 生成路由 → 动态添加 → 重新导航
  3. 路由守卫:beforeEach中校验token和角色
  4. 持久化:localStorage缓存,刷新时恢复
  5. 登出清理:移除动态路由,重置状态

考点3:按钮级权限的实现方式

回答要点

  1. 自定义指令:v-permission,挂载时检查权限
  2. 工具函数:checkPermission,用于模板中的v-if
  3. 权限数据:用户信息中包含permissions数组
  4. admin特权:admin角色默认拥有所有权限
  5. 更新机制:权限变化时通过updated钩子重新检查

考点4:如何处理权限变更?

回答要点

  1. 主动刷新:提供"刷新权限"按钮
  2. 被动推送:WebSocket推送权限变更通知
  3. 重新登录:最简单的方式,退出后重新登录
  4. 路由重载:清空动态路由,重新获取并添加
  5. 页面刷新:location.reload()强制刷新

考点5:路由守卫的执行顺序

回答要点

  1. 全局前置守卫:router.beforeEach
  2. 路由独享守卫:beforeEnter(路由配置中)
  3. 组件内守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave
  4. 全局解析守卫:router.beforeResolve
  5. 全局后置钩子:router.afterEach(不能改变导航)

总结与扩展

核心经验总结

  1. 选择合适的权限模式

    • 内部系统用intelligence模式(开发快)
    • 高安全要求用all模式(更安全)
    • 可以混合使用(大部分前端控制,敏感页面后端控制)
  2. 多层防护不可少

    • 路由级:路由守卫拦截
    • 菜单级:过滤不可见菜单
    • 按钮级:隐藏或禁用操作
    • API级:后端最终校验
  3. 动态路由要注意细节

    • 添加后必须重新导航
    • 通配符路由最后添加
    • 登出时要清理动态路由
    • 刷新页面要恢复路由
  4. 权限缓存要合理

    • localStorage持久化token和角色
    • Pinia/Vuex运行时缓存
    • 提供强制刷新接口
    • 可选WebSocket推送变更
  5. 测试覆盖关键场景

    • 正常登录流程
    • 权限不足拦截
    • 权限变更刷新
    • 登出清理路由

扩展阅读


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐