From 9926e3e7e211c7e2ed29d5ed5a330d1165bfd973 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Mon, 20 Apr 2026 09:24:46 +0800 Subject: [PATCH] add api key manage --- .../D22.4-admin-api-key-page.devlog | 15 ++ .../src/components/layouts/MainLayout.vue | 2 + .../pages/api-keys/__tests__/index.spec.ts | 164 ++++++++++++++++++ frontend/src/pages/api-keys/index.vue | 160 +++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 frontend/22-api-key-management-ui/D22.4-admin-api-key-page.devlog create mode 100644 frontend/src/pages/api-keys/__tests__/index.spec.ts create mode 100644 frontend/src/pages/api-keys/index.vue diff --git a/frontend/22-api-key-management-ui/D22.4-admin-api-key-page.devlog b/frontend/22-api-key-management-ui/D22.4-admin-api-key-page.devlog new file mode 100644 index 0000000..0dd2004 --- /dev/null +++ b/frontend/22-api-key-management-ui/D22.4-admin-api-key-page.devlog @@ -0,0 +1,15 @@ +# D22.4 管理员 API Key 管理页面 Devlog + +## 当前状态速览 +- 完成进度:4/4 ✅ +- 当前子项:已全部完成 +- 下一步行动:P22.5 + +## 日志 + +### 2026-04-20 — 完成 +- [启动] 开始执行 P22.4,context 已加载(store/types/MainLayout/参考 spec 均已读取) +- [22.4.1] 创建 `pages/api-keys/index.vue`;发现 TS 类型错误 6 个(boolean/scroll/record 类型)→ 修复(enabledFilter computed + 移除插槽类型注解 + Boolean() 转型)→ build ✅ +- [22.4.2] MainLayout.vue 追加 `/api-keys` 菜单项(KeyOutlined 已导入) +- [22.4.3] MainLayout.vue pathMap 追加 `/api-keys: 'API Key 管理'` +- [22.4.4] 新增 7 用例,全绿;MainLayout 既有 6 条失败(layout 组件类型不匹配)非本项引入 diff --git a/frontend/src/components/layouts/MainLayout.vue b/frontend/src/components/layouts/MainLayout.vue index ee5f62d..070e3ff 100644 --- a/frontend/src/components/layouts/MainLayout.vue +++ b/frontend/src/components/layouts/MainLayout.vue @@ -94,6 +94,7 @@ const menuItems: MenuItem[] = [ { key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' }, ], }, + { key: '/api-keys', icon: KeyOutlined, label: 'API Key 管理', adminOnly: true }, { key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true }, { key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true }, { key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true }, @@ -146,6 +147,7 @@ const breadcrumbItems = computed(() => { '/failed-messages': '失败消息', '/logs/requests': '请求日志', '/logs/operations': '操作日志', + '/api-keys': 'API Key 管理', '/profile': '个人信息', '/profile/password': '修改密码', } diff --git a/frontend/src/pages/api-keys/__tests__/index.spec.ts b/frontend/src/pages/api-keys/__tests__/index.spec.ts new file mode 100644 index 0000000..c0501d7 --- /dev/null +++ b/frontend/src/pages/api-keys/__tests__/index.spec.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import AdminApiKeyPage from '../index.vue' +import { useAdminApiKeyStore } from '@/stores/admin-api-key' +import type { ApiKeyRecord } from '@/types/api' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +import { api } from '@/utils/request' + +const mockRecord = (overrides: Partial = {}): ApiKeyRecord => ({ + id: 1, + user_id: 10, + name: '测试Key', + key_prefix: 'abc12345', + last_used_at: null, + expires_at: null, + enabled: true, + created_at: '2026-04-01T10:00:00', + user: { id: 10, username: 'alice', api_key_enabled: true }, + ...overrides, +}) + +function setupApi(keys: ApiKeyRecord[] = []) { + vi.mocked(api.get).mockResolvedValue({ + items: keys, + total: keys.length, + page: 1, + per_page: 15, + } as never) + vi.mocked(api.patch).mockResolvedValue(undefined as never) + vi.mocked(api.delete).mockResolvedValue(undefined as never) +} + +describe('AdminApiKeyPage', () => { + let wrapper: ReturnType + + beforeEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + setActivePinia(createPinia()) + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + async function mountPage() { + setupApi([mockRecord()]) + wrapper = mount(AdminApiKeyPage, { attachTo: document.body }) + await flushPromises() + await nextTick() + return wrapper + } + + it('挂载时调用 fetchAllKeys', async () => { + setupApi([]) + wrapper = mount(AdminApiKeyPage, { attachTo: document.body }) + await flushPromises() + + expect(vi.mocked(api.get)).toHaveBeenCalledWith( + '/api/v1/admin/api-keys', + expect.objectContaining({ page: 1 }), + ) + }) + + it('搜索按钮触发 fetchAllKeys 且 pagination.page 重置为 1', async () => { + await mountPage() + const store = useAdminApiKeyStore() + store.pagination.page = 3 + + vi.mocked(api.get).mockClear() + setupApi([]) + + const btn = Array.from(document.body.querySelectorAll('.ant-btn')).find((b) => + b.textContent?.includes('搜索'), + ) as HTMLElement + btn?.click() + await flushPromises() + + expect(store.pagination.page).toBe(1) + expect(vi.mocked(api.get)).toHaveBeenCalled() + }) + + it('重置按钮调用 resetFilters 并 fetchAllKeys', async () => { + await mountPage() + const store = useAdminApiKeyStore() + store.filters.user_id = 5 + + vi.mocked(api.get).mockClear() + setupApi([]) + + const btn = Array.from(document.body.querySelectorAll('.ant-btn')).find((b) => + b.textContent?.includes('重置'), + ) as HTMLElement + btn?.click() + await flushPromises() + + expect(store.filters.user_id).toBeUndefined() + expect(vi.mocked(api.get)).toHaveBeenCalled() + }) + + it('api_key_enabled = false → 渲染红色"已停用"标签', async () => { + setupApi([mockRecord({ user: { id: 10, username: 'bob', api_key_enabled: false } })]) + wrapper = mount(AdminApiKeyPage, { attachTo: document.body }) + await flushPromises() + await nextTick() + + expect(document.body.innerHTML).toContain('已停用') + }) + + it('api_key_enabled = undefined → 不渲染"已停用"标签', async () => { + setupApi([mockRecord({ user: { id: 10, username: 'carol' } })]) + wrapper = mount(AdminApiKeyPage, { attachTo: document.body }) + await flushPromises() + await nextTick() + + expect(document.body.innerHTML).not.toContain('已停用') + }) + + it('Switch 切换调用 store.toggleKey(id, enabled)', async () => { + await mountPage() + const store = useAdminApiKeyStore() + const spy = vi.spyOn(store, 'toggleKey').mockResolvedValue() + + const sw = document.body.querySelector('.ant-switch') as HTMLElement + sw?.click() + await flushPromises() + + expect(spy).toHaveBeenCalledWith(1, expect.any(Boolean)) + }) + + it('空列表时表格正常渲染(无崩溃)', async () => { + setupApi([]) + wrapper = mount(AdminApiKeyPage, { attachTo: document.body }) + await flushPromises() + await nextTick() + + expect(document.body.querySelector('.ant-table')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/api-keys/index.vue b/frontend/src/pages/api-keys/index.vue new file mode 100644 index 0000000..eaec9f1 --- /dev/null +++ b/frontend/src/pages/api-keys/index.vue @@ -0,0 +1,160 @@ + + +