add api keyb admin page

This commit is contained in:
2026-04-20 09:47:32 +08:00
parent 9926e3e7e2
commit 2235deadf1
5 changed files with 115 additions and 18 deletions
@@ -1,15 +0,0 @@
# 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 组件类型不匹配)非本项引入
@@ -32,8 +32,8 @@ vi.mock('@/utils/request', () => ({
}))
const mockUsers: UserRecord[] = [
{ id: 1, username: 'admin', email: 'admin@test.com', status: 1, role_id: 1, ext: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' },
{ id: 2, username: 'user1', email: 'user1@test.com', status: 0, role_id: 2, ext: { note: 'test' }, created_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z' },
{ id: 1, username: 'admin', email: 'admin@test.com', status: 1, role_id: 1, ext: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', api_key_enabled: true },
{ id: 2, username: 'user1', email: 'user1@test.com', status: 0, role_id: 2, ext: { note: 'test' }, created_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', api_key_enabled: false },
]
const mockPaginatedResponse = {
@@ -200,7 +200,8 @@ describe('Users Page', () => {
if (opts.rolesError) return Promise.reject(new Error('roles fail'))
return Promise.resolve(mockRoles) as never
}
return Promise.resolve(mockPaginatedResponse) as never
// use fresh copies to prevent cross-test mutation of mockUsers
return Promise.resolve({ ...mockPaginatedResponse, items: mockUsers.map(u => ({ ...u })) }) as never
})
// 设置 admin 用户以显示操作按钮(isAdmin 从 JWT 派生)
@@ -330,4 +331,73 @@ describe('Users Page', () => {
const rows = document.body.querySelectorAll('.ant-table-tbody tr.ant-table-row')
expect(rows).toHaveLength(mockUsers.length)
})
describe('API Key Switch', () => {
it('renders Switch with correct checked state based on api_key_enabled', async () => {
await mountPage()
const switches = document.body.querySelectorAll('.ant-switch')
// 第1行 api_key_enabled=true → checked
expect(switches[0]?.classList.contains('ant-switch-checked')).toBe(true)
// 第2行 api_key_enabled=false → unchecked
expect(switches[1]?.classList.contains('ant-switch-checked')).toBe(false)
})
it('calls api.patch directly when enabling and shows success', async () => {
const { api } = await mountPage()
vi.mocked(api.patch).mockResolvedValueOnce(undefined)
const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as never)
const store = useUserManageStore()
const record = store.users[1]! // api_key_enabled=false
// Invoke the component's handleToggleApiKeyEnabled via setupState
const handleToggle = (wrapper.getCurrentComponent() as any).setupState.handleToggleApiKeyEnabled
await handleToggle(record, true)
expect(api.patch).toHaveBeenCalledWith(
`/api/v1/users/${record.id}/api-key-enabled`,
{ api_key_enabled: true },
)
expect(record.api_key_enabled).toBe(true)
expect(successSpy).toHaveBeenCalledWith('操作成功')
successSpy.mockRestore()
})
it('calls api.patch with false on disable and shows success', async () => {
const { api } = await mountPage()
vi.mocked(api.patch).mockResolvedValueOnce(undefined)
const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as never)
const store = useUserManageStore()
const record = store.users[0]! // api_key_enabled=true
const handleToggle = (wrapper.getCurrentComponent() as any).setupState.handleToggleApiKeyEnabled
await handleToggle(record, false)
expect(api.patch).toHaveBeenCalledWith(
`/api/v1/users/${record.id}/api-key-enabled`,
{ api_key_enabled: false },
)
expect(record.api_key_enabled).toBe(false)
expect(successSpy).toHaveBeenCalledWith('操作成功')
successSpy.mockRestore()
})
it('rolls back api_key_enabled and shows error on patch failure', async () => {
const { api } = await mountPage()
vi.mocked(api.patch).mockRejectedValueOnce(new Error('服务器错误'))
const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never)
const store = useUserManageStore()
const record = store.users[0]! // api_key_enabled=true
const handleToggle = (wrapper.getCurrentComponent() as any).setupState.handleToggleApiKeyEnabled
await handleToggle(record, false)
// record is the same reactive ref as store.users[0]; rollback should restore it
expect(record.api_key_enabled).toBe(true)
expect(errorSpy).toHaveBeenCalledWith('服务器错误')
errorSpy.mockRestore()
})
})
})
+35
View File
@@ -44,6 +44,7 @@ const columns = [
{ title: '邮箱', dataIndex: 'email' },
{ title: '角色', key: 'role', width: 120 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: 'API Key', key: 'api_key_enabled', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
{ title: '数据范围', key: 'data_scope', width: 120 },
{ title: '操作', key: 'action', width: 320 },
@@ -150,6 +151,24 @@ function handleDataScope(record: UserRecord) {
scopeModalOpen.value = true
}
const togglingApiKeyId = ref<number | null>(null)
async function handleToggleApiKeyEnabled(record: UserRecord, enabled: boolean) {
togglingApiKeyId.value = record.id
const original = record.api_key_enabled
record.api_key_enabled = enabled
try {
await store.toggleApiKeyEnabled(record.id, enabled)
message.success('操作成功')
} catch (err: unknown) {
record.api_key_enabled = original
const msg = err instanceof Error ? err.message : '操作失败'
message.error(msg)
} finally {
togglingApiKeyId.value = null
}
}
function handleScopeSuccess() {
store.fetchUsers()
}
@@ -238,6 +257,22 @@ function formatTime(time: string) {
<a-tag v-if="record.role" color="blue">{{ record.role.name }}</a-tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'api_key_enabled'">
<a-popconfirm
title="确定关闭该用户的 API Key 功能?"
description="关闭后,该用户的所有 API Key 将立即无法用于认证。已有 Key 不会被删除,重新开启后自动恢复。"
:disabled="!(record as UserRecord).api_key_enabled"
@confirm="handleToggleApiKeyEnabled(record as UserRecord, false)"
>
<a-switch
:checked="(record as UserRecord).api_key_enabled"
:loading="togglingApiKeyId === (record as UserRecord).id"
@change="(checked) => {
if (checked === true) handleToggleApiKeyEnabled(record as UserRecord, true)
}"
/>
</a-popconfirm>
</template>
<template v-else-if="column.key === 'data_scope'">
<a-tag v-if="record.role?.name === 'administrator'" color="green">全部权限</a-tag>
<a-tag v-else-if="record.role?.name === 'developer'" color="blue">平台级</a-tag>
+6
View File
@@ -11,6 +11,7 @@ export interface UserRecord {
ext: Record<string, unknown> | null
created_at: string
updated_at: string
api_key_enabled: boolean
}
export interface UserFilters {
@@ -86,6 +87,10 @@ export const useUserManageStore = defineStore('user-manage', () => {
await api.put(`/api/v1/users/${userId}/data-scope`, { scopes })
}
async function toggleApiKeyEnabled(userId: number, enabled: boolean) {
await api.patch(`/api/v1/users/${userId}/api-key-enabled`, { api_key_enabled: enabled })
}
return {
users,
loading,
@@ -95,5 +100,6 @@ export const useUserManageStore = defineStore('user-manage', () => {
resetFilters,
fetchUserDataScope,
saveUserDataScope,
toggleApiKeyEnabled,
}
})