mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
feat(admin): batch update user limits
This commit is contained in:
@@ -11,9 +11,12 @@ vi.mock('@/api/client', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
batchUpdateLimits,
|
||||
bindUserAuthIdentity,
|
||||
type AdminBindAuthIdentityRequest,
|
||||
type AdminBoundAuthIdentity,
|
||||
type BatchUpdateUserLimitsRequest,
|
||||
type BatchUpdateUserLimitsResponse,
|
||||
} from '@/api/admin/users'
|
||||
|
||||
type Assert<T extends true> = T
|
||||
@@ -63,6 +66,20 @@ const requestContractExact: Assert<
|
||||
const responseContractExact: Assert<
|
||||
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
|
||||
> = true
|
||||
const batchRequestContractExact: Assert<
|
||||
IsExact<
|
||||
BatchUpdateUserLimitsRequest,
|
||||
{
|
||||
user_ids: number[]
|
||||
all?: boolean
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
>
|
||||
> = true
|
||||
const batchResponseContractExact: Assert<
|
||||
IsExact<BatchUpdateUserLimitsResponse, { affected: number }>
|
||||
> = true
|
||||
|
||||
describe('admin users api auth identity binding', () => {
|
||||
beforeEach(() => {
|
||||
@@ -114,4 +131,20 @@ describe('admin users api auth identity binding', () => {
|
||||
expect(requestContractExact).toBe(true)
|
||||
expect(responseContractExact).toBe(true)
|
||||
})
|
||||
|
||||
it('posts batch limit updates once with only the supplied limit fields', async () => {
|
||||
const request: BatchUpdateUserLimitsRequest = {
|
||||
user_ids: [4, 7],
|
||||
all: false,
|
||||
rpm_limit: 0,
|
||||
}
|
||||
post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse })
|
||||
|
||||
const result = await batchUpdateLimits(request)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request)
|
||||
expect(result).toEqual({ affected: 2 })
|
||||
expect(batchRequestContractExact).toBe(true)
|
||||
expect(batchResponseContractExact).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,17 @@ export interface AdminBoundAuthIdentity {
|
||||
channel?: AdminBoundAuthIdentityChannel | null
|
||||
}
|
||||
|
||||
export interface BatchUpdateUserLimitsRequest {
|
||||
user_ids: number[]
|
||||
all?: boolean
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
|
||||
export interface BatchUpdateUserLimitsResponse {
|
||||
affected: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
@@ -184,6 +195,17 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
|
||||
return update(id, { concurrency })
|
||||
}
|
||||
|
||||
/** Overwrite concurrency and/or RPM limits for multiple users in one request. */
|
||||
export async function batchUpdateLimits(
|
||||
request: BatchUpdateUserLimitsRequest
|
||||
): Promise<BatchUpdateUserLimitsResponse> {
|
||||
const { data } = await apiClient.post<BatchUpdateUserLimitsResponse>(
|
||||
'/admin/users/batch-limits',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user status
|
||||
* @param id - User ID
|
||||
@@ -385,6 +407,7 @@ export const usersAPI = {
|
||||
delete: deleteUser,
|
||||
updateBalance,
|
||||
updateConcurrency,
|
||||
batchUpdateLimits,
|
||||
toggleStatus,
|
||||
getUserApiKeys,
|
||||
getUserUsageStats,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -32,14 +32,41 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="selectable" class="flex items-center justify-end gap-2 px-1">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
|
||||
:checked="allVisibleSelected"
|
||||
:indeterminate="someVisibleSelected"
|
||||
data-test="select-all-mobile"
|
||||
@change="toggleAllVisible(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ t('common.selectAll') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="resolveRowKey(row, index)"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
|
||||
:class="{ 'cursor-pointer': clickableRows }"
|
||||
:class="{
|
||||
'cursor-pointer': clickableRows,
|
||||
'border-primary-300 bg-primary-50/40 dark:border-primary-700 dark:bg-primary-900/10': selectable && isRowSelected(row, index)
|
||||
}"
|
||||
@click="clickableRows && emit('rowClick', row)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div v-if="selectable" class="flex justify-end">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
|
||||
:checked="isRowSelected(row, index)"
|
||||
:aria-label="getRowSelectionLabel(row, index)"
|
||||
data-test="select-row"
|
||||
@click.stop
|
||||
@change="toggleRowSelection(row, index, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="column in dataColumns"
|
||||
:key="column.key"
|
||||
@@ -74,6 +101,21 @@
|
||||
<table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="table-header bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th
|
||||
v-if="selectable"
|
||||
scope="col"
|
||||
class="sticky-header-cell w-11 min-w-11 px-3 py-3 text-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
|
||||
:checked="allVisibleSelected"
|
||||
:indeterminate="someVisibleSelected"
|
||||
:aria-label="t('common.selectAll')"
|
||||
data-test="select-all"
|
||||
@change="toggleAllVisible(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
v-for="(column, index) in columns"
|
||||
:key="column.key"
|
||||
@@ -126,6 +168,9 @@
|
||||
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<!-- Loading skeleton -->
|
||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||
<td v-if="selectable" class="w-11 min-w-11 px-3 py-4">
|
||||
<div class="mx-auto h-4 w-4 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</td>
|
||||
<td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
@@ -136,7 +181,7 @@
|
||||
<!-- Empty state -->
|
||||
<tr v-else-if="!data || data.length === 0">
|
||||
<td
|
||||
:colspan="columns.length"
|
||||
:colspan="tableColumnCount"
|
||||
:class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
|
||||
>
|
||||
<slot name="empty">
|
||||
@@ -157,7 +202,7 @@
|
||||
<!-- Data rows: windowed when large, fully rendered when small (shared row/cell template) -->
|
||||
<template v-else>
|
||||
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
|
||||
<td :colspan="columns.length"
|
||||
<td :colspan="tableColumnCount"
|
||||
:style="{ height: virtualPaddingTop + 'px', padding: 0, border: 'none' }">
|
||||
</td>
|
||||
</tr>
|
||||
@@ -168,9 +213,23 @@
|
||||
:data-index="item.index"
|
||||
:ref="item.measure ? measureElement : undefined"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
:class="{ 'cursor-pointer': clickableRows }"
|
||||
:class="{
|
||||
'cursor-pointer': clickableRows,
|
||||
'bg-primary-50/40 dark:bg-primary-900/10': selectable && isRowSelected(item.row, item.index)
|
||||
}"
|
||||
@click="clickableRows && emit('rowClick', item.row)"
|
||||
>
|
||||
<td v-if="selectable" class="w-11 min-w-11 px-3 py-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-800"
|
||||
:checked="isRowSelected(item.row, item.index)"
|
||||
:aria-label="getRowSelectionLabel(item.row, item.index)"
|
||||
data-test="select-row"
|
||||
@click.stop
|
||||
@change="toggleRowSelection(item.row, item.index, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.key"
|
||||
@@ -192,7 +251,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualPaddingBottom > 0" aria-hidden="true">
|
||||
<td :colspan="columns.length"
|
||||
<td :colspan="tableColumnCount"
|
||||
:style="{ height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }">
|
||||
</td>
|
||||
</tr>
|
||||
@@ -219,6 +278,8 @@ const isDesktopViewport = ref(
|
||||
const emit = defineEmits<{
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
rowClick: [row: any]
|
||||
'update:selectedKeys': [keys: Array<string | number>]
|
||||
selectionChange: [keys: Array<string | number>]
|
||||
}>()
|
||||
|
||||
// 表格容器引用
|
||||
@@ -403,6 +464,12 @@ interface Props {
|
||||
* estimated-vs-actual row heights when rows have variable height.
|
||||
*/
|
||||
virtualizeThreshold?: number
|
||||
/** Enable controlled row selection. Stable row keys are strongly recommended. */
|
||||
selectable?: boolean
|
||||
/** Selected row keys. Keys outside the current data page are preserved. */
|
||||
selectedKeys?: Array<string | number>
|
||||
/** Accessible label for a row selection checkbox. */
|
||||
selectionLabel?: string | ((row: any) => string)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -411,7 +478,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
stickyActionsColumn: true,
|
||||
expandableActions: true,
|
||||
defaultSortOrder: 'asc',
|
||||
serverSideSort: false
|
||||
serverSideSort: false,
|
||||
selectable: false,
|
||||
selectedKeys: () => []
|
||||
})
|
||||
|
||||
const sortKey = ref<string>('')
|
||||
@@ -634,6 +703,52 @@ const sortedData = computed(() => {
|
||||
.map(item => item.row)
|
||||
})
|
||||
|
||||
const tableColumnCount = computed(() => props.columns.length + (props.selectable ? 1 : 0))
|
||||
const selectedKeySet = computed(() => new Set(props.selectedKeys))
|
||||
const visibleRowKeys = computed(() =>
|
||||
(sortedData.value ?? []).map((row, index) => resolveRowKey(row, index))
|
||||
)
|
||||
const allVisibleSelected = computed(() =>
|
||||
visibleRowKeys.value.length > 0
|
||||
&& visibleRowKeys.value.every((key) => selectedKeySet.value.has(key))
|
||||
)
|
||||
const someVisibleSelected = computed(() => {
|
||||
if (allVisibleSelected.value) return false
|
||||
return visibleRowKeys.value.some((key) => selectedKeySet.value.has(key))
|
||||
})
|
||||
|
||||
const emitSelection = (next: Set<string | number>) => {
|
||||
const keys = Array.from(next)
|
||||
emit('update:selectedKeys', keys)
|
||||
emit('selectionChange', keys)
|
||||
}
|
||||
|
||||
const isRowSelected = (row: any, index: number) =>
|
||||
selectedKeySet.value.has(resolveRowKey(row, index))
|
||||
|
||||
const getRowSelectionLabel = (row: any, index: number) => {
|
||||
if (typeof props.selectionLabel === 'function') return props.selectionLabel(row)
|
||||
if (props.selectionLabel) return props.selectionLabel
|
||||
return `${t('common.selectOption')} ${resolveRowKey(row, index)}`
|
||||
}
|
||||
|
||||
const toggleRowSelection = (row: any, index: number, checked: boolean) => {
|
||||
const next = new Set(props.selectedKeys)
|
||||
const key = resolveRowKey(row, index)
|
||||
if (checked) next.add(key)
|
||||
else next.delete(key)
|
||||
emitSelection(next)
|
||||
}
|
||||
|
||||
const toggleAllVisible = (checked: boolean) => {
|
||||
const next = new Set(props.selectedKeys)
|
||||
for (const key of visibleRowKeys.value) {
|
||||
if (checked) next.add(key)
|
||||
else next.delete(key)
|
||||
}
|
||||
emitSelection(next)
|
||||
}
|
||||
|
||||
// --- Virtual scrolling ---
|
||||
// 是否启用虚拟化:仅桌面端且行数超过阈值时开启。小列表全量渲染,彻底绕开虚拟器的
|
||||
// 估算/测量/滚动补偿链路,消除可变行高导致的滚动抖动。
|
||||
|
||||
@@ -25,6 +25,22 @@ const stubDesktopMatchMedia = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const stubMobileMatchMedia = () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
describe('DataTable', () => {
|
||||
beforeEach(() => {
|
||||
stubDesktopMatchMedia()
|
||||
@@ -259,4 +275,53 @@ describe('DataTable', () => {
|
||||
expect(measureSpy).not.toHaveBeenCalled()
|
||||
expect(sizeCache.size).toBe(100)
|
||||
})
|
||||
|
||||
it('emits controlled current-page selection while preserving off-page keys', async () => {
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data: [
|
||||
{ id: 1, name: 'One' },
|
||||
{ id: 2, name: 'Two' }
|
||||
],
|
||||
rowKey: 'id',
|
||||
selectable: true,
|
||||
selectedKeys: [99]
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.get('[data-test="select-all"]').setValue(true)
|
||||
|
||||
const selectedAll = wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]
|
||||
expect(selectedAll).toEqual([99, 1, 2])
|
||||
|
||||
await wrapper.setProps({ selectedKeys: selectedAll as number[] })
|
||||
const rowCheckboxes = wrapper.findAll<HTMLInputElement>('[data-test="select-row"]')
|
||||
expect(rowCheckboxes.every((checkbox) => checkbox.element.checked)).toBe(true)
|
||||
|
||||
await rowCheckboxes[0].setValue(false)
|
||||
|
||||
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([99, 2])
|
||||
expect(wrapper.emitted('selectionChange')?.at(-1)?.[0]).toEqual([99, 2])
|
||||
})
|
||||
|
||||
it('offers current-page select all in the mobile card layout', async () => {
|
||||
stubMobileMatchMedia()
|
||||
const wrapper = mount(DataTable, {
|
||||
props: {
|
||||
columns: [{ key: 'name', label: 'Name' }],
|
||||
data: [
|
||||
{ id: 1, name: 'One' },
|
||||
{ id: 2, name: 'Two' }
|
||||
],
|
||||
rowKey: 'id',
|
||||
selectable: true,
|
||||
selectedKeys: [99]
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.get('[data-test="select-all-mobile"]').setValue(true)
|
||||
|
||||
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([99, 1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -418,6 +418,25 @@ export default {
|
||||
title: 'User Management',
|
||||
description: 'Manage users and their permissions',
|
||||
createUser: 'Create User',
|
||||
bulkLimits: {
|
||||
action: 'Set limits ({count})',
|
||||
title: 'Set user limits',
|
||||
selectedCount: '{count} users selected',
|
||||
selectionLimit: 'Select no more than {max} users at a time.',
|
||||
selectUser: 'Select {email}',
|
||||
enableConcurrency: 'Update concurrency',
|
||||
enableRPMLimit: 'Update RPM limit',
|
||||
unlimited: 'Unlimited',
|
||||
nonNegativeInteger: 'Enter a non-negative whole number.',
|
||||
apply: 'Apply limits',
|
||||
applying: 'Applying...',
|
||||
concurrencyValue: 'Concurrency: {value}',
|
||||
rpmValue: 'RPM: {value}',
|
||||
rpmUnlimitedValue: 'RPM: Unlimited',
|
||||
confirm: 'Overwrite limits for {count} users?\n{fields}',
|
||||
success: 'Updated limits for {count} users',
|
||||
failed: 'Failed to update user limits'
|
||||
},
|
||||
editUser: 'Edit User',
|
||||
deleteUser: 'Delete User',
|
||||
deleteConfirmMessage: "Are you sure you want to delete user '{email}'? This action cannot be undone.",
|
||||
|
||||
@@ -418,6 +418,25 @@ export default {
|
||||
title: '用户管理',
|
||||
description: '管理用户账户和权限',
|
||||
createUser: '创建用户',
|
||||
bulkLimits: {
|
||||
action: '批量设置限制({count})',
|
||||
title: '批量设置用户限制',
|
||||
selectedCount: '已选择 {count} 个用户',
|
||||
selectionLimit: '一次最多选择 {max} 个用户。',
|
||||
selectUser: '选择 {email}',
|
||||
enableConcurrency: '修改并发数',
|
||||
enableRPMLimit: '修改 RPM 限制',
|
||||
unlimited: '不限制',
|
||||
nonNegativeInteger: '请输入非负整数。',
|
||||
apply: '应用限制',
|
||||
applying: '应用中...',
|
||||
concurrencyValue: '并发数:{value}',
|
||||
rpmValue: 'RPM:{value}',
|
||||
rpmUnlimitedValue: 'RPM:不限制',
|
||||
confirm: '确定覆盖 {count} 个用户的限制吗?\n{fields}',
|
||||
success: '已更新 {count} 个用户的限制',
|
||||
failed: '批量更新用户限制失败'
|
||||
},
|
||||
editUser: '编辑用户',
|
||||
deleteUser: '删除用户',
|
||||
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||
|
||||
@@ -1734,6 +1734,7 @@ export interface UpdateUserRequest {
|
||||
role?: 'admin' | 'user'
|
||||
balance?: number
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
status?: 'active' | 'disabled'
|
||||
allowed_groups?: number[] | null
|
||||
// 用户专属分组倍率配置 (group_id -> rate_multiplier | null)
|
||||
|
||||
@@ -242,6 +242,16 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
class="btn btn-secondary flex-1 md:flex-initial"
|
||||
data-test="bulk-edit-limits"
|
||||
@click="showBulkEditModal = true"
|
||||
>
|
||||
<Icon name="users" size="md" class="mr-2" />
|
||||
{{ t('admin.users.bulkLimits.action', { count: selectedCount }) }}
|
||||
</button>
|
||||
|
||||
<!-- Create User Button (full width on mobile, auto width on desktop) -->
|
||||
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
@@ -257,12 +267,17 @@
|
||||
:columns="columns"
|
||||
:data="sortedUsers"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
selectable
|
||||
:selected-keys="selectedIds"
|
||||
:selection-label="getUserSelectionLabel"
|
||||
:actions-count="7"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
:sort-storage-key="USER_SORT_STORAGE_KEY"
|
||||
@sort="handleSort"
|
||||
@update:selected-keys="handleSelectedKeysUpdate"
|
||||
>
|
||||
<template #cell-email="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -735,6 +750,12 @@
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.users.deleteUser')" :message="t('admin.users.deleteConfirm', { email: deletingUser?.email })" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<UserCreateModal :show="showCreateModal" @close="showCreateModal = false" @success="loadUsers" />
|
||||
<UserEditModal :show="showEditModal" :user="editingUser" @close="closeEditModal" @success="loadUsers" />
|
||||
<BulkEditUserModal
|
||||
:show="showBulkEditModal"
|
||||
:selected-ids="selectedIds"
|
||||
@close="showBulkEditModal = false"
|
||||
@success="handleBulkLimitsSuccess"
|
||||
/>
|
||||
<UserPlatformQuotaModal
|
||||
:show="showPlatformQuotaModal"
|
||||
:user="platformQuotaUser"
|
||||
@@ -755,6 +776,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { useTableSelection } from '@/composables/useTableSelection'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
@@ -781,6 +803,7 @@ import PlatformCostCell from '@/components/user/PlatformCostCell.vue'
|
||||
import UserPlatformQuotaCell from '@/components/user/UserPlatformQuotaCell.vue'
|
||||
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
|
||||
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import BulkEditUserModal from '@/components/admin/user/BulkEditUserModal.vue'
|
||||
import UserPlatformQuotaModal from '@/components/admin/user/UserPlatformQuotaModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
|
||||
@@ -1268,6 +1291,23 @@ const sortedUsers = computed(() => {
|
||||
.map((x) => x.row)
|
||||
})
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
setSelectedIds,
|
||||
clear: clearSelection
|
||||
} = useTableSelection<AdminUser>({
|
||||
rows: sortedUsers,
|
||||
getId: (user) => user.id
|
||||
})
|
||||
|
||||
const handleSelectedKeysUpdate = (keys: Array<string | number>) => {
|
||||
setSelectedIds(keys.filter((key): key is number => typeof key === 'number'))
|
||||
}
|
||||
|
||||
const getUserSelectionLabel = (user: AdminUser) =>
|
||||
t('admin.users.bulkLimits.selectUser', { email: user.email })
|
||||
|
||||
// User attribute definitions and values
|
||||
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
|
||||
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
|
||||
@@ -1280,6 +1320,7 @@ const pagination = reactive({
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showBulkEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
@@ -1584,6 +1625,11 @@ const loadUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkLimitsSuccess = async () => {
|
||||
clearSelection()
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
|
||||
@@ -77,13 +77,22 @@ const createAdminUser = (overrides: Partial<AdminUser> = {}): AdminUser => ({
|
||||
})
|
||||
|
||||
const DataTableStub = {
|
||||
props: ['columns', 'data'],
|
||||
emits: ['sort'],
|
||||
props: ['columns', 'data', 'selectedKeys'],
|
||||
emits: ['sort', 'update:selectedKeys'],
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
|
||||
<div data-test="row-order">{{ data.map(row => row.email).join(',') }}</div>
|
||||
<div data-test="selected-keys">{{ (selectedKeys || []).join(',') }}</div>
|
||||
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
|
||||
<button
|
||||
v-for="row in data"
|
||||
:key="'select-' + row.id"
|
||||
:data-test="'select-' + row.id"
|
||||
@click="$emit('update:selectedKeys', Array.from(new Set([...(selectedKeys || []), row.id])))"
|
||||
>
|
||||
select
|
||||
</button>
|
||||
<template v-for="col in columns" :key="col.key">
|
||||
<slot :name="'header-' + col.key" :column="col" />
|
||||
</template>
|
||||
@@ -94,6 +103,22 @@ const DataTableStub = {
|
||||
`
|
||||
}
|
||||
|
||||
const PaginationStub = {
|
||||
emits: ['update:page'],
|
||||
template: '<button data-test="next-page" @click="$emit(\'update:page\', 2)">next</button>'
|
||||
}
|
||||
|
||||
const BulkEditUserModalStub = {
|
||||
props: ['show', 'selectedIds'],
|
||||
emits: ['close', 'success'],
|
||||
template: `
|
||||
<div v-if="show" data-test="bulk-modal">
|
||||
<span data-test="bulk-modal-ids">{{ selectedIds.join(',') }}</span>
|
||||
<button data-test="bulk-success" @click="$emit('success', selectedIds.length)">success</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
describe('admin UsersView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -140,6 +165,8 @@ describe('admin UsersView', () => {
|
||||
UserConcurrencyCell: true,
|
||||
UserCreateModal: true,
|
||||
UserEditModal: true,
|
||||
BulkEditUserModal: BulkEditUserModalStub,
|
||||
UserPlatformQuotaModal: true,
|
||||
UserApiKeysModal: true,
|
||||
UserAllowedGroupsModal: true,
|
||||
UserBalanceModal: true,
|
||||
@@ -224,6 +251,8 @@ describe('admin UsersView', () => {
|
||||
UserConcurrencyCell: true,
|
||||
UserCreateModal: true,
|
||||
UserEditModal: true,
|
||||
BulkEditUserModal: BulkEditUserModalStub,
|
||||
UserPlatformQuotaModal: true,
|
||||
UserApiKeysModal: true,
|
||||
UserAllowedGroupsModal: true,
|
||||
UserBalanceModal: true,
|
||||
@@ -264,4 +293,80 @@ describe('admin UsersView', () => {
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps selected user IDs across pages and clears them after a successful bulk update', async () => {
|
||||
let refreshed = false
|
||||
listUsers.mockImplementation(async (page: number) => {
|
||||
const user = page === 2
|
||||
? createAdminUser({
|
||||
id: 43,
|
||||
email: refreshed ? 'refreshed-page-two@example.com' : 'page-two@example.com'
|
||||
})
|
||||
: createAdminUser({ id: 42, email: 'page-one@example.com' })
|
||||
return {
|
||||
items: [user],
|
||||
total: 2,
|
||||
page,
|
||||
page_size: 20,
|
||||
pages: 2
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(UsersView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
},
|
||||
DataTable: DataTableStub,
|
||||
Pagination: PaginationStub,
|
||||
ConfirmDialog: true,
|
||||
EmptyState: true,
|
||||
GroupBadge: true,
|
||||
Select: true,
|
||||
UserAttributesConfigModal: true,
|
||||
UserConcurrencyCell: true,
|
||||
UserCreateModal: true,
|
||||
UserEditModal: true,
|
||||
BulkEditUserModal: BulkEditUserModalStub,
|
||||
UserPlatformQuotaModal: true,
|
||||
UserApiKeysModal: true,
|
||||
UserAllowedGroupsModal: true,
|
||||
UserBalanceModal: true,
|
||||
UserBalanceHistoryModal: true,
|
||||
GroupReplaceModal: true,
|
||||
Icon: true,
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-test="select-42"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
|
||||
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[data-test="next-page"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
|
||||
|
||||
await wrapper.get('[data-test="select-43"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42,43')
|
||||
|
||||
await wrapper.get('[data-test="bulk-edit-limits"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="bulk-modal-ids"]').text()).toBe('42,43')
|
||||
|
||||
const callsBeforeSuccess = listUsers.mock.calls.length
|
||||
refreshed = true
|
||||
await wrapper.get('[data-test="bulk-success"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listUsers.mock.calls.length).toBeGreaterThan(callsBeforeSuccess)
|
||||
expect(wrapper.get('[data-test="row-order"]').text()).toBe('refreshed-page-two@example.com')
|
||||
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user