SpringBoot3+Vue3角色管理权限树回显与分配实战
做后台管理系统SpringBoot3Vue3这套技术栈现在用得最多。等项目走到角色管理这块时很多新手卡住的不是简单的CRUD而是一整天纠结于角色的权限树怎么回显、保存权限要选哪些节点、菜单里有按钮到底要不要给角色分配。这篇文章按后台管理系统系列实战的第21节来处理目标很明确——用SpringBoot3和Vue3从零实现一个能落地的角色管理界面覆盖数据库设计、后端接口、前端页面和权限树分配。适合正在学习前后端分离开发、想系统做权限系统的读者有一定基础再看收获会更大。这里面的内容我都在真实项目里跑通过不是只贴一段能运行的最小代码就结束。网上很多教程只给你一个角色表格和保存按钮等联调才发现Long类型ID会被前端精度丢失、el-tree回显勾选不完整、刷新页面之后导航菜单消失。这篇文章想把坑提前埋平。实际开发里“角色管理”不太可能独立存在它通常跟着用户管理、菜单管理和动态路由一起出现所以我会把权限分配的细节讲透但不会扯一堆用不上的框架设计。1. 动手前先理清角色模型这不是简单的一张CRUD表1.1 角色在RBAC体系里面的位置权限设计里用得最普遍的就是RBAC模型也就是用户、角色、权限三者之间的关系。用户不直接绑定菜单权限而是给用户赋予角色角色再去关联一批菜单/按钮权限。这样做的好处很明显新增一个部门主管时不需要一条条配权限直接赋予“主管”角色即可改权限时也只需要调整角色对应的菜单不会影响其他用户的数据。角色表就是RBAC模型的中间枢纽。你在做角色管理的时候实际上是在维护一个“可以被赋予给用户”的权限集合。比如一个班级管理系统里“教师”这个角色能看学生列表、录入成绩“学生”这个角色只能看自己的成绩单。这种业务需求最终都会落到一张sys_role表里再通过sys_user_role关联用户通过sys_role_menu关联菜单权限。所以在编码之前不要急着写页面。先把角色管理的边界搞清楚角色有哪些字段、角色和权限怎么关联、停用角色之后用户要不要立刻失去对应功能、删除角色时如果该角色下还有用户怎么办。这些问题不提前定后面每改一次表结构前后端的代码都得跟着动。1.2 sys_role表字段设计角色表本身通常不会太复杂但字段命名和约束会影响后续编码。下面这个是我在项目里常用的角色表结构适合大多数后台管理系统的需求。CREATE TABLE sys_role ( id bigint NOT NULL AUTO_INCREMENT COMMENT 角色ID, role_name varchar(50) NOT NULL COMMENT 角色名称, role_code varchar(100) NOT NULL COMMENT 角色编码, sort int NOT NULL DEFAULT 0 COMMENT 显示顺序, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0正常 1停用, remark varchar(500) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, PRIMARY KEY (id), UNIQUE KEY uk_role_code (role_code, deleted) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色表;这里有两个细节值得注意。第一个是role_code也就是角色的权限字符编码。在真实项目中我们通常会用PreAuthorize(hasRole(admin))这种注解或者前端v-permission指令判断权限代码里判断的是role_code而不是角色名称。因为角色名称是可以随便改的比如前端今天叫“管理员”明天领导要求改成“超级管理员”如果代码里写死的是名称那明天就要改代码。而role_code一般约定用英文字母如admin、teacher、student代码里稳定使用。第二个是逻辑删除字段deleted要参与联合唯一索引。当初我以为给role_code加普通唯一索引就够了结果遇到一个问题角色A删掉之后再新建一个同样编码的角色时会被数据库挡住因为删掉的记录还在表里。逻辑删除场景下唯一索引判断数据是否有效必须同时看deleted字段这也是我在实际开发中踩过的小坑。1.3 角色与菜单、角色的关联关系角色访问菜单权限需要一张关联表设计如下CREATE TABLE sys_role_menu ( role_id bigint NOT NULL COMMENT 角色ID, menu_id bigint NOT NULL COMMENT 菜单ID, PRIMARY KEY (role_id, menu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色菜单关联表;这张表不需要单独的id字段用role_id和menu_id做联合主键就够了。主键本身就是一种约束可以防止同一条关系被重复插入。用户和角色的关联表sys_user_role结构也类似。菜单表sys_menu是实现权限分配树的数据来源。菜单表的关键字段包括id、parent_id、menu_name、menu_type目录/菜单/按钮、perms、path、component、icon、sort、visible、status。这里的parent_id让菜单变成一棵树root节点的parent_id为0menu_type区分目录、菜单和按钮三类节点因为这三种节点在权限树里的展示逻辑不同目录和菜单用于前端路由按钮用于操作权限判断。角色管理页面上的“分配权限”操作本质上是修改sys_role_menu这张关联表。理解了这三个表关系后面无论是写后端还是画前端树组件都只是搬运数据而已。2. SpringBoot3后端搭接口依赖、代码结构和权限树返回2.1 JDK17、MyBatis-Plus 3.5依赖版本怎么选SpringBoot3和之前的SpringBoot2最大的区别是Java版本基线提到了JDK17并且javax包迁移到了jakarta包。如果照抄网上老项目模板经常会在启动时报包名错误。用SpringBoot3时建议直接选3.2.x以上版本稳定一点。MyBatis-Plus也不能继续用mybatis-plus-boot-starter得用适配Boot3的mybatis-plus-spring-boot3-starter这一点非常容易踩坑。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyapplication.yml里需要配置数据源、逻辑删除、Jackson时间序列化格式。MyBatis-Plus的logic-delete-field要指向实体的逻辑删除字段名logic-delete-value设为1logic-not-delete-value设为0。如果漏掉这段配置逻辑删除就不生效删除操作会变成物理删除。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/role_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 02.2 统一定义返回体后端接口的请求响应写接口前建议先做统一返回体。没有统一返回体时Controller里有人返回Map有人直接返回实体前端axios拦截器处理起来要写一堆分支。定一个简单的Result类就够了结构为code、message、data三个字段200表示成功500表示失败。前端axios拦截器可以只关心code非200直接报错提示。我习惯用BFF层常用的做法建一个BizException异常类业务校验不通过时直接throw。全局异常处理器捕获后把异常信息转成友好的message返回。这样角色编码重复、角色已经被用户占用、菜单参数为空这类错误不需要在Controller里到处写try-catch。2.3 角色分页、新建、编辑、删除的完整实现角色管理的后端接口主要有分页查询、新增、修改、删除、权限树查询、保存权限分配。先看实体类Data TableName(sys_role) public class SysRole { TableId(type IdType.AUTO) private Long id; private String roleName; private String roleCode; private Integer sort; private Integer status; private String remark; TableLogic private Integer deleted; }Controller层定义接口路径尽量用RESTful风格和前端约定好后不要随意变动。分页接口字段我用pageNum和pageSize参数传关键词roleName做模糊搜索。RestController RequestMapping(/api/role) public class SysRoleController { Resource private SysRoleService roleService; GetMapping(/page) public ResultIPageSysRole page(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, String roleName) { return Result.ok(roleService.pageRoles(pageNum, pageSize, roleName)); } PostMapping public ResultVoid add(RequestBody SysRole role) { roleService.addRole(role); return Result.ok(); } PutMapping(/{id}) public ResultVoid update(PathVariable Long id, RequestBody SysRole role) { role.setId(id); roleService.updateRole(role); return Result.ok(); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { roleService.deleteRole(id); return Result.ok(); } }Service实现中分页查询其实就是用MyBatis-Plus的LambdaQueryWrapper包一下like条件。新增和编辑共用一个校验方法角色名称不能为空角色编码不能为空。编码是否重复需要通过id ! null判断排除自己否则编辑时如果没改编码会把自己的记录当作重复项拦截掉。删除角色时一定要检查sys_user_role关联表里是否有用户还在使用该角色。如果直接删就会出现用户拥有一个不存在的角色的脏数据后续该用户登录时权限拿不到菜单渲染异常。删除角色的逻辑是先按roleId查sys_user_role有记录就抛BizException提示“该角色已分配给用户请先解除关联”没有用户关联时逻辑删除sys_role记录同时删除sys_role_menu里的关联数据避免残留权限数据。整个删除角色流程需要加Transactional防止删到一半出错。2.4 菜单树和角色勾选ID的后端返回前端权限分配弹窗需要两类数据所有菜单组成的树形结构、当前角色已勾选的菜单ID集合。这两个接口我通常会合并成一个接口返回前端打开弹窗时只需要请求一次。GetMapping(/{roleId}/menu-tree) public ResultRoleMenuTreeVO menuTree(PathVariable Long roleId) { return Result.ok(roleService.getRoleMenuTree(roleId)); }菜单树结构是典型的三层模型目录-菜单-按钮。数据库查出来的menus是平铺的List后端需要把它组装成树。组装逻辑不复杂遍历一次List把每一条记录的children做成一个List再根据parent_id将节点挂到对应父节点下面。public ListMenuVO buildMenuTree(ListMenu menuList) { ListMenuVO tree new ArrayList(); MapLong, MenuVO map new HashMap(); for (Menu menu : menuList) { MenuVO vo new MenuVO(); BeanUtils.copyProperties(menu, vo); vo.setChildren(new ArrayList()); map.put(vo.getId(), vo); } for (MenuVO vo : map.values()) { if (vo.getParentId() ! null vo.getParentId() ! 0 map.containsKey(vo.getParentId())) { map.get(vo.getParentId()).getChildren().add(vo); } else { tree.add(vo); } } return tree; }“节点排序”可以由Sort字段在构造children时排一次序保证树中菜单顺序稳定。第二份数据是当前角色已经勾选的menuId集合ListLong checkedMenuIds roleMenuMapper.selectList( new LambdaQueryWrapperSysRoleMenu().eq(SysRoleMenu::getRoleId, roleId) ).stream().map(SysRoleMenu::getMenuId).collect(Collectors.toList());2.5 保存角色权限时要“先删后插”保存分配权限时前端传过来一个数组menuIds内容是弹窗里勾选的所有节点。后端不推荐去做“哪些菜单新增、哪些菜单取消”的增量对比直接先删除该角色在sys_role_menu里的全部记录再批量插入前端传回来的新数组逻辑简单且不容易出错。核心代码如下Transactional(rollbackFor Exception.class) public void saveRoleMenus(Long roleId, ListLong menuIds) { roleMenuMapper.delete(new LambdaQueryWrapperSysRoleMenu() .eq(SysRoleMenu::getRoleId, roleId)); if (CollUtil.isEmpty(menuIds)) { return; } ListSysRoleMenu list menuIds.stream().map(menuId - { SysRoleMenu roleMenu new SysRoleMenu(); roleMenu.setRoleId(roleId); roleMenu.setMenuId(menuId); return roleMenu; }).collect(Collectors.toList()); roleMenuMapper.insertBatch(list); }delete和insert都加到同一个事务里。如果没有事务中间某一条插入失败旧权限已经被清空新权限没有写入角色就变成“空权限”用户刷新后菜单全丢了。这种问题还不是报错而是权限数据丢了排查很麻烦。这里要说明一点如果在sys_role_menu里保存的menuIds包含了“父级目录”和“子菜单”前端做动态菜单渲染时更好处理。有的教程建议只存叶子节点但叶子节点一般是按钮这样目录和菜单的父子层级就丢了。保存前要不要过滤父节点需要看权限判定逻辑。业务上菜单树层级不能丢所以我建议前端把父子节点一起提交权限过滤时只要能判断用户是否拥有指定菜单id即可多存父节点不会造成多余权限。3. Vue3前端按业务拆组织axios封装与页面代码3.1 Vite创建Vue3项目和功能目录前端工程创建我直接用Vite命令是npm create vitelatest role-admin -- --template vue-ts。Vue3的推荐写法是script setup语法单文件组件里可以少写一层export default逻辑集中模板里也不需要再注册组件。这个写法已经成了Vue3的默认约定和Vue2时期的Options API风格差别很大。目录结构建议按业务功能拆分src ├── api │ ├── http.ts │ └── role.ts ├── views │ └── system │ └── role │ ├── index.vue │ └── assign.vue └── types └── role.ts有人会纠结角色管理的页面放在views/system/role下还是直接在views下面建role文件夹。我建议按“模块功能”的方式组织比如system表示系统管理role表示角色管理模块。以后系统管理下还要加菜单管理、用户管理目录结构能保持整齐。前端状态管理我引入了Pinia保存用户信息、角色信息和当前用户菜单。角色管理页面本身可以不依赖store但登录获取用户角色、动态路由注入需要全局状态所以还是建议项目初期就把Pinia搭好。3.2 axios与接口请求层的封装axios封装网上版本很多但有的封装过度复杂层数太多反而不利于团队协作。我从项目角度给出一个简洁版本核心是处理好三件事请求头里带token、放进统一baseURL、响应拦截器统一解包。import axios from axios import { ElMessage } from element-plus const http axios.create({ baseURL: /api, timeout: 10000 }) http.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) http.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default http这样封装之后接口文件里每个函数更干净。比如角色接口文件role.tsimport http from ./http export function fetchRolePage(params: any) { return http.get(/role/page, { params }) } export function createRole(data: any) { return http.post(/role, data) } export function updateRole(id: number, data: any) { return http.put(/role/${id}, data) } export function deleteRole(id: number) { return http.delete(/role/${id}) } export function fetchRoleMenuTree(roleId: number) { return http.get(/role/${roleId}/menu-tree) } export function saveRoleMenus(roleId: number, menuIds: number[]) { return http.post(/role/${roleId}/menus, { menuIds }) }Typescript接口类型不要省。角色记录类型定义出来后后面写表格和表单时自动补全会舒服很多查字段错误也能提前发现。类型是帮助项目长期维护的手段不是增加代码量。3.3 角色列表页的模板和逻辑角色列表页的模板我通常由四块组成顶部筛选区、工具栏新增按钮、表格区、底部分页器。template el-card el-form inline el-form-item label角色名称 el-input v-modelqueryParams.roleName placeholder请输入角色名称 clearable / /el-form-item el-form-item el-button typeprimary clickhandleQuery查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form div stylemargin-bottom: 12px el-button typeprimary clickopenDialog()新增角色/el-button /div el-table v-loadingloading :dataroleList row-keyid border el-table-column label角色名称 proproleName min-width140 / el-table-column label权限字符 proproleCode min-width140 / el-table-column label排序 propsort width80 aligncenter / el-table-column label状态 width100 aligncenter template #default{ row } el-switch :model-valuerow.status 0 changehandleStatusChange(row) / /template /el-table-column el-table-column label备注 propremark min-width160 show-overflow-tooltip / el-table-column label创建时间 propcreateTime width180 / el-table-column label操作 width240 fixedright template #default{ row } el-button link typeprimary clickopenAssign(row)分配权限/el-button el-button link typeprimary clickopenDialog(row)编辑/el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryParams.pageNum v-model:page-sizequeryParams.pageSize :totaltotal layouttotal, prev, pager, next changeloadRoleList / /el-card /template在script setup里通过ref和reactive定义数据。需要注意Element Plus的el-switch组件我使用的是model-value配合change事件而不是直接v-model绑定row.status原因是状态切换后不能直接改表格里数据必须等后端返回成功才更新否则接口报错后UI已经变掉了界面状态和数据库不一致。正确做法是在change事件里拿row去调后端接口后端成功后手动更新row.status。async function handleStatusChange(row: any) { const newStatus row.status 0 ? 1 : 0 await updateRole(row.id, { ...row, status: newStatus }) row.status newStatus }删除角色操作前用ElMessageBox.confirm再次确认可以防止误删。删除成功之后如果当前页没数据了pageNum要回退一页否则用户看到的就是空白数据。3.4 新增/编辑弹窗与校验细节新增和编辑共用一个Dialog弹窗组件打开时判断是否有row参数。有值就回填表单没有值就把表单重置为空。打开窗口后禁用一些不该编辑的字段比如角色编码在编辑时是否可以修改这个问题建议在需求阶段就敲定避免后续返工。我一般允许修改但需要在后端做唯一性校验。表单校验规则const rules { roleName: [{ required: true, message: 请输入角色名称, trigger: blur }], roleCode: [ { required: true, message: 请输入角色编码, trigger: blur }, { pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: 角色编码必须以字母开头且只能包含字母、数字、下划线, trigger: blur } ] }保存按钮点击时先调用FormInstance的validate方法全部校验通过后再调接口。这里有个提升体验的细节提交期间给按钮加loading状态防止用户重复点击。loading要绑一个ref接口成功和失败都要记得重置。async function handleSave() { const valid await formRef.value?.validate().catch(() false) if (!valid) return submitting.value true try { if (form.id) { await updateRole(form.id, form) } else { await createRole(form) } emit(success) } finally { submitting.value false } }用try-finally而不是在成功回调里改status能确保接口报错时loading也会被关掉。这个小细节写代码时很容易漏导致页面按钮一直转圈只能用F5解决。4. 权限分配弹窗树组件勾选、回显、半选状态的完整实践4.1 el-tree引入和数据加载时机角色管理页最复杂的交互就是权限分配弹窗。弹窗里用Element Plus的el-tree组件展示菜单权限树节点数据结构直接使用后端返回的菜单树。el-drawer v-modelassignVisible title分配权限 size520px el-tree refmenuTreeRef :datamenuTree node-keyid show-checkbox default-expand-all :props{ label: menuName, children: children } / template #footer el-button clickassignVisible false取消/el-button el-button typeprimary clicksubmitAssign确定/el-button /template /el-drawer树组件必须在弹窗打开后再渲染。如果把树放在Dialog里使用v-if控制加载时机那么关闭再打开时树会重新创建已勾选状态清空逻辑比较干净。不建议把树数据常驻在内存里否则角色A看完权限再打开角色B还要手动清空上一次的勾选。最简单可靠的方式是每次打开抽屉都重新请求权限树接口。打开抽屉的函数逻辑为先设置当前角色id和名称再拉取菜单树接口在拿到数据后通过树组件实例的setCheckedKeys方法回显勾选。4.2 回显前必须等树挂载完成回显是多数新手最容易出错的地方。el-tree的ref实例引用必须等DOM渲染完成后才能调用setCheckedKeys。如果请求菜单树期间树还没渲染出来或者弹窗刚打开就把treeRef实例拿来调用都会出现“Cannot read properties of undefined”或回显失败。所以我在拿到菜单树数据后不会立刻调用setCheckedKeys而是把状态更新放到Vue的nextTick里确保el-tree已经根据menuTree渲染出全部节点后再回显。下面这段逻辑是实际项目里的标准写法async function openAssign(row) { currentRoleId.value row.id assignVisible.value true const res await fetchRoleMenuTree(row.id) menuTree.value res.menus nextTick(() { menuTreeRef.value?.setCheckedKeys(res.checkedMenuIds) }) }如果菜单节点非常多可以不用default-expand-all属性先只展开第一层等用户展开节点后再去对应勾选。不过做系统管理后台时菜单数量通常能控制在两百条内default-expand-all体验更直观。4.3 父子联动导致半选父节点要一起传回去菜单树默认是父子级联勾选也就是说勾选了一个子菜单父目录会自动变成半选状态取消某一个子菜单时如果还有其他子菜单勾选父菜单依旧半选只有全部子菜单勾选或全部取消时父菜单才会变成全选或未选。问题在于el-tree的getCheckedKeys默认只返回完整勾选的节点半选的父节点不包含在内。假如角色勾选了“学生管理”下的“新增学生”按钮菜单数据结构是系统管理(M) ├── 学生管理(C) │ ├── 学生列表(C) │ │ └── 新增学生(F)这个时候“系统管理”和“学生管理”可能都是半选状态getCheckedKeys只会返回“新增学生”的这个菜单id。如果后端只按这个id去给角色授权之后前端渲染动态菜单时会发现角色只有“新增学生”的权限码却没有父级菜单节点导航菜单的层级路由构建不出来页面上看不到任何入口。所以保存时必须同时取出全选节点和半选节点合并成一个数组传给后端function submitAssign() { const checkedKeys menuTreeRef.value.getCheckedKeys() as number[] const halfCheckedKeys menuTreeRef.value.getHalfCheckedKeys() as number[] const allKeys Array.from(new Set([...checkedKeys, ...halfCheckedKeys])) await saveRoleMenus(currentRoleId.value, allKeys) ElMessage.success(权限分配成功) assignVisible.value false }因为用了父子联动父节点即便保存进sys_role_menu也不会导致权限失控。后端在做操作权限判断时是按某个按钮或菜单权限标识去匹配父节点只是用于动态路由和菜单展示。这是我在项目里试过的最稳妥方案。4.4 用computed做按钮级隐藏角色管理页面上可以加一个“当前选中节点数”的提示让操作者知道自己勾了多少个权限点。这个时候Vue3的computed很合适。它依赖ref或reactive数据在树勾选变化后自动更新已被选中的节点数量。相比每次在change事件里手动更新一个普通变量computed不需要手工维护也不容易漏更新。const checkedCount computed(() { if (!menuTreeRef.value) return 0 const checked menuTreeRef.value.getCheckedKeys() const halfChecked menuTreeRef.value.getHalfCheckedKeys() return checked.length halfChecked.length })菜单栏按钮级权限也能放进computed。比如页面里如果当前用户没有“system:role:delete”这个权限点“删除”按钮就不渲染。把执行权限判断的逻辑封装成computed函数相比模板里直接写方法调用可以依赖缓存反复渲染时性能更好。这类判断在Vue3里是高频场景掌握computed用法是必要的。5. 联调和上线阶段容易踩的坑5.1 雪花ID在前端会丢精度我最初做角色管理时数据库主键用的是自动递增的bigint还碰不到精度问题。后来项目切到MyBatis-Plus的雪花算法生成IDid直接变成一串19位数字。ID传到前端后JavaScript的Number只能安全表示16位以内的整数19位已经超出安全范围结果就是编辑角色时提交的id和后端实际id不一样接口返回“记录不存在”。解决办法很直接让后端的Long类型主键序列化成字符串返回。在Jackson配置里全局处理一次比给每个字段加JsonSerialize注解省事得多spring: jackson: generator: write-numbers-as-strings: true或者在后端配置一个Jackson自定义ObjectMapper注册ToStringSerializer。否则不单是角色管理的id用户表、菜单表这些有主键的数据都会受影响。5.2 本地联调代理和线上部署的URL问题前后端分离开发时Vue开发服务器在5173端口后端接口在8080端口。如果不配置代理直接请求浏览器会出现跨域错误。现在的开发习惯是前端通过Vite的proxy把/api开头的请求代理到后端地址而不是在后端写CorsFilter通配所有来源。vite.config.ts里可以这样配server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8080, changeOrigin: true } } }线上部署时通常把前端打包后的dist目录交给Nginx由Nginx做静态文件服务和/api反向代理。只要前端所有请求路径都带/api前缀后端Controller的RequestMapping也保持/api前缀Nginx只需要一行location /api { proxy_pass http://后端地址; }即可。不要把后端接口地址硬编码写在前端代码里不然每次换环境都要重新打包。5.3 登录后刷新页面动态菜单丢失动态菜单权限做完之后会遇到一个很奇怪的现象登录成功后菜单正常一旦按F5刷新浏览器菜单就消失了。原因是Vue Router的路由分为静态路由和动态路由登录后我们根据角色返回的菜单权限调用了router.addRoute动态注册页面路由但刷新时整个前端重新加载动态路由没有被注册store里的菜单数据也被清空了。解决思路是在应用初始化时先调一个“获取当前登录用户信息”的接口拿到用户角色和权限菜单后重新addRoute再放行页面跳转。前端路由守卫里需要await这个初始化过程确保动态路由已经注册成功。这个和角色管理本身是上下游关系做完角色分配后刷新丢菜单的问题一定会遇到提前知道能省很多排查时间。5.4 排查问题速查表这里把角色管理开发中经常出现的问题整理成速查表遇到类似现象可以直接对照定位。现象可能原因解决建议同一个角色编码能插入多条逻辑删除字段没参与唯一索引建联合唯一索引(role_code, deleted)删除角色报外键错误用户关联表里仍有该角色分配记录删除前查sys_user_role并提示先解除关联权限树勾选后刷新被还原保存时只提交了全选节点没有提交半选父节点合并getCheckedKeys和getHalfCheckedKeys后保存树节点回显不完整树还没渲染完就调用setCheckedKeys在nextTick中执行setCheckedKeys编辑角色时提示编码重复唯一性校验没有排除当前记录校验时追加id ! currentId条件表格中id显示异常Long型雪花ID超过JS安全数字范围Jackson把Long序列化为字符串角色停用后还看得到菜单后端菜单查询过滤器没根据status过滤角色状态为停用时过滤掉该角色关联菜单5.5 保存权限后的即时生效策略保存权限后已经登录并持有旧权限的用户要不要立刻生效这属于产品策略问题。如果系统权限敏感度高可以在登录用户每次请求接口时动态查数据库权限如果为了性能可以把权限缓存到Redis并在角色权限变更后删除对应用户的缓存key让用户下一次请求时重新加载。角色管理界面本身不需要关心这部分但做权限系统就会碰到。我的建议是在角色管理页面保存权限成功后除了提示“保存成功”之外不要过多替后端做数据清理操作。REST接口的职责是保存角色菜单关联关系缓存清理应该通过事件或消息机制由后端处理。这样即便将来加了缓存前端代码不需要改动。最后再分享一个我自己做角色管理时的实际体会前端页面写得再顺手如果从一开始没把菜单树数据结构和后端返回格式对齐后面一定会反复返工。最稳妥的方式是前后端先定一条“菜单树VO”契约目录、菜单、按钮都统一用children字段递归嵌套。只要这层数据结构稳定角色列表和权限分配也只是对同一份数据的不同操作视角。