
一、核心原理与流程总览
动态权限控制的本质是:用户登录后,从后端获取其权限数据,前端根据此数据动态地构建出只属于该用户的可访问路由和菜单,并在视图层面(按钮)进行权限控制。
整个流程可以分为以下几个核心步骤,下图清晰地展示了其工作原理和闭环流程:
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]
(动态菜单组件)] 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):
通常,后端会返回一个树形结构,包含前端定义的路由和权限点。
json
{
"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
javascript
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
javascript
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
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
(在原有代码上追加)
javascript
// ... 之前的导入和路由初始化代码 ...
// 路由守卫
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
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
(递归组件)
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
javascript
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
javascript
// ...
import permissionDirective from '@/directives/permission';
const app = createApp(App);
app.directive('permission', permissionDirective);
// ...
5.2 在 Pinia 中提供获取当前路由按钮权限的方法
修改 /src/stores/user.js
javascript
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
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 层面严格执行。