精炼版 RuoYi-Vue-Plus 前端(Vue版):从零做一套不臃肿的 RBAC 中后台前端

精炼重构 RuoYi-Vue-Plus / plus-ui 的前端实践——从业务上讲清 RBAC 权限闭环(动态路由 + 按钮指令)是怎么转起来的,再摊开一串"type-check 全绿、联调才暴露"的真实跨仓契约坑。

2026/6/3
0 分钟阅读

后台管理系统看起来都长一个样:左边一排菜单,右边一堆表格和表单。但真正难的不是画界面,而是那套**"谁能看什么、谁能做什么"**的权限系统。这篇记录我从零做一套 RBAC 中后台前端(Vue3 + Vite + TS + Pinia + Element Plus)的过程——先把权限闭环从业务上讲透,再把一路踩到的、type-check 永远抓不到的坑摊开来讲。

参考对象是开源的 RuoYi-Vue-Plus / plus-ui,但我刻意定了一条原则:学它的架构思想,拒绝它为了极致通用而对 Element Plus 做的二三层深度封装。业务页就直接用原生 el-table / el-dialog / el-form 写,一眼能看清数据流向。可读性,比"少写两行"重要。

项目地址

github:https://github.com/oinsist/oinsist-frontend-vue

gitee:https://gitee.com/o_insist/oinsist-frontend-vue

一、这是个什么东西

本身不是某个具体业务(不是商城、不是 CRM),而是任何后台系统都要先搭好的那层地基:登录、权限、菜单、日志。把业务模块往里加就行。所以衡量它的标准不是"功能多",而是"那套权限地基稳不稳、干不干净"。

二、理解全局的钥匙:RBAC 权限三角

整个项目 80% 的复杂度都在 RBAC 这一件事上。三个主角、两张关联表:

用户(谁登录) ──sys_user_role──► 角色(什么身份) ──sys_role_menu──► 菜单/权限(能看能做什么)
  • 用户不直接绑权限,而是绑角色("给用户分配角色",写 sys_user_role);
  • 角色菜单("给角色分配菜单",写 sys_role_menu);
  • 菜单分三类:目录(M,侧边栏分组)、菜单(C,能进的页面)、按钮(F,页面里的操作权限标识)。

所以一个人能干什么,是 用户 → 他的角色 → 角色拥有的菜单 一层层推出来的。用两个真实账号对照最直观:

admin(超级管理员)test(普通角色)
拥有菜单全部 + 通配权限 *:*:*[系统管理、用户管理、用户查询]
侧边栏系统管理 ▸ 用户/角色/菜单/操作日志/登录日志(5 项)系统管理 ▸ 只有"用户管理"(1 项)
用户管理页增/删/改/查 全可见只能查询,增删改按钮全被隐藏
角色/菜单/日志页能进地址都进不去(路由根本没注册)

维护顺序上的因果也很清楚:先有菜单(定义有哪些权限)→ 再有角色(打包成身份)→ 再有用户(把身份发给人)。这就是为什么"菜单管理"是整个权限体系的源头。

三、两层权限:路由管页面,指令管按钮

登录后,路由守卫会先向后端要两样东西,再决定你能看到什么:

①  POST /auth/login            → 拿到 token(存进 localStorage + Pinia)
②  GET  /auth/userInfo         → 我是谁、有哪些角色和权限标识
    GET  /auth/routers          → 我有权限的菜单树(后端按 角色→sys_role_menu→sys_menu 算好)
③  前端把菜单树「翻译」成 Vue 路由,逐条 addRoute 注册
④  Sidebar 读这棵树渲染左侧菜单
⑤  落地首页。此后能进的页面 = 第②步那棵树,多一个都进不去

**菜单级权限(动态路由)**的核心是把后端下发的字符串组件名映射成真实组件:

const views = import.meta.glob('/src/views/**/*.vue')

function resolveComponent(component: string) {
  if (component === 'Layout') return Layout          // 目录 → 布局壳
  const view = views[`/src/views/${component}.vue`]  // "system/user/index" → 真实页面
  if (!view) { console.warn('missing view:', component); return NotFound } // 缺失降级,不崩整树
  return view
}

注意"我能看哪些菜单"这个判断是在后端做的(查 sys_role_menu),前端只负责把结果渲染成菜单 + 注册成路由。

**按钮级权限(自定义指令)**则管页面内的操作。v-hasPermi 在按钮挂载时读当前用户权限集,没有就把 DOM 删掉,超管 *:*:* 放行:

const allowed = permissions.includes('*:*:*') || expected.some(p => permissions.includes(p))
if (!allowed) el.parentNode?.removeChild(el)

