38dc055a27
- 添加菜单数据修复设计文档 - 添加用户管理和角色管理测试修复设计文档 - 添加本地开发测试设计文档 - 添加相关实现计划
1365 lines
34 KiB
Markdown
1365 lines
34 KiB
Markdown
# 权限系统增强实现计划
|
||
|
||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||
|
||
**目标:** 实现完整的权限系统增强,包括 Permission Store、v-permission 指令、动态菜单渲染和 API 权限检查。
|
||
|
||
**架构:** 使用 Pinia 统一管理权限数据,localStorage 持久化存储;通过 v-permission 指令实现按钮级权限控制;从后端 API 获取菜单数据动态渲染;在请求拦截器中添加 API 权限检查。
|
||
|
||
**技术栈:** Vue 3, Pinia, TypeScript, Element Plus, jwt-decode
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
### 新增文件
|
||
|
||
```
|
||
novalon-manage-web/src/
|
||
├── stores/
|
||
│ └── permission.ts # Permission Store
|
||
├── directives/
|
||
│ └── permission.ts # v-permission 指令
|
||
├── components/
|
||
│ └── MenuItem.vue # 递归菜单组件
|
||
├── utils/
|
||
│ └── permission-check.ts # API 权限检查工具
|
||
└── __tests__/
|
||
├── stores/
|
||
│ └── permission.test.ts # Permission Store 测试
|
||
├── directives/
|
||
│ └── permission.test.ts # v-permission 指令测试
|
||
├── components/
|
||
│ └── MenuItem.test.ts # MenuItem 组件测试
|
||
└── utils/
|
||
└── permission-check.test.ts # API 权限检查测试
|
||
```
|
||
|
||
### 修改文件
|
||
|
||
```
|
||
novalon-manage-web/src/
|
||
├── main.ts # 注册权限指令
|
||
├── views/system/Login.vue # 集成 Permission Store
|
||
├── layouts/DefaultLayout.vue # 使用动态菜单
|
||
└── utils/request.ts # 添加 API 权限检查
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 1:创建 Permission Store
|
||
|
||
**文件:**
|
||
- 创建:`novalon-manage-web/src/stores/permission.ts`
|
||
- 测试:`novalon-manage-web/src/__tests__/stores/permission.test.ts`
|
||
|
||
- [ ] **步骤 1:编写 Permission Store 测试 - 基础功能**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/__tests__/stores/permission.test.ts
|
||
import { describe, it, expect, beforeEach } from 'vitest'
|
||
import { setActivePinia, createPinia } from 'pinia'
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
|
||
describe('Permission Store', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
localStorage.clear()
|
||
})
|
||
|
||
describe('基础功能', () => {
|
||
it('应该正确初始化状态', () => {
|
||
const store = usePermissionStore()
|
||
|
||
expect(store.roles).toEqual([])
|
||
expect(store.permissions).toEqual([])
|
||
expect(store.menus).toEqual([])
|
||
expect(store.loaded).toBe(false)
|
||
})
|
||
|
||
it('应该正确设置权限数据', () => {
|
||
const store = usePermissionStore()
|
||
|
||
store.setPermissionData({
|
||
roles: ['admin'],
|
||
permissions: ['user:read', 'user:delete'],
|
||
menus: [
|
||
{
|
||
id: 1,
|
||
name: '仪表盘',
|
||
path: '/dashboard',
|
||
icon: 'Odometer',
|
||
sort: 1
|
||
}
|
||
]
|
||
})
|
||
|
||
expect(store.roles).toEqual(['admin'])
|
||
expect(store.permissions).toEqual(['user:read', 'user:delete'])
|
||
expect(store.menus).toHaveLength(1)
|
||
expect(store.loaded).toBe(true)
|
||
})
|
||
|
||
it('应该正确清除权限数据', () => {
|
||
const store = usePermissionStore()
|
||
|
||
store.setPermissionData({
|
||
roles: ['admin'],
|
||
permissions: ['user:read'],
|
||
menus: []
|
||
})
|
||
|
||
store.clearPermissionData()
|
||
|
||
expect(store.roles).toEqual([])
|
||
expect(store.permissions).toEqual([])
|
||
expect(store.menus).toEqual([])
|
||
expect(store.loaded).toBe(false)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/stores/permission.test.ts`
|
||
|
||
预期:FAIL,报错 "Cannot find module '@/stores/permission'"
|
||
|
||
- [ ] **步骤 3:创建 Permission Store 基础结构**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/stores/permission.ts
|
||
import { defineStore } from 'pinia'
|
||
|
||
export interface MenuItem {
|
||
id: number
|
||
name: string
|
||
path: string
|
||
icon?: string
|
||
parentId?: number
|
||
sort: number
|
||
children?: MenuItem[]
|
||
}
|
||
|
||
interface PermissionState {
|
||
roles: string[]
|
||
permissions: string[]
|
||
menus: MenuItem[]
|
||
loaded: boolean
|
||
}
|
||
|
||
export const usePermissionStore = defineStore('permission', {
|
||
state: (): PermissionState => ({
|
||
roles: [],
|
||
permissions: [],
|
||
menus: [],
|
||
loaded: false
|
||
}),
|
||
|
||
actions: {
|
||
setPermissionData(data: {
|
||
roles: string[]
|
||
permissions: string[]
|
||
menus: MenuItem[]
|
||
}) {
|
||
this.roles = data.roles
|
||
this.permissions = data.permissions
|
||
this.menus = data.menus
|
||
this.loaded = true
|
||
|
||
this.saveToStorage()
|
||
},
|
||
|
||
clearPermissionData() {
|
||
this.roles = []
|
||
this.permissions = []
|
||
this.menus = []
|
||
this.loaded = false
|
||
|
||
localStorage.removeItem('permission')
|
||
},
|
||
|
||
saveToStorage() {
|
||
const data = {
|
||
roles: this.roles,
|
||
permissions: this.permissions,
|
||
menus: this.menus
|
||
}
|
||
localStorage.setItem('permission', JSON.stringify(data))
|
||
},
|
||
|
||
initFromStorage() {
|
||
const stored = localStorage.getItem('permission')
|
||
if (stored) {
|
||
try {
|
||
const data = JSON.parse(stored)
|
||
this.roles = data.roles || []
|
||
this.permissions = data.permissions || []
|
||
this.menus = data.menus || []
|
||
this.loaded = true
|
||
} catch (error) {
|
||
console.error('从 localStorage 恢复权限数据失败:', error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/stores/permission.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 5:编写 Permission Store 测试 - 权限检查方法**
|
||
|
||
```typescript
|
||
// 在 novalon-manage-web/src/__tests__/stores/permission.test.ts 中添加
|
||
describe('权限检查方法', () => {
|
||
it('应该正确检查单个角色', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: ['admin', 'user'],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
expect(store.hasRole('admin')).toBe(true)
|
||
expect(store.hasRole('manager')).toBe(false)
|
||
})
|
||
|
||
it('应该正确检查多个角色(满足任一即可)', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: ['user'],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
expect(store.hasRole(['admin', 'user'])).toBe(true)
|
||
expect(store.hasRole(['admin', 'manager'])).toBe(false)
|
||
})
|
||
|
||
it('应该正确检查单个权限', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:read', 'user:delete'],
|
||
menus: []
|
||
})
|
||
|
||
expect(store.hasPermission('user:read')).toBe(true)
|
||
expect(store.hasPermission('user:create')).toBe(false)
|
||
})
|
||
|
||
it('应该正确检查多个权限(满足任一即可)', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:read'],
|
||
menus: []
|
||
})
|
||
|
||
expect(store.hasPermission(['user:read', 'user:create'])).toBe(true)
|
||
expect(store.hasPermission(['user:create', 'user:update'])).toBe(false)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 6:运行测试验证失败**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/stores/permission.test.ts`
|
||
|
||
预期:FAIL,报错 "store.hasRole is not a function"
|
||
|
||
- [ ] **步骤 7:添加权限检查方法**
|
||
|
||
```typescript
|
||
// 在 novalon-manage-web/src/stores/permission.ts 中添加 getters
|
||
export const usePermissionStore = defineStore('permission', {
|
||
state: (): PermissionState => ({
|
||
roles: [],
|
||
permissions: [],
|
||
menus: [],
|
||
loaded: false
|
||
}),
|
||
|
||
getters: {
|
||
hasRole: (state) => (role: string | string[]) => {
|
||
if (Array.isArray(role)) {
|
||
return role.some(r => state.roles.includes(r))
|
||
}
|
||
return state.roles.includes(role)
|
||
},
|
||
|
||
hasPermission: (state) => (permission: string | string[]) => {
|
||
if (Array.isArray(permission)) {
|
||
return permission.some(p => state.permissions.includes(p))
|
||
}
|
||
return state.permissions.includes(permission)
|
||
}
|
||
},
|
||
|
||
actions: {
|
||
// ... 已有的 actions
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 8:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/stores/permission.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 9:编写 Permission Store 测试 - localStorage 持久化**
|
||
|
||
```typescript
|
||
// 在 novalon-manage-web/src/__tests__/stores/permission.test.ts 中添加
|
||
describe('localStorage 持久化', () => {
|
||
it('应该正确保存到 localStorage', () => {
|
||
const store = usePermissionStore()
|
||
|
||
store.setPermissionData({
|
||
roles: ['admin'],
|
||
permissions: ['user:read'],
|
||
menus: [
|
||
{
|
||
id: 1,
|
||
name: '仪表盘',
|
||
path: '/dashboard',
|
||
sort: 1
|
||
}
|
||
]
|
||
})
|
||
|
||
const stored = localStorage.getItem('permission')
|
||
expect(stored).toBeTruthy()
|
||
|
||
const data = JSON.parse(stored!)
|
||
expect(data.roles).toEqual(['admin'])
|
||
expect(data.permissions).toEqual(['user:read'])
|
||
expect(data.menus).toHaveLength(1)
|
||
})
|
||
|
||
it('应该正确从 localStorage 恢复', () => {
|
||
localStorage.setItem('permission', JSON.stringify({
|
||
roles: ['user'],
|
||
permissions: ['user:read:self'],
|
||
menus: []
|
||
}))
|
||
|
||
const store = usePermissionStore()
|
||
store.initFromStorage()
|
||
|
||
expect(store.roles).toEqual(['user'])
|
||
expect(store.permissions).toEqual(['user:read:self'])
|
||
expect(store.loaded).toBe(true)
|
||
})
|
||
|
||
it('清除数据时应该同时清除 localStorage', () => {
|
||
const store = usePermissionStore()
|
||
|
||
store.setPermissionData({
|
||
roles: ['admin'],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
store.clearPermissionData()
|
||
|
||
expect(localStorage.getItem('permission')).toBeNull()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 10:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/stores/permission.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 11:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/stores/permission.ts src/__tests__/stores/permission.test.ts
|
||
git commit -m "feat: 添加 Permission Store 实现权限数据管理"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 2:创建 v-permission 指令
|
||
|
||
**文件:**
|
||
- 创建:`novalon-manage-web/src/directives/permission.ts`
|
||
- 测试:`novalon-manage-web/src/__tests__/directives/permission.test.ts`
|
||
|
||
- [ ] **步骤 1:编写 v-permission 指令测试 - 角色检查**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/__tests__/directives/permission.test.ts
|
||
import { describe, it, expect, beforeEach } from 'vitest'
|
||
import { mount } from '@vue/test-utils'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { permissionDirective } from '@/directives/permission'
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
|
||
describe('v-permission 指令', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
localStorage.clear()
|
||
})
|
||
|
||
describe('角色检查', () => {
|
||
it('有角色时应该显示元素', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: ['admin'],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
const wrapper = mount({
|
||
template: '<button v-permission:role="\'admin\'">管理员按钮</button>',
|
||
directives: {
|
||
permission: permissionDirective
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('button').isVisible()).toBe(true)
|
||
})
|
||
|
||
it('无角色时应该隐藏元素', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: ['user'],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
const wrapper = mount({
|
||
template: '<button v-permission:role="\'admin\'">管理员按钮</button>',
|
||
directives: {
|
||
permission: permissionDirective
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('button').isVisible()).toBe(false)
|
||
})
|
||
|
||
it('支持数组参数(满足任一即可)', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: ['user'],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
const wrapper = mount({
|
||
template: '<button v-permission:role="[\'admin\', \'user\']">按钮</button>',
|
||
directives: {
|
||
permission: permissionDirective
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('button').isVisible()).toBe(true)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/directives/permission.test.ts`
|
||
|
||
预期:FAIL,报错 "Cannot find module '@/directives/permission'"
|
||
|
||
- [ ] **步骤 3:创建 v-permission 指令基础结构**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/directives/permission.ts
|
||
import type { Directive, DirectiveBinding } from 'vue'
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
|
||
export const permissionDirective: Directive = {
|
||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||
const permissionStore = usePermissionStore()
|
||
|
||
const { arg, value } = binding
|
||
const checkType = arg || 'permission'
|
||
|
||
if (!value) {
|
||
console.warn('v-permission 指令需要提供权限值')
|
||
el.style.display = 'none'
|
||
return
|
||
}
|
||
|
||
let hasAccess = false
|
||
|
||
if (checkType === 'role') {
|
||
hasAccess = permissionStore.hasRole(value)
|
||
} else if (checkType === 'permission') {
|
||
hasAccess = permissionStore.hasPermission(value)
|
||
} else {
|
||
console.warn(`未知的权限检查类型: ${checkType}`)
|
||
el.style.display = 'none'
|
||
return
|
||
}
|
||
|
||
if (!hasAccess) {
|
||
el.style.display = 'none'
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/directives/permission.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 5:编写 v-permission 指令测试 - 权限检查**
|
||
|
||
```typescript
|
||
// 在 novalon-manage-web/src/__tests__/directives/permission.test.ts 中添加
|
||
describe('权限检查', () => {
|
||
it('有权限时应该显示元素', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:delete'],
|
||
menus: []
|
||
})
|
||
|
||
const wrapper = mount({
|
||
template: '<button v-permission:permission="\'user:delete\'">删除用户</button>',
|
||
directives: {
|
||
permission: permissionDirective
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('button').isVisible()).toBe(true)
|
||
})
|
||
|
||
it('无权限时应该隐藏元素', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:read'],
|
||
menus: []
|
||
})
|
||
|
||
const wrapper = mount({
|
||
template: '<button v-permission:permission="\'user:delete\'">删除用户</button>',
|
||
directives: {
|
||
permission: permissionDirective
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('button').isVisible()).toBe(false)
|
||
})
|
||
|
||
it('支持简写形式(默认权限检查)', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:create'],
|
||
menus: []
|
||
})
|
||
|
||
const wrapper = mount({
|
||
template: '<button v-permission="\'user:create\'">创建用户</button>',
|
||
directives: {
|
||
permission: permissionDirective
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('button').isVisible()).toBe(true)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 6:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/directives/permission.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 7:在 main.ts 中注册指令**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/main.ts
|
||
import { createApp } from 'vue'
|
||
import { createPinia } from 'pinia'
|
||
import ElementPlus from 'element-plus'
|
||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||
import 'element-plus/dist/index.css'
|
||
import router from './router'
|
||
import App from './App.vue'
|
||
import './assets/styles.css'
|
||
import { permissionDirective } from './directives/permission'
|
||
|
||
const app = createApp(App)
|
||
const pinia = createPinia()
|
||
|
||
app.use(pinia)
|
||
app.use(router)
|
||
app.use(ElementPlus, {
|
||
locale: zhCn,
|
||
})
|
||
|
||
app.directive('permission', permissionDirective)
|
||
|
||
app.mount('#app')
|
||
```
|
||
|
||
- [ ] **步骤 8:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/directives/permission.ts src/__tests__/directives/permission.test.ts src/main.ts
|
||
git commit -m "feat: 添加 v-permission 指令实现按钮级权限控制"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 3:集成 Permission Store 到登录流程
|
||
|
||
**文件:**
|
||
- 修改:`novalon-manage-web/src/views/system/Login.vue`
|
||
|
||
- [ ] **步骤 1:修改 Login.vue 集成 Permission Store**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/views/system/Login.vue
|
||
// 在 <script setup lang="ts"> 部分修改
|
||
|
||
import { reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import request from '@/utils/request'
|
||
import { onMounted } from 'vue'
|
||
import { jwtDecode } from 'jwt-decode'
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
const permissionStore = usePermissionStore()
|
||
|
||
const formState = reactive({
|
||
username: '',
|
||
password: ''
|
||
})
|
||
|
||
onMounted(() => {
|
||
document.title = '登录 - Novalon 管理系统'
|
||
})
|
||
|
||
interface JwtPayload {
|
||
userId: number
|
||
username: string
|
||
roles: string[]
|
||
exp: number
|
||
iat: number
|
||
}
|
||
|
||
const onFinish = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await request.post('/auth/login', formState)
|
||
|
||
if (!res || !res.token) {
|
||
ElMessage.error('登录失败:未收到有效响应')
|
||
return
|
||
}
|
||
|
||
localStorage.setItem('token', res.token)
|
||
if (res.userId) {
|
||
localStorage.setItem('userId', String(res.userId))
|
||
}
|
||
if (res.username) {
|
||
localStorage.setItem('username', res.username)
|
||
}
|
||
|
||
try {
|
||
const decoded = jwtDecode<JwtPayload>(res.token)
|
||
if (decoded.roles && Array.isArray(decoded.roles)) {
|
||
localStorage.setItem('roles', JSON.stringify(decoded.roles))
|
||
}
|
||
} catch (decodeError) {
|
||
console.warn('解析Token中的角色信息失败:', decodeError)
|
||
}
|
||
|
||
try {
|
||
await permissionStore.fetchUserMenus()
|
||
} catch (fetchError) {
|
||
console.warn('获取用户菜单失败:', fetchError)
|
||
}
|
||
|
||
ElMessage.success('登录成功')
|
||
|
||
await router.push('/')
|
||
} catch (error: any) {
|
||
console.error('登录错误:', error)
|
||
ElMessage.error(error.response?.data?.message || error.message || '登录失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:在 Permission Store 中添加 fetchUserMenus 方法**
|
||
|
||
```typescript
|
||
// 在 novalon-manage-web/src/stores/permission.ts 中添加
|
||
import request from '@/utils/request'
|
||
|
||
export const usePermissionStore = defineStore('permission', {
|
||
// ... 已有的 state, getters
|
||
|
||
actions: {
|
||
// ... 已有的 actions
|
||
|
||
async fetchUserMenus() {
|
||
try {
|
||
const res: any = await request.get('/menus/user')
|
||
|
||
if (res && res.data) {
|
||
this.setPermissionData({
|
||
roles: JSON.parse(localStorage.getItem('roles') || '[]'),
|
||
permissions: res.data.permissions || [],
|
||
menus: res.data.menus || []
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('获取用户菜单失败:', error)
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 3:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/views/system/Login.vue src/stores/permission.ts
|
||
git commit -m "feat: 集成 Permission Store 到登录流程"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 4:创建递归菜单组件
|
||
|
||
**文件:**
|
||
- 创建:`novalon-manage-web/src/components/MenuItem.vue`
|
||
- 测试:`novalon-manage-web/src/__tests__/components/MenuItem.test.ts`
|
||
|
||
- [ ] **步骤 1:编写 MenuItem 组件测试**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/__tests__/components/MenuItem.test.ts
|
||
import { describe, it, expect } from 'vitest'
|
||
import { mount } from '@vue/test-utils'
|
||
import MenuItem from '@/components/MenuItem.vue'
|
||
|
||
describe('MenuItem 组件', () => {
|
||
it('应该正确渲染无子菜单的菜单项', () => {
|
||
const menu = {
|
||
id: 1,
|
||
name: '仪表盘',
|
||
path: '/dashboard',
|
||
icon: 'Odometer',
|
||
sort: 1
|
||
}
|
||
|
||
const wrapper = mount(MenuItem, {
|
||
props: { menu }
|
||
})
|
||
|
||
expect(wrapper.find('.el-menu-item').exists()).toBe(true)
|
||
expect(wrapper.text()).toContain('仪表盘')
|
||
})
|
||
|
||
it('应该正确渲染有子菜单的菜单项', () => {
|
||
const menu = {
|
||
id: 2,
|
||
name: '系统管理',
|
||
path: '/system',
|
||
icon: 'Setting',
|
||
sort: 2,
|
||
children: [
|
||
{
|
||
id: 3,
|
||
name: '用户管理',
|
||
path: '/users',
|
||
sort: 1
|
||
}
|
||
]
|
||
}
|
||
|
||
const wrapper = mount(MenuItem, {
|
||
props: { menu }
|
||
})
|
||
|
||
expect(wrapper.find('.el-sub-menu').exists()).toBe(true)
|
||
expect(wrapper.text()).toContain('系统管理')
|
||
expect(wrapper.text()).toContain('用户管理')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/components/MenuItem.test.ts`
|
||
|
||
预期:FAIL,报错 "Cannot find module '@/components/MenuItem.vue'"
|
||
|
||
- [ ] **步骤 3:创建 MenuItem 组件**
|
||
|
||
```vue
|
||
<!-- novalon-manage-web/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>
|
||
|
||
<script setup lang="ts">
|
||
import type { MenuItem as MenuItemType } from '@/stores/permission'
|
||
|
||
defineProps<{
|
||
menu: MenuItemType
|
||
}>()
|
||
</script>
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/components/MenuItem.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/components/MenuItem.vue src/__tests__/components/MenuItem.test.ts
|
||
git commit -m "feat: 添加递归菜单组件 MenuItem"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 5:修改 DefaultLayout 使用动态菜单
|
||
|
||
**文件:**
|
||
- 修改:`novalon-manage-web/src/layouts/DefaultLayout.vue`
|
||
|
||
- [ ] **步骤 1:修改 DefaultLayout.vue**
|
||
|
||
```vue
|
||
<!-- novalon-manage-web/src/layouts/DefaultLayout.vue -->
|
||
<template>
|
||
<el-container class="default-layout">
|
||
<el-aside
|
||
:width="collapsed ? '64px' : '200px'"
|
||
class="aside"
|
||
>
|
||
<div class="logo">
|
||
<span v-if="!collapsed">Novalon</span>
|
||
<span v-else>N</span>
|
||
</div>
|
||
<el-menu
|
||
:default-active="activeMenu"
|
||
class="menu"
|
||
:collapse="collapsed"
|
||
background-color="#f5f7fa"
|
||
text-color="#606266"
|
||
active-text-color="#409eff"
|
||
router
|
||
>
|
||
<menu-item
|
||
v-for="menu in menuTree"
|
||
:key="menu.id"
|
||
:menu="menu"
|
||
/>
|
||
</el-menu>
|
||
</el-aside>
|
||
<el-container>
|
||
<el-header class="header">
|
||
<el-icon
|
||
class="trigger"
|
||
@click="collapsed = !collapsed"
|
||
>
|
||
<Fold v-if="!collapsed" />
|
||
<Expand v-else />
|
||
</el-icon>
|
||
<div class="header-right">
|
||
<el-dropdown @command="handleCommand">
|
||
<el-avatar :size="32">
|
||
{{ username }}
|
||
</el-avatar>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="profile">
|
||
个人中心
|
||
</el-dropdown-item>
|
||
<el-dropdown-item
|
||
command="logout"
|
||
divided
|
||
>
|
||
退出登录
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</el-header>
|
||
<el-main class="content">
|
||
<router-view />
|
||
</el-main>
|
||
</el-container>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { Fold, Expand } from '@element-plus/icons-vue'
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
import MenuItem from '@/components/MenuItem.vue'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const collapsed = ref(false)
|
||
const username = ref(localStorage.getItem('username') || 'Admin')
|
||
const permissionStore = usePermissionStore()
|
||
|
||
const activeMenu = computed(() => route.path)
|
||
const menuTree = computed(() => permissionStore.menus)
|
||
|
||
const handleCommand = (command: string) => {
|
||
if (command === 'profile') {
|
||
router.push('/profile')
|
||
} else if (command === 'logout') {
|
||
permissionStore.clearPermissionData()
|
||
localStorage.clear()
|
||
router.push('/login')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
const token = localStorage.getItem('token')
|
||
if (!token) {
|
||
router.push('/login')
|
||
} else if (!permissionStore.loaded) {
|
||
permissionStore.initFromStorage()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="css">
|
||
.default-layout {
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.aside {
|
||
background-color: #f5f7fa;
|
||
transition: width 0.3s;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.logo {
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #303133;
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.menu {
|
||
border-right: none;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
background: #fff;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
|
||
.trigger {
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
transition: color 0.3s;
|
||
&:hover { color: #409eff; }
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
}
|
||
|
||
.content {
|
||
margin: 16px;
|
||
padding: 16px;
|
||
background: #fff;
|
||
min-height: calc(100vh - 96px);
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **步骤 2:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/layouts/DefaultLayout.vue
|
||
git commit -m "feat: 修改 DefaultLayout 使用动态菜单渲染"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 6:创建 API 权限检查工具
|
||
|
||
**文件:**
|
||
- 创建:`novalon-manage-web/src/utils/permission-check.ts`
|
||
- 测试:`novalon-manage-web/src/__tests__/utils/permission-check.test.ts`
|
||
|
||
- [ ] **步骤 1:编写 API 权限检查测试**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/__tests__/utils/permission-check.test.ts
|
||
import { describe, it, expect, beforeEach } from 'vitest'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
import { canAccessApi } from '@/utils/permission-check'
|
||
|
||
describe('API 权限检查', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
localStorage.clear()
|
||
})
|
||
|
||
it('有权限时应该返回 true', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:read'],
|
||
menus: []
|
||
})
|
||
|
||
expect(canAccessApi('/api/users', 'GET')).toBe(true)
|
||
})
|
||
|
||
it('无权限时应该返回 false', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:read'],
|
||
menus: []
|
||
})
|
||
|
||
expect(canAccessApi('/api/users', 'POST')).toBe(false)
|
||
})
|
||
|
||
it('未定义权限要求的 API 应该默认允许', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: [],
|
||
menus: []
|
||
})
|
||
|
||
expect(canAccessApi('/api/unknown', 'GET')).toBe(true)
|
||
})
|
||
|
||
it('应该正确匹配通配符路径', () => {
|
||
const store = usePermissionStore()
|
||
store.setPermissionData({
|
||
roles: [],
|
||
permissions: ['user:update'],
|
||
menus: []
|
||
})
|
||
|
||
expect(canAccessApi('/api/users/123', 'PUT')).toBe(true)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/utils/permission-check.test.ts`
|
||
|
||
预期:FAIL,报错 "Cannot find module '@/utils/permission-check'"
|
||
|
||
- [ ] **步骤 3:创建 API 权限检查工具**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/utils/permission-check.ts
|
||
import { usePermissionStore } from '@/stores/permission'
|
||
|
||
const apiPermissionMap: Record<string, string> = {
|
||
'/api/users:GET': 'user:read',
|
||
'/api/users:POST': 'user:create',
|
||
'/api/users/*:PUT': 'user:update',
|
||
'/api/users/*:DELETE': 'user:delete',
|
||
'/api/roles:GET': 'role:read',
|
||
'/api/roles:POST': 'role:create',
|
||
'/api/roles/*:PUT': 'role:update',
|
||
'/api/roles/*:DELETE': 'role:delete',
|
||
'/api/menus:GET': 'menu:read',
|
||
'/api/menus:POST': 'menu:create',
|
||
'/api/menus/*:PUT': 'menu:update',
|
||
'/api/menus/*:DELETE': 'menu:delete',
|
||
'/api/config:GET': 'config:read',
|
||
'/api/config:POST': 'config:create',
|
||
'/api/config/*:PUT': 'config:update',
|
||
'/api/config/*:DELETE': 'config:delete',
|
||
'/api/dict:GET': 'dict:read',
|
||
'/api/dict:POST': 'dict:create',
|
||
'/api/dict/*:PUT': 'dict:update',
|
||
'/api/dict/*:DELETE': 'dict:delete',
|
||
'/api/files:GET': 'file:read',
|
||
'/api/files:POST': 'file:create',
|
||
'/api/files/*:DELETE': 'file:delete',
|
||
'/api/notices:GET': 'notice:read',
|
||
'/api/notices:POST': 'notice:create',
|
||
'/api/notices/*:PUT': 'notice:update',
|
||
'/api/notices/*:DELETE': 'notice:delete',
|
||
'/api/logs/login:GET': 'log:read',
|
||
'/api/logs/operation:GET': 'log:read',
|
||
'/api/logs/exception:GET': 'log:read'
|
||
}
|
||
|
||
function findRequiredPermission(path: string, method: string): string | null {
|
||
const exactKey = `${path}:${method}`
|
||
if (apiPermissionMap[exactKey]) {
|
||
return apiPermissionMap[exactKey]
|
||
}
|
||
|
||
for (const [key, permission] of Object.entries(apiPermissionMap)) {
|
||
const [pattern, reqMethod] = key.split(':')
|
||
if (reqMethod !== method) continue
|
||
|
||
const regex = new RegExp('^' + pattern.replace('*', '.*') + '$')
|
||
if (regex.test(path)) {
|
||
return permission
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export function canAccessApi(path: string, method: string): boolean {
|
||
const permissionStore = usePermissionStore()
|
||
|
||
const required = findRequiredPermission(path, method)
|
||
|
||
if (!required) {
|
||
return true
|
||
}
|
||
|
||
return permissionStore.hasPermission(required)
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test src/__tests__/utils/permission-check.test.ts`
|
||
|
||
预期:PASS
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/utils/permission-check.ts src/__tests__/utils/permission-check.test.ts
|
||
git commit -m "feat: 添加 API 权限检查工具"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 7:集成 API 权限检查到请求拦截器
|
||
|
||
**文件:**
|
||
- 修改:`novalon-manage-web/src/utils/request.ts`
|
||
|
||
- [ ] **步骤 1:修改 request.ts 添加权限检查**
|
||
|
||
```typescript
|
||
// novalon-manage-web/src/utils/request.ts
|
||
import axios, { AxiosRequestConfig } from 'axios'
|
||
import { generateSignatureHeaders } from './signature'
|
||
import { canAccessApi } from './permission-check'
|
||
|
||
const request = axios.create({
|
||
baseURL: '/api',
|
||
timeout: 10000
|
||
})
|
||
|
||
request.interceptors.request.use(
|
||
(config: AxiosRequestConfig) => {
|
||
const path = config.url || ''
|
||
const method = config.method?.toUpperCase() || 'GET'
|
||
|
||
if (!canAccessApi(path, method)) {
|
||
console.warn(`无权限访问 API: ${method} ${path}`)
|
||
return Promise.reject(new Error(`无权限访问此 API: ${method} ${path}`))
|
||
}
|
||
|
||
const token = localStorage.getItem('token')
|
||
if (token) {
|
||
config.headers = config.headers || {}
|
||
config.headers.Authorization = `Bearer ${token}`
|
||
}
|
||
|
||
const methodForSignature = config.method?.toUpperCase() || 'GET'
|
||
let url = config.url || ''
|
||
const body = config.data
|
||
|
||
if (config.params && Object.keys(config.params).length > 0) {
|
||
const queryParams = new URLSearchParams()
|
||
Object.entries(config.params).forEach(([key, value]) => {
|
||
if (value !== undefined && value !== null) {
|
||
queryParams.append(key, String(value))
|
||
}
|
||
})
|
||
const queryString = queryParams.toString()
|
||
if (queryString) {
|
||
url += (url.includes('?') ? '&' : '?') + queryString
|
||
}
|
||
}
|
||
|
||
const fullPath = `/api${url.startsWith('/') ? url : '/' + url}`
|
||
const signatureHeaders = generateSignatureHeaders(methodForSignature, fullPath, body)
|
||
|
||
config.headers = config.headers || {}
|
||
Object.assign(config.headers, signatureHeaders)
|
||
|
||
return config
|
||
},
|
||
(error) => Promise.reject(error)
|
||
)
|
||
|
||
request.interceptors.response.use(
|
||
(response) => response.data,
|
||
(error) => {
|
||
if (error.response?.status === 401) {
|
||
localStorage.removeItem('token')
|
||
if (!window.location.pathname.includes('/login')) {
|
||
window.location.href = '/login'
|
||
}
|
||
}
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
export default request
|
||
```
|
||
|
||
- [ ] **步骤 2:Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add src/utils/request.ts
|
||
git commit -m "feat: 集成 API 权限检查到请求拦截器"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 8:运行完整测试套件
|
||
|
||
- [ ] **步骤 1:运行所有单元测试**
|
||
|
||
运行:`cd novalon-manage-web && pnpm test`
|
||
|
||
预期:所有测试通过
|
||
|
||
- [ ] **步骤 2:运行 TypeScript 类型检查**
|
||
|
||
运行:`cd novalon-manage-web && pnpm type-check`
|
||
|
||
预期:类型检查通过(忽略已存在的其他文件错误)
|
||
|
||
- [ ] **步骤 3:最终 Commit**
|
||
|
||
```bash
|
||
cd novalon-manage-web
|
||
git add .
|
||
git commit -m "feat: 完成权限系统增强功能实现"
|
||
```
|
||
|
||
---
|
||
|
||
## 后端 API 实现说明
|
||
|
||
本计划需要后端新增以下 API:
|
||
|
||
**接口**: `GET /api/menus/user`
|
||
|
||
**功能**: 获取当前登录用户可访问的菜单和权限
|
||
|
||
**实现要点**:
|
||
1. 从 token 获取用户 ID
|
||
2. 查询用户角色
|
||
3. 根据角色查询菜单和权限
|
||
4. 构建菜单树结构
|
||
5. 返回菜单和权限列表
|
||
|
||
**响应格式**:
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"data": {
|
||
"menus": [
|
||
{
|
||
"id": 1,
|
||
"name": "仪表盘",
|
||
"path": "/dashboard",
|
||
"icon": "Odometer",
|
||
"parentId": null,
|
||
"sort": 1
|
||
}
|
||
],
|
||
"permissions": [
|
||
"user:read",
|
||
"user:create"
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
后端实现不在本计划范围内,需要单独开发。
|