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

34 KiB
Raw Permalink Blame History

权限系统增强实现计划

面向 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 测试 - 基础功能

// 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 基础结构
// 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 测试 - 权限检查方法
// 在 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:添加权限检查方法
// 在 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 持久化
// 在 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
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 指令测试 - 角色检查

// 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 指令基础结构
// 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 指令测试 - 权限检查
// 在 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 中注册指令
// 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
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

// 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 方法
// 在 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
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 组件测试

// 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 组件
<!-- 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
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

<!-- 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
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 权限检查测试

// 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 权限检查工具
// 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
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 添加权限检查

// 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
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
cd novalon-manage-web
git add .
git commit -m "feat: 完成权限系统增强功能实现"

后端 API 实现说明

本计划需要后端新增以下 API

接口: GET /api/menus/user

功能: 获取当前登录用户可访问的菜单和权限

实现要点:

  1. 从 token 获取用户 ID
  2. 查询用户角色
  3. 根据角色查询菜单和权限
  4. 构建菜单树结构
  5. 返回菜单和权限列表

响应格式:

{
  "code": 200,
  "data": {
    "menus": [
      {
        "id": 1,
        "name": "仪表盘",
        "path": "/dashboard",
        "icon": "Odometer",
        "parentId": null,
        "sort": 1
      }
    ],
    "permissions": [
      "user:read",
      "user:create"
    ]
  }
}

后端实现不在本计划范围内,需要单独开发。