Vue3 权限系统实战 | 从 0 搭建完整 RBAC 权限管理

企业级后台系统的核心功能,覆盖从登录到权限控制的全流程

前言

在后台管理系统中,权限控制是绕不开的核心需求。无论是简单的内部系统,还是复杂的 SaaS 平台,一套完善的权限方案都是系统安全性和可扩展性的基石。

本文将从零开始,手把手带你实现一套完整的 RBAC 权限管理系统,涵盖 菜单权限按钮权限数据权限 三大核心模块。文章基于 Vue3 + TypeScript + Vue Router 4 + Pinia 技术栈,所有代码均已实战验证。


一、RBAC 权限模型详解

1.1 什么是 RBAC?

RBAC(Role-Based Access Control,基于角色的访问控制)是目前最主流的权限管理模型。核心思想是:用户 → 角色 → 权限,通过角色作为中间层,将用户与权限解耦。

复制代码
┌─────────┐     ┌─────────┐     ┌─────────┐
│  用户   │────▶│  角色   │────▶│  权限   │
└─────────┘     └─────────┘     └─────────┘

1.2 核心概念

概念 说明 示例
用户 系统使用者 admin、zhangsan
角色 权限的集合 超级管理员、运营专员
权限 可执行的操作 用户管理、删除按钮、查看数据

1.3 权限粒度划分

  • 菜单权限:控制用户能看到哪些菜单/页面
  • 按钮权限:控制用户能使用哪些按钮/操作
  • 数据权限:控制用户能访问哪些数据范围(本部门、全部、仅本人)

二、前端权限方案设计

2.1 整体架构流程图

text 复制代码
用户登录 ──▶ 后端返回 token + roles ──▶ 前端存储 token
                                              │
                                              ▼
                              根据 roles 获取权限菜单 + 权限标识
                                              │
                                              ▼
                              动态生成路由 + 渲染菜单树
                                              │
                                              ▼
                              路由守卫拦截 + 按钮权限指令
                                              │
                                              ▼
                              数据权限过滤请求参数

2.2 技术选型

模块 技术
框架 Vue 3 + Composition API
路由 Vue Router 4
状态管理 Pinia
HTTP 请求 Axios
UI 组件库 Element Plus / Ant Design Vue
类型支持 TypeScript

2.3 后端数据结构约定

假设后端返回的权限数据结构如下:

typescript 复制代码
// 登录响应
interface LoginResponse {
  token: string;
  userInfo: UserInfo;
}

interface UserInfo {
  id: string;
  username: string;
  roles: string[];  // ['admin', 'editor']
}

// 权限菜单响应
interface PermissionMenu {
  id: string;
  name: string;
  path: string;
  component: string;
  icon?: string;
  children?: PermissionMenu[];
  meta: {
    title: string;
    permission?: string;  // 菜单对应的权限标识
  };
}

// 按钮权限响应
interface ButtonPermission {
  code: string;   // 权限标识,如 'user:add'
  name: string;   // 权限名称
}

三、项目初始化与基础配置

3.1 创建项目

bash 复制代码
npm create vue@latest vue3-permission-demo
# 选择 TypeScript、Vue Router、Pinia

cd vue3-permission-demo
npm install
npm install axios element-plus

3.2 目录结构设计

text 复制代码
src/
├── api/              # API 接口
│   ├── auth.ts       # 登录/登出
│   ├── permission.ts # 权限相关
│   └── types/        # 接口类型定义
├── directives/       # 自定义指令(按钮权限)
│   └── permission.ts
├── layouts/          # 布局组件
│   ├── DefaultLayout.vue
│   └── Sidebar.vue
├── router/           # 路由配置
│   ├── index.ts      # 路由实例
│   ├── routes.ts     # 静态路由
│   └── permission.ts # 权限守卫
├── stores/           # Pinia 状态
│   ├── user.ts       # 用户状态
│   ├── permission.ts # 权限状态
│   └── app.ts        # 应用配置
├── utils/            # 工具函数
│   ├── request.ts    # Axios 封装
│   └── storage.ts    # 本地存储
└── views/            # 页面视图

四、登录认证与 Token 管理

4.1 Axios 封装

typescript 复制代码
// utils/request.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';

const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
});

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    const userStore = useUserStore();
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, data, message } = response.data;
    if (code === 200) {
      return data;
    }
    ElMessage.error(message || '请求失败');
    return Promise.reject(new Error(message));
  },
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore();
      userStore.logout();
      window.location.href = '/login';
    }
    ElMessage.error(error.message || '网络错误');
    return Promise.reject(error);
  }
);

