feat(admin): batch update user limits

This commit is contained in:
zhaozewu
2026-07-16 15:37:35 +08:00
parent 09c6c6d740
commit 7947619cc3
31 changed files with 1243 additions and 9 deletions
@@ -0,0 +1,214 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.bulkLimits.title')"
width="normal"
@close="emit('close')"
>
<form id="bulk-edit-user-limits-form" class="space-y-5" @submit.prevent="handleSubmit">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.users.bulkLimits.selectedCount', { count: selectedIds.length }) }}
</p>
<div class="divide-y divide-gray-200 border-y border-gray-200 dark:divide-dark-700 dark:border-dark-700">
<div class="space-y-3 py-4">
<div class="flex items-center justify-between gap-4">
<label for="bulk-concurrency" class="input-label mb-0">
{{ t('admin.users.columns.concurrency') }}
</label>
<Toggle
v-model="enableConcurrency"
:aria-label="t('admin.users.bulkLimits.enableConcurrency')"
data-test="enable-concurrency"
/>
</div>
<input
v-if="enableConcurrency"
id="bulk-concurrency"
v-model="concurrencyValue"
type="number"
min="0"
step="1"
class="input"
data-test="concurrency-input"
/>
</div>
<div class="space-y-3 py-4">
<div class="flex items-center justify-between gap-4">
<label for="bulk-rpm-limit" class="input-label mb-0">
{{ t('admin.users.form.rpmLimit') }}
</label>
<Toggle
v-model="enableRPMLimit"
:aria-label="t('admin.users.bulkLimits.enableRPMLimit')"
data-test="enable-rpm-limit"
/>
</div>
<div v-if="enableRPMLimit">
<input
id="bulk-rpm-limit"
v-model="rpmLimitValue"
type="number"
min="0"
step="1"
class="input"
data-test="rpm-limit-input"
/>
<p v-if="parsedRPMLimit === 0" class="input-hint">
{{ t('admin.users.bulkLimits.unlimited') }}
</p>
</div>
</div>
</div>
<p v-if="hasInvalidValue" class="text-sm text-red-600 dark:text-red-400">
{{ t('admin.users.bulkLimits.nonNegativeInteger') }}
</p>
<p v-if="selectionTooLarge" class="text-sm text-red-600 dark:text-red-400">
{{ t('admin.users.bulkLimits.selectionLimit', { max: MAX_BATCH_USER_IDS }) }}
</p>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="emit('close')">
{{ t('common.cancel') }}
</button>
<button
type="submit"
form="bulk-edit-user-limits-form"
class="btn btn-primary"
:disabled="!canSubmit"
data-test="submit"
>
{{ submitting ? t('admin.users.bulkLimits.applying') : t('admin.users.bulkLimits.apply') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { BatchUpdateUserLimitsRequest } from '@/api/admin/users'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Toggle from '@/components/common/Toggle.vue'
const props = defineProps<{
show: boolean
selectedIds: number[]
}>()
const emit = defineEmits<{
close: []
success: [affected: number]
}>()
const { t } = useI18n()
const appStore = useAppStore()
const enableConcurrency = ref(false)
const enableRPMLimit = ref(false)
const concurrencyValue = ref<string | number>('')
const rpmLimitValue = ref<string | number>('')
const submitting = ref(false)
const MAX_BATCH_USER_IDS = 500
const parseLimit = (value: string | number): number | null | undefined => {
const trimmed = String(value).trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isInteger(parsed) || parsed < 0) return null
return parsed
}
const parsedConcurrency = computed(() =>
enableConcurrency.value ? parseLimit(concurrencyValue.value) : undefined
)
const parsedRPMLimit = computed(() =>
enableRPMLimit.value ? parseLimit(rpmLimitValue.value) : undefined
)
const hasInvalidValue = computed(() =>
parsedConcurrency.value === null || parsedRPMLimit.value === null
)
const hasUpdate = computed(() =>
(parsedConcurrency.value !== undefined && parsedConcurrency.value !== null)
|| (parsedRPMLimit.value !== undefined && parsedRPMLimit.value !== null)
)
const selectionTooLarge = computed(() => props.selectedIds.length > MAX_BATCH_USER_IDS)
const canSubmit = computed(() =>
props.selectedIds.length > 0
&& !selectionTooLarge.value
&& hasUpdate.value
&& !hasInvalidValue.value
&& !submitting.value
)
const reset = () => {
enableConcurrency.value = false
enableRPMLimit.value = false
concurrencyValue.value = ''
rpmLimitValue.value = ''
submitting.value = false
}
watch(
() => props.show,
(show) => {
if (show) reset()
}
)
const handleSubmit = async () => {
if (!canSubmit.value) return
const request: BatchUpdateUserLimitsRequest = {
user_ids: [...props.selectedIds],
all: false
}
const fields: string[] = []
if (parsedConcurrency.value !== undefined && parsedConcurrency.value !== null) {
request.concurrency = parsedConcurrency.value
fields.push(
t('admin.users.bulkLimits.concurrencyValue', { value: parsedConcurrency.value })
)
}
if (parsedRPMLimit.value !== undefined && parsedRPMLimit.value !== null) {
request.rpm_limit = parsedRPMLimit.value
fields.push(
parsedRPMLimit.value === 0
? t('admin.users.bulkLimits.rpmUnlimitedValue')
: t('admin.users.bulkLimits.rpmValue', { value: parsedRPMLimit.value })
)
}
const confirmed = window.confirm(
t('admin.users.bulkLimits.confirm', {
count: props.selectedIds.length,
fields: fields.join(', ')
})
)
if (!confirmed) return
submitting.value = true
try {
const result = await adminAPI.users.batchUpdateLimits(request)
appStore.showSuccess(
t('admin.users.bulkLimits.success', { count: result.affected })
)
emit('success', result.affected)
emit('close')
} catch (error: any) {
appStore.showError(
error.response?.data?.message
|| error.response?.data?.detail
|| t('admin.users.bulkLimits.failed')
)
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import BulkEditUserModal from '../BulkEditUserModal.vue'
const { batchUpdateLimits, showSuccess, showError } = vi.hoisted(() => ({
batchUpdateLimits: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
users: {
batchUpdateLimits
}
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showSuccess,
showError
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
})
}))
const mountModal = () => mount(BulkEditUserModal, {
props: {
show: true,
selectedIds: [4, 7]
},
global: {
stubs: {
BaseDialog: {
props: ['show', 'title'],
emits: ['close'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
}
}
}
})
describe('BulkEditUserModal', () => {
beforeEach(() => {
batchUpdateLimits.mockReset()
showSuccess.mockReset()
showError.mockReset()
batchUpdateLimits.mockResolvedValue({ affected: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('disables submission until at least one enabled field has a value', async () => {
const wrapper = mountModal()
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="concurrency-input"]').setValue('5')
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined()
})
it('disables submission when more than 500 users are selected', async () => {
const wrapper = mountModal()
await wrapper.setProps({ selectedIds: Array.from({ length: 501 }, (_, index) => index + 1) })
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
await wrapper.get('[data-test="concurrency-input"]').setValue('5')
expect(wrapper.text()).toContain('admin.users.bulkLimits.selectionLimit')
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
})
it('submits only the enabled RPM field and preserves zero as unlimited', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountModal()
await wrapper.get('[data-test="enable-rpm-limit"]').trigger('click')
await wrapper.get('[data-test="rpm-limit-input"]').setValue('0')
expect(wrapper.text()).toContain('admin.users.bulkLimits.unlimited')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(batchUpdateLimits).toHaveBeenCalledWith({
user_ids: [4, 7],
all: false,
rpm_limit: 0
})
expect(confirm).toHaveBeenCalledWith(
expect.stringContaining('admin.users.bulkLimits.rpmUnlimitedValue')
)
expect(wrapper.emitted('success')).toEqual([[2]])
})
it('omits disabled fields from the request', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountModal()
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
await wrapper.get('[data-test="concurrency-input"]').setValue('9')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(batchUpdateLimits).toHaveBeenCalledWith({
user_ids: [4, 7],
all: false,
concurrency: 9
})
})
it('does not call the API when overwrite confirmation is cancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
const wrapper = mountModal()
await wrapper.get('[data-test="enable-concurrency"]').trigger('click')
await wrapper.get('[data-test="concurrency-input"]').setValue('9')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(batchUpdateLimits).not.toHaveBeenCalled()
})
})