mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
refactor(pool): share batch selection helpers
This commit is contained in:
89
frontend/src/composables/useBatchSelection.ts
Normal file
89
frontend/src/composables/useBatchSelection.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { computed, ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export function useBatchSelection<TItem>(options: {
|
||||||
|
pageItems: Ref<TItem[]>
|
||||||
|
filteredTotal: Ref<number>
|
||||||
|
getItemId: (item: TItem) => string
|
||||||
|
}) {
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
|
const selectAllFiltered = ref(false)
|
||||||
|
const knownItemsById = ref<Record<string, TItem>>({})
|
||||||
|
|
||||||
|
const selectedIdSet = computed(() => new Set(selectedIds.value))
|
||||||
|
const selectedCount = computed(() => (
|
||||||
|
selectAllFiltered.value ? options.filteredTotal.value : selectedIds.value.length
|
||||||
|
))
|
||||||
|
const isAllFilteredSelected = computed(() => (
|
||||||
|
selectAllFiltered.value && options.filteredTotal.value > 0
|
||||||
|
))
|
||||||
|
const isPartiallyFilteredSelected = computed(() => (
|
||||||
|
!selectAllFiltered.value && selectedIds.value.length > 0
|
||||||
|
))
|
||||||
|
const isCurrentPageFullySelected = computed(() => {
|
||||||
|
const pageIds = options.pageItems.value.map(options.getItemId)
|
||||||
|
return pageIds.length > 0 && pageIds.every((id) => selectedIdSet.value.has(id))
|
||||||
|
})
|
||||||
|
const canClearSelection = computed(() => selectAllFiltered.value || selectedIds.value.length > 0)
|
||||||
|
|
||||||
|
function rememberItems(items: TItem[]): void {
|
||||||
|
if (items.length === 0) return
|
||||||
|
const next = { ...knownItemsById.value }
|
||||||
|
for (const item of items) {
|
||||||
|
next[options.getItemId(item)] = item
|
||||||
|
}
|
||||||
|
knownItemsById.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSelection(clearKnown = false): void {
|
||||||
|
selectAllFiltered.value = false
|
||||||
|
selectedIds.value = []
|
||||||
|
if (clearKnown) knownItemsById.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOne(id: string, checked: boolean): void {
|
||||||
|
if (selectAllFiltered.value) return
|
||||||
|
const set = new Set(selectedIds.value)
|
||||||
|
if (checked) set.add(id)
|
||||||
|
else set.delete(id)
|
||||||
|
selectedIds.value = [...set]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
|
||||||
|
selectAllFiltered.value = checked === true
|
||||||
|
if (selectAllFiltered.value) selectedIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectCurrentPage(): void {
|
||||||
|
if (selectAllFiltered.value || options.pageItems.value.length === 0) return
|
||||||
|
const set = new Set(selectedIds.value)
|
||||||
|
const pageIds = options.pageItems.value.map(options.getItemId)
|
||||||
|
const shouldUnselect = pageIds.every((id) => set.has(id))
|
||||||
|
for (const id of pageIds) {
|
||||||
|
if (shouldUnselect) set.delete(id)
|
||||||
|
else set.add(id)
|
||||||
|
}
|
||||||
|
selectedIds.value = [...set]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
resetSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedIds,
|
||||||
|
selectAllFiltered,
|
||||||
|
knownItemsById,
|
||||||
|
selectedIdSet,
|
||||||
|
selectedCount,
|
||||||
|
isAllFilteredSelected,
|
||||||
|
isPartiallyFilteredSelected,
|
||||||
|
isCurrentPageFullySelected,
|
||||||
|
canClearSelection,
|
||||||
|
rememberItems,
|
||||||
|
resetSelection,
|
||||||
|
toggleOne,
|
||||||
|
toggleSelectFiltered,
|
||||||
|
toggleSelectCurrentPage,
|
||||||
|
clearSelection,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -301,6 +301,7 @@ import {
|
|||||||
getOAuthStatusTitle,
|
getOAuthStatusTitle,
|
||||||
} from '@/utils/providerKeyStatus'
|
} from '@/utils/providerKeyStatus'
|
||||||
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
|
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
|
||||||
|
import { runChunkedBatchAction } from '@/utils/batchAction'
|
||||||
|
|
||||||
type QuickSelectorValue =
|
type QuickSelectorValue =
|
||||||
| 'banned'
|
| 'banned'
|
||||||
@@ -839,24 +840,20 @@ async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
|
|||||||
if (selectedAction.value === 'refresh_quota') {
|
if (selectedAction.value === 'refresh_quota') {
|
||||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||||
const BATCH_SIZE = 20
|
const BATCH_SIZE = 20
|
||||||
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
const counts = await runChunkedBatchAction({
|
||||||
|
items: targetIds,
|
||||||
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
chunkSize: BATCH_SIZE,
|
||||||
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
|
runChunk: (batch) => refreshProviderQuota(props.providerId, batch),
|
||||||
const batch = targetIds.slice(i, i + BATCH_SIZE)
|
onChunkStart: ({ batchIndex, totalBatches }) => {
|
||||||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||||||
|
},
|
||||||
try {
|
onChunkDone: ({ processed }) => {
|
||||||
const result = await refreshProviderQuota(props.providerId, batch)
|
progressDone.value = processed
|
||||||
successCount += Number(result.success || 0)
|
},
|
||||||
failedCount += Number(result.failed || 0)
|
})
|
||||||
skippedCount += Math.max(0, batch.length - Number(result.total || 0))
|
successCount += counts.success
|
||||||
} catch {
|
failedCount += counts.failed
|
||||||
failedCount += batch.length
|
skippedCount += counts.skipped
|
||||||
}
|
|
||||||
|
|
||||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
|
||||||
}
|
|
||||||
} else if (selectedAction.value === 'export') {
|
} else if (selectedAction.value === 'export') {
|
||||||
const exportableKeys = selectedKeys.filter((key) => canExportOAuthCredential(key))
|
const exportableKeys = selectedKeys.filter((key) => canExportOAuthCredential(key))
|
||||||
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)
|
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)
|
||||||
|
|||||||
24
frontend/src/utils/__tests__/batchAction.spec.ts
Normal file
24
frontend/src/utils/__tests__/batchAction.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { runChunkedBatchAction } from '../batchAction'
|
||||||
|
|
||||||
|
describe('runChunkedBatchAction', () => {
|
||||||
|
it('counts unreported items as skipped when chunk total is omitted', async () => {
|
||||||
|
const counts = await runChunkedBatchAction({
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
chunkSize: 3,
|
||||||
|
runChunk: async () => ({ success: 1, failed: 1 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(counts).toEqual({ success: 1, failed: 1, skipped: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps legacy total-based skipped fallback when chunk total is reported', async () => {
|
||||||
|
const counts = await runChunkedBatchAction({
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
chunkSize: 3,
|
||||||
|
runChunk: async () => ({ total: 2, success: 1, failed: 0 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(counts).toEqual({ success: 1, failed: 0, skipped: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
61
frontend/src/utils/batchAction.ts
Normal file
61
frontend/src/utils/batchAction.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface BatchChunkCounts {
|
||||||
|
total?: number
|
||||||
|
success?: number
|
||||||
|
failed?: number
|
||||||
|
skipped?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchChunkProgress<TItem> {
|
||||||
|
batch: TItem[]
|
||||||
|
batchIndex: number
|
||||||
|
totalBatches: number
|
||||||
|
processed: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchActionCounts {
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runChunkedBatchAction<TItem>(options: {
|
||||||
|
items: TItem[]
|
||||||
|
chunkSize: number
|
||||||
|
runChunk: (batch: TItem[], context: BatchChunkProgress<TItem>) => Promise<BatchChunkCounts>
|
||||||
|
onChunkStart?: (context: BatchChunkProgress<TItem>) => void
|
||||||
|
onChunkDone?: (context: BatchChunkProgress<TItem>, counts: BatchChunkCounts) => void
|
||||||
|
}): Promise<BatchActionCounts> {
|
||||||
|
const chunkSize = Math.max(1, options.chunkSize)
|
||||||
|
const totalBatches = Math.ceil(options.items.length / chunkSize)
|
||||||
|
const counts: BatchActionCounts = { success: 0, failed: 0, skipped: 0 }
|
||||||
|
|
||||||
|
for (let offset = 0; offset < options.items.length; offset += chunkSize) {
|
||||||
|
const batch = options.items.slice(offset, offset + chunkSize)
|
||||||
|
const context: BatchChunkProgress<TItem> = {
|
||||||
|
batch,
|
||||||
|
batchIndex: Math.floor(offset / chunkSize) + 1,
|
||||||
|
totalBatches,
|
||||||
|
processed: Math.min(offset + batch.length, options.items.length),
|
||||||
|
total: options.items.length,
|
||||||
|
}
|
||||||
|
options.onChunkStart?.(context)
|
||||||
|
try {
|
||||||
|
const result = await options.runChunk(batch, context)
|
||||||
|
const success = Number(result.success ?? 0)
|
||||||
|
const failed = Number(result.failed ?? 0)
|
||||||
|
const skipped = result.skipped == null
|
||||||
|
? Math.max(0, batch.length - Number(result.total ?? success + failed))
|
||||||
|
: Number(result.skipped)
|
||||||
|
counts.success += success
|
||||||
|
counts.failed += failed
|
||||||
|
counts.skipped += skipped
|
||||||
|
options.onChunkDone?.(context, result)
|
||||||
|
} catch {
|
||||||
|
counts.failed += batch.length
|
||||||
|
options.onChunkDone?.(context, { total: batch.length, failed: batch.length })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user