前端权限系统设计与实战

从模型到落地:RBAC / ABAC 权限模型、路由守卫、按钮级权限、动态菜单、数据权限与多租户的完整方案


目录

  1. 导读:前端权限的边界与职责
  2. 权限模型:RBAC 与 ABAC
  3. 后端契约:权限接口设计
  4. 权限 Store 设计与初始化
  5. 路由权限:静态路由与动态路由
  6. 导航守卫:全局、路由级与组件级
  7. 动态菜单:递归渲染与权限过滤
  8. 按钮级权限:指令与组件方案
  9. 数据权限:行级与列级控制
  10. 多租户权限隔离
  11. 权限缓存与实时更新
  12. 权限与微前端
  13. 安全加固:前端权限的不可信本质
  14. 测试策略
  15. 踩坑清单与最佳实践

一、导读:前端权限的边界与职责

1.1 前端权限做什么、不做什么

┌─────────────────────────────────────────────────────────────┐
│                     权限控制的分层                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐      │
│  │  网关层      │   │  后端服务层  │   │  前端展示层  │      │
│  │  (Nginx/    │   │  (接口鉴权   │   │  (UI 控制)   │      │
│  │   Gateway)  │   │   数据过滤)  │   │             │      │
│  └──────┬──────┘   └──────┬──────┘   └──────┬──────┘      │
│         │                 │                  │              │
│    IP / 限流         角色/权限校验      菜单/按钮/路由       │
│    粗粒度拦截        数据行/列过滤       体验优化、防误操作   │
│                                                             │
│  ★ 核心原则:前端权限 = 体验层,后端权限 = 安全层            │
│  ★ 前端权限永远不能替代后端校验                               │
└─────────────────────────────────────────────────────────────┘

前端权限的核心职责

职责 说明 示例
路由守卫 无权限路由不可访问 普通用户访问 /admin 被拦截
菜单过滤 只展示有权限的菜单项 运营看不到「系统设置」
按钮控制 无权限操作按钮隐藏/禁用 无删除权限则隐藏删除按钮
数据脱敏 前端对敏感字段做遮罩 身份证号显示 330***1234
体验优化 避免用户操作后才发现无权限 提前告知而非提交后报错

前端权限不做的事

  • ❌ 不做接口安全校验(后端必须兜底)
  • ❌ 不做数据行级过滤(后端 SQL 层控制)
  • ❌ 不信任前端传来的权限标识(后端必须重新校验)

1.2 权限系统全景图

用户登录
  │
  ▼
获取 Token ──→ 存储 Token(内存优先 / Storage 加密)
  │
  ▼
拉取权限数据(角色 + 权限码 + 菜单树)
  │
  ├─→ 注册动态路由(router.addRoute)
  ├─→ 生成菜单树(过滤 + 排序)
  ├─→ 存入权限 Store(按钮码集合)
  │
  ▼
导航守卫拦截
  │
  ├─→ 路由匹配:有无权限访问目标页?
  ├─→ 按钮渲染:v-permission / <Auth> 组件
  └─→ 数据权限:列过滤 / 字段脱敏

二、权限模型:RBAC 与 ABAC

2.1 RBAC:基于角色的访问控制

RBAC 是最广泛使用的权限模型,核心思想:用户 → 角色 → 权限

┌────────┐     ┌────────┐     ┌────────────┐
│  用户   │────→│  角色   │────→│   权限      │
│ User   │ N:M │ Role   │ N:M │ Permission │
└────────┘     └────────┘     └────────────┘

示例:
  张三 ──→ [管理员] ──→ [user:create, user:delete, user:export]
  李四 ──→ [运营]   ──→ [user:read, user:export]
  王五 ──→ [审计]   ──→ [user:read, log:read]

RBAC 数据结构

// types/permission.ts

/** 权限码 —— 最小粒度的权限单元 */
type PermissionCode = string;

/** 角色 */
interface Role {
  id: string;
  code: string;          // 如 'admin', 'operator'
  name: string;          // 如 '管理员', '运营'
  permissions: PermissionCode[];
}

/** 用户(含角色与权限) */
interface UserInfo {
  id: string;
  username: string;
  roles: Role[];
  permissions: PermissionCode[];  // 扁平化:所有角色的权限并集
}

RBAC 的优势与局限

维度 优势 局限
理解成本 直观,角色即权限集合 角色爆炸:细粒度控制导致角色数量膨胀
管理成本 分配角色即可 无法表达上下文相关权限(如"只能看自己部门")
扩展性 适合 90% 的中后台系统 需要数据权限时力不从心

2.2 ABAC:基于属性的访问控制

ABAC 基于用户属性、资源属性、环境属性动态计算权限,适合复杂场景。

┌──────────────┐
│   策略引擎    │
│ Policy Engine │
└──────┬───────┘
       │
  ┌────┼────────────────────┐
  │    │                    │
  ▼    ▼                    ▼
用户属性  资源属性          环境属性
────────  ────────          ────────
部门=技术  类型=合同         时间=工作日
职级=P7    密级=机密         IP=内网
区域=华东  金额>10000       终端=PC

策略示例:
  当 用户.部门 == 资源.归属部门 AND 资源.密级 IN ['公开','内部'] → 允许查看

ABAC TypeScript 类型

// types/abac.ts

/** 属性值类型 */
type AttributeValue = string | number | boolean | string[];

interface PolicyCondition {
  field: string;           // 属性路径,如 'user.department'
  operator: 'eq' | 'ne' | 'in' | 'notIn' | 'gt' | 'lt' | 'contains';
  value: AttributeValue;
}

interface Policy {
  id: string;
  name: string;
  effect: 'allow' | 'deny';
  actions: string[];       // 如 ['document:read', 'document:download']
  conditions: PolicyCondition[];
  priority: number;        // 优先级,deny 优先原则
}

/** 策略评估上下文 */
interface PolicyContext {
  user: Record<string, AttributeValue>;      // 用户属性
  resource: Record<string, AttributeValue>;  // 资源属性
  environment: Record<string, AttributeValue>; // 环境属性
}

策略评估引擎

// utils/policyEngine.ts

const OPERATOR_MAP = {
  eq: (a: AttributeValue, b: AttributeValue) => a === b,
  ne: (a: AttributeValue, b: AttributeValue) => a !== b,
  in: (a: AttributeValue, b: AttributeValue) =>
    Array.isArray(b) && b.includes(a as string),
  notIn: (a: AttributeValue, b: AttributeValue) =>
    Array.isArray(b) && !b.includes(a as string),
  gt: (a: AttributeValue, b: AttributeValue) =>
    typeof a === 'number' && typeof b === 'number' && a > b,
  lt: (a: AttributeValue, b: AttributeValue) =>
    typeof a === 'number' && typeof b === 'number' && a < b,
  contains: (a: AttributeValue, b: AttributeValue) =>
    Array.isArray(a) && a.includes(b as string),
} as const;

/** 从上下文中按路径取值,如 'user.department' → context.user.department */
function resolveValue(context: PolicyContext, path: string): AttributeValue | undefined {
  const keys = path.split('.');
  let current: unknown = context;
  for (const key of keys) {
    if (current === null || current === undefined || typeof current !== 'object') {
      return undefined;
    }
    current = (current as Record<string, unknown>)[key];
  }
  return current as AttributeValue;
}

/** 评估单条条件 */
function evaluateCondition(
  condition: PolicyCondition,
  context: PolicyContext
): boolean {
  const actual = resolveValue(context, condition.field);
  if (actual === undefined) return false;

  const evaluator = OPERATOR_MAP[condition.operator];
  return evaluator(actual, condition.value);
}

/** 评估策略:所有条件都满足才通过 */
function evaluatePolicy(policy: Policy, context: PolicyContext): boolean {
  return policy.conditions.every((cond) => evaluateCondition(cond, context));
}

/**
 * 策略决策:按优先级排序,deny 优先
 * 返回是否允许执行指定 action
 */
export function evaluateAccess(
  policies: Policy[],
  context: PolicyContext,
  action: string
): boolean {
  const sorted = [...policies].sort((a, b) => b.priority - a.priority);

  let allowed = false;

  for (const policy of sorted) {
    if (!policy.actions.includes(action)) continue;
    if (!evaluatePolicy(policy, context)) continue;

    if (policy.effect === 'deny') return false;  // deny 优先,立即拒绝
    allowed = true;
  }

  return allowed;
}

2.3 RBAC + ABAC 混合模型(推荐)

实际项目中,RBAC 做粗粒度、ABAC 做细粒度,两者结合:

┌─────────────────────────────────────────────────┐
│              RBAC + ABAC 混合模型                 │
│                                                 │
│  第一层:RBAC                                    │
│  用户 → 角色 → 权限码                            │
│  解决"能不能访问这个功能"                         │
│                                                 │
│  第二层:ABAC                                    │
│  属性条件 → 策略决策                             │
│  解决"能访问哪些数据 / 在什么条件下可以"          │
│                                                 │
│  示例:                                          │
│  RBAC: 运营角色 → 有 order:export 权限           │
│  ABAC: 只能导出自己区域的订单                     │
│       (user.region == resource.region)           │
└─────────────────────────────────────────────────┘

