mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
feat(admin): 支持按筛选结果全选账号
This commit is contained in:
@@ -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