[{"data":1,"prerenderedAt":1482},["ShallowReactive",2],{"article-frontend\u002Fruoyi-vue-plus(vue)":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"tags":11,"body":13,"_type":1476,"_id":1477,"_source":1478,"_file":1479,"_stem":1480,"_extension":1481},"\u002Farticles\u002Ffrontend\u002Fruoyi-vue-plus(vue)","frontend",false,"","精炼版 RuoYi-Vue-Plus 前端(Vue版)：从零做一套不臃肿的 RBAC 中后台前端","精炼重构 RuoYi-Vue-Plus \u002F plus-ui 的前端实践——从业务上讲清 RBAC 权限闭环（动态路由 + 按钮指令）是怎么转起来的，再摊开一串\"type-check 全绿、联调才暴露\"的真实跨仓契约坑。","2026-06-03",[12],"软件工程",{"type":14,"children":15,"toc":1460},"root",[16,33,69,75,88,99,105,124,130,135,144,202,214,339,351,357,362,370,375,669,688,708,826,845,855,861,873,880,916,922,966,1007,1021,1088,1137,1143,1224,1266,1272,1372,1378,1415,1420,1425,1430,1449,1454],{"type":17,"tag":18,"props":19,"children":20},"element","p",{},[21,24,31],{"type":22,"value":23},"text","后台管理系统看起来都长一个样：左边一排菜单，右边一堆表格和表单。但真正难的不是画界面，而是那套**\"谁能看什么、谁能做什么\"**的权限系统。这篇记录我从零做一套 RBAC 中后台前端（Vue3 + Vite + TS + Pinia + Element Plus）的过程——先把权限闭环从业务上讲透，再把一路踩到的、",{"type":17,"tag":25,"props":26,"children":28},"code",{"className":27},[],[29],{"type":22,"value":30},"type-check",{"type":22,"value":32}," 永远抓不到的坑摊开来讲。",{"type":17,"tag":18,"props":34,"children":35},{},[36,38,44,46,52,54,60,61,67],{"type":22,"value":37},"参考对象是开源的 RuoYi-Vue-Plus \u002F plus-ui，但我刻意定了一条原则：",{"type":17,"tag":39,"props":40,"children":41},"strong",{},[42],{"type":22,"value":43},"学它的架构思想，拒绝它为了极致通用而对 Element Plus 做的二三层深度封装",{"type":22,"value":45},"。业务页就直接用原生 ",{"type":17,"tag":25,"props":47,"children":49},{"className":48},[],[50],{"type":22,"value":51},"el-table",{"type":22,"value":53}," \u002F ",{"type":17,"tag":25,"props":55,"children":57},{"className":56},[],[58],{"type":22,"value":59},"el-dialog",{"type":22,"value":53},{"type":17,"tag":25,"props":62,"children":64},{"className":63},[],[65],{"type":22,"value":66},"el-form",{"type":22,"value":68}," 写，一眼能看清数据流向。可读性，比\"少写两行\"重要。",{"type":17,"tag":70,"props":71,"children":73},"h2",{"id":72},"项目地址",[74],{"type":22,"value":72},{"type":17,"tag":18,"props":76,"children":77},{},[78,80],{"type":22,"value":79},"github：",{"type":17,"tag":81,"props":82,"children":86},"a",{"href":83,"rel":84},"https:\u002F\u002Fgithub.com\u002Foinsist\u002Foinsist-frontend-vue",[85],"nofollow",[87],{"type":22,"value":83},{"type":17,"tag":18,"props":89,"children":90},{},[91,93],{"type":22,"value":92},"gitee：",{"type":17,"tag":81,"props":94,"children":97},{"href":95,"rel":96},"https:\u002F\u002Fgitee.com\u002Fo_insist\u002Foinsist-frontend-vue",[85],[98],{"type":22,"value":95},{"type":17,"tag":70,"props":100,"children":102},{"id":101},"一这是个什么东西",[103],{"type":22,"value":104},"一、这是个什么东西",{"type":17,"tag":18,"props":106,"children":107},{},[108,110,115,117,122],{"type":22,"value":109},"它",{"type":17,"tag":39,"props":111,"children":112},{},[113],{"type":22,"value":114},"本身不是某个具体业务",{"type":22,"value":116},"（不是商城、不是 CRM），而是任何后台系统都要先搭好的那层地基：",{"type":17,"tag":39,"props":118,"children":119},{},[120],{"type":22,"value":121},"登录、权限、菜单、日志",{"type":22,"value":123},"。把业务模块往里加就行。所以衡量它的标准不是\"功能多\"，而是\"那套权限地基稳不稳、干不干净\"。",{"type":17,"tag":70,"props":125,"children":127},{"id":126},"二理解全局的钥匙rbac-权限三角",[128],{"type":22,"value":129},"二、理解全局的钥匙：RBAC 权限三角",{"type":17,"tag":18,"props":131,"children":132},{},[133],{"type":22,"value":134},"整个项目 80% 的复杂度都在 RBAC 这一件事上。三个主角、两张关联表：",{"type":17,"tag":136,"props":137,"children":139},"pre",{"code":138},"用户(谁登录) ──sys_user_role──► 角色(什么身份) ──sys_role_menu──► 菜单\u002F权限(能看能做什么)\n",[140],{"type":17,"tag":25,"props":141,"children":142},{"__ignoreMap":7},[143],{"type":22,"value":138},{"type":17,"tag":145,"props":146,"children":147},"ul",{},[148,174,197],{"type":17,"tag":149,"props":150,"children":151},"li",{},[152,157,159,164,166,172],{"type":17,"tag":39,"props":153,"children":154},{},[155],{"type":22,"value":156},"用户",{"type":22,"value":158},"不直接绑权限，而是绑",{"type":17,"tag":39,"props":160,"children":161},{},[162],{"type":22,"value":163},"角色",{"type":22,"value":165},"（\"给用户分配角色\"，写 ",{"type":17,"tag":25,"props":167,"children":169},{"className":168},[],[170],{"type":22,"value":171},"sys_user_role",{"type":22,"value":173},"）；",{"type":17,"tag":149,"props":175,"children":176},{},[177,181,183,188,190,196],{"type":17,"tag":39,"props":178,"children":179},{},[180],{"type":22,"value":163},{"type":22,"value":182},"绑",{"type":17,"tag":39,"props":184,"children":185},{},[186],{"type":22,"value":187},"菜单",{"type":22,"value":189},"（\"给角色分配菜单\"，写 ",{"type":17,"tag":25,"props":191,"children":193},{"className":192},[],[194],{"type":22,"value":195},"sys_role_menu",{"type":22,"value":173},{"type":17,"tag":149,"props":198,"children":199},{},[200],{"type":22,"value":201},"菜单分三类：目录(M，侧边栏分组)、菜单(C，能进的页面)、按钮(F，页面里的操作权限标识)。",{"type":17,"tag":18,"props":203,"children":204},{},[205,207,212],{"type":22,"value":206},"所以一个人能干什么，是 ",{"type":17,"tag":39,"props":208,"children":209},{},[210],{"type":22,"value":211},"用户 → 他的角色 → 角色拥有的菜单",{"type":22,"value":213}," 一层层推出来的。用两个真实账号对照最直观：",{"type":17,"tag":215,"props":216,"children":217},"table",{},[218,240],{"type":17,"tag":219,"props":220,"children":221},"thead",{},[222],{"type":17,"tag":223,"props":224,"children":225},"tr",{},[226,230,235],{"type":17,"tag":227,"props":228,"children":229},"th",{},[],{"type":17,"tag":227,"props":231,"children":232},{},[233],{"type":22,"value":234},"admin（超级管理员）",{"type":17,"tag":227,"props":236,"children":237},{},[238],{"type":22,"value":239},"test（普通角色）",{"type":17,"tag":241,"props":242,"children":243},"tbody",{},[244,275,300,321],{"type":17,"tag":223,"props":245,"children":246},{},[247,253,264],{"type":17,"tag":248,"props":249,"children":250},"td",{},[251],{"type":22,"value":252},"拥有菜单",{"type":17,"tag":248,"props":254,"children":255},{},[256,258],{"type":22,"value":257},"全部 + 通配权限 ",{"type":17,"tag":25,"props":259,"children":261},{"className":260},[],[262],{"type":22,"value":263},"*:*:*",{"type":17,"tag":248,"props":265,"children":266},{},[267,269],{"type":22,"value":268},"仅 ",{"type":17,"tag":25,"props":270,"children":272},{"className":271},[],[273],{"type":22,"value":274},"[系统管理、用户管理、用户查询]",{"type":17,"tag":223,"props":276,"children":277},{},[278,283,288],{"type":17,"tag":248,"props":279,"children":280},{},[281],{"type":22,"value":282},"侧边栏",{"type":17,"tag":248,"props":284,"children":285},{},[286],{"type":22,"value":287},"系统管理 ▸ 用户\u002F角色\u002F菜单\u002F操作日志\u002F登录日志（5 项）",{"type":17,"tag":248,"props":289,"children":290},{},[291,293,298],{"type":22,"value":292},"系统管理 ▸ ",{"type":17,"tag":39,"props":294,"children":295},{},[296],{"type":22,"value":297},"只有\"用户管理\"",{"type":22,"value":299},"（1 项）",{"type":17,"tag":223,"props":301,"children":302},{},[303,308,313],{"type":17,"tag":248,"props":304,"children":305},{},[306],{"type":22,"value":307},"用户管理页",{"type":17,"tag":248,"props":309,"children":310},{},[311],{"type":22,"value":312},"增\u002F删\u002F改\u002F查 全可见",{"type":17,"tag":248,"props":314,"children":315},{},[316],{"type":17,"tag":39,"props":317,"children":318},{},[319],{"type":22,"value":320},"只能查询，增删改按钮全被隐藏",{"type":17,"tag":223,"props":322,"children":323},{},[324,329,334],{"type":17,"tag":248,"props":325,"children":326},{},[327],{"type":22,"value":328},"角色\u002F菜单\u002F日志页",{"type":17,"tag":248,"props":330,"children":331},{},[332],{"type":22,"value":333},"能进",{"type":17,"tag":248,"props":335,"children":336},{},[337],{"type":22,"value":338},"地址都进不去（路由根本没注册）",{"type":17,"tag":18,"props":340,"children":341},{},[342,344,349],{"type":22,"value":343},"维护顺序上的因果也很清楚：",{"type":17,"tag":39,"props":345,"children":346},{},[347],{"type":22,"value":348},"先有菜单（定义有哪些权限）→ 再有角色（打包成身份）→ 再有用户（把身份发给人）",{"type":22,"value":350},"。这就是为什么\"菜单管理\"是整个权限体系的源头。",{"type":17,"tag":70,"props":352,"children":354},{"id":353},"三两层权限路由管页面指令管按钮",[355],{"type":22,"value":356},"三、两层权限：路由管页面，指令管按钮",{"type":17,"tag":18,"props":358,"children":359},{},[360],{"type":22,"value":361},"登录后，路由守卫会先向后端要两样东西，再决定你能看到什么：",{"type":17,"tag":136,"props":363,"children":365},{"code":364},"①  POST \u002Fauth\u002Flogin            → 拿到 token（存进 localStorage + Pinia）\n②  GET  \u002Fauth\u002FuserInfo         → 我是谁、有哪些角色和权限标识\n    GET  \u002Fauth\u002Frouters          → 我有权限的菜单树（后端按 角色→sys_role_menu→sys_menu 算好）\n③  前端把菜单树「翻译」成 Vue 路由，逐条 addRoute 注册\n④  Sidebar 读这棵树渲染左侧菜单\n⑤  落地首页。此后能进的页面 = 第②步那棵树，多一个都进不去\n",[366],{"type":17,"tag":25,"props":367,"children":368},{"__ignoreMap":7},[369],{"type":22,"value":364},{"type":17,"tag":18,"props":371,"children":372},{},[373],{"type":22,"value":374},"**菜单级权限（动态路由）**的核心是把后端下发的字符串组件名映射成真实组件：",{"type":17,"tag":136,"props":376,"children":380},{"code":377,"language":378,"meta":7,"className":379,"style":7},"const views = import.meta.glob('\u002Fsrc\u002Fviews\u002F**\u002F*.vue')\n\nfunction resolveComponent(component: string) {\n  if (component === 'Layout') return Layout          \u002F\u002F 目录 → 布局壳\n  const view = views[`\u002Fsrc\u002Fviews\u002F${component}.vue`]  \u002F\u002F \"system\u002Fuser\u002Findex\" → 真实页面\n  if (!view) { console.warn('missing view:', component); return NotFound } \u002F\u002F 缺失降级，不崩整树\n  return view\n}\n","ts","language-ts shiki shiki-themes github-dark",[381],{"type":17,"tag":25,"props":382,"children":383},{"__ignoreMap":7},[384,449,459,498,543,590,646,660],{"type":17,"tag":385,"props":386,"children":389},"span",{"class":387,"line":388},"line",1,[390,396,402,407,412,418,423,427,433,438,444],{"type":17,"tag":385,"props":391,"children":393},{"style":392},"--shiki-default:#F97583",[394],{"type":22,"value":395},"const",{"type":17,"tag":385,"props":397,"children":399},{"style":398},"--shiki-default:#79B8FF",[400],{"type":22,"value":401}," views",{"type":17,"tag":385,"props":403,"children":404},{"style":392},[405],{"type":22,"value":406}," =",{"type":17,"tag":385,"props":408,"children":409},{"style":392},[410],{"type":22,"value":411}," import",{"type":17,"tag":385,"props":413,"children":415},{"style":414},"--shiki-default:#E1E4E8",[416],{"type":22,"value":417},".",{"type":17,"tag":385,"props":419,"children":420},{"style":398},[421],{"type":22,"value":422},"meta",{"type":17,"tag":385,"props":424,"children":425},{"style":414},[426],{"type":22,"value":417},{"type":17,"tag":385,"props":428,"children":430},{"style":429},"--shiki-default:#B392F0",[431],{"type":22,"value":432},"glob",{"type":17,"tag":385,"props":434,"children":435},{"style":414},[436],{"type":22,"value":437},"(",{"type":17,"tag":385,"props":439,"children":441},{"style":440},"--shiki-default:#9ECBFF",[442],{"type":22,"value":443},"'\u002Fsrc\u002Fviews\u002F**\u002F*.vue'",{"type":17,"tag":385,"props":445,"children":446},{"style":414},[447],{"type":22,"value":448},")\n",{"type":17,"tag":385,"props":450,"children":452},{"class":387,"line":451},2,[453],{"type":17,"tag":385,"props":454,"children":456},{"emptyLinePlaceholder":455},true,[457],{"type":22,"value":458},"\n",{"type":17,"tag":385,"props":460,"children":462},{"class":387,"line":461},3,[463,468,473,477,483,488,493],{"type":17,"tag":385,"props":464,"children":465},{"style":392},[466],{"type":22,"value":467},"function",{"type":17,"tag":385,"props":469,"children":470},{"style":429},[471],{"type":22,"value":472}," resolveComponent",{"type":17,"tag":385,"props":474,"children":475},{"style":414},[476],{"type":22,"value":437},{"type":17,"tag":385,"props":478,"children":480},{"style":479},"--shiki-default:#FFAB70",[481],{"type":22,"value":482},"component",{"type":17,"tag":385,"props":484,"children":485},{"style":392},[486],{"type":22,"value":487},":",{"type":17,"tag":385,"props":489,"children":490},{"style":398},[491],{"type":22,"value":492}," string",{"type":17,"tag":385,"props":494,"children":495},{"style":414},[496],{"type":22,"value":497},") {\n",{"type":17,"tag":385,"props":499,"children":501},{"class":387,"line":500},4,[502,507,512,517,522,527,532,537],{"type":17,"tag":385,"props":503,"children":504},{"style":392},[505],{"type":22,"value":506},"  if",{"type":17,"tag":385,"props":508,"children":509},{"style":414},[510],{"type":22,"value":511}," (component ",{"type":17,"tag":385,"props":513,"children":514},{"style":392},[515],{"type":22,"value":516},"===",{"type":17,"tag":385,"props":518,"children":519},{"style":440},[520],{"type":22,"value":521}," 'Layout'",{"type":17,"tag":385,"props":523,"children":524},{"style":414},[525],{"type":22,"value":526},") ",{"type":17,"tag":385,"props":528,"children":529},{"style":392},[530],{"type":22,"value":531},"return",{"type":17,"tag":385,"props":533,"children":534},{"style":414},[535],{"type":22,"value":536}," Layout          ",{"type":17,"tag":385,"props":538,"children":540},{"style":539},"--shiki-default:#6A737D",[541],{"type":22,"value":542},"\u002F\u002F 目录 → 布局壳\n",{"type":17,"tag":385,"props":544,"children":546},{"class":387,"line":545},5,[547,552,557,561,566,571,575,580,585],{"type":17,"tag":385,"props":548,"children":549},{"style":392},[550],{"type":22,"value":551},"  const",{"type":17,"tag":385,"props":553,"children":554},{"style":398},[555],{"type":22,"value":556}," view",{"type":17,"tag":385,"props":558,"children":559},{"style":392},[560],{"type":22,"value":406},{"type":17,"tag":385,"props":562,"children":563},{"style":414},[564],{"type":22,"value":565}," views[",{"type":17,"tag":385,"props":567,"children":568},{"style":440},[569],{"type":22,"value":570},"`\u002Fsrc\u002Fviews\u002F${",{"type":17,"tag":385,"props":572,"children":573},{"style":414},[574],{"type":22,"value":482},{"type":17,"tag":385,"props":576,"children":577},{"style":440},[578],{"type":22,"value":579},"}.vue`",{"type":17,"tag":385,"props":581,"children":582},{"style":414},[583],{"type":22,"value":584},"]  ",{"type":17,"tag":385,"props":586,"children":587},{"style":539},[588],{"type":22,"value":589},"\u002F\u002F \"system\u002Fuser\u002Findex\" → 真实页面\n",{"type":17,"tag":385,"props":591,"children":593},{"class":387,"line":592},6,[594,598,603,608,613,618,622,627,632,636,641],{"type":17,"tag":385,"props":595,"children":596},{"style":392},[597],{"type":22,"value":506},{"type":17,"tag":385,"props":599,"children":600},{"style":414},[601],{"type":22,"value":602}," (",{"type":17,"tag":385,"props":604,"children":605},{"style":392},[606],{"type":22,"value":607},"!",{"type":17,"tag":385,"props":609,"children":610},{"style":414},[611],{"type":22,"value":612},"view) { console.",{"type":17,"tag":385,"props":614,"children":615},{"style":429},[616],{"type":22,"value":617},"warn",{"type":17,"tag":385,"props":619,"children":620},{"style":414},[621],{"type":22,"value":437},{"type":17,"tag":385,"props":623,"children":624},{"style":440},[625],{"type":22,"value":626},"'missing view:'",{"type":17,"tag":385,"props":628,"children":629},{"style":414},[630],{"type":22,"value":631},", component); ",{"type":17,"tag":385,"props":633,"children":634},{"style":392},[635],{"type":22,"value":531},{"type":17,"tag":385,"props":637,"children":638},{"style":414},[639],{"type":22,"value":640}," NotFound } ",{"type":17,"tag":385,"props":642,"children":643},{"style":539},[644],{"type":22,"value":645},"\u002F\u002F 缺失降级，不崩整树\n",{"type":17,"tag":385,"props":647,"children":649},{"class":387,"line":648},7,[650,655],{"type":17,"tag":385,"props":651,"children":652},{"style":392},[653],{"type":22,"value":654},"  return",{"type":17,"tag":385,"props":656,"children":657},{"style":414},[658],{"type":22,"value":659}," view\n",{"type":17,"tag":385,"props":661,"children":663},{"class":387,"line":662},8,[664],{"type":17,"tag":385,"props":665,"children":666},{"style":414},[667],{"type":22,"value":668},"}\n",{"type":17,"tag":18,"props":670,"children":671},{},[672,674,679,681,686],{"type":22,"value":673},"注意\"我能看哪些菜单\"这个判断是",{"type":17,"tag":39,"props":675,"children":676},{},[677],{"type":22,"value":678},"在后端做的",{"type":22,"value":680},"（查 ",{"type":17,"tag":25,"props":682,"children":684},{"className":683},[],[685],{"type":22,"value":195},{"type":22,"value":687},"），前端只负责把结果渲染成菜单 + 注册成路由。",{"type":17,"tag":18,"props":689,"children":690},{},[691,693,699,701,706],{"type":22,"value":692},"**按钮级权限（自定义指令）**则管页面内的操作。",{"type":17,"tag":25,"props":694,"children":696},{"className":695},[],[697],{"type":22,"value":698},"v-hasPermi",{"type":22,"value":700}," 在按钮挂载时读当前用户权限集，没有就把 DOM 删掉，超管 ",{"type":17,"tag":25,"props":702,"children":704},{"className":703},[],[705],{"type":22,"value":263},{"type":22,"value":707}," 放行：",{"type":17,"tag":136,"props":709,"children":711},{"code":710,"language":378,"meta":7,"className":379,"style":7},"const allowed = permissions.includes('*:*:*') || expected.some(p => permissions.includes(p))\nif (!allowed) el.parentNode?.removeChild(el)\n",[712],{"type":17,"tag":25,"props":713,"children":714},{"__ignoreMap":7},[715,795],{"type":17,"tag":385,"props":716,"children":717},{"class":387,"line":388},[718,722,727,731,736,741,745,750,754,759,764,769,773,777,782,786,790],{"type":17,"tag":385,"props":719,"children":720},{"style":392},[721],{"type":22,"value":395},{"type":17,"tag":385,"props":723,"children":724},{"style":398},[725],{"type":22,"value":726}," allowed",{"type":17,"tag":385,"props":728,"children":729},{"style":392},[730],{"type":22,"value":406},{"type":17,"tag":385,"props":732,"children":733},{"style":414},[734],{"type":22,"value":735}," permissions.",{"type":17,"tag":385,"props":737,"children":738},{"style":429},[739],{"type":22,"value":740},"includes",{"type":17,"tag":385,"props":742,"children":743},{"style":414},[744],{"type":22,"value":437},{"type":17,"tag":385,"props":746,"children":747},{"style":440},[748],{"type":22,"value":749},"'*:*:*'",{"type":17,"tag":385,"props":751,"children":752},{"style":414},[753],{"type":22,"value":526},{"type":17,"tag":385,"props":755,"children":756},{"style":392},[757],{"type":22,"value":758},"||",{"type":17,"tag":385,"props":760,"children":761},{"style":414},[762],{"type":22,"value":763}," expected.",{"type":17,"tag":385,"props":765,"children":766},{"style":429},[767],{"type":22,"value":768},"some",{"type":17,"tag":385,"props":770,"children":771},{"style":414},[772],{"type":22,"value":437},{"type":17,"tag":385,"props":774,"children":775},{"style":479},[776],{"type":22,"value":18},{"type":17,"tag":385,"props":778,"children":779},{"style":392},[780],{"type":22,"value":781}," =>",{"type":17,"tag":385,"props":783,"children":784},{"style":414},[785],{"type":22,"value":735},{"type":17,"tag":385,"props":787,"children":788},{"style":429},[789],{"type":22,"value":740},{"type":17,"tag":385,"props":791,"children":792},{"style":414},[793],{"type":22,"value":794},"(p))\n",{"type":17,"tag":385,"props":796,"children":797},{"class":387,"line":451},[798,803,807,811,816,821],{"type":17,"tag":385,"props":799,"children":800},{"style":392},[801],{"type":22,"value":802},"if",{"type":17,"tag":385,"props":804,"children":805},{"style":414},[806],{"type":22,"value":602},{"type":17,"tag":385,"props":808,"children":809},{"style":392},[810],{"type":22,"value":607},{"type":17,"tag":385,"props":812,"children":813},{"style":414},[814],{"type":22,"value":815},"allowed) el.parentNode?.",{"type":17,"tag":385,"props":817,"children":818},{"style":429},[819],{"type":22,"value":820},"removeChild",{"type":17,"tag":385,"props":822,"children":823},{"style":414},[824],{"type":22,"value":825},"(el)\n",{"type":17,"tag":18,"props":827,"children":828},{},[829,831,836,838,843],{"type":22,"value":830},"而你每点一次\"删除\"，后端删数据的同时被 AOP 切面写进",{"type":17,"tag":39,"props":832,"children":833},{},[834],{"type":22,"value":835},"操作日志",{"type":22,"value":837},"；每次登录写进",{"type":17,"tag":39,"props":839,"children":840},{},[841],{"type":22,"value":842},"登录日志",{"type":22,"value":844},"——闭环的最后一环是审计留痕。",{"type":17,"tag":18,"props":846,"children":847},{},[848,850],{"type":22,"value":849},"一句话总结：",{"type":17,"tag":39,"props":851,"children":852},{},[853],{"type":22,"value":854},"登录时后端算出你的可见范围，前端用\"动态路由\"管页面、\"v-hasPermi\"管按钮，操作再被日志记下来。",{"type":17,"tag":70,"props":856,"children":858},{"id":857},"四真正的坑type-check-全绿联调才崩",[859],{"type":22,"value":860},"四、真正的坑：type-check 全绿，联调才崩",{"type":17,"tag":18,"props":862,"children":863},{},[864,866,871],{"type":22,"value":865},"下面这些才是这篇最想留下的东西——共同点是",{"type":17,"tag":39,"props":867,"children":868},{},[869],{"type":22,"value":870},"编译期一切正常，连到真后端才暴露",{"type":22,"value":872},"。",{"type":17,"tag":874,"props":875,"children":877},"h3",{"id":876},"_1-裸-tokenbearer-前缀不一致",[878],{"type":22,"value":879},"1. 裸 token：Bearer 前缀不一致",{"type":17,"tag":18,"props":881,"children":882},{},[883,885,891,893,899,901,906,908,914],{"type":22,"value":884},"拦截器一开始写的是 ",{"type":17,"tag":25,"props":886,"children":888},{"className":887},[],[889],{"type":22,"value":890},"Authorization: Bearer ${token}",{"type":22,"value":892},"，登录能成功，但后续接口全部 401，表现成\"登录后立刻被踢回登录页\"。原因是后端 Sa-Token 的 ",{"type":17,"tag":25,"props":894,"children":896},{"className":895},[],[897],{"type":22,"value":898},"token-prefix",{"type":22,"value":900}," 没启用，它期望",{"type":17,"tag":39,"props":902,"children":903},{},[904],{"type":22,"value":905},"裸 token",{"type":22,"value":907},"。改成 ",{"type":17,"tag":25,"props":909,"children":911},{"className":910},[],[912],{"type":22,"value":913},"Authorization: ${token}",{"type":22,"value":915}," 就好。教训：鉴权头格式必须和后端实际配置对齐，不能想当然。",{"type":17,"tag":874,"props":917,"children":919},{"id":918},"_2-雪花-id-的-js-精度丢失",[920],{"type":22,"value":921},"2. 雪花 ID 的 JS 精度丢失",{"type":17,"tag":18,"props":923,"children":924},{},[925,927,933,935,941,943,949,951,957,959,964],{"type":22,"value":926},"后端主键是雪花算法 Long（19 位，约 ",{"type":17,"tag":25,"props":928,"children":930},{"className":929},[],[931],{"type":22,"value":932},"1.9×10¹⁸",{"type":22,"value":934},"），远超 JavaScript ",{"type":17,"tag":25,"props":936,"children":938},{"className":937},[],[939],{"type":22,"value":940},"Number.MAX_SAFE_INTEGER",{"type":22,"value":942},"（",{"type":17,"tag":25,"props":944,"children":946},{"className":945},[],[947],{"type":22,"value":948},"9×10¹⁵",{"type":22,"value":950},"）。直接当数字收，",{"type":17,"tag":25,"props":952,"children":954},{"className":953},[],[955],{"type":22,"value":956},"JSON.parse",{"type":22,"value":958}," 会",{"type":17,"tag":39,"props":960,"children":961},{},[962],{"type":22,"value":963},"静默丢精度",{"type":22,"value":965},"，导致\"查到的 ID 回传后命中错误记录\"。",{"type":17,"tag":18,"props":967,"children":968},{},[969,971,982,984,990,992,997,999,1005],{"type":22,"value":970},"正确做法是后端对超出安全范围的 Long 序列化成字符串，前端",{"type":17,"tag":39,"props":972,"children":973},{},[974,976],{"type":22,"value":975},"所有 ID 字段一律用 ",{"type":17,"tag":25,"props":977,"children":979},{"className":978},[],[980],{"type":22,"value":981},"string",{"type":22,"value":983},"，只当不透明标识透传（做 key、拼 URL），绝不参与数值运算。坑在于：",{"type":17,"tag":25,"props":985,"children":987},{"className":986},[],[988],{"type":22,"value":989},"id=1",{"type":22,"value":991}," 这种小值后端仍按数字下发，",{"type":17,"tag":39,"props":993,"children":994},{},[995],{"type":22,"value":996},"同一列表里 ID 可能既是数字又是字符串",{"type":22,"value":998},"，前端拿到要统一 ",{"type":17,"tag":25,"props":1000,"children":1002},{"className":1001},[],[1003],{"type":22,"value":1004},"String()",{"type":22,"value":1006}," 归一化——这点在树形多选预勾选时尤其致命（见下）。",{"type":17,"tag":874,"props":1008,"children":1010},{"id":1009},"_3-authrouters-叶子节点-children-为-null崩整棵树",[1011,1013,1019],{"type":22,"value":1012},"3. ",{"type":17,"tag":25,"props":1014,"children":1016},{"className":1015},[],[1017],{"type":22,"value":1018},"\u002Fauth\u002Frouters",{"type":22,"value":1020}," 叶子节点 children 为 null，崩整棵树",{"type":17,"tag":18,"props":1022,"children":1023},{},[1024,1026,1032,1034,1040,1042,1048,1050,1056,1058,1064,1066,1071,1073,1079,1081,1086],{"type":22,"value":1025},"后端构建菜单树时只在有子节点时才 ",{"type":17,"tag":25,"props":1027,"children":1029},{"className":1028},[],[1030],{"type":22,"value":1031},"setChildren",{"type":22,"value":1033},"，叶子菜单的 ",{"type":17,"tag":25,"props":1035,"children":1037},{"className":1036},[],[1038],{"type":22,"value":1039},"children",{"type":22,"value":1041}," 是 Java ",{"type":17,"tag":25,"props":1043,"children":1045},{"className":1044},[],[1046],{"type":22,"value":1047},"null",{"type":22,"value":1049},"，Jackson 默认输出 ",{"type":17,"tag":25,"props":1051,"children":1053},{"className":1052},[],[1054],{"type":22,"value":1055},"\"children\": null",{"type":22,"value":1057},"。前端转换器里 ",{"type":17,"tag":25,"props":1059,"children":1061},{"className":1060},[],[1062],{"type":22,"value":1063},"route.children.map(...)",{"type":22,"value":1065}," 对 ",{"type":17,"tag":25,"props":1067,"children":1069},{"className":1068},[],[1070],{"type":22,"value":1047},{"type":22,"value":1072}," 直接 ",{"type":17,"tag":25,"props":1074,"children":1076},{"className":1075},[],[1077],{"type":22,"value":1078},"TypeError",{"type":22,"value":1080},"，",{"type":17,"tag":39,"props":1082,"children":1083},{},[1084],{"type":22,"value":1085},"整棵动态路由树生成失败",{"type":22,"value":1087},"、白屏。",{"type":17,"tag":18,"props":1089,"children":1090},{},[1091,1093,1099,1101,1106,1108,1114,1116,1122,1124,1130,1132],{"type":22,"value":1092},"而前端类型我声明的是 ",{"type":17,"tag":25,"props":1094,"children":1096},{"className":1095},[],[1097],{"type":22,"value":1098},"children: RouterVo[]",{"type":22,"value":1100},"（非空），TS 以为永远是数组，所以 ",{"type":17,"tag":25,"props":1102,"children":1104},{"className":1103},[],[1105],{"type":22,"value":30},{"type":22,"value":1107},"、",{"type":17,"tag":25,"props":1109,"children":1111},{"className":1110},[],[1112],{"type":22,"value":1113},"build",{"type":22,"value":1115}," 全绿。修复就两处：类型改 ",{"type":17,"tag":25,"props":1117,"children":1119},{"className":1118},[],[1120],{"type":22,"value":1121},"children?: RouterVo[] | null",{"type":22,"value":1123},"，转换器 ",{"type":17,"tag":25,"props":1125,"children":1127},{"className":1126},[],[1128],{"type":22,"value":1129},"(route.children ?? []).map(...)",{"type":22,"value":1131},"。这个坑让我牢牢记住：",{"type":17,"tag":39,"props":1133,"children":1134},{},[1135],{"type":22,"value":1136},"前后端的 nullability 差异，类型检查抓不到，必须连后端跑。",{"type":17,"tag":874,"props":1138,"children":1140},{"id":1139},"_4-el-tree-多选回显node-key-类型不匹配",[1141],{"type":22,"value":1142},"4. el-tree 多选回显：node-key 类型不匹配",{"type":17,"tag":18,"props":1144,"children":1145},{},[1146,1148,1154,1156,1162,1164,1170,1172,1177,1179,1185,1187,1193,1195,1201,1203,1209,1211,1216,1218,1223],{"type":22,"value":1147},"做\"给角色分配菜单\"时用 ",{"type":17,"tag":25,"props":1149,"children":1151},{"className":1150},[],[1152],{"type":22,"value":1153},"el-tree",{"type":22,"value":1155}," 勾选。后端返回的已分配 ",{"type":17,"tag":25,"props":1157,"children":1159},{"className":1158},[],[1160],{"type":22,"value":1161},"menuIds",{"type":22,"value":1163}," 是 ",{"type":17,"tag":25,"props":1165,"children":1167},{"className":1166},[],[1168],{"type":22,"value":1169},"[1, 100, 1000]",{"type":22,"value":1171},"（数字），而 ",{"type":17,"tag":25,"props":1173,"children":1175},{"className":1174},[],[1176],{"type":22,"value":1153},{"type":22,"value":1178}," 的 ",{"type":17,"tag":25,"props":1180,"children":1182},{"className":1181},[],[1183],{"type":22,"value":1184},"node-key",{"type":22,"value":1186}," 来自 ",{"type":17,"tag":25,"props":1188,"children":1190},{"className":1189},[],[1191],{"type":22,"value":1192},"menuId",{"type":22,"value":1194},"。一边数字 ",{"type":17,"tag":25,"props":1196,"children":1198},{"className":1197},[],[1199],{"type":22,"value":1200},"100",{"type":22,"value":1202},"、一边 ",{"type":17,"tag":25,"props":1204,"children":1206},{"className":1205},[],[1207],{"type":22,"value":1208},"setCheckedKeys([\"100\"])",{"type":22,"value":1210}," 字符串，",{"type":17,"tag":25,"props":1212,"children":1214},{"className":1213},[],[1215],{"type":22,"value":516},{"type":22,"value":1217}," 比不上，",{"type":17,"tag":39,"props":1219,"children":1220},{},[1221],{"type":22,"value":1222},"预勾选直接失效",{"type":22,"value":872},{"type":17,"tag":18,"props":1225,"children":1226},{},[1227,1229,1234,1236,1241,1242,1248,1250,1256,1258,1264],{"type":22,"value":1228},"解法还是归一化：整棵树的 ",{"type":17,"tag":25,"props":1230,"children":1232},{"className":1231},[],[1233],{"type":22,"value":1192},{"type":22,"value":1235}," 递归 ",{"type":17,"tag":25,"props":1237,"children":1239},{"className":1238},[],[1240],{"type":22,"value":1004},{"type":22,"value":1080},{"type":17,"tag":25,"props":1243,"children":1245},{"className":1244},[],[1246],{"type":22,"value":1247},"setCheckedKeys",{"type":22,"value":1249}," 入参也 ",{"type":17,"tag":25,"props":1251,"children":1253},{"className":1252},[],[1254],{"type":22,"value":1255},".map(String)",{"type":22,"value":1257},"。另外这里选了 ",{"type":17,"tag":25,"props":1259,"children":1261},{"className":1260},[],[1262],{"type":22,"value":1263},"check-strictly",{"type":22,"value":1265},"（父子独立勾选），避免 RuoYi 那套\"提交要带半选父节点否则菜单树断裂\"的复杂逻辑——精炼优先。",{"type":17,"tag":874,"props":1267,"children":1269},{"id":1268},"_5-首次深链-404-与-el-radio-的-api-变更",[1270],{"type":22,"value":1271},"5. 首次深链 404 与 el-radio 的 API 变更",{"type":17,"tag":145,"props":1273,"children":1274},{},[1275,1339],{"type":17,"tag":149,"props":1276,"children":1277},{},[1278,1283,1285,1291,1293,1299,1301,1306,1308,1314,1315,1321,1323,1329,1331,1337],{"type":17,"tag":39,"props":1279,"children":1280},{},[1281],{"type":22,"value":1282},"首次深链",{"type":22,"value":1284},"：直接访问 ",{"type":17,"tag":25,"props":1286,"children":1288},{"className":1287},[],[1289],{"type":22,"value":1290},"\u002Fsystem\u002Fuser",{"type":22,"value":1292},"，此时动态路由还没 ",{"type":17,"tag":25,"props":1294,"children":1296},{"className":1295},[],[1297],{"type":22,"value":1298},"addRoute",{"type":22,"value":1300},"，会先命中 catch-all 404。守卫里补完 ",{"type":17,"tag":25,"props":1302,"children":1304},{"className":1303},[],[1305],{"type":22,"value":1298},{"type":22,"value":1307}," 后不能 ",{"type":17,"tag":25,"props":1309,"children":1311},{"className":1310},[],[1312],{"type":22,"value":1313},"next({ ...to })",{"type":22,"value":942},{"type":17,"tag":25,"props":1316,"children":1318},{"className":1317},[],[1319],{"type":22,"value":1320},"to.name",{"type":22,"value":1322}," 已是 ",{"type":17,"tag":25,"props":1324,"children":1326},{"className":1325},[],[1327],{"type":22,"value":1328},"NotFound",{"type":22,"value":1330},"，带回去继续 404），要 ",{"type":17,"tag":25,"props":1332,"children":1334},{"className":1333},[],[1335],{"type":22,"value":1336},"next({ path: to.path, query, hash, replace: true })",{"type":22,"value":1338}," 只保留地址重新匹配。",{"type":17,"tag":149,"props":1340,"children":1341},{},[1342,1347,1349,1355,1357,1363,1364,1370],{"type":17,"tag":39,"props":1343,"children":1344},{},[1345],{"type":22,"value":1346},"el-radio",{"type":22,"value":1348},"：Element Plus 2.6+ 起单选值要用 ",{"type":17,"tag":25,"props":1350,"children":1352},{"className":1351},[],[1353],{"type":22,"value":1354},"value",{"type":22,"value":1356}," 而非 ",{"type":17,"tag":25,"props":1358,"children":1360},{"className":1359},[],[1361],{"type":22,"value":1362},"label",{"type":22,"value":1080},{"type":17,"tag":25,"props":1365,"children":1367},{"className":1366},[],[1368],{"type":22,"value":1369},"\u003Cel-radio label=\"0\">",{"type":22,"value":1371}," 会触发废弃告警甚至绑定失效。这种只有运行时控制台才看得到，构建不报。",{"type":17,"tag":70,"props":1373,"children":1375},{"id":1374},"五工程化收尾",[1376],{"type":22,"value":1377},"五、工程化收尾",{"type":17,"tag":18,"props":1379,"children":1380},{},[1381,1383,1389,1391,1397,1399,1405,1407,1413],{"type":22,"value":1382},"最后做了打包优化：",{"type":17,"tag":25,"props":1384,"children":1386},{"className":1385},[],[1387],{"type":22,"value":1388},"manualChunks",{"type":22,"value":1390}," 把 ",{"type":17,"tag":25,"props":1392,"children":1394},{"className":1393},[],[1395],{"type":22,"value":1396},"element-plus",{"type":22,"value":1398}," 和 ",{"type":17,"tag":25,"props":1400,"children":1402},{"className":1401},[],[1403],{"type":22,"value":1404},"vue",{"type":22,"value":1406}," 全家桶拆成独立 chunk（独立缓存），把原来 1MB+ 的单体包拆开；用 Vite 的 ",{"type":17,"tag":25,"props":1408,"children":1410},{"className":1409},[],[1411],{"type":22,"value":1412},"css.preprocessorOptions.scss.api = 'modern-compiler'",{"type":22,"value":1414}," 消掉满屏的 Sass legacy-js-api 弃用告警。构建从一堆 warning 到干净通过。",{"type":17,"tag":70,"props":1416,"children":1418},{"id":1417},"小结",[1419],{"type":22,"value":1417},{"type":17,"tag":18,"props":1421,"children":1422},{},[1423],{"type":22,"value":1424},"这套前端最终覆盖了：登录认证闭环 → 双层权限（动态路由 + 指令）→ 用户\u002F角色\u002F菜单三件套及相互分配 → 操作\u002F登录日志审计 → 打包优化。本质是一套\"做好了权限和审计地基、可以往上加业务\"的中后台脚手架。",{"type":17,"tag":18,"props":1426,"children":1427},{},[1428],{"type":22,"value":1429},"但真正让我有收获的不是\"又写了几个 CRUD 页\"，而是那串跨仓契约坑。它们反复印证同一件事：",{"type":17,"tag":1431,"props":1432,"children":1433},"blockquote",{},[1434],{"type":17,"tag":18,"props":1435,"children":1436},{},[1437],{"type":17,"tag":39,"props":1438,"children":1439},{},[1440,1442,1447],{"type":22,"value":1441},"前端的类型系统只能保证\"前端代码自洽\"，它对后端实际返回的形状一无所知。",{"type":17,"tag":25,"props":1443,"children":1445},{"className":1444},[],[1446],{"type":22,"value":30},{"type":22,"value":1448}," 绿 ≠ 功能对。涉及前后端契约的部分，必须连真后端、走真数据验一遍。",{"type":17,"tag":18,"props":1450,"children":1451},{},[1452],{"type":22,"value":1453},"这也是我后来给每个阶段都加一道\"连后端真机验收\"的原因——很多 bug，只有在浏览器里点下去、看 Network 面板里那条真实响应时，才会现形。",{"type":17,"tag":1455,"props":1456,"children":1457},"style",{},[1458],{"type":22,"value":1459},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":451,"depth":451,"links":1461},[1462,1463,1464,1465,1466,1474,1475],{"id":72,"depth":451,"text":72},{"id":101,"depth":451,"text":104},{"id":126,"depth":451,"text":129},{"id":353,"depth":451,"text":356},{"id":857,"depth":451,"text":860,"children":1467},[1468,1469,1470,1472,1473],{"id":876,"depth":461,"text":879},{"id":918,"depth":461,"text":921},{"id":1009,"depth":461,"text":1471},"3. \u002Fauth\u002Frouters 叶子节点 children 为 null，崩整棵树",{"id":1139,"depth":461,"text":1142},{"id":1268,"depth":461,"text":1271},{"id":1374,"depth":451,"text":1377},{"id":1417,"depth":451,"text":1417},"markdown","content:articles:frontend:精炼版RuoYi-Vue-Plus前端(Vue版).md","content","articles\u002Ffrontend\u002F精炼版RuoYi-Vue-Plus前端(Vue版).md","articles\u002Ffrontend\u002F精炼版RuoYi-Vue-Plus前端(Vue版)","md",1780481290975]