Files
gym-manage/docs/superpowers/plans/2026-04-08-permission-system-enhancement-plan.md
T

1365 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 权限系统增强实现计划
> **面向 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
- [ ] **步骤 11Commit**
```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')
```
- [ ] **步骤 8Commit**
```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
}
}
}
})
```
- [ ] **步骤 3Commit**
```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
- [ ] **步骤 5Commit**
```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>
```
- [ ] **步骤 2Commit**
```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
- [ ] **步骤 5Commit**
```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
```
- [ ] **步骤 2Commit**
```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"
]
}
}
```
后端实现不在本计划范围内,需要单独开发。