权限控制:菜单级与按钮级

1. 三层权限模型

登录解决的是"你是谁",权限解决的是"你能做什么"。前端权限有三个层次:

┌─────────────────────────────────────┐
│  1. 路由级:能不能进这个页面          │
│     └── 路由守卫拦截                 │
├─────────────────────────────────────┤
│  2. 菜单级:侧边栏看不看得到          │
│     └── 根据角色过滤菜单数据         │
├─────────────────────────────────────┤
│  3. 按钮级:页面内某个操作能不能做    │
│     └── v-if / v-permission 指令    │
└─────────────────────────────────────┘

后端对应

前端 Spring Security
路由守卫拦截 HttpSecurity.authorizeRequests().antMatchers().hasRole()
菜单根据角色过滤 查询当前用户有权限的菜单列表(菜单表 + RBAC)
v-permission="'user:delete'" @PreAuthorize("hasAuthority('user:delete')")

一个核心认知前端权限只是"体验增强",不是"安全防线"。最终判断必须由后端做。前端能改本地 JS,把 v-if 去掉就能看到隐藏按钮——但点下去后端会 403 拦截。前端做权限是为了不让用户看到不能用的功能,而不是为了阻止攻击者


2. 权限模型设计:角色 vs 权限码

角色(Role)admineditorviewer 权限码(Permission)note:createnote:deleteuser:manage

两种方式对应不同粒度:

场景 推荐方案
角色少、权限边界清晰(管理员/普通用户) 角色
角色多、功能细、要灵活配置 权限码
想两者结合 角色包含权限码,前端按权限码判断

in4vue 用**"角色包含权限码"**的方式:info.roles = ['editor']info.permissions = ['note:create', 'note:edit']

// src/stores/user.ts 补充
export interface UserInfo {
  id: number
  username: string
  roles: string[]
  permissions: string[]
}

// 判断权限的工具
const hasPermission = (code: string | string[]) => {
  if (!info.value) return false
  const codes = Array.isArray(code) ? code : [code]
  return codes.some((c) => info.value!.permissions.includes(c))
}

const hasAnyRole = (role: string | string[]) => {
  if (!info.value) return false
  const roles = Array.isArray(role) ? role : [role]
  return roles.some((r) => info.value!.roles.includes(r))
}

return { /* ... */ hasPermission, hasAnyRole }

3. 路由级权限:两种实现思路

3.1 静态路由 + 守卫过滤(简单)

所有路由在前端写死,守卫里判断当前用户是否有权限:

// src/router/index.ts
const routes = [
  {
    path: '/admin/users',
    component: () => import('@/pages/admin/UserList.vue'),
    meta: { requiresAuth: true, permissions: ['user:manage'] },
  },
  {
    path: '/admin/logs',
    component: () => import('@/pages/admin/Logs.vue'),
    meta: { requiresAuth: true, roles: ['admin'] },
  },
]

router.beforeEach((to) => {
  const userStore = useUserStore()
  if (!to.meta.requiresAuth) return true
  if (!userStore.isLoggedIn) return { path: '/login', query: { redirect: to.fullPath } }

  // 权限码检查
  const perms = (to.matched.flatMap((r) => r.meta.permissions) as string[]).filter(Boolean)
  if (perms.length && !perms.some((p) => userStore.hasPermission(p))) {
    return { path: '/403' }
  }

  // 角色检查
  const roles = (to.matched.flatMap((r) => r.meta.roles) as string[]).filter(Boolean)
  if (roles.length && !userStore.hasAnyRole(roles)) {
    return { path: '/403' }
  }

  return true
})

适合:权限相对固定、页面总数不多的项目。in4vue 用这种

3.2 动态路由:登录后从后端拉路由表

适合"不同角色看到完全不同的功能模块"的复杂后台。登录后调接口:

// 登录成功后
const { menus } = await getUserMenusApi()
// menus 类似:[{ path: '/notes', component: 'NoteList', meta: {...} }]

// 动态添加路由
menus.forEach((m) => {
  router.addRoute({
    path: m.path,
    component: loadComponent(m.component),
    meta: m.meta,
  })
})

坑点

结论:动态路由强大但复杂。学习阶段先用静态路由,等产品真的需要"按角色配菜单"再升级。


4. 菜单级权限:渲染时过滤

不管是静态还是动态路由,菜单的显示都基于"当前用户能进哪些路由"。

// src/composables/useMenus.ts
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { computed } from 'vue'

export interface MenuItem {
  path: string
  title: string
  icon?: any
  children?: MenuItem[]
}

