mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +00:00
feat(admin): batch update user limits
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user