而你每点一次"删除",后端删数据的同时被 AOP 切面写进操作日志;每次登录写进登录日志——闭环的最后一环是审计留痕。

一句话总结:登录时后端算出你的可见范围,前端用"动态路由"管页面、"v-hasPermi"管按钮,操作再被日志记下来。

四、真正的坑:type-check 全绿,联调才崩

下面这些才是这篇最想留下的东西——共同点是编译期一切正常,连到真后端才暴露

1. 裸 token:Bearer 前缀不一致

拦截器一开始写的是 Authorization: Bearer ${token},登录能成功,但后续接口全部 401,表现成"登录后立刻被踢回登录页"。原因是后端 Sa-Token 的 token-prefix 没启用,它期望裸 token。改成 Authorization: ${token} 就好。教训:鉴权头格式必须和后端实际配置对齐,不能想当然。

2. 雪花 ID 的 JS 精度丢失

后端主键是雪花算法 Long(19 位,约 1.9×10¹⁸),远超 JavaScript Number.MAX_SAFE_INTEGER9×10¹⁵)。直接当数字收,JSON.parse静默丢精度,导致"查到的 ID 回传后命中错误记录"。

正确做法是后端对超出安全范围的 Long 序列化成字符串,前端所有 ID 字段一律用 string,只当不透明标识透传(做 key、拼 URL),绝不参与数值运算。坑在于:id=1 这种小值后端仍按数字下发,同一列表里 ID 可能既是数字又是字符串,前端拿到要统一 String() 归一化——这点在树形多选预勾选时尤其致命(见下)。

3. /auth/routers 叶子节点 children 为 null,崩整棵树

后端构建菜单树时只在有子节点时才 setChildren,叶子菜单的 children 是 Java null,Jackson 默认输出 "children": null。前端转换器里 route.children.map(...)null 直接 TypeError整棵动态路由树生成失败、白屏。

而前端类型我声明的是 children: RouterVo[](非空),TS 以为永远是数组,所以 type-checkbuild 全绿。修复就两处:类型改 children?: RouterVo[] | null,转换器 (route.children ?? []).map(...)。这个坑让我牢牢记住:前后端的 nullability 差异,类型检查抓不到,必须连后端跑。

4. el-tree 多选回显:node-key 类型不匹配

做"给角色分配菜单"时用 el-tree 勾选。后端返回的已分配 menuIds[1, 100, 1000](数字),而 el-treenode-key 来自 menuId。一边数字 100、一边 setCheckedKeys(["100"]) 字符串,=== 比不上,预勾选直接失效

解法还是归一化:整棵树的 menuId 递归 String()setCheckedKeys 入参也 .map(String)。另外这里选了 check-strictly(父子独立勾选),避免 RuoYi 那套"提交要带半选父节点否则菜单树断裂"的复杂逻辑——精炼优先。

5. 首次深链 404 与 el-radio 的 API 变更

  • 首次深链:直接访问 /system/user,此时动态路由还没 addRoute,会先命中 catch-all 404。守卫里补完 addRoute 后不能 next({ ...to })to.name 已是 NotFound,带回去继续 404),要 next({ path: to.path, query, hash, replace: true }) 只保留地址重新匹配。
  • el-radio:Element Plus 2.6+ 起单选值要用 value 而非 label<el-radio label="0"> 会触发废弃告警甚至绑定失效。这种只有运行时控制台才看得到,构建不报。

五、工程化收尾

最后做了打包优化:manualChunkselement-plusvue 全家桶拆成独立 chunk(独立缓存),把原来 1MB+ 的单体包拆开;用 Vite 的 css.preprocessorOptions.scss.api = 'modern-compiler' 消掉满屏的 Sass legacy-js-api 弃用告警。构建从一堆 warning 到干净通过。

小结

这套前端最终覆盖了:登录认证闭环 → 双层权限(动态路由 + 指令)→ 用户/角色/菜单三件套及相互分配 → 操作/登录日志审计 → 打包优化。本质是一套"做好了权限和审计地基、可以往上加业务"的中后台脚手架。

但真正让我有收获的不是"又写了几个 CRUD 页",而是那串跨仓契约坑。它们反复印证同一件事:

前端的类型系统只能保证"前端代码自洽",它对后端实际返回的形状一无所知。type-check 绿 ≠ 功能对。涉及前后端契约的部分,必须连真后端、走真数据验一遍。

这也是我后来给每个阶段都加一道"连后端真机验收"的原因——很多 bug,只有在浏览器里点下去、看 Network 面板里那条真实响应时,才会现形。