add api key manage
This commit is contained in:
@@ -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': '修改密码',
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user