feat(admin): 支持按筛选结果全选账号

This commit is contained in:
Litong
2026-07-30 18:07:27 +08:00
parent a35ff9613e
commit 2a871ec852
9 changed files with 533 additions and 18 deletions
+13
View File
@@ -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')
})
})
+45
View File
@@ -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
}
+76 -3
View File
@@ -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')
})
})