Skip to content

前端权限控制

作者:唐亚峰 | battcn
字数统计:1.5k 字

学习目标

掌握 Wemirr Platform 前端权限控制的实现方式

权限模型

前端权限控制分为三个层级:

┌─────────────────────────────────────────┐
│              路由权限                     │
│    控制用户能访问哪些页面                  │
├─────────────────────────────────────────┤
│              按钮权限                     │
│    控制用户能操作哪些按钮                  │
├─────────────────────────────────────────┤
│              数据权限                     │
│    控制用户能看到哪些数据(后端实现)       │
└─────────────────────────────────────────┘

路由权限

权限获取流程

用户登录 → 获取用户信息 → 获取权限列表 → 动态生成路由 → 渲染菜单

路由配置

路由权限通过后端返回的菜单数据动态生成:

typescript
// 后端返回的菜单数据结构
interface MenuItem {
  id: number;
  parentId: number;
  name: string;           // 路由名称
  path: string;           // 路由路径
  component: string;      // 组件路径
  redirect?: string;      // 重定向
  meta: {
    title: string;        // 菜单标题
    icon?: string;        // 菜单图标
    hidden?: boolean;     // 是否隐藏
    keepAlive?: boolean;  // 是否缓存
    permissions?: string[]; // 权限标识
  };
  children?: MenuItem[];
}

路由守卫

typescript
// router/guard.ts
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  const token = userStore.getToken;

  // 白名单路由直接放行
  if (whiteList.includes(to.path)) {
    next();
    return;
  }

  // 未登录跳转登录页
  if (!token) {
    next({ path: '/login', query: { redirect: to.fullPath } });
    return;
  }

  // 已登录但未获取用户信息
  if (!userStore.getUserInfo) {
    try {
      // 获取用户信息和权限
      await userStore.getUserInfoAction();
      // 动态添加路由
      const routes = await permissionStore.buildRoutesAction();
      routes.forEach((route) => router.addRoute(route));
      // 重新进入当前路由
      next({ ...to, replace: true });
    } catch (error) {
      await userStore.logout();
      next({ path: '/login' });
    }
    return;
  }

  next();
});

按钮权限

权限指令

使用 v-auth 指令控制按钮显示:

vue
<template>
  <!-- 单个权限 -->
  <a-button v-auth="'user:add'">新增用户</a-button>
  
  <!-- 多个权限(满足任一即可) -->
  <a-button v-auth="['user:edit', 'user:update']">编辑</a-button>
  
  <!-- 必须满足所有权限 -->
  <a-button v-auth.all="['user:edit', 'user:audit']">审核编辑</a-button>
</template>

指令实现

typescript
// directives/auth.ts
import type { Directive, DirectiveBinding } from 'vue';
import { usePermission } from '@/hooks/web/usePermission';

const auth: Directive = {
  mounted(el: Element, binding: DirectiveBinding) {
    const { hasPermission } = usePermission();
    const { value, modifiers } = binding;

    if (!value) return;

    const permissions = Array.isArray(value) ? value : [value];
    const hasAuth = modifiers.all
      ? permissions.every((p) => hasPermission(p))
      : permissions.some((p) => hasPermission(p));

    if (!hasAuth) {
      el.parentNode?.removeChild(el);
    }
  },
};

export default auth;

函数式判断

在 JS/TS 中判断权限:

typescript
import { usePermission } from '@/hooks/web/usePermission';

const { hasPermission } = usePermission();

// 单个权限判断
if (hasPermission('user:add')) {
  // 有权限
}

// 多个权限判断(任一满足)
if (hasPermission(['user:edit', 'user:update'])) {
  // 有权限
}

// 多个权限判断(全部满足)
if (hasPermission(['user:edit', 'user:audit'], true)) {
  // 有权限
}

usePermission Hook

typescript
// hooks/web/usePermission.ts
import { useUserStore } from '@/store/modules/user';

export function usePermission() {
  const userStore = useUserStore();

  /**
   * 判断是否有权限
   * @param permission 权限标识,可以是字符串或数组
   * @param all 是否需要全部满足,默认 false(满足任一即可)
   */
  function hasPermission(
    permission: string | string[],
    all = false
  ): boolean {
    const permissions = userStore.getPermissions;

    if (!permission) return true;
    if (!permissions || permissions.length === 0) return false;

    // 超级管理员拥有所有权限
    if (permissions.includes('*:*:*')) return true;

    const permissionList = Array.isArray(permission) ? permission : [permission];

    return all
      ? permissionList.every((p) => permissions.includes(p))
      : permissionList.some((p) => permissions.includes(p));
  }

  /**
   * 判断是否有角色
   */
  function hasRole(role: string | string[], all = false): boolean {
    const roles = userStore.getRoles;

    if (!role) return true;
    if (!roles || roles.length === 0) return false;

    const roleList = Array.isArray(role) ? role : [role];

    return all
      ? roleList.every((r) => roles.includes(r))
      : roleList.some((r) => roles.includes(r));
  }

  return {
    hasPermission,
    hasRole,
  };
}