三、后端契约:权限接口设计

3.1 接口清单

前端权限依赖后端提供三类数据:

┌──────────────────────────────────────────────────────┐
│                  权限相关接口                          │
├──────────────┬───────────────────────────────────────┤
│ 接口          │ 说明                                  │
├──────────────┼───────────────────────────────────────┤
│ POST /login  │ 登录,返回 accessToken + refreshToken │
│ GET  /user   │ 获取当前用户信息 + 角色列表            │
│ GET  /perms  │ 获取当前用户的权限码集合               │
│ GET  /menus  │ 获取当前用户的菜单树(已过滤)         │
│ GET  /routes │ 获取当前用户的动态路由配置              │
└──────────────┴───────────────────────────────────────┘

3.2 接口响应契约

// types/api.ts

/** 登录响应 */
interface LoginResponse {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}

/** 用户信息响应 */
interface UserProfileResponse {
  id: string;
  username: string;
  nickname: string;
  avatar: string;
  roles: Array<{
    id: string;
    code: string;     // 'admin' | 'editor' | 'viewer'
    name: string;
  }>;
}

/** 权限码响应 */
interface PermissionResponse {
  codes: string[];    // ['user:create', 'user:delete', 'order:export', ...]
}

/** 菜单项 */
interface MenuItem {
  id: string;
  parentId: string | null;
  name: string;           // 路由 name,如 'UserManage'
  path: string;           // 路由 path,如 '/system/user'
  component: string;      // 组件路径,如 'system/user/index'
  redirect?: string;
  icon?: string;
  title: string;          // 显示名称
  sort: number;           // 排序
  hidden: boolean;        // 是否隐藏(在菜单中不显示,但路由可访问)
  cache: boolean;         // 是否 keep-alive
  children?: MenuItem[];
}

/** 菜单响应 */
interface MenuResponse {
  menus: MenuItem[];
}

3.3 合并方案:一次请求拿全

很多项目将用户信息、权限码、菜单树拆成三个接口,但初始化时串行请求会拖慢首屏。推荐合并为一次请求

// types/api.ts

/** 用户信息 */
interface UserProfileResponse {
  id: string;
  username: string;
  nickname: string;
  avatar: string;
  roles: Array<{
    id: string;
    code: string;
    name: string;
  }>;
}

/** 初始化数据(登录后一次性拉取) */
interface AppInitResponse {
  user: UserProfileResponse;
  permissions: string[];
  menus: MenuItem[];
}
// api/app.ts

import { http } from '@/utils/http';
import type { AppInitResponse } from '@/types/api';

/** 应用初始化:一次性获取用户 + 权限 + 菜单 */
export function fetchAppInit() {
  return http.get<AppInitResponse>('/app/init');
}

四、权限 Store 设计与初始化

4.1 Store 结构

// stores/permission.ts

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { fetchAppInit } from '@/api/app';
import type { MenuItem, Role } from '@/types/api';

export const usePermissionStore = defineStore('permission', () => {
  // ─── State ───
  const token = ref('');
  const roles = ref<Role[]>([]);
  const permissionCodes = ref<Set<string>>(new Set());
  const menuList = ref<MenuItem[]>([]);
  const isInitialized = ref(false);

  // ─── Getters ───

  /** 是否已登录 */
  const isLoggedIn = computed(() => !!token.value);

  /** 角色码列表 */
  const roleCodes = computed(() => roles.value.map((r) => r.code));

  /** 是否超级管理员(跳过所有权限检查) */
  const isSuperAdmin = computed(() => roleCodes.value.includes('super_admin'));

  /** 判断是否拥有指定权限码 */
  function hasPermission(code: string): boolean {
    if (isSuperAdmin.value) return true;
    return permissionCodes.value.has(code);
  }

  /** 判断是否拥有任一权限码 */
  function hasAnyPermission(codes: string[]): boolean {
    if (isSuperAdmin.value) return true;
    return codes.some((code) => permissionCodes.value.has(code));
  }

  /** 判断是否拥有全部权限码 */
  function hasAllPermissions(codes: string[]): boolean {
    if (isSuperAdmin.value) return true;
    return codes.every((code) => permissionCodes.value.has(code));
  }

  /** 判断是否拥有指定角色 */
  function hasRole(code: string): boolean {
    return roleCodes.value.includes(code);
  }

  // ─── Actions ───

  /** 初始化:拉取用户信息 + 权限 + 菜单 */
  async function initApp(force = false) {
    if (isInitialized.value && !force) return;

    const { user, permissions, menus } = await fetchAppInit();

    roles.value = user.roles;
    permissionCodes.value = new Set(permissions);
    menuList.value = menus;
    isInitialized.value = true;
  }

  /** 退出登录,清除所有状态 */
  function reset() {
    token.value = '';
    roles.value = [];
    permissionCodes.value = new Set();
    menuList.value = [];
    isInitialized.value = false;
  }

  return {
    token,
    roles,
    permissionCodes,
    menuList,
    isInitialized,
    isLoggedIn,
    roleCodes,
    isSuperAdmin,
    hasPermission,
    hasAnyPermission,
    hasAllPermissions,
    hasRole,
    initApp,
    reset,
  };
});

4.2 初始化时序

App.vue mounted
  │
  ▼
permissionStore.initApp()
  │
  ├─→ 1. 请求 /app/init
  ├─→ 2. 存储角色 + 权限码 + 菜单
  ├─→ 3. 生成动态路由(router.addRoute)
  ├─→ 4. 标记 isInitialized = true
  │
  ▼
导航守卫放行,页面正常渲染

关键点isInitialized 是一个门控标志,确保权限数据就绪后才放行路由跳转。

4.3 权限码用 Set 而非 Array

// ❌ 数组查找 O(n)
const permissionCodes = ref<string[]>([]);
hasPermission: (code) => permissionCodes.value.includes(code)

// ✅ Set 查找 O(1)
const permissionCodes = ref<Set<string>>(new Set());
hasPermission: (code) => permissionCodes.value.has(code)

当权限码数量达到几百个时,Set 的性能优势明显。注意 Pinia 持久化时 Set 需要序列化处理:

// 持久化插件中处理 Set
const permissionPlugin = ({ store }) => {
  if (store.$id === 'permission') {
    store.$subscribe((_, state) => {
      const serialized = {
        ...state,
        permissionCodes: Array.from(state.permissionCodes),  // Set → Array
      };
      sessionStorage.setItem('permission', JSON.stringify(serialized));
    });
  }
};

// 恢复时处理 Set
function hydratePermission() {
  const raw = sessionStorage.getItem('permission');
  if (!raw) return null;
  const data = JSON.parse(raw);
  return {
    ...data,
    permissionCodes: new Set(data.permissionCodes),  // Array → Set
  };
}

五、路由权限:静态路由与动态路由

5.1 路由分类

┌────────────────────────────────────────────────────────┐
│                     路由分类                            │
├────────────────────┬───────────────────────────────────┤
│ 静态路由            │ 动态路由                           │
│ (constantRoutes)   │ (asyncRoutes)                     │
├────────────────────┼───────────────────────────────────┤
│ 登录页、404、       │ 需要权限才能访问的业务页面          │
│ 注册页、公共页      │ 根据后端菜单数据动态注册            │
│ 不需要权限控制      │ 每个用户看到的路由可能不同          │
│ 写死在代码里        │ 运行时 addRoute                   │
└────────────────────┴───────────────────────────────────┘

5.2 静态路由定义

// router/constants.ts

import type { RouteRecordRaw } from 'vue-router';

/** 静态路由:不需要权限,所有人可访问 */
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', hidden: true },
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/403.vue'),
    meta: { title: '无权限', hidden: true },
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '页面不存在', hidden: true },
  },
  {
    path: '/',
    name: 'Layout',
    component: () => import('@/layouts/DefaultLayout.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { title: '工作台', icon: 'dashboard' },
      },
    ],
  },
];

5.3 动态路由生成

后端返回的菜单数据需要转换为 Vue Router 的路由配置:

// router/dynamic.ts

import type { RouteRecordRaw } from 'vue-router';
import type { MenuItem } from '@/types/api';

/**
 * 组件映射表
 * 后端返回 component 字符串,前端映射到实际组件
 *
 * 使用 Vite 的 import.meta.glob 实现批量导入
 * 注意:key 必须使用绝对路径 /src/views/... 而非别名 @/views/...
 * 因为 import.meta.glob 返回的 key 是基于项目根目录的绝对路径
 */
const VIEW_MODULES = import.meta.glob('/src/views/**/*.vue');