export default service;

4.2 登录 API

typescript 复制代码
// api/auth.ts
import request from '@/utils/request';
import type { LoginParams, LoginResponse, UserInfo } from './types';

export const loginApi = (data: LoginParams): Promise<LoginResponse> => {
  return request.post('/auth/login', data);
};

export const getUserInfoApi = (): Promise<UserInfo> => {
  return request.get('/auth/user-info');
};

export const logoutApi = (): Promise<void> => {
  return request.post('/auth/logout');
};

4.3 用户 Store 实现

typescript 复制代码
// stores/user.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { loginApi, getUserInfoApi, logoutApi } from '@/api/auth';
import { usePermissionStore } from './permission';
import router from '@/router';

export const useUserStore = defineStore('user', () => {
  const token = ref<string>(localStorage.getItem('token') || '');
  const userInfo = ref<any>(null);
  const roles = ref<string[]>([]);

  const setToken = (newToken: string) => {
    token.value = newToken;
    localStorage.setItem('token', newToken);
  };

  const login = async (username: string, password: string) => {
    const res = await loginApi({ username, password });
    setToken(res.token);
    await getUserInfo();
    return res;
  };

  const getUserInfo = async () => {
    const res = await getUserInfoApi();
    userInfo.value = res;
    roles.value = res.roles;
    
    // 获取权限后,生成动态路由
    const permissionStore = usePermissionStore();
    await permissionStore.generateRoutes(roles.value);
    
    return res;
  };

  const logout = async () => {
    try {
      await logoutApi();
    } finally {
      token.value = '';
      userInfo.value = null;
      roles.value = [];
      localStorage.removeItem('token');
      
      // 重置路由
      const permissionStore = usePermissionStore();
      permissionStore.resetRoutes();
      
      router.push('/login');
    }
  };

  return {
    token,
    userInfo,
    roles,
    login,
    getUserInfo,
    logout,
    setToken,
  };
});

五、菜单权限实现(动态路由)

5.1 路由结构设计

typescript 复制代码
// router/routes.ts
import type { RouteRecordRaw } from 'vue-router';

// 静态路由(无需权限)
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', hidden: true },
  },
  {
    path: '/404',
    name: '404',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '404', hidden: true },
  },
];

// 动态路由(需要权限)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/layouts/DefaultLayout.vue'),
    meta: { title: '仪表盘', icon: 'dashboard', permission: 'dashboard:view' },
    children: [
      {
        path: 'index',
        name: 'DashboardIndex',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { title: '工作台', permission: 'dashboard:view' },
      },
    ],
  },
  {
    path: '/system',
    name: 'System',
    component: () => import('@/layouts/DefaultLayout.vue'),
    meta: { title: '系统管理', icon: 'setting', permission: 'system:view' },
    children: [
      {
        path: 'user',
        name: 'UserManage',
        component: () => import('@/views/system/user/index.vue'),
        meta: { title: '用户管理', permission: 'system:user:view' },
      },
      {
        path: 'role',
        name: 'RoleManage',
        component: () => import('@/views/system/role/index.vue'),
        meta: { title: '角色管理', permission: 'system:role:view' },
      },
    ],
  },
];

5.2 权限 Store(路由过滤核心)

typescript 复制代码
// stores/permission.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { asyncRoutes, constantRoutes } from '@/router/routes';
import type { RouteRecordRaw } from 'vue-router';
import router from '@/router';

// 递归过滤路由
function filterRoutes(routes: RouteRecordRaw[], permissions: string[]): RouteRecordRaw[] {
  return routes.filter(route => {
    // 检查当前路由权限
    const permission = route.meta?.permission as string;
    if (permission && !permissions.includes(permission)) {
      return false;
    }
    
    // 递归过滤子路由
    if (route.children) {
      route.children = filterRoutes(route.children, permissions);
      // 如果子路由全部被过滤,则当前路由也无意义
      if (route.children.length === 0 && !route.meta?.alwaysShow) {
        return false;
      }
    }
    return true;
  });
}

