Code前端首页关于Code前端联系我们

Vue3 里咋给按钮加权限控制?从场景到实践一次讲透

terry 3小时前 阅读数 41 #Vue
文章标签 按钮权限

为啥 Vue3 项目要做按钮权限控制?

咱先聊聊为啥得做按钮权限控制~ 举个实际例子,比如公司后台系统,普通客服只能看订单,不能删订单;运营能删但不能改价格;管理员啥都能操作,要是不做权限控制,客服误点删除按钮,数据就没了,这损失谁扛得住?

业务安全角度说,权限控制是防止「越权操作」的第一道防线,尤其是涉及资金、用户隐私的功能,必须严格限制谁能点。

另外像金融、医疗这些行业,政策法规对数据操作权限有明确要求,没做权限控制可能合规性都过不了。

还有体验优化层面,要是所有按钮不管有没有权限都堆在界面上,用户找功能时一堆用不了的按钮,体验稀碎,所以按钮权限控制不是可选,是中后台项目刚需~

Vue3 按钮权限常见实现思路有哪些?

Vue3 里常见的实现思路有三类,各有各的适用场景,咱一个个说~

自定义指令(v - permission)

写个 v - permission 指令,好处是模板里写法特简洁,<button v - permission="'btn:add'">添加</button> 一行就搞定,不用在组件里写一堆逻辑,适合项目里权限逻辑比较统一的情况,比如所有按钮权限都通过“是否包含权限码”判断。

组合式函数(usePermission)

写个 usePermission 函数,返回 hasPermission 方法,组件里调用 hasPermission('btn:add') 来控制 v - if,这种方式灵活,能处理复杂逻辑,需要同时有 btn:addbtn:edit 权限才能显示」,函数里写条件判断就行。

权限组件封装(PermissionButton)

写个 PermissionButton 组件,传个 code 属性,组件内部判断权限后决定是否渲染按钮,这种适合项目里按钮样式、交互逻辑需要统一管理的情况,比如所有按钮要加 loading 态、统一的 hover 样式,改组件一处就全改了~

用自定义指令实现按钮权限,步骤是啥?

接下来详细说自定义指令咋实现,步骤很清晰:

步骤 1:创建自定义指令文件

新建 directives/permission.js,写指令逻辑:

// directives/permission.js
export const permissionDirective = {
  mounted(el, binding) {
    // 获取指令绑定的权限码,v - permission="'btn:add'" 里的 'btn:add'
    const permissionCode = binding.value;
    // 假设用 Pinia 存用户权限,先引入状态管理
    const userStore = useUserStore(); 
    const userPermissions = userStore.permissions; 
    if (permissionCode && !userPermissions.includes(permissionCode)) {
      // 没有权限,把按钮从 DOM 里移除
      el.parentNode?.removeChild(el);
    }
  }
};

这里有个小细节:指令的 mounted 钩子是 DOM 渲染后执行,所以能操作 el 的父节点,但如果权限是异步获取的(比如登录后才拿到),指令可能在权限加载前就执行了,这时候得处理异步情况,可以加个 watch 或者在权限加载完成后重新渲染组件~

步骤 2:全局注册指令

main.js 里把指令注册到 App 上:

import { createApp } from 'vue';
import App from './App.vue';
import { permissionDirective } from './directives/permission';
const app = createApp(App);
app.directive('permission', permissionDirective);
app.mount('#app');

步骤 3:模板中使用指令

现在任何组件里,给按钮加 v - permission 绑定权限码:

<template>
  <button v - permission="'btn:add'">添加订单</button>
  <button v - permission="'btn:delete'">删除订单</button>
</template>

扩展:处理禁用而不是隐藏

有时候产品希望保留按钮但置灰,这时候指令里可以改逻辑:

export const permissionDirective = {
  mounted(el, binding) {
    const { value: code, arg: action = 'hide' } = binding; // arg 用来传操作类型,v - permission:disable="'btn:add'"
    const userPermissions = useUserStore().permissions;
    if (code && !userPermissions.includes(code)) {
      if (action === 'disable') {
        el.disabled = true;
        el.classList.add('disabled - style'); // 加个禁用样式
      } else {
        el.parentNode?.removeChild(el);
      }
    }
  }
};

用的时候:
<button v - permission:disable="'btn:add'">添加</button> 这样按钮就会被禁用而不是删除~

如果需要「或权限」(有 A 或 B 权限就能显示),可以让指令支持数组,v - permission="['btn:add', 'btn:edit']",然后在指令里判断是否有一个权限满足:

mounted(el, binding) {
  const permissionList = binding.value;
  const userPermissions = useUserStore().permissions;
  const hasPermission = permissionList.some(code => userPermissions.includes(code));
  if (!hasPermission) {
    // 处理隐藏或禁用
  }
}

这样指令就更灵活啦~

组合式函数方案咋落地?

咱再看组合式函数方案咋落地~

