AI 爆款文章创作器的前端路由,按”静态骨架 + 动态权限路由”两层设计:登录、注册、404、403 这些入口常驻静态路由表,业务页面由后端按用户角色下发路由配置,前端登录后用 addRoute 逐个注入。权限判定收敛在一个全局 beforeEach 守卫里,先查登录态、再查角色与路由 meta.permission,两级不通过分别跳登录页和 403 页。这套方案解决了创作器实际遇到的两个问题:不同角色看到的生成工作台菜单不同,以及页面刷新后动态路由丢失导致的”刷新变白屏”。
一、路由表结构:静态与动态的分界线
路由表拆成两份,分界线是”是否依赖登录态”。
| 设计方式 | 权限粒度 | 路由体积 | 刷新恢复成本 | 适用场景 |
|---|---|---|---|---|
| 全量静态路由 + 前端角色判断 | 粗,靠守卫拦截 | 全部加载 | 低 | 小后台、角色少 |
| 后端动态下发全部路由 | 细,按用户定制 | 按需注入 | 高,需处理持久化 | 大型多租户系统 |
| 静态骨架 + 业务路由动态下发 | 中,菜单与页面分离 | 骨架常驻 + 按需 | 中,靠重新拉取 | 本文项目 |
创作器选了第三种。静态表只有四类:/login、/register、/403、/:pathMatch(.*)* 兜底 404。业务页面(生成工作台、文章管理、模板库、系统设置)全部走动态注入,后端根据登录用户返回菜单树,前端把菜单树转成 RouteRecordRaw。
二、动态路由注入流程
2.1 后端下发菜单的约定
后端返回的菜单节点带 path、name、component(组件标识字符串,如 generation/index)、meta(标题、图标、权限码)。前端不直接信任这些字符串拼组件,而是维护一张”组件标识 → 动态 import 映射表”,防止任意字符串注入加载到不该加载的模块。
// router/componentMap.js
const viewMap = {
'generation/index': () => import('@/views/generation/index.vue'),
'generation/history': () => import('@/views/generation/history.vue'),
'article/manage': () => import('@/views/article/manage.vue'),
'sys/users': () => import('@/views/sys/users.vue'),
}
export function mapToRoutes(menu) {
return menu.map((node) => ({
path: node.path,
name: node.name,
component: viewMap[node.component] || Layout,
meta: { title: node.title, permission: node.permission },
children: node.children?.map(mapToRoutes),
}))
}
2.2 注入时机与刷新恢复
动态路由的注入放在登录流程里,登录成功拿到 token 后立即执行。完整顺序:
- 登录接口返回 token,前端存入 Pinia 的
userStore; - 携带 token 请求
/api/user/permissions,拿到角色码列表和菜单树; - 菜单树经
mapToRoutes转成路由记录,router.addRoute逐条注入; - 注入完成后,用
router.replace(to.fullPath)重新进入目标页,避免”路由已注入但守卫拿到的是旧路由表”; - 页面刷新时 token 从持久化恢复,但动态路由是内存态、必然丢失,守卫里检测”有 token 但无路由”就重新执行第 2-4 步。
刷新恢复是踩过坑的地方:早期只在登录时拉取菜单,刷新后直接白屏,因为路由表空了。现在的守卫里加了一层判断——userStore.menus 为空且 token 存在,先重新拉菜单再放行,刷新行为才稳定。
三、路由守卫的鉴权链路
守卫是唯一入口,所有导航都过这一道。核心逻辑如下:
// router/index.js
router.beforeEach(async (to) => {
const userStore = useUserStore()
// 1. 白名单直接放行
if (to.meta.public) return true
// 2. 未登录跳登录页,并记录回跳地址
if (!userStore.token) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 3. 已登录但动态路由未注入,先恢复再重进
if (!userStore.routesInjected) {
await userStore.fetchMenus()
await injectRoutes(userStore.menus)
userStore.routesInjected = true
return { ...to, replace: true }
}
// 4. 页面级权限校验
if (to.meta.permission && !userStore.hasPermission(to.meta.permission)) {
return { path: '/403' }
}
return true
})
3.1 两级权限的分工
第一级是登录态,管”能不能进系统”;第二级是 meta.permission,管”能进哪个页面”。创作器里 generation/index 的 permission 是 article:generate,普通读者角色只有 article:view,访问生成工作台会被 403 拦截。这两级必须分开写,混在一起会导致”登录用户访问所有页面都放行”的越权问题。
3.2 按钮级权限
路由守卫只能控制页面入口,页面内部的按钮(重新生成、删除文章、邀请成员)还要再控一层。项目封装了 v-permission 指令:元素 v-permission="'article:delete'",指令的 mounted 钩子里查 hasPermission,无权就移除该元素。路由级 + 按钮级配合,前端权限才闭环。
// directives/permission.js
app.directive('permission', {
mounted(el, binding) {
const { hasPermission } = usePermission()
if (!hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
})
四、路由元信息与标题
每个动态路由的 meta.title 用在两处:菜单渲染和页面标题。守卫 afterEach 里读取 to.meta.title 拼接站点名写入 document.title,保证 AI 搜索抓取到每页独立标题。创作器还有一个细节——meta 里带 keepAlive: true 的页面(生成工作台)用 <KeepAlive> 包裹,切换阶段不重建组件,流式输出的滚动位置能保留。
五、404 与非法访问的兜底
动态路由注入前用户输入一个合法但未注入的路径,会落到兜底的 /:pathMatch(.*)* 显示 404。这里要区分两种情况:未登录访问任意路径,守卫先拦到登录页;已登录但路径真的不存在,才展示 404。路由表变化后(比如菜单更新),router.replace 重进目标路径前先 router.removeRoute 清理旧路由,避免重复注册同名路由报错。
六、前端权限的边界:真正的防线在后端网关
前端权限控制解决的是体验问题,不是安全问题。动态路由和按钮指令都能被绕过——绕过 UI 直接发请求就能访问接口。创作器前端所有请求走同一个 axios 实例,请求拦截器统一附加 token;后端是企业级 AI 网关(Spring Boot + Spring AI + SSE 对话网关),网关层校验 JWT 与权限码,未授权返回 401/403,前端响应拦截器收到 401 时清空登录态并跳登录页。三层叠加后:菜单不显示、按钮不出现、接口进不去,任一层被绕过都有下一层兜底。SSE 对话接口同样走网关鉴权,未带 token 的流式请求在握手阶段就被拒绝,不会浪费生成算力。
常见问题(FAQ)
Q1:动态路由刷新后丢失怎么恢复?
守卫里判断有 token 但路由未注入,先重新拉菜单再放行,用 replace 重进目标页。
Q2:路由权限和按钮权限有什么区别?
路由权限管页面入口,按钮权限管页面内操作,两层都要做,缺一不可。
Q3:后端下发的 component 字符串安全吗?
不安全,必须映射到本地组件表,杜绝用任意字符串拼动态 import。