权限管理系统笔记
本文档结合 elder 养老管理后台的完整代码,讲解「用户 → 角色 → 权限(菜单/按钮)」这一套基于 RBAC(Role-Based Access Control,基于角色的访问控制)模型的权限管理系统实现原理,覆盖从数据库表设计、后端权限查询、JWT 登录鉴权,到前端动态菜单渲染与按钮级权限控制的完整链路。
一、系统概述
本项目采用经典的 RBAC 权限模型,核心思想是:
给用户分配角色,给角色分配权限。 用户不直接与权限挂钩,而是通过「角色」这个中间层间接获得权限。
这样一来好处很明显:当一群用户的权限需要整体调整时,只需改动角色所绑定的权限,而不是逐个用户去配置。比如公司来了一批新护理主管,管理员只要把这些主管的账号都挂到「护理主管」这个角色下即可,角色配什么权限,他们就自动拥有什么权限。
权限被细分为三类(type 字段):
| type | 名称 | 含义 | 例子 |
|---|---|---|---|
| 0 | 目录 | 顶级分组,侧边栏里的一级菜单 | 「系统功能」「护理管理」 |
| 1 | 菜单 | 一个具体的功能页面 | 「用户管理」「角色管理」 |
| 2 | 按钮 | 页面里的一个操作按钮 | 新增用户、删除角色(对应 user:add、role:deleteById) |
系统最终实现两级权限控制:
- 菜单级:根据当前用户拥有的「目录 + 菜单」权限,动态渲染左侧导航栏——没有权限的页面,入口根本不会出现在菜单里。
- 按钮级:根据当前用户拥有的「按钮」权限值(如
user:add),控制页面里增删改查等按钮的可用/禁用。
二、数据库表设计(5 张核心表)
整个权限系统围绕 5 张表展开:用户、角色、权限,以及连接它们的 2 张关联表。
┌──────────┐ N N ┌──────────┐
│ user │──────────────▶│ role │
│ 用户表 │ user_role │ 角色表 │
└──────────┘ └──────────┘
┌──────────┐ N N ┌──────────────┐
│ role │──────────────▶│ permission │
│ 角色表 │ role_permission │ 权限表 │
└──────────┘ └──────────────┘
2.1 权限表 permission
对应实体 Permission.java(src/main/java/com/situ/elder/pojo/entity/Permission.java):
@Data
@EqualsAndHashCode(callSuper = false)
public class Permission implements Serializable {
/** 编号 */
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 所属上级:顶级为 0 */
@TableField("parent_id")
private Long parentId;
/** 名称 */
private String name;
/** 类型(0:目录,1:菜单,2:按钮) —— 这是权限分级的核心字段 */
private Integer type;
/** 路由地址:仅菜单类型使用,如 /role */
private String path;
/** 权限值:仅按钮类型使用,如 user:add */
@TableField("permission_value")
private String permissionValue;
/** 图标:菜单/目录显示的图标名,如 User、Crop */
private String icon;
/** 排序 */
private Integer sort;
/** 状态(1:正常,0:禁止) */
private Integer status;
/** 逻辑删除 0未删除,1已删除 */
@TableLogic
private Integer deleted;
/** 创建时间(自动填充) */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
/** 更新时间(自动填充) */
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
说明:每一条记录用
parent_id指向上一级,parent_id = 0表示顶级。这样 permission 表本身就是一棵树:目录(type=0)→ 菜单(type=1)→ 按钮(type=2)。
2.2 角色表 role
对应实体 Role.java:包含 id、name(角色名称)、code(角色编码,表单里校验只能是大写字母或下划线)、description(描述),以及逻辑删除字段和时间自动填充字段。Role 与 Permission 两个实体结构高度相似,就不再重复粘贴完整代码。
2.3 用户表 user
对应实体 User.java,包含用户的账号、密码、手机号、邮箱、头像、状态(status,0 禁用/1 正常)、逻辑删除等字段。用户是 user——角色(user_role)关联的另一端。
2.4 角色-权限关联表 role_permission
对应实体 RolePermission.java:一端关联 role_id,另一端关联 permission_id,记录「某个角色拥有哪些权限」:
@Data
@EqualsAndHashCode(callSuper = false)
public class RolePermission implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("role_id")
private Long roleId; // 角色 id
@TableField("permission_id")
private Long permissionId; // 权限 id
/** 逻辑删除 */
@TableLogic
private Integer deleted;
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
2.5 用户-角色关联表 user_role
对应实体 UserRole.java:一端 user_id,另一端 role_id,记录「某个用户拥有哪些角色」:
@Data
@EqualsAndHashCode(callSuper = false)
public class UserRole implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("role_id")
private Long roleId; // 角色 id
@TableField("user_id")
private Long userId; // 用户 id
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
三、整体数据流(先看全貌)
【管理员后台配置阶段】
权限管理页 ──▶ 维护 permission 权限树(目录/菜单/按钮)
角色管理页 ──▶ 维护角色 + 给角色勾选权限(写入 role_permission)
用户管理页 ──▶ 维护用户 + 给用户勾选角色(写入 user_role)
【普通用户登录使用阶段】
① 前端提交账号密码 POST /users/login
② 后端校验通过 → JwtUtil 生成 token 返回前端(前端存到 pinia 并持久化)
③ 前端每次请求在 Header 带 Authorization: token
④ Index.vue 挂载时 GET /users/userInfo
后端按 token 解析出用户 id
→ 三次表关联查出该用户所有权限
→ 拆成 routerList(目录+菜单,拼成树) + btnList(按钮权限值数组)
→ 前端用它动态渲染左侧菜单,并控制按钮可用性
⑤ LoginInterceptor 拦截所有接口,token 非法/过期 → 401 (后端接口层校验登录态)
关键设计:后端在接口权限层面主要保证「用户是否登录」(JWT);而「这个登录用户能不能访问某个菜单、能不能点某个按钮」相对地由前端配合菜单/按钮渲染 + 路由守卫路径级鉴权(见 5.2)共同决定。这是本项目偏重前端侧实现权限的一种典型做法(更严格的方案还会在后端每个接口上加角色判定,属于可扩展点,见第六节)。
四、后端实现详解
4.1 登录接口:生成 token(入口)
文件:UserController.java 的 login 方法
@PostMapping("/login")
public Result<String> login(@RequestBody User user) {
User dbUser = iUserService.getOne(new QueryWrapper<User>().eq("name", user.getName()));
if (dbUser == null) {
return Result.error("用户名不存在");
}
if (!dbUser.getPassword().equals(user.getPassword())) {
return Result.error("密码错误");
}
if (dbUser.getDeleted() == 1) {
return Result.error("用户已经注销");
}
if (dbUser.getStatus() == 0) {
return Result.error("用户被禁用");
}
//登录成功生成token
Map<String, Object> map = new HashMap<>();
map.put("id", dbUser.getId());
map.put("name", dbUser.getName());
String token = JwtUtil.createToken(map);
return Result.ok("登录成功", token);
}
要点:
- 按
name查库,依次校验 密码、是否注销(逻辑删除)、是否禁用(status)。 - 登录成功把用户
id、name放进 map,交给JwtUtil.createToken生成 token。 - 密码用的是明文比对(学习项目简化,生产应使用 BCrypt 加密),前端登录时直接明文提交。
4.2 JWT 工具类 JwtUtil
文件:utils/JwtUtil.java
public class JwtUtil {
private static final String KEY = "dxk"; // 签名秘钥
// 把业务数据放进 token 的 claims(有效载荷)里,用 HMAC256 签名,有效期 1 小时
public static String createToken(Map<String, Object> map) {
return JWT.create()
.withClaim("claims", map)
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60))
.sign(Algorithm.HMAC256(KEY));
}
// 验证 token 签名与有效期,并把 claims 还原为 Map;token 非法或过期这里会抛异常
public static Map<String, Object> parseToken(String token) {
return JWT.require(Algorithm.HMAC256(KEY))
.build()
.verify(token)
.getClaim("claims")
.asMap();
}
}
注意:parseToken 得到的 claims 是 Map<String,Object>,其中 id 取出来时类型是 Integer(这也和后面的 Integer id = (Integer) map.get("id") 对应上了)。
4.3 登录态拦截器 LoginInterceptor
文件:interceptor/LoginInterceptor.java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (ObjectUtils.isEmpty(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
return false;
}
try {
// token 非法、或者过期,解析都会抛出异常
JwtUtil.parseToken(token);
return true; // token 合法,放行
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
}
注册于 config/WebConfig.java(同时这里还注册了一个字符串→Date 的类型转换器,解决前端 ISO 时间字符串的转换):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截所有请求,登录与忘记密码接口例外(否则没 token 没法登录)
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/users/login", "/users/forgetPassword");
}
// ... addFormatters 处理 yyyy-MM-dd 等日期字符串转 Date
}
前端 request.js 如何配合:请求拦截器把 token 放到 Authorization 头;响应拦截器里如果后端返回 401,就清除 token 并跳回登录页:
// 请求拦截器:带上 token
request.interceptors.request.use((config) => {
const tokenStore = useTokenStore();
if (tokenStore.token) {
config.headers.Authorization = tokenStore.token;
}
return config;
});
// 响应拦截器:401 说明未登录/过期
if (error.response.status === 401) {
ElMessage.error('请先登录');
tokenStore.removeToken();
router.push('/login');
}
4.4 ★ 核心:按用户查权限 SQL(三次表关联)
有了 userId,怎么得到这个用户的所有权限?——通过 user_role → role_permission → permission 三张表 INNER JOIN。
文件:mapper/PermissionMapper.xml
<select id="selectPermissionByUserId" resultType="com.situ.elder.pojo.entity.Permission">
SELECT DISTINCT p.id,
p.parent_id,
p.name,
p.type,
p.path,
p.permission_value,
p.icon,
p.sort
FROM user_role AS ar
INNER JOIN role_permission AS rp ON ar.role_id = rp.role_id
INNER JOIN permission AS p ON rp.permission_id = p.id
WHERE ar.user_id = #{id}
AND rp.deleted = 0
AND p.deleted = 0
ORDER BY p.sort
</select>
对应 Mapper 接口 PermissionMapper.java:
public interface PermissionMapper extends BaseMapper<Permission> {
List<Permission> selectPermissionByUserId(Long id);
}
理解这条 SQL:
user_role按ar.user_id = #{id}定位用户 → 拿到该用户的所有角色 id。role_permission用ar.role_id = rp.role_id关联 → 拿到这些角色绑定过的所有权限 id。permission用rp.permission_id = p.id关联 → 还原出权限的完整字段。DISTINCT去重:考虑用户可能同时有多个角色、或一个权限被多个角色重复绑定,最终每个权限只出现一次。deleted=0过滤逻辑删除的记录。- 按
sort排序,保证菜单顺序一致。
4.5 ★ 核心:把权限拆成「路由菜单」和「按钮列表」
文件:PermissionServiceImpl.java
@Service
public class PermissionServiceImpl extends ServiceImpl<PermissionMapper, Permission> implements IPermissionService {
@Override
public Map<String, Object> selectUserPermissionInfoById(Long id) {
// 1. 根据用户 id 查出他所有的权限
List<Permission> permissionList = permissionMapper.selectPermissionByUserId(id);
// 2. 把 List<Permission> 拆分成两类:
List<String> btnList = new ArrayList<>(); // 按钮权限值集合
List<PermissionVo> permissionVoList = new ArrayList<>(); // 目录+菜单集合
permissionList.forEach(permission -> {
if (permission.getType() == 2) {
// 按钮类型:把"权限值"(如 user:add) 塞进 btnList
btnList.add(permission.getPermissionValue());
} else {
// 目录(0)或菜单(1):复制成 PermissionVo,用于后面拼树
PermissionVo permissionVo = new PermissionVo();
BeanUtils.copyProperties(permission, permissionVo);
permissionVoList.add(permissionVo);
}
});
return Map.of("routerList", buildTree(permissionVoList), "btnList", btnList);
}
// ... 其余代码见下
}
它返回一个 Map,里面有两个 key,正好对应前端 Index.vue 里需要的两份数据:
routerList:目录+菜单组成的树 → 用于渲染左侧导航。btnList:权限值数组,如["user:add","user:deleteById",...]→ 用于按钮级控制。
4.6 权限树构建 buildTree(递归)
文件:PermissionServiceImpl.java
private List<PermissionVo> buildTree(List<PermissionVo> permissionVoList) {
// 先找所有顶级(parentId == 0)
return permissionVoList.stream()
.filter(permissionVo -> permissionVo.getParentId() == 0)
.map(permissionVo -> {
permissionVo.setChildren(buildChildrenTree(permissionVo, permissionVoList));
return permissionVo;
}).toList();
}
private List<PermissionVo> buildChildrenTree(PermissionVo parentPermissionVo, List<PermissionVo> permissionVoList) {
// 递归找当前父节点的所有子节点
return permissionVoList.stream()
.filter(permissionVo -> permissionVo.getParentId().equals(parentPermissionVo.getId()))
.map(permissionVo -> {
permissionVo.setChildren(buildChildrenTree(permissionVo, permissionVoList));
return permissionVo;
}).toList();
}
buildTree找到所有parentId == 0的顶级节点。buildChildrenTree递归地为每个节点挂上它的所有子节点,直到没有子节点为止。- 用
PermissionVo extends Permission并在其上扩展了一个children字段存放子树:
@Data
public class PermissionVo extends Permission {
private List<PermissionVo> children; // 子权限
}
这样”扁平的权限列表”就被整理成了一条嵌套的 JSON 树,前端可以直接交给 el-tree / 菜单组件渲染。
同样,管理员配置权限时,selectPermissionTree() 接口也走同一个 selectPermissionTree() + buildTree(见 4.7),只是它查的是所有权限(orderByAsc(Permission::getSort)),而不限定某个用户。
4.7 完整的 userInfo 接口(返回菜单 + 按钮)
文件:UserController.java
@GetMapping("userInfo")
public Result userInfo(@RequestHeader(name = "Authorization") String token) {
// 从 token 解析出用户 id
Map<String, Object> map = JwtUtil.parseToken(token);
Integer id = (Integer) map.get("id");
// 查用户信息,密码置空(不返回给前端)
User user = iUserService.getById(id);
user.setPassword(null);
// 查该用户的权限,拆成 routerList(菜单树) + btnList(按钮数组)
Map<String, Object> PermissionMap = iPermissionService.selectUserPermissionInfoById(user.getId());
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("user", user);
resultMap.put("routerList", PermissionMap.get("routerList"));
resultMap.put("btnList", PermissionMap.get("btnList"));
return Result.ok(resultMap);
}
前端 Index.vue 拿到这个结果后,routerList → 动态菜单,btnList → 按钮控制(详见第五节)。
4.8 权限管理页后端(Permission 表 CRUD)
文件:PermissionController.java。这一层就是非常标准的 MyBatis-Plus 增删改查,注意几个接口约定:
| 接口 | 方法 | 作用 |
|---|---|---|
GET /permissions/selectPermissionTree |
selectPermissionTree() |
返回完整权限树(用于权限管理页 / 角色分配页) |
GET /permissions |
selectAll(permissionQuery) |
条件分页查询 |
GET /permissions/{id} |
selectById |
按 id 查 |
POST /permissions |
insert |
新增权限 |
PUT /permissions/{id} |
updateById |
修改权限 |
DELETE /permissions |
deleteBatch(@RequestBody Long[] ids) |
批量删除 |
DELETE /permissions/{id} |
deleteById |
单个删除 |
条件分页 list(PermissionQuery) 实现(PermissionServiceImpl):
@Override
public IPage<Permission> list(PermissionQuery permissionQuery) {
IPage<Permission> page = new Page<>(permissionQuery.getPage(), permissionQuery.getLimit());
LambdaQueryWrapper<Permission> wrapper = new LambdaQueryWrapper<>();
wrapper.like(!ObjectUtils.isEmpty(permissionQuery.getName()), Permission::getName, permissionQuery.getName())
.eq(!ObjectUtils.isEmpty(permissionQuery.getType()), Permission::getType, permissionQuery.getType())
.between(!ObjectUtils.isEmpty(permissionQuery.getBegintime()) && !ObjectUtils.isEmpty(permissionQuery.getEndtime()),
Permission::getCreateTime, permissionQuery.getBegintime(), permissionQuery.getEndtime())
.orderByDesc(Permission::getCreateTime);
return permissionMapper.selectPage(page, wrapper);
}
PermissionQuery 继承分页基类,提供 name、type、begintime、endtime 等搜索条件。
4.9 ★ 角色管理:查看/保存角色的权限(role_permission)
文件:RoleServiceImpl.java
@Override
public Map<String, Object> selectAssignedPermission(Long roleId) {
// 1. 所有权限的树形结构(给前端 el-tree 用)
List<PermissionVo> permissionVOList = permissionService.selectPermissionTree();
// 2. 查这个角色已经在 role_permission 里绑定了哪些权限 id
LambdaQueryWrapper<RolePermission> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RolePermission::getRoleId, roleId);
List<RolePermission> rolePermissionList = rolePermissionMapper.selectList(wrapper);
List<Long> assignedPermissionIdList = rolePermissionList.stream()
.map(RolePermission::getPermissionId).toList();
Map<String, Object> map = new HashMap<>();
map.put("permissionVOList", permissionVOList); // 全部权限树
map.put("assignedPermissionIdList", assignedPermissionIdList); // 已勾选的权限 id
return map;
}
@Override
public void assignPermission(Long roleId, Long[] permissionIds) {
// 1. 先删掉该角色原有的所有权限关联
rolePermissionMapper.delete(new LambdaQueryWrapper<RolePermission>().eq(RolePermission::getRoleId, roleId));
// 2. 再逐个插入新的关联
for (Long permissionId : permissionIds) {
RolePermission rolePermission = new RolePermission();
rolePermission.setRoleId(roleId);
rolePermission.setPermissionId(permissionId);
rolePermissionMapper.insert(rolePermission);
}
}
这里采用「先删后插」的策略:因为前端给的是这次勾选的完整集合,直接整表删除该角色的旧关联、再写入新集合,逻辑最简单,也天然支持取消勾选。代价是频繁整删整插,但对权限这种低频变更的场景完全够用,而且避免了复杂的两两比较。
Controller 入口 RoleController.java:
@GetMapping("/selectAssignedPermission/{roleId}")
public Result<Map<String, Object>> selectAssignedPermission(@PathVariable("roleId") Long roleId) {
return Result.ok(iRoleService.selectAssignedPermission(roleId));
}
@PostMapping("/assignPermission")
public Result assignPermission(Long roleId, Long[] permissionIds) {
iRoleService.assignPermission(roleId, permissionIds);
return Result.ok("分配完成");
}
4.10 用户分配角色(user_role)
与 4.9 完全同构,只是对象从「角色-权限」换成了「用户-角色」。文件:UserServiceImpl.java
@Override
public UserRoleVo selectAssinedRole(Long userId) {
// 1. 所有角色
List<Role> roleList = roleMapper.selectList(null);
// 2. 该用户已经分配的角色 id
LambdaQueryWrapper<UserRole> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UserRole::getUserId, userId);
List<Long> assignedRoleIdList = userRoleMapper.selectList(wrapper).stream()
.map(UserRole::getRoleId).collect(Collectors.toList());
UserRoleVo vo = new UserRoleVo();
vo.setAssignedRoleIdList(assignedRoleIdList);
vo.setRoleList(roleList);
return vo;
}
@Override
public void assignRole(Long userId, Long[] roleIds) {
// 先删该用户旧角色,再写入新的
userRoleMapper.delete(new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId));
for (Long roleId : roleIds) {
UserRole userRole = new UserRole();
userRole.setRoleId(roleId);
userRole.setUserId(userId);
userRoleMapper.insert(userRole);
}
}
UserRoleVo.java:
@Data
public class UserRoleVo {
private List<Role> roleList; // 所有角色
private List<Long> assignedRoleIdList; // 该用户已选角色 id
}
Controller 入口 UserController.java:
@GetMapping("/selectAssingnedRole/{userId}")
public Result<UserRoleVo> selectAssingnedRole(@PathVariable("userId") Long userId) {
return Result.ok(iUserService.selectAssinedRole(userId));
}
@PostMapping("/assignRole")
public Result assignRole(Long userId, Long[] roleIds) {
iUserService.assignRole(userId, roleIds);
return Result.ok("分配成功");
}
五、前端实现详解
5.1 登录保存 token
文件:views/Login.vue → 调用 userApi.login,成功后把 token 存进 pinia(持久化到 localStorage),跳转首页:
const login = () => {
userApi.login(user.value).then(result => {
if (result.code == 1) {
ElMessage.success(result.msg)
tokenStore.setToken(result.data) // 保存 token
router.push('/')
} else {
ElMessage.error(result.msg)
}
})
}
token 的 pinia store(store/token.js)开启了 persist: {enabled: true},所以刷新页面 token 不丢,登录态得以保持。
5.2 路由守卫(登录校验 + 路径级鉴权)
文件:router/index.js
import {useTokenStore} from '@/store/token.js'
import {useUserInfoStore} from '@/store/userInfo.js'
import userApi from '@/api/user.js'
import {ElMessage} from "element-plus"
let whiteList = ['/login', '/forget']; // 白名单:未登录也能访问
router.beforeEach(async (to) => {
const tokenStore = useTokenStore();
const userInfoStore = useUserInfoStore();
const token = tokenStore.token;
// 已登录访问白名单 → 跳首页
if (whiteList.includes(to.path) && token) return '/';
// 未登录访问非白名单 → 跳登录页
if (!whiteList.includes(to.path) && !token) return '/login';
// 白名单页直接放行,不参与鉴权(防止访问 /login 时被后面的鉴权拦截,形成死循环)
if (whiteList.includes(to.path)) return true;
// ——已登录进入受保护区域:确保权限已加载——
// 直接刷新 /user 时 Pinia 里可能还是空的,先从后端拉一次菜单,才能判断有无权限
if (!userInfoStore.routesLoaded) {
try {
const result = await userApi.userInfo();
if (result.code === 1) {
userInfoStore.setUser(result.data.user);
userInfoStore.setBtnList(result.data.btnList);
userInfoStore.setRouterList(result.data.routerList);
}
} catch (e) {
// 拉取失败保持空权限,未授权路径一律被拦截
}
}
// ★ 路径级鉴权:目标路由不在“允许路径集合”内 → 拦截回首页
if (!userInfoStore.allowedPaths.includes(to.path)) {
ElMessage.warning('无权访问该页面');
return '/chart'; // 首页 /chart 在 allowedPaths 里固定放行,不会二次拦截
}
});
说明:
- 路由仍然是静态注册的(
/user、/role、/permission…都在 router 里写死、Component已 import),并没有改成”按 routerList 动态生成路由、无权限即 404″。 - 但守卫里多了一层路径级鉴权——用
userInfoStore.allowedPaths(见下)判断目标路径是否在用户权限内,不在就拦截并跳回首页。这就解决了”登录后直接敲http://.../user绕过权限”的问题:即使知道 URL,没有权限也会被守卫拦回首页。 allowedPaths是 userInfo store 里的 getter,把权限树routerList扁平化成的”允许访问的路径数组”,并固定放行一些不属于权限菜单的页面(如首页/chart、个人中心/userCenter、/resetPassword),保证任何登录用户都能进这些页面:
// store/userInfo.js
getters: {
allowedPaths(state) {
const acc = ['/chart', '/userCenter', '/resetPassword']; // 非权限菜单,固定放行
const walk = (list) => {
(list || []).forEach(item => {
if (item.path) acc.push(item.path);
if (item.children?.length) walk(item.children);
});
};
walk(state.routerList);
return acc;
}
}
- 该 store 开启了
persist: { enabled: true },所以routerList/routesLoaded会持久化到 localStorage:有权限的用户刷新/user能直接进入(routesLoaded为真、跳过重新拉取)。退出登录时用resetAll()清空权限,避免换账号后沿用上一账号的权限(越权)。
5.3 ★ 核心:Index.vue 挂载时拉取用户权限 → 动态菜单 + 按钮数组
文件:views/Index.vue(布局页,/ 的根组件)
const getUserInfo = () => {
userApi.userInfo().then(result => {
if (result.code == 1) {
// 1. 用户基本信息 → pinia
UserInfoStore.setUser(result.data.user);
// 2. 目录+菜单列表 → 动态菜单数据(首页固定拼在最前,不依赖角色权限)
menuData.value = [
{name: '首页', icon: 'TrendCharts', path: '/chart'},
...result.data.routerList,
];
// 3. 按钮权限数组 → pinia
UserInfoStore.setBtnList(result.data.btnList);
// 4. 菜单树也存入 store,供路由守卫做“路径级鉴权”(见 5.2)
UserInfoStore.setRouterList(result.data.routerList);
}
})
}
getUserInfo() // 挂载后立即执行
- 动态菜单:
menuData.value被后端返回的routerList(权限树)覆盖,<el-menu>用v-for渲染它:
<el-menu ... router :collapse="isCollapse" :default-active="route.path">
<template v-for="menu in menuData" :key="menu.name">
<!-- 有子节点 → 子菜单(目录) -->
<el-sub-menu v-if="menu.children?.length > 0" :index="menu.name">
<template #title>
<component class="icons" :is="menu.icon"></component>
<span>{{ menu.name }}</span>
</template>
<el-menu-item v-for="child in menu.children" :index="child.path">
<el-icon><component :is="child.icon"></component></el-icon>
<span>{{ child.name }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 无子节点 → 普通菜单项(菜单) -->
<el-menu-item v-else :index="menu.path">
<el-icon><component :is="menu.icon"></component></el-icon>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
</el-menu>
关键点:菜单里 <component :is="menu.icon"> 中的 icon 是后端 permission 表存的图标字符串(如 User、Crop)。之所以字符串能直接解析成组件,是因为 main.js 里把所有 Element Plus 图标全局注册了:
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component); // 全局注册,可用字符串 :is 使用
}
- 按钮数组:
btnList存入 userInfo store,供hasBtnPermission使用。
5.4 ★ 核心:按钮级权限控制 btnPermission.js
文件:utils/btnPermission.js
export default function hasBtnPermission(permission) {
const userInfoStore = useUserInfoStore();
const btnList = userInfoStore.btnList; // 例如 ['user:add','user:deleteById',...]
return btnList.indexOf(permission) !== -1; // 用户拥有该权限值 → true
}
在页面里配合 :disabled 使用。例如 Permission.vue:
<el-button type="success" @click="showAddDialog()"
:disabled="!hasBtnPermission('permission:add')">添加顶级菜单</el-button>
- 当用户拥有
permission:add这个权限值时,按钮可点;否则disabled置灰不可点。 - 每个”危险/新增”按钮都绑一个权限值,就实现了按钮级权限。各页面用到的权限值举例如下:
| 权限值 | 含义 | 所在页面 |
|---|---|---|
user:add / user:deleteAll / user:update / user:deleteById / user:assignRole / user:exportExcel / user:importExcel |
用户管理的各类按钮 | User.vue |
role:add / role:deleteAll / role:update / role:deleteById / role:assignPermission |
角色管理的各类按钮 | Role.vue |
permission:add / permission:update / permission:deleteById |
权限管理的各类按钮 | Permission.vue |
userInfo 的 pinia store(store/userInfo.js)同样开启持久化:
export const useUserInfoStore = defineStore('userInfo', {
state() {
return { user: {}, btnList: [] }
},
actions: {
setUser(user) { this.user = user },
setBtnList(btnList) { this.btnList = btnList },
removeBtnList() { this.btnList = [] }
},
persist: { enabled: true }
})
由于
btnList被持久化,刷新页面后hasBtnPermission依然有效,按钮权限不会因刷新而丢失。
5.5 权限管理页 Permission.vue(维护权限树)
- 数据加载:
permissionApi.selectPermissionTree()拿整棵权限树,用el-table的row-key="id"配合树形子行展示。 - 权限类型限制:打开”添加”弹窗时,根据父节点类型限定可选的 type —— 在一个目录(type=0)下只能加菜单(type=1);在一个菜单(type=1)下只能加按钮(type=2):
const showAddDialog = (row) => {
dialogFormVisible.value = true;
permission.value = {status: 1};
title.value = "添加下级菜单";
if (row) {
permission.value.parentId = row.id;
permission.value.parentName = row.name;
if (row.type == 0) {
// 目录下 → 只能加菜单
permission.value.type = 1;
type0Disabled.value = true; // 目录被禁选
type2Disabled.value = true; // 按钮被禁选
} else if (row.type == 1) {
// 菜单下 → 只能加按钮
permission.value.type = 2;
type0Disabled.value = true;
type1Disabled.value = true;
}
} else {
// 顶级 → 只能加目录
title.value = "添加顶级菜单";
permission.value.type = 0;
permission.value.parentId = 0;
type0Disabled.value = false;
type1Disabled.value = false;
type2Disabled.value = true;
}
}
- 表单条件显示:图标只有目录/菜单才显示;「按钮权限值」只有 type=2 才显示并必填;「路由地址」只有 type=1 才显示并必填:
const validatePath = (rule, value, callback) => {
if (permission.value.type === 1 && !value) return callback(new Error('请输入路由地址'));
callback();
};
const validatePermissionValue = (rule, value, callback) => {
if (permission.value.type === 2 && !value) return callback(new Error('请输入按钮权限值'));
callback();
};
- 启用/禁用:表格里每行一个
el-switch,切换时调update接口只更新status。
5.6 ★ 角色管理页 Role.vue(角色 CRUD + 权限分配)
除了角色的增删改查(和用户/权限页套路一致),重点是分配权限弹窗,用 el-tree 勾选权限:
① 打开弹窗,回显已分配的权限:
const showAssignedPermissionDialog = (row) => {
role.value = row;
dialogPermissionVisible.value = true;
treeData.value = [];
// 一次性拿到:全部权限树 + 该角色已分配的权限 id
roleApi.selectAssignedPermission(row.id).then((result) => {
treeData.value = result.data.permissionVOList; // el-tree 数据
let checkedLeafIdList = [];
// 递归找出"该角色拥有且为叶子节点"的权限 id
getCheckedLeafIdList(result.data.permissionVOList,
result.data.assignedPermissionIdList, checkedLeafIdList);
treeRef.value.setCheckedKeys(checkedLeafIdList); // 回显勾选
});
};
const getCheckedLeafIdList = (permissionVOList, assignedPermissionIdList, checkedLeafIdList) => {
permissionVOList.forEach(permissionVO => {
assignedPermissionIdList.forEach(id => {
if (permissionVO.id == id && permissionVO.children.length == 0) {
checkedLeafIdList.push(id); // 是叶子节点 → 加入勾选
} else if (permissionVO.id == id && permissionVO.children.length != 0) {
getCheckedLeafIdList(permissionVO.children, assignedPermissionIdList, checkedLeafIdList);
}
});
});
};
为什么只勾”叶子节点”?因为
el-tree是父子联动的:父节点选中后会自动带上所有子节点。后端role_permission里通常只存最细粒度的权限。这里只回显叶子节点,避免父子同时 setChecked 造成重复/歧义。
② 保存分配的权限:
const assignPermission = () => {
// getCheckedNodes(false, true):只取被完整勾选的叶子节点
let checkedNodes = treeRef.value.getCheckedNodes(false, true);
let permissionIds = checkedNodes.map(node => node.id);
permissionIds = permissionIds.join(',');
roleApi.assignPermission(role.value.id, permissionIds).then((result) => {
ElMessage({message: result.msg, type: 'success'});
dialogPermissionVisible.value = false;
});
};
提交时把勾选的叶子权限 id 用逗号拼成字符串 "1,2,5",Spring 会自动把它解析成 Long[] permissionIds 数组传给后端 assignPermission(roleId, permissionIds)(见 4.9)。
③ 分配权限的树形控件:
<el-tree :data="treeData" ref="treeRef" show-checkbox node-key="id"
default-expand-all :props="{ children: 'children', label: 'name' }">
</el-tree>
5.7 用户管理页 User.vue(用户 CRUD + 分配角色)
与 5.6 完全对称,只是从”角色分配权限”换成”用户分配角色”(代码见 4.10)。前端:
const showAssignedRoleDialog = (row) => {
user.value = row;
// 拿到:所有角色 + 该用户已分配的角色 id
userApi.selectAssingnedRole(row.id).then((result) => {
roleList.value = result.data.roleList;
assignedRoleIdList.value = result.data.assignedRoleIdList;
dialogRoleVisible.value = true;
});
};
// 保存:把勾选的角色 id 拼成字符串提交
const assignRole = () => {
let roleIds = assignedRoleIdList.value.join(',');
userApi.assignRole(user.value.id, roleIds).then((result) => {
ElMessage.success(result.msg);
loadData();
dialogRoleVisible.value = false;
});
};
六、完整实现链路(一图流)
以”给一个新用户把系统配好,并让他登录后只能看到他能操作的东西”为例串联全部:
【配置阶段 — 管理员】
1. 权限管理页(_permission_):录入目录(系统功能)→菜单(用户管理/角色管理…)→按钮(user:add, role:deleteById…)
落库到 permission 表,parent_id 形成树。
2. 角色管理页(_role_):新增角色"护理主管";点[权限]弹出权限树,
勾选该角色需要的目录/菜单/按钮 → POST /role/assignPermission
后端"先删后插"写入 role_permission 表。
3. 用户管理页(_user_):新增用户"张三",点[角色]弹出角色勾选框,
勾选"护理主管" → POST /users/assignRole
后端"先删后插"写入 user_role 表。
至此:user_role: 张三→护理主管;role_permission: 护理主管→一堆权限
【使用阶段 — 张三】
4. 张三登录:POST /users/login → 校验通过 → 返回 JWT token
5. 前端存 token 到 pinia(localStorage),跳转首页
6. Index.vue 挂载 → GET /users/userInfo(带 Authorization: token)
后端:解析 token 拿 userId → 三表 INNER JOIN 查出所有权限
→ type=2 归入 btnList;type=0/1 构建成树 routerList → 一并返回
7. 前端:routerList 渲染左侧导航(只显示张三有权限的菜单)
btnList 存入 pinia → 各页面 hasBtnPermission() 控制按钮禁用/可用
8. 整站所有接口都经过 LoginInterceptor:token 缺失/过期 → 401
前端响应拦截器收到 401 → 清 token → 跳登录页
七、关键点与注意事项
- RBAC 核心:用户↔角色↔权限三层解耦。改权限只动
role_permission,改归属只动user_role,互不影响。
- 权限分级:
permission.type(0 目录 / 1 菜单 / 2 按钮)是前端”菜单树 / 按钮数组”分流的关键判据。
- 树形结构:
permission.parent_id自引用构成树,后端用递归buildTree组装成嵌套 JSON;PermissionVo extends Permission加了children存子树。
- 三表联查:
selectPermissionByUserId通过user_role→role_permission→permission三次 INNER JOIN,DISTINCT去重、deleted=0过滤、ORDER BY sort排序。
- 先删后插:分配权限/角色都采用”清空旧的 + 逐个插入新的”,逻辑简单、天然支持取消勾选。低频配置场景下够用。
- 只回显叶子节点:
el-tree父子联动,分配权限弹窗回显时只setCheckedKeys叶子节点 id,避免父子重复勾选。
- 按钮权限实现:
hasBtnPermission(权限值)判断btnList是否包含该值,配:disabled控制。权限值命名约定模块:动作(如user:add)。
- 图标字符串渲染:菜单
<component :is="菜单图标字符串">能生效,靠的是main.js全局注册了所有 Element Plus 图标组件。
- token 生命周期:登录生成、前端持久化、
LoginInterceptor校验(过期 1 小时)、401 时前端拦截器清 token 跳登录。
- 未做/可扩展的地方(本项目取舍):
- 登录密码为明文比对,生产应使用 BCrypt 加密(用
BCryptPasswordEncoder)。 - 前端已在路由守卫里做了路径级鉴权(见 5.2):把权限树
routerList扁平化成allowedPaths,直接敲无权限的 URL 会被拦截并跳回首页。但路由仍是静态注册,未做”按 routerList 动态生成路由 / 无权限即 404″;更彻底的方案是动态添加路由 + 后端接口级鉴权(Spring Security 或自定义注解 + AOP)。 - 后端
LoginInterceptor只验证”是否登录”,未验证”该用户是否有权调用此接口”。前端的路由守卫拦截只是一道”看不见/进不去”的体验层防线,真正的数据安全必须靠后端;如需后端强约束,可给每个接口绑定所需权限值并二次校验。
八、如果要在新项目里复刻这套权限系统
- 建表:复制
user、role、permission、user_role、role_permission五张表结构与实体类(Permission 需带parentId/type/path/permissionValue/icon/sort)。 - 后端:
- 复制
JwtUtil、LoginInterceptor、WebConfig(拦截器注册); - 复制
selectPermissionByUserId三表联查 SQL +buildTree递归拼树; - 复制角色权限(
assignPermission/selectAssignedPermission)和用户角色(assignRole/selectAssinedRole)两套”先删后插”逻辑。
- 前端:
- 复制
btnPermission.js、userInfo/token两个 pinia store(含持久化); - 在
Index.vue挂载时调userInfo接口填充menuData和btnList,菜单用el-menu动态渲染; - 页面按钮统一用
:disabled="!hasBtnPermission('xxx:add')"包裹。
- 接入新页面:在
permission表加一条目录/菜单记录(parent_id=0 目录 → 子菜单),并把需要的按钮(type=2)挂到该菜单下;角色的权限分配里勾选即可;页面组件里对应按钮写上permission:xxx权限值。
本文档基于 elder 养老管理后台的代码现场整理,核心逻辑集中在后端
PermissionServiceImpl、RoleServiceImpl、UserServiceImpl、PermissionMapper.xml,以及前端Index.vue、Role.vue、Permission.vue、btnPermission.js。如需在其它项目落地,按第八节步骤即可。