[{"data":1,"prerenderedAt":809},["ShallowReactive",2],{"rainink-post-vue3-admin-base":3,"rainink-content-index":669},{"id":4,"title":5,"body":6,"category":653,"cover":654,"date":655,"description":656,"draft":657,"extension":658,"featured":657,"lang":654,"meta":659,"navigation":343,"order":654,"path":660,"period":654,"pinned":657,"repo":654,"seo":661,"slug":654,"stack":654,"stem":662,"tags":663,"thumb":654,"toc":343,"updated":654,"__hash__":668},"content\u002Fposts\u002Fvue3-admin-base.md","B 端后台底座：把权限、菜单、请求收敛成三层",{"type":7,"value":8,"toc":644},"minimark",[9,16,21,24,28,31,59,202,208,212,215,290,293,297,308,579,582,585,593,596,634,637,640],[10,11,12],"blockquote",{},[13,14,15],"p",{},"每个后台项目都从登录页开始，但让项目变难维护的从来不是登录页。",[17,18,20],"h2",{"id":19},"页面越多越需要底座","页面越多，越需要底座",[13,22,23],{},"后台系统有个特点：业务页面长得都差不多，但每个项目都会重新发明一遍权限判断、菜单渲染和请求封装。做了几个之后就能看清，真正值得沉淀的是三层底座。",[17,25,27],{"id":26},"第一层权限模型","第一层：权限模型",[13,29,30],{},"关键是想清楚「权限挂在什么上」。实践中比较稳的做法是三层收敛：",[32,33,34,42,48],"ol",{},[35,36,37,41],"li",{},[38,39,40],"strong",{},"接口权限","：由后端鉴权，前端只负责在 403 时给出统一提示；",[35,43,44,47],{},[38,45,46],{},"路由权限","：前端按角色过滤路由表，决定哪些页面可达；",[35,49,50,53,54,58],{},[38,51,52],{},"操作权限","：按钮级的 ",[55,56,57],"code",{},"v-permission"," 指令，只做展示层收敛，不作为安全边界。",[60,61,66],"pre",{"className":62,"code":63,"language":64,"meta":65,"style":65},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 路由级权限：用一个纯函数过滤，便于单测\nexport function filterRoutes(routes: RouteRecord[], role: string): RouteRecord[] {\n  return routes.filter((route) => {\n    if (!route.meta?.roles) return true\n    return route.meta.roles.includes(role)\n  })\n}\n","ts","",[55,67,68,77,127,154,175,190,196],{"__ignoreMap":65},[69,70,73],"span",{"class":71,"line":72},"line",1,[69,74,76],{"class":75},"sJ8bj","\u002F\u002F 路由级权限：用一个纯函数过滤，便于单测\n",[69,78,80,84,87,91,95,99,102,105,108,111,113,117,120,122,124],{"class":71,"line":79},2,[69,81,83],{"class":82},"szBVR","export",[69,85,86],{"class":82}," function",[69,88,90],{"class":89},"sScJk"," filterRoutes",[69,92,94],{"class":93},"sVt8B","(",[69,96,98],{"class":97},"s4XuR","routes",[69,100,101],{"class":82},":",[69,103,104],{"class":89}," RouteRecord",[69,106,107],{"class":93},"[], ",[69,109,110],{"class":97},"role",[69,112,101],{"class":82},[69,114,116],{"class":115},"sj4cs"," string",[69,118,119],{"class":93},")",[69,121,101],{"class":82},[69,123,104],{"class":89},[69,125,126],{"class":93},"[] {\n",[69,128,130,133,136,139,142,145,148,151],{"class":71,"line":129},3,[69,131,132],{"class":82},"  return",[69,134,135],{"class":93}," routes.",[69,137,138],{"class":89},"filter",[69,140,141],{"class":93},"((",[69,143,144],{"class":97},"route",[69,146,147],{"class":93},") ",[69,149,150],{"class":82},"=>",[69,152,153],{"class":93}," {\n",[69,155,157,160,163,166,169,172],{"class":71,"line":156},4,[69,158,159],{"class":82},"    if",[69,161,162],{"class":93}," (",[69,164,165],{"class":82},"!",[69,167,168],{"class":93},"route.meta?.roles) ",[69,170,171],{"class":82},"return",[69,173,174],{"class":115}," true\n",[69,176,178,181,184,187],{"class":71,"line":177},5,[69,179,180],{"class":82},"    return",[69,182,183],{"class":93}," route.meta.roles.",[69,185,186],{"class":89},"includes",[69,188,189],{"class":93},"(role)\n",[69,191,193],{"class":71,"line":192},6,[69,194,195],{"class":93},"  })\n",[69,197,199],{"class":71,"line":198},7,[69,200,201],{"class":93},"}\n",[13,203,204,205],{},"需要强调：",[38,206,207],{},"前端权限只是体验层收敛，安全边界永远在后端。",[17,209,211],{"id":210},"第二层动态菜单","第二层：动态菜单",[13,213,214],{},"菜单不要手写，让它从路由表里长出来：",[216,217,218,234],"table",{},[219,220,221],"thead",{},[222,223,224,228,231],"tr",{},[225,226,227],"th",{},"字段",[225,229,230],{},"含义",[225,232,233],{},"说明",[235,236,237,251,264,277],"tbody",{},[222,238,239,245,248],{},[240,241,242],"td",{},[55,243,244],{},"meta.title",[240,246,247],{},"菜单文案",[240,249,250],{},"同时用于面包屑",[222,252,253,258,261],{},[240,254,255],{},[55,256,257],{},"meta.icon",[240,259,260],{},"图标名",[240,262,263],{},"统一走图标组件",[222,265,266,271,274],{},[240,267,268],{},[55,269,270],{},"meta.hidden",[240,272,273],{},"是否隐藏",[240,275,276],{},"详情页不进菜单",[222,278,279,284,287],{},[240,280,281],{},[55,282,283],{},"meta.keepAlive",[240,285,286],{},"是否缓存",[240,288,289],{},"列表页常用",[13,291,292],{},"这样新增页面时只改一个地方，菜单、面包屑、标签页会同步跟上。",[17,294,296],{"id":295},"第三层请求层","第三层：请求层",[13,298,299,300,303,304,307],{},"请求层的目标只有两个：",[38,301,302],{},"错误处理统一","、",[38,305,306],{},"业务代码干净","。",[60,309,311],{"className":62,"code":310,"language":64,"meta":65,"style":65},"const http = axios.create({ timeout: 15000 })\n\nhttp.interceptors.response.use(\n  (res) => {\n    const body = res.data\n    if (body.code !== 0) {\n      toast(body.message || '请求失败')\n      return Promise.reject(new Error(body.message))\n    }\n    return body.data\n  },\n  (err) => {\n    const status = err.response?.status\n    if (status === 401) redirectToLogin()\n    else if (status === 403) toast('没有操作权限')\n    else toast('网络异常，请稍后重试')\n    return Promise.reject(err)\n  },\n)\n",[55,312,313,339,345,356,370,383,399,417,443,449,457,463,477,490,512,540,555,569,574],{"__ignoreMap":65},[69,314,315,318,321,324,327,330,333,336],{"class":71,"line":72},[69,316,317],{"class":82},"const",[69,319,320],{"class":115}," http",[69,322,323],{"class":82}," =",[69,325,326],{"class":93}," axios.",[69,328,329],{"class":89},"create",[69,331,332],{"class":93},"({ timeout: ",[69,334,335],{"class":115},"15000",[69,337,338],{"class":93}," })\n",[69,340,341],{"class":71,"line":79},[69,342,344],{"emptyLinePlaceholder":343},true,"\n",[69,346,347,350,353],{"class":71,"line":129},[69,348,349],{"class":93},"http.interceptors.response.",[69,351,352],{"class":89},"use",[69,354,355],{"class":93},"(\n",[69,357,358,361,364,366,368],{"class":71,"line":156},[69,359,360],{"class":93},"  (",[69,362,363],{"class":97},"res",[69,365,147],{"class":93},[69,367,150],{"class":82},[69,369,153],{"class":93},[69,371,372,375,378,380],{"class":71,"line":177},[69,373,374],{"class":82},"    const",[69,376,377],{"class":115}," body",[69,379,323],{"class":82},[69,381,382],{"class":93}," res.data\n",[69,384,385,387,390,393,396],{"class":71,"line":192},[69,386,159],{"class":82},[69,388,389],{"class":93}," (body.code ",[69,391,392],{"class":82},"!==",[69,394,395],{"class":115}," 0",[69,397,398],{"class":93},") {\n",[69,400,401,404,407,410,414],{"class":71,"line":198},[69,402,403],{"class":89},"      toast",[69,405,406],{"class":93},"(body.message ",[69,408,409],{"class":82},"||",[69,411,413],{"class":412},"sZZnC"," '请求失败'",[69,415,416],{"class":93},")\n",[69,418,420,423,426,429,432,434,437,440],{"class":71,"line":419},8,[69,421,422],{"class":82},"      return",[69,424,425],{"class":115}," Promise",[69,427,428],{"class":93},".",[69,430,431],{"class":89},"reject",[69,433,94],{"class":93},[69,435,436],{"class":82},"new",[69,438,439],{"class":89}," Error",[69,441,442],{"class":93},"(body.message))\n",[69,444,446],{"class":71,"line":445},9,[69,447,448],{"class":93},"    }\n",[69,450,452,454],{"class":71,"line":451},10,[69,453,180],{"class":82},[69,455,456],{"class":93}," body.data\n",[69,458,460],{"class":71,"line":459},11,[69,461,462],{"class":93},"  },\n",[69,464,466,468,471,473,475],{"class":71,"line":465},12,[69,467,360],{"class":93},[69,469,470],{"class":97},"err",[69,472,147],{"class":93},[69,474,150],{"class":82},[69,476,153],{"class":93},[69,478,480,482,485,487],{"class":71,"line":479},13,[69,481,374],{"class":82},[69,483,484],{"class":115}," status",[69,486,323],{"class":82},[69,488,489],{"class":93}," err.response?.status\n",[69,491,493,495,498,501,504,506,509],{"class":71,"line":492},14,[69,494,159],{"class":82},[69,496,497],{"class":93}," (status ",[69,499,500],{"class":82},"===",[69,502,503],{"class":115}," 401",[69,505,147],{"class":93},[69,507,508],{"class":89},"redirectToLogin",[69,510,511],{"class":93},"()\n",[69,513,515,518,521,523,525,528,530,533,535,538],{"class":71,"line":514},15,[69,516,517],{"class":82},"    else",[69,519,520],{"class":82}," if",[69,522,497],{"class":93},[69,524,500],{"class":82},[69,526,527],{"class":115}," 403",[69,529,147],{"class":93},[69,531,532],{"class":89},"toast",[69,534,94],{"class":93},[69,536,537],{"class":412},"'没有操作权限'",[69,539,416],{"class":93},[69,541,543,545,548,550,553],{"class":71,"line":542},16,[69,544,517],{"class":82},[69,546,547],{"class":89}," toast",[69,549,94],{"class":93},[69,551,552],{"class":412},"'网络异常，请稍后重试'",[69,554,416],{"class":93},[69,556,558,560,562,564,566],{"class":71,"line":557},17,[69,559,180],{"class":82},[69,561,425],{"class":115},[69,563,428],{"class":93},[69,565,431],{"class":89},[69,567,568],{"class":93},"(err)\n",[69,570,572],{"class":71,"line":571},18,[69,573,462],{"class":93},[69,575,577],{"class":71,"line":576},19,[69,578,416],{"class":93},[13,580,581],{},"拦截器里只做「翻译」，把各种失败翻译成用户能懂的一句话；业务层拿到的永远是成功数据。",[17,583,584],{"id":584},"目录切分",[60,586,591],{"className":587,"code":589,"language":590,"meta":65},[588],"language-text","src\u002F\n├─ api\u002F          # 按业务域拆分的接口定义\n├─ components\u002F   # 通用组件（表格、表单、上传）\n├─ layouts\u002F      # 布局骨架\n├─ router\u002F       # 静态路由 + 权限过滤\n├─ stores\u002F       # 用户、权限、字典\n├─ directives\u002F   # v-permission 等\n└─ views\u002F        # 业务页面\n","text",[55,592,589],{"__ignoreMap":65},[17,594,595],{"id":595},"几条踩过的经验",[597,598,599,605,622,628],"ul",{},[35,600,601,604],{},[38,602,603],{},"别把字典表塞进组件","：字典是全局缓存，用 store 管，页面里只读。",[35,606,607,610,611,614,615,614,618,621],{},[38,608,609],{},"表格分页参数规范化","：",[55,612,613],{},"page"," \u002F ",[55,616,617],{},"pageSize",[55,619,620],{},"total"," 三个字段统一命名，后端不一致就在请求层适配，不要污染页面。",[35,623,624,627],{},[38,625,626],{},"表单校验复用","：把常用的手机号、身份证等规则抽成 composable，别在每个页面重写正则。",[35,629,630,633],{},[38,631,632],{},"列表页一定要有「空状态」和「加载失败」两种形态","：这是后台体验最容易被忽略的地方。",[17,635,636],{"id":636},"小结",[13,638,639],{},"底座的价值在于「下一个项目可以直接复用」。判断标准很简单：新开一个后台项目时，需要重写的业务无关代码有多少 —— 越少越好。",[641,642,643],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":65,"searchDepth":79,"depth":79,"links":645},[646,647,648,649,650,651,652],{"id":19,"depth":79,"text":20},{"id":26,"depth":79,"text":27},{"id":210,"depth":79,"text":211},{"id":295,"depth":79,"text":296},{"id":584,"depth":79,"text":584},{"id":595,"depth":79,"text":595},{"id":636,"depth":79,"text":636},"tech",null,"2026-09-24","后台系统做多了会发现，真正需要沉淀的不是页面，而是权限模型、动态菜单和请求层这三块底座。",false,"md",{},"\u002Fposts\u002Fvue3-admin-base",{"title":5,"description":656},"posts\u002Fvue3-admin-base",[664,665,666,667],"Vue","后台","架构","权限","N4kKOoZxe6WAoOyIzm3B6BHXDQwcVBqGFnpFdQJajAY",{"all":670,"posts":764,"essays":774,"projects":781,"tags":791},[671,687,691,702,714,723,740,754],{"title":672,"description":673,"path":674,"slug":675,"category":653,"categoryLabel":676,"kind":677,"tags":678,"date":683,"updated":684,"minutes":177,"pinned":343,"draft":657,"order":685,"stack":686,"period":65,"repo":65,"thumb":65},"用 Nuxt 4 + Nuxt Content 搭一个纯静态个人站","站点骨架的取舍：内容目录怎么定、SSG 怎么预渲染、设计令牌怎么从原型抽成一套 CSS 变量，以及首屏主题闪烁的解法。","\u002Fposts\u002Fnuxt4-static-site","nuxt4-static-site","技术","post",[679,680,681,682],"Nuxt","Nuxt Content","SSG","前端工程化","2026-10-12","2026-10-18",0,[],{"title":5,"description":656,"path":660,"slug":688,"category":653,"categoryLabel":676,"kind":677,"tags":689,"date":655,"updated":65,"minutes":177,"pinned":657,"draft":657,"order":685,"stack":690,"period":65,"repo":65,"thumb":65},"vue3-admin-base",[664,665,666,667],[],{"title":692,"description":693,"path":694,"slug":695,"category":653,"categoryLabel":676,"kind":677,"tags":696,"date":700,"updated":65,"minutes":177,"pinned":657,"draft":657,"order":685,"stack":701,"period":65,"repo":65,"thumb":65},"工作流引擎实操笔记：节点、流转与幂等","从状态机到可配置流程：一次工作流引擎落地中关于节点模型、流转条件、幂等与补偿的实操记录。","\u002Fposts\u002Fworkflow-engine-notes","workflow-engine-notes",[697,698,666,699],"工作流","后端","幂等","2026-08-30",[],{"title":703,"description":704,"path":705,"slug":706,"category":707,"categoryLabel":708,"kind":677,"tags":709,"date":712,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":685,"stack":713,"period":65,"repo":65,"thumb":65},"上半年读书清单：几本反复翻的技术书","上半年读完与重读的几本书，以及它们各自解决了我在工程实践中的哪个具体问题。","\u002Fposts\u002Freading-2026-h1","reading-2026-h1","life","随笔",[710,708,711],"阅读","效率","2026-07-06",[],{"title":715,"description":716,"path":717,"slug":718,"category":707,"categoryLabel":708,"kind":677,"tags":719,"date":721,"updated":65,"minutes":79,"pinned":657,"draft":657,"order":685,"stack":722,"period":65,"repo":65,"thumb":65},"我的写作与输入工具链","从收集、整理到成稿：一套尽量少依赖工具的写作流程，以及几个用了三年以上没换过的选择。","\u002Fposts\u002Ftools-and-writing","tools-and-writing",[720,711,708],"工具","2026-06-15",[],{"title":724,"description":725,"path":726,"slug":727,"category":728,"categoryLabel":729,"kind":728,"tags":730,"date":731,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":72,"stack":732,"period":738,"repo":65,"thumb":739},"Super Admin 后台底座","一套可复用的 B 端后台前端底座：权限三层收敛、动态菜单、请求层封装与通用表格表单组件，新项目接入只需替换业务页面。","\u002Fprojects\u002Fsuper-admin","super-admin","project","项目",[664,665,682],"2026-05-20",[733,734,735,736,737],"Vue 3","TypeScript","Vite","Pinia","Element Plus","2026 年至今","SA",{"title":741,"description":742,"path":743,"slug":744,"category":728,"categoryLabel":729,"kind":728,"tags":745,"date":746,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":79,"stack":747,"period":752,"repo":65,"thumb":753},"轻量工作流引擎","面向审批场景的轻量工作流：节点与连线可配置，支持条件分支、抄送、超时补偿，重点解决幂等与流程改版两类问题。","\u002Fprojects\u002Fworkflow-engine","workflow-engine",[697,698,666],"2026-04-08",[748,749,750,751],"Spring Boot","MyBatis","MySQL","Redis","2026 年上半年","WF",{"title":755,"description":756,"path":757,"slug":758,"category":728,"categoryLabel":729,"kind":728,"tags":759,"date":684,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":129,"stack":760,"period":762,"repo":65,"thumb":763},"雨墨 RainInk 本站","你正在看的这个站：Nuxt 4 + Nuxt Content 的纯静态个人站，手写设计令牌、无评论无用户输入、产物可直接静态托管。","\u002Fprojects\u002Frainink-site","rainink-site",[679,681,682],[761,733,680,734],"Nuxt 4","2026 年 10 月","RI",[765,768,771],{"title":672,"description":673,"path":674,"slug":675,"category":653,"categoryLabel":676,"kind":677,"tags":766,"date":683,"updated":684,"minutes":177,"pinned":343,"draft":657,"order":685,"stack":767,"period":65,"repo":65,"thumb":65},[679,680,681,682],[],{"title":5,"description":656,"path":660,"slug":688,"category":653,"categoryLabel":676,"kind":677,"tags":769,"date":655,"updated":65,"minutes":177,"pinned":657,"draft":657,"order":685,"stack":770,"period":65,"repo":65,"thumb":65},[664,665,666,667],[],{"title":692,"description":693,"path":694,"slug":695,"category":653,"categoryLabel":676,"kind":677,"tags":772,"date":700,"updated":65,"minutes":177,"pinned":657,"draft":657,"order":685,"stack":773,"period":65,"repo":65,"thumb":65},[697,698,666,699],[],[775,778],{"title":703,"description":704,"path":705,"slug":706,"category":707,"categoryLabel":708,"kind":677,"tags":776,"date":712,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":685,"stack":777,"period":65,"repo":65,"thumb":65},[710,708,711],[],{"title":715,"description":716,"path":717,"slug":718,"category":707,"categoryLabel":708,"kind":677,"tags":779,"date":721,"updated":65,"minutes":79,"pinned":657,"draft":657,"order":685,"stack":780,"period":65,"repo":65,"thumb":65},[720,711,708],[],[782,785,788],{"title":724,"description":725,"path":726,"slug":727,"category":728,"categoryLabel":729,"kind":728,"tags":783,"date":731,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":72,"stack":784,"period":738,"repo":65,"thumb":739},[664,665,682],[733,734,735,736,737],{"title":741,"description":742,"path":743,"slug":744,"category":728,"categoryLabel":729,"kind":728,"tags":786,"date":746,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":79,"stack":787,"period":752,"repo":65,"thumb":753},[697,698,666],[748,749,750,751],{"title":755,"description":756,"path":757,"slug":758,"category":728,"categoryLabel":729,"kind":728,"tags":789,"date":684,"updated":65,"minutes":72,"pinned":657,"draft":657,"order":129,"stack":790,"period":762,"repo":65,"thumb":763},[679,681,682],[761,733,680,734],[792,794,795,796,798,799,800,801,802,803,804,805,806,807,808],{"name":711,"count":79,"weight":793},1.6,{"name":666,"count":79,"weight":793},{"name":708,"count":79,"weight":793},{"name":679,"count":72,"weight":797},1.25,{"name":680,"count":72,"weight":797},{"name":681,"count":72,"weight":797},{"name":664,"count":72,"weight":797},{"name":682,"count":72,"weight":797},{"name":665,"count":72,"weight":797},{"name":698,"count":72,"weight":797},{"name":697,"count":72,"weight":797},{"name":720,"count":72,"weight":797},{"name":699,"count":72,"weight":797},{"name":667,"count":72,"weight":797},{"name":710,"count":72,"weight":797},1792657178959]