写 usePermission 函数,封装权限判断逻辑

新建 composables/usePermission.js

// composables/usePermission.js
export function usePermission() {
  const userStore = useUserStore();
  const hasPermission = (code) => {
    return userStore.permissions.includes(code);
  };
  return { hasPermission };
}

组件内调用函数,控制按钮显示

在需要权限控制的组件里:

<template>
  <button v - if="hasPermission('btn:add')">添加订单</button>
  <button v - if="hasPermission('btn:delete')">删除订单</button>
</template>
<script setup>
import { usePermission } from '../composables/usePermission';
const { hasPermission } = usePermission();
</script>

优势:灵活处理多权限逻辑

比如业务要求「同时有 btn:addbtn:edit 权限才能显示按钮」,函数里加逻辑:

// composables/usePermission.js 扩展
export function usePermission() {
  const userStore = useUserStore();
  const hasPermission = (codeList) => {
    // 如果传的是数组,判断是否所有权限都有;传字符串则判断单个
    if (typeof codeList === 'string') {
      return userStore.permissions.includes(codeList);
    }
    return codeList.every(code => userStore.permissions.includes(code));
  };
  return { hasPermission };
}

组件里用:
<button v - if="hasPermission(['btn:add', 'btn:edit'])">批量添加</button>

封装权限组件有啥好处?咋实现?

封装 PermissionButton 组件,能统一管理按钮的权限逻辑和样式,适合团队协作或大型项目~

好处:一处修改,处处生效

比如所有按钮要加统一的 loading 态、hover 样式,或者权限逻辑要新增“超时隐藏”,只需要改 PermissionButton 组件,不用每个按钮都改。

实现步骤:接收权限码,内部判断权限

新建 components/PermissionButton.vue

<template>
  <button v - if="hasPermission" v - bind="$attrs">{{ defaultSlot }}</button>
</template>
<script setup>
import { useUserStore } from '../stores/user';
import { useSlots } from 'vue';
const props = defineProps({
  code: String // 接收权限码
});
const userStore = useUserStore();
const hasPermission = userStore.permissions.includes(props.code);
const slots = useSlots();
const defaultSlot = slots.default?.()[0].children; // 获取插槽内容
</script>

使用方式:传 code 属性,插槽写按钮文案

在其他组件里:

<template>
  <PermissionButton code="btn:add">添加订单</PermissionButton>
  <PermissionButton code="btn:delete">删除订单</PermissionButton>
</template>
<script setup>
import PermissionButton from '../components/PermissionButton.vue';
</script>

扩展:支持自定义样式、加载态

如果要给按钮加加载态,组件里扩展:

<template>
  <button 
    v - if="hasPermission" 
    v - bind="$attrs" 
    :loading="loading"
  >
    {{ loading ? '加载中...' : defaultSlot }}
  </button>
</template>
<script setup>
import { ref, onMounted } from 'vue';
// ... 之前的逻辑
const loading = ref(false);
onMounted(() => {
  // 模拟接口请求加载态
  loading.value = true;
  setTimeout(() => {
    loading.value = false;
  }, 1000);
});
</script>

权限数据从哪来?咋和后端配合?

权限数据从哪来?这得前后端配合~ 咱分场景说:

登录时获取基础权限

用户登录成功后,后端接口(/user/info)返回的用户信息里,通常包含 roles(角色列表)和 permissions(按钮权限码列表),前端把这些数据存到状态管理(Pinia 的 userStore 里):

// stores/user.js
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
  state: () => ({
    permissions: []
  }),
  actions: {
    async fetchUserInfo() {
      const res = await api.login(); // 假设登录接口返回用户信息
      this.permissions = res.data.permissions;
    }
  }
});

登录流程里调用 fetchUserInfo,把权限存起来,后续所有权限判断都基于这个数组~

细粒度权限:后端配置系统

如果是更细的按钮权限,比如每个按钮对应唯一的 permissionCode(如 btn:order:delete),后端需要有「权限配置系统」,管理员在系统里给角色分配可操作的按钮,用户登录时,后端根据角色返回对应的 permissions 列表。

权限更新的处理

如果用户在系统内切换角色(比如从普通员工切到管理员),或者管理员修改了用户权限,这时候要触发权限重新加载,可以写个 refreshPermissions 方法:

// stores/user.js
actions: {
  async refreshPermissions() {
    const res = await api.getLatestPermissions();
    this.permissions = res.data.permissions;
  }
}

然后在角色切换的组件里调用 refreshPermissions,这样所有用到权限的按钮会自动更新(因为 Pinia 的 state 是响应式的,指令或函数里用了这个 state,变化后会触发重新渲染)~

复杂场景(动态权限、多角色)咋处理?

实际项目里总有复杂场景,比如动态权限、多角色咋处理?

场景 1:动态权限(权限中途变化)

