接手过几套基于若依的中后台之后,我发现一个挺普遍的现象:很多人能把登录跑通,但一旦问起「登录之后那几个请求到底干了什么」,回答就开始含糊了。若依里 GetInfo 负责拿角色和权限标识,GenerateRouters 负责拿动态路由,这两个接口看着简单,实际上是整套权限体系的地基——前端能不能渲染出菜单、按钮能不能显示、接口能不能调通,全压在它们身上。这篇就把这条链路从头到尾捋一遍,包括后端菜单表怎么翻译成路由、前端怎么把后端返回的 component 字符串映射成真实组件、首页看板数据什么时候加载最合适,以及我在实际项目里踩过的那些坑。不管你是刚拉下若依源码准备二次开发,还是已经在维护一套跑了很久的系统,应该都能从里面找到点有用的东西。
1. 登录完成后的两次关键请求:先把整条链路理清
1.1 从点下登录按钮到看见首页,中间发生了什么
大多数人以为登录成功后就直接跳首页了,其实中间隔了一层「准备工作」。完整顺序大概是这样:用户提交账号密码,后端校验通过后签发 token 返回;前端把 token 存进 Cookie 或者 localStorage;紧接着路由守卫拦下来,发现本地还没有角色信息(roles.length === 0),于是触发useUserStore().getInfo();拿到角色和权限之后再调用usePermissionStore().generateRoutes(),把动态路由注册进 router 实例;最后才next({ ...to, replace: true })放行到目标页面。
这个顺序是有硬性依赖的,不能乱。GetInfo 必须先于 GenerateRouters 完成,因为后端在查菜单树的时候,是拿当前登录用户的 userId 去过滤的,而这个 userId 是从 token 解析出来的。如果你把两个请求并行发,大概率不会出错,但逻辑上仍然存在隐患——某些定制版本里 GenerateRouters 会读SysUser上的角色缓存,并行的时候缓存还没写进去,就会返回空路由。我见过一个项目为了「优化首屏速度」把两个请求合并成Promise.all,结果偶发白屏,排查了两天才定位到这儿。
另外要说明一点,getInfo和getRouters的命名在不同版本里不太一样。若依前后端分离单体版(RuoYi-Vue)里是GET /getInfo和GET /getRouters,挂在SysLoginController上;若是微服务版(RuoYi-Cloud),这两个接口通常落在 system 服务的登录控制器里,由网关转发过去,路径前缀可能会被 gateway 的 StripPrefix 过滤器裁掉一层。你在写自定义前端的时候,接口地址一定要对着实际服务的 controller 注解看,别照着网上文章抄。
1.2 为什么若依要把权限和路由拆成两个接口
这是很多人第一次读源码时会犯的疑惑:既然菜单表里既有菜单又有按钮,为什么不一次性全返回?
我的理解是职责分离 + 使用时机不同。GetInfo 返回的roles和permissions是能力清单,是一堆扁平的字符串集合,比如["system:user:list", "system:user:add"]。前端拿到之后主要干两件事:一是v-hasPermi指令用来控制按钮显隐,二是checkPermi/checkRole这类工具函数在需要的时候做判断。这些东西在整个会话期间是常驻内存的,不会因为路由变化而变化。
GenerateRouters 返回的是一棵结构化的树,包含 path、name、component、meta 这些渲染所需的信息。它只在路由守卫首次进入时调用一次(准确的说是每次刷新页面时调一次),之后由前端自己维护。
拆开的好处很直接:权限标识可以在任意页面随时查,不依赖路由是否已经注册完;而路由树是重量级的嵌套结构,只在必要的时候拉一次就够了。反过来说,如果你把它们合成一个接口,首页加载会被拖慢,而且每次单独刷新权限(比如后台改了角色权限要即时生效)都得把整棵路由树重新拉一遍。
1.3 前端状态放在哪里:user store 和 permission store
前端这一层,若依把状态拆成了两个 store:src/store/modules/user.js(Vue3 版本是user.ts)负责 token、name、avatar、roles、permissions;src/store/modules/permission.ts负责 routes、addRoutes、defaultRoutes 以及侧边栏渲染用的 sidebarRouters。
我建议二次开发时不要随意改这两个 store 的字段名,因为v-hasPermi指令、Sidebar组件、TagsView组件、甚至路由守卫都直接引用了它们。改动一个字段名,可能要全局搜十几个文件。真要扩展,就在 store 里加字段,别动已有的。
还有个容易忽略的点:permissions存的是Set转换后的数组,超管被硬编码成["*:*:*"]。前端v-hasPermi的实现里会先判断当前用户是否是 admin,如果是就直接放行,不做字符串匹配。所以你手动给某个角色配上*:*:*是没用的,只有user.isAdmin()为真(一般是 userId === 1)才有效。
2. GetInfo 拆解:角色和权限标识是怎么算出来的
2.1 从 Controller 到 Service 的调用链
后端这块逻辑不长,但每一步都有讲究。控制器大致是这么几行:
@GetMapping("getInfo") public AjaxResult getInfo() { SysUser user = SecurityUtils.getLoginUser().getUser(); Set<String> roles = permissionService.getRolePermission(user); Set<String> permissions = permissionService.getMenuPermission(user); AjaxResult ajax = AjaxResult.success(); ajax.put("user", user); ajax.put("roles", roles); ajax.put("permissions", permissions); return ajax; }注意SecurityUtils.getLoginUser()这一步,它取的是登录时写进 Redis(或 JWT)里的缓存对象。这意味着如果管理员在后台改了用户的角色,而这个用户还挂着没退登,他看到的权限还是旧的。解决办法要么是让用户重新登录,要么是在角色变更时主动清掉对应的登录缓存——若依在用户管理里改角色时确实会调用tokenService.delLoginUser之类的逻辑把在线用户踢下线,但你自己写的业务表如果也涉及权限变更,就得手动加上这一步。
另外,ajax.put("user", user)直接把SysUser实体丢出去了。如果你在SysUser上加了敏感字段(比如身份证、手机号密文),记得用@JsonIgnore或者改造一个 VO 再返回,否则前端 F12 一看全暴露了。
2.2 角色集合 getRolePermission 的两种情况
public Set<String> getRolePermission(SysUser user) { Set<String> roles = new HashSet<String>(); if (user.isAdmin()) { roles.add("admin"); } else { roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId())); } return roles; }逻辑很简单,但有两个细节值得说。
第一,isAdmin()的判断依据通常是userId == 1L。这是个硬编码约定,很多项目部署完之后第一件事就是把超管账号改名换密码,但 userId 依然是 1。如果你在初始化脚本里把超管的 userId 改成别的值,那整套 admin 特权就失效了,permissions会退化成普通角色的权限集合,界面上什么按钮都点不了——这个坑我踩过一次,当时在ry_2023xxxx.sql里手动调整了 insert 语句,忘了SecurityUtils.isAdmin()的判断。
第二,返回的是角色的roleKey不是roleId。roleKey就是你在角色管理里看到的那个英文标识,比如common、manager。前端hasRole('manager')就是拿它来比对的。所以改角色的 key 要慎重,改完之后所有前端硬编码的hasRole判断都会失效。
2.3 权限集合 getMenuPermission 与冒号拼接规则
public Set<String> getMenuPermission(SysUser user) { Set<String> perms = new HashSet<String>(); if (user.isAdmin()) { perms.add("*:*:*"); } else { List<SysRole> roles = user.getRoles(); if (!StringUtils.isEmpty(roles)) { for (SysRole role : roles) { Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId()); role.setPermissions(rolePerms); perms.addAll(rolePerms); } } else { perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId())); } } return perms; }这里有个分叉:如果用户对象上已经挂了角色列表,就按角色逐个查权限;没挂的话,直接按 userId 查。实际运行时通常走的是第一个分支,因为登录时会通过selectUserByUserName把roles一起装载进去。
权限字符串的来源是sys_menu表的perms字段。菜单管理里给按钮类型(menu_type = 'F')的菜单填上system:user:add这样的字符串,就会出现在这里。多角色之间是并集关系,不会取交集,所以给用户加角色是「叠加权限」而不是「互相约束」。如果你需要「必须同时具备两个角色才能操作」这种逻辑,得在前端或者后端自己额外判断。
还有个细节:role.setPermissions(rolePerms)这行不只是顺手写的,它把每个角色的权限集合回填到SysRole对象上,然后通过ajax.put("user", user)一起返回给前端。前端在用户管理或者角色详情里展示「这个角色有哪些权限」时用到的就是这份数据。
提示:
perms字段填错一个冒号或者大小写,权限就会静默失效。system:user:list和System:User:List是两个完全不同的字符串,后端@PreAuthorize("@ss.hasPermi('system:user:list')")是大小写敏感的精确匹配。
2.4 前端拿到 roles 和 permissions 之后怎么用
前端最常用的两个入口是v-hasPermi和v-hasRole指令,它们在src/directive/permission目录下。
// v-hasPermi 的核心逻辑 const all_permission = "*:*:*"; const permissions = useUserStore().permissions; if (value && value instanceof Array && value.length > 0) { const permissionFlag = value; const hasPermissions = permissions.some(permission => { return all_permission === permission || permissionFlag.includes(permission); }); if (!hasPermissions) { el.parentNode && el.parentNode.removeChild(el); } }注意最后一行:不满足权限时是把 DOM 直接删掉,不是display: none。这意味着你没法用「隐藏 + 后续放开」的方式做权限切换,权限变更只能通过刷新页面或者重新走一遍 getInfo 生效。
除了指令,实际项目里我更常用的是工具函数形式,因为有些场景需要动态判断:
import { checkPermi, checkRole } from "@/utils/permission"; if (checkPermi(['system:user:edit'])) { // 做点什么 }这种写法在表格操作列里特别实用——你可以根据权限动态决定渲染哪几个按钮,而不是写五个v-hasPermi然后靠指令去删 DOM。
3. GenerateRouters 拆解:菜单表到动态路由的翻译过程
3.1 菜单表结构与 M/C/F 三种类型
动态路由的一切源头都是sys_menu表。核心字段其实就几个:menu_id、parent_id、menu_name、path、component、menu_type、visible、status、perms、icon、is_frame、is_cache、query。
menu_type有三种取值,这个必须记住:
| 类型值 | 含义 | 是否出现在路由树里 | 说明 |
|---|---|---|---|
| M | 目录 | 是 | 通常对应 Layout 或 ParentView,可以嵌套子菜单 |
| C | 菜单 | 是 | 对应一个真实的 .vue 页面 |
| F | 按钮 | 否 | 只贡献 perms 权限标识,不参与路由 |
很多新手会疑惑「为什么我加了个菜单但路由里看不到」,八成是把menu_type选成了 F,或者status填成了停用,又或者visible设成了隐藏。隐藏的菜单其实是会进路由的,只是meta上打了hidden: true的标记,侧边栏不渲染而已——这一点很重要,隐藏菜单可以用作详情页、编辑页这类需要路由但不希望出现在菜单栏的页面。
还有一个字段is_frame,0 表示是外链,1 表示不是。设成外链时,路由会走InnerLink组件用 iframe 嵌进去,而不是走正常的组件加载流程。
3.2 buildMenus 的四个分支决定路由长什么样
后端SysMenuServiceImpl.buildMenus是整个翻译过程的核心。它遍历菜单树,每个菜单根据情况落进四个分支之一。
分支一:目录类型且有子菜单。这时候会把alwaysShow设为 true、redirect设为noRedirect,然后递归调用buildMenus处理 children。alwaysShow的作用是让侧边栏即使在只有一个子菜单时也保持父级展开的状态,而不是把父级自动折叠掉。
分支二:一级菜单(isMenuFrame)。所谓一级菜单,指的是parentId == 0且类型为 C 的菜单——也就是直接在根目录下的页面。这种情况下后端会做一层「包装」:外层路径设为/,内层 children 里放真正的路径。为什么要这么绕?因为若依的布局是「外层布局 + 内层页面」的结构,如果直接给一级菜单挂组件,它就脱离了 Layout 的壳子,没有侧边栏和顶栏了。
} else if (isMenuFrame(menu)) { router.setMeta(null); List<RouterVo> childrenList = new ArrayList<RouterVo>(); RouterVo children = new RouterVo(); children.setPath(menu.getPath()); children.setComponent(menu.getComponent()); children.setName(StringUtils.capitalize(menu.getPath())); children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), ...)); children.setQuery(menu.getQuery()); childrenList.add(children); router.setChildren(childrenList); }分支三:根节点下的内链。路径会被替换掉协议头,组件设为InnerLink。
分支四:普通情况。什么特殊处理都不做,直接用菜单自身的 path 和 component。
同时getComponent方法会决定这个路由用哪个壳组件:有 component 且不是一级菜单,就用菜单里配的;component 为空且是内链,用InnerLink;component 为空且父级不是 0,用ParentView。这里的ParentView是个只渲染<router-view>的空壳,专门用来承接多层嵌套。
3.3 前端 filterAsyncRouter 与 component 字符串映射
后端返回的component是个字符串,比如"system/user/index",前端要把它变成真实的组件。这一步靠import.meta.glob完成:
const modules = import.meta.glob('./../../views/**/*.vue') function loadView(view) { let res Object.keys(modules).forEach(key => { if (key === `./../../views/${view}.vue`) { res = modules[key] } }) return res }这段代码有两个必须知道的前提。第一,import.meta.glob是构建时静态分析的,路径里不能有变量。你写import.meta.glob('./../../views/**/*.vue')是合法的,但写import.meta.glob('./../../views/' + dir + '/**/*.vue')会直接报错或者返回空对象。第二,路径必须一字不差。菜单里配system/user,但实际文件在src/views/system/user/index.vue,那loadView就会返回undefined,路由匹配上了但组件是空的,页面就白屏。这是最常见的白屏原因之一,排查时先看浏览器控制台有没有组件加载相关的 warning。
filterAsyncRouter负责把后端返回的路由数组递归转换:
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) { return asyncRouterMap.filter(route => { if (type && route.children) { route.children = filterChildren(route.children) } if (route.component) { if (route.component === 'Layout') { route.component = Layout } else if (route.component === 'ParentView') { route.component = ParentView } else if (route.component === 'InnerLink') { route.component = InnerLink } else { route.component = loadView(route.component) } } if (route.children != null && route.children.length) { route.children = filterAsyncRouter(route.children, route, type) } else { delete route['children'] delete route['redirect'] } return true }) }有一行特别容易被忽略:delete route['children']和delete route['redirect']。如果叶子节点上还挂着空的 children 数组,Vue Router 会认为这是个有子路由的父级,然后redirect又指向一个不存在的地方,页面直接跳飞。这行删除操作就是为了避免这种「空壳父级」造成的诡异跳转。
3.4 路由注册顺序与 404 兜底
路由守卫里注册路由的写法是这样的:
usePermissionStore().generateRoutes().then(accessRoutes => { accessRoutes.forEach(route => { if (!isHttp(route.path)) { router.addRoute(route) } }) next({ ...to, replace: true }) })isHttp的判断是为了把外链路由排除掉,外链不应该注册到前端 router 里。
next({ ...to, replace: true })这一行是整套动态路由能跑通的关键。它的作用是取消当前这次导航,用同样的目标重新触发一次。为什么要这样?因为router.addRoute是同步的,但当前这次导航已经开始匹配路由表了,如果不用中文重新触发一次,即便路由已经加进去了,Vue Router 依然会认为目标路径不存在,直接跳到 404。我第一次自己实现动态路由的时候就漏了这一行,现象是「登录后能进首页,但刷新任何子页面都跳 404」,折腾了好久。
还有 404 兜底规则的位置。若依把path: '/:pathMatch(.*)*'或者老版本的path: '*'放在constantRoutes的末尾。Vue Router 匹配时会按路径的具体程度打分,具体路径优先级高于通配符,所以一般情况下不会误伤。但如果你自己手写了一个通配符路由插在中间,就要小心了——动态路由后注册,可能永远匹配不到。
注意:如果用 Vue3 的
router.addRoute加通配符兜底,一定要确保它在所有动态路由都加完之后再加。否则页面刷新时兜底先匹配上了,用户看到的永远是 404。
4. 首页数据加载:串行还是并行,缓存放哪一层
4.1 路由守卫里那行代码的真实作用
前面提到next({ ...to, replace: true }),这里再展开说说它跟首页加载的关系。replace: true的含义是不在浏览器历史里留记录,避免用户点后退的时候又回到「正在加载」的中间态。
而{ ...to }的展开写法也值得说。有人喜欢写成next(to),看着更简洁。但to是一个RouteLocationNormalized对象,里面有一些只读属性,直接传的话在某些 Vue Router 版本上会出现replace字段丢失或者matched数组不更新的问题。展开成新对象再传,等于把当前的路由信息作为一个普通的 location 对象重新走一遍匹配流程,这是最稳妥的写法。
另外整个守卫是要配合 NProgress 的:
router.beforeEach((to, from, next) => { NProgress.start() // ... 各种判断 }) router.afterEach(() => { NProgress.done() })如果你在守卫里抛异常又没 catch,NProgress.done()就不会执行,进度条会一直卡在屏幕顶部。我见过不少项目的「页面卡住不跳转」其实就是这个原因,看着像路由问题,实际是个未捕获的异常。
4.2 首页看板数据的加载策略
首页(src/views/index.vue)通常是个数据看板,要拉好几组统计数据。这里的关键问题是:这些请求什么时候发?
有几种常见做法,我挨个说下优劣。
做法一:放在路由守卫里。不推荐。守卫是全局的,把业务数据塞进去会让它越来越臃肿,而且每个人进任何页面都会触发一次首页数据请求,纯属浪费。
做法二:放在首页组件的 mounted 里。最常见的做法。优点是无侵入、容易维护;缺点是首页会经历「先渲染骨架,再填数据」的过程,如果接口慢,用户会看到一段空白。
做法三:路由守卫完成后再发起。也就是在next()之后,通过router.isReady()或者app.mount之后的时机去预热首页数据。这个做法可以让首页数据请求和路由渲染并行,体验最好,但代码组织上要单独抽一个模块。
我一般的处理是「做法二 + 骨架屏」。在mounted里先给每个统计项赋默认值(比如-或者0),然后并行发请求,回来了逐个替换。用Promise.allSettled而不是Promise.all很关键,因为看板上往往有五六个接口,只要一个挂了,Promise.all就会整体 reject,其他成功了的数据也显示不出来。
async function loadDashboard() { const tasks = [ fetchUserCount(), fetchOrderCount(), fetchRecentLogs(), fetchTodoList() ] const results = await Promise.allSettled(tasks) results.forEach((r, i) => { if (r.status === 'fulfilled') { // 按索引填入对应模块 } else { console.warn(`第 ${i} 个看板接口失败`, r.reason) } }) }4.3 keep-alive 缓存与 tagsView 的 name 对齐
首页加载还有一个绕不开的话题:缓存。若依用了keep-alive来做页面缓存,tagsView里的多标签页也是靠它维持状态的。但keep-alive有一个很硬性的要求——缓存组件的 name 必须和路由的 name 完全一致。
而路由的 name 从哪来?后端的getRouteName方法,一般是对菜单的 path 做首字母大写处理。也就是说,如果菜单 path 是user,路由 name 就是User。那你的src/views/system/user/index.vue里就必须写:
export default { name: "User" }或者在<script setup>里用defineOptions({ name: 'User' })。写错了会怎样?页面能正常打开,但切换标签页再回来,页面状态被重置了,用户填了一半的表单全没了。这种 bug 特别隐蔽,因为功能上「没报错」。
至于首页本身要不要缓存,我的建议是不缓存。看板数据每次进来刷一遍是合理的,而且很多项目里首页会放待办、通知这类时效性强的信息,缓存反而会造成误导。你可以在菜单的is_cache字段里把它设成 0,或者干脆在tagsView的右键菜单里让用户自己决定。
5. 常见问题排查速查表与实操心得
5.1 白屏、菜单不显示、刷新 404 这三类问题
这三类问题我遇到的频率最高,而且症状容易混淆,先把它们的区分点列清楚。
白屏通常是组件加载失败。打开控制台看,如果有类似Component is missing template or render function或者一堆 undefined 的警告,基本可以确定是loadView没匹配上。排查顺序是:先在loadView里加一行console.log(key),看看 glob 到底扫到了哪些文件;然后拿菜单表的 component 字段值和实际文件路径逐字符对比。常见的坑包括路径少了一层目录、文件名大小写不一致(Linux 部署时尤其明显,Windows 开发机不区分大小写所以本地好好的)、以及后缀.vue重复或者缺失。
菜单不显示要先分清是路由没生成还是侧边栏没渲染。F12 打开 Vue Devtools 看permissionstore 里的routes,如果里面压根没这条路由,说明是后端过滤掉了——检查菜单的status是不是停用,当前用户有没有这个菜单关联的角色。如果路由在但侧边栏看不到,那就是visible设成了 1,或者meta.hidden为 true。
刷新后 404几乎都是next({ ...to, replace: true })缺失或者写法不对。另一个可能是 Nginx 配置问题:前端用了 history 模式,但服务器没配try_files $uri $uri/ /index.html,刷新非根路径就直接 404 了。这两个原因要分开看,前者是浏览器里画面正常但路由匹配错了,后者是服务器直接返回了 404 页面。
5.2 权限标识不生效的几类原因
按钮该显示的不显示、该隐藏的还在,排查下来无非这么几种情况。
第一种,@PreAuthorize注解里的标识和sys_menu表里填的不一致。后端拦截用的是注解里的字符串,前端显隐用的是菜单表里的字符串,两边得对上。我习惯的做法是在菜单管理里填完之后,把整个perms字段复制出来,贴到后端代码里,避免手打出错。
第二种,用户登录后权限被缓存了。前面说过,getInfo拿的是 Redis 里的登录对象,改了角色要重新登录。如果你希望权限「准实时」生效,可以在角色或者菜单变更的后端方法里主动清缓存。若依的用户管理里有「强退」功能,本质就是调tokenService.delLoginUser。
第三种,多个角色权限取并集,跟你预期不一致。比如用户同时有 A、B 两个角色,A 有system:user:list,B 没有,那最终他就是有。反过来说,你没法通过加一个「受限角色」的方式来限制权限,只能靠不加权限来做限制。
第四种,超管的*:*:*覆盖了一切。测试的时候经常遇到「我用普通账号测权限,怎么改都生效」,最后一查发现测试账号是 userId = 1。测试权限一定要用一个干净的普通账号。
5.3 常见问题速查表
| 现象 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| 登录后首页正常,刷新子页面 404 | 守卫里缺next({...to, replace: true}),或 Nginx 未配 history 回退 | 看 Network 是否请求了 index.html;看守卫代码 |
| 页面白屏无报错 | loadView未匹配到组件,component 路径与文件不一致 | 在loadView里打印 glob 的 key 列表 |
| 菜单不出现 | 菜单visible=1、status=0,或角色未关联 | Vue Devtools 看 permission store 的 routes |
| 按钮权限不生效 | perms 字符串大小写/拼写错误,或 Redis 权限缓存未刷新 | 对比菜单表 perms 与注解字符串,重新登录 |
| 切换标签页后表单被重置 | 组件 name 与路由 name 不一致,keep-alive 失效 | 检查组件name与菜单 path 的 capitalize 结果 |
| 外链菜单打开是空白 | is_frame设置与实际需求不符 | 检查该菜单的 is_frame 字段 |
| 动态路由加上了但跳不过去 | 404 兜底路由注册顺序靠前 | 检查 constantRoutes 中通配符的位置 |
| 微服务版接口 404 | 网关 StripPrefix 配置与前端请求前缀不匹配 | 对比网关配置与前端 baseURL |
5.4 几个踩坑之后固定下来的习惯
最后分享几个我现在做若依二次开发时雷打不动的习惯,都是从实际事故里总结出来的。
第一,菜单 component 字段一律从文件路径反推填写。先在src/views下把目录和文件建好,再回菜单管理里照着填,不要凭记忆写。填完之后随手在浏览器里点一下,比事后排查白屏省事得多。
第二,每次改完菜单或者角色,强制退出重新登录再测。不要相信「应该生效了」,缓存这事说起来简单,真出问题的时候特别耗时间。
第三,前端写权限判断优先用checkPermi函数而不是v-hasPermi指令。指令会直接删 DOM,出问题时你连证据都看不到。函数形式可以在控制台打断点,排查方便太多。
第四,Vue3 + TS 项目里把路由相关的类型收一收。若依 Vue3 版本里filterAsyncRouter返回的类型经常对不上RouteRecordRaw,报一堆类型错误。我的做法是在调用处显式标注as unknown as RouteRecordRaw[],而不是去改源码里的类型定义——改源码会让后续升级对比 diff 变得很痛苦。
第五,getInfo返回的 user 对象不要直接往业务里传。它带着password、salt之类的字段(虽然一般是空串或者加密串),但如果你的业务代码把整个 user 塞进日志或者响应里,还是有泄露风险的。封装一个pickUser工具函数,只取需要的字段。
第六,动态路由数量大的时候注意首屏耗时。菜单规模超过两三百个之后,后端buildMenus的递归加上前端import.meta.glob的遍历会明显变慢。这时候可以考虑给getRouters加一层服务端缓存,缓存 key 用 userId + 角色版本号,角色变更时清掉。不过这是优化阶段才需要考虑的事,别一上来就搞,容易把简单的逻辑搞复杂。