16 KiB
16 KiB
权限系统增强设计文档
日期: 2026-04-08
作者: 张翔
版本: 1.0
状态: 待审查
1. 概述
1.1 背景
当前系统已完成基础的路由权限控制,但存在以下优化空间:
- 菜单硬编码 - 菜单在前端硬编码,无法根据用户角色动态显示
- 权限数据分散 - 角色和权限信息存储在 localStorage,缺乏统一管理
- 缺少按钮级权限控制 - 无法控制按钮级别的权限
- 缺少 API 权限检查 - 前端调用 API 前未检查权限,可能发送无效请求
1.2 目标
实现完整的权限系统增强,包括:
- 动态菜单渲染 - 从后端获取菜单数据,根据用户权限动态渲染
- 权限缓存优化 - 使用 Pinia 统一管理权限数据,localStorage 持久化
- 权限指令 - 提供
v-permission指令实现按钮级权限控制 - API 权限检查 - 前端调用 API 前检查权限,减少无效请求
1.3 范围
包含:
- Permission Store (Pinia)
- v-permission 指令
- 动态菜单渲染
- API 权限检查工具
- 相关单元测试
不包含:
- 后端权限系统修改(仅需新增 API)
- 数据库权限表结构调整
- 其他业务功能开发
2. 架构设计
2.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 前端应用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 路由守卫 │ │ 权限指令 │ │ 动态菜单 │ │
│ │ (已完成) │ │ v-permission │ │ 渲染 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Permission │ │
│ │ Store (Pinia) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ localStorage │ │
│ │ (持久化) │ │
│ └─────────────────┘ │
│ │
└───────────────────────────┬─────────────────────────────────┘
│
HTTP API │
│
┌───────────────────────────▼─────────────────────────────────┐
│ 后端服务 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /auth/login │ │ /menus/user │ │ /permissions │ │
│ │ (已存在) │ │ (新增) │ │ (已存在) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RBAC 权限系统 (角色-权限-菜单) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
2.2 数据流
登录成功
↓
解析 JWT token 获取角色
↓
调用 fetchUserMenus() 获取菜单和权限
↓
存入 Store + localStorage
↓
页面刷新时从 localStorage 恢复
3. 详细设计
3.1 Permission Store
文件位置: src/stores/permission.ts
状态定义:
interface PermissionState {
roles: string[] // 用户角色
permissions: string[] // 用户权限码
menus: MenuItem[] // 用户菜单
loaded: boolean // 是否已加载
}
interface MenuItem {
id: number
name: string
path: string
icon?: string
parentId?: number
sort: number
children?: MenuItem[]
}
核心 Actions:
// 初始化权限数据(从 localStorage 恢复)
initFromStorage(): void
// 登录后设置权限数据
setPermissionData(data: {
roles: string[]
permissions: string[]
menus: MenuItem[]
}): void
// 从后端刷新权限数据
async fetchUserMenus(): Promise<void>
// 清除权限数据(退出登录)
clearPermissionData(): void
// 权限检查方法
hasRole(role: string | string[]): boolean
hasPermission(permission: string | string[]): boolean
持久化策略:
- 登录时:将角色、权限、菜单数据存入 localStorage
- 页面刷新:Pinia 从 localStorage 恢复数据,立即渲染菜单
- 权限变更:提供刷新机制,同步更新 localStorage 和 Pinia
- 退出登录:清除所有数据
3.2 v-permission 指令
文件位置: src/directives/permission.ts
用法:
<!-- 角色检查 -->
<button v-permission:role="'admin'">管理员按钮</button>
<!-- 权限码检查 -->
<button v-permission:permission="'user:delete'">删除用户</button>
<!-- 支持数组(满足任一条件) -->
<button v-permission:role="['admin', 'manager']">导出数据</button>
<button v-permission:permission="['user:create', 'user:update']">编辑用户</button>
<!-- 简写形式(默认权限检查) -->
<button v-permission="'user:delete'">删除</button>
实现逻辑:
export const permissionDirective = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const permissionStore = usePermissionStore()
const { arg, value } = binding
const checkType = arg || 'permission' // 默认权限检查
let hasAccess = false
if (checkType === 'role') {
hasAccess = permissionStore.hasRole(value)
} else if (checkType === 'permission') {
hasAccess = permissionStore.hasPermission(value)
}
if (!hasAccess) {
el.style.display = 'none'
}
}
}
注册方式:
// src/main.ts
import { permissionDirective } from '@/directives/permission'
app.directive('permission', permissionDirective)
3.3 动态菜单渲染
后端 API:
GET /api/menus/user
请求头:
Authorization: Bearer <token>
响应:
{
"code": 200,
"data": {
"menus": [
{
"id": 1,
"name": "仪表盘",
"path": "/dashboard",
"icon": "Odometer",
"parentId": null,
"sort": 1
},
{
"id": 2,
"name": "系统管理",
"path": "/system",
"icon": "Setting",
"parentId": null,
"sort": 2,
"children": [
{
"id": 3,
"name": "用户管理",
"path": "/users",
"icon": null,
"parentId": 2,
"sort": 1
}
]
}
],
"permissions": [
"user:read",
"user:create",
"user:update",
"user:delete"
]
}
}
前端组件:
<!-- src/layouts/DefaultLayout.vue -->
<template>
<el-menu
:default-active="activeMenu"
class="menu"
:collapse="collapsed"
router
>
<menu-item
v-for="menu in menuTree"
:key="menu.id"
:menu="menu"
/>
</el-menu>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePermissionStore } from '@/stores/permission'
import MenuItem from '@/components/MenuItem.vue'
const permissionStore = usePermissionStore()
const menuTree = computed(() => permissionStore.menus)
</script>
递归菜单组件:
<!-- src/components/MenuItem.vue -->
<template>
<!-- 有子菜单 -->
<el-sub-menu
v-if="menu.children && menu.children.length > 0"
:index="String(menu.id)"
>
<template #title>
<el-icon v-if="menu.icon">
<component :is="menu.icon" />
</el-icon>
<span>{{ menu.name }}</span>
</template>
<menu-item
v-for="child in menu.children"
:key="child.id"
:menu="child"
/>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item
v-else
:index="menu.path"
>
<el-icon v-if="menu.icon">
<component :is="menu.icon" />
</el-icon>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
3.4 API 权限检查
文件位置: src/utils/permission-check.ts
权限映射配置:
const apiPermissionMap: Record<string, { permission: string; method: string }> = {
'/api/users:GET': { permission: 'user:read', method: 'GET' },
'/api/users:POST': { permission: 'user:create', method: 'POST' },
'/api/users/*:PUT': { permission: 'user:update', method: 'PUT' },
'/api/users/*:DELETE': { permission: 'user:delete', method: 'DELETE' },
'/api/roles:GET': { permission: 'role:read', method: 'GET' },
// ... 更多映射
}
检查函数:
export function canAccessApi(path: string, method: string): boolean {
const permissionStore = usePermissionStore()
const required = findRequiredPermission(path, method, apiPermissionMap)
if (!required) {
return true // 未定义权限要求的 API 默认允许
}
return permissionStore.hasPermission(required.permission)
}
集成到请求拦截器:
// src/utils/request.ts
import { canAccessApi } from './permission-check'
request.interceptors.request.use(
(config) => {
// 权限检查
const path = config.url || ''
const method = config.method?.toUpperCase() || 'GET'
if (!canAccessApi(path, method)) {
return Promise.reject(new Error('无权限访问此 API'))
}
// 原有的 token 和签名逻辑
// ...
return config
}
)
4. 测试策略
4.1 测试覆盖范围
-
Permission Store 单元测试
- 测试权限数据的存储和恢复
- 测试 hasRole 和 hasPermission 方法
- 测试 localStorage 持久化
- 测试数据清除功能
-
v-permission 指令测试
- 测试角色检查功能
- 测试权限码检查功能
- 测试数组参数处理
- 测试元素隐藏/显示逻辑
-
动态菜单测试
- 测试菜单数据获取
- 测试菜单树渲染
- 测试菜单缓存机制
- 测试菜单权限过滤
-
API 权限检查测试
- 测试权限映射匹配
- 测试通配符匹配
- 测试请求拦截逻辑
4.2 测试文件结构
src/
├── stores/
│ └── __tests__/
│ └── permission.test.ts
├── directives/
│ └── __tests__/
│ └── permission.test.ts
├── components/
│ └── __tests__/
│ └── MenuItem.test.ts
└── utils/
└── __tests__/
└── permission-check.test.ts
5. 实施计划
5.1 实施顺序
第 1 步:Permission Store(1-2 小时)
- 创建
src/stores/permission.ts - 实现 localStorage 持久化
- 编写单元测试
- 集成到登录流程
第 2 步:v-permission 指令(1-2 小时)
- 创建
src/directives/permission.ts - 注册全局指令
- 编写单元测试
- 在现有页面应用示例
第 3 步:后端 API 开发(2-3 小时)
- 新增
GET /api/menus/user接口 - 根据用户角色返回菜单树
- 返回用户权限列表
- 编写后端测试
第 4 步:动态菜单渲染(2-3 小时)
- 创建
src/components/MenuItem.vue - 修改
DefaultLayout.vue - 集成 Permission Store
- 编写组件测试
第 5 步:API 权限检查(1-2 小时)
- 创建
src/utils/permission-check.ts - 集成到请求拦截器
- 编写单元测试
- 优化性能
5.2 后端 API 需求
接口: GET /api/menus/user
功能: 获取当前登录用户可访问的菜单和权限
业务逻辑:
- 从 token 获取用户 ID
- 查询用户角色
- 根据角色查询菜单和权限
- 构建菜单树结构
- 返回菜单和权限列表
预估时间: 7-12 小时
6. 风险和约束
6.1 技术风险
- 后端 API 开发时间 - 需要后端配合开发新 API
- 菜单数据迁移 - 需要将硬编码菜单迁移到数据库
- 权限数据同步 - 前后端权限数据需要保持一致
6.2 约束条件
- 向后兼容 - 需要兼容现有的路由守卫逻辑
- 性能要求 - 菜单加载不能影响页面首屏渲染速度
- 测试覆盖 - 所有新增代码需要单元测试覆盖
7. 验收标准
7.1 功能验收
- Permission Store 正确管理权限数据
- v-permission 指令正确控制按钮显示
- 动态菜单根据用户权限正确渲染
- API 权限检查正确拦截无权限请求
7.2 质量验收
- 所有单元测试通过
- 代码覆盖率 ≥ 80%
- TypeScript 类型检查通过
- ESLint 检查通过
7.3 性能验收
- 菜单加载时间 < 500ms
- localStorage 读写不影响页面性能
- 权限检查不影响 API 请求速度
8. 后续优化
8.1 短期优化
- 权限缓存过期 - 添加权限数据过期机制
- 权限变更通知 - 实现权限变更后的实时通知
- 权限日志 - 记录权限检查日志,便于调试
8.2 长期优化
- 权限可视化配置 - 提供权限配置界面
- 权限审计 - 记录用户权限变更历史
- 权限模板 - 提供常用权限模板,简化配置