add api key manage
This commit is contained in:
@@ -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 组件类型不匹配)非本项引入
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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