mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:48:45 +08:00
feat(admin): 支持按筛选结果全选账号
This commit is contained in:
@@ -715,6 +715,8 @@ export interface BatchOperationResult {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
errors?: Array<{ account_id: number; error: string }>
|
||||
warnings?: Array<{ account_id: number; warning: string }>
|
||||
}
|
||||
@@ -729,6 +731,16 @@ export async function revertProxyFallback(id: number): Promise<{ message: string
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple accounts with bounded server-side concurrency.
|
||||
*/
|
||||
export async function batchDelete(accountIds: number[]): Promise<BatchOperationResult> {
|
||||
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-delete', {
|
||||
account_ids: accountIds
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch clear account errors
|
||||
* @param accountIds - Array of account IDs
|
||||
@@ -968,6 +980,7 @@ export const accountsAPI = {
|
||||
importCodexSession,
|
||||
createOpenAICodexPAT,
|
||||
getAntigravityDefaultModelMapping,
|
||||
batchDelete,
|
||||
batchClearError,
|
||||
batchRefresh,
|
||||
setPrivacy,
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
<template>
|
||||
<div class="mb-4 flex items-center justify-between rounded-lg bg-primary-50 p-3 dark:bg-primary-900/20">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="selectedIds.length > 0" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
<span v-if="allResultsSelected" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selectedAll', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<span v-else-if="selectedIds.length > 0" class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<span v-else class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkEdit.title') }}
|
||||
</span>
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<button
|
||||
@click="$emit('select-page')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="$emit('clear')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('select-page')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="!allResultsSelected && totalResults > selectedIds.length">
|
||||
<span v-if="selectedIds.length > 0" class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
:disabled="selectingAll"
|
||||
@click="$emit('select-all-results')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 disabled:cursor-not-allowed disabled:opacity-60 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{
|
||||
selectingAll
|
||||
? t('admin.accounts.bulkActions.selectingAll')
|
||||
: t('admin.accounts.bulkActions.selectAllResults', { count: totalResults })
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="selectedIds.length > 0">
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="$emit('clear')"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -43,13 +62,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{ selectedIds: number[] }>()
|
||||
defineProps<{
|
||||
selectedIds: number[]
|
||||
totalResults: number
|
||||
selectingAll: boolean
|
||||
allResultsSelected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits([
|
||||
'delete',
|
||||
'edit-selected',
|
||||
'edit-filtered',
|
||||
'clear',
|
||||
'select-page',
|
||||
'select-all-results',
|
||||
'toggle-schedulable',
|
||||
'reset-status',
|
||||
'refresh-token',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import AccountBulkActionsBar from '../AccountBulkActionsBar.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('AccountBulkActionsBar', () => {
|
||||
it('allows selecting all results before any row is selected', async () => {
|
||||
const wrapper = mount(AccountBulkActionsBar, {
|
||||
props: {
|
||||
selectedIds: [],
|
||||
totalResults: 45,
|
||||
selectingAll: false,
|
||||
allResultsSelected: false
|
||||
}
|
||||
})
|
||||
|
||||
const button = wrapper.findAll('button').find(item =>
|
||||
item.text().includes('admin.accounts.bulkActions.selectAllResults')
|
||||
)
|
||||
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
expect(wrapper.emitted('select-all-results')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves the upstream billing probe action from v0.1.166', async () => {
|
||||
const wrapper = mount(AccountBulkActionsBar, {
|
||||
props: {
|
||||
selectedIds: [1],
|
||||
totalResults: 45,
|
||||
selectingAll: false,
|
||||
allResultsSelected: false
|
||||
}
|
||||
})
|
||||
|
||||
const button = wrapper.findAll('button').find(item =>
|
||||
item.text().includes('admin.accounts.bulkActions.probeUpstreamBilling')
|
||||
)
|
||||
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
expect(wrapper.emitted('probe-upstream-billing')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -388,10 +388,16 @@ export default {
|
||||
bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.',
|
||||
bulkActions: {
|
||||
selected: '{count} account(s) selected',
|
||||
selectedAll: 'All {count} account(s) selected',
|
||||
selectCurrentPage: 'Select this page',
|
||||
selectAllResults: 'Select all results ({count})',
|
||||
selectingAll: 'Selecting all results...',
|
||||
selectAllFailed: 'Failed to load all accounts. The previous selection was kept.',
|
||||
clear: 'Clear selection',
|
||||
edit: 'Bulk Edit',
|
||||
delete: 'Bulk Delete',
|
||||
confirmDelete: 'Delete the selected {count} account(s)? This action cannot be undone.',
|
||||
deleteSuccess: 'Deleted {count} account(s)',
|
||||
enableScheduling: 'Enable Scheduling',
|
||||
disableScheduling: 'Disable Scheduling',
|
||||
resetStatus: 'Reset Status',
|
||||
|
||||
@@ -457,10 +457,16 @@ export default {
|
||||
bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表',
|
||||
bulkActions: {
|
||||
selected: '已选择 {count} 个账号',
|
||||
selectedAll: '已选择全部 {count} 个账号',
|
||||
selectCurrentPage: '本页全选',
|
||||
selectAllResults: '全选所有结果({count})',
|
||||
selectingAll: '正在选择全部结果...',
|
||||
selectAllFailed: '获取全部账号失败,原有选择未改变',
|
||||
clear: '清除选择',
|
||||
edit: '批量编辑账号',
|
||||
delete: '批量删除',
|
||||
confirmDelete: '确认删除选中的 {count} 个账号吗?此操作不可恢复。',
|
||||
deleteSuccess: '已成功删除 {count} 个账号',
|
||||
enableScheduling: '批量启用调度',
|
||||
disableScheduling: '批量停止调度',
|
||||
resetStatus: '批量重置状态',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchAllAccountIds } from '../accountSelection'
|
||||
|
||||
describe('fetchAllAccountIds', () => {
|
||||
it('loads every page with the same filter snapshot and returns unique account IDs', async () => {
|
||||
const fetchPage = vi.fn(async (page: number, pageSize: number, _filters: Record<string, unknown>) => {
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, 2505)
|
||||
return {
|
||||
items: Array.from({ length: end - start + 1 }, (_, index) => ({ id: start + index })),
|
||||
total: 2505,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
pages: 3
|
||||
}
|
||||
})
|
||||
|
||||
const filters = {
|
||||
platform: 'grok',
|
||||
status: 'active',
|
||||
search: 'example'
|
||||
}
|
||||
|
||||
const ids = await fetchAllAccountIds(fetchPage, filters)
|
||||
|
||||
expect(ids).toHaveLength(2505)
|
||||
expect(ids[0]).toBe(1)
|
||||
expect(ids[2504]).toBe(2505)
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3)
|
||||
expect(fetchPage).toHaveBeenNthCalledWith(1, 1, 1000, {
|
||||
...filters,
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
})
|
||||
expect(fetchPage).toHaveBeenNthCalledWith(3, 3, 1000, {
|
||||
...filters,
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an incomplete or duplicated result instead of returning a partial selection', async () => {
|
||||
const fetchPage = vi.fn().mockResolvedValue({
|
||||
items: [{ id: 1 }, { id: 1 }],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 1
|
||||
})
|
||||
|
||||
await expect(fetchAllAccountIds(fetchPage, {})).rejects.toThrow('账号列表结果不完整')
|
||||
})
|
||||
|
||||
it('propagates a later page failure', async () => {
|
||||
const fetchPage = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 1000 }, (_, index) => ({ id: index + 1 })),
|
||||
total: 1001,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 2
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('page 2 failed'))
|
||||
|
||||
await expect(fetchAllAccountIds(fetchPage, { group: '7' })).rejects.toThrow('page 2 failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
interface AccountIDRow {
|
||||
id: number
|
||||
}
|
||||
|
||||
interface AccountListPage {
|
||||
items: AccountIDRow[]
|
||||
total: number
|
||||
pages?: number
|
||||
}
|
||||
|
||||
type AccountPageFetcher = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
filters: Record<string, unknown>
|
||||
) => Promise<AccountListPage>
|
||||
|
||||
const SELECT_ALL_PAGE_SIZE = 1000
|
||||
|
||||
export async function fetchAllAccountIds(
|
||||
fetchPage: AccountPageFetcher,
|
||||
filters: Record<string, unknown>
|
||||
): Promise<number[]> {
|
||||
const requestFilters = {
|
||||
...filters,
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
}
|
||||
const firstPage = await fetchPage(1, SELECT_ALL_PAGE_SIZE, requestFilters)
|
||||
const pageCount = Math.max(
|
||||
firstPage.pages ?? 0,
|
||||
Math.ceil(firstPage.total / SELECT_ALL_PAGE_SIZE)
|
||||
)
|
||||
const ids = firstPage.items.map(account => account.id)
|
||||
|
||||
for (let page = 2; page <= pageCount; page++) {
|
||||
const result = await fetchPage(page, SELECT_ALL_PAGE_SIZE, requestFilters)
|
||||
ids.push(...result.items.map(account => account.id))
|
||||
}
|
||||
|
||||
const uniqueIDs = Array.from(new Set(ids))
|
||||
if (uniqueIDs.length !== firstPage.total) {
|
||||
throw new Error('账号列表结果不完整')
|
||||
}
|
||||
return uniqueIDs
|
||||
}
|
||||
@@ -177,6 +177,9 @@
|
||||
<template #table>
|
||||
<AccountBulkActionsBar
|
||||
:selected-ids="selIds"
|
||||
:total-results="pagination.total"
|
||||
:selecting-all="selectingAllResults"
|
||||
:all-results-selected="allResultsSelected"
|
||||
@delete="handleBulkDelete"
|
||||
@reset-status="handleBulkResetStatus"
|
||||
@refresh-token="handleBulkRefreshToken"
|
||||
@@ -185,6 +188,7 @@
|
||||
@edit-filtered="openBulkEditFiltered"
|
||||
@clear="clearSelection"
|
||||
@select-page="selectPage"
|
||||
@select-all-results="handleSelectAllResults"
|
||||
@toggle-schedulable="handleBulkToggleSchedulable"
|
||||
/>
|
||||
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -505,6 +509,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
|
||||
import { fetchAllAccountIds } from '@/utils/accountSelection'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
@@ -913,6 +918,7 @@ const {
|
||||
})
|
||||
|
||||
const {
|
||||
selectedSet,
|
||||
selectedIds: selIds,
|
||||
allVisibleSelected,
|
||||
isSelected,
|
||||
@@ -920,16 +926,36 @@ const {
|
||||
select,
|
||||
deselect,
|
||||
toggle: toggleSel,
|
||||
clear: clearSelection,
|
||||
clear: clearSelectedIds,
|
||||
removeMany: removeSelectedAccounts,
|
||||
toggleVisible,
|
||||
selectVisible: selectPage,
|
||||
selectVisible: selectCurrentPage,
|
||||
batchUpdate
|
||||
} = useTableSelection<Account>({
|
||||
rows: accounts,
|
||||
getId: (account) => account.id
|
||||
})
|
||||
|
||||
const selectingAllResults = ref(false)
|
||||
const selectedAllResultIDs = ref<Set<number> | null>(null)
|
||||
const selectionRequestVersion = ref(0)
|
||||
const allResultsSelected = computed(() => {
|
||||
const snapshot = selectedAllResultIDs.value
|
||||
if (!snapshot || snapshot.size === 0 || snapshot.size !== selectedSet.value.size) return false
|
||||
return Array.from(snapshot).every(id => selectedSet.value.has(id))
|
||||
})
|
||||
|
||||
const clearSelection = () => {
|
||||
selectionRequestVersion.value++
|
||||
selectingAllResults.value = false
|
||||
selectedAllResultIDs.value = null
|
||||
clearSelectedIds()
|
||||
}
|
||||
|
||||
const selectPage = () => {
|
||||
selectCurrentPage()
|
||||
}
|
||||
|
||||
const swipeVirtualContext: SwipeSelectVirtualContext = {
|
||||
getVirtualizer: () => dataTableRef.value?.virtualizer ?? null,
|
||||
getSortedData: () => dataTableRef.value?.sortedData ?? accounts.value,
|
||||
@@ -997,6 +1023,7 @@ const refreshUpstreamBillingSortedList = async (force = false) => {
|
||||
}
|
||||
|
||||
const debouncedReload = () => {
|
||||
clearSelection()
|
||||
syncAccountListDerivedParams()
|
||||
hasPendingListSync.value = false
|
||||
resetAutoRefreshCache()
|
||||
@@ -1491,7 +1518,27 @@ const toggleSelectAllVisible = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
toggleVisible(target.checked)
|
||||
}
|
||||
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); clearSelection(); reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
|
||||
const handleBulkDelete = async () => {
|
||||
const accountIds = [...selIds.value]
|
||||
if (!confirm(t('admin.accounts.bulkActions.confirmDelete', { count: accountIds.length }))) return
|
||||
try {
|
||||
const result = await adminAPI.accounts.batchDelete(accountIds)
|
||||
if (result.failed > 0) {
|
||||
appStore.showError(t('admin.accounts.bulkActions.partialSuccess', {
|
||||
success: result.success,
|
||||
failed: result.failed
|
||||
}))
|
||||
setSelectedIds(result.failed_ids?.length ? result.failed_ids : accountIds)
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.bulkActions.deleteSuccess', { count: result.success }))
|
||||
clearSelection()
|
||||
}
|
||||
await reload()
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk delete accounts:', error)
|
||||
appStore.showError(String(error))
|
||||
}
|
||||
}
|
||||
const handleBulkResetStatus = async () => {
|
||||
if (!confirm(t('common.confirm'))) return
|
||||
try {
|
||||
@@ -1675,6 +1722,32 @@ const buildBulkEditFilterSnapshot = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAllResults = async () => {
|
||||
if (selectingAllResults.value || pagination.total === 0) return
|
||||
|
||||
const requestVersion = ++selectionRequestVersion.value
|
||||
const filters = buildBulkEditFilterSnapshot()
|
||||
selectingAllResults.value = true
|
||||
try {
|
||||
const ids = await fetchAllAccountIds(
|
||||
(page, pageSize, requestFilters) => adminAPI.accounts.list(page, pageSize, requestFilters),
|
||||
filters
|
||||
)
|
||||
if (requestVersion !== selectionRequestVersion.value) return
|
||||
|
||||
setSelectedIds(ids)
|
||||
selectedAllResultIDs.value = new Set(ids)
|
||||
} catch (error) {
|
||||
if (requestVersion !== selectionRequestVersion.value) return
|
||||
console.error('Failed to select all account results:', error)
|
||||
appStore.showError(t('admin.accounts.bulkActions.selectAllFailed'))
|
||||
} finally {
|
||||
if (requestVersion === selectionRequestVersion.value) {
|
||||
selectingAllResults.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collectSelectionMetadata = (rows: Account[]) => {
|
||||
const selectedPlatforms = Array.from(new Set(rows.map(account => account.platform)))
|
||||
const selectedTypes = Array.from(new Set(rows.map(account => account.type)))
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import AccountsView from '../AccountsView.vue'
|
||||
|
||||
const {
|
||||
listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
getAllProxies,
|
||||
getAllGroups,
|
||||
showError
|
||||
} = vi.hoisted(() => ({
|
||||
listAccounts: vi.fn(),
|
||||
listWithEtag: vi.fn(),
|
||||
getBatchTodayStats: vi.fn(),
|
||||
getUpstreamBillingProbeSettings: vi.fn(),
|
||||
getAllProxies: vi.fn(),
|
||||
getAllGroups: vi.fn(),
|
||||
showError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
list: listAccounts,
|
||||
listWithEtag,
|
||||
getBatchTodayStats,
|
||||
getUpstreamBillingProbeSettings,
|
||||
batchDelete: vi.fn(),
|
||||
batchClearError: vi.fn(),
|
||||
batchRefresh: vi.fn(),
|
||||
bulkUpdate: vi.fn()
|
||||
},
|
||||
proxies: {
|
||||
getAll: getAllProxies
|
||||
},
|
||||
groups: {
|
||||
getAll: getAllGroups
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
token: 'test-token'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const makeAccounts = (count: number) => Array.from({ length: count }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `account-${index + 1}`,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
status: 'active',
|
||||
schedulable: true,
|
||||
created_at: '2026-07-23T00:00:00Z',
|
||||
updated_at: '2026-07-23T00:00:00Z'
|
||||
}))
|
||||
|
||||
const AccountBulkActionsBarStub = {
|
||||
props: ['selectedIds', 'totalResults', 'selectingAll', 'allResultsSelected'],
|
||||
emits: ['select-all-results', 'select-page', 'clear'],
|
||||
template: `
|
||||
<div>
|
||||
<span data-test="selected-count">{{ selectedIds.length }}</span>
|
||||
<span data-test="total-results">{{ totalResults }}</span>
|
||||
<span data-test="all-results-selected">{{ String(allResultsSelected) }}</span>
|
||||
<button data-test="select-page" @click="$emit('select-page')">select page</button>
|
||||
<button data-test="select-all-results" @click="$emit('select-all-results')">select all</button>
|
||||
<button data-test="clear" @click="$emit('clear')">clear</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const AccountTableFiltersStub = {
|
||||
emits: ['change'],
|
||||
template: '<button data-test="change-filter" @click="$emit(\'change\')">change filter</button>'
|
||||
}
|
||||
|
||||
const mountView = () => mount(AccountsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
},
|
||||
DataTable: { props: ['data'], template: '<div data-test="data-table"></div>' },
|
||||
Pagination: true,
|
||||
ConfirmDialog: true,
|
||||
AccountTableActions: { template: '<div><slot name="beforeCreate" /><slot name="after" /></div>' },
|
||||
AccountTableFilters: AccountTableFiltersStub,
|
||||
AccountBulkActionsBar: AccountBulkActionsBarStub,
|
||||
AccountActionMenu: true,
|
||||
ImportDataModal: true,
|
||||
ReAuthAccountModal: true,
|
||||
AccountTestModal: true,
|
||||
AccountStatsModal: true,
|
||||
ScheduledTestsPanel: true,
|
||||
SyncFromCrsModal: true,
|
||||
TempUnschedStatusModal: true,
|
||||
ErrorPassthroughRulesModal: true,
|
||||
TLSFingerprintProfilesModal: true,
|
||||
CreateAccountModal: true,
|
||||
EditAccountModal: true,
|
||||
BulkEditAccountModal: true,
|
||||
PlatformTypeBadge: true,
|
||||
AccountCapacityCell: true,
|
||||
AccountStatusIndicator: true,
|
||||
AccountTodayStatsCell: true,
|
||||
AccountGroupsCell: true,
|
||||
AccountUsageCell: true,
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin AccountsView select all filtered results', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
listAccounts.mockReset()
|
||||
listWithEtag.mockReset()
|
||||
getBatchTodayStats.mockReset()
|
||||
getUpstreamBillingProbeSettings.mockReset()
|
||||
getAllProxies.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
showError.mockReset()
|
||||
|
||||
listWithEtag.mockResolvedValue({
|
||||
notModified: true,
|
||||
etag: null,
|
||||
data: null
|
||||
})
|
||||
getBatchTodayStats.mockResolvedValue({ stats: {} })
|
||||
getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 })
|
||||
getAllProxies.mockResolvedValue([])
|
||||
getAllGroups.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('selects all matching IDs in one commit and clears the selection when filters change', async () => {
|
||||
const allAccounts = makeAccounts(45)
|
||||
listAccounts.mockImplementation(async (_page: number, pageSize: number) => {
|
||||
if (pageSize === 1000) {
|
||||
return {
|
||||
items: allAccounts,
|
||||
total: 45,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: allAccounts.slice(0, 20),
|
||||
total: 45,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 3
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="select-all-results"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('45')
|
||||
expect(wrapper.get('[data-test="total-results"]').text()).toBe('45')
|
||||
expect(wrapper.get('[data-test="all-results-selected"]').text()).toBe('true')
|
||||
expect(listAccounts).toHaveBeenCalledWith(1, 1000, expect.objectContaining({
|
||||
lite: '1',
|
||||
include_scheduler_score: '0'
|
||||
}))
|
||||
|
||||
await wrapper.get('[data-test="change-filter"]').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('0')
|
||||
expect(wrapper.get('[data-test="all-results-selected"]').text()).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps the original page selection when loading all results fails', async () => {
|
||||
const currentPage = makeAccounts(20)
|
||||
listAccounts.mockImplementation(async (_page: number, pageSize: number) => {
|
||||
if (pageSize === 1000) {
|
||||
throw new Error('load all failed')
|
||||
}
|
||||
return {
|
||||
items: currentPage,
|
||||
total: 45,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 3
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-test="select-page"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('20')
|
||||
|
||||
await wrapper.get('[data-test="select-all-results"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="selected-count"]').text()).toBe('20')
|
||||
expect(wrapper.get('[data-test="all-results-selected"]').text()).toBe('false')
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.bulkActions.selectAllFailed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user