feat(pool): 批量操作对话框改为服务端分页筛选,新增凭据导出功能

- 批量操作对话框从全量加载改为服务端分页+筛选,支持搜索和快捷选择器的服务端过滤
- 新增 resolve-selection API,支持"全选筛选结果"时解析完整匹配列表
- 新增批量导出凭据功能(仅 OAuth 账号),并发下载后导出为 JSON 文件
- 将快捷选择器和全文搜索的匹配逻辑从前端迁移到后端,统一复用
- 提取 pool key 序列化与过滤的公共函数,消除 AdminListPoolKeysAdapter 中的重复代码
- entrypoint.sh 增加 PostgreSQL 就绪等待,避免数据库未启动时迁移失败
This commit is contained in:
fawney19
2026-03-11 18:18:59 +08:00
parent 1a1bce3e8c
commit b45f021bba
6 changed files with 994 additions and 492 deletions

View File

@@ -169,6 +169,24 @@ export interface PoolKeysQuery {
page_size?: number
search?: string
status?: 'all' | 'active' | 'cooldown' | 'inactive'
quick_selectors?: string[]
search_scope?: 'name' | 'full'
}
export interface PoolKeySelectionRequest {
search?: string
quick_selectors?: string[]
}
export interface PoolKeySelectionItem {
key_id: string
key_name: string
auth_type: string
}
export interface PoolKeySelectionResponse {
total: number
items: PoolKeySelectionItem[]
}
export interface PoolBatchAction {
@@ -203,13 +221,29 @@ export async function listPoolKeys(
providerId: string,
params: PoolKeysQuery = {},
): Promise<PoolKeysPageResponse> {
const key = `pool:keys:${providerId}|${params.page ?? ''}|${params.page_size ?? ''}|${params.search ?? ''}|${params.status ?? ''}`
const normalizedParams = {
...params,
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
}
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}`
return dedupedRequest(key, async () => {
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params })
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
return response.data
})
}
export async function resolvePoolKeySelection(
providerId: string,
body: PoolKeySelectionRequest,
): Promise<PoolKeySelectionResponse> {
const response = await client.post<PoolKeySelectionResponse>(
`/api/admin/pool/${providerId}/keys/resolve-selection`,
body,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data
}
export async function batchActionPoolKeys(
providerId: string,
body: PoolBatchAction,

View File

@@ -15,7 +15,7 @@
placeholder="快捷多选"
trigger-class="h-8 w-40"
dropdown-min-width="10rem"
:disabled="loading || executing || allKeys.length === 0"
:disabled="loading || executing"
@update:model-value="onQuickSelectChange"
/>
<Input
@@ -29,7 +29,7 @@
size="icon"
class="h-8 w-8 shrink-0"
:disabled="loading || executing"
@click="loadAllKeys()"
@click="loadKeysPage()"
>
<RefreshCw
class="h-3.5 w-3.5"
@@ -67,13 +67,13 @@
<div class="flex items-center justify-between text-xs">
<div class="text-muted-foreground">
{{ allKeys.length }} 个账号筛选 {{ filteredKeys.length }} 已选 {{ selectedKeyIds.length }}
{{ filteredTotal }} 匹配账号当前页 {{ pageKeys.length }} 已选 {{ selectedCount }}
</div>
<div class="flex items-center gap-2">
<Checkbox
:checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected"
:disabled="filteredKeys.length === 0 || loading || executing"
:disabled="filteredTotal === 0 || loading || executing"
@update:checked="toggleSelectFiltered"
/>
<span class="text-muted-foreground">全选筛选结果</span>
@@ -88,19 +88,19 @@
正在加载账号列表...
</div>
<div
v-else-if="filteredKeys.length === 0"
v-else-if="pageKeys.length === 0"
class="py-10 text-center text-sm text-muted-foreground"
>
无匹配账号
</div>
<label
v-for="key in pagedKeys"
v-for="key in pageKeys"
:key="key.key_id"
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
>
<Checkbox
:checked="selectedIdSet.has(key.key_id)"
:disabled="executing"
:checked="selectAllFiltered || selectedIdSet.has(key.key_id)"
:disabled="executing || selectAllFiltered"
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
/>
<div class="min-w-0 flex-1">
@@ -151,7 +151,7 @@
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="currentPage = 1"
@click="goToPage(1)"
>
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
@@ -160,7 +160,7 @@
size="icon"
class="h-7 w-7"
:disabled="currentPage <= 1"
@click="currentPage -= 1"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
@@ -169,7 +169,7 @@
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="currentPage += 1"
@click="goToPage(currentPage + 1)"
>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
@@ -178,7 +178,7 @@
size="icon"
class="h-7 w-7"
:disabled="currentPage >= totalPages"
@click="currentPage = totalPages"
@click="goToPage(totalPages)"
>
<ChevronsRight class="h-3.5 w-3.5" />
</Button>
@@ -205,7 +205,7 @@
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="executing || selectedKeyIds.length === 0 || loading"
:disabled="executing || selectedCount === 0 || loading"
@click="executeAction"
>
<Play
@@ -258,7 +258,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Dialog, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Badge } from '@/components/ui'
import { MultiSelect } from '@/components/common'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
@@ -266,11 +266,17 @@ import { RefreshCw, Play, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
import { listPoolKeys, batchActionPoolKeys, getPoolBatchDeleteTask, type PoolKeyDetail } from '@/api/endpoints/pool'
import { refreshProviderQuota } from '@/api/endpoints/keys'
import {
listPoolKeys,
batchActionPoolKeys,
getPoolBatchDeleteTask,
resolvePoolKeySelection,
type PoolKeyDetail,
type PoolKeySelectionItem,
} from '@/api/endpoints/pool'
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
type QuickSelectorValue =
| 'banned'
@@ -285,6 +291,7 @@ type QuickSelectorValue =
| 'enabled'
type BatchActionValue =
| 'export'
| 'delete'
| 'refresh_oauth'
| 'refresh_quota'
@@ -297,6 +304,7 @@ const props = defineProps<{
modelValue: boolean
providerId: string
providerName?: string
providerType?: string
batchConcurrency?: number | null
}>()
@@ -319,6 +327,7 @@ const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }>
]
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
{ value: 'export', label: '导出凭据' },
{ value: 'delete', label: '删除账号' },
{ value: 'refresh_oauth', label: '刷新 OAuth' },
{ value: 'refresh_quota', label: '刷新额度' },
@@ -334,8 +343,11 @@ const proxyNodesStore = useProxyNodesStore()
const loading = ref(false)
const executing = ref(false)
const allKeys = ref<PoolKeyDetail[]>([])
const pageKeys = ref<PoolKeyDetail[]>([])
const filteredTotal = ref(0)
const selectedKeyIds = ref<string[]>([])
const knownKeysById = ref<Record<string, PoolKeyDetail>>({})
const selectAllFiltered = ref(false)
const searchText = ref('')
const selectedAction = ref<BatchActionValue>('delete')
const proxyNodeIdForAction = ref('')
@@ -345,53 +357,61 @@ const progressDone = ref(0)
const progressLabel = ref('')
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
const currentPage = ref(1)
const PAGE_SIZE = 50
const SEARCH_DEBOUNCE_MS = 250
let loadRequestId = 0
let searchDebounceTimer: number | null = null
let suppressFilterWatch = false
const dialogDescription = computed(() => {
const name = (props.providerName || '').trim()
return name ? `${name} - 选择账号并批量执行动作` : '选择账号并批量执行动作'
})
const selectedIdSet = computed(() => new Set(selectedKeyIds.value))
const filteredKeys = computed(() => {
const keyword = normalizeText(searchText.value)
if (!keyword) return allKeys.value
return allKeys.value.filter((key) => {
const parts = [
key.key_name,
key.auth_type,
key.oauth_plan_type,
key.account_quota,
key.proxy?.node_id ? '独立代理' : '未配置代理',
key.is_active ? '已启用' : '已禁用',
key.oauth_invalid_reason,
]
return parts.some((part) => normalizeText(part).includes(keyword))
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredKeys.value.length / PAGE_SIZE)))
const pagedKeys = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return filteredKeys.value.slice(start, start + PAGE_SIZE)
})
const isAllFilteredSelected = computed(() => {
if (filteredKeys.value.length === 0) return false
return filteredKeys.value.every((key) => selectedIdSet.value.has(key.key_id))
})
const isPartiallyFilteredSelected = computed(() => {
if (filteredKeys.value.length === 0) return false
const selectedCount = filteredKeys.value.filter((key) => selectedIdSet.value.has(key.key_id)).length
return selectedCount > 0 && selectedCount < filteredKeys.value.length
})
const selectedCount = computed(() => (selectAllFiltered.value ? filteredTotal.value : selectedKeyIds.value.length))
const totalPages = computed(() => Math.max(1, Math.ceil(filteredTotal.value / PAGE_SIZE)))
const isAllFilteredSelected = computed(() => selectAllFiltered.value && filteredTotal.value > 0)
const isPartiallyFilteredSelected = computed(() => !selectAllFiltered.value && selectedKeyIds.value.length > 0)
function normalizeText(value: unknown): string {
return String(value || '').trim().toLowerCase()
}
function sanitizeFileNamePart(value: unknown, fallback: string): string {
const sanitized = String(value || '')
.trim()
.replace(/[^a-zA-Z0-9_\-@.]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
return sanitized || fallback
}
function formatExportTimestamp(date: Date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
function getBatchExportFilename(): string {
const providerType = sanitizeFileNamePart(props.providerType || 'pool', 'pool')
const providerName = sanitizeFileNamePart(props.providerName || props.providerId.slice(0, 8), 'provider')
return `aether_${providerType}_${providerName}_batch_export_${formatExportTimestamp()}.json`
}
function downloadJsonFile(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function normalizeAuthTypeLabel(authType: string): string {
const text = normalizeText(authType)
if (text === 'oauth') return 'OAuth'
@@ -411,14 +431,6 @@ function isBannedKey(key: PoolKeyDetail): boolean {
return false
}
function hasNoFiveHourQuota(key: PoolKeyDetail): boolean {
return hasNoFiveHourLimitByQuota(key.account_quota)
}
function hasNoWeeklyQuota(key: PoolKeyDetail): boolean {
return hasNoWeeklyLimitByQuota(key.account_quota)
}
function isOAuthInvalid(key: PoolKeyDetail): boolean {
if (normalizeText(key.auth_type) !== 'oauth') return false
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
@@ -428,70 +440,6 @@ function isOAuthInvalid(key: PoolKeyDetail): boolean {
return false
}
function isFreePlan(key: PoolKeyDetail): boolean {
return normalizeText(key.oauth_plan_type).includes('free')
}
function isTeamPlan(key: PoolKeyDetail): boolean {
return normalizeText(key.oauth_plan_type).includes('team')
}
function toggleOne(keyId: string, checked: boolean): void {
const set = new Set(selectedKeyIds.value)
if (checked) set.add(keyId)
else set.delete(keyId)
selectedKeyIds.value = [...set]
}
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
const shouldSelect = checked === true
const set = new Set(selectedKeyIds.value)
if (shouldSelect) {
for (const key of filteredKeys.value) set.add(key.key_id)
} else {
for (const key of filteredKeys.value) set.delete(key.key_id)
}
selectedKeyIds.value = [...set]
}
function matchesSelector(key: PoolKeyDetail, selector: QuickSelectorValue): boolean {
if (selector === 'banned') return isBannedKey(key)
if (selector === 'no_5h_limit') return hasNoFiveHourQuota(key)
if (selector === 'no_weekly_limit') return hasNoWeeklyQuota(key)
if (selector === 'plan_free') return isFreePlan(key)
if (selector === 'plan_team') return isTeamPlan(key)
if (selector === 'oauth_invalid') return isOAuthInvalid(key)
if (selector === 'proxy_unset') return !key.proxy?.node_id
if (selector === 'proxy_set') return Boolean(key.proxy?.node_id)
if (selector === 'disabled') return !key.is_active
if (selector === 'enabled') return key.is_active
return false
}
function onQuickSelectChange(values: string[]): void {
activeQuickSelectors.value = values as QuickSelectorValue[]
applyQuickSelectors()
}
function removeQuickSelector(selector: QuickSelectorValue): void {
const idx = activeQuickSelectors.value.indexOf(selector)
if (idx >= 0) {
activeQuickSelectors.value.splice(idx, 1)
applyQuickSelectors()
}
}
function applyQuickSelectors(): void {
if (activeQuickSelectors.value.length === 0) {
selectedKeyIds.value = []
return
}
const matched = allKeys.value.filter((key) =>
activeQuickSelectors.value.some((sel) => matchesSelector(key, sel))
)
selectedKeyIds.value = matched.map((key) => key.key_id)
}
function formatRelativeTime(value: string): string {
const ts = new Date(value).getTime()
if (!Number.isFinite(ts)) return '-'
@@ -503,73 +451,149 @@ function formatRelativeTime(value: string): string {
}
function shortenQuota(raw: string): string {
// "周剩余 0.0%5天3小时后重置|5H剩余100.05小时0分钟后重置"
// -> "周0.0% 5d3h | 5H100.0% 5h"
return raw.split('|').map((seg) => {
let s = seg.trim()
s = s.replace(/剩余\s*/g, '')
s = s.replace(//g, '%')
s = s.replace(/[(]\s*(\d+)\s*天\s*(\d+)\s*小时.*?[)]/g, ' $1d$2h')
s = s.replace(/[(]\s*(\d+)\s*小时\s*(\d+)\s*分钟.*?[)]/g, ' $1h$2m')
s = s.replace(/[(]\s*(\d+)\s*小时.*?[)]/g, ' $1h')
s = s.replace(/[(]\s*(\d+)\s*分钟.*?[)]/g, ' $1m')
s = s.replace(/[(]\s*(\d+)\s*天.*?[)]/g, ' $1d')
s = s.replace(/[(].*?[)]/g, '')
return s.trim()
return raw.split('|').map((segment) => {
let value = segment.trim()
value = value.replace(/剩余\s*/g, '')
value = value.replace(//g, '%')
value = value.replace(/[(]\s*(\d+)\s*天\s*(\d+)\s*小时.*?[)]/g, ' $1d$2h')
value = value.replace(/[(]\s*(\d+)\s*小时\s*(\d+)\s*分钟.*?[)]/g, ' $1h$2m')
value = value.replace(/[(]\s*(\d+)\s*小时.*?[)]/g, ' $1h')
value = value.replace(/[(]\s*(\d+)\s*分钟.*?[)]/g, ' $1m')
value = value.replace(/[(]\s*(\d+)\s*.*?[)]/g, ' $1d')
value = value.replace(/[(].*?[)]/g, '')
return value.trim()
}).join(' | ')
}
async function loadAllKeys(): Promise<void> {
function clearSearchDebounce(): void {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
function rememberPageKeys(keys: PoolKeyDetail[]): void {
if (keys.length === 0) return
const next = { ...knownKeysById.value }
for (const key of keys) {
next[key.key_id] = key
}
knownKeysById.value = next
}
function resetSelection(clearKnown = false): void {
selectAllFiltered.value = false
selectedKeyIds.value = []
if (clearKnown) knownKeysById.value = {}
}
function buildSelectionFilters(): { search?: string; quick_selectors?: string[] } {
const search = searchText.value.trim()
const quickSelectors = activeQuickSelectors.value.map((value) => String(value))
return {
...(search ? { search } : {}),
...(quickSelectors.length > 0 ? { quick_selectors: quickSelectors } : {}),
}
}
async function loadKeysPage(): Promise<void> {
if (!props.providerId) {
allKeys.value = []
selectedKeyIds.value = []
pageKeys.value = []
filteredTotal.value = 0
resetSelection(true)
return
}
const requestId = ++loadRequestId
loading.value = true
const startedAt = performance.now()
let fetchedPages = 0
let total = 0
let loadedCount = 0
let ok = false
try {
const pageSize = 200
let page = 1
const collected: PoolKeyDetail[] = []
const res = await listPoolKeys(props.providerId, {
page: currentPage.value,
page_size: PAGE_SIZE,
status: 'all',
search: searchText.value.trim() || undefined,
quick_selectors: activeQuickSelectors.value,
search_scope: 'full',
})
if (requestId !== loadRequestId) return
while (page <= 50) {
const res = await listPoolKeys(props.providerId, {
page,
page_size: pageSize,
status: 'all',
})
fetchedPages = page
const keys = Array.isArray(res.keys) ? res.keys : []
collected.push(...keys)
total = Number(res.total || 0)
if (keys.length < pageSize || collected.length >= total) break
page += 1
}
allKeys.value = collected
loadedCount = collected.length
const validIds = new Set(collected.map((key) => key.key_id))
selectedKeyIds.value = selectedKeyIds.value.filter((id) => validIds.has(id))
pageKeys.value = Array.isArray(res.keys) ? res.keys : []
filteredTotal.value = Number(res.total || 0)
rememberPageKeys(pageKeys.value)
ok = true
} catch (err) {
if (requestId !== loadRequestId) return
pageKeys.value = []
filteredTotal.value = 0
showError(parseApiError(err, '加载账号列表失败'))
allKeys.value = []
selectedKeyIds.value = []
} finally {
loading.value = false
// eslint-disable-next-line no-console
console.info('[PoolAccountBatchDialog] loadAllKeys timing', {
providerId: props.providerId,
ok,
fetchedPages,
total,
loadedCount,
durationMs: Math.round(performance.now() - startedAt),
})
if (requestId === loadRequestId) {
loading.value = false
// eslint-disable-next-line no-console
console.info('[PoolAccountBatchDialog] loadKeysPage timing', {
providerId: props.providerId,
page: currentPage.value,
pageSize: PAGE_SIZE,
search: searchText.value.trim(),
quickSelectors: activeQuickSelectors.value,
total: filteredTotal.value,
count: pageKeys.value.length,
ok,
durationMs: Math.round(performance.now() - startedAt),
})
}
}
}
function requestFilteredReload(debounceMs = 0): void {
if (!props.modelValue) return
clearSearchDebounce()
resetSelection()
lastResultMessage.value = ''
const run = () => {
searchDebounceTimer = null
currentPage.value = 1
void loadKeysPage()
}
if (debounceMs > 0) {
searchDebounceTimer = window.setTimeout(run, debounceMs)
} else {
run()
}
}
async function goToPage(page: number): Promise<void> {
const nextPage = Math.min(Math.max(1, page), totalPages.value)
currentPage.value = nextPage
await loadKeysPage()
}
function toggleOne(keyId: string, checked: boolean): void {
const set = new Set(selectedKeyIds.value)
if (checked) set.add(keyId)
else set.delete(keyId)
selectedKeyIds.value = [...set]
}
function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
selectAllFiltered.value = checked === true
if (selectAllFiltered.value) {
selectedKeyIds.value = []
}
}
function onQuickSelectChange(values: string[]): void {
activeQuickSelectors.value = values as QuickSelectorValue[]
requestFilteredReload()
}
function removeQuickSelector(selector: QuickSelectorValue): void {
const idx = activeQuickSelectors.value.indexOf(selector)
if (idx >= 0) {
activeQuickSelectors.value.splice(idx, 1)
requestFilteredReload()
}
}
@@ -598,29 +622,42 @@ async function pollDeleteTask(
return { status: 'failed', deleted: 0 }
}
}
await new Promise((r) => setTimeout(r, DELETE_POLL_INTERVAL_MS))
await new Promise((resolve) => setTimeout(resolve, DELETE_POLL_INTERVAL_MS))
}
return { status: 'failed', deleted: 0 }
}
async function resolveSelectedItems(): Promise<PoolKeySelectionItem[]> {
if (!props.providerId) return []
if (selectAllFiltered.value) {
progressLabel.value = '正在解析筛选结果...'
const result = await resolvePoolKeySelection(props.providerId, buildSelectionFilters())
return Array.isArray(result.items) ? result.items : []
}
return selectedKeyIds.value.map((keyId) => {
const key = knownKeysById.value[keyId]
return {
key_id: keyId,
key_name: key?.key_name || '',
auth_type: key?.auth_type || 'api_key',
}
})
}
async function executeAction(): Promise<void> {
if (executing.value) return
if (selectedKeyIds.value.length === 0) {
if (selectedCount.value === 0) {
warning('请先选择账号')
return
}
const selectedMap = new Set(selectedKeyIds.value)
const selectedKeys = allKeys.value.filter((key) => selectedMap.has(key.key_id))
if (selectedKeys.length === 0) {
warning('未找到可执行账号,请刷新列表重试')
return
}
const requestedCount = selectedCount.value
if (selectedAction.value === 'delete') {
const confirmed = await confirm({
title: '删除账号',
message: `将删除 ${selectedKeys.length} 个账号,操作不可恢复,是否继续?`,
message: `将删除 ${requestedCount} 个账号,操作不可恢复,是否继续?`,
confirmText: '确认删除',
variant: 'destructive',
})
@@ -636,17 +673,29 @@ async function executeAction(): Promise<void> {
let successCount = 0
let failedCount = 0
let skippedCount = 0
let resolvedCount = 0
const actionStartedAt = performance.now()
let actionPhaseMs = 0
let reloadPhaseMs = 0
const actionLabel = ACTION_OPTIONS.find((a) => a.value === selectedAction.value)?.label || '执行'
const actionLabel = ACTION_OPTIONS.find((item) => item.value === selectedAction.value)?.label || '执行'
progressDone.value = 0
progressTotal.value = selectedKeys.length
progressLabel.value = `正在${actionLabel}...`
progressTotal.value = 0
progressLabel.value = selectAllFiltered.value ? '正在解析筛选结果...' : `正在${actionLabel}...`
lastResultMessage.value = ''
try {
const selectedKeys = await resolveSelectedItems()
resolvedCount = selectedKeys.length
if (selectedKeys.length === 0) {
warning('未找到可执行账号,请刷新列表重试')
return
}
progressDone.value = 0
progressTotal.value = selectedKeys.length
progressLabel.value = `正在${actionLabel}...`
if (selectedAction.value === 'refresh_quota') {
const targetIds = selectedKeys.map((key) => key.key_id)
const BATCH_SIZE = 20
@@ -668,8 +717,47 @@ async function executeAction(): Promise<void> {
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
}
} else if (selectedAction.value === 'export') {
const exportableKeys = selectedKeys.filter((key) => normalizeText(key.auth_type) === 'oauth')
const exportedEntries: Array<Record<string, unknown> | null> = Array.from({ length: exportableKeys.length }, () => null)
skippedCount += selectedKeys.length - exportableKeys.length
progressDone.value = 0
progressTotal.value = exportableKeys.length
if (skippedCount > 0) {
progressLabel.value = `正在${actionLabel}...(跳过 ${skippedCount} 个非 OAuth 账号)`
}
let cursor = 0
const CONCURRENCY = props.batchConcurrency || 8
const runNext = async (): Promise<void> => {
while (cursor < exportableKeys.length) {
const idx = cursor++
const key = exportableKeys[idx]
try {
exportedEntries[idx] = await exportKey(key.key_id)
successCount += 1
} catch (err) {
failedCount += 1
// eslint-disable-next-line no-console
console.error(`[PoolAccountBatchDialog] export failed (${key.key_id}):`, err)
} finally {
progressDone.value += 1
}
}
}
const workers = Array.from(
{ length: Math.min(CONCURRENCY, exportableKeys.length) },
() => runNext(),
)
await Promise.all(workers)
const exportedData = exportedEntries.filter((item): item is Record<string, unknown> => item !== null)
if (exportedData.length > 0) {
downloadJsonFile(exportedData, getBatchExportFilename())
}
} else if (selectedAction.value === 'delete') {
// 删除走异步任务模式:提交后轮询进度
const targetIds = selectedKeys.map((key) => key.key_id)
const BATCH_SIZE = 2000
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
@@ -688,7 +776,6 @@ async function executeAction(): Promise<void> {
})
if (result.task_id) {
// 异步任务:轮询进度
progressLabel.value = `正在${actionLabel}...(后台执行中)`
const taskResult = await pollDeleteTask(props.providerId, result.task_id, i)
successCount += taskResult.deleted
@@ -738,7 +825,6 @@ async function executeAction(): Promise<void> {
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
}
} else {
// refresh_oauth: 逐个并发调用(涉及外部 OAuth 令牌刷新)
const CONCURRENCY = props.batchConcurrency || 8
const tasks: Array<() => Promise<'success' | 'skip'>> = []
for (const key of selectedKeys) {
@@ -769,24 +855,22 @@ async function executeAction(): Promise<void> {
}
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount},跳过 ${skippedCount}`
if (failedCount > 0) warning(lastResultMessage.value)
if (failedCount > 0 || (selectedAction.value === 'export' && successCount === 0)) warning(lastResultMessage.value)
else success(lastResultMessage.value)
actionPhaseMs = performance.now() - actionStartedAt
const reloadStartedAt = performance.now()
if (selectedAction.value === 'delete' && successCount > 0 && failedCount === 0) {
// 全部删除成功:直接从本地列表移除,不做全量重载
const deletedIds = new Set(selectedKeys.map((key) => key.key_id))
allKeys.value = allKeys.value.filter((key) => !deletedIds.has(key.key_id))
selectedKeyIds.value = []
} else {
const previousSelection = new Set(selectedKeyIds.value)
await loadAllKeys()
const existingIds = new Set(allKeys.value.map((key) => key.key_id))
selectedKeyIds.value = [...previousSelection].filter((id) => existingIds.has(id))
if (selectedAction.value !== 'export') {
const reloadStartedAt = performance.now()
if (selectedAction.value === 'delete' && successCount > 0) {
resetSelection(true)
}
await loadKeysPage()
if (pageKeys.value.length === 0 && filteredTotal.value > 0 && currentPage.value > totalPages.value) {
await goToPage(totalPages.value)
}
reloadPhaseMs = performance.now() - reloadStartedAt
emit('changed')
}
reloadPhaseMs = performance.now() - reloadStartedAt
emit('changed')
} catch (err) {
showError(parseApiError(err, '批量操作失败'))
} finally {
@@ -794,7 +878,8 @@ async function executeAction(): Promise<void> {
console.info('[PoolAccountBatchDialog] executeAction timing', {
providerId: props.providerId,
action: selectedAction.value,
selectedCount: selectedKeys.length,
requestedCount,
resolvedCount,
successCount,
failedCount,
skippedCount,
@@ -809,15 +894,29 @@ async function executeAction(): Promise<void> {
}
}
watch(searchText, () => {
if (suppressFilterWatch || !props.modelValue) return
requestFilteredReload(SEARCH_DEBOUNCE_MS)
})
watch(
() => props.modelValue,
(open) => {
if (!open) return
if (!open) {
clearSearchDebounce()
return
}
suppressFilterWatch = true
searchText.value = ''
lastResultMessage.value = ''
activeQuickSelectors.value = []
resetSelection(true)
filteredTotal.value = 0
pageKeys.value = []
currentPage.value = 1
suppressFilterWatch = false
proxyNodesStore.ensureLoaded()
loadAllKeys()
void loadKeysPage()
},
)
@@ -825,12 +924,18 @@ watch(
() => props.providerId,
(newId, oldId) => {
if (!props.modelValue || !newId || newId === oldId) return
selectedKeyIds.value = []
loadAllKeys()
clearSearchDebounce()
suppressFilterWatch = true
resetSelection(true)
filteredTotal.value = 0
pageKeys.value = []
currentPage.value = 1
suppressFilterWatch = false
void loadKeysPage()
},
)
watch(filteredKeys, () => {
currentPage.value = 1
onBeforeUnmount(() => {
clearSearchDebounce()
})
</script>

View File

@@ -1061,6 +1061,7 @@
v-model="showAccountBatchDialog"
:provider-id="selectedProviderId"
:provider-name="selectedProviderData?.name || ''"
:provider-type="selectedProviderData?.provider_type || selectedProviderType"
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
@changed="handleAccountBatchChanged"
/>