add api key manage

This commit is contained in:
2026-04-20 09:24:46 +08:00
parent 3d7c05cdcb
commit 9926e3e7e2
4 changed files with 341 additions and 0 deletions
@@ -0,0 +1,15 @@
# D22.4 管理员 API Key 管理页面 Devlog
## 当前状态速览
- 完成进度:4/4 ✅
- 当前子项:已全部完成
- 下一步行动:P22.5
## 日志
### 2026-04-20 — 完成
- [启动] 开始执行 P22.4context 已加载(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 组件类型不匹配)非本项引入
@@ -94,6 +94,7 @@ const menuItems: MenuItem[] = [
{ key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' }, { key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' },
], ],
}, },
{ key: '/api-keys', icon: KeyOutlined, label: 'API Key 管理', adminOnly: true },
{ key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true }, { key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true },
{ key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true }, { key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true },
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true }, { key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true },
@@ -146,6 +147,7 @@ const breadcrumbItems = computed(() => {
'/failed-messages': '失败消息', '/failed-messages': '失败消息',
'/logs/requests': '请求日志', '/logs/requests': '请求日志',
'/logs/operations': '操作日志', '/logs/operations': '操作日志',
'/api-keys': 'API Key 管理',
'/profile': '个人信息', '/profile': '个人信息',
'/profile/password': '修改密码', '/profile/password': '修改密码',
} }
@@ -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> = {}): 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<typeof mount>
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()
})
})
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { useAdminApiKeyStore } from '@/stores/admin-api-key'
import { SearchOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import type { ApiKeyRecord } from '@/types/api'
const store = useAdminApiKeyStore()
// a-select 不接受 boolean,用 number (1/0) 做本地中间态
const enabledFilter = computed<number | undefined>({
get: () => (store.filters.enabled === undefined ? undefined : store.filters.enabled ? 1 : 0),
set: (val) => {
store.filters.enabled = val === undefined ? undefined : val === 1
},
})
const columns = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户', key: 'user', width: 160 },
{ title: '名称', dataIndex: 'name', width: 150 },
{ title: '前缀', key: 'prefix', width: 120 },
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{ title: '过期时间', key: 'expires_at', width: 170 },
{ title: '最后使用', key: 'last_used_at', width: 170 },
{ title: '状态', key: 'enabled', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
onMounted(() => store.fetchAllKeys())
function handleSearch() {
store.pagination.page = 1
store.fetchAllKeys()
}
function handleReset() {
store.resetFilters()
store.fetchAllKeys()
}
function handlePageChange(page: number, pageSize: number) {
store.pagination.page = page
store.pagination.per_page = pageSize
store.fetchAllKeys()
}
function formatTime(t: string | null, fallback: string) {
return t ? t.replace('T', ' ').substring(0, 19) : fallback
}
function handleToggle(id: number, checked: boolean | string) {
store.toggleKey(id, Boolean(checked))
}
function handleDelete(id: number) {
store.deleteKey(id)
}
</script>
<template>
<div>
<h2 class="text-xl font-semibold mb-4">API Key 管理</h2>
<!-- 筛选区 -->
<div class="flex gap-3 mb-4 flex-wrap">
<a-input-number
v-model:value="store.filters.user_id"
placeholder="用户 ID"
:min="1"
style="width: 140px"
allow-clear
/>
<a-select
v-model:value="enabledFilter"
placeholder="启用状态"
style="width: 120px"
allow-clear
>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="store.keys"
:loading="store.loading"
:pagination="false"
:scroll="{ x: 1200 }"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'user'">
<span>{{ (record as ApiKeyRecord).user?.username ?? record.user_id }}</span>
<a-tooltip
v-if="(record as ApiKeyRecord).user?.api_key_enabled === false"
title="该用户的 API Key 功能已关闭,所有 Key 均无法认证"
>
<a-tag color="red" class="ml-1">已停用</a-tag>
</a-tooltip>
</template>
<template v-else-if="column.key === 'prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.key === 'expires_at'">
{{ formatTime(record.expires_at as string | null, '永不过期') }}
</template>
<template v-else-if="column.key === 'last_used_at'">
{{ formatTime(record.last_used_at as string | null, '从未使用') }}
</template>
<template v-else-if="column.key === 'enabled'">
<a-switch
:checked="record.enabled as boolean"
@change="(checked) => handleToggle(record.id as number, checked as boolean | string)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
title="确认删除该 API Key"
ok-text="删除"
cancel-text="取消"
@confirm="handleDelete(record.id as number)"
>
<a-button type="text" danger size="small">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
<!-- 分页 -->
<div class="flex justify-end mt-4">
<a-pagination
:current="store.pagination.page"
:page-size="store.pagination.per_page"
:total="store.pagination.total"
show-size-changer
show-quick-jumper
:page-size-options="['15', '30', '50']"
@change="handlePageChange"
@show-size-change="handlePageChange"
/>
</div>
</div>
</template>