/** 将后端菜单转换为路由配置 */
function transformMenuToRoute(menu: MenuItem): RouteRecordRaw | null {
  const route: RouteRecordRaw = {
    path: menu.path,
    name: menu.name,
    component: resolveComponent(menu.component),
    meta: {
      title: menu.title,
      icon: menu.icon,
      hidden: menu.hidden,
      cache: menu.cache,
    },
  };

  if (menu.redirect) {
    route.redirect = menu.redirect;
  }

  if (menu.children?.length) {
    route.children = menu.children
      .map(transformMenuToRoute)
      .filter((r): r is RouteRecordRaw => r !== null);
  }

  return route;
}

/** 解析组件路径 */
function resolveComponent(componentPath: string) {
  const path = `/src/views/${componentPath}.vue`;
  const module = VIEW_MODULES[path];
  if (!module) {
    console.error(`[Router] 组件未找到: ${path}`);
    return () => import('@/views/error/404.vue');
  }
  return module;
}

/** 批量转换菜单为路由 */
export function generateRoutes(menus: MenuItem[]): RouteRecordRaw[] {
  return menus
    .map(transformMenuToRoute)
    .filter((r): r is RouteRecordRaw => r !== null);
}

5.4 注册动态路由

// router/index.ts

import { createRouter, createWebHistory } from 'vue-router';
import { constantRoutes } from './constants';
import { generateRoutes } from './dynamic';
import { usePermissionStore } from '@/stores/permission';

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
});

/** 标记:动态路由是否已注册 */
let dynamicRoutesAdded = false;

/** 保存动态路由的移除回调 */
let removeRouteCallbacks: Array<() => void> = [];

/** 注册动态路由 */
export function addDynamicRoutes() {
  if (dynamicRoutesAdded) return;

  const permissionStore = usePermissionStore();
  const routes = generateRoutes(permissionStore.menuList);

  routes.forEach((route) => {
    // addRoute 返回移除回调,比 router.getRoutes() 遍历更安全
    const remove = router.addRoute(route);
    removeRouteCallbacks.push(remove);
  });

  // 兜底:所有未匹配路由跳转 404(必须在动态路由之后添加)
  const removeNotFound = router.addRoute({
    name: 'DynamicNotFound',
    path: '/:pathMatch(.*)*',
    redirect: '/404',
  });
  removeRouteCallbacks.push(removeNotFound);

  dynamicRoutesAdded = true;
}

/**
 * 重置路由(退出登录时调用)
 *
 * 通过 addRoute 返回的回调移除,比遍历 router.getRoutes() 更安全:
 * 1. 避免误删嵌套在 Layout.children 中的静态路由
 * 2. 无 name 的兜底路由也能正确移除
 */
export function resetRouter() {
  removeRouteCallbacks.forEach((remove) => remove());
  removeRouteCallbacks = [];
  dynamicRoutesAdded = false;
}

export default router;

5.5 两种动态路由方案对比

方案 说明 优势 劣势
后端返回路由配置 后端返回完整的路由 JSON 前后端完全解耦,菜单可后台配置 后端需要维护路由结构,前后端耦合
前端定义 + 权限过滤 前端定义所有路由,后端只返回权限码 前端自治,路由结构清晰 新增页面需前端发版

推荐:中小项目用方案二(前端定义 + 权限过滤),大型项目或需要运营配置菜单的用方案一。

方案二的核心实现:

// router/asyncRoutes.ts

import type { RouteRecordRaw } from 'vue-router';

/** 全量异步路由(前端定义) */
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/system',
    name: 'System',
    component: () => import('@/layouts/DefaultLayout.vue'),
    meta: { title: '系统管理', icon: 'setting' },
    children: [
      {
        path: 'user',
        name: 'UserManage',
        component: () => import('@/views/system/user/index.vue'),
        meta: { title: '用户管理', icon: 'user', permissions: ['system:user:list'] },
      },
      {
        path: 'role',
        name: 'RoleManage',
        component: () => import('@/views/system/role/index.vue'),
        meta: { title: '角色管理', icon: 'role', permissions: ['system:role:list'] },
      },
    ],
  },
  {
    path: '/order',
    name: 'Order',
    component: () => import('@/layouts/DefaultLayout.vue'),
    meta: { title: '订单管理', icon: 'order' },
    children: [
      {
        path: 'list',
        name: 'OrderList',
        component: () => import('@/views/order/list/index.vue'),
        meta: { title: '订单列表', permissions: ['order:list'] },
      },
      {
        path: 'detail/:id',
        name: 'OrderDetail',
        component: () => import('@/views/order/detail/index.vue'),
        meta: { title: '订单详情', hidden: true, permissions: ['order:detail'] },
      },
    ],
  },
];
// router/filter.ts

import type { RouteRecordRaw } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';

/**
 * 根据权限过滤路由
 * 递归过滤:如果父路由没有权限,子路由也不会显示
 * 剪枝:父路由无权限要求但子路由全部被过滤后,父路由也移除(避免空目录)
 */
export function filterAsyncRoutes(
  routes: RouteRecordRaw[],
  permissionCodes: Set<string>
): RouteRecordRaw[] {
  return routes
    .map((route) => {
      const requiredPerms = route.meta?.permissions as string[] | undefined;

      // 有权限要求,检查是否满足
      if (requiredPerms?.length) {
        const hasAccess = requiredPerms.some((perm) => permissionCodes.has(perm));
        if (!hasAccess) return null;
      }

      const cloned = { ...route };

      // 递归过滤子路由
      if (cloned.children) {
        cloned.children = filterAsyncRoutes(cloned.children, permissionCodes);
      }

      // 剪枝:目录型路由(无 component)如果子路由全部被过滤,则移除
      const isDirectory = !cloned.component && Array.isArray(route.children);
      if (isDirectory && (!cloned.children || cloned.children.length === 0)) {
        return null;
      }

      return cloned;
    })
    .filter((route): route is RouteRecordRaw => route !== null);
}

六、导航守卫:全局、路由级与组件级

6.1 全局前置守卫

// router/guard.ts

import type { Router } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { addDynamicRoutes, resetRouter } from '@/router/index';

const WHITE_LIST = ['/login', '/403', '/404'];

/**
 * 全局前置守卫
 *
 * Vue Router 4 推荐使用 return 风格而非 next():
 * - return true → 放行
 * - return false → 取消导航
 * - return 路由位置 → 重定向
 *
 * 官方文档明确说明:在守卫中 addRoute 后,
 * 不要调用 router.replace(),而是通过返回新的 location 触发重定向。
 */
export function setupRouterGuard(router: Router) {
  router.beforeEach(async (to) => {
    const permissionStore = usePermissionStore();

    // 1. 未登录
    if (!permissionStore.isLoggedIn) {
      if (WHITE_LIST.includes(to.path)) return true;
      return {
        path: '/login',
        query: { redirect: to.fullPath },
        replace: true,
      };
    }

    // 2. 已登录但访问登录页 → 跳转首页
    if (to.path === '/login') {
      return { path: '/', replace: true };
    }

    // 3. 权限数据未初始化
    if (!permissionStore.isInitialized) {
      try {
        await permissionStore.initApp();
        addDynamicRoutes();

        // addRoute 后通过返回新 location 触发重定向
        return { path: to.fullPath, replace: true };
      } catch {
        // 初始化失败(如 token 过期),清除状态并跳转登录
        permissionStore.reset();
        resetRouter();
        return {
          path: '/login',
          query: { redirect: to.fullPath },
          replace: true,
        };
      }
    }

    // 4. 路由权限检查
    const requiredPerms = to.meta?.permissions as string[] | undefined;
    if (requiredPerms?.length && !permissionStore.hasAnyPermission(requiredPerms)) {
      return { path: '/403', replace: true };
    }

    // 5. 权限数据已就绪,正常放行
    return true;
  });

  router.afterEach((to) => {
    // 设置页面标题
    const title = to.meta?.title as string | undefined;
    document.title = title ? `${title} - 管理后台` : '管理后台';
  });
}

6.2 守卫流程图

beforeEach
  │
  ├─ 未登录?
  │   ├─ 白名单 → 放行
  │   └─ 非白名单 → 跳转 /login?redirect=xxx
  │
  ├─ 已登录 + 访问 /login → 跳转 /
  │
  ├─ 已登录 + 未初始化?
  │   ├─ initApp() 成功 → addRoute → next({ ...to, replace: true })
  │   └─ initApp() 失败 → reset → 跳转 /login
  │
  ├─ 已登录 + 已初始化?
  │   ├─ 路由有权限要求?
  │   │   ├─ 有权限 → 放行
  │   │   └─ 无权限 → 跳转 /403
  │   └─ 无权限要求 → 放行
  │
  ▼
afterEach → 设置页面标题

6.3 组件级权限

某些页面内部需要根据权限展示不同内容:

<!-- views/system/user/index.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { PERM } from '@/constants/permission-codes';

const permissionStore = usePermissionStore();