export const usePermissionStore = defineStore('permission', () => {
  const routes = ref<RouteRecordRaw[]>([]);
  
  // 获取所有可访问路由(包含静态路由)
  const accessibleRoutes = computed(() => constantRoutes.concat(routes.value));
  
  // 获取菜单路由(用于侧边栏渲染,排除 hidden 路由)
  const menuRoutes = computed(() => {
    return accessibleRoutes.value.filter(route => !route.meta?.hidden);
  });

  // 生成动态路由
  const generateRoutes = async (roles: string[]) => {
    // 获取用户权限标识列表(由后端返回)
    const permissions = await getUserPermissions(roles);
    
    // 根据权限过滤动态路由
    const filteredRoutes = filterRoutes(asyncRoutes, permissions);
    routes.value = filteredRoutes;
    
    // 动态添加路由
    filteredRoutes.forEach(route => {
      router.addRoute(route);
    });
    
    // 添加 404 兜底路由
    router.addRoute({
      path: '/:pathMatch(.*)*',
      name: 'NotFound',
      component: () => import('@/views/error/404.vue'),
    });
    
    return filteredRoutes;
  };

  // 重置路由(登出时调用)
  const resetRoutes = () => {
    routes.value = [];
    // 移除所有动态添加的路由
    const existingRoutes = router.getRoutes();
    existingRoutes.forEach(route => {
      if (route.name && route.name !== 'Login' && route.name !== '404') {
        router.removeRoute(route.name);
      }
    });
  };

  return {
    routes,
    accessibleRoutes,
    menuRoutes,
    generateRoutes,
    resetRoutes,
  };
});

// 模拟获取用户权限(实际应该调用 API)
async function getUserPermissions(roles: string[]): Promise<string[]> {
  // 可以根据角色从后端获取权限标识列表
  // 这里模拟数据
  if (roles.includes('admin')) {
    return ['dashboard:view', 'system:view', 'system:user:view', 'system:role:view'];
  }
  if (roles.includes('editor')) {
    return ['dashboard:view'];
  }
  return [];
}

5.3 路由守卫实现

typescript 复制代码
// router/permission.ts
import router from './index';
import { useUserStore } from '@/stores/user';
import { usePermissionStore } from '@/stores/permission';
import { ElMessage } from 'element-plus';

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

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  const permissionStore = usePermissionStore();
  
  // 设置页面标题
  document.title = `${to.meta.title || '权限系统'} | Vue3 Admin`;
  
  if (userStore.token) {
    if (to.path === '/login') {
      // 已登录,跳转到首页
      next({ path: '/' });
    } else {
      // 检查是否已获取用户信息
      if (!userStore.userInfo) {
        try {
          await userStore.getUserInfo();
          // 重定向到目标路由(避免动态路由未完全添加的问题)
          next({ ...to, replace: true });
        } catch (error) {
          // token 无效,清除并跳转登录
          await userStore.logout();
          next(`/login?redirect=${to.path}`);
        }
      } else {
        next();
      }
    }
  } else {
    // 未登录
    if (whiteList.includes(to.path)) {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
    }
  }
});

六、按钮权限实现(自定义指令)

6.1 按钮权限指令

typescript 复制代码
// directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue';
import { useUserStore } from '@/stores/user';

// 权限指令 v-permission="'user:add'"
export const permissionDirective: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { value } = binding;
    const userStore = useUserStore();
    const permissions = userStore.userInfo?.permissions || [];
    
    if (value && !permissions.includes(value)) {
      // 没有权限,移除元素或添加禁用样式
      if (el.parentNode) {
        el.parentNode.removeChild(el);
      }
      // 或者隐藏元素
      // el.style.display = 'none';
    }
  },
};

// 按钮级别权限函数式使用
export function hasPermission(permission: string): boolean {
  const userStore = useUserStore();
  const permissions = userStore.userInfo?.permissions || [];
  return permissions.includes(permission);
}

6.2 注册全局指令

typescript 复制代码
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from 'pinia';
import { permissionDirective } from './directives/permission';

const app = createApp(App);

app.use(createPinia());
app.use(router);

// 注册权限指令
app.directive('permission', permissionDirective);

app.mount('#app');

6.3 使用示例

vue 复制代码
<template>
  <div>
    <!-- 指令方式 -->
    <el-button v-permission="'user:add'" type="primary" @click="handleAdd">
      新增用户
    </el-button>
    
    <!-- 函数式判断 -->
    <el-button 
      v-if="hasPermission('user:edit')" 
      type="warning" 
      @click="handleEdit"
    >
      编辑
    </el-button>
    
    <el-button 
      v-if="hasPermission('user:delete')" 
      type="danger" 
      @click="handleDelete"
    >
      删除
    </el-button>
  </div>