export function useVisibleMenus() {
  const router = useRouter()
  const userStore = useUserStore()

  // 所有需要出现在菜单里的路由都加 meta.menu = true
  const allMenus = router.getRoutes()
    .filter((r) => r.meta.menu)
    .map((r) => ({
      path: r.path,
      title: r.meta.title as string,
      icon: r.meta.icon,
      permissions: r.meta.permissions as string[] | undefined,
      roles: r.meta.roles as string[] | undefined,
    }))

  const visibleMenus = computed<MenuItem[]>(() => {
    return allMenus.filter((m) => {
      if (m.roles?.length && !userStore.hasAnyRole(m.roles)) return false
      if (m.permissions?.length && !m.permissions.some((p) => userStore.hasPermission(p))) {
        return false
      }
      return true
    })
  })

  return { visibleMenus }
}

侧边栏使用:

<script setup lang="ts">
import { useVisibleMenus } from '@/composables/useMenus'
const { visibleMenus } = useVisibleMenus()
</script>

<template>
  <router-link v-for="m in visibleMenus" :key="m.path" :to="m.path">
    <el-icon><component :is="m.icon" /></el-icon>
    <span>{{ m.title }}</span>
  </router-link>
</template>

Java 对照:这就是"后台菜单表 + 用户角色关联查询"的前端版,只不过数据源从 SQL 变成了 Vue Router 的路由表 + Pinia 的用户信息。


5. 按钮级权限:v-permission 自定义指令

最常见的需求:"管理员能看到删除按钮,普通用户看不到。" 用 v-if 能做,但满屏 v-if 读起来累:

<!-- 啰嗦版 -->
<el-button v-if="userStore.hasPermission('note:delete')">删除</el-button>

写一个自定义指令:

// src/directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/stores/user'

/**
 * v-permission 指令:根据权限码控制元素显示
 *
 * 用法:
 *   v-permission="'note:delete'"
 *   v-permission="['note:delete', 'note:archive']"  // 任意一个满足
 */
export const vPermission: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
    const userStore = useUserStore()
    const required = binding.value
    if (!userStore.hasPermission(required)) {
      el.remove()
    }
  },
}

注册:

// src/main.ts
import { vPermission } from '@/directives/permission'
app.directive('permission', vPermission)

使用:

<el-button v-permission="'note:delete'" type="danger">删除</el-button>
<el-button v-permission="['note:delete', 'note:archive']">归档或删除</el-button>

为什么用 el.remove() 而不是 el.style.display = 'none'

5.1 角色版指令

同理:

export const vRole: Directive = {
  mounted(el, binding) {
    const userStore = useUserStore()
    if (!userStore.hasAnyRole(binding.value)) el.remove()
  },
}
<el-button v-role="'admin'">管理员专属</el-button>

5.2 响应式版(权限会变时)

上面的指令只在 mounted 时判断一次。如果用户权限会在运行时变化(切换账号、角色变更),要升级到响应式:

import { watchEffect } from 'vue'

export const vPermission: Directive = {
  mounted(el, binding) {
    const userStore = useUserStore()
    const parent = el.parentNode
    const placeholder = document.createComment('v-permission')

    const stop = watchEffect(() => {
      const ok = userStore.hasPermission(binding.value)
      if (ok && !el.isConnected) {
        parent?.insertBefore(el, placeholder)
        placeholder.parentNode?.removeChild(placeholder)
      } else if (!ok && el.isConnected) {
        parent?.insertBefore(placeholder, el)
        parent?.removeChild(el)
      }
    })

    ;(el as any)._permissionStop = stop
  },
  unmounted(el) {
    ;(el as any)._permissionStop?.()
  },
}

大多数项目登录后权限不变,第一版(mount 时一次判断)就够。


6. 组合式函数版:usePermission

除了指令,还可以用 composable,更灵活(能拿返回值做条件判断):

// src/composables/usePermission.ts
import { useUserStore } from '@/stores/user'

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

  const can = (code: string | string[]) => userStore.hasPermission(code)
  const hasRole = (role: string | string[]) => userStore.hasAnyRole(role)

  return { can, hasRole }
}

用在 script 里做逻辑判断:

<script setup lang="ts">
import { usePermission } from '@/composables/usePermission'
const { can } = usePermission()

const onSave = () => {
  if (can('note:edit')) {
    // 保存逻辑
  }
}
</script>

模板里也能用:

<el-button v-if="can('note:publish')">发布</el-button>

指令 vs composable 怎么选


7. 表格行内操作:动态列

表格的"操作列"里的按钮要按权限显示。组合拳:

<script setup lang="ts">
import { usePermission } from '@/composables/usePermission'
const { can } = usePermission()
</script>

<template>
  <el-table :data="list">
    <el-table-column prop="title" label="标题" />
    <el-table-column prop="author" label="作者" />
    <el-table-column label="操作" width="200">
      <template #default="{ row }">
        <el-button v-if="can('note:edit')" link type="primary" @click="edit(row)">
          编辑
        </el-button>
        <el-button v-if="can('note:delete')" link type="danger" @click="remove(row)">
          删除
        </el-button>
        <el-button v-if="can('note:publish') && row.status === 'draft'" link @click="publish(row)">
          发布
        </el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

细节:操作列宽度随权限显示的按钮数量变化时会有抖动。简单做法——给操作列一个固定宽度,即使某用户只看到 1 个按钮也预留位置。


8. 403 页面

访问无权限页面时跳到 /403,给出友好提示:

<!-- src/pages/Forbidden.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>

<template>
  <div class="flex min-h-dvh flex-col items-center justify-center gap-4">
    <h1 class="text-6xl font-bold text-gray-300">403</h1>
    <p class="text-gray-600">抱歉,你没有访问这个页面的权限</p>
    <el-button type="primary" @click="router.replace('/')">返回首页</el-button>
  </div>
</template>

路由里加:

{ path: '/403', component: () => import('@/pages/Forbidden.vue') }

9. 数据级权限:只看自己的数据

除了界面能不能看、能不能点,还有一层:"即使能打开列表页,也只能看到自己相关的数据"。

这一层必须由后端做,前端不用特别处理——发请求时后端根据 token 识别用户,返回过滤后的数据即可。

前端要做的只是:当后端返回空列表时显示友好提示,不要让用户以为页面坏了。

<el-empty v-if="list.length === 0" description="暂无你参与的笔记" />

10. 测试权限的开发技巧

本地开发时经常要切换不同角色测效果。小技巧:

// src/stores/user.ts 加一个开发辅助
const mockRole = (role: 'admin' | 'editor' | 'viewer') => {
  if (import.meta.env.DEV) {
    info.value = {
      id: 1,
      username: `mock-${role}`,
      roles: [role],
      permissions: {
        admin: ['note:create', 'note:edit', 'note:delete', 'user:manage'],
        editor: ['note:create', 'note:edit'],
        viewer: [],
      }[role],
    }
    token.value = 'mock-token'
  }
}

开发者控制台里:

// 浏览器控制台
$pinia.state.value.user.info = { roles: ['admin'], permissions: ['*'] }

或者直接做一个开发模式专用的"角色切换器"组件——仅在 import.meta.env.DEV 下挂载,生产构建时不打包。


11. 常见坑点

现象 原因 解法
指令不生效,按钮还在 忘了在 main.ts 注册 app.directive('permission', vPermission)
菜单过滤了但路由还能直接访问 URL 只做了菜单级没做路由级 路由级必须独立做一遍
动态路由刷新后 404 addRoute 要在路由守卫里判断 守卫里判断"用户信息存在但路由未加载"时先加载
前端隐藏了但 API 还能调 前端权限不是安全防线 后端必须再判一次
指令 + v-if 混用导致不显示 指令在 v-if 为 false 时不执行 选一种,不要叠加

12. 心智模型:三层协作

回到开头那张图,每层权限都有具体对应物:

┌──────────────────────────────────────────┐
│ 1. 路由级   → router.beforeEach           │
│    "不让你进来"                           │
├──────────────────────────────────────────┤
│ 2. 菜单级   → useVisibleMenus + v-for    │
│    "不让你看到入口"                       │
├──────────────────────────────────────────┤
│ 3. 按钮级   → v-permission / usePermission│
│    "不让你点关键按钮"                     │
├──────────────────────────────────────────┤
│ 4. 数据级   → 后端过滤                     │
│    "即使能看列表,只给你自己的数据"         │
└──────────────────────────────────────────┘

完整的权限系统是前端 3 层 + 后端 1 层的组合。省略任何一层体验或安全都会出问题——前端少了"按钮级"用户会看到点下去 403 的按钮;后端少了"数据级"用户能看到别人的数据。


小练习

  1. user.ts 里加 hasPermissionhasAnyRole 方法
  2. 实现 v-permission 指令并注册
  3. 在笔记列表的操作列里用 v-permission 控制按钮
  4. 把侧边栏菜单改成响应用户权限的动态过滤版本
  5. 加一个 /403 页面,在守卫里跳转测试

延伸阅读