const canCreate = computed(() => permissionStore.hasPermission(PERM.USER_CREATE));
const canDelete = computed(() => permissionStore.hasPermission(PERM.USER_DELETE));
const canExport = computed(() => permissionStore.hasPermission(PERM.USER_EXPORT));
</script>

<template>
  <div class="user-manage">
    <div class="toolbar">
      <el-button v-if="canCreate" type="primary" @click="handleCreate">
        新增用户
      </el-button>
      <el-button v-if="canExport" @click="handleExport">
        导出
      </el-button>
    </div>

    <el-table :data="userList">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column v-if="canDelete" label="操作" width="120">
        <template #default="{ row }">
          <el-button type="danger" link @click="handleDelete(row.id)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

七、动态菜单:递归渲染与权限过滤

7.1 菜单数据处理

后端返回的菜单已经是按权限过滤后的,但前端仍需做二次处理:排序、层级构建、隐藏项过滤。

// utils/menu.ts

import type { MenuItem } from '@/types/api';

/** 菜单项(前端渲染用,扩展了层级信息) */
interface RenderMenuItem extends MenuItem {
  children?: RenderMenuItem[];
}

/** 将扁平菜单列表构建为树形结构 */
export function buildMenuTree(flatList: MenuItem[]): RenderMenuItem[] {
  const map = new Map<string, RenderMenuItem>();
  const roots: RenderMenuItem[] = [];

  // 第一遍:建立映射
  flatList.forEach((item) => {
    map.set(item.id, { ...item, children: [] });
  });

  // 第二遍:构建树
  flatList.forEach((item) => {
    const node = map.get(item.id)!;
    if (item.parentId === null || item.parentId === undefined) {
      roots.push(node);
    } else {
      const parent = map.get(item.parentId);
      parent?.children?.push(node);
    }
  });

  // 排序
  const sortNodes = (nodes: RenderMenuItem[]) => {
    nodes.sort((a, b) => a.sort - b.sort);
    nodes.forEach((node) => {
      if (node.children?.length) {
        sortNodes(node.children);
      }
    });
  };
  sortNodes(roots);

  return roots;
}

/** 过滤隐藏菜单项(仅用于菜单渲染,路由不过滤) */
export function filterHiddenMenus(menus: RenderMenuItem[]): RenderMenuItem[] {
  return menus
    .filter((menu) => !menu.hidden)
    .map((menu) => {
      if (menu.children?.length) {
        return {
          ...menu,
          children: filterHiddenMenus(menu.children),
        };
      }
      return menu;
    })
    .filter((menu) => {
      // 过滤掉没有子项且没有组件的空目录
      if (menu.children && menu.children.length === 0 && !menu.component) {
        return false;
      }
      return true;
    });
}

7.2 递归菜单组件

<!-- components/Sidebar/MenuItem.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import type { RenderMenuItem } from '@/utils/menu';

const props = defineProps<{
  menu: RenderMenuItem;
}>();

const router = useRouter();

const hasChildren = computed(() => (props.menu.children?.length ?? 0) > 0);

function handleClick() {
  if (hasChildren.value) return;
  router.push(props.menu.path);
}
</script>

<template>
  <!-- 有子菜单:展开为子菜单组 -->
  <el-sub-menu v-if="hasChildren" :index="menu.path">
    <template #title>
      <el-icon v-if="menu.icon"><component :is="menu.icon" /></el-icon>
      <span>{{ menu.title }}</span>
    </template>
    <MenuItem
      v-for="child in menu.children"
      :key="child.id"
      :menu="child"
    />
  </el-sub-menu>

  <!-- 无子菜单:菜单项 -->
  <el-menu-item v-else :index="menu.path" @click="handleClick">
    <el-icon v-if="menu.icon"><component :is="menu.icon" /></el-icon>
    <template #title>{{ menu.title }}</template>
  </el-menu-item>
</template>
<!-- components/Sidebar/index.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { buildMenuTree, filterHiddenMenus } from '@/utils/menu';
import MenuItem from './MenuItem.vue';

const route = useRoute();
const permissionStore = usePermissionStore();

const menuTree = computed(() => {
  const tree = buildMenuTree(permissionStore.menuList);
  return filterHiddenMenus(tree);
});

const activeMenu = computed(() => route.path);
</script>

<template>
  <el-menu
    :default-active="activeMenu"
    background-color="#001529"
    text-color="#ffffffa6"
    active-text-color="#ffffff"
    router
  >
    <MenuItem
      v-for="menu in menuTree"
      :key="menu.id"
      :menu="menu"
    />
  </el-menu>
</template>

7.3 面包屑与标签页联动

// composables/useBreadcrumb.ts

import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { buildMenuTree } from '@/utils/menu';

interface BreadcrumbItem {
  title: string;
  path: string;
}

export function useBreadcrumb() {
  const route = useRoute();
  const permissionStore = usePermissionStore();

  const breadcrumb = computed<BreadcrumbItem[]>(() => {
    const menuTree = buildMenuTree(permissionStore.menuList);
    const matched: BreadcrumbItem[] = [];

    function findPath(menus: typeof menuTree, target: string): boolean {
      for (const menu of menus) {
        if (menu.path === target) {
          matched.push({ title: menu.title, path: menu.path });
          return true;
        }
        if (menu.children?.length) {
          matched.push({ title: menu.title, path: menu.path });
          if (findPath(menu.children, target)) return true;
          matched.pop();
        }
      }
      return false;
    }

    findPath(menuTree, route.path);
    return matched;
  });

  return { breadcrumb };
}

八、按钮级权限:指令与组件方案

8.1 自定义指令 v-permission

// directives/permission.ts

import type { Directive, DirectiveBinding } from 'vue';
import { usePermissionStore } from '@/stores/permission';

/**
 * v-permission="'user:delete'"
 * v-permission="['user:delete', 'user:edit']"  // 满足任一即可
 *
 * 修饰符:
 *   v-permission.all="['a', 'b']"       // 需要全部满足
 *   v-permission.disable="'user:delete'" // 无权限时禁用而非移除
 *
 * 注意:指令方式在权限实时变更场景下有局限性:
 * - mounted 只执行一次,权限变化后不会自动响应
 * - 已移除的 DOM 无法恢复
 *
 * 推荐策略:
 * - 简单按钮显隐:可用 v-permission
 * - 需要响应式权限变化(切换角色/实时权限变更/多租户切换):优先用 <Auth> 组件
 */
export const vPermission: Directive<HTMLElement> = {
  mounted(el, binding: DirectiveBinding<string | string[]>) {
    checkPermission(el, binding);
  },
  updated(el, binding: DirectiveBinding<string | string[]>) {
    checkPermission(el, binding);
  },
};

function checkPermission(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
  const permissionStore = usePermissionStore();
  const { value, modifiers } = binding;

  if (!value) return;

  const codes = Array.isArray(value) ? value : [value];
  const hasAccess = modifiers.all
    ? permissionStore.hasAllPermissions(codes)
    : permissionStore.hasAnyPermission(codes);

  if (hasAccess) {
    // 有权限时恢复元素状态
    el.removeAttribute('disabled');
    el.style.opacity = '';
    el.style.cursor = '';
    el.style.pointerEvents = '';
    return;
  }

  if (modifiers.disable) {
    // 禁用模式:元素保留但不可点击
    el.setAttribute('disabled', '');
    el.style.opacity = '0.5';
    el.style.cursor = 'not-allowed';
    el.style.pointerEvents = 'none';
  } else {
    // 默认:直接移除元素
    el.parentNode?.removeChild(el);
  }
}

注册指令

// main.ts

import { createApp } from 'vue';
import { vPermission } from '@/directives/permission';
import App from './App.vue';

const app = createApp(App);

app.directive('permission', vPermission);

app.mount('#app');

使用示例

<template>
  <!-- 有删除权限才显示按钮 -->
  <el-button v-permission="'user:delete'" type="danger">删除</el-button>

  <!-- 有编辑或审核权限才显示 -->
  <el-button v-permission="['order:edit', 'order:audit']">操作</el-button>

  <!-- 需要同时拥有两个权限 -->
  <el-button v-permission.all="['report:export', 'report:download']">导出</el-button>

  <!-- 无权限时禁用而非隐藏 -->
  <el-button v-permission.disable="'user:edit'">编辑</el-button>
</template>

8.2 权限组件

指令方案的问题:无法作用于组件级别的条件渲染(如 v-if 场景)。组件方案更灵活:

<!-- components/Auth/index.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';

const props = withDefaults(
  defineProps<{
    /** 权限码 */
    value: string | string[];
    /** 模式:any=满足任一,all=满足全部 */
    mode?: 'any' | 'all';
  }>(),
  { mode: 'any' }
);

const permissionStore = usePermissionStore();

const hasAccess = computed(() => {
  const codes = Array.isArray(props.value) ? props.value : [props.value];
  return props.mode === 'all'
    ? permissionStore.hasAllPermissions(codes)
    : permissionStore.hasAnyPermission(codes);
});
</script>

