路由与菜单
路由与菜单
业务用途
路由是前端应用的骨架,决定了「用户能访问哪些页面、以什么 URL 访问、访问前要做什么校验」。本平台采用 Vue Router 4(History 模式),在单文件 frontend/src/router/index.ts(约 500 行)中集中定义全部路由与全局守卫。核心能力包括:
- 静态路由表覆盖系统管理、应用管理、数据管理、设计器、流程中心、ERP、HR 等全部页面。
- 全局守卫
beforeEach实现 JWT 过期检测、登录态校验、菜单路径白名单三重拦截。 - 应用运行时(
/app/:appId)作为顶级路由独立于主布局,支持对外发布的应用以「无框架外壳」的方式运行。
涉及文件
frontend/src/router/index.ts-- 路由表 + 全局守卫(核心)frontend/src/layout/index.vue-- 主布局组件,负责加载菜单树并写入accessibleMenuPathsfrontend/src/views/login/Login.vue-- 登录页,登录成功后写入 Token / 权限frontend/src/views/runtime/AppRuntime.vue-- 应用运行时(独立布局)frontend/src/store/user.ts-- 用户状态(Token / 权限 / 角色)
实现机制
路由表结构
路由表在 frontend/src/router/index.ts 顶部以常量数组定义,分为三大块:
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
// ① 公开页面:无需登录
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Login.vue'),
meta: { title: '登录', requiresAuth: false }
},
{
path: '/sso/callback',
name: 'SsoCallback',
component: () => import('@/views/login/SsoCallback.vue'),
meta: { title: '单点登录', requiresAuth: false }
},
// ② 主应用:带 Layout 外壳
{
path: '/',
name: 'Layout',
component: () => import('@/layout/index.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', requiresAuth: true } },
{ path: 'system/user', name: 'UserManage', component: () => import('@/views/system/user/index.vue'),
meta: { title: '用户管理', requiresAuth: true } },
// ... 系统管理 / 应用管理 / 数据管理 / 设计器 / 流程中心 / ERP / HR 等子路由
]
},
// ③ 应用运行时:顶级路由,脱离主布局
{
path: '/app/:appId',
name: 'AppRuntime',
component: () => import('@/views/runtime/AppRuntime.vue'),
meta: { title: '应用运行', requiresAuth: false }
},
{
path: '/app/:appId/page/:pageId',
name: 'PageRuntime',
component: () => import('@/views/runtime/AppRuntime.vue'),
meta: { title: '应用页面', requiresAuth: false }
},
// 兜底:缺少 pageId 时回退到应用首页
{ path: '/app/:appId/page', redirect: (to: any) => `/app/${to.params.appId}` },
{ path: '/app/:appId/page/', redirect: (to: any) => `/app/${to.params.appId}` }
]
const router = createRouter({
history: createWebHistory(),
routes: routes as any
})为什么要用动态 import
所有 component 都写成 () => import('@/views/xxx.vue') 的形式,这是 Vue Router 的路由懒加载。每个路由对应的代码会被 Vite 打包成独立 chunk,首次访问该路由时才加载,显著减小首屏体积。
路由 Meta 约定
每条路由的 meta 对象包含两个关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
title | string | 页面标题,守卫会拼接为 ${title} - 企业级低代码开发平台 |
requiresAuth | boolean | 是否需要登录态。true 表示需要登录后才能访问 |
/login、/sso/callback、/app/:appId等运行时路由requiresAuth: false。- Layout 下的所有业务路由
requiresAuth: true。
主布局子路由分类
Layout(/)下的 children 按业务域分组,覆盖平台全部功能:
| 业务域 | 代表路由 | 说明 |
|---|---|---|
| 首页 | /dashboard | 仪表盘首页 |
| 系统管理 | /system/user、/system/role、/system/menu、/system/dept、/system/tenant、/system/dict 等 | 用户 / 角色 / 菜单 / 部门 / 租户 / 字典 |
| 应用管理 | /application/workspace、/application/builder/:id | 应用中心、应用构建器 |
| 数据管理 | /database/designer、/database/data-source | 表设计器、数据源管理 |
| 设计器 | /designer/form/builder、/designer/page/builder/:id、/designer/report/builder/:id | 表单 / 列表 / 页面 / 报表设计器 |
| 流程中心 | /bpm/model、/bpm/todo、/bpm/approval/:taskId | 流程模型、待办、审批详情 |
| 代码生成 | /generator/code | 代码生成器 |
| AI 助手 | /ai/assistant | AI 开发助手 |
| 行业模板 | /template/list | 模板列表 |
| ERP 示例 | /erp/supplier、/erp/purchase、/erp/inventory 等 | 供应商 / 采购 / 库存等 |
| 人事管理 | /hr/employee、/hr/demand、/hr/interview | 员工 / 需求 / 面试 |
JWT 过期检测
守卫中定义了 isTokenExpired 函数,对 JWT 进行本地过期检测:
const isTokenExpired = (token: string) => {
try {
// JWT 格式:header.payload.signature,取 payload 做 base64 解码
const payload = JSON.parse(decodeURIComponent(escape(window.atob(token.split('.')[1] || ''))))
// payload.exp 是秒级时间戳,对比当前时间
return payload.exp && payload.exp * 1000 <= Date.now()
} catch {
return true // 解析失败视为已过期
}
}escape/atob 的兼容性处理
window.atob 返回的是 Latin-1 字符串,直接 JSON.parse 在遇到中文或多字节字符时会乱码。这里用 decodeURIComponent(escape(...)) 做了一次编码转换,将 Latin-1 字符串正确还原为 UTF-8。这是处理 JWT payload 含中文的经典技巧。
全局守卫 beforeEach
router.beforeEach 是整个权限体系的核心,按顺序执行四重校验:
router.beforeEach((to, from, next) => {
// ① 设置页面标题
document.title = `${to.meta.title || '低代码平台'} - 企业级低代码开发平台`
const token = localStorage.getItem('token')
// ② JWT 过期检测:过期则清除登录态
if (token && isTokenExpired(token)) {
clearLoginState()
if (to.meta.requiresAuth) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
}
// ③ 需要登录但无 Token:跳登录页,记住目标地址
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
if (to.fullPath && to.fullPath !== '/login') {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next('/login')
}
// ④ 已登录访问登录页:直接跳回(带 redirect 优先跳 redirect)
} else if (to.path === '/login' && token) {
const redirect = (to.query.redirect as string) || '/'
next(redirect)
// ⑤ 菜单白名单校验:有权限的路由才能进入,否则回首页
} else if (to.meta.requiresAuth && !isPathAllowed(to.path)) {
next('/dashboard')
} else {
next()
}
})校验流程图:
请求进入路由 to
│
▼
设置 document.title
│
▼
Token 存在且已过期?──是──▶ clearLoginState() + 跳登录页(若需登录)
│否
▼
requiresAuth 且无 Token?──是──▶ 跳 /login?redirect=to.fullPath
│否
▼
访问 /login 且已登录?──是──▶ 跳 redirect 或 /
│否
▼
requiresAuth 且不在菜单白名单?──是──▶ 跳 /dashboard
│否
▼
放行 next()菜单白名单 accessibleMenuPaths
这是平台「按钮级以外」的页面级权限控制。核心思路:用户登录后,后端返回该用户可见的菜单树,前端将其所有叶子节点的 path 拍平存入 localStorage 的 accessibleMenuPaths,守卫据此判断目标路径是否可访问。
写入时机在 frontend/src/layout/index.vue 的 loadMenus 方法中:
const loadMenus = async () => {
try {
const res = await request.get('/system/menu/tree')
const menus = filterVisibleMenus(res.data || [])
menuTree.value = menus
// 将菜单树拍平为路径数组,写入 localStorage
localStorage.setItem('accessibleMenuPaths', JSON.stringify(flattenMenuPaths(menus)))
} catch (error) {
menuTree.value = []
localStorage.setItem('accessibleMenuPaths', '[]')
}
}flattenMenuPaths 递归收集所有 menuType === 'menu' 的节点路径。

校验逻辑 isPathAllowed:
const isPathAllowed = (path: string) => {
// 1. 绝对白名单:首页与根路径
if (allowWithoutMenu.includes(path)) return true
// 2. 前缀白名单:设计器 / 运行时 / 审批等带参数路由
if (allowWithoutMenuPrefixes.some(prefix => path.startsWith(prefix))) return true
// 3. 动态菜单白名单:用户可见菜单路径
const paths = JSON.parse(localStorage.getItem('accessibleMenuPaths') || '[]')
return paths.some((item: string) => path === item || path.startsWith(`${item}/`))
}allowWithoutMenu(绝对白名单)
const allowWithoutMenu = ['/dashboard', '/']首页和根路径不需要任何菜单权限即可访问。
allowWithoutMenuPrefixes(前缀白名单)
带路径参数的页面无法直接匹配菜单路径(如 /application/builder/123),因此用前缀匹配放行:
const allowWithoutMenuPrefixes = [
'/application/builder/', // 应用构建器(带 :id)
'/app/', // 应用运行时(带 :appId)
'/database/table-designer', // 数据表设计
'/designer/form/builder', // 表单设计器
'/designer/list/builder', // 列表设计器
'/designer/page/builder/', // 页面设计器(带 :id)
'/designer/report/builder/', // 报表设计器(带 :id)
'/bpm/approval/' // 审批详情(带 :taskId,权限由后端基于 taskId 校验)
]为什么审批详情放前缀白名单
/bpm/approval/:taskId 是从应用运行时「审批详情」按钮跳入的,taskId 是动态的,无法预先写入菜单白名单。这里前端放行,真正的权限校验交给后端:后端基于 taskId 判断当前用户是否有权审批该任务。这是前后端分工的典型实践。
清除登录状态
当 JWT 过期或响应拦截器收到 401 时,调用 clearLoginState 清除全部登录痕迹:
const clearLoginState = () => {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
localStorage.removeItem('userId')
localStorage.removeItem('tenantId')
localStorage.removeItem('permissions')
localStorage.removeItem('roles')
localStorage.removeItem('accessibleMenuPaths')
}clearLoginState 与 store.clearSession 的区别
路由守卫里的 clearLoginState 直接操作 localStorage,不经过 Pinia Store(因为守卫执行时 Store 可能尚未就绪)。而请求层 request.ts 中的 redirectToLogin 调用的是 userStore.clearSession(),会同步清空 Pinia 响应式状态和 localStorage。两者清理的 localStorage 键完全一致,但触发场景不同:守卫清 JWT 过期,请求层清 401 响应。详见 。
应用运行时路由
/app/:appId 与 /app/:appId/page/:pageId 是两个特殊的顶级路由,它们不在 Layout 之下,意味着应用运行时没有侧边栏、顶栏等平台外壳,适合对外发布的独立应用:
{
path: '/app/:appId',
name: 'AppRuntime',
component: () => import('@/views/runtime/AppRuntime.vue'),
meta: { title: '应用运行', requiresAuth: false } // 自身处理登录态
}requiresAuth: false 并不意味着完全公开,而是 AppRuntime.vue 内部自行处理登录态校验(支持免登浏览、独立登录等场景)。
兜底路由处理缺少 pageId 的情况:
// 必须用函数,字符串里的 :appId 不会被替换
{ path: '/app/:appId/page', redirect: (to: any) => `/app/${to.params.appId}` }不要用字符串重定向
redirect: '/app/:appId' 这种字符串形式不会自动替换 :appId 参数,会导致跳转到字面量 /app/:appId。必须用函数形式 (to) => \/app/${to.params.appId}`` 手动拼接。
操作步骤
新增一个需要登录的业务页面
- 在
frontend/src/views/下对应业务目录创建.vue文件,例如views/system/log.vue。 - 在
frontend/src/router/index.ts的 Layoutchildren数组中添加路由:
{
path: 'system/log',
name: 'SystemLog',
component: () => import('@/views/system/log.vue'),
meta: { title: '系统日志', requiresAuth: true }
}- 在后台「菜单管理」中新增对应菜单项,
path填/system/log,分配给目标角色。这样用户登录后accessibleMenuPaths才会包含该路径,守卫才会放行。
不配菜单会被拦截
如果只加了路由但没配菜单,用户访问 /system/log 时 isPathAllowed 返回 false,会被守卫重定向到 /dashboard。要么在菜单管理里配置,要么加入 allowWithoutMenu / allowWithoutMenuPrefixes(仅限无需菜单控制的页面)。
新增一个带路径参数的设计器页面
- 创建视图文件
views/designer/workflow/builder.vue。 - 添加路由(注意
:id参数):
{
path: 'designer/workflow/builder/:id',
name: 'WorkflowBuilder',
component: () => import('@/views/designer/workflow/builder.vue'),
meta: { title: '工作流设计器', requiresAuth: true }
}- 由于路径带参数,无法精确匹配菜单白名单,需将前缀加入
allowWithoutMenuPrefixes:
const allowWithoutMenuPrefixes = [
// ... 已有项
'/designer/workflow/builder/'
]常见问题
登录后访问某页面被跳回首页
这是 isPathAllowed 返回 false 导致的。排查步骤:
- 打开浏览器 DevTools -> Application -> Local Storage,检查
accessibleMenuPaths是否包含目标路径。 - 检查后台「菜单管理」是否给当前用户角色分配了该菜单。
- 如果是带参数的路由(如
/application/builder/123),确认其前缀已在allowWithoutMenuPrefixes中。 - 清除 localStorage 重新登录,让
loadMenus重新写入白名单。
JWT 过期后页面不跳登录
守卫中 JWT 过期检测只在路由切换时触发。如果用户停留在某页面不操作,JWT 过期后不会自动跳转,直到下一次路由切换或接口请求收到 401。请求层的 401 处理(见 )作为兜底,会在接口返回 401 时主动跳转登录。
页面标题不更新
标题在 beforeEach 中通过 document.title = ${to.meta.title} - 企业级低代码开发平台 设置。如果标题没更新,检查路由 meta.title 是否配置。动态标题(如「编辑用户 - 张三」)需在组件内 onMounted 中手动设置 document.title。
运行时路由 /app/:appId 显示空白
AppRuntime.vue 自行处理登录态与数据加载。若显示空白:
- 确认
appId对应的应用存在且已发布。 - 打开控制台查看是否有接口报错(通常是应用数据接口 404 或权限不足)。
- 确认后端 52856 端口正常运行。
下一步
- 了解 Token 如何注入请求、401 如何统一处理:
- 了解 Pinia 如何管理 Token 与权限:
