From 58f461b0873e86f6c44ba8235b71a556b04f07a3 Mon Sep 17 00:00:00 2001 From: yan9651688 Date: Sun, 6 Sep 2026 11:25:33 +0800 Subject: [PATCH] feat(keys): add bulk editing to API key management --- Makefile | 3 + .../src/api/__tests__/keys.bulkUpdate.spec.ts | 63 ++++ frontend/src/api/keys.ts | 27 ++ .../src/components/keys/BulkEditKeysModal.vue | 298 ++++++++++++++++++ .../keys/__tests__/BulkEditKeysModal.spec.ts | 164 ++++++++++ frontend/src/i18n/locales/en/dashboard.ts | 15 + frontend/src/i18n/locales/zh/dashboard.ts | 15 + frontend/src/views/user/KeysView.vue | 50 +++ .../src/views/user/__tests__/KeysView.spec.ts | 65 +++- 9 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/__tests__/keys.bulkUpdate.spec.ts create mode 100644 frontend/src/components/keys/BulkEditKeysModal.vue create mode 100644 frontend/src/components/keys/__tests__/BulkEditKeysModal.spec.ts diff --git a/Makefile b/Makefile index b0bc1b99f6..141a574d4b 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ FRONTEND_CRITICAL_VITEST := \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ + src/api/__tests__/keys.bulkUpdate.spec.ts \ + src/components/keys/__tests__/BulkEditKeysModal.spec.ts \ + src/views/user/__tests__/KeysView.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ src/views/auth/__tests__/WechatCallbackView.spec.ts \ diff --git a/frontend/src/api/__tests__/keys.bulkUpdate.spec.ts b/frontend/src/api/__tests__/keys.bulkUpdate.spec.ts new file mode 100644 index 0000000000..0ccd28fb0f --- /dev/null +++ b/frontend/src/api/__tests__/keys.bulkUpdate.spec.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { bulkUpdate } from '../keys' + +const { put } = vi.hoisted(() => ({ put: vi.fn() })) +vi.mock('../client', () => ({ apiClient: { put } })) + +describe('API key bulk updates', () => { + beforeEach(() => { + put.mockReset() + }) + + it('updates each selected key once and preserves partial failures', async () => { + const failure = { response: { status: 403, data: { detail: 'Forbidden' } } } + put.mockImplementation((url: string) => url === '/keys/2' + ? Promise.reject(failure) + : Promise.resolve({ data: { id: Number(url.split('/').pop()) } })) + + const updates = { group_id: 12, quota: 0, expires_at: '', ip_whitelist: [] } + const result = await bulkUpdate([1, 2, 1, 3], updates) + + expect(put.mock.calls).toEqual([ + ['/keys/1', updates], ['/keys/2', updates], ['/keys/3', updates] + ]) + expect(result).toEqual({ succeededIds: [1, 3], failures: [{ id: 2, error: failure }] }) + }) + + it('limits in-flight requests and continues after a failed batch member', async () => { + const settle: Array<() => void> = [] + let inFlight = 0 + let peak = 0 + put.mockImplementation(() => new Promise((resolve, reject) => { + inFlight++ + peak = Math.max(peak, inFlight) + const index = settle.length + settle.push(() => { + inFlight-- + if (index === 0) reject(new Error('network error')) + else resolve({ data: {} }) + }) + })) + + const pending = bulkUpdate([1, 2, 3, 4, 5, 6, 7], { status: 'inactive' }) + expect(put).toHaveBeenCalledTimes(5) + settle.slice(0, 4).forEach((finish) => finish()) + await flushPromises() + expect(put).toHaveBeenCalledTimes(5) + settle[4]() + await flushPromises() + expect(put).toHaveBeenCalledTimes(7) + settle.slice(5).forEach((finish) => finish()) + const result = await pending + + expect(peak).toBe(5) + expect(result.succeededIds).toEqual([2, 3, 4, 5, 6, 7]) + expect(result.failures.map(({ id }) => id)).toEqual([1]) + }) + + it('does not send requests when no keys are selected', async () => { + expect(await bulkUpdate([], { quota: 0 })).toEqual({ succeededIds: [], failures: [] }) + expect(put).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index 34dd5b4b76..85640eadba 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -111,6 +111,32 @@ export async function update(id: number, updates: UpdateApiKeyRequest): Promise< return data } +export interface BulkUpdateApiKeysResult { + succeededIds: number[] + failures: Array<{ id: number; error: unknown }> +} + +/** Reuse per-key validation and permissions, with at most five requests in flight. */ +export async function bulkUpdate( + ids: number[], + updates: UpdateApiKeyRequest +): Promise { + const uniqueIds = [...new Set(ids)] + const result: BulkUpdateApiKeysResult = { succeededIds: [], failures: [] } + for (let offset = 0; offset < uniqueIds.length; offset += 5) { + const batch = uniqueIds.slice(offset, offset + 5) + const responses = await Promise.allSettled(batch.map((id) => update(id, updates))) + responses.forEach((response, index) => { + if (response.status === 'fulfilled') { + result.succeededIds.push(batch[index]) + } else { + result.failures.push({ id: batch[index], error: response.reason }) + } + }) + } + return result +} + /** * Delete API key * @param id - API key ID @@ -136,6 +162,7 @@ export const keysAPI = { getById, create, update, + bulkUpdate, delete: deleteKey, toggleStatus } diff --git a/frontend/src/components/keys/BulkEditKeysModal.vue b/frontend/src/components/keys/BulkEditKeysModal.vue new file mode 100644 index 0000000000..320b585fa7 --- /dev/null +++ b/frontend/src/components/keys/BulkEditKeysModal.vue @@ -0,0 +1,298 @@ +