<template>
  <slot v-if="hasAccess" />
  <slot v-else name="fallback" />
</template>

使用示例

<template>
  <!-- 基本用法 -->
  <Auth value="user:create">
    <el-button type="primary">新增用户</el-button>
  </Auth>

  <!-- 带 fallback 插槽 -->
  <Auth value="user:delete">
    <el-button type="danger">删除</el-button>
    <template #fallback>
      <el-button type="danger" disabled>删除(无权限)</el-button>
    </template>
  </Auth>

  <!-- 多权限码 -->
  <Auth :value="['order:edit', 'order:cancel']" mode="any">
    <el-button>操作订单</el-button>
  </Auth>
</template>

8.3 函数式调用

在非模板场景(如逻辑判断)中,直接使用 Store 方法:

import { usePermissionStore } from '@/stores/permission';

const permissionStore = usePermissionStore();

// 控制表格操作列
const columns = computed(() => {
  const base = [
    { prop: 'name', label: '姓名' },
    { prop: 'email', label: '邮箱' },
  ];

  if (permissionStore.hasPermission('user:edit')) {
    base.push({ prop: 'actions', label: '操作', slot: 'actions' });
  }

  return base;
});

// 控制批量操作按钮
const showBatchDelete = computed(
  () => permissionStore.hasPermission('user:batchDelete') && selectedRows.value.length > 0
);

8.4 三种方案对比

方案 适用场景 优势 劣势
v-permission 指令 简单的按钮显隐 简洁,一行搞定 无法作用在组件级;移除后无法恢复
<Auth> 组件 需要条件渲染、fallback 灵活,支持插槽 嵌套层级多
Store 方法 逻辑判断、动态配置 最灵活 需手动引入

九、数据权限:行级与列级控制

9.1 行级数据权限

行级权限控制"能看到哪些数据",通常由后端 SQL 层控制,但前端也需要配合:

// types/dataPermission.ts

/** 数据范围 */
type DataScope =
  | 'all'                  // 全部数据
  | 'department'           // 本部门
  | 'department_and_sub'   // 本部门及子部门
  | 'self'                 // 仅本人
  | 'custom';              // 自定义部门列表

interface DataPermission {
  resource: string;        // 资源标识,如 'order', 'contract'
  scope: DataScope;
  departmentIds?: string[];  // scope=custom 时指定
}

前端配合方式

// api/order.ts

import { http } from '@/utils/http';

interface OrderQuery {
  page: number;
  pageSize: number;
  status?: string;
}

/** 获取订单列表(后端自动根据数据权限过滤) */
export function fetchOrderList(params: OrderQuery) {
  return http.get('/api/orders', { params });
  // 后端会根据当前用户的数据权限自动过滤:
  // - scope=all: 返回所有订单
  // - scope=department: 只返回用户所属部门的订单
  // - scope=self: 只返回用户自己创建的订单
}

前端提示:根据数据范围显示提示文案,管理用户预期:

<template>
  <div class="order-list">
    <el-alert v-if="dataScopeHint" type="info" :closable="false">
      {{ dataScopeHint }}
    </el-alert>

    <el-table :data="orders">
      <!-- ... -->
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';

const permissionStore = usePermissionStore();

const DATA_SCOPE_HINTS: Record<string, string> = {
  department: '当前仅展示您所属部门的订单',
  department_and_sub: '当前展示您所属部门及下级部门的订单',
  self: '当前仅展示您自己创建的订单',
  custom: '当前展示指定部门的订单',
};

const dataScopeHint = computed(() => {
  const scope = permissionStore.dataPermissions?.find(
    (p) => p.resource === 'order'
  )?.scope;
  return scope ? DATA_SCOPE_HINTS[scope] : '';
});
</script>

9.2 列级数据权限

列级权限必须由后端决定返回哪些字段或返回何种脱敏值,前端只负责展示层控制。敏感字段(手机号、身份证、薪资、银行卡号等)的原文不应返回给无权限用户,仅靠前端隐藏列或脱敏不能算安全控制。

┌────────────────────────────────────────────────────────┐
│              列级权限分两层                              │
│                                                        │
│  后端字段级控制(安全层,必须):                        │
│  - 无权限时不返回该字段                                 │
│  - 或直接返回脱敏后的值(如 138****1234)               │
│  - 敏感字段原文绝不返回给无权限用户                     │
│                                                        │
│  前端展示控制(体验层,辅助):                          │
│  - 根据权限决定是否显示该列                             │
│  - 显示脱敏提示(如 "无权查看完整信息")                │
│  - 提供"申请查看原文"入口                               │
└────────────────────────────────────────────────────────┘
// utils/columnPermission.ts

import { usePermissionStore } from '@/stores/permission';

interface ColumnConfig {
  prop: string;
  label: string;
  permission?: string;           // 查看该列需要的权限码
  sensitive?: boolean;           // 是否敏感字段
  desensitizePermission?: string; // 脱敏查看权限码
  width?: number | string;
  [key: string]: unknown;
}

/**
 * 根据权限过滤表格列
 * 1. 无 permission 或有权限 → 显示
 * 2. 有 permission 但无权限 → 隐藏
 */
export function filterColumns<T extends ColumnConfig>(columns: T[]): T[] {
  const permissionStore = usePermissionStore();

  return columns.filter((col) => {
    if (!col.permission) return true;
    return permissionStore.hasPermission(col.permission);
  });
}

/** 脱敏处理函数 */
const DESENSITIZE_MAP: Record<string, (val: string) => string> = {
  phone: (val) => val.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
  idCard: (val) => val.replace(/(\d{3})\d{11}(\d{4})/, '$1***********$2'),
  email: (val) => val.replace(/(.{2}).*(@.*)/, '$1***$2'),
  bankCard: (val) => val.replace(/(\d{4})\d{8,12}(\d{4})/, '$1********$2'),
  name: (val) =>
    val.length <= 2
      ? val[0] + '*'
      : val[0] + '*'.repeat(val.length - 2) + val[val.length - 1],
};

/** 对敏感字段做脱敏 */
export function desensitize(
  value: string,
  type: keyof typeof DESENSITIZE_MAP
): string {
  const handler = DESENSITIZE_MAP[type];
  return handler ? handler(value) : value;
}

/**
 * 创建带脱敏的列渲染器
 * 有脱敏权限显示原文,否则显示脱敏值
 */
export function createSensitiveColumn(
  type: keyof typeof DESENSITIZE_MAP,
  permissionCode: string
) {
  const permissionStore = usePermissionStore();
  const canViewOriginal = permissionStore.hasPermission(permissionCode);

  return (value: string) =>
    canViewOriginal ? value : desensitize(value, type);
}

使用示例

<template>
  <el-table :data="userList">
    <el-table-column prop="name" label="姓名" />
    <el-table-column prop="phone" label="手机号">
      <template #default="{ row }">
        {{ formatPhone(row.phone) }}
      </template>
    </el-table-column>
    <el-table-column v-if="showSalary" prop="salary" label="薪资">
      <template #default="{ row }">
        {{ formatSalary(row.salary) }}
      </template>
    </el-table-column>
    <el-table-column v-if="showIdCard" prop="idCard" label="身份证号">
      <template #default="{ row }">
        {{ formatIdCard(row.idCard) }}
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { createSensitiveColumn } from '@/utils/columnPermission';
import { PERM } from '@/constants/permission-codes';

const permissionStore = usePermissionStore();

const showSalary = computed(() => permissionStore.hasPermission(PERM.USER_SALARY_VIEW));
const showIdCard = computed(() => permissionStore.hasPermission(PERM.USER_IDCARD_VIEW));

const formatPhone = createSensitiveColumn('phone', PERM.USER_PHONE_ORIGINAL);
const formatSalary = createSensitiveColumn('name', PERM.USER_SALARY_ORIGINAL);
const formatIdCard = createSensitiveColumn('idCard', PERM.USER_IDCARD_ORIGINAL);
</script>

十、多租户权限隔离

10.1 多租户架构

┌────────────────────────────────────────────────────────┐
│                    多租户架构                           │
│                                                        │
│  租户 A ──→ 域名 a.app.com ──→ 租户配置 A             │
│  租户 B ──→ 域名 b.app.com ──→ 租户配置 B             │
│  租户 C ──→ 路径 /c/app    ──→ 租户配置 C             │
│                                                        │
│  每个租户:                                             │
│  - 独立的角色体系                                       │
│  - 独立的菜单配置                                       │
│  - 独立的数据隔离                                       │
│  - 可能不同的功能模块                                   │
└────────────────────────────────────────────────────────┘

10.2 租户 Store

// stores/tenant.ts

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface TenantConfig {
  id: string;
  name: string;
  logo: string;
  theme: {
    primaryColor: string;
    sidebarStyle: 'dark' | 'light';
  };
  features: string[];       // 启用的功能模块
  maxUsers: number;
  customDomain?: string;
}

export const useTenantStore = defineStore('tenant', () => {
  const currentTenant = ref<TenantConfig | null>(null);

  /** 判断功能模块是否启用 */
  const isFeatureEnabled = computed(() => {
    return (feature: string) =>
      currentTenant.value?.features.includes(feature) ?? false;
  });

  /** 初始化租户信息 */
  async function initTenant(tenantId: string) {
    const config = await fetchTenantConfig(tenantId);
    currentTenant.value = config;

    // 应用租户主题
    applyTenantTheme(config.theme);
  }

  return {
    currentTenant,
    isFeatureEnabled,
    initTenant,
  };
});

10.3 租户识别策略

// utils/tenant.ts

/** 从域名识别租户 */
export function resolveTenantFromDomain(): string | null {
  // 子域名方案:tenant.app.com → tenant
  const hostname = window.location.hostname;
  const parts = hostname.split('.');

  if (parts.length > 2) {
    return parts[0];
  }

  return null;
}

/** 从路径识别租户 */
export function resolveTenantFromPath(): string | null {
  // 路径方案:/t/tenantId/xxx
  const match = window.location.pathname.match(/^\/t\/([^/]+)/);
  return match?.[1] ?? null;
}

/** 从 localStorage 恢复租户 */
export function resolveTenantFromStorage(): string | null {
  return localStorage.getItem('tenant_id');
}

/** 综合识别租户 */
export function resolveTenant(): string {
  return (
    resolveTenantFromDomain() ??
    resolveTenantFromPath() ??
    resolveTenantFromStorage() ??
    'default'
  );
}

10.4 租户与权限联动

// 初始化流程:先识别租户,再加载权限

async function bootstrap() {
  // 1. 识别租户
  const tenantId = resolveTenant();
  const tenantStore = useTenantStore();
  await tenantStore.initTenant(tenantId);

  // 2. 加载权限(请求头带上租户标识)
  const permissionStore = usePermissionStore();
  await permissionStore.initApp();

  // 3. 注册动态路由
  addDynamicRoutes();
}

十一、权限缓存与实时更新

11.1 权限缓存策略

┌────────────────────────────────────────────────────────┐
│                  权限缓存分层                           │
│                                                        │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │  内存 Store   │  │ HttpOnly Cookie│ │  BFF 模式    │ │
│  │  (accessToken)│  │(refreshToken) │  │ (最安全)     │ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
│                                                        │
│  ★ accessToken:优先内存存储,页面刷新通过 refresh 恢复 │
│  ★ refreshToken:优先 HttpOnly + Secure + SameSite     │
│    Cookie,由后端控制,前端 JavaScript 无法读取         │
│  ★ 高安全场景:采用 BFF 模式,前端不直接持有长期 Token  │
│  ★ 禁止将 Token 存入 localStorage / sessionStorage,   │
│    同源下任意 JS 均可读取,存在 XSS 泄露风险           │
│  ★ 如业务限制必须使用 Storage,需配合严格的 XSS 防护、 │
│    短有效期和刷新机制,并明确标注风险                   │
└────────────────────────────────────────────────────────┘
// utils/auth.ts

/**
 * Token 管理策略
 *
 * - accessToken:内存存储,页面刷新后通过 /auth/refresh 接口恢复
 * - refreshToken:由后端通过 HttpOnly + Secure + SameSite=Strict Cookie 管理
 *   前端 JavaScript 无法读取,天然防 XSS 窃取
 *
 * 安全提醒:
 *   OWASP 明确建议不要将 authentication tokens 存入 localStorage / sessionStorage,
 *   因为同源下任意 JavaScript 都能访问它们。如业务限制必须使用 Storage,
 *   需配合严格的 XSS 防护、短有效期和刷新机制,并明确标注风险。
 */
let memoryAccessToken = '';

/** 获取 accessToken(内存优先) */
export function getToken(): string {
  return memoryAccessToken;
}

/** 设置 accessToken(仅存内存) */
export function setToken(token: string) {
  memoryAccessToken = token;
}

/** 清除 accessToken */
export function removeToken() {
  memoryAccessToken = '';
}

/**
 * 页面刷新后恢复 accessToken
 * 调用 /auth/refresh 接口,后端通过 HttpOnly Cookie 中的 refreshToken 签发新 accessToken
 * 如果 refreshToken 也过期,后端返回 401,前端跳转登录页
 */
export async function refreshAccessToken(): Promise<string> {
  const { token } = await http.post<{ token: string }>('/auth/refresh');
  memoryAccessToken = token;
  return token;
}

11.2 权限实时更新

管理员修改了某用户的权限后,该用户需要实时感知。三种方案:

┌──────────────────────────────────────────────────────────┐
│               权限实时更新方案对比                         │
├──────────┬──────────────────┬────────────────────────────┤
│ 方案      │ 实现方式          │ 适用场景                    │
├──────────┼──────────────────┼────────────────────────────┤
│ 轮询      │ 定时请求权限接口  │ 简单场景,实时性要求不高     │
│ WebSocket │ 服务端推送变更    │ 实时性要求高                 │
│ 路由切换  │ 每次导航重新校验  │ 最安全,但请求较多           │
└──────────┴──────────────────┴────────────────────────────┘

方案一:轮询刷新

// composables/usePermissionRefresh.ts

