用 Vue 3 + Pinia 做「字段级」权限控制,这可能是你见过最细粒度的 RBAC 实战

如果你做过后台管理系统,一定遇到过这种需求:
– 同样是「销售报表」页面,普通销售只能看自己的数据,销售经理可以看全部门,财务还能看金额列。
– 同一个按钮,管理员能点,其他人连看都看不到。
– 用户刚被领导升了职,菜单立刻出现新的「管理后台」入口,无需刷新页面。

这篇文章,我把自己最近落地的一套「细粒度 RBAC」方案完整梳理出来,粒度到字段级代码可运行思路可迁移。如果你正准备做权限系统,直接抄作业就行。


1. 什么是「细粒度 RBAC」?

基于角色(Role)的访问控制(RBAC)是一种基于组织中用户的角色来调节控制对计算机或网络资源的访问的方法。

RBAC(Role-Based Access Control)大家都不陌生,但很多人只停留在「页面能不能进」的维度。
我们把权限拆成了三层:

维度 控制点 举例
页面级 路由守卫 销售不能访问 /admin
按钮级 DOM 显隐/禁用 非管理员隐藏「删除」按钮
字段级 数据过滤 普通销售看不到「利润」列

最终目标是:后端只返回「用户该看的数据」,前端只渲染「用户能看的 UI」。任何一层被绕过,都有兜底。


2. 权限协议:让后端只约定「一份 JSON」

后端不需要懂前端组件长什么样,只需要按约定返回一份「权限清单」:

1
2
3
4
5
6
7
8
9
10
11
12
// types/permission.ts
export type Permission = {
role: "admin" | "editor" | "viewer";
resources: Record<string, {
canRead: boolean;
canWrite?: boolean;
fields?: string[]; // 字段白名单
fieldACL?: { // 更精细的字段级 ACL
[field: string]: { readable: boolean; writable?: boolean }
};
}>;
};

示例:一个「编辑」角色,在「销售数据」资源里只能读,且只能看 productrevenue 两列。

1
2
3
4
5
6
7
8
9
10
{
"role": "editor",
"resources": {
"dashboard": { "canRead": true, "canWrite": false },
"sales_data": {
"canRead": true,
"fields": ["product", "revenue"]
}
}
}

前端拿到这份协议后,所有权限判断都基于它,不再跟后端沟通「能不能看」


3. 用 Pinia 做单一数据源

权限状态最忌讳「多处拷贝」,我们用 Pinia 做一个只读 Store,所有组件、指令、路由守卫都从它拿数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// stores/permissionStore.ts
export const usePermissionStore = defineStore('permission', {
state: () => ({ permissions: null as Permission | null }),
getters: {
hasAccess: state => (res: string, act: 'read' | 'write') =>
state.permissions?.resources[res]?.[`can${capitalize(act)}`] ?? false,

allowedFields: state => (res: string) =>
state.permissions?.resources[res]?.fields || [],

// 新增:字段级 ACL
fieldACL: state => (res: string, field: string) =>
state.permissions?.resources[res]?.fieldACL?.[field] || { readable: false }
},
actions: {
setPermissions(p: Permission) { this.permissions = p; }
}
});

拿到权限后,再初始化路由和菜单,避免「先渲染后权限」的闪现问题:

1
2
3
4
5
6
// main.ts
await fetchUserPermission(); // 先拿权限
app.use(createPinia());
permissionStore.setPermissions(perm);
await router.isReady();
app.mount('#app');

4. 路由守卫:让「进错页面」成为历史

1
2
3
4
5
6
7
8
9
10
11
12
// router/index.ts
router.beforeEach(async (to, from, next) => {
const pStore = usePermissionStore();

if (!pStore.permissions) return next('/login'); // 未登录
if (!to.meta.resource) return next(); // 无需鉴权

const resource = to.meta.resource as string;
if (!pStore.hasAccess(resource, 'read')) return next('/403');

next();
});

提示:

  • 403 页面也放一张「联系管理员」的二维码,用户反馈一步到位。
  • 如果用了动态路由(见第 6 节),守卫要 await 动态添加完成后再检查。

5. 表格组件:字段级过滤,一行代码搞定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!-- SecuredTable.vue -->
<script setup lang="ts">
const props = defineProps<{
resource: string;
rawData: Record<string, any>[];
}>();

const pStore = usePermissionStore();

const columns = computed(() =>
props.rawData.length
? Object.keys(props.rawData[0]).filter(k =>
pStore.allowedFields(props.resource).includes(k)
)
: []
);

const rows = computed(() =>
props.rawData.map(row =>
Object.fromEntries(
columns.value.map(k => [k, row[k]])
)
)
);
</script>

<template>
<el-table :data="rows">
<el-table-column
v-for="col in columns"
:key="col"
:prop="col"
:label="col"
/>
</el-table>
</template>

效果:

  • 后端返回 20 列,权限里只给了 5 列,表格自动只剩这 5 列,零配置。
  • 如果想做「可编辑」粒度,把 fieldACL 再套一层即可。

6. 按钮级控制:用自定义指令最优雅

1
2
3
4
5
6
7
// plugins/authDirective.ts
app.directive('auth', {
mounted(el, { value: [res, act] }) {
const pStore = usePermissionStore();
if (!pStore.hasAccess(res, act)) el.remove(); // 直接移除节点
}
});

使用:

1
<el-button v-auth="['sales_data', 'write']">修改金额</el-button>

v-if 更干净,模板里不再有权限逻辑


7. 动态菜单 & 路由:角色一变,UI 秒变

很多后台框架把路由写死在前端,导致「新增角色就得发版」。
我们改为先拿权限,再生成菜单和路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const asyncRoutes = [
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: { resource: 'admin_panel' }
}
];

function registerDynamicRoutes() {
const pStore = usePermissionStore();
asyncRoutes.forEach(route => {
if (pStore.hasAccess(route.meta.resource, 'read')) {
router.addRoute('Layout', route); // Layout 是你的父级路由
menuStore.addVisible(route); // 把菜单也加进去
}
});
}

用户角色被管理员修改后,重新登录或主动拉取权限即可生效,无需前端发版。


8. 权限实时更新:WebSocket vs. 轮询

  • WebSocket:管理员修改角色后广播一条消息,前端收到后 location.reload() 或重新拉权限。
  • 轮询:在 setInterval 里 30s 拉一次,成本低、实现简单,适合管理后台场景。
1
2
3
4
5
6
7
8
setInterval(() => {
fetch('/api/permissions').then(r => r.json()).then(p => {
if (JSON.stringify(p) !== JSON.stringify(permissionStore.permissions)) {
ElMessage.warning('权限已更新,即将刷新');
location.reload();
}
});
}, 30000);

9. 安全兜底:前端只是「体验优化」

再强调一次:前端权限只是提升用户体验,真正的安全在后端

  • 所有接口仍需后端按用户 ID + 角色再校验。
  • 敏感字段(如利润、成本)如果前端被绕过,后端直接报错 403

10. 完整目录结构(可直接拷贝到项目)

1
2
3
4
5
6
7
8
9
10
11
12
src
├─ types/permission.ts
├─ stores
│ ├─ permissionStore.ts
│ └─ menuStore.ts
├─ router/index.ts
├─ plugins/authDirective.ts
├─ components/SecuredTable.vue
└─ views
├─ Dashboard.vue
├─ Sales.vue
└─ 403.vue