mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +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()
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user