mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
feat(pool): add table-driven account batch actions
This commit is contained in:
@@ -12,31 +12,21 @@
|
||||
:provider-select-disabled="providerSelectDisabled"
|
||||
:status-options="poolKeyStatusFilterOptions"
|
||||
:meta-text="poolHeaderMetaText"
|
||||
:provider-proxy-node-id="selectedProviderData?.proxy?.node_id"
|
||||
:provider-proxy-mobile-open="providerProxyMobilePopoverOpen"
|
||||
:provider-proxy-desktop-open="providerProxyDesktopPopoverOpen"
|
||||
:provider-proxy-button-title="getProviderProxyButtonTitle()"
|
||||
:saving-provider-proxy="savingProviderProxy"
|
||||
:pool-scheduling-label="poolSchedulingLabel"
|
||||
:show-adaptive-hot-pool-metrics-button="showAdaptiveHotPoolMetricsButton"
|
||||
:provider-toggle-button-title="getProviderToggleButtonTitle()"
|
||||
:provider-toggle-button-class="getProviderToggleButtonClass()"
|
||||
:toggling-provider-status="togglingProviderStatus"
|
||||
:selected-count="selectedKeyCount"
|
||||
:is-all-filtered-selected="isAllFilteredPoolKeysSelected"
|
||||
:selection-disabled="keyPage.total === 0 || poolKeySelectionBusy"
|
||||
:batch-actions-disabled="selectedKeyCount === 0 || poolKeySelectionBusy"
|
||||
:refresh-loading="refreshCurrentPageLoading"
|
||||
:refresh-title="refreshButtonTitle"
|
||||
@import="showImportDialog = true"
|
||||
@view-provider="openProviderDrawer"
|
||||
@scheduling="openSchedulingDialog"
|
||||
@account-batch="showAccountBatchDialog = true"
|
||||
@edit-provider="openProviderEditDialog"
|
||||
@edit-endpoint="openEndpointEditDialog"
|
||||
@demand-metrics="showDemandMetricsDialog = true"
|
||||
@advanced="showAdvancedDialog = true"
|
||||
@toggle-provider="toggleSelectedProviderStatus"
|
||||
@toggle-select-all="toggleAllFilteredPoolKeys"
|
||||
@batch-action="openAccountBatchDialog"
|
||||
@refresh="refreshCurrentPage"
|
||||
@update:provider-proxy-mobile-open="handleProviderProxyPopoverToggle('mobile', $event)"
|
||||
@update:provider-proxy-desktop-open="handleProviderProxyPopoverToggle('desktop', $event)"
|
||||
@select-provider-proxy="setProviderProxy"
|
||||
@clear-provider-proxy="clearProviderProxy"
|
||||
/>
|
||||
|
||||
<!-- Loading (initial) -->
|
||||
@@ -91,13 +81,47 @@
|
||||
class="hidden xl:block overflow-x-auto"
|
||||
>
|
||||
<Table class="w-full table-fixed">
|
||||
<colgroup>
|
||||
<col :style="{ width: desktopColumnWidths.name }">
|
||||
<col
|
||||
v-if="showAccountQuotaColumn"
|
||||
:style="{ width: desktopColumnWidths.quota }"
|
||||
>
|
||||
<col :style="{ width: desktopColumnWidths.stats }">
|
||||
<col :style="{ width: desktopColumnWidths.imported }">
|
||||
<col :style="{ width: desktopColumnWidths.lastUsed }">
|
||||
<col :style="{ width: desktopColumnWidths.score }">
|
||||
<col :style="{ width: desktopColumnWidths.status }">
|
||||
<col :style="{ width: desktopColumnWidths.actions }">
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead
|
||||
class="font-semibold whitespace-nowrap"
|
||||
class="px-4 font-semibold whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.name }"
|
||||
>
|
||||
名称
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
:checked="selectAllFilteredPoolKeys || isCurrentPoolKeyPageFullySelected"
|
||||
:indeterminate="!selectAllFilteredPoolKeys && isCurrentPoolKeyPagePartiallySelected"
|
||||
:disabled="keyPage.keys.length === 0 || poolKeySelectionBusy || selectAllFilteredPoolKeys"
|
||||
aria-label="选择当前页账号"
|
||||
data-testid="pool-select-page-desktop"
|
||||
@update:checked="toggleCurrentPoolKeyPage"
|
||||
/>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="leading-none">名称</span>
|
||||
<span
|
||||
v-if="selectedKeyCount > 0"
|
||||
class="text-[11px] font-medium leading-none tabular-nums text-primary"
|
||||
aria-live="polite"
|
||||
data-testid="pool-selected-count-desktop"
|
||||
>
|
||||
{{ selectedKeyCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="showAccountQuotaColumn"
|
||||
@@ -183,106 +207,116 @@
|
||||
v-for="key in keyPage.keys"
|
||||
:key="key.key_id"
|
||||
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
:class="keyUiStateMap[key.key_id]?.rowClass || ''"
|
||||
:class="getPoolKeyRowClass(key.key_id)"
|
||||
>
|
||||
<TableCell
|
||||
class="py-3"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-sm truncate block">
|
||||
{{ key.key_name || '未命名' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
|
||||
<input
|
||||
v-if="editingPriorityKeyId === key.key_id"
|
||||
:value="editingPriorityValue"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999999"
|
||||
autofocus
|
||||
class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)"
|
||||
@blur="(e) => finishEditInternalPriority(key, e)"
|
||||
@keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)"
|
||||
@keydown.esc.prevent="cancelEditInternalPriority"
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
|
||||
title="点击编辑优先级"
|
||||
@click="startEditInternalPriority(key)"
|
||||
>
|
||||
P{{ key.internal_priority ?? 50 }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="canExportOAuthCredential(key)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="下载 OAuth 授权文件"
|
||||
@click.stop="downloadRefreshToken(key)"
|
||||
>
|
||||
<Download class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="复制密钥"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<span class="font-mono">
|
||||
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
|
||||
</span>
|
||||
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
:checked="isPoolKeySelected(key.key_id)"
|
||||
:disabled="poolKeySelectionBusy || selectAllFilteredPoolKeys"
|
||||
:aria-label="`选择账号 ${key.key_name || key.key_id}`"
|
||||
:data-testid="`pool-select-desktop-${key.key_id}`"
|
||||
@update:checked="togglePoolKeySelection(key.key_id, $event === true)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-sm truncate block">
|
||||
{{ key.key_name || '未命名' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
|
||||
<input
|
||||
v-if="editingPriorityKeyId === key.key_id"
|
||||
:value="editingPriorityValue"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999999"
|
||||
autofocus
|
||||
class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)"
|
||||
@blur="(e) => finishEditInternalPriority(key, e)"
|
||||
@keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)"
|
||||
@keydown.esc.prevent="cancelEditInternalPriority"
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
|
||||
title="点击编辑优先级"
|
||||
@click="startEditInternalPriority(key)"
|
||||
>
|
||||
P{{ key.internal_priority ?? 50 }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="canExportOAuthCredential(key)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
|
||||
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
|
||||
@click.stop="handleRefreshOAuth(key)"
|
||||
title="下载 OAuth 授权文件"
|
||||
@click.stop="downloadRefreshToken(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-2.5 h-2.5"
|
||||
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
|
||||
/>
|
||||
<Download class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<span
|
||||
v-if="keyUiStateMap[key.key_id]?.visibleOAuthState"
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid || keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired,
|
||||
'text-warning': keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid,
|
||||
'text-muted-foreground': !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid
|
||||
}"
|
||||
:title="keyUiStateMap[key.key_id]?.oauthStatusTitle || ''"
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="复制密钥"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
{{ keyUiStateMap[key.key_id]?.visibleOAuthState?.text }}
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<span class="font-mono">
|
||||
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="keyUiStateMap[key.key_id]?.planLabel"
|
||||
variant="outline"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:class="keyUiStateMap[key.key_id]?.planClass || ''"
|
||||
>
|
||||
{{ keyUiStateMap[key.key_id]?.planLabel }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="keyUiStateMap[key.key_id]?.oauthOrgBadge"
|
||||
variant="secondary"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:title="keyUiStateMap[key.key_id]?.oauthOrgBadge?.title"
|
||||
>
|
||||
{{ keyUiStateMap[key.key_id]?.oauthOrgBadge?.label }}
|
||||
</Badge>
|
||||
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
|
||||
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
|
||||
@click.stop="handleRefreshOAuth(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-2.5 h-2.5"
|
||||
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="keyUiStateMap[key.key_id]?.visibleOAuthState"
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid || keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired,
|
||||
'text-warning': keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid,
|
||||
'text-muted-foreground': !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid
|
||||
}"
|
||||
:title="keyUiStateMap[key.key_id]?.oauthStatusTitle || ''"
|
||||
>
|
||||
{{ keyUiStateMap[key.key_id]?.visibleOAuthState?.text }}
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="keyUiStateMap[key.key_id]?.planLabel"
|
||||
variant="outline"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:class="keyUiStateMap[key.key_id]?.planClass || ''"
|
||||
>
|
||||
{{ keyUiStateMap[key.key_id]?.planLabel }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="keyUiStateMap[key.key_id]?.oauthOrgBadge"
|
||||
variant="secondary"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:title="keyUiStateMap[key.key_id]?.oauthOrgBadge?.title"
|
||||
>
|
||||
{{ keyUiStateMap[key.key_id]?.oauthOrgBadge?.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -512,11 +546,21 @@
|
||||
v-for="key in keyPage.keys"
|
||||
:key="key.key_id"
|
||||
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
|
||||
:class="keyUiStateMap[key.key_id]?.rowClass || ''"
|
||||
:class="getPoolKeyRowClass(key.key_id)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium truncate">
|
||||
{{ key.key_name || '未命名' }}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
:checked="isPoolKeySelected(key.key_id)"
|
||||
:disabled="poolKeySelectionBusy || selectAllFilteredPoolKeys"
|
||||
:aria-label="`选择账号 ${key.key_name || key.key_id}`"
|
||||
:data-testid="`pool-select-mobile-${key.key_id}`"
|
||||
@update:checked="togglePoolKeySelection(key.key_id, $event === true)"
|
||||
/>
|
||||
<div class="min-w-0 truncate text-sm font-medium">
|
||||
{{ key.key_name || '未命名' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
@@ -900,20 +944,21 @@
|
||||
:provider-name="selectedProviderOverview?.provider_name"
|
||||
:samples="providerDemandMetricSamples"
|
||||
/>
|
||||
<ProviderDetailDrawer
|
||||
v-if="providerDrawerOpen && selectedProviderId"
|
||||
:open="providerDrawerOpen"
|
||||
:provider-id="selectedProviderId"
|
||||
:initial-provider="selectedProviderData"
|
||||
@update:open="providerDrawerOpen = $event"
|
||||
@edit="openProviderEditDialog"
|
||||
@toggle-status="toggleSelectedProviderStatus"
|
||||
@refresh="handleProviderDrawerRefresh"
|
||||
/>
|
||||
<ProviderFormDialog
|
||||
v-model="providerEditDialogOpen"
|
||||
:provider="providerToEdit"
|
||||
@provider-updated="handleProviderEditSaved"
|
||||
/>
|
||||
<EndpointFormDialog
|
||||
v-if="selectedProviderData"
|
||||
v-model="endpointEditDialogOpen"
|
||||
:provider="selectedProviderData"
|
||||
:endpoints="providerEndpointsForEdit"
|
||||
:provider-format-conversion-enabled="selectedProviderData.enable_format_conversion"
|
||||
@endpoint-created="handleEndpointEditSaved"
|
||||
@endpoint-updated="handleEndpointEditSaved"
|
||||
/>
|
||||
<PoolAccountBatchDialog
|
||||
v-if="selectedProviderId"
|
||||
v-model="showAccountBatchDialog"
|
||||
@@ -921,6 +966,11 @@
|
||||
:provider-name="selectedProviderData?.name || ''"
|
||||
:provider-type="selectedProviderData?.provider_type || selectedProviderType"
|
||||
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
|
||||
:selected-keys="selectedPoolKeys"
|
||||
:select-all-filtered="selectAllFilteredPoolKeys"
|
||||
:selected-count="selectedKeyCount"
|
||||
:selection-filters="poolKeySelectionFilters"
|
||||
:initial-action="pendingAccountBatchAction"
|
||||
@changed="handleAccountBatchChanged"
|
||||
@edit-config="openKeyBatchEditDialog"
|
||||
/>
|
||||
@@ -963,7 +1013,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
|
||||
import {
|
||||
Upload,
|
||||
RefreshCw,
|
||||
@@ -984,6 +1034,7 @@ import {
|
||||
Card,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
@@ -1002,6 +1053,8 @@ import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useRouteQuery } from '@/composables/useRouteQuery'
|
||||
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
getPoolOverview,
|
||||
@@ -1021,18 +1074,18 @@ import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||
import type {
|
||||
PoolOverviewItem,
|
||||
PoolKeyDetail,
|
||||
PoolKeySelectionRequest,
|
||||
PoolKeysPageResponse,
|
||||
PoolPresetMeta,
|
||||
} from '@/api/endpoints/pool'
|
||||
import type {
|
||||
ClaudeCodeAdvancedConfig,
|
||||
EndpointAPIKey,
|
||||
ProviderEndpoint,
|
||||
PoolAdvancedConfig,
|
||||
ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints/types/provider'
|
||||
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
||||
import { getProvider, updateProvider } from '@/api/endpoints'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
||||
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
|
||||
@@ -1046,7 +1099,6 @@ import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllow
|
||||
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
|
||||
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||
import ProviderFormDialog from '@/features/providers/components/ProviderFormDialog.vue'
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
import {
|
||||
@@ -1065,6 +1117,7 @@ import {
|
||||
type PoolManagementViewState,
|
||||
writePoolManagementViewState,
|
||||
} from '@/features/pool/utils/poolManagementState'
|
||||
import type { PoolBatchActionValue } from '@/features/pool/utils/poolBatchActions'
|
||||
import {
|
||||
buildPoolStatsDisplay,
|
||||
type PoolCodexCycleStatsGroup,
|
||||
@@ -1098,9 +1151,14 @@ import {
|
||||
getQuotaDisplayText,
|
||||
} from '@/utils/providerKeyQuota'
|
||||
|
||||
const ProviderDetailDrawer = defineAsyncComponent(
|
||||
() => import('@/features/providers/components/ProviderDetailDrawer.vue'),
|
||||
)
|
||||
|
||||
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
|
||||
|
||||
const { success, error: showError, warning: showWarning } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
const { confirm } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
||||
@@ -1129,6 +1187,7 @@ let selectProviderRequestId = 0
|
||||
let providerDataRequestId = 0
|
||||
let keysRequestId = 0
|
||||
let keysSearchDebounceTimer: number | null = null
|
||||
const keysSearchPending = ref(false)
|
||||
let demandMetricsPollingTimer: number | null = null
|
||||
let demandMetricsRequestId = 0
|
||||
let suppressFiltersWatch = false
|
||||
@@ -1242,12 +1301,11 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
|
||||
selectedProviderId.value = null
|
||||
selectedProviderData.value = null
|
||||
keysLoadedOnce.value = false
|
||||
endpointEditDialogOpen.value = false
|
||||
providerEndpointsForEdit.value = []
|
||||
resetPoolKeySelection(true)
|
||||
providerDrawerOpen.value = false
|
||||
showAccountBatchDialog.value = false
|
||||
keyBatchEditDialogOpen.value = false
|
||||
keyBatchEditKeyIds.value = []
|
||||
closeProviderProxyPopovers()
|
||||
resetKeyPage()
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1603,8 +1661,8 @@ async function selectProvider(
|
||||
hasHydratedInitialProviderSelection = true
|
||||
selectedProviderId.value = id
|
||||
selectedProviderData.value = null
|
||||
endpointEditDialogOpen.value = false
|
||||
providerEndpointsForEdit.value = []
|
||||
resetPoolKeySelection(true)
|
||||
providerDrawerOpen.value = false
|
||||
editingKeyDetail.value = null
|
||||
showAccountBatchDialog.value = false
|
||||
keyBatchEditDialogOpen.value = false
|
||||
@@ -1612,7 +1670,6 @@ async function selectProvider(
|
||||
keyPermissionsDialogOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
oauthKeyEditDialogOpen.value = false
|
||||
closeProviderProxyPopovers()
|
||||
proxyDesktopPopoverOpenKeyId.value = null
|
||||
proxyMobilePopoverOpenKeyId.value = null
|
||||
scoreDesktopPopoverOpenKeyId.value = null
|
||||
@@ -1632,6 +1689,7 @@ async function selectProvider(
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
keysSearchPending.value = false
|
||||
keysLoadedOnce.value = false
|
||||
resetKeyPage(currentPage.value, pageSize.value)
|
||||
const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 })
|
||||
@@ -1664,10 +1722,67 @@ function createEmptyKeyPage(page = 1, pageSizeValue = 50): PoolKeysPageResponse
|
||||
|
||||
const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage())
|
||||
const keysLoading = ref(false)
|
||||
const poolKeySelectionBusy = computed(() => keysLoading.value || keysSearchPending.value)
|
||||
const keysLoadedOnce = ref(false)
|
||||
const poolKeyPageItems = computed(() => keyPage.value.keys)
|
||||
const poolKeyFilteredTotal = computed(() => keyPage.value.total)
|
||||
const {
|
||||
selectedIds: selectedPoolKeyIds,
|
||||
selectedIdSet: selectedPoolKeyIdSet,
|
||||
selectedCount: selectedKeyCount,
|
||||
selectAllFiltered: selectAllFilteredPoolKeys,
|
||||
isAllFilteredSelected: isAllFilteredPoolKeysSelected,
|
||||
isCurrentPageFullySelected: isCurrentPoolKeyPageFullySelected,
|
||||
rememberItems: rememberPoolKeys,
|
||||
knownItemsById: knownPoolKeysById,
|
||||
resetSelection: resetPoolKeySelection,
|
||||
toggleOne: togglePoolKeySelection,
|
||||
toggleSelectFiltered: toggleSelectFilteredPoolKeys,
|
||||
toggleSelectCurrentPage: toggleCurrentPoolKeyPage,
|
||||
} = useBatchSelection<PoolKeyDetail>({
|
||||
pageItems: poolKeyPageItems,
|
||||
filteredTotal: poolKeyFilteredTotal,
|
||||
getItemId: key => key.key_id,
|
||||
})
|
||||
const selectedKeyCountLabel = computed(() => legacyT(`已选 ${selectedKeyCount.value} 个`))
|
||||
const selectedPoolKeys = computed(() => selectedPoolKeyIds.value
|
||||
.map(keyId => knownPoolKeysById.value[keyId])
|
||||
.filter((key): key is PoolKeyDetail => Boolean(key)))
|
||||
const selectedOnCurrentPoolKeyPageCount = computed(() => poolKeyPageItems.value
|
||||
.filter(key => selectedPoolKeyIdSet.value.has(key.key_id)).length)
|
||||
const isCurrentPoolKeyPagePartiallySelected = computed(() => (
|
||||
selectedOnCurrentPoolKeyPageCount.value > 0
|
||||
&& !isCurrentPoolKeyPageFullySelected.value
|
||||
))
|
||||
|
||||
watch(poolKeyPageItems, (keys) => rememberPoolKeys(keys), { immediate: true })
|
||||
|
||||
function isPoolKeySelected(keyId: string): boolean {
|
||||
return selectAllFilteredPoolKeys.value || selectedPoolKeyIdSet.value.has(keyId)
|
||||
}
|
||||
|
||||
function toggleAllFilteredPoolKeys(): void {
|
||||
if (poolKeySelectionBusy.value || keyPage.value.total === 0) return
|
||||
toggleSelectFilteredPoolKeys(!isAllFilteredPoolKeysSelected.value)
|
||||
}
|
||||
|
||||
function getPoolKeyRowClass(keyId: string): string {
|
||||
return [
|
||||
keyUiStateMap.value[keyId]?.rowClass || '',
|
||||
isPoolKeySelected(keyId) ? 'bg-primary/5' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const refreshingCurrentPageQuota = ref(false)
|
||||
const searchQuery = ref(restoredViewState.search)
|
||||
const statusFilter = ref(restoredViewState.status)
|
||||
const poolKeySelectionFilters = computed<PoolKeySelectionRequest>(() => {
|
||||
const search = searchQuery.value.trim()
|
||||
return {
|
||||
...(search ? { search } : {}),
|
||||
status: statusFilter.value,
|
||||
}
|
||||
})
|
||||
const currentPage = ref(restoredViewState.page)
|
||||
const pageSize = ref(restoredViewState.pageSize)
|
||||
const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy)
|
||||
@@ -1696,6 +1811,12 @@ const editingKeyDetail = ref<PoolKeyDetail | null>(null)
|
||||
|
||||
function clearPoolKeyFilters() {
|
||||
if (!hasPoolKeyFilters.value) return
|
||||
resetPoolKeySelection(true)
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
keysSearchPending.value = false
|
||||
suppressFiltersWatch = true
|
||||
searchQuery.value = ''
|
||||
statusFilter.value = 'all'
|
||||
@@ -2140,12 +2261,22 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([currentPage, pageSize], () => {
|
||||
watch(currentPage, () => {
|
||||
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||
})
|
||||
|
||||
watch(pageSize, () => {
|
||||
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
if (suppressFiltersWatch) return
|
||||
resetPoolKeySelection(true)
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
keysSearchPending.value = false
|
||||
currentPage.value = 1
|
||||
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||
})
|
||||
@@ -2160,12 +2291,15 @@ watch([sortBy, sortOrder], () => {
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (suppressFiltersWatch) return
|
||||
resetPoolKeySelection(true)
|
||||
currentPage.value = 1
|
||||
if (keysSearchDebounceTimer !== null) {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
}
|
||||
keysSearchPending.value = true
|
||||
keysSearchDebounceTimer = window.setTimeout(() => {
|
||||
keysSearchDebounceTimer = null
|
||||
keysSearchPending.value = false
|
||||
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||
}, 300)
|
||||
})
|
||||
@@ -2329,6 +2463,7 @@ function closeKeyBatchEditDialog(): void {
|
||||
}
|
||||
|
||||
async function handleKeyBatchEditSaved(): Promise<void> {
|
||||
resetPoolKeySelection(true)
|
||||
await Promise.all([loadKeys(), loadOverview()])
|
||||
}
|
||||
|
||||
@@ -2437,6 +2572,7 @@ async function handleDeleteKey(key: PoolKeyDetail) {
|
||||
try {
|
||||
await deleteEndpointKey(key.key_id)
|
||||
success('账号已删除')
|
||||
togglePoolKeySelection(key.key_id, false)
|
||||
// 乐观更新:直接从本地列表移除,避免等待网络重载
|
||||
keyPage.value.keys = keyPage.value.keys.filter(k => k.key_id !== key.key_id)
|
||||
keyPage.value.total = Math.max(0, keyPage.value.total - 1)
|
||||
@@ -2607,25 +2743,48 @@ async function toggleKeyActive(key: PoolKeyDetail) {
|
||||
const showImportDialog = ref(false)
|
||||
const showSchedulingDialog = ref(false)
|
||||
const showAdvancedDialog = ref(false)
|
||||
const providerDrawerOpen = ref(false)
|
||||
const providerEditDialogOpen = ref(false)
|
||||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
const endpointEditDialogOpen = ref(false)
|
||||
const providerEndpointsForEdit = ref<ProviderEndpoint[]>([])
|
||||
const showAccountBatchDialog = ref(false)
|
||||
const providerProxyMobilePopoverOpen = ref(false)
|
||||
const providerProxyDesktopPopoverOpen = ref(false)
|
||||
const savingProviderProxy = ref(false)
|
||||
const pendingAccountBatchAction = ref<PoolBatchActionValue | null>(null)
|
||||
const togglingProviderStatus = ref(false)
|
||||
let endpointEditRequestId = 0
|
||||
|
||||
function openSchedulingDialog() {
|
||||
showSchedulingDialog.value = true
|
||||
}
|
||||
|
||||
async function openProviderEditDialog(): Promise<void> {
|
||||
function openAccountBatchDialog(action: PoolBatchActionValue = 'refresh_quota'): void {
|
||||
if (!selectedProviderId.value || selectedKeyCount.value === 0) return
|
||||
pendingAccountBatchAction.value = action
|
||||
showAccountBatchDialog.value = true
|
||||
}
|
||||
|
||||
watch(showAccountBatchDialog, (open) => {
|
||||
if (!open) pendingAccountBatchAction.value = null
|
||||
})
|
||||
|
||||
function openProviderDrawer(): void {
|
||||
if (!selectedProviderId.value) return
|
||||
providerDrawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleProviderDrawerRefresh(): Promise<void> {
|
||||
const providerId = selectedProviderId.value
|
||||
if (!providerId) return
|
||||
|
||||
await Promise.all([
|
||||
loadKeys(),
|
||||
loadOverview({ silent: true }),
|
||||
loadProviderData(providerId),
|
||||
])
|
||||
resetPoolKeySelection(true)
|
||||
}
|
||||
|
||||
async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary): Promise<void> {
|
||||
const providerId = provider?.id || selectedProviderId.value
|
||||
if (!providerId) return
|
||||
|
||||
try {
|
||||
const latest = await getProvider(providerId)
|
||||
if (selectedProviderId.value !== providerId) return
|
||||
@@ -2633,11 +2792,12 @@ async function openProviderEditDialog(): Promise<void> {
|
||||
providerToEdit.value = latest
|
||||
} catch (err) {
|
||||
if (selectedProviderId.value !== providerId) return
|
||||
if (!selectedProviderData.value) {
|
||||
const fallbackProvider = provider ?? selectedProviderData.value
|
||||
if (!fallbackProvider) {
|
||||
showError(parseApiError(err, '刷新提供商状态失败'))
|
||||
return
|
||||
}
|
||||
providerToEdit.value = selectedProviderData.value
|
||||
providerToEdit.value = fallbackProvider
|
||||
}
|
||||
|
||||
providerEditDialogOpen.value = true
|
||||
@@ -2652,134 +2812,10 @@ async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSum
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
async function openEndpointEditDialog(): Promise<void> {
|
||||
const providerId = selectedProviderId.value
|
||||
if (!providerId) return
|
||||
|
||||
const requestId = ++endpointEditRequestId
|
||||
try {
|
||||
const [provider, endpoints] = await Promise.all([
|
||||
getProvider(providerId),
|
||||
getProviderEndpoints(providerId),
|
||||
])
|
||||
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||
selectedProviderData.value = provider
|
||||
providerEndpointsForEdit.value = endpoints
|
||||
endpointEditDialogOpen.value = true
|
||||
} catch (err) {
|
||||
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||
showError(parseApiError(err, '加载端点失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndpointEditSaved(): Promise<void> {
|
||||
const providerId = selectedProviderId.value
|
||||
if (!providerId) return
|
||||
|
||||
const requestId = ++endpointEditRequestId
|
||||
try {
|
||||
const [provider, endpoints] = await Promise.all([
|
||||
getProvider(providerId),
|
||||
getProviderEndpoints(providerId),
|
||||
])
|
||||
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||
selectedProviderData.value = provider
|
||||
providerEndpointsForEdit.value = endpoints
|
||||
await Promise.all([loadOverview(), loadKeys()])
|
||||
} catch (err) {
|
||||
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
||||
showError(parseApiError(err, '刷新端点失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderProxyNodeName(): string | null {
|
||||
const nodeId = selectedProviderData.value?.proxy?.node_id
|
||||
if (!nodeId) return null
|
||||
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
|
||||
return node ? node.name : `${nodeId.slice(0, 8)}...`
|
||||
}
|
||||
|
||||
function getProviderProxyButtonTitle(): string {
|
||||
const nodeName = getProviderProxyNodeName()
|
||||
if (nodeName) return `提供商代理(当前: ${nodeName})`
|
||||
return '提供商代理(未设置)'
|
||||
}
|
||||
|
||||
function closeProviderProxyPopovers(): void {
|
||||
providerProxyMobilePopoverOpen.value = false
|
||||
providerProxyDesktopPopoverOpen.value = false
|
||||
}
|
||||
|
||||
function handleProviderProxyPopoverToggle(scope: 'mobile' | 'desktop', open: boolean): void {
|
||||
if (scope === 'mobile') {
|
||||
providerProxyMobilePopoverOpen.value = open
|
||||
if (open) {
|
||||
providerProxyDesktopPopoverOpen.value = false
|
||||
}
|
||||
} else {
|
||||
providerProxyDesktopPopoverOpen.value = open
|
||||
if (open) {
|
||||
providerProxyMobilePopoverOpen.value = false
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
proxyDesktopPopoverOpenKeyId.value = null
|
||||
proxyMobilePopoverOpenKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function setProviderProxy(nodeId: string): Promise<void> {
|
||||
const providerId = selectedProviderId.value
|
||||
if (!providerId) return
|
||||
savingProviderProxy.value = true
|
||||
try {
|
||||
const updated = await updateProvider(providerId, {
|
||||
proxy: { node_id: nodeId, enabled: true },
|
||||
})
|
||||
if (selectedProviderId.value === providerId) {
|
||||
selectedProviderData.value = updated
|
||||
}
|
||||
closeProviderProxyPopovers()
|
||||
success('提供商代理已设置')
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '设置提供商代理失败'))
|
||||
} finally {
|
||||
savingProviderProxy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearProviderProxy(): Promise<void> {
|
||||
const providerId = selectedProviderId.value
|
||||
if (!providerId) return
|
||||
savingProviderProxy.value = true
|
||||
try {
|
||||
const updated = await updateProvider(providerId, { proxy: null })
|
||||
if (selectedProviderId.value === providerId) {
|
||||
selectedProviderData.value = updated
|
||||
}
|
||||
closeProviderProxyPopovers()
|
||||
success('提供商代理已清除')
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '清除提供商代理失败'))
|
||||
} finally {
|
||||
savingProviderProxy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderToggleButtonTitle(): string {
|
||||
const active = selectedProviderData.value?.is_active !== false
|
||||
return active ? '当前状态:已启用,点击禁用提供商' : '当前状态:已禁用,点击启用提供商'
|
||||
}
|
||||
|
||||
function getProviderToggleButtonClass(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
async function toggleSelectedProviderStatus(): Promise<void> {
|
||||
async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> {
|
||||
if (togglingProviderStatus.value) return
|
||||
const providerId = selectedProviderId.value
|
||||
const current = selectedProviderData.value
|
||||
const current = provider?.id === providerId ? provider : selectedProviderData.value
|
||||
if (!providerId || !current) return
|
||||
|
||||
const nextStatus = !current.is_active
|
||||
@@ -2796,6 +2832,7 @@ async function toggleSelectedProviderStatus(): Promise<void> {
|
||||
togglingProviderStatus.value = true
|
||||
try {
|
||||
const updated = await updateProvider(providerId, { is_active: nextStatus })
|
||||
Object.assign(current, updated)
|
||||
if (selectedProviderId.value === providerId) {
|
||||
selectedProviderData.value = updated
|
||||
}
|
||||
@@ -2809,6 +2846,7 @@ async function toggleSelectedProviderStatus(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handleAccountBatchChanged(): Promise<void> {
|
||||
resetPoolKeySelection(true)
|
||||
await Promise.all([loadKeys(), loadOverview()])
|
||||
}
|
||||
|
||||
@@ -3787,6 +3825,7 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(keysSearchDebounceTimer)
|
||||
keysSearchDebounceTimer = null
|
||||
}
|
||||
keysSearchPending.value = false
|
||||
overviewRequestId += 1
|
||||
selectProviderRequestId += 1
|
||||
providerDataRequestId += 1
|
||||
|
||||
Reference in New Issue
Block a user