vue中动态权限控制的实现示例
作者:百锦再@新空间
一、核心原理与流程总览
动态权限控制的本质是:用户登录后,从后端获取其权限数据,前端根据此数据动态地构建出只属于该用户的可访问路由和菜单,并在视图层面(按钮)进行权限控制。
整个流程可以分为以下几个核心步骤,下图清晰地展示了其工作原理和闭环流程:
flowchart TD A[用户登录] --> B[获取用户权限数据JSON] B -- 解析为前端所需结构 --> C[生成动态路由] C -- addRoute添加到路由器 --> D[路由器Router] D -- 根据当前路由生成 --> E[侧边栏菜单 (动态菜单组件)] E -- 点击菜单项触发路由切换 --> D F[访问路由] --> G{路由守卫检查权限} G -- 有权限 --> H[正常渲染组件] G -- 无权限 --> I[跳转404或登录页] H -- 组件内按钮 --> J{按钮权限指令v-permission} J -- 权限码匹配 --> K[显示按钮] J -- 权限码不匹配 --> L[移除按钮DOM]
下面,我们将按照这个流程中的每一个环节,进行详细的原理说明和代码实现。
二、详细步骤与代码实现
步骤 1: 定义权限数据结构与状态管理
首先,我们需要在后端和前端约定好权限数据的结构。
1.1 后端返回的权限数据示例 (GET /api/user/permissions):
通常,后端会返回一个树形结构,包含前端定义的路由和权限点。
{ "code": 200, "data": { "userInfo": { "name": "Alice", "avatar": "" }, "permissions": [ { "id": 1, "parentId": 0, "path": "/system", "name": "System", "meta": { "title": "系统管理", "icon": "setting", "requiresAuth": true }, "children": [ { "id": 2, "parentId": 1, "path": "user", "name": "UserManagement", "meta": { "title": "用户管理", "requiresAuth": true }, "btnPermissions": ["user:add", "user:edit", "user:delete"] // 按钮级权限标识 } ] }, { "id": 3, "parentId": 0, "path": "/about", "name": "About", "meta": { "title": "关于", "icon": "info", "requiresAuth": false } } ] } }
1.2 前端定义静态路由和动态路由
我们将路由分为两类:
- 静态路由 (Constant Routes): 无需权限即可访问的路由,如
/login
,/404
。 - 动态路由 (Dynamic Routes / Async Routes): 需要根据权限动态添加的路由。
/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'; import { useUserStore } from '@/stores/user'; // 静态路由 export const constantRoutes = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { title: '登录', hidden: true } // hidden 表示不在侧边栏显示 }, { path: '/404', name: 'NotFound', component: () => import('@/views/404.vue'), meta: { title: '404', hidden: true } } ]; // 动态路由(初始化为空,后续根据权限添加) // 注意:这里不是直接定义,而是提供一个和后台数据匹配的模板 export const asyncRoutesMap = { 'UserManagement': { path: 'user', // 会拼接到父路由的 path 上 name: 'UserManagement', component: () => import('@/views/system/UserManagement.vue'), // 需要提前创建好组件 meta: { title: '用户管理', requiresAuth: true } }, 'RoleManagement': { path: 'role', name: 'RoleManagement', component: () => import('@/views/system/RoleManagement.vue'), meta: { title: '角色管理', requiresAuth: true } } // ... 其他所有可能的路由 }; const router = createRouter({ history: createWebHistory(), routes: constantRoutes // 初始化时只挂载静态路由 }); export default router;
1.3 使用 Pinia 存储权限状态/src/stores/user.js
import { defineStore } from 'pinia'; import { ref } from 'vue'; import { getPermission } from '@/api/user'; import { asyncRoutesMap } from '@/router'; import { generateRoutes, generateMenu } from '@/utils/permission'; export const useUserStore = defineStore('user', () => { const token = ref(''); const userInfo = ref({}); const permissions = ref([]); // 存储原始权限数据 const dynamicRoutes = ref([]); // 存储生成后的动态路由对象 const menus = ref([]); // 存储用于生成导航菜单的数据 // 获取用户权限信息 const getUserPermissions = async () => { try { const res = await getPermission(); permissions.value = res.data.permissions; userInfo.value = res.data.userInfo; // 核心:根据权限数据生成动态路由和菜单 const { routes, menuList } = generateRoutesAndMenus(permissions.value, asyncRoutesMap); dynamicRoutes.value = routes; menus.value = menuList; return dynamicRoutes.value; } catch (error) { console.error('获取权限失败', error); return []; } }; // 退出登录清空状态 const logout = () => { token.value = ''; userInfo.value = {}; permissions.value = []; dynamicRoutes.value = []; menus.value = []; }; return { token, userInfo, permissions, dynamicRoutes, menus, getUserPermissions, logout }; }); // 工具函数:递归处理权限数据,生成路由和菜单 export const generateRoutesAndMenus = (permissionList, routeMap) => { const routes = []; const menuList = []; const traverse = (nodes, isChild = false) => { nodes.forEach(node => { // 1. 生成菜单项 const menuItem = { path: node.path, name: node.name, meta: { ...node.meta, btnPermissions: node.btnPermissions }, // 保存按钮权限 children: [] }; if (isChild) { menuList[menuList.length - 1]?.children.push(menuItem); } else { menuList.push(menuItem); } // 2. 生成路由项 (只处理有 component 的节点,即叶子节点或需要布局的节点) // 如果后端返回的节点名称能在我们的映射表 asyncRoutesMap 中找到,说明是有效路由 if (routeMap[node.name]) { const route = { ...routeMap[node.name], // 展开映射表中的预设配置(最重要的是component) path: node.path, name: node.name, meta: { ...node.meta, btnPermissions: node.btnPermissions } }; routes.push(route); } // 3. 递归处理子节点 if (node.children && node.children.length > 0) { traverse(node.children, true); } }); }; traverse(permissionList); return { routes, menuList }; };
步骤 2: 登录与获取权限数据
/src/views/Login.vue
<script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useUserStore } from '@/stores/user'; const router = useRouter(); const userStore = useUserStore(); const loginForm = ref({ username: '', password: '' }); const handleLogin = async () => { try { // 1. 执行登录请求,获取 token const loginRes = await api.login(loginForm.value); userStore.token = loginRes.data.token; // 2. 获取用户权限信息 const dynamicRoutes = await userStore.getUserPermissions(); // 3. 动态添加路由 dynamicRoutes.forEach(route => { // 注意:addRoute 可以接受父路由的 name 作为第一个参数,来实现嵌套路由的添加 // 这里假设我们的权限数据已经是一个平铺的数组,或者使用其他方式匹配父路由 // 一种更复杂的实现需要递归处理嵌套路由的添加,这里简化演示 router.addRoute(route); // 添加到根路由 // 如果路由有父级,例如:router.addRoute('ParentRouteName', route); }); // 4. 添加一个兜底的 404 路由(必须放在最后) router.addRoute({ path: '/:pathMatch(.*)*', name: 'CatchAll', redirect: '/404' }); // 5. 跳转到首页 router.push('/'); } catch (error) { console.error('登录失败', error); } }; </script>
步骤 3: 路由守卫进行权限校验
/src/router/index.js
(在原有代码上追加)
// ... 之前的导入和路由初始化代码 ... // 路由守卫 router.beforeEach(async (to, from, next) => { const userStore = useUserStore(); const token = userStore.token; // 1. 判断是否有 token if (token) { // 2. 如果是访问登录页,直接跳转到首页 if (to.path === '/login') { next('/'); } else { // 3. 判断是否已经拉取过用户权限信息 if (userStore.permissions.length === 0) { try { // 4. 如果没有获取权限,则获取权限并添加动态路由 const dynamicRoutes = await userStore.getUserPermissions(); dynamicRoutes.forEach(route => { router.addRoute(route); }); // 5. 添加完动态路由后,需要重定向到目标路由 to // replace: true 防止重复添加路由导致导航失败 next({ ...to, replace: true }); } catch (error) { // 6. 如果获取失败,可能是 token 过期,清除状态并跳回登录页 userStore.logout(); next(`/login?redirect=${to.path}`); } } else { // 7. 如果已经有权限信息,直接放行 next(); } } } else { // 8. 没有 token if (to.meta.requiresAuth === false || to.path === '/login') { // 如果目标路由不需要权限或者是登录页,则放行 next(); } else { // 否则,跳转到登录页,并记录重定向地址 next(`/login?redirect=${to.path}`); } } });
步骤 4: 根据权限数据生成动态菜单
使用上面 Pinia 中生成的 menus
来循环生成侧边栏菜单。
/src/components/Layout/Sidebar.vue
<template> <el-menu :default-active="$route.path" router unique-opened background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF" > <sidebar-item v-for="menu in userStore.menus" :key="menu.path" :item="menu" /> </el-menu> </template> <script setup> import SidebarItem from './SidebarItem.vue'; import { useUserStore } from '@/stores/user'; const userStore = useUserStore(); </script>
/src/components/Layout/SidebarItem.vue
(递归组件)
<template> <!--- 如果有子菜单,渲染 el-sub-menu --> <el-sub-menu v-if="item.children && item.children.length > 0" :index="item.path" > <template #title> <el-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> <!--- 如果没有子菜单,渲染 el-menu-item --> <el-menu-item v-else :index="resolvePath(item.path)"> <el-icon><component :is="item.meta.icon" /></el-icon> <template #title>{{ item.meta.title }}</template> </el-menu-item> </template> <script setup> import { resolve } from 'path-browserify'; const props = defineProps({ item: { type: Object, required: true }, basePath: { type: String, default: '' } }); // 处理完整路径(如果需要处理嵌套路径) function resolvePath(routePath) { return resolve(props.basePath, routePath); } </script>
步骤 5: 实现按钮级权限控制
有两种常见方式:自定义指令 和 函数组件。这里展示更优雅的自定义指令方式。
5.1 创建权限指令 v-permission/src/directives/permission.js
import { useUserStore } from '@/stores/user'; // 按钮权限检查函数 function checkPermission(el, binding) { const { value } = binding; // 指令的绑定值,例如 v-permission="'user:add'" const userStore = useUserStore(); const btnPermissions = userStore.currentRouteBtnPermissions; // 需要从当前路由元信息中获取按钮权限 // 从当前路由的 meta 中获取按钮权限列表 // 注意:需要在路由守卫或菜单生成时,将 btnPermissions 存储到当前路由的 meta 中 // 这里假设我们已经有了 currentRouteBtnPermissions if (value && Array.isArray(btnPermissions)) { const hasPermission = btnPermissions.includes(value); if (!hasPermission) { // 如果没有权限,则移除该元素 el.parentNode && el.parentNode.removeChild(el); } } else { throw new Error(`需要指定权限标识,如 v-permission="'user:add'"`); } } export default { mounted(el, binding) { checkPermission(el, binding); }, updated(el, binding) { checkPermission(el, binding); } };
/src/main.js
// ... import permissionDirective from '@/directives/permission'; const app = createApp(App); app.directive('permission', permissionDirective); // ...
5.2 在 Pinia 中提供获取当前路由按钮权限的方法
修改 /src/stores/user.js
import { useRoute } from 'vue-router'; // ... export const useUserStore = defineStore('user', () => { // ... 其他状态 ... // 计算属性:获取当前路由的按钮权限 const currentRouteBtnPermissions = computed(() => { const route = useRoute(); return route.meta.btnPermissions || []; // 从当前路由的元信息中获取 }); return { // ... 其他返回 ... currentRouteBtnPermissions }; });
5.3 在组件中使用指令/src/views/system/UserManagement.vue
<template> <div> <el-button type="primary" v-permission="'user:add'" @click="handleAdd" >新增用户</el-button> <el-button type="warning" v-permission="'user:edit'" @click="handleEdit" >编辑</el-button> <el-button type="danger" v-permission="'user:delete'" @click="handleDelete" >删除</el-button> <el-table :data="tableData"> <!-- ... --> </el-table> </div> </template>
三、注意事项与优化
- 路由组件加载: 确保
component: () => import(...)
中的路径正确,Webpack/Vite 会将这些组件打包到独立的 chunk 中实现懒加载。 - 404 路由处理: 动态添加路由后,一定要确保
router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
是最后一个添加的路由。 - 按钮权限的存储: 上述指令示例中,按钮权限是从当前路由的
meta
中获取。你需要确保在路由导航守卫或生成动态路由时,将每个路由对应的btnPermissions
正确地设置到其meta
中。 - 权限更新: 如果系统支持用户动态更改权限(如切换角色),需要在权限变更后调用
router.go(0)
刷新页面或手动重置路由状态。 - 安全性: 前端权限控制只是为了用户体验和基础防护,真正的权限校验必须在后端 API 层面严格执行。
到此这篇关于vue中动态权限控制的实现示例的文章就介绍到这了,更多相关vue 动态权限控制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!