import { onMounted, onUnmounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';

const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 分钟

export function usePermissionRefresh() {
  const permissionStore = usePermissionStore();
  let timer: ReturnType<typeof setInterval> | null = null;

  async function refreshPermissions() {
    try {
      await permissionStore.initApp(true);
    } catch {
      console.warn('[Permission] 权限刷新失败');
    }
  }

  onMounted(() => {
    timer = setInterval(refreshPermissions, REFRESH_INTERVAL);
  });

  onUnmounted(() => {
    if (timer) {
      clearInterval(timer);
      timer = null;
    }
  });
}

方案二:WebSocket 推送

// composables/usePermissionPush.ts

import { onMounted, onUnmounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { useWebSocket } from '@/composables/useWebSocket';

export function usePermissionPush() {
  const permissionStore = usePermissionStore();
  const { subscribe, disconnect } = useWebSocket();

  function handlePermissionChange(data: { type: string; payload: unknown }) {
    if (data.type === 'PERMISSION_CHANGED') {
      permissionStore.initApp(true);
    }

    if (data.type === 'FORCE_LOGOUT') {
      permissionStore.reset();
      window.location.href = '/login';
    }
  }

  onMounted(() => {
    subscribe('permission', handlePermissionChange);
  });

  onUnmounted(() => {
    disconnect();
  });
}

方案三:路由切换时轻量校验

// router/guard.ts(在全局守卫中增加)

router.beforeEach(async (to) => {
  // ... 登录检查 ...

  // 每次路由切换都重新校验权限版本号
  if (permissionStore.isInitialized) {
    const { version } = await http.get<{ version: number }>('/api/permission/version');
    if (version !== permissionStore.permissionVersion) {
      // 版本不一致,重新拉取全量权限
      await permissionStore.initApp(true);
    }
  }

  return true;
});

11.3 多标签页同步

同一浏览器多个标签页,A 标签退出登录后 B 标签应同步:

// composables/useCrossTabSync.ts

import { onMounted, onUnmounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';

export function useCrossTabSync() {
  const permissionStore = usePermissionStore();

  function handleStorageChange(event: StorageEvent) {
    if (event.key === 'access_token') {
      if (!event.newValue) {
        // Token 被清除(其他标签页退出登录)
        permissionStore.reset();
        window.location.href = '/login';
      }
    }

    if (event.key === 'permission_version') {
      // 权限版本变更,重新拉取
      permissionStore.initApp(true);
    }
  }

  onMounted(() => {
    window.addEventListener('storage', handleStorageChange);
  });

  onUnmounted(() => {
    window.removeEventListener('storage', handleStorageChange);
  });
}

十二、权限与微前端

12.1 微前端权限共享架构

┌──────────────────────────────────────────────────────────┐
│              微前端权限共享架构                            │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │                    基座应用                          │  │
│  │  ┌──────────────────────────────────────────────┐  │  │
│  │  │           权限 Store(单一数据源)             │  │  │
│  │  │  - token / roles / permissions / menus        │  │  │
│  │  └──────────────────┬───────────────────────────┘  │  │
│  │                     │                               │  │
│  │          ┌──────────┼──────────┐                    │  │
│  │          ▼          ▼          ▼                    │  │
│  │     ┌────────┐ ┌────────┐ ┌────────┐              │  │
│  │     │子应用A │ │子应用B │ │子应用C │              │  │
│  │     │用户管理│ │订单系统│ │报表中心│              │  │
│  │     └────────┘ └────────┘ └────────┘              │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  共享方式:                                               │
│  1. props 传递(qiankun)                                │
│  2. CustomEvent 通信                                     │
│  3. 共享 Store(Module Federation 单例共享)              │
└──────────────────────────────────────────────────────────┘

12.2 基座向子应用传递权限

qiankun 方案

// 基座:micro-apps.ts

import { registerMicroApps, start } from 'qiankun';
import { usePermissionStore } from '@/stores/permission';

/**
 * qiankun 的 props 必须是对象,不能是函数
 * 通过 getAuthState 函数让子应用在需要时获取最新权限状态
 * (权限可能因实时推送而变更,子应用应主动拉取而非缓存)
 */
registerMicroApps([
  {
    name: 'user-admin',
    entry: '//localhost:8001',
    container: '#sub-container',
    activeRule: '/user',
    props: {
      getAuthState: () => {
        const permissionStore = usePermissionStore();
        return {
          token: permissionStore.token,
          roles: permissionStore.roleCodes,
          permissions: Array.from(permissionStore.permissionCodes),
          menus: permissionStore.menuList,
        };
      },
    },
  },
  {
    name: 'order-system',
    entry: '//localhost:8002',
    container: '#sub-container',
    activeRule: '/order',
    props: {
      getAuthState: () => {
        const permissionStore = usePermissionStore();
        return {
          token: permissionStore.token,
          roles: permissionStore.roleCodes,
          permissions: Array.from(permissionStore.permissionCodes),
          menus: permissionStore.menuList,
        };
      },
    },
  },
]);

start();

子应用接收权限

// 子应用:main.ts

import { createApp } from 'vue';
import { useSubPermissionStore } from './stores/permission';

let app: ReturnType<typeof createApp>;

interface AuthState {
  token: string;
  roles: string[];
  permissions: string[];
  menus: MenuItem[];
}

export async function mount(props: {
  getAuthState: () => AuthState;
}) {
  // 通过 getAuthState 获取最新权限状态
  const authState = props.getAuthState();

  const permissionStore = useSubPermissionStore();
  permissionStore.setPermissions(authState.permissions);
  permissionStore.setRoles(authState.roles);

  app = createApp(App);
  app.mount('#sub-app');
}

export async function unmount() {
  app?.unmount();
}

12.3 子应用权限独立 vs 共享

维度 共享(基座统一管理) 独立(子应用自管)
一致性 全局统一,体验一致 各子应用可能不一致
维护成本 基座维护,子应用零成本 每个子应用都要实现
灵活性 子应用无法自定义 子应用可扩展
适用场景 中后台、企业内部系统 开放平台、第三方集成

推荐:基座统一管理权限数据,子应用通过接口获取,不做独立权限逻辑。


十三、安全加固:前端权限的不可信本质

13.1 前端权限的攻击面

┌──────────────────────────────────────────────────────────┐
│              前端权限的攻击面                              │
│                                                          │
│  攻击方式              防御措施                            │
│  ─────────────────────────────────────────────────────── │
│  1. 修改 localStorage   → Token 加密存储 + 签名校验       │
│  2. 浏览器 DevTools     → 后端必须校验,前端只是体验层     │
│  3. 直接请求后端接口    → 后端接口权限校验(必须)         │
│  4. 伪造权限码          → 权限码从后端获取,不前端硬编码   │
│  5. URL 直接访问        → 路由守卫 + 后端 403             │
│  6. 接口参数篡改        → 后端参数校验 + 数据权限过滤     │
└──────────────────────────────────────────────────────────┘

13.2 安全加固清单

// utils/security.ts

/**
 * 1. Token 安全存储
 * - 优先存内存,避免 XSS 读取
 * - SessionStorage 次选(关闭标签即清除)
 * - 禁止存 Cookie(除非 HttpOnly)
 * - 禁止存 LocalStorage(除非加密)
 */

/**
 * 2. 权限码不可篡改
 * - 权限码由后端下发,前端不硬编码
 * - 每次敏感操作前,后端重新校验
 */

/**
 * 3. 接口请求携带 Token
 */
import { getToken } from '@/utils/auth';

http.interceptors.request.use((config) => {
  const token = getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

/**
 * 4. 401 / 403 统一处理
 */
http.interceptors.response.use(
  (response) => response,
  (error) => {
    const { status } = error.response ?? {};

    if (status === 401) {
      handleTokenExpired();
    } else if (status === 403) {
      router.push('/403');
    }

    return Promise.reject(error);
  }
);

/**
 * 5. XSS 防护:权限码不渲染到 DOM
 * - v-permission 指令只做移除/禁用,不把权限码写入 DOM 属性
 * - 菜单数据不暴露到全局变量
 */

/**
 * 6. CSRF 防护
 * - 使用 Bearer Token 而非 Cookie
 * - 如必须用 Cookie,添加 CSRF Token
 */

13.3 IDOR 越权防护

IDOR(Insecure Direct Object Reference)是最常见的越权漏洞:

┌────────────────────────────────────────────────────────┐
│              IDOR 越权攻击示例                          │
│                                                        │
│  攻击:                                                │
│  GET /api/users/123     ← 正常访问自己的信息           │
│  GET /api/users/456     ← 篡改 ID,访问他人信息        │
│                                                        │
│  防御:                                                │
│  后端必须校验当前用户是否有权访问 ID=456 的资源         │
│  前端无法防御,只能后端兜底                             │
│                                                        │
│  前端配合:                                            │
│  - 不在 URL 中暴露自增 ID,使用 UUID                   │
│  - 敏感操作二次确认                                    │
│  - 操作日志记录                                        │
└────────────────────────────────────────────────────────┘

13.4 后端必须做的事

┌────────────────────────────────────────────────────────┐
│              后端权限校验(必须)                         │
│                                                        │
│  每个接口必须:                                         │
│  1. 校验 Token 有效性                                   │
│  2. 校验用户角色是否有权访问该接口                       │
│  3. 校验数据权限(行级过滤)                             │
│  4. 校验操作权限(如删除需要 delete 权限)               │
│  5. 参数校验(防止 IDOR 越权访问)                      │
│                                                        │
│  ★ 核心原则:即使前端绕过了所有检查,                   │
│    后端也能拦截非法请求                                  │
└────────────────────────────────────────────────────────┘

十四、测试策略

14.1 权限 Store 测试

// stores/__tests__/permission.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { usePermissionStore } from '../permission';

describe('PermissionStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  describe('hasPermission', () => {
    it('超级管理员拥有所有权限', () => {
      const store = usePermissionStore();
      store.$patch({
        roles: [{ id: '1', code: 'super_admin', name: '超管', permissions: [] }],
        permissionCodes: new Set(),
      });

      expect(store.hasPermission('any:permission')).toBe(true);
    });

    it('普通用户检查权限码', () => {
      const store = usePermissionStore();
      store.$patch({
        roles: [{ id: '2', code: 'editor', name: '编辑', permissions: [] }],
        permissionCodes: new Set(['article:create', 'article:edit']),
      });

      expect(store.hasPermission('article:create')).toBe(true);
      expect(store.hasPermission('article:delete')).toBe(false);
    });
  });

  describe('hasAnyPermission', () => {
    it('满足任一权限即返回 true', () => {
      const store = usePermissionStore();
      store.$patch({
        roles: [{ id: '2', code: 'editor', name: '编辑', permissions: [] }],
        permissionCodes: new Set(['article:edit']),
      });

      expect(store.hasAnyPermission(['article:create', 'article:edit'])).toBe(true);
      expect(store.hasAnyPermission(['article:delete', 'article:export'])).toBe(false);
    });
  });

  describe('hasAllPermissions', () => {
    it('必须全部满足', () => {
      const store = usePermissionStore();
      store.$patch({
        roles: [{ id: '2', code: 'editor', name: '编辑', permissions: [] }],
        permissionCodes: new Set(['article:edit', 'article:create']),
      });

      expect(store.hasAllPermissions(['article:edit', 'article:create'])).toBe(true);
      expect(store.hasAllPermissions(['article:edit', 'article:delete'])).toBe(false);
    });
  });

  describe('reset', () => {
    it('清除所有状态', () => {
      const store = usePermissionStore();
      store.$patch({
        token: 'test-token',
        roles: [{ id: '1', code: 'admin', name: '管理员', permissions: [] }],
        permissionCodes: new Set(['test:perm']),
        isInitialized: true,
      });

      store.reset();

      expect(store.token).toBe('');
      expect(store.roles).toEqual([]);
      expect(store.permissionCodes.size).toBe(0);
      expect(store.isInitialized).toBe(false);
    });
  });
});

14.2 路由守卫测试

// router/__tests__/guard.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
import { setupRouterGuard } from '../guard';
import { usePermissionStore } from '@/stores/permission';

describe('Router Guard', () => {
  let router: Router;

  beforeEach(async () => {
    router = createRouter({
      history: createMemoryHistory(),
      routes: [
        { path: '/login', name: 'Login', component: { render: () => null } },
        { path: '/403', name: 'Forbidden', component: { render: () => null } },
        { path: '/dashboard', name: 'Dashboard', component: { render: () => null } },
      ],
    });

    setupRouterGuard(router);
  });

  it('未登录访问非白名单页面 → 跳转登录', async () => {
    const permissionStore = usePermissionStore();
    permissionStore.reset();

    await router.push('/dashboard');
    expect(router.currentRoute.value.path).toBe('/login');
  });

  it('已登录访问 /login → 跳转首页', async () => {
    const permissionStore = usePermissionStore();
    permissionStore.$patch({ token: 'valid-token' });

    await router.push('/login');
    expect(router.currentRoute.value.path).toBe('/');
  });
});

14.3 权限指令测试

// directives/__tests__/permission.test.ts

import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { defineComponent, h } from 'vue';
import { vPermission } from '../permission';
import { usePermissionStore } from '@/stores/permission';

describe('vPermission', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it('有权限时保留元素', () => {
    const store = usePermissionStore();
    store.$patch({
      roles: [{ id: '1', code: 'admin', name: '管理员', permissions: [] }],
      permissionCodes: new Set(['user:delete']),
    });

    const Comp = defineComponent({
      template: '<button v-permission="\'user:delete\'">删除</button>',
      directives: { permission: vPermission },
    });

    const wrapper = mount(Comp);
    expect(wrapper.find('button').exists()).toBe(true);
  });

  it('无权限时移除元素', () => {
    const store = usePermissionStore();
    store.$patch({
      roles: [{ id: '1', code: 'viewer', name: '查看者', permissions: [] }],
      permissionCodes: new Set(),
    });

    const Comp = defineComponent({
      template: '<div><button v-permission="\'user:delete\'">删除</button></div>',
      directives: { permission: vPermission },
    });

    const wrapper = mount(Comp);
    expect(wrapper.find('button').exists()).toBe(false);
  });

  it('disable 修饰符:无权限时禁用而非移除', () => {
    const store = usePermissionStore();
    store.$patch({
      roles: [{ id: '1', code: 'viewer', name: '查看者', permissions: [] }],
      permissionCodes: new Set(),
    });

    const Comp = defineComponent({
      template: '<button v-permission.disable="\'user:edit\'">编辑</button>',
      directives: { permission: vPermission },
    });

    const wrapper = mount(Comp);
    const btn = wrapper.find('button');
    expect(btn.exists()).toBe(true);
    expect(btn.attributes('disabled')).toBeDefined();
  });
});

14.4 E2E 测试

// e2e/permission.spec.ts

import { test, expect } from '@playwright/test';

test.describe('权限系统', () => {
  test('普通用户看不到管理员菜单', async ({ page }) => {
    // 以普通用户登录
    await page.goto('/login');
    await page.fill('[data-test="username"]', 'viewer');
    await page.fill('[data-test="password"]', 'password');
    await page.click('[data-test="submit"]');

    // 等待页面加载
    await page.waitForURL('/dashboard');

    // 检查菜单中不包含"系统管理"
    const menuItems = page.locator('.el-menu-item, .el-sub-menu__title');
    const texts = await menuItems.allTextContents();
    expect(texts).not.toContain('系统管理');
  });

  test('直接访问无权限路由 → 403', async ({ page }) => {
    // 以普通用户登录
    await loginAs(page, 'viewer');

    // 直接访问管理员页面
    await page.goto('/system/user');
    await page.waitForURL('/403');
    expect(page.url()).toContain('/403');
  });

  test('管理员可以看到所有菜单', async ({ page }) => {
    await loginAs(page, 'admin');
    await page.waitForURL('/dashboard');

    const menuItems = page.locator('.el-menu-item, .el-sub-menu__title');
    const texts = await menuItems.allTextContents();
    expect(texts).toContain('系统管理');
  });
});

十五、踩坑清单与最佳实践

15.1 踩坑清单

# 原因 解决方案
1 动态路由 404 addRoute 后当前导航还没生效 next({ ...to, replace: true }) 重新导航
2 刷新页面白屏 动态路由丢失,路由守卫未重新注册 守卫中判断 isInitialized,未初始化则重新拉取
3 退出登录路由残留 动态路由未清除 退出时调用 resetRouter() 移除动态路由
4 权限码硬编码 前端到处写死字符串 集中定义 PERM 常量对象
5 v-permission 移除后无法恢复 指令 mounted 只执行一次 切换用户时需重新渲染组件,或使用 <Auth> 组件
6 Set 序列化丢失 JSON.stringify(new Set()){} 持久化时 Array.from() 转换
7 超管判断遗漏 只检查了 hasPermission 忘记超管跳过 hasPermission 内部统一处理超管
8 菜单与路由不一致 菜单过滤了但路由没过滤,或反之 菜单和路由使用同一份数据源
9 Token 过期未处理 401 响应未清除状态 响应拦截器统一处理 401
10 多标签页不同步 A 标签退出,B 标签仍可用 storage 事件监听 + Token 校验

15.2 权限码常量管理

// constants/permission-codes.ts

/**
 * 权限码常量
 * 命名规则:模块:资源:操作
 *
 * 按模块分组,方便维护和检索
 */
export const PERM = {
  // ─── 用户管理 ───
  USER_LIST: 'system:user:list',
  USER_CREATE: 'system:user:create',
  USER_EDIT: 'system:user:edit',
  USER_DELETE: 'system:user:delete',
  USER_EXPORT: 'system:user:export',
  USER_SALARY_VIEW: 'system:user:salary',
  USER_IDCARD_VIEW: 'system:user:idcard',
  USER_PHONE_ORIGINAL: 'system:user:phone:original',
  USER_IDCARD_ORIGINAL: 'system:user:idcard:original',
  USER_SALARY_ORIGINAL: 'system:user:salary:original',

  // ─── 角色管理 ───
  ROLE_LIST: 'system:role:list',
  ROLE_CREATE: 'system:role:create',
  ROLE_EDIT: 'system:role:edit',
  ROLE_DELETE: 'system:role:delete',

  // ─── 订单管理 ───
  ORDER_LIST: 'order:list',
  ORDER_DETAIL: 'order:detail',
  ORDER_EDIT: 'order:edit',
  ORDER_DELETE: 'order:delete',
  ORDER_EXPORT: 'order:export',
  ORDER_AUDIT: 'order:audit',

  // ─── 报表中心 ───
  REPORT_VIEW: 'report:view',
  REPORT_EXPORT: 'report:export',
  REPORT_DOWNLOAD: 'report:download',
} as const;

/** 权限码类型 */
export type PermissionCode = (typeof PERM)[keyof typeof PERM];

15.3 最佳实践总结

┌──────────────────────────────────────────────────────────┐
│              前端权限最佳实践                              │
│                                                          │
│  1. 后端兜底:前端权限只是体验层,后端必须校验             │
│  2. 单一数据源:权限数据集中在一个 Store                  │
│  3. 初始化门控:isInitialized 标志确保数据就绪            │
│  4. 权限码常量化:集中定义,禁止散落硬编码                 │
│  5. Set 存储:权限码用 Set 而非 Array,O(1) 查找         │
│  6. 超管统一处理:hasPermission 内部处理超管跳过           │
│  7. 路由与菜单同源:使用同一份数据生成路由和菜单           │
│  8. 退出清理:退出登录时清除路由、Store、Token             │
│  9. 多标签同步:storage 事件监听 + Token 校验             │
│  10. 安全优先:Token 内存存储,敏感操作后端二次校验        │
└──────────────────────────────────────────────────────────┘

15.4 完整初始化流程

// main.ts

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { setupRouterGuard } from './router/guard';
import { vPermission } from './directives/permission';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.use(router);

// 注册权限指令
app.directive('permission', vPermission);

// 注册路由守卫
setupRouterGuard(router);

app.mount('#app');
// App.vue

<script setup lang="ts">
import { onMounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { useCrossTabSync } from '@/composables/useCrossTabSync';
import { usePermissionRefresh } from '@/composables/usePermissionRefresh';

const permissionStore = usePermissionStore();

// 多标签页同步
useCrossTabSync();

// 权限定时刷新
usePermissionRefresh();

onMounted(async () => {
  // 如果有 Token,初始化权限数据
  if (permissionStore.isLoggedIn && !permissionStore.isInitialized) {
    try {
      await permissionStore.initApp();
    } catch {
      permissionStore.reset();
    }
  }
});
</script>

<template>
  <router-view />
</template>

总结

前端权限系统是一个横切关注点,贯穿路由、组件、指令、数据层。核心要点:

  1. 模型选择:RBAC 适合 90% 场景,ABAC 补充细粒度控制,混合模型最实用
  2. 单一数据源:权限数据集中在 Store,避免多处维护
  3. 分层控制:路由级 → 菜单级 → 按钮级 → 数据级,层层递进
  4. 安全底线:前端权限是体验优化,后端校验是安全兜底,两者缺一不可
  5. 工程化:权限码常量化、Store 标准化、指令/组件复用、测试覆盖

权限系统没有银弹,但遵循上述架构和原则,可以构建出安全、可维护、可扩展的前端权限体系。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