mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
1342 lines
48 KiB
Vue
1342 lines
48 KiB
Vue
<template>
|
||
<Dialog
|
||
:model-value="modelValue"
|
||
title="账号批量操作"
|
||
:description="dialogDescription"
|
||
size="3xl"
|
||
persistent
|
||
@update:model-value="emit('update:modelValue', $event)"
|
||
>
|
||
<div class="max-h-[calc(100dvh-13rem)] space-y-4 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,44rem)] sm:pr-2">
|
||
<div class="space-y-3 rounded-lg border bg-muted/20 px-3 py-2.5">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<span class="text-xs font-medium text-foreground">快捷多选</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-7 px-2 text-[11px]"
|
||
:disabled="loading || executing || !hasActiveFilters"
|
||
@click="clearFilters"
|
||
>
|
||
重置筛选
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
<Button
|
||
v-for="option in QUICK_SELECT_OPTIONS"
|
||
:key="option.value"
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-8 px-2.5 text-[11px]"
|
||
:class="activeQuickSelectorSet.has(option.value) ? 'border-primary/70 bg-primary/10 text-primary' : ''"
|
||
:disabled="loading || executing"
|
||
@click="toggleQuickSelector(option.value)"
|
||
>
|
||
{{ option.label }}
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="space-y-3 rounded-md border bg-background/80 px-3 py-3">
|
||
<div class="flex items-center gap-2">
|
||
<Input
|
||
:model-value="searchText"
|
||
placeholder="搜索账号名 / 套餐 / 额度 / 代理状态"
|
||
class="h-8 flex-1"
|
||
@update:model-value="(v) => searchText = String(v || '')"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-8 w-8 shrink-0"
|
||
:disabled="loading || executing"
|
||
@click="loadKeysPage()"
|
||
>
|
||
<RefreshCw
|
||
class="h-3.5 w-3.5"
|
||
:class="loading ? 'animate-spin' : ''"
|
||
/>
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 text-xs lg:flex-row lg:items-center lg:justify-between">
|
||
<div class="text-muted-foreground">
|
||
共 {{ filteredTotal }} 个匹配账号,当前页 {{ pageKeyRows.length }} 个,已选 {{ selectedCount }} 个
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<div class="mr-1 flex items-center gap-2">
|
||
<Checkbox
|
||
:checked="isAllFilteredSelected"
|
||
:indeterminate="isPartiallyFilteredSelected"
|
||
:disabled="filteredTotal === 0 || loading || executing"
|
||
@update:checked="toggleSelectFiltered"
|
||
/>
|
||
<span class="text-muted-foreground">全选筛选结果</span>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-7 px-2 text-[11px]"
|
||
:disabled="pageKeyRows.length === 0 || loading || executing || selectAllFiltered"
|
||
@click="toggleSelectCurrentPage"
|
||
>
|
||
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-7 px-2 text-[11px]"
|
||
:disabled="!canClearSelection || loading || executing"
|
||
@click="clearSelection"
|
||
>
|
||
清空选择
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_19rem]">
|
||
<div class="min-w-0 space-y-3">
|
||
<div class="rounded-lg border lg:max-h-[420px] lg:overflow-y-auto">
|
||
<div
|
||
v-if="loading"
|
||
class="py-10 text-center text-sm text-muted-foreground"
|
||
>
|
||
正在加载账号列表...
|
||
</div>
|
||
<div
|
||
v-else-if="pageKeyRows.length === 0"
|
||
class="py-10 text-center text-sm text-muted-foreground"
|
||
>
|
||
无匹配账号
|
||
</div>
|
||
<label
|
||
v-for="row in pageKeyRows"
|
||
:key="row.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="selectAllFiltered || selectedIdSet.has(row.key.key_id)"
|
||
:disabled="executing || selectAllFiltered"
|
||
@update:checked="(checked) => toggleOne(row.key.key_id, checked === true)"
|
||
/>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-1.5">
|
||
<span class="text-xs font-medium truncate">{{ row.key.key_name || '未命名' }}</span>
|
||
<Badge
|
||
variant="outline"
|
||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||
>{{ row.authTypeLabel }}</Badge>
|
||
<Badge
|
||
v-if="row.statusBadgeLabel"
|
||
variant="destructive"
|
||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||
:title="row.statusBadgeTitle"
|
||
>{{ row.statusBadgeLabel }}</Badge>
|
||
<Badge
|
||
v-if="row.planLabel"
|
||
variant="outline"
|
||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||
>{{ row.planLabel }}</Badge>
|
||
<Badge
|
||
v-if="row.oauthOrgBadge"
|
||
variant="secondary"
|
||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||
:title="row.oauthOrgBadge.title"
|
||
>{{ row.oauthOrgBadge.label }}</Badge>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||
<span :class="row.key.is_active ? '' : 'text-destructive'">{{ row.key.is_active ? '启用' : '禁用' }}</span>
|
||
<span v-if="row.quotaText">{{ row.quotaTextShort }}</span>
|
||
<span v-if="row.key.proxy?.node_id">独立代理</span>
|
||
<span
|
||
v-if="row.lastUsedRelative"
|
||
class="ml-auto shrink-0"
|
||
>{{ row.lastUsedRelative }}</span>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div
|
||
v-if="totalPages > 1"
|
||
class="flex items-center justify-between text-xs text-muted-foreground"
|
||
>
|
||
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||
<div class="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-7 w-7"
|
||
:disabled="currentPage <= 1"
|
||
@click="goToPage(1)"
|
||
>
|
||
<ChevronsLeft class="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-7 w-7"
|
||
:disabled="currentPage <= 1"
|
||
@click="goToPage(currentPage - 1)"
|
||
>
|
||
<ChevronLeft class="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-7 w-7"
|
||
:disabled="currentPage >= totalPages"
|
||
@click="goToPage(currentPage + 1)"
|
||
>
|
||
<ChevronRight class="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-7 w-7"
|
||
:disabled="currentPage >= totalPages"
|
||
@click="goToPage(totalPages)"
|
||
>
|
||
<ChevronsRight class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3 lg:sticky lg:top-1 lg:self-start">
|
||
<div class="space-y-2 rounded-lg border bg-background px-3 py-3">
|
||
<div class="text-xs font-medium text-foreground">
|
||
执行动作
|
||
</div>
|
||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||
<Button
|
||
v-for="item in ACTION_OPTIONS"
|
||
:key="item.value"
|
||
class="h-9 w-full px-3 text-xs"
|
||
:variant="getActionButtonVariant(item)"
|
||
:disabled="!canSelectAction(item.value)"
|
||
@click="handleActionButtonClick(item.value)"
|
||
>
|
||
{{ item.label }}
|
||
</Button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="selectedAction === 'set_proxy'"
|
||
class="space-y-2 border-t border-border/60 pt-3"
|
||
>
|
||
<div class="text-[11px] text-muted-foreground">选择要绑定的代理节点</div>
|
||
<ProxyNodeSelect
|
||
:model-value="proxyNodeIdForAction"
|
||
trigger-class="h-9"
|
||
@update:model-value="(v: string) => proxyNodeIdForAction = v"
|
||
/>
|
||
<Button
|
||
class="h-9 w-full text-xs"
|
||
:disabled="!canExecuteSpecifiedAction('set_proxy')"
|
||
@click="confirmAndExecuteAction('set_proxy')"
|
||
>
|
||
应用代理设置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section
|
||
v-if="selectedAction === 'update_settings'"
|
||
class="space-y-3 rounded-lg border border-primary/25 bg-primary/5 p-3 sm:p-4"
|
||
>
|
||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||
<div>
|
||
<h3 class="text-sm font-semibold">更多设置</h3>
|
||
<p class="text-[11px] text-muted-foreground">仅更新已勾选字段,未勾选配置保持不变</p>
|
||
</div>
|
||
<Badge variant="outline" class="tabular-nums">已选 {{ selectedSettingsCount }} 项</Badge>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.internal_priority"
|
||
@update:checked="settingsSelection.internal_priority = $event === true"
|
||
/>
|
||
<Label class="text-xs">优先级</Label>
|
||
</div>
|
||
<Input
|
||
v-model.number="settingsDraft.internal_priority"
|
||
type="number"
|
||
min="0"
|
||
class="h-9"
|
||
:disabled="!settingsSelection.internal_priority"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.rpm_limit"
|
||
@update:checked="settingsSelection.rpm_limit = $event === true"
|
||
/>
|
||
<Label class="text-xs">RPM 限制</Label>
|
||
</div>
|
||
<Input
|
||
:model-value="settingsDraft.rpm_limit ?? ''"
|
||
type="number"
|
||
min="1"
|
||
max="10000"
|
||
class="h-9"
|
||
placeholder="留空为自适应"
|
||
:disabled="!settingsSelection.rpm_limit"
|
||
@update:model-value="settingsDraft.rpm_limit = parseNullableNumberInput($event, { min: 1, max: 10000 })"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.concurrent_limit"
|
||
@update:checked="settingsSelection.concurrent_limit = $event === true"
|
||
/>
|
||
<Label class="text-xs">并发请求上限</Label>
|
||
</div>
|
||
<Input
|
||
:model-value="settingsDraft.concurrent_limit ?? ''"
|
||
type="number"
|
||
min="0"
|
||
class="h-9"
|
||
placeholder="留空为不限制"
|
||
:disabled="!settingsSelection.concurrent_limit"
|
||
@update:model-value="settingsDraft.concurrent_limit = parseNullableNumberInput($event, { min: 0 })"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.cache_ttl_minutes"
|
||
@update:checked="settingsSelection.cache_ttl_minutes = $event === true"
|
||
/>
|
||
<Label class="text-xs">缓存 TTL(分钟)</Label>
|
||
</div>
|
||
<Input
|
||
:model-value="settingsDraft.cache_ttl_minutes"
|
||
type="number"
|
||
min="0"
|
||
max="60"
|
||
class="h-9"
|
||
:disabled="!settingsSelection.cache_ttl_minutes"
|
||
@update:model-value="settingsDraft.cache_ttl_minutes = parseNumberInput($event, { min: 0, max: 60 }) ?? 5"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.max_probe_interval_minutes"
|
||
@update:checked="settingsSelection.max_probe_interval_minutes = $event === true"
|
||
/>
|
||
<Label class="text-xs">熔断探测(分钟)</Label>
|
||
</div>
|
||
<Input
|
||
:model-value="settingsDraft.max_probe_interval_minutes"
|
||
type="number"
|
||
min="0"
|
||
max="32"
|
||
class="h-9"
|
||
:disabled="!settingsSelection.max_probe_interval_minutes"
|
||
@update:model-value="settingsDraft.max_probe_interval_minutes = parseNumberInput($event, { min: 0, max: 32 }) ?? 32"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.is_active"
|
||
@update:checked="settingsSelection.is_active = $event === true"
|
||
/>
|
||
<Label class="text-xs">启用状态</Label>
|
||
</div>
|
||
<Select v-model="settingsStatus" :disabled="!settingsSelection.is_active">
|
||
<SelectTrigger class="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="enabled">启用</SelectItem>
|
||
<SelectItem value="disabled">停用</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3 sm:col-span-2">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.proxy_node_id"
|
||
@update:checked="settingsSelection.proxy_node_id = $event === true"
|
||
/>
|
||
<Label class="text-xs">账号代理</Label>
|
||
</div>
|
||
<div class="grid gap-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||
<Select v-model="settingsDraft.proxy_mode" :disabled="!settingsSelection.proxy_node_id">
|
||
<SelectTrigger class="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="set">设置节点</SelectItem>
|
||
<SelectItem value="clear">清除代理</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<ProxyNodeSelect
|
||
v-if="settingsDraft.proxy_mode === 'set'"
|
||
:model-value="settingsDraft.proxy_node_id"
|
||
trigger-class="h-9"
|
||
:class="!settingsSelection.proxy_node_id ? 'pointer-events-none opacity-50' : ''"
|
||
@update:model-value="(value: string) => settingsDraft.proxy_node_id = value"
|
||
/>
|
||
<div
|
||
v-else
|
||
class="flex h-9 items-center rounded-md border bg-muted/30 px-3 text-xs text-muted-foreground"
|
||
>
|
||
回退到 Provider 默认代理
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2 rounded-md border bg-background p-3">
|
||
<div class="flex min-h-6 items-center gap-2">
|
||
<Checkbox
|
||
:checked="settingsSelection.note"
|
||
@update:checked="settingsSelection.note = $event === true"
|
||
/>
|
||
<Label class="text-xs">备注</Label>
|
||
</div>
|
||
<Input
|
||
v-model="settingsDraft.note"
|
||
class="h-9"
|
||
placeholder="留空清除备注"
|
||
:disabled="!settingsSelection.note"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<p class="min-h-5 text-xs text-destructive">
|
||
{{ settingsErrors[0] || '' }}
|
||
</p>
|
||
<Button
|
||
class="min-h-10 sm:min-w-36"
|
||
:disabled="!canExecuteSpecifiedAction('update_settings')"
|
||
@click="confirmAndExecuteAction('update_settings')"
|
||
>
|
||
应用更多设置
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
|
||
<div
|
||
v-if="executing && progressTotal > 0"
|
||
class="space-y-1"
|
||
>
|
||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>{{ progressLabel }}</span>
|
||
<span>{{ progressDone }} / {{ progressTotal }}</span>
|
||
</div>
|
||
<div class="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||
<div
|
||
class="h-full rounded-full bg-primary transition-all duration-150"
|
||
:style="{ width: `${Math.round((progressDone / progressTotal) * 100)}%` }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-else-if="lastResultMessage"
|
||
class="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground"
|
||
>
|
||
{{ lastResultMessage }}
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<Button
|
||
variant="outline"
|
||
:disabled="executing"
|
||
@click="emit('update:modelValue', false)"
|
||
>
|
||
关闭
|
||
</Button>
|
||
</template>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Checkbox,
|
||
Dialog,
|
||
Input,
|
||
Label,
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui'
|
||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||
import { RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
|
||
import { useToast } from '@/composables/useToast'
|
||
import { useConfirm } from '@/composables/useConfirm'
|
||
import { parseApiError } from '@/utils/errorParser'
|
||
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 { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||
import { formatOAuthPlanType } from '@/utils/oauthPlanType'
|
||
import {
|
||
canExportOAuthCredential,
|
||
canRefreshOAuthCredential,
|
||
getProviderAuthLabel,
|
||
} from '@/utils/providerKeyAuth'
|
||
import {
|
||
getAccountStatusDisplay,
|
||
getAccountStatusTitle,
|
||
getOAuthStatusDisplay,
|
||
getOAuthStatusTitle,
|
||
} from '@/utils/providerKeyStatus'
|
||
import { getQuotaDisplayText } from '@/utils/providerKeyQuota'
|
||
import { runChunkedBatchAction } from '@/utils/batchAction'
|
||
import { parseNullableNumberInput, parseNumberInput } from '@/utils/form'
|
||
import {
|
||
buildPoolKeySettingsPatch,
|
||
createPoolKeyBatchSettingSelection,
|
||
createPoolKeyBatchSettingsDraft,
|
||
validatePoolKeyBatchSettings,
|
||
} from '@/features/pool/utils/poolKeyBatchSettings'
|
||
|
||
type QuickSelectorValue =
|
||
| 'banned'
|
||
| 'no_5h_limit'
|
||
| 'no_weekly_limit'
|
||
| 'plan_free'
|
||
| 'plan_team'
|
||
| 'oauth_invalid'
|
||
| 'proxy_unset'
|
||
| 'proxy_set'
|
||
| 'disabled'
|
||
| 'enabled'
|
||
|
||
type BatchActionValue =
|
||
| 'export'
|
||
| 'delete'
|
||
| 'refresh_oauth'
|
||
| 'refresh_quota'
|
||
| 'clear_proxy'
|
||
| 'set_proxy'
|
||
| 'update_settings'
|
||
| 'enable'
|
||
| 'disable'
|
||
|
||
type BatchActionOption = {
|
||
value: BatchActionValue
|
||
label: string
|
||
hint: string
|
||
destructive?: boolean
|
||
}
|
||
|
||
type PageKeyRow = {
|
||
key: PoolKeyDetail
|
||
planLabel: string
|
||
authTypeLabel: string
|
||
statusBadgeLabel: string | null
|
||
statusBadgeTitle: string
|
||
oauthOrgBadge: ReturnType<typeof getOAuthOrgBadge>
|
||
quotaText: string | null
|
||
quotaTextShort: string
|
||
lastUsedRelative: string
|
||
}
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
providerId: string
|
||
providerName?: string
|
||
providerType?: string
|
||
batchConcurrency?: number | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: boolean]
|
||
changed: []
|
||
}>()
|
||
|
||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||
{ value: 'banned', label: '账号异常' },
|
||
{ value: 'oauth_invalid', label: 'Token 异常' },
|
||
{ value: 'no_5h_limit', label: '无5H限额' },
|
||
{ value: 'no_weekly_limit', label: '无周限额' },
|
||
{ value: 'plan_free', label: '全部 Free' },
|
||
{ value: 'plan_team', label: '全部 Team' },
|
||
{ value: 'proxy_unset', label: '未配置代理' },
|
||
{ value: 'proxy_set', label: '已配置独立代理' },
|
||
{ value: 'disabled', label: '已禁用' },
|
||
{ value: 'enabled', label: '已启用' },
|
||
]
|
||
|
||
const ACTION_OPTIONS: BatchActionOption[] = [
|
||
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
|
||
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
|
||
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
|
||
{ value: 'update_settings', label: '更多设置', hint: '选择性修改 RPM、并发、熔断、备注和代理等配置。' },
|
||
{ value: 'clear_proxy', label: '清除代理', hint: '移除账号独立代理,回退到提供商默认代理。' },
|
||
{ value: 'enable', label: '启用', hint: '批量启用账号,恢复可调度状态。' },
|
||
{ value: 'disable', label: '禁用', hint: '批量禁用账号,保留数据但停止调度。' },
|
||
{ value: 'export', label: '导出凭据', hint: '仅导出 OAuth 凭据,其他类型账号将被跳过。' },
|
||
{ value: 'delete', label: '删除账号', hint: '永久删除账号数据,执行后不可恢复。', destructive: true },
|
||
]
|
||
|
||
const { success, warning, error: showError } = useToast()
|
||
const { confirm } = useConfirm()
|
||
const proxyNodesStore = useProxyNodesStore()
|
||
|
||
const loading = ref(false)
|
||
const executing = ref(false)
|
||
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>('refresh_quota')
|
||
const proxyNodeIdForAction = ref('')
|
||
const settingsSelection = reactive(createPoolKeyBatchSettingSelection())
|
||
const settingsDraft = reactive(createPoolKeyBatchSettingsDraft())
|
||
const settingsStatus = computed({
|
||
get: () => settingsDraft.is_active ? 'enabled' : 'disabled',
|
||
set: (value: string) => { settingsDraft.is_active = value === 'enabled' },
|
||
})
|
||
const lastResultMessage = ref('')
|
||
const progressTotal = ref(0)
|
||
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 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)
|
||
const hasActiveFilters = computed(() => searchText.value.trim().length > 0 || activeQuickSelectors.value.length > 0)
|
||
const pageKeyRows = computed<PageKeyRow[]>(() => pageKeys.value.map((key) => {
|
||
const statusBadgeLabel = getStatusBadgeLabel(key)
|
||
const quotaText = getQuotaText(key)
|
||
|
||
return {
|
||
key,
|
||
planLabel: formatOAuthPlanType(key.oauth_plan_type),
|
||
authTypeLabel: normalizeAuthTypeLabel(key),
|
||
statusBadgeLabel,
|
||
statusBadgeTitle: statusBadgeLabel ? getStatusBadgeTitle(key) : '',
|
||
oauthOrgBadge: getOAuthOrgBadge(key),
|
||
quotaText,
|
||
quotaTextShort: quotaText ? shortenQuota(quotaText) : '',
|
||
lastUsedRelative: key.last_used_at ? formatRelativeTime(key.last_used_at) : '',
|
||
}
|
||
}))
|
||
const selectedOnCurrentPageCount = computed(() => {
|
||
if (selectAllFiltered.value) return pageKeyRows.value.length
|
||
let count = 0
|
||
for (const row of pageKeyRows.value) {
|
||
if (selectedIdSet.value.has(row.key.key_id)) count += 1
|
||
}
|
||
return count
|
||
})
|
||
const isCurrentPageFullySelected = computed(() => {
|
||
if (selectAllFiltered.value || pageKeyRows.value.length === 0) return false
|
||
return selectedOnCurrentPageCount.value === pageKeyRows.value.length
|
||
})
|
||
const canClearSelection = computed(() => selectAllFiltered.value || selectedKeyIds.value.length > 0)
|
||
const activeQuickSelectorSet = computed(() => new Set(activeQuickSelectors.value))
|
||
const settingsErrors = computed(() => validatePoolKeyBatchSettings(settingsSelection, settingsDraft))
|
||
const selectedSettingsCount = computed(() => Object.values(settingsSelection).filter(Boolean).length)
|
||
|
||
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(key: PoolKeyDetail | PoolKeySelectionItem): string {
|
||
return getProviderAuthLabel(key)
|
||
}
|
||
|
||
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
|
||
const account = getAccountStatusDisplay(key)
|
||
if (account.blocked && account.label) return compactStatusBadgeLabel(account.label)
|
||
|
||
const oauth = getOAuthStatusDisplay(key, 0)
|
||
if (oauth?.requiresReauth) return '续期失败'
|
||
if (oauth?.isInvalid) return '已失效'
|
||
if (oauth?.isExpired) return '已过期'
|
||
return null
|
||
}
|
||
|
||
function compactStatusBadgeLabel(label: string): string {
|
||
const normalized = label.trim()
|
||
const mapped: Record<string, string> = {
|
||
'Token 失效': '已失效',
|
||
'Token 过期': '已过期',
|
||
账号已封禁: '账号封禁',
|
||
工作区已停用: '工作区停用',
|
||
账号访问受限: '访问受限',
|
||
}
|
||
return Array.from(mapped[normalized] || normalized).slice(0, 5).join('')
|
||
}
|
||
|
||
function getStatusBadgeTitle(key: PoolKeyDetail): string {
|
||
const label = getStatusBadgeLabel(key)
|
||
if (!label) return ''
|
||
|
||
const accountTitle = getAccountStatusTitle(key)
|
||
if (accountTitle) return accountTitle
|
||
|
||
const oauthTitle = getOAuthStatusTitle(key, 0)
|
||
return oauthTitle || label
|
||
}
|
||
|
||
function formatRelativeTime(value: string): string {
|
||
const ts = new Date(value).getTime()
|
||
if (!Number.isFinite(ts)) return '-'
|
||
const diff = Date.now() - ts
|
||
if (diff < 60_000) return '刚刚'
|
||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`
|
||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`
|
||
return `${Math.floor(diff / 86_400_000)}天前`
|
||
}
|
||
|
||
function getQuotaText(key: PoolKeyDetail): string | null {
|
||
return getQuotaDisplayText(key, props.providerType)
|
||
}
|
||
|
||
function shortenQuota(raw: string): string {
|
||
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(' | ')
|
||
}
|
||
|
||
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) {
|
||
pageKeys.value = []
|
||
filteredTotal.value = 0
|
||
resetSelection(true)
|
||
return
|
||
}
|
||
|
||
const requestId = ++loadRequestId
|
||
loading.value = true
|
||
const startedAt = performance.now()
|
||
let ok = false
|
||
try {
|
||
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
|
||
|
||
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, '加载账号列表失败'))
|
||
} finally {
|
||
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 toggleSelectCurrentPage(): void {
|
||
if (selectAllFiltered.value || pageKeys.value.length === 0) return
|
||
const set = new Set(selectedKeyIds.value)
|
||
const pageIds = pageKeys.value.map((key) => key.key_id)
|
||
const shouldUnselect = pageIds.every((id) => set.has(id))
|
||
for (const id of pageIds) {
|
||
if (shouldUnselect) set.delete(id)
|
||
else set.add(id)
|
||
}
|
||
selectedKeyIds.value = [...set]
|
||
}
|
||
|
||
function clearSelection(): void {
|
||
resetSelection()
|
||
}
|
||
|
||
function clearFilters(): void {
|
||
if (!hasActiveFilters.value) return
|
||
clearSearchDebounce()
|
||
suppressFilterWatch = true
|
||
searchText.value = ''
|
||
activeQuickSelectors.value = []
|
||
suppressFilterWatch = false
|
||
requestFilteredReload()
|
||
}
|
||
|
||
function toggleQuickSelector(selector: QuickSelectorValue): void {
|
||
const idx = activeQuickSelectors.value.indexOf(selector)
|
||
if (idx >= 0) {
|
||
activeQuickSelectors.value.splice(idx, 1)
|
||
} else {
|
||
activeQuickSelectors.value.push(selector)
|
||
}
|
||
requestFilteredReload()
|
||
}
|
||
|
||
function canExecuteSpecifiedAction(action: BatchActionValue): boolean {
|
||
if (executing.value || loading.value || selectedCount.value === 0) return false
|
||
if (action === 'set_proxy') return Boolean(proxyNodeIdForAction.value)
|
||
if (action === 'update_settings') return settingsErrors.value.length === 0
|
||
return true
|
||
}
|
||
|
||
function canSelectAction(action: BatchActionValue): boolean {
|
||
if (executing.value || loading.value) return false
|
||
if (action === 'set_proxy' || action === 'update_settings') return true
|
||
return selectedCount.value > 0
|
||
}
|
||
|
||
function getActionButtonVariant(option: BatchActionOption): 'default' | 'destructive' | 'outline' {
|
||
if (option.destructive) return 'destructive'
|
||
if (
|
||
option.value === selectedAction.value
|
||
&& (option.value === 'set_proxy' || option.value === 'update_settings')
|
||
) return 'default'
|
||
return 'outline'
|
||
}
|
||
|
||
function handleActionButtonClick(action: BatchActionValue): void {
|
||
if (action === 'set_proxy' || action === 'update_settings') {
|
||
selectedAction.value = action
|
||
return
|
||
}
|
||
void confirmAndExecuteAction(action)
|
||
}
|
||
|
||
async function confirmAndExecuteAction(action: BatchActionValue): Promise<void> {
|
||
selectedAction.value = action
|
||
if (selectedCount.value === 0) {
|
||
warning('请先选择账号')
|
||
return
|
||
}
|
||
if (action === 'set_proxy' && !proxyNodeIdForAction.value) {
|
||
warning('请先选择代理节点')
|
||
return
|
||
}
|
||
if (action === 'update_settings' && settingsErrors.value.length > 0) {
|
||
warning(settingsErrors.value[0])
|
||
return
|
||
}
|
||
if (!canExecuteSpecifiedAction(action)) return
|
||
|
||
const actionOption = ACTION_OPTIONS.find((item) => item.value === action)
|
||
const actionLabel = actionOption?.label || '执行动作'
|
||
const scopeLabel = selectAllFiltered.value ? '筛选结果' : '已选账号'
|
||
const confirmed = await confirm({
|
||
title: actionLabel,
|
||
message: `将对${scopeLabel}(${selectedCount.value} 个)执行:${actionLabel},是否继续?`,
|
||
confirmText: actionOption?.destructive ? '确认删除' : '确认执行',
|
||
...(actionOption?.destructive ? { variant: 'destructive' as const } : {}),
|
||
})
|
||
if (!confirmed) return
|
||
await executeAction(action)
|
||
}
|
||
|
||
const DELETE_POLL_INTERVAL_MS = 2000
|
||
const DELETE_POLL_MAX_MS = 10 * 60 * 1000
|
||
const DELETE_POLL_MAX_FAILURES = 3
|
||
|
||
async function pollDeleteTask(
|
||
providerId: string,
|
||
taskId: string,
|
||
progressOffset: number,
|
||
): Promise<{ status: string; deleted: number }> {
|
||
const deadline = Date.now() + DELETE_POLL_MAX_MS
|
||
let consecutiveFailures = 0
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const task = await getPoolBatchDeleteTask(providerId, taskId)
|
||
consecutiveFailures = 0
|
||
progressDone.value = progressOffset + task.deleted
|
||
if (task.status === 'completed' || task.status === 'failed') {
|
||
return { status: task.status, deleted: task.deleted }
|
||
}
|
||
} catch {
|
||
consecutiveFailures++
|
||
if (consecutiveFailures >= DELETE_POLL_MAX_FAILURES) {
|
||
return { status: 'failed', deleted: 0 }
|
||
}
|
||
}
|
||
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',
|
||
credential_kind: key?.credential_kind,
|
||
runtime_auth_kind: key?.runtime_auth_kind,
|
||
oauth_managed: key?.oauth_managed,
|
||
can_refresh_oauth: key?.can_refresh_oauth,
|
||
can_export_oauth: key?.can_export_oauth,
|
||
can_edit_oauth: key?.can_edit_oauth,
|
||
}
|
||
})
|
||
}
|
||
|
||
async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
|
||
if (executing.value) return
|
||
if (actionOverride) {
|
||
selectedAction.value = actionOverride
|
||
}
|
||
if (selectedCount.value === 0) {
|
||
warning('请先选择账号')
|
||
return
|
||
}
|
||
|
||
const requestedCount = selectedCount.value
|
||
if (selectedAction.value === 'set_proxy' && !proxyNodeIdForAction.value) {
|
||
warning('请先选择代理节点')
|
||
return
|
||
}
|
||
if (selectedAction.value === 'update_settings' && settingsErrors.value.length > 0) {
|
||
warning(settingsErrors.value[0])
|
||
return
|
||
}
|
||
|
||
executing.value = true
|
||
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((item) => item.value === selectedAction.value)?.label || '执行'
|
||
progressDone.value = 0
|
||
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
|
||
const counts = await runChunkedBatchAction({
|
||
items: targetIds,
|
||
chunkSize: BATCH_SIZE,
|
||
runChunk: (batch) => refreshProviderQuota(props.providerId, batch),
|
||
onChunkStart: ({ batchIndex, totalBatches }) => {
|
||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||
},
|
||
onChunkDone: ({ processed }) => {
|
||
progressDone.value = processed
|
||
},
|
||
})
|
||
successCount += counts.success
|
||
failedCount += counts.failed
|
||
skippedCount += counts.skipped
|
||
} else if (selectedAction.value === 'export') {
|
||
const exportableKeys = selectedKeys.filter((key) => canExportOAuthCredential(key))
|
||
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)
|
||
|
||
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
||
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
|
||
const batch = targetIds.slice(i, i + BATCH_SIZE)
|
||
if (totalBatches > 1) {
|
||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||
}
|
||
|
||
try {
|
||
const result = await batchActionPoolKeys(props.providerId, {
|
||
key_ids: batch,
|
||
action: 'delete',
|
||
})
|
||
|
||
if (result.task_id) {
|
||
progressLabel.value = `正在${actionLabel}...(后台执行中)`
|
||
const taskResult = await pollDeleteTask(props.providerId, result.task_id, i)
|
||
successCount += taskResult.deleted
|
||
if (taskResult.status === 'failed') {
|
||
failedCount += batch.length - taskResult.deleted
|
||
}
|
||
} else {
|
||
successCount += result.affected
|
||
}
|
||
} catch (err) {
|
||
// eslint-disable-next-line no-console
|
||
console.error(`batch delete failed (batch ${batchIndex}/${totalBatches}):`, err)
|
||
failedCount += batch.length
|
||
}
|
||
|
||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||
}
|
||
} else if (['enable', 'disable', 'clear_proxy', 'set_proxy', 'update_settings'].includes(selectedAction.value)) {
|
||
const targetIds = selectedKeys.map((key) => key.key_id)
|
||
const BATCH_SIZE = 2000
|
||
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
||
|
||
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
||
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
|
||
const batch = targetIds.slice(i, i + BATCH_SIZE)
|
||
if (totalBatches > 1) {
|
||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||
}
|
||
|
||
const payload = selectedAction.value === 'set_proxy'
|
||
? { node_id: proxyNodeIdForAction.value, enabled: true }
|
||
: selectedAction.value === 'update_settings'
|
||
? buildPoolKeySettingsPatch(settingsSelection, settingsDraft)
|
||
: undefined
|
||
|
||
try {
|
||
const result = await batchActionPoolKeys(props.providerId, {
|
||
key_ids: batch,
|
||
action: selectedAction.value as 'enable' | 'disable' | 'clear_proxy' | 'set_proxy' | 'update_settings',
|
||
...(payload ? { payload } : {}),
|
||
})
|
||
successCount += result.affected
|
||
} catch (err) {
|
||
// eslint-disable-next-line no-console
|
||
console.error(`batch ${selectedAction.value} failed (batch ${batchIndex}/${totalBatches}):`, err)
|
||
failedCount += batch.length
|
||
}
|
||
|
||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||
}
|
||
} else {
|
||
const CONCURRENCY = props.batchConcurrency || 8
|
||
const tasks: Array<() => Promise<'success' | 'skip'>> = []
|
||
for (const key of selectedKeys) {
|
||
if (selectedAction.value === 'refresh_oauth' && !canRefreshOAuthCredential(key)) {
|
||
skippedCount += 1
|
||
progressDone.value += 1
|
||
continue
|
||
}
|
||
tasks.push(() => refreshProviderOAuth(key.key_id).then(() => 'success' as const))
|
||
}
|
||
progressTotal.value = selectedKeys.length
|
||
|
||
let cursor = 0
|
||
const runNext = async (): Promise<void> => {
|
||
while (cursor < tasks.length) {
|
||
const idx = cursor++
|
||
try {
|
||
await tasks[idx]()
|
||
successCount += 1
|
||
} catch {
|
||
failedCount += 1
|
||
}
|
||
progressDone.value += 1
|
||
}
|
||
}
|
||
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => runNext())
|
||
await Promise.all(workers)
|
||
}
|
||
|
||
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount},跳过 ${skippedCount}`
|
||
if (failedCount > 0 || (selectedAction.value === 'export' && successCount === 0)) warning(lastResultMessage.value)
|
||
else success(lastResultMessage.value)
|
||
|
||
actionPhaseMs = performance.now() - actionStartedAt
|
||
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')
|
||
}
|
||
} catch (err) {
|
||
showError(parseApiError(err, '批量操作失败'))
|
||
} finally {
|
||
// eslint-disable-next-line no-console
|
||
console.info('[PoolAccountBatchDialog] executeAction timing', {
|
||
providerId: props.providerId,
|
||
action: selectedAction.value,
|
||
requestedCount,
|
||
resolvedCount,
|
||
successCount,
|
||
failedCount,
|
||
skippedCount,
|
||
actionPhaseMs: Math.round(actionPhaseMs),
|
||
reloadPhaseMs: Math.round(reloadPhaseMs),
|
||
totalMs: Math.round(performance.now() - actionStartedAt),
|
||
})
|
||
executing.value = false
|
||
progressTotal.value = 0
|
||
progressDone.value = 0
|
||
progressLabel.value = ''
|
||
}
|
||
}
|
||
|
||
watch(searchText, () => {
|
||
if (suppressFilterWatch || !props.modelValue) return
|
||
requestFilteredReload(SEARCH_DEBOUNCE_MS)
|
||
})
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(open) => {
|
||
if (!open) {
|
||
clearSearchDebounce()
|
||
return
|
||
}
|
||
suppressFilterWatch = true
|
||
searchText.value = ''
|
||
lastResultMessage.value = ''
|
||
activeQuickSelectors.value = []
|
||
selectedAction.value = 'refresh_quota'
|
||
proxyNodeIdForAction.value = ''
|
||
Object.assign(settingsSelection, createPoolKeyBatchSettingSelection())
|
||
Object.assign(settingsDraft, createPoolKeyBatchSettingsDraft())
|
||
resetSelection(true)
|
||
filteredTotal.value = 0
|
||
pageKeys.value = []
|
||
currentPage.value = 1
|
||
suppressFilterWatch = false
|
||
proxyNodesStore.ensureLoaded()
|
||
void loadKeysPage()
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => props.providerId,
|
||
(newId, oldId) => {
|
||
if (!props.modelValue || !newId || newId === oldId) return
|
||
clearSearchDebounce()
|
||
suppressFilterWatch = true
|
||
resetSelection(true)
|
||
filteredTotal.value = 0
|
||
pageKeys.value = []
|
||
currentPage.value = 1
|
||
suppressFilterWatch = false
|
||
void loadKeysPage()
|
||
},
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
clearSearchDebounce()
|
||
})
|
||
</script>
|