</template>

<script setup lang="ts">
import { hasPermission } from '@/directives/permission';

const handleAdd = () => {
  console.log('新增用户');
};
</script>

七、数据权限实现

7.1 数据权限方案设计

数据权限通常在请求层实现,前端需要将当前用户的数据权限范围传递给后端。

常见数据权限类型:

  • ALL:全部数据
  • DEPT:本部门数据
  • DEPT_AND_SUB:本部门及子部门
  • SELF:仅本人数据

7.2 在请求中注入数据权限

typescript 复制代码
// utils/request.ts 中添加数据权限拦截器
import { useUserStore } from '@/stores/user';

// 数据权限枚举
export enum DataScope {
  ALL = 'ALL',
  DEPT = 'DEPT',
  DEPT_AND_SUB = 'DEPT_AND_SUB',
  SELF = 'SELF',
}

// 在请求拦截器中添加数据权限参数
service.interceptors.request.use((config) => {
  const userStore = useUserStore();
  
  // 添加用户信息到请求头,供后端做数据权限过滤
  if (userStore.userInfo) {
    config.headers['X-User-Id'] = userStore.userInfo.id;
    config.headers['X-Dept-Id'] = userStore.userInfo.deptId;
    config.headers['X-Data-Scope'] = userStore.userInfo.dataScope || DataScope.DEPT;
  }
  
  return config;
});

7.3 数据权限 Hook

typescript 复制代码
// composables/useDataPermission.ts
import { computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { DataScope } from '@/utils/request';

export function useDataPermission() {
  const userStore = useUserStore();
  
  const currentUserDeptId = computed(() => userStore.userInfo?.deptId);
  const currentUserId = computed(() => userStore.userInfo?.id);
  const dataScope = computed(() => userStore.userInfo?.dataScope);
  
  // 判断是否能查看某条数据
  const canViewData = (data: { userId?: string; deptId?: string }): boolean => {
    switch (dataScope.value) {
      case DataScope.ALL:
        return true;
      case DataScope.DEPT:
        return data.deptId === currentUserDeptId.value;
      case DataScope.SELF:
        return data.userId === currentUserId.value;
      case DataScope.DEPT_AND_SUB:
        // 需要后端判断子部门,前端简化为部门匹配
        return data.deptId === currentUserDeptId.value;
      default:
        return false;
    }
  };
  
  // 构建数据权限查询参数
  const buildDataPermissionParams = () => {
    return {
      dataScope: dataScope.value,
      userId: currentUserId.value,
      deptId: currentUserDeptId.value,
    };
  };
  
  return {
    currentUserDeptId,
    currentUserId,
    dataScope,
    canViewData,
    buildDataPermissionParams,
  };
}

7.4 列表页面使用示例

vue 复制代码
<template>
  <div>
    <el-table :data="tableData" v-loading="loading">
      <el-table-column prop="username" label="用户名" />
      <el-table-column prop="deptName" label="部门" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button 
            v-if="canEdit(row)" 
            type="text" 
            @click="handleEdit(row)"
          >
            编辑
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useDataPermission } from '@/composables/useDataPermission';
import { getUserList } from '@/api/user';

const { canViewData, buildDataPermissionParams } = useDataPermission();
const loading = ref(false);
const tableData = ref([]);

// 获取列表时传递数据权限参数
const fetchData = async () => {
  loading.value = true;
  try {
    const params = {
      ...buildDataPermissionParams(),
      page: 1,
      pageSize: 10,
    };
    const res = await getUserList(params);
    // 前端二次过滤(可选,主要靠后端)
    tableData.value = res.list.filter(item => canViewData(item));
  } finally {
    loading.value = false;
  }
};

// 判断是否可编辑
const canEdit = (row: any) => {
  const { dataScope, currentUserId } = useDataPermission();
  // 数据权限 + 按钮权限组合判断
  if (dataScope.value === 'SELF' && row.userId !== currentUserId.value) {
    return false;
  }
  return hasPermission('user:edit');
};

onMounted(() => {
  fetchData();
});
</script>

八、侧边栏菜单渲染

8.1 递归菜单组件

vue 复制代码
<!-- layouts/components/SidebarItem.vue -->
<template>
  <template v-if="hasChildren(item)">
    <el-sub-menu :index="item.path">
      <template #title>
        <el-icon v-if="item.meta?.icon">
          <component :is="item.meta.icon" />
        </el-icon>
        <span>{{ item.meta?.title }}</span>
      </template>
      <sidebar-item 
        v-for="child in item.children" 
        :key="child.path" 
        :item="child" 
      />
    </el-sub-menu>
  </template>
  <template v-else>
    <el-menu-item :index="resolvePath(item.path)" @click="handleNavigate(item)">
      <el-icon v-if="item.meta?.icon">
        <component :is="item.meta.icon" />
      </el-icon>
      <template #title>{{ item.meta?.title }}</template>
    </el-menu-item>
  </template>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';

const props = defineProps<{
  item: RouteRecordRaw;
  basePath?: string;
}>();

const router = useRouter();

const hasChildren = (route: RouteRecordRaw) => {
  return route.children && route.children.length > 0;
};

const resolvePath = (routePath: string) => {
  if (props.basePath) {
    return `${props.basePath}/${routePath}`.replace(//+/g, '/');
  }
  return routePath;
};

const handleNavigate = (item: RouteRecordRaw) => {
  const fullPath = resolvePath(item.path);
  router.push(fullPath);
};
</script>

8.2 侧边栏主组件

vue 复制代码
<!-- layouts/Sidebar.vue -->
<template>
  <div class="sidebar">
    <div class="logo">
      <img src="/logo.svg" alt="logo" />
      <span v-if="!isCollapse">权限管理系统</span>
    </div>
    <el-menu
      :default-active="activeMenu"
      :collapse="isCollapse"
      :unique-opened="true"
      background-color="#304156"
      text-color="#bfcbd9"
      active-text-color="#409EFF"
      router
    >
      <sidebar-item 
        v-for="route in menuRoutes" 
        :key="route.path" 
        :item="route" 
      />
    </el-menu>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { useAppStore } from '@/stores/app';
import SidebarItem from './SidebarItem.vue';

const route = useRoute();
const permissionStore = usePermissionStore();
const appStore = useAppStore();

const menuRoutes = computed(() => permissionStore.menuRoutes);
const isCollapse = computed(() => appStore.sidebarCollapse);

const activeMenu = computed(() => {
  const { path, meta } = route;
  if (meta.activeMenu) {
    return meta.activeMenu as string;
  }
  return path;
});
</script>

九、完整登录页面实现

vue 复制代码
<!-- views/login/index.vue -->
<template>
  <div class="login-container">
    <el-form 
      ref="formRef" 
      :model="form" 
      :rules="rules" 
      class="login-form"
    >
      <h2 class="title">权限管理系统</h2>
      
      <el-form-item prop="username">
        <el-input 
          v-model="form.username" 
          placeholder="用户名" 
          prefix-icon="User"
          size="large"
        />
      </el-form-item>
      
      <el-form-item prop="password">
        <el-input 
          v-model="form.password" 
          type="password" 
          placeholder="密码" 
          prefix-icon="Lock"
          size="large"
          show-password
          @keyup.enter="handleLogin"
        />
      </el-form-item>
      
      <el-form-item>
        <el-button 
          type="primary" 
          size="large" 
          :loading="loading" 
          class="login-btn"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
      
      <div class="demo-tips">
        <p>演示账号:</p>
        <p>超级管理员:admin / 123456</p>
        <p>普通用户:editor / 123456</p>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';

const router = useRouter();
const route = useRoute();
const userStore = useUserStore();

const formRef = ref();
const loading = ref(false);

const form = reactive({
  username: 'admin',
  password: '123456',
});

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};

const handleLogin = async () => {
  if (!formRef.value) return;
  
  await formRef.value.validate();
  
  loading.value = true;
  try {
    await userStore.login(form.username, form.password);
    ElMessage.success('登录成功');
    const redirect = route.query.redirect as string || '/';
    router.push(redirect);
  } catch (error: any) {
    ElMessage.error(error.message || '登录失败');
  } finally {
    loading.value = false;
  }
};
</script>

<style scoped lang="scss">
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  .login-form {
    width: 400px;
    padding: 40px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    
    .title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    
    .login-btn {
      width: 100%;
    }
    
    .demo-tips {
      margin-top: 20px;
      padding: 12px;
      background: #f5f7fa;
      border-radius: 4px;
      font-size: 12px;
      color: #666;
      
      p {
        margin: 4px 0;
      }
    }
  }
}
</style>

十、完整项目源码结构

10.1 API 类型定义

typescript 复制代码
// api/types/index.ts
export interface LoginParams {
  username: string;
  password: string;
}