用户操作过程中,权限可能被后台修改(比如管理员实时更新了权限),这时候要让按钮权限「实时生效」,做法是让权限数据保持响应式,并在权限变化时触发视图更新。

比如用 Pinia 存权限,因为 Pinia 的 state 是响应式的,当 permissions 数组变化时,所有用到它的地方(指令、函数、组件)都会自动更新。

如果权限是存在普通变量里,没响应式,那修改后视图不会变,这就坑了,所以一定要把权限存在响应式的状态管理里~

场景 2:多角色权限叠加

如果用户有多个角色(比如既是「运营」又是「客服」),权限是取角色权限的并集还是交集?这得看业务规则。

比如后端返回每个角色的 permissions,前端合并去重:

// 登录后处理多角色权限
const roles = res.data.roles; // 假设是 ['operator', 'customer - service']
const allPermissions = [];
for (const role of roles) {
  const rolePermissions = await api.getRolePermissions(role);
  allPermissions.push(...rolePermissions);
}
userStore.permissions = [...new Set(allPermissions)]; // 去重后存起来

这样用户就有所有角色的权限总和~

场景 3:权限缓存与失效

为了减少接口请求,可以把权限数据存在 localStorage/sessionStorage 里,但要注意缓存失效问题,比如用户权限被修改后,缓存没更新。

解决方案是:登录时先读缓存,若缓存存在且未过期,用缓存;否则请求新权限,当用户主动触发权限更新(比如点「刷新权限」按钮),清除缓存并重新请求~

// 简化的缓存逻辑
const CACHE_KEY = 'user_permissions';
const CACHE_EXPIRE = 60 * 60 * 1000; // 1 小时
async function getPermissions() {
  const cache = localStorage.getItem(CACHE_KEY);
  if (cache) {
    const { data, expire } = JSON.parse(cache);
    if (Date.now() < expire) {
      return data;
    }
  }
  const res = await api.getPermissions();
  const newCache = {
    data: res.data,
    expire: Date.now() + CACHE_EXPIRE
  };
  localStorage.setItem(CACHE_KEY, JSON.stringify(newCache));
  return res.data;
}

这样平衡了性能和数据准确性~

实际项目里容易踩的坑有哪些?咋避?

实际开发中,这些坑容易踩,咱提前避避~

坑 1:权限码命名混乱

比如有的按钮权限码是 add_order,有的是 order_add,后期维护根本分不清哪个对应哪个。解决方案:定统一规范,模块:操作:资源,像 order:add:btn,这样看权限码就知道是订单模块的添加按钮~

坑 2:指令逻辑没考虑异步权限

如果权限是登录后异步获取的,指令的 mounted 钩子可能在权限加载前就执行了,导致判断错误。解决方案:在权限加载完成前,按钮默认隐藏或禁用,等权限到了再渲染,可以用 v - if 控制整个按钮区域的加载状态:

<template>
  <div v - if="permissionsLoaded">
    <button v - permission="'btn:add'">添加</button>
  </div>
  <div v - else>加载中...</div>
</template>
<script setup>
const userStore = useUserStore();
const permissionsLoaded = computed(() => userStore.permissions.length > 0);
onMounted(() => {
  userStore.fetchUserInfo(); // 登录后获取权限
});
</script>

坑 3:隐藏按钮导致布局错乱

如果按钮是行内元素,隐藏后旁边元素会移位。解决方案:用 visibility: hidden 代替 removeChild,这样元素占位置但不可见;或者给按钮父元素定宽高,用 flex 布局等,保证布局稳定~

坑 4:响应式丢失

如果把权限存在普通变量里(不是 Pinia/Vuex 的 state),修改后视图不会更新。解决方案:所有权限数据必须存在响应式对象里,利用 Vue 的响应式系统自动更新视图~

怎么给按钮权限写测试?

最后聊聊咋给按钮权限写测试,毕竟逻辑不测试容易出 bug~

自定义指令的单元测试

用 Vitest 测试指令在有权限和无权限时的 DOM 变化:

import { mount } from '@vue/test - utils';
import { createTestingPinia } from '@pinia/testing';
import { permissionDirective } from '../directives/permission';
describe('permissionDirective', () => {
  it('有权限时显示按钮', () => {
    const wrapper = mount({
      template: '<button v - permission="\'btn:add\'">添加</button>'
    }, {
      global: {
        directives: { permission: permissionDirective },
        plugins: [createTestingPinia({
          initialState: { user: { permissions: ['btn:add'] } }
        })]
      }
    });
    expect(wrapper.find('button').exists()).toBe(true);
  });
  it('无权限时隐藏按钮', () => {
    const wrapper = mount({
      template: '<button v - permission="\'btn:add\'">添加</button>'
    }, {
      global: {
        directives: { permission: permissionDirective },
        plugins: [createTestingPinia({
          initialState: { user: { permissions: [] } }
        })]
      }
    });
    expect(wrapper.find('button').exists()).toBe(false);
  });
});

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门