路由与权限
项目把“菜单来源”“路由注册”“页面访问”和“按钮展示”拆成独立环节。这样既可以直接使用前端路由,也可以让客户后端下发菜单,同时保持页面权限写法一致。
前端权限不是安全边界
菜单隐藏、路由拦截和按钮隐藏只能改善用户体验。用户仍可以自行构造 HTTP 请求,因此查询、写入、下载和任务执行必须由后端根据当前身份重新鉴权。
权限数据来源
GET /api/v1/user/info 返回:
{
"roles": ["R_ADMIN"],
"buttons": [
"system:user:add",
"system:user:edit",
"system:user:delete"
]
}| 字段 | 用途 |
|---|---|
roles | 本地菜单过滤、v-roles、hasRole |
buttons | v-auth、hasAuth |
超级管理员可以返回 buttons: ["*"]。通配符不能与其他权限码同时存在。
菜单模式
配置位于 .env:
VITE_MENU_SOURCE = local| 模式 | 菜单来源 | 适合场景 |
|---|---|---|
local | src/router/modules | 菜单结构由前端版本控制,后端只返回角色和按钮 |
remote | /api/v1/user/menus | 后端需要按租户、组织或账号下发完整菜单树 |
非法值会按 local 处理,保证首次启动不依赖菜单接口。
动态路由初始化
首次进入受保护页面时:
路由守卫
-> 恢复 Access Token
-> GET /api/v1/user/info
-> MenuProcessor 获取菜单
-> 校验菜单结构和路径
-> 规范化子路由路径与 redirect
-> RouteRegistry 注册路由
-> menuStore 保存菜单
-> 检查原目标路径权限
-> 恢复导航动态路由只会初始化一次。登出、账号切换或收到 401 时,会清除用户状态、动态路由和相关缓存,以免新账号看到旧账号菜单。
本地菜单
本地动态路由入口位于:
src/router/routes/asyncRoutes.ts
src/router/modules/*.ts路由示例:
const routes: AppRouteRecord = {
path: '/customer',
name: 'Customer',
component: '/index/index',
meta: {
title: 'menus.customer.title',
icon: 'ri:user-3-line',
roles: ['R_ADMIN', 'R_OPERATOR']
},
children: [
{
path: 'list',
name: 'CustomerList',
component: '/customer/list',
meta: {
title: 'menus.customer.list'
}
}
]
}角色过滤规则
- 未声明
meta.roles:所有已登录用户都保留该路由。 - 非空数组:用户拥有任意一个角色即可保留。
roles: []:任何用户都不能访问。- 父路由被过滤时,其子路由不会单独保留。
- 过滤后没有可访问子节点的空目录会被移除。
路径规则
- 一级路由通常使用
/开头的绝对路径。 - 二级及更深层子路由使用相对路径,例如
list,不要写/list。 - 路由
name必须全局唯一。 - 页面组件路径相对于
src/views,不包含.vue。 - 目录路由可以由处理器补充布局组件和默认跳转。
远程菜单
启用:
VITE_MENU_SOURCE = remote前端将请求:
GET /api/v1/user/menus响应 data 是完整路由树:
[
{
"path": "/dashboard",
"name": "Dashboard",
"component": "/index/index",
"meta": {
"title": "menus.dashboard.title",
"icon": "ri:pie-chart-line"
},
"children": [
{
"path": "console",
"name": "Console",
"component": "/dashboard/console",
"meta": {
"title": "menus.dashboard.console",
"keepAlive": false,
"fixedTab": true
}
}
]
}
]字段说明
| 字段 | 要求 |
|---|---|
path | 非空字符串,子路由使用相对路径 |
name | 非空且全局唯一 |
component | src/views 下的安全组件路径,目录或外链可按规则省略 |
meta.title | 非空字符串,可直接使用 i18n key |
meta.icon | 可选,Iconify 图标名 |
meta.roles | 可选,非空字符串数组 |
meta.link | 可选,只允许 HTTP(S) 外链 |
meta.isIframe | 可选,iframe 页面标记 |
meta.isHide | 可选,注册路由但隐藏菜单入口 |
children | 可选,结构相同的子路由数组 |
远程菜单会执行严格校验:
- 禁止重复路由名称。
- 禁止
..、反斜杠和不安全组件路径。 - 禁止非 HTTP(S) 外链。
roles、authList等字段必须满足类型要求。- 叶子节点必须是页面、外链或 iframe,不能是不可访问的空节点。
任一节点不合法都会拒绝整棵菜单,不会产生半注册状态。
按钮权限不放在远程菜单接口
当前用户的 buttons 始终来自 /api/v1/user/info。菜单接口负责路由树,用户接口负责当前身份和按钮权限,两者职责不要混合。
按钮权限
路由通过权限前缀和操作列表描述页面能力:
meta: {
permissionPrefix: 'system:user',
authList: [
{ title: '新增用户', authMark: 'add' },
{ title: '编辑用户', authMark: 'edit' },
{ title: '删除用户', authMark: 'delete' }
]
}页面使用短权限标记:
<ElButton v-auth="'add'" type="primary">新增</ElButton>
<ElButton v-if="hasAuth('edit')">编辑</ElButton>运行时会组合为:
system:user:add
system:user:edit在脚本中使用:
const { hasAuth, hasRole } = useAuth()
if (hasAuth('delete')) {
// 展示删除入口
}角色指令
<ElButton v-roles="['R_ADMIN', 'R_AUDITOR']">
查看审计记录
</ElButton>角色更适合控制模块或身份级入口,按钮权限更适合控制具体业务操作。不要为每个按钮都创建角色,也不要用按钮权限替代服务端资源权限。
隐藏详情页
详情、编辑等页面可以注册在路由树中,同时通过 meta.isHide: true 隐藏菜单入口。远程菜单模式下,后端仍要返回这些页面,否则用户从列表点击详情时无法匹配动态路由。
新增页面检查清单
- 在
src/views/<domain>创建页面。 - 在
src/router/modules/<domain>.ts注册路由。 - 确保
name全局唯一,子路由路径为相对路径。 - 添加
meta.title、图标和必要的roles。 - 有按钮权限时配置
permissionPrefix和authList。 - 页面使用
v-auth、hasAuth或角色判断。 - 远程菜单模式同步后端菜单数据。
- 后端接口实现身份、资源和数据范围鉴权。
- 分别用有权限和无权限账号验证菜单、URL 直达与按钮。
常见问题
登录成功后菜单为空
本地模式检查用户 roles 是否能匹配路由;远程模式检查菜单 data 是否为数组且至少有一个可访问页面。
菜单能看到,进入页面却是 403
检查目标路径是否真实存在于当前菜单树,以及父子路径规范化后是否与浏览器地址一致。
按钮全部不显示
检查当前路由是否配置 permissionPrefix,用户 buttons 是否返回完整权限码,authList 的 authMark 是否与页面一致。
远程菜单进入 500 页面
查看浏览器 Console 中 远程菜单数据格式错误 信息。重点检查重复 name、绝对子路径、组件路径和空目录节点。