export interface LoginResponse {
  token: string;
  userInfo: UserInfo;
}

export interface UserInfo {
  id: string;
  username: string;
  nickname: string;
  avatar: string;
  roles: string[];
  permissions: string[];
  deptId: string;
  deptName: string;
  dataScope: 'ALL' | 'DEPT' | 'DEPT_AND_SUB' | 'SELF';
}

export interface PermissionMenu {
  id: string;
  name: string;
  path: string;
  component: string;
  parentId: string | null;
  icon?: string;
  sort: number;
  meta: {
    title: string;
    permission: string;
    hidden?: boolean;
    alwaysShow?: boolean;
  };
  children?: PermissionMenu[];
}

10.2 环境变量配置

bash 复制代码
# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_TITLE=权限管理系统

10.3 入口文件完整配置

typescript 复制代码
// main.ts
import { createApp } from 'vue';
import pinia from './stores';
import router from './router';
import App from './App.vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import { permissionDirective } from './directives/permission';

const app = createApp(App);

// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component);
}

app.use(pinia);
app.use(router);
app.use(ElementPlus);
app.directive('permission', permissionDirective);

// 路由守卫
import './router/permission';

app.mount('#app');

十一、常见问题与最佳实践

11.1 动态路由刷新 404 问题

问题:页面刷新后,动态添加的路由丢失,导致 404。

解决方案:在路由守卫中判断,如果用户已登录但没有 routes,重新生成。

typescript 复制代码
// router/permission.ts 中添加
router.beforeEach(async (to, from, next) => {
  // ... 省略其他代码
  
  if (userStore.token) {
    if (userStore.userInfo) {
      // 检查动态路由是否存在
      const permissionStore = usePermissionStore();
      if (permissionStore.routes.length === 0 && to.path !== '/login') {
        await permissionStore.generateRoutes(userStore.roles);
        next({ ...to, replace: true });
        return;
      }
      next();
    } else {
      // ... 获取用户信息
    }
  }
});

11.2 性能优化建议

  1. 路由懒加载:所有页面组件使用动态 import
  2. 权限数据缓存:用户权限信息存储在 localStorage 中,避免重复请求
  3. 菜单渲染优化 :使用 v-memo 优化递归组件渲染

11.3 安全注意事项

  1. 前端权限只做 UI 展示:真正的权限校验必须在后端执行
  2. Token 安全存储:敏感信息避免使用 localStorage,可使用 httpOnly Cookie
  3. 路由守卫不能绕过:所有页面跳转都会经过守卫,但后端仍需鉴权

总结

本文完整实现了一套基于 Vue3 的 RBAC 权限管理系统,涵盖了:

  • ✅ RBAC 权限模型的核心概念
  • ✅ 前端权限架构设计与流程
  • ✅ Token 登录认证完整实现
  • ✅ 动态菜单/路由权限(后端驱动)
  • ✅ 按钮级权限(自定义指令 + 函数式)
  • ✅ 数据权限(请求拦截 + 组合式函数)
  • ✅ 路由守卫完整实现
  • ✅ 侧边栏递归菜单组件

这套方案已在多个企业级项目中验证,具有良好的扩展性和可维护性。你可以根据实际业务需求,在此基础上增加更多功能,如:权限缓存策略、多租户支持、API 级别权限校验等。


本文为技术分享,欢迎交流讨论。如有疑问,请在评论区留言。

相关推荐
前端小木屋1 小时前
Node基础入门
javascript·node.js
IT_陈寒1 小时前
用了Vue的动态组件之后,我被坑得找不着北
前端·人工智能·后端
阳火锅1 小时前
💡 告别类名地狱!Tailwind CSS 语义化转换神器来了
前端·css·vue.js
ricardo19731 小时前
Core Web Vitals 全解:LCP / INP / CLS 逐个击破
前端
VillenK1 小时前
版本依赖问题:vite-plugin-dts@3.1.0 与 jiti 的兼容性
前端·typescript·vite
用户125758524361 小时前
XYGo Admin 即时通讯与异步任务实战:WebSocket 长连接 + 消息队列驱动后台处理
vue.js
Apifox2 小时前
如何在 Apifox 中快速构建和调试 AI Agent
前端·agent·ai编程
一晌贪欢i2 小时前
WebContainer 重点介绍
前端·webcontainer
山河木马2 小时前
Emscripten 从 C/C++ 调用 JavaScript
前端·javascript·c++