Skip to content

路由与权限

项目把“菜单来源”“路由注册”“页面访问”和“按钮展示”拆成独立环节。这样既可以直接使用前端路由,也可以让客户后端下发菜单,同时保持页面权限写法一致。

前端权限不是安全边界

菜单隐藏、路由拦截和按钮隐藏只能改善用户体验。用户仍可以自行构造 HTTP 请求,因此查询、写入、下载和任务执行必须由后端根据当前身份重新鉴权。

权限数据来源

GET /api/v1/user/info 返回:

json
{
  "roles": ["R_ADMIN"],
  "buttons": [
    "system:user:add",
    "system:user:edit",
    "system:user:delete"
  ]
}
字段用途
roles本地菜单过滤、v-roleshasRole
buttonsv-authhasAuth

超级管理员可以返回 buttons: ["*"]。通配符不能与其他权限码同时存在。

菜单模式

配置位于 .env

ini
VITE_MENU_SOURCE = local
模式菜单来源适合场景
localsrc/router/modules菜单结构由前端版本控制,后端只返回角色和按钮
remote/api/v1/user/menus后端需要按租户、组织或账号下发完整菜单树

非法值会按 local 处理,保证首次启动不依赖菜单接口。

动态路由初始化

首次进入受保护页面时:

text
路由守卫
  -> 恢复 Access Token
  -> GET /api/v1/user/info
  -> MenuProcessor 获取菜单
  -> 校验菜单结构和路径
  -> 规范化子路由路径与 redirect
  -> RouteRegistry 注册路由
  -> menuStore 保存菜单
  -> 检查原目标路径权限
  -> 恢复导航

动态路由只会初始化一次。登出、账号切换或收到 401 时,会清除用户状态、动态路由和相关缓存,以免新账号看到旧账号菜单。

本地菜单

本地动态路由入口位于:

text
src/router/routes/asyncRoutes.ts
src/router/modules/*.ts

路由示例:

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
  • 目录路由可以由处理器补充布局组件和默认跳转。

远程菜单

启用:

ini
VITE_MENU_SOURCE = remote

前端将请求:

text
GET /api/v1/user/menus

响应 data 是完整路由树:

json
[
  {
    "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非空且全局唯一
componentsrc/views 下的安全组件路径,目录或外链可按规则省略
meta.title非空字符串,可直接使用 i18n key
meta.icon可选,Iconify 图标名
meta.roles可选,非空字符串数组
meta.link可选,只允许 HTTP(S) 外链
meta.isIframe可选,iframe 页面标记
meta.isHide可选,注册路由但隐藏菜单入口
children可选,结构相同的子路由数组

远程菜单会执行严格校验:

  • 禁止重复路由名称。
  • 禁止 ..、反斜杠和不安全组件路径。
  • 禁止非 HTTP(S) 外链。
  • rolesauthList 等字段必须满足类型要求。
  • 叶子节点必须是页面、外链或 iframe,不能是不可访问的空节点。

任一节点不合法都会拒绝整棵菜单,不会产生半注册状态。

按钮权限不放在远程菜单接口

当前用户的 buttons 始终来自 /api/v1/user/info。菜单接口负责路由树,用户接口负责当前身份和按钮权限,两者职责不要混合。

按钮权限

路由通过权限前缀和操作列表描述页面能力:

ts
meta: {
  permissionPrefix: 'system:user',
  authList: [
    { title: '新增用户', authMark: 'add' },
    { title: '编辑用户', authMark: 'edit' },
    { title: '删除用户', authMark: 'delete' }
  ]
}

页面使用短权限标记:

vue
<ElButton v-auth="'add'" type="primary">新增</ElButton>
<ElButton v-if="hasAuth('edit')">编辑</ElButton>

运行时会组合为:

text
system:user:add
system:user:edit

在脚本中使用:

ts
const { hasAuth, hasRole } = useAuth()

if (hasAuth('delete')) {
  // 展示删除入口
}

角色指令

vue
<ElButton v-roles="['R_ADMIN', 'R_AUDITOR']">
  查看审计记录
</ElButton>

角色更适合控制模块或身份级入口,按钮权限更适合控制具体业务操作。不要为每个按钮都创建角色,也不要用按钮权限替代服务端资源权限。

隐藏详情页

详情、编辑等页面可以注册在路由树中,同时通过 meta.isHide: true 隐藏菜单入口。远程菜单模式下,后端仍要返回这些页面,否则用户从列表点击详情时无法匹配动态路由。

新增页面检查清单

  1. src/views/<domain> 创建页面。
  2. src/router/modules/<domain>.ts 注册路由。
  3. 确保 name 全局唯一,子路由路径为相对路径。
  4. 添加 meta.title、图标和必要的 roles
  5. 有按钮权限时配置 permissionPrefixauthList
  6. 页面使用 v-authhasAuth 或角色判断。
  7. 远程菜单模式同步后端菜单数据。
  8. 后端接口实现身份、资源和数据范围鉴权。
  9. 分别用有权限和无权限账号验证菜单、URL 直达与按钮。

常见问题

登录成功后菜单为空

本地模式检查用户 roles 是否能匹配路由;远程模式检查菜单 data 是否为数组且至少有一个可访问页面。

菜单能看到,进入页面却是 403

检查目标路径是否真实存在于当前菜单树,以及父子路径规范化后是否与浏览器地址一致。

按钮全部不显示

检查当前路由是否配置 permissionPrefix,用户 buttons 是否返回完整权限码,authListauthMark 是否与页面一致。

远程菜单进入 500 页面

查看浏览器 Console 中 远程菜单数据格式错误 信息。重点检查重复 name、绝对子路径、组件路径和空目录节点。

根据 MIT 许可证发布