菜单配置

后台菜单配置

在「系统管理」-「菜单管理」中配置菜单:

字段说明示例
菜单名称显示的标题用户管理
路由地址前端路由路径/system/user
组件路径Vue 组件路径wemirr/system/user/index
权限标识权限编码system:user:list
图标菜单图标ant-design:user-outlined

按钮权限配置

按钮作为菜单的子节点配置:

按钮名称权限标识
新增system:user:add
编辑system:user:edit
删除system:user:delete
导出system:user:export

权限标识规范

推荐使用三级命名:模块:资源:操作

system:user:list     # 系统模块-用户-列表
system:user:add      # 系统模块-用户-新增
system:user:edit     # 系统模块-用户-编辑
system:user:delete   # 系统模块-用户-删除
system:user:export   # 系统模块-用户-导出
system:role:list     # 系统模块-角色-列表

实战示例

完整页面示例

vue
<script setup lang="ts">
import { ref } from 'vue';
import { usePermission } from '@/hooks/web/usePermission';
import { getUserList, deleteUser } from './api';

const { hasPermission } = usePermission();

// 根据权限控制列表列显示
const columns = computed(() => {
  const baseColumns = [
    { title: '用户名', dataIndex: 'username' },
    { title: '邮箱', dataIndex: 'email' },
  ];

  // 有查看薪资权限才显示薪资列
  if (hasPermission('user:salary:view')) {
    baseColumns.push({ title: '薪资', dataIndex: 'salary' });
  }

  return baseColumns;
});

// 根据权限控制操作按钮
const actionColumn = computed(() => ({
  title: '操作',
  key: 'action',
  width: 200,
  customRender: ({ record }) => (
    <a-space>
      {hasPermission('user:view') && (
        <a-button type="link" onClick={() => handleView(record)}>
          查看
        </a-button>
      )}
      {hasPermission('user:edit') && (
        <a-button type="link" onClick={() => handleEdit(record)}>
          编辑
        </a-button>
      )}
      {hasPermission('user:delete') && (
        <a-popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
          <a-button type="link" danger>删除</a-button>
        </a-popconfirm>
      )}
    </a-space>
  ),
}));
</script>

<template>
  <div class="user-list">
    <!-- 工具栏 -->
    <div class="toolbar">
      <a-button v-auth="'user:add'" type="primary" @click="handleAdd">
        新增用户
      </a-button>
      <a-button v-auth="'user:export'" @click="handleExport">
        导出
      </a-button>
      <a-button v-auth="'user:import'" @click="handleImport">
        导入
      </a-button>
    </div>

    <!-- 数据表格 -->
    <a-table :columns="[...columns, actionColumn]" :data-source="dataSource" />
  </div>
</template>

动态表单控制

vue
<script setup lang="ts">
import { usePermission } from '@/hooks/web/usePermission';

const { hasPermission } = usePermission();

// 根据权限动态生成表单项
const formSchema = computed(() => {
  const schema = [
    { field: 'username', label: '用户名', component: 'Input' },
    { field: 'email', label: '邮箱', component: 'Input' },
  ];

  // 有管理员权限才能分配角色
  if (hasPermission('user:assignRole')) {
    schema.push({
      field: 'roleIds',
      label: '角色',
      component: 'Select',
      componentProps: { mode: 'multiple' },
    });
  }

  // 有设置薪资权限才显示薪资字段
  if (hasPermission('user:salary:edit')) {
    schema.push({
      field: 'salary',
      label: '薪资',
      component: 'InputNumber',
    });
  }

  return schema;
});
</script>

最佳实践

1. 权限粒度

  • 粗粒度:菜单级别,控制页面访问
  • 细粒度:按钮级别,控制具体操作
  • 数据粒度:行级别,由后端控制

2. 权限缓存

typescript
// 登录后缓存权限,避免重复请求
const userStore = useUserStore();

// 权限存储在 Pinia 中
userStore.setPermissions(permissions);

// 需要时从 store 获取
const permissions = userStore.getPermissions;

3. 权限刷新

用户权限变更后需要刷新:

typescript
// 方式一:重新登录
await userStore.logout();
router.push('/login');

// 方式二:重新获取权限
await userStore.getUserInfoAction();
// 刷新页面以重新生成路由
window.location.reload();

4. 超级管理员

typescript
// 超级管理员拥有所有权限
if (permissions.includes('*:*:*')) {
  return true;
}

下一步