mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
feat(keys): add bulk editing to API key management
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<BulkUpdateApiKeysResult> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('keys.bulkEdit.title')"
|
||||
width="normal"
|
||||
:close-on-escape="!submitting"
|
||||
:show-close-button="!submitting"
|
||||
@close="close"
|
||||
>
|
||||
<form id="bulk-edit-keys-form" class="space-y-5" @submit.prevent="submit">
|
||||
<div class="space-y-1 text-sm">
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('keys.bulkEdit.selectedCount', { count: pendingKeys.length }) }}
|
||||
</p>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('keys.bulkEdit.hint') }}</p>
|
||||
</div>
|
||||
|
||||
<fieldset :disabled="submitting" class="space-y-5">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<input v-model="enabled.group_id" type="checkbox" class="checkbox" data-test="enable-group" />
|
||||
{{ t('keys.groupLabel') }}
|
||||
</label>
|
||||
<Select
|
||||
v-if="enabled.group_id"
|
||||
v-model="groupId"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('keys.selectGroup')"
|
||||
:disabled="submitting"
|
||||
:aria-label="t('keys.groupLabel')"
|
||||
searchable
|
||||
data-test="group-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<input v-model="enabled.status" type="checkbox" class="checkbox" data-test="enable-status" />
|
||||
{{ t('keys.statusLabel') }}
|
||||
</label>
|
||||
<Select
|
||||
v-if="enabled.status"
|
||||
v-model="status"
|
||||
:options="statusOptions"
|
||||
:disabled="submitting"
|
||||
:aria-label="t('keys.statusLabel')"
|
||||
data-test="status-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-for="field in limitFields" :key="field.key" class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
v-model="enabled[field.key]"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:data-test="`enable-${field.key}`"
|
||||
/>
|
||||
{{ t(field.label) }}
|
||||
</label>
|
||||
<div v-if="enabled[field.key]">
|
||||
<input
|
||||
v-model="limits[field.key]"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
class="input"
|
||||
:aria-label="t(field.label)"
|
||||
:data-test="`${field.key}-input`"
|
||||
/>
|
||||
<p class="input-hint">{{ t('keys.bulkEdit.limitHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<input v-model="enabled.expires_at" type="checkbox" class="checkbox" data-test="enable-expiration" />
|
||||
{{ t('keys.expiration') }}
|
||||
</label>
|
||||
<div v-if="enabled.expires_at" class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="neverExpires" type="checkbox" class="checkbox" data-test="never-expires" />
|
||||
{{ t('keys.noExpiration') }}
|
||||
</label>
|
||||
<input
|
||||
v-if="!neverExpires"
|
||||
v-model="expirationDate"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="input"
|
||||
:aria-label="t('keys.expirationDate')"
|
||||
data-test="expiration-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="field in ipFields" :key="field.key" class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
v-model="enabled[field.key]"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:data-test="`enable-${field.key}`"
|
||||
/>
|
||||
{{ t(field.label) }}
|
||||
</label>
|
||||
<div v-if="enabled[field.key]">
|
||||
<textarea
|
||||
v-model="ipLists[field.key]"
|
||||
rows="3"
|
||||
class="input font-mono text-sm"
|
||||
:aria-label="t(field.label)"
|
||||
:data-test="`${field.key}-input`"
|
||||
/>
|
||||
<p class="input-hint">{{ t('keys.bulkEdit.ipHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p v-if="validationError" role="alert" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ validationError }}
|
||||
</p>
|
||||
<div v-if="failures.length" role="alert" class="space-y-2 text-sm text-red-600 dark:text-red-400">
|
||||
<p>{{ t('keys.bulkEdit.failureHint') }}</p>
|
||||
<ul class="max-h-40 space-y-1 overflow-y-auto">
|
||||
<li v-for="failure in failures" :key="failure.id" class="break-words">
|
||||
#{{ failure.id }} {{ failure.name }}: {{ failure.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="close">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="bulk-edit-keys-form"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSubmit"
|
||||
data-test="submit"
|
||||
>
|
||||
{{ submitting ? t('keys.saving') : t('keys.bulkEdit.apply', { count: pendingKeys.length }) }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { keysAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import type { ApiKey, Group, UpdateApiKeyRequest } from '@/types'
|
||||
|
||||
type SelectedKey = Pick<ApiKey, 'id' | 'name'>
|
||||
type LimitField = 'quota' | 'rate_limit_5h' | 'rate_limit_1d' | 'rate_limit_7d'
|
||||
type IPField = 'ip_whitelist' | 'ip_blacklist'
|
||||
type EditableField = LimitField | IPField | 'group_id' | 'status' | 'expires_at'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
selectedKeys: SelectedKey[]
|
||||
groups: Group[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: [succeededIds: number[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const submitting = ref(false)
|
||||
const pendingKeys = ref<SelectedKey[]>([])
|
||||
const failures = ref<Array<{ id: number; name: string; message: string }>>([])
|
||||
const enabled = reactive<Record<EditableField, boolean>>({
|
||||
group_id: false,
|
||||
status: false,
|
||||
quota: false,
|
||||
rate_limit_5h: false,
|
||||
rate_limit_1d: false,
|
||||
rate_limit_7d: false,
|
||||
expires_at: false,
|
||||
ip_whitelist: false,
|
||||
ip_blacklist: false
|
||||
})
|
||||
const groupId = ref<number | null>(null)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const limits = reactive<Record<LimitField, string | number>>({
|
||||
quota: '', rate_limit_5h: '', rate_limit_1d: '', rate_limit_7d: ''
|
||||
})
|
||||
const ipLists = reactive<Record<IPField, string>>({ ip_whitelist: '', ip_blacklist: '' })
|
||||
const neverExpires = ref(false)
|
||||
const expirationDate = ref('')
|
||||
const limitFields: Array<{ key: LimitField; label: string }> = [
|
||||
{ key: 'quota', label: 'keys.quotaAmount' },
|
||||
{ key: 'rate_limit_5h', label: 'keys.rateLimit5h' },
|
||||
{ key: 'rate_limit_1d', label: 'keys.rateLimit1d' },
|
||||
{ key: 'rate_limit_7d', label: 'keys.rateLimit7d' }
|
||||
]
|
||||
const ipFields: Array<{ key: IPField; label: string }> = [
|
||||
{ key: 'ip_whitelist', label: 'keys.ipWhitelist' },
|
||||
{ key: 'ip_blacklist', label: 'keys.ipBlacklist' }
|
||||
]
|
||||
const groupOptions = computed(() => props.groups.map((group) => ({ value: group.id, label: group.name })))
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'active', label: t('keys.enable') },
|
||||
{ value: 'inactive', label: t('keys.disable') }
|
||||
])
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (enabled.group_id && !props.groups.some((group) => group.id === groupId.value)) {
|
||||
return t('keys.groupRequired')
|
||||
}
|
||||
for (const { key } of limitFields) {
|
||||
if (!enabled[key]) continue
|
||||
const value = String(limits[key]).trim()
|
||||
if (!value || !Number.isFinite(Number(value)) || Number(value) < 0) {
|
||||
return t('keys.bulkEdit.invalidLimit')
|
||||
}
|
||||
}
|
||||
if (enabled.expires_at && !neverExpires.value && !Number.isFinite(Date.parse(expirationDate.value))) {
|
||||
return t('keys.bulkEdit.invalidExpiration')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const canSubmit = computed(() =>
|
||||
pendingKeys.value.length > 0 && Object.values(enabled).some(Boolean)
|
||||
&& !validationError.value && !submitting.value
|
||||
)
|
||||
|
||||
watch(() => props.show, (show) => {
|
||||
if (!show) return
|
||||
pendingKeys.value = props.selectedKeys.map(({ id, name }) => ({ id, name }))
|
||||
failures.value = []
|
||||
for (const field of Object.keys(enabled) as EditableField[]) enabled[field] = false
|
||||
for (const { key } of limitFields) limits[key] = ''
|
||||
for (const { key } of ipFields) ipLists[key] = ''
|
||||
groupId.value = null
|
||||
status.value = 'active'
|
||||
neverExpires.value = false
|
||||
expirationDate.value = ''
|
||||
}, { immediate: true })
|
||||
|
||||
const close = () => {
|
||||
if (!submitting.value) emit('close')
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown): string => {
|
||||
const message = (error as { message?: unknown } | null)?.message
|
||||
return typeof message === 'string' && message ? message : t('keys.failedToSave')
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
const updates: UpdateApiKeyRequest = {}
|
||||
if (enabled.group_id) updates.group_id = groupId.value
|
||||
if (enabled.status) updates.status = status.value
|
||||
for (const { key } of limitFields) {
|
||||
if (enabled[key]) updates[key] = Number(limits[key])
|
||||
}
|
||||
for (const { key } of ipFields) {
|
||||
if (enabled[key]) updates[key] = ipLists[key].split('\n').map((ip) => ip.trim()).filter(Boolean)
|
||||
}
|
||||
if (enabled.expires_at) {
|
||||
updates.expires_at = neverExpires.value ? '' : new Date(expirationDate.value).toISOString()
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await keysAPI.bulkUpdate(pendingKeys.value.map((key) => key.id), updates)
|
||||
failures.value = result.failures.map(({ id, error }) => ({
|
||||
id,
|
||||
name: pendingKeys.value.find((key) => key.id === id)?.name ?? '',
|
||||
message: errorMessage(error)
|
||||
}))
|
||||
const failedIds = new Set(result.failures.map(({ id }) => id))
|
||||
pendingKeys.value = pendingKeys.value.filter((key) => failedIds.has(key.id))
|
||||
if (result.succeededIds.length) emit('updated', result.succeededIds)
|
||||
if (result.failures.length) {
|
||||
appStore.showError(t('keys.bulkEdit.partialFailure', {
|
||||
success: result.succeededIds.length, failed: result.failures.length
|
||||
}))
|
||||
} else {
|
||||
appStore.showSuccess(t('keys.bulkEdit.success', { count: result.succeededIds.length }))
|
||||
emit('close')
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError(errorMessage(error))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import BulkEditKeysModal from '../BulkEditKeysModal.vue'
|
||||
import type { Group } from '@/types'
|
||||
|
||||
const { bulkUpdate, showSuccess, showError } = vi.hoisted(() => ({
|
||||
bulkUpdate: vi.fn(), showSuccess: vi.fn(), showError: vi.fn()
|
||||
}))
|
||||
vi.mock('@/api', () => ({ keysAPI: { bulkUpdate } }))
|
||||
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess, showError }) }))
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => `${key} ${JSON.stringify(params ?? {})}` })
|
||||
}))
|
||||
|
||||
const mountModal = () => mount(BulkEditKeysModal, {
|
||||
props: {
|
||||
show: true,
|
||||
selectedKeys: [{ id: 1, name: 'First' }, { id: 2, name: 'Second' }],
|
||||
groups: [{ id: 7, name: 'Available group' }] as Group[]
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
name: 'BaseDialog',
|
||||
props: ['show', 'closeOnEscape', 'showCloseButton'],
|
||||
emits: ['close'],
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Select: {
|
||||
props: ['modelValue', 'options', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<select :disabled="disabled" :value="modelValue" @change="$emit('update:modelValue', $event.target.value === '7' ? 7 : $event.target.value)">
|
||||
<option value=""></option>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('BulkEditKeysModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
bulkUpdate.mockResolvedValue({ succeededIds: [1, 2], failures: [] })
|
||||
})
|
||||
|
||||
it('requires an explicit field choice and sends only that field', async () => {
|
||||
const wrapper = mountModal()
|
||||
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-test="enable-status"]').setValue(true)
|
||||
await wrapper.get('[data-test="status-input"]').setValue('inactive')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { status: 'inactive' })
|
||||
expect(wrapper.emitted('updated')).toEqual([[[1, 2]]])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves other fields when changing one window limit, including zero', async () => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-rate_limit_1d"]').setValue(true)
|
||||
await wrapper.get('[data-test="rate_limit_1d-input"]').setValue('0')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { rate_limit_1d: 0 })
|
||||
})
|
||||
|
||||
it.each(['', '-1', 'not-a-number'])('rejects an invalid selected quota: %s', async (value) => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-quota"]').setValue(true)
|
||||
await wrapper.get('[data-test="quota-input"]').setValue(value)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('requires an available group when changing group', async () => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-group"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-test="group-input"]').setValue('7')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { group_id: 7 })
|
||||
})
|
||||
|
||||
it('clears only an explicitly selected IP list and expiration', async () => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-ip_whitelist"]').setValue(true)
|
||||
await wrapper.get('[data-test="enable-expiration"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-test="never-expires"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { ip_whitelist: [], expires_at: '' })
|
||||
})
|
||||
|
||||
it('normalizes IP lines and converts the chosen local date to ISO', async () => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-ip_blacklist"]').setValue(true)
|
||||
await wrapper.get('[data-test="ip_blacklist-input"]').setValue(' 192.0.2.1 \n\n 198.51.100.0/24\n')
|
||||
await wrapper.get('[data-test="enable-expiration"]').setValue(true)
|
||||
await wrapper.get('[data-test="expiration-input"]').setValue('2030-01-02T03:04')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
ip_blacklist: ['192.0.2.1', '198.51.100.0/24'],
|
||||
expires_at: new Date('2030-01-02T03:04').toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
it('reports individual failures and retries only failed keys', async () => {
|
||||
bulkUpdate.mockResolvedValueOnce({
|
||||
succeededIds: [1],
|
||||
failures: [{ id: 2, error: { status: 403, message: 'Group access denied' } }]
|
||||
}).mockResolvedValueOnce({ succeededIds: [2], failures: [] })
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-quota"]').setValue(true)
|
||||
await wrapper.get('[data-test="quota-input"]').setValue('25.50')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('#2 Second: Group access denied')
|
||||
expect(wrapper.emitted('updated')).toEqual([[[1]]])
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
expect(showSuccess).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ selectedKeys: [{ id: 2, name: 'Second' }, { id: 3, name: 'New selection' }] })
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(bulkUpdate).toHaveBeenLastCalledWith([2], { quota: 25.5 })
|
||||
expect(wrapper.emitted('updated')).toEqual([[[1]], [[2]]])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prevents duplicate submissions and closing during an update', async () => {
|
||||
let finish!: (result: { succeededIds: number[]; failures: [] }) => void
|
||||
bulkUpdate.mockReturnValue(new Promise((resolve) => { finish = resolve }))
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-status"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
wrapper.findComponent({ name: 'BaseDialog' }).vm.$emit('close')
|
||||
expect(bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
finish({ succeededIds: [1, 2], failures: [] })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('resets all field choices when reopened for a new selection', async () => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.get('[data-test="enable-quota"]').setValue(true)
|
||||
await wrapper.get('[data-test="quota-input"]').setValue('12')
|
||||
await wrapper.setProps({ show: false })
|
||||
await wrapper.setProps({ show: true, selectedKeys: [{ id: 3, name: 'Third' }] })
|
||||
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-test="quota-input"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-test="enable-status"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(bulkUpdate).toHaveBeenCalledWith([3], { status: 'active' })
|
||||
})
|
||||
})
|
||||
@@ -83,6 +83,21 @@ export default {
|
||||
columnAlwaysVisible: 'This column is always visible',
|
||||
createKey: 'Create API Key',
|
||||
editKey: 'Edit API Key',
|
||||
bulkEdit: {
|
||||
title: 'Bulk Edit',
|
||||
selectedCount: '{count} keys selected',
|
||||
selectKey: 'Select key {name}',
|
||||
clearSelection: 'Clear selection',
|
||||
hint: 'Check the fields to update. Unchecked fields keep their current values.',
|
||||
limitHint: 'Enter 0 for no limit. Existing usage is preserved.',
|
||||
ipHint: 'One IP or CIDR per line. Leave empty to clear this list on the selected keys.',
|
||||
invalidLimit: 'Enter a valid amount greater than or equal to 0.',
|
||||
invalidExpiration: 'Choose a valid expiration date or select Never expires.',
|
||||
apply: 'Apply to {count} keys',
|
||||
success: 'Updated {count} keys',
|
||||
partialFailure: 'Updated {success} keys; {failed} failed',
|
||||
failureHint: 'These keys could not be updated. Adjust the settings and retry. Only failed keys will be retried.'
|
||||
},
|
||||
deleteKey: 'Delete API Key',
|
||||
deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.",
|
||||
id: 'ID',
|
||||
|
||||
@@ -83,6 +83,21 @@ export default {
|
||||
columnAlwaysVisible: '该列固定显示,不可隐藏',
|
||||
createKey: '创建密钥',
|
||||
editKey: '编辑密钥',
|
||||
bulkEdit: {
|
||||
title: '批量编辑',
|
||||
selectedCount: '已选择 {count} 个密钥',
|
||||
selectKey: '选择密钥 {name}',
|
||||
clearSelection: '取消选择',
|
||||
hint: '勾选需要修改的字段,未勾选的字段保持原值。',
|
||||
limitHint: '输入 0 表示不限制;已用额度保持不变。',
|
||||
ipHint: '每行一个 IP 或 CIDR;留空将清空所选密钥的此项名单。',
|
||||
invalidLimit: '请输入大于或等于 0 的有效金额。',
|
||||
invalidExpiration: '请选择有效的过期时间,或勾选永久有效。',
|
||||
apply: '应用到 {count} 个密钥',
|
||||
success: '已更新 {count} 个密钥',
|
||||
partialFailure: '已更新 {success} 个密钥,{failed} 个失败',
|
||||
failureHint: '以下密钥更新失败,可修改设置后重试。再次提交只会更新失败的密钥。'
|
||||
},
|
||||
deleteKey: '删除密钥',
|
||||
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
|
||||
id: 'ID',
|
||||
|
||||
@@ -28,6 +28,22 @@
|
||||
:api-base-url="publicSettings?.api_base_url || ''"
|
||||
:custom-endpoints="publicSettings?.custom_endpoints || []"
|
||||
/>
|
||||
<div v-if="selectedIds.length" class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-300">
|
||||
{{ t('keys.bulkEdit.selectedCount', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
data-test="bulk-edit-keys"
|
||||
@click="showBulkEditModal = true"
|
||||
>
|
||||
{{ t('keys.bulkEdit.title') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="selectedIds = []">
|
||||
{{ t('keys.bulkEdit.clearSelection') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -85,6 +101,11 @@
|
||||
:columns="columns"
|
||||
:data="apiKeys"
|
||||
:loading="loading"
|
||||
selectable
|
||||
row-key="id"
|
||||
:selected-keys="selectedIds"
|
||||
:selection-label="(key: ApiKey) => t('keys.bulkEdit.selectKey', { name: key.name })"
|
||||
@update:selected-keys="handleSelectionChange"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@@ -952,6 +973,14 @@
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<BulkEditKeysModal
|
||||
:show="showBulkEditModal"
|
||||
:selected-keys="selectedApiKeys"
|
||||
:groups="groups"
|
||||
@close="showBulkEditModal = false"
|
||||
@updated="handleBulkUpdated"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
@@ -1128,6 +1157,7 @@ const { t } = useI18n()
|
||||
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import BulkEditKeysModal from '@/components/keys/BulkEditKeysModal.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
@@ -1270,6 +1300,21 @@ const columns = computed<Column[]>(() =>
|
||||
)
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
const showBulkEditModal = ref(false)
|
||||
const selectedApiKeys = computed(() => apiKeys.value.filter((key) => selectedIds.value.includes(key.id)))
|
||||
|
||||
const handleSelectionChange = (ids: Array<string | number>) => {
|
||||
const visibleIds = new Set(apiKeys.value.map((key) => key.id))
|
||||
selectedIds.value = [...new Set(ids.map(Number))].filter((id) => visibleIds.has(id))
|
||||
}
|
||||
|
||||
const handleBulkUpdated = (succeededIds: number[]) => {
|
||||
const succeeded = new Set(succeededIds)
|
||||
selectedIds.value = selectedIds.value.filter((id) => !succeeded.has(id))
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -1393,6 +1438,7 @@ const statusFilterOptions = computed(() => [
|
||||
])
|
||||
|
||||
const onFilterChange = () => {
|
||||
selectedIds.value = []
|
||||
pagination.value.page = 1
|
||||
loadApiKeys()
|
||||
}
|
||||
@@ -1477,6 +1523,7 @@ const loadApiKeys = async () => {
|
||||
})
|
||||
if (signal.aborted) return
|
||||
apiKeys.value = response.items
|
||||
handleSelectionChange(selectedIds.value)
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
|
||||
@@ -1540,17 +1587,20 @@ const closeUseKeyModal = () => {
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
selectedIds.value = []
|
||||
pagination.value.page = page
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
selectedIds.value = []
|
||||
pagination.value.page_size = pageSize
|
||||
pagination.value.page = 1
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
selectedIds.value = []
|
||||
sortState.value.sort_by = key
|
||||
sortState.value.sort_order = order
|
||||
pagination.value.page = 1
|
||||
|
||||
@@ -153,8 +153,8 @@ const TablePageLayoutStub = {
|
||||
|
||||
const DataTableStub = {
|
||||
name: 'DataTable',
|
||||
props: ['columns', 'data'],
|
||||
emits: ['sort'],
|
||||
props: { columns: Array, data: Array, selectedKeys: Array, selectable: Boolean },
|
||||
emits: ['sort', 'update:selectedKeys'],
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
|
||||
@@ -230,6 +230,7 @@ const mountView = async () => {
|
||||
SearchInput: SearchInputStub,
|
||||
Icon: IconStub,
|
||||
UseKeyModal: true,
|
||||
BulkEditKeysModal: true,
|
||||
EndpointPopover: true,
|
||||
GroupBadge: true,
|
||||
GroupOptionItem: true,
|
||||
@@ -305,6 +306,66 @@ describe('user KeysView column settings', () => {
|
||||
expect(visibleColumnKeys(wrapper)).not.toContain('id')
|
||||
})
|
||||
|
||||
it('opens bulk editing with only selected visible keys', async () => {
|
||||
const wrapper = await mountView()
|
||||
const table = wrapper.findComponent({ name: 'DataTable' })
|
||||
expect(table.props('selectable')).toBe(true)
|
||||
table.vm.$emit('update:selectedKeys', [1, 99])
|
||||
await nextTick()
|
||||
await wrapper.get('[data-test="bulk-edit-keys"]').trigger('click')
|
||||
const modal = wrapper.findComponent({ name: 'BulkEditKeysModal' })
|
||||
expect(modal.props('show')).toBe(true)
|
||||
expect(modal.props('selectedKeys').map((key: ApiKey) => key.id)).toEqual([1])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['filter', 'page size', 'sort'])('clears selection on %s changes', async (change) => {
|
||||
const wrapper = await mountView()
|
||||
const table = wrapper.findComponent({ name: 'DataTable' })
|
||||
table.vm.$emit('update:selectedKeys', [1])
|
||||
await nextTick()
|
||||
if (change === 'filter') {
|
||||
wrapper.findComponent({ name: 'SearchInput' }).vm.$emit('search')
|
||||
} else if (change === 'page size') {
|
||||
await wrapper.get('[data-test="page-size-50"]').trigger('click')
|
||||
} else {
|
||||
table.vm.$emit('sort', 'created_at', 'asc')
|
||||
}
|
||||
await flushPromises()
|
||||
expect(table.props('selectedKeys')).toEqual([])
|
||||
expect(wrapper.find('[data-test="bulk-edit-keys"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('removes successful keys from the selection and refreshes the table', async () => {
|
||||
listKeys.mockResolvedValue({
|
||||
items: [createApiKey(), { ...createApiKey(), id: 2, name: 'Second' }],
|
||||
total: 2, pages: 1
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
const table = wrapper.findComponent({ name: 'DataTable' })
|
||||
table.vm.$emit('update:selectedKeys', [1, 2])
|
||||
await nextTick()
|
||||
await wrapper.get('[data-test="bulk-edit-keys"]').trigger('click')
|
||||
wrapper.findComponent({ name: 'BulkEditKeysModal' }).vm.$emit('updated', [1])
|
||||
await flushPromises()
|
||||
expect(listKeys).toHaveBeenCalledTimes(2)
|
||||
expect(table.props('selectedKeys')).toEqual([2])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('drops keys that are no longer visible after a refresh', async () => {
|
||||
const wrapper = await mountView()
|
||||
const table = wrapper.findComponent({ name: 'DataTable' })
|
||||
table.vm.$emit('update:selectedKeys', [1])
|
||||
await nextTick()
|
||||
listKeys.mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
await wrapper.get('button[title="Refresh"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(table.props('selectedKeys')).toEqual([])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a hidden column when toggled and persists the preference', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user