低代码平台的权限问题比传统后台系统更棘手。传统系统的页面和接口相对固定,写死几个角色判断就够了;而低代码平台的表单、列表、按钮都是运行时动态生成的,权限必须跟着元数据走,不能跟着代码走。很多团队在第一版只做了菜单权限,上线后发现用户虽然看不到某个按钮,却可以直接调接口改别人的数据,这就是典型的只做了功能权限、漏了数据权限。本文围绕 Vue 3 的组合式 API,把角色权限和数据权限的完整链路捋一遍。

一、权限体系整体设计:前端管体验,后端管安全
先明确一个原则:前端权限控制的目的是用户体验,不是安全。任何只在前端做的权限拦截,用户打开浏览器开发者工具就能绕过。所以整个体系要分两层:前端负责根据角色渲染对应的菜单、按钮和数据范围,让用户看不到不该看的东西;后端在每一个接口入口处校验角色和数据归属,保证即使前端被绕过,数据也拿不到。
在这个前提下,权限模型通常拆成三个概念:角色是权限的集合,比如管理员、运营、普通员工;功能权限指能不能执行某个操作,对应菜单、页面、按钮;数据权限指能操作哪些范围的数据,比如只能看自己创建的订单、只能看本部门的客户。低代码平台的特点是页面和按钮都是元数据驱动的,所以功能权限最好绑定在元数据的 permission 字段上,而不是写死在代码里。
权限数据的下发时机也有讲究。推荐在用户登录后,由后端一次性返回该用户拥有的完整权限标识列表,前端存入 Pinia。这样路由守卫和按钮指令都能同步判断,避免每个组件各自请求权限接口造成的抖动。
二、动态路由与菜单权限的实现
Vue 3 里推荐用 createRouter 加路由守卫的组合方案。基础思路是:路由表分静态和动态两部分,静态路由放登录页、404 页这类所有人可见的页面;动态路由根据后端返回的菜单树,在登录成功后用 router.addRoute 注册。这样改角色权限只需要改后端配置,前端不用重新发版。
核心代码大概是这样的结构:
const routes = [
{ path: '/login', component: Login }
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from) => {
const userStore = useUserStore()
if (!userStore.token) return '/login'
// 动态路由只注册一次
if (!userStore.routesAdded) {
const menus = await fetchUserMenus()
buildRoutes(menus).forEach(route => router.addRoute(route))
userStore.routesAdded = true
// addRoute 后需要重新跳转一次才能命中新路由
return { ...to, replace: true }
}
if (!to.matched.length) return '/404'
})这里有个高频坑:addRoute 是异步生效的,第一次注册完动态路由后,当前导航已经解析完了,必须用 return { ...to, replace: true } 重新触发一次导航,否则会白屏或者直接掉进 404。另外 404 路由要放在动态路由注册之后再匹配,或者用通配写法兜底,不能写死在静态路由最前面。
菜单渲染则直接消费后端返回的菜单树,用递归组件生成 el-menu 或自定义侧边栏。菜单项上带上权限标识,如果后端已经按角色过滤过菜单树,前端甚至不需要再判断,直接渲染即可,这样逻辑最简单。
三、按钮级权限:自定义指令统一收口
页面内的按钮权限如果靠每个组件里写 v-if="hasPermission('order:delete')",代码会散落各处,后期梳理权限点非常痛苦。更优雅的方式是封装一个自定义指令 v-permission,统一从 Pinia 的权限集合里判断,没有权限时直接把元素从 DOM 中移除。
// directives/permission.js
export const permission = {
mounted(el, binding) {
const userStore = useUserStore()
const { value } = binding
if (!value) return
const has = Array.isArray(value)
? value.some(p => userStore.permissions.includes(p))
: userStore.permissions.includes(value)
if (!has) {
el.parentNode && el.parentNode.removeChild(el)
}
}
}
// 使用方式
// <el-button v-permission="'order:delete'">删除订单</el-button>
// <el-button v-permission="['order:export', 'order:audit']">导出或审核</el-button>低代码平台的表单渲染器可以更进一步:在解析页面元数据时,遇到按钮组件自动读取其 permission 字段并挂上指令,业务方配置表单时勾选权限点即可,完全不用碰代码。需要注意指令方式是直接删 DOM,如果只是想禁用而不是隐藏,可以改成一个组合式函数 usePermission,返回布尔值后用 :disabled 绑定,交互上更友好,用户能感知到功能存在但无权操作。
四、数据权限:行级过滤怎么做
数据权限是很多团队容易漏掉的部分。常见的数据范围有四档:全部数据、本部门、本部门及下级、仅本人。落到实现上,前端和后端各有一份工作。后端在查询接口里根据用户角色拼接过滤条件,比如给 SQL 注入 create_by = 用户ID 或者 dept_id IN (部门集合);前端则在列表页把过滤条件同步传给接口,并且隐藏无权数据的操作按钮。
在低代码平台里,数据权限可以做成元数据配置。每个列表模型定义一个数据范围字段,运行时根据当前用户的部门和角色动态计算过滤参数。示意的查询参数大概是这样:
// 根据角色计算数据范围参数
function buildDataScope(userStore) {
switch (userStore.dataScope) {
case 'all':
return {} // 不加过滤,后端管理员角色放行
case 'dept':
return { deptId: userStore.deptId }
case 'deptAndChild':
return { deptIdPath: userStore.deptIdPath } // 如 /1/12/120/
case 'self':
return { createBy: userStore.userId }
}
}
// 列表查询时合并参数
const params = { page: 1, size: 20, ...buildDataScope(userStore) }部门树用路径编码(如 /1/12/120/)是比递归查子部门更高效的方案,一次 LIKE '/1/12/%' 就能查出本部门及全部下级,避免每次查询都递归展开部门树。另外要强调一点:这些过滤参数只是给后端的提示,后端必须独立校验,不能信任前端传来的 deptId,否则攻击者改一下参数就能越权拉取全量数据。
五、常见坑与优化建议
第一是权限缓存问题。权限列表存在 Pinia 里,用户切换角色或管理员修改权限后,前端不会自动感知。简单做法是权限变更后强制相关用户重新登录;体验更好的做法是后端推送变更事件,前端重新拉取权限并重建路由,重建前记得调用 router.removeRoute 清掉旧路由。
第二是按钮指令的时序问题。v-permission 在 mounted 里移除元素,如果父组件后续还操作该元素可能报错,必要时可以在 beforeMount 里置为隐藏,或在指令里同时处理 updated 钩子应对权限动态变化的场景。
第三是权限点命名规范。建议统一用 模块:资源:操作 的三段式,比如 order:list:export,前后端共用同一套标识。低代码平台的权限点最好由页面设计器在保存元数据时自动登记到权限表中,避免手工维护出现遗漏。把这些细节提前定好,权限体系后期扩展才不会失控。