mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(pool): 号池额度主动探测、封禁自动清除、调度硬优先级与前端重构
- 新增 PoolQuotaProbeScheduler,按 probing_interval_minutes 主动探测静默 Key 额度 - pool_advanced 增加 probing_enabled / auto_remove_banned_keys 配置项 - error_handler 和 quota_service 支持封禁 Key 自动删除及缓存清理 - multi_score 策略从加权混合重构为硬优先级排序,引入 mutex_group 互斥组 - 指纹注入从 handler 层下移至 ClaudeCode envelope 层 - OAuth 批量导入支持 concurrency 并发参数 - 前端号池管理拆分高级设置/账号批量/代理设置为独立组件 - 号池总览接口精简,仅返回已启用调度的 Provider
This commit is contained in:
@@ -482,6 +482,10 @@ export interface PoolAdvancedConfig {
|
||||
proactive_refresh_seconds?: number | null
|
||||
health_policy_enabled?: boolean
|
||||
unschedulable_rules?: Array<Record<string, unknown>> | null
|
||||
batch_concurrency?: number | null
|
||||
probing_enabled?: boolean
|
||||
probing_interval_minutes?: number | null
|
||||
auto_remove_banned_keys?: boolean
|
||||
}
|
||||
|
||||
export interface FailoverRuleItem {
|
||||
|
||||
110
frontend/src/components/common/MultiSelect.vue
Normal file
110
frontend/src/components/common/MultiSelect.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(
|
||||
'h-9 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors gap-1',
|
||||
triggerClass,
|
||||
)"
|
||||
:disabled="disabled"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span
|
||||
:class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'"
|
||||
class="text-xs truncate"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform"
|
||||
:class="isOpen ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[80]"
|
||||
@click.stop="isOpen = false"
|
||||
/>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
:style="dropdownMinWidth ? { minWidth: dropdownMinWidth } : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
class="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer text-xs"
|
||||
@click="toggle(item.value)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.includes(item.value)"
|
||||
class="h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm accent-primary cursor-pointer"
|
||||
@click.stop
|
||||
@change="toggle(item.value)"
|
||||
>
|
||||
<span class="text-sm">{{ item.label }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string[]
|
||||
options: MultiSelectOption[]
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
triggerClass?: string
|
||||
dropdownMinWidth?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
placeholder: '请选择',
|
||||
emptyText: '暂无选项',
|
||||
triggerClass: '',
|
||||
dropdownMinWidth: undefined,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.modelValue.length === 0) return props.placeholder
|
||||
if (props.modelValue.length <= 2) {
|
||||
return props.modelValue
|
||||
.map(v => props.options.find(o => o.value === v)?.label ?? v)
|
||||
.join(', ')
|
||||
}
|
||||
return `已选择 ${props.modelValue.length} 项`
|
||||
})
|
||||
|
||||
function toggle(value: string) {
|
||||
const newValue = [...props.modelValue]
|
||||
const index = newValue.indexOf(value)
|
||||
if (index === -1) {
|
||||
newValue.push(value)
|
||||
} else {
|
||||
newValue.splice(index, 1)
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
@@ -10,4 +10,5 @@ export { default as LoadingState } from './LoadingState.vue'
|
||||
|
||||
// 表单组件
|
||||
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
||||
export { default as MultiSelect } from './MultiSelect.vue'
|
||||
export { default as TimeRangePicker } from './TimeRangePicker.vue'
|
||||
|
||||
677
frontend/src/features/pool/components/PoolAccountBatchDialog.vue
Normal file
677
frontend/src/features/pool/components/PoolAccountBatchDialog.vue
Normal file
@@ -0,0 +1,677 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="账号批量操作"
|
||||
:description="dialogDescription"
|
||||
size="xl"
|
||||
persistent
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<MultiSelect
|
||||
:model-value="activeQuickSelectors"
|
||||
:options="QUICK_SELECT_OPTIONS"
|
||||
placeholder="快捷多选"
|
||||
trigger-class="h-8 w-40"
|
||||
dropdown-min-width="10rem"
|
||||
:disabled="loading || executing || allKeys.length === 0"
|
||||
@update:model-value="onQuickSelectChange"
|
||||
/>
|
||||
<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="loadAllKeys()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5"
|
||||
:class="loading ? 'animate-spin' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeQuickSelectors.length > 0"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="sel in activeQuickSelectors"
|
||||
:key="sel"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0 h-5 cursor-pointer hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="removeQuickSelector(sel)"
|
||||
>
|
||||
{{ QUICK_SELECT_OPTIONS.find(s => s.value === sel)?.label }}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="ml-0.5"
|
||||
><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<div class="text-muted-foreground">
|
||||
共 {{ allKeys.length }} 个账号,筛选 {{ filteredKeys.length }} 个,已选 {{ selectedKeyIds.length }} 个
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected"
|
||||
:disabled="filteredKeys.length === 0 || loading || executing"
|
||||
@update:checked="toggleSelectFiltered"
|
||||
/>
|
||||
<span class="text-muted-foreground">全选筛选结果</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[380px] overflow-y-auto rounded-lg border">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载账号列表...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredKeys.length === 0"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
无匹配账号
|
||||
</div>
|
||||
<label
|
||||
v-for="key in pagedKeys"
|
||||
: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"
|
||||
@update:checked="(checked) => toggleOne(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">{{ key.key_name || '未命名' }}</span>
|
||||
<Badge
|
||||
v-if="isOAuthInvalid(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>OAuth失效</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ key.oauth_plan_type }}</Badge>
|
||||
<Badge
|
||||
v-if="isBannedKey(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>封号</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
||||
<span v-if="key.proxy?.node_id">独立代理</span>
|
||||
<span
|
||||
v-if="key.last_used_at"
|
||||
class="ml-auto shrink-0"
|
||||
>{{ formatRelativeTime(key.last_used_at) }}</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="currentPage = 1"
|
||||
>
|
||||
<ChevronsLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="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="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="currentPage = totalPages"
|
||||
>
|
||||
<ChevronsRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedAction">
|
||||
<SelectTrigger class="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="选择动作" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in ACTION_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:disabled="executing || selectedKeyIds.length === 0 || loading"
|
||||
@click="executeAction"
|
||||
>
|
||||
<Play
|
||||
class="h-3.5 w-3.5"
|
||||
:class="executing ? 'animate-pulse' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
v-if="selectedAction === 'set_proxy'"
|
||||
:model-value="proxyNodeIdForAction"
|
||||
trigger-class="h-8"
|
||||
@update:model-value="(v: string) => proxyNodeIdForAction = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-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, 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'
|
||||
import { RefreshCw, Play, 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, type PoolKeyDetail } from '@/api/endpoints/pool'
|
||||
import { deleteEndpointKey, refreshProviderQuota, updateProviderKey } from '@/api/endpoints/keys'
|
||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
type QuickSelectorValue =
|
||||
| 'banned'
|
||||
| 'no_quota'
|
||||
| 'plan_free'
|
||||
| 'plan_team'
|
||||
| 'oauth_invalid'
|
||||
| 'proxy_unset'
|
||||
| 'proxy_set'
|
||||
| 'disabled'
|
||||
| 'enabled'
|
||||
|
||||
type BatchActionValue =
|
||||
| 'delete'
|
||||
| 'refresh_oauth'
|
||||
| 'refresh_quota'
|
||||
| 'clear_proxy'
|
||||
| 'set_proxy'
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerId: string
|
||||
providerName?: 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: 'no_quota', label: '无额度' },
|
||||
{ value: 'plan_free', label: '全部 Free' },
|
||||
{ value: 'plan_team', label: '全部 Team' },
|
||||
{ value: 'oauth_invalid', label: 'OAuth 失效' },
|
||||
{ value: 'proxy_unset', label: '未配置代理' },
|
||||
{ value: 'proxy_set', label: '已配置独立代理' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
{ value: 'enabled', label: '已启用' },
|
||||
]
|
||||
|
||||
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
|
||||
{ value: 'delete', label: '删除账号' },
|
||||
{ value: 'refresh_oauth', label: '刷新 OAuth' },
|
||||
{ value: 'refresh_quota', label: '刷新额度' },
|
||||
{ value: 'clear_proxy', label: '清除代理' },
|
||||
{ value: 'set_proxy', label: '配置代理' },
|
||||
{ value: 'enable', label: '启用' },
|
||||
{ value: 'disable', label: '禁用' },
|
||||
]
|
||||
|
||||
const { success, warning, error: showError } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const executing = ref(false)
|
||||
const allKeys = ref<PoolKeyDetail[]>([])
|
||||
const selectedKeyIds = ref<string[]>([])
|
||||
const searchText = ref('')
|
||||
const selectedAction = ref<BatchActionValue>('delete')
|
||||
const proxyNodeIdForAction = ref('')
|
||||
const lastResultMessage = ref('')
|
||||
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
|
||||
const currentPage = ref(1)
|
||||
const PAGE_SIZE = 50
|
||||
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
|
||||
})
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeAuthTypeLabel(authType: string): string {
|
||||
const text = normalizeText(authType)
|
||||
if (text === 'oauth') return 'OAuth'
|
||||
if (text === 'service_account') return 'Service'
|
||||
return 'API Key'
|
||||
}
|
||||
|
||||
function isBannedKey(key: PoolKeyDetail): boolean {
|
||||
const reason = normalizeText(key.oauth_invalid_reason)
|
||||
if (reason && /(banned|forbidden|blocked|suspend|封|禁|受限)/.test(reason)) return true
|
||||
if (Array.isArray(key.scheduling_reasons)) {
|
||||
return key.scheduling_reasons.some((item) => {
|
||||
const code = normalizeText(item.code)
|
||||
return code === 'account_banned' || code === 'account_forbidden' || code === 'account_blocked'
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function hasNoQuota(key: PoolKeyDetail): boolean {
|
||||
const quotaText = normalizeText(key.account_quota)
|
||||
if (!quotaText) return false
|
||||
if (/(无额度|额度不足|已耗尽|耗尽|depleted|exhausted|insufficient)/.test(quotaText)) return true
|
||||
if (/剩余\s*0(\.0+)?/.test(quotaText)) return true
|
||||
if (/\b0(\.0+)?\s*\/\s*\d/.test(quotaText)) return true
|
||||
if (/\b0(\.0+)?%/.test(quotaText)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
if (typeof key.oauth_expires_at === 'number' && key.oauth_expires_at > 0) {
|
||||
return key.oauth_expires_at * 1000 <= Date.now()
|
||||
}
|
||||
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_quota') return hasNoQuota(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 '-'
|
||||
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 shortenQuota(raw: string): string {
|
||||
// "周剩余 0.0%(5天3小时后重置)|5H剩余100.0%(5小时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()
|
||||
}).join(' | ')
|
||||
}
|
||||
|
||||
async function loadAllKeys(): Promise<void> {
|
||||
if (!props.providerId) {
|
||||
allKeys.value = []
|
||||
selectedKeyIds.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const pageSize = 200
|
||||
let page = 1
|
||||
let total = 0
|
||||
const collected: PoolKeyDetail[] = []
|
||||
|
||||
while (page <= 50) {
|
||||
const res = await listPoolKeys(props.providerId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: 'all',
|
||||
})
|
||||
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
|
||||
const validIds = new Set(collected.map((key) => key.key_id))
|
||||
selectedKeyIds.value = selectedKeyIds.value.filter((id) => validIds.has(id))
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '加载账号列表失败'))
|
||||
allKeys.value = []
|
||||
selectedKeyIds.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAction(): Promise<void> {
|
||||
if (executing.value) return
|
||||
if (selectedKeyIds.value.length === 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
|
||||
}
|
||||
|
||||
if (selectedAction.value === 'delete') {
|
||||
const confirmed = await confirm({
|
||||
title: '删除账号',
|
||||
message: `将删除 ${selectedKeys.length} 个账号,操作不可恢复,是否继续?`,
|
||||
confirmText: '确认删除',
|
||||
variant: 'destructive',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
if (selectedAction.value === 'set_proxy' && !proxyNodeIdForAction.value) {
|
||||
warning('请先选择代理节点')
|
||||
return
|
||||
}
|
||||
|
||||
executing.value = true
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
try {
|
||||
if (selectedAction.value === 'refresh_quota') {
|
||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||
const result = await refreshProviderQuota(props.providerId, targetIds)
|
||||
successCount = Number(result.success || 0)
|
||||
failedCount = Number(result.failed || 0)
|
||||
skippedCount = Math.max(0, targetIds.length - Number(result.total || 0))
|
||||
} else {
|
||||
const CONCURRENCY = props.batchConcurrency || 8
|
||||
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
|
||||
if (selectedAction.value === 'delete') {
|
||||
return () => deleteEndpointKey(key.key_id).then(() => 'success' as const)
|
||||
}
|
||||
if (selectedAction.value === 'refresh_oauth') {
|
||||
if (normalizeText(key.auth_type) !== 'oauth') return null
|
||||
return () => refreshProviderOAuth(key.key_id).then(() => 'success' as const)
|
||||
}
|
||||
if (selectedAction.value === 'clear_proxy') {
|
||||
return () => updateProviderKey(key.key_id, { proxy: null }).then(() => 'success' as const)
|
||||
}
|
||||
if (selectedAction.value === 'set_proxy') {
|
||||
return () => updateProviderKey(key.key_id, {
|
||||
proxy: { node_id: proxyNodeIdForAction.value, enabled: true },
|
||||
}).then(() => 'success' as const)
|
||||
}
|
||||
if (selectedAction.value === 'enable') {
|
||||
return () => updateProviderKey(key.key_id, { is_active: true }).then(() => 'success' as const)
|
||||
}
|
||||
if (selectedAction.value === 'disable') {
|
||||
return () => updateProviderKey(key.key_id, { is_active: false }).then(() => 'success' as const)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const tasks: Array<() => Promise<'success' | 'skip'>> = []
|
||||
for (const key of selectedKeys) {
|
||||
const task = taskForKey(key)
|
||||
if (task) tasks.push(task)
|
||||
else skippedCount += 1
|
||||
}
|
||||
|
||||
// 并发执行,限制并发数
|
||||
let cursor = 0
|
||||
const runNext = async (): Promise<void> => {
|
||||
while (cursor < tasks.length) {
|
||||
const idx = cursor++
|
||||
try {
|
||||
await tasks[idx]()
|
||||
successCount += 1
|
||||
} catch {
|
||||
failedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => runNext())
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount},跳过 ${skippedCount}`
|
||||
if (failedCount > 0) warning(lastResultMessage.value)
|
||||
else success(lastResultMessage.value)
|
||||
|
||||
const shouldClearSelection = selectedAction.value === 'delete'
|
||||
const previousSelection = new Set(selectedKeyIds.value)
|
||||
await loadAllKeys()
|
||||
if (shouldClearSelection) {
|
||||
selectedKeyIds.value = []
|
||||
} else {
|
||||
const existingIds = new Set(allKeys.value.map((key) => key.key_id))
|
||||
selectedKeyIds.value = [...previousSelection].filter((id) => existingIds.has(id))
|
||||
}
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '批量操作失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
searchText.value = ''
|
||||
lastResultMessage.value = ''
|
||||
activeQuickSelectors.value = []
|
||||
proxyNodesStore.ensureLoaded()
|
||||
loadAllKeys()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.providerId,
|
||||
(newId, oldId) => {
|
||||
if (!props.modelValue || !newId || newId === oldId) return
|
||||
selectedKeyIds.value = []
|
||||
loadAllKeys()
|
||||
},
|
||||
)
|
||||
|
||||
watch(filteredKeys, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
</script>
|
||||
490
frontend/src/features/pool/components/PoolAdvancedDialog.vue
Normal file
490
frontend/src/features/pool/components/PoolAdvancedDialog.vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="高级设置"
|
||||
description="冷却、健康、成本控制与其他高级参数"
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Cooldown & Health -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
冷却与健康
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">健康策略</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按上游错误自动冷却并跳过账号
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.health_policy_enabled"
|
||||
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">主动探测</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
定期检查 Key 可用性,提前发现异常
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.probing_enabled"
|
||||
@update:model-value="(v: boolean) => form.probing_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.probing_enabled"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测间隔
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probing_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="10"
|
||||
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">封号自动清除</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
检测到账号被封禁时自动从号池中移除
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.auto_remove_banned_keys"
|
||||
@update:model-value="(v: boolean) => form.auto_remove_banned_keys = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
429 冷却
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.rate_limit_cooldown_seconds ?? ''"
|
||||
type="number"
|
||||
min="10"
|
||||
max="3600"
|
||||
placeholder="300"
|
||||
@update:model-value="(v) => form.rate_limit_cooldown_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
529 冷却
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.overload_cooldown_seconds ?? ''"
|
||||
type="number"
|
||||
min="5"
|
||||
max="600"
|
||||
placeholder="30"
|
||||
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
粘性会话 TTL
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.sticky_session_ttl_seconds ?? ''"
|
||||
type="number"
|
||||
min="60"
|
||||
max="86400"
|
||||
placeholder="3600 (留空禁用)"
|
||||
@update:model-value="(v) => form.sticky_session_ttl_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
全局优先级
|
||||
<span class="text-xs text-muted-foreground">(global_key)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.global_priority ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="999999"
|
||||
placeholder="留空回退 provider_priority"
|
||||
@update:model-value="(v) => form.global_priority = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Operations -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
批量操作
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
并发数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.batch_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
placeholder="8"
|
||||
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
|
||||
/>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
批量刷新 OAuth / 额度等操作的并行请求数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude Code -->
|
||||
<div
|
||||
v-if="isClaudeCode"
|
||||
class="space-y-3"
|
||||
>
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
Claude Code
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Session ID 伪装</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
固定 metadata.user_id 中 session 片段
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_id_masking_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">仅限 CLI 客户端</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅允许 Claude Code CLI 格式请求
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cli_only_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Cache TTL 统一</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
强制所有 cache_control 使用相同 TTL 类型
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cache_ttl_override_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="claudeForm.cache_ttl_override_enabled"
|
||||
class="pl-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>TTL 类型</Label>
|
||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md w-fit">
|
||||
<button
|
||||
v-for="opt in ['ephemeral']"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
claudeForm.cache_ttl_override_target === opt
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="claudeForm.cache_ttl_override_target = opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">会话数量控制</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
限制单 Key 同时活跃会话数
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_control_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="claudeForm.session_control_enabled"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
最大会话数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.max_sessions ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
空闲超时
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="5"
|
||||
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Control -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
成本控制
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
成本窗口
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_window_seconds ?? ''"
|
||||
type="number"
|
||||
min="3600"
|
||||
max="86400"
|
||||
placeholder="18000 (5 小时)"
|
||||
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
Key 窗口限额
|
||||
<span class="text-xs text-muted-foreground">(tokens)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_limit_per_key_tokens ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
软阈值
|
||||
<span class="text-xs text-muted-foreground">(%)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_soft_threshold_percent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="80"
|
||||
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
import type {
|
||||
PoolAdvancedConfig,
|
||||
ClaudeCodeAdvancedConfig,
|
||||
ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints/types/provider'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerId: string
|
||||
providerType?: string
|
||||
currentConfig: PoolAdvancedConfig | null
|
||||
currentClaudeConfig?: ClaudeCodeAdvancedConfig | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: [provider: ProviderWithEndpointsSummary]
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const isClaudeCode = computed(() => {
|
||||
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
global_priority: null as number | null | undefined,
|
||||
sticky_session_ttl_seconds: null as number | null | undefined,
|
||||
health_policy_enabled: true,
|
||||
rate_limit_cooldown_seconds: null as number | null | undefined,
|
||||
overload_cooldown_seconds: null as number | null | undefined,
|
||||
cost_window_seconds: null as number | null | undefined,
|
||||
cost_limit_per_key_tokens: null as number | null | undefined,
|
||||
cost_soft_threshold_percent: null as number | null | undefined,
|
||||
batch_concurrency: null as number | null | undefined,
|
||||
probing_enabled: false,
|
||||
probing_interval_minutes: null as number | null | undefined,
|
||||
auto_remove_banned_keys: false,
|
||||
})
|
||||
|
||||
interface ClaudeFormState {
|
||||
session_control_enabled: boolean
|
||||
max_sessions: number | undefined
|
||||
session_idle_timeout_minutes: number
|
||||
session_id_masking_enabled: boolean
|
||||
cache_ttl_override_enabled: boolean
|
||||
cache_ttl_override_target: string
|
||||
cli_only_enabled: boolean
|
||||
}
|
||||
|
||||
const claudeForm = ref<ClaudeFormState>({
|
||||
session_control_enabled: true,
|
||||
max_sessions: undefined,
|
||||
session_idle_timeout_minutes: 5,
|
||||
session_id_masking_enabled: true,
|
||||
cache_ttl_override_enabled: false,
|
||||
cache_ttl_override_target: 'ephemeral',
|
||||
cli_only_enabled: false,
|
||||
})
|
||||
|
||||
function parseNum(v: string | number): number | undefined {
|
||||
if (v === '' || v === null || v === undefined) return undefined
|
||||
const n = Number(v)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (!open) return
|
||||
|
||||
const cfg = props.currentConfig
|
||||
form.value = {
|
||||
global_priority: cfg?.global_priority ?? null,
|
||||
sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null,
|
||||
health_policy_enabled: cfg?.health_policy_enabled !== false,
|
||||
rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null,
|
||||
overload_cooldown_seconds: cfg?.overload_cooldown_seconds ?? null,
|
||||
cost_window_seconds: cfg?.cost_window_seconds ?? null,
|
||||
cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null,
|
||||
cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null,
|
||||
batch_concurrency: cfg?.batch_concurrency ?? null,
|
||||
probing_enabled: cfg?.probing_enabled ?? false,
|
||||
probing_interval_minutes: cfg?.probing_interval_minutes ?? null,
|
||||
auto_remove_banned_keys: cfg?.auto_remove_banned_keys ?? false,
|
||||
}
|
||||
|
||||
const cc = props.currentClaudeConfig
|
||||
claudeForm.value = {
|
||||
session_control_enabled: cc?.max_sessions !== null,
|
||||
max_sessions: cc?.max_sessions ?? undefined,
|
||||
session_idle_timeout_minutes: cc?.session_idle_timeout_minutes ?? 5,
|
||||
session_id_masking_enabled: cc?.session_id_masking_enabled !== false,
|
||||
cache_ttl_override_enabled: cc?.cache_ttl_override_enabled ?? false,
|
||||
cache_ttl_override_target: cc?.cache_ttl_override_target ?? 'ephemeral',
|
||||
cli_only_enabled: cc?.cli_only_enabled ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 合并已有配置(保留 scheduling_presets 等不在此对话框编辑的字段)
|
||||
const poolAdvanced: Record<string, unknown> = {
|
||||
...(props.currentConfig ?? {}),
|
||||
global_priority: form.value.global_priority ?? undefined,
|
||||
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
|
||||
cost_window_seconds: form.value.cost_window_seconds ?? undefined,
|
||||
cost_limit_per_key_tokens: form.value.cost_limit_per_key_tokens ?? undefined,
|
||||
cost_soft_threshold_percent: form.value.cost_soft_threshold_percent ?? undefined,
|
||||
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
|
||||
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
|
||||
health_policy_enabled: form.value.health_policy_enabled,
|
||||
batch_concurrency: form.value.batch_concurrency ?? undefined,
|
||||
probing_enabled: form.value.probing_enabled,
|
||||
probing_interval_minutes: form.value.probing_enabled
|
||||
? (form.value.probing_interval_minutes ?? undefined)
|
||||
: undefined,
|
||||
auto_remove_banned_keys: form.value.auto_remove_banned_keys,
|
||||
}
|
||||
|
||||
const payload: Parameters<typeof updateProvider>[1] = {
|
||||
pool_advanced: poolAdvanced as PoolAdvancedConfig,
|
||||
}
|
||||
if (isClaudeCode.value) {
|
||||
const cf = claudeForm.value
|
||||
payload.claude_code_advanced = {
|
||||
max_sessions: cf.session_control_enabled ? (cf.max_sessions ?? null) : null,
|
||||
session_idle_timeout_minutes: cf.session_control_enabled ? cf.session_idle_timeout_minutes : null,
|
||||
session_id_masking_enabled: cf.session_id_masking_enabled,
|
||||
cache_ttl_override_enabled: cf.cache_ttl_override_enabled,
|
||||
cache_ttl_override_target: cf.cache_ttl_override_enabled ? cf.cache_ttl_override_target : undefined,
|
||||
cli_only_enabled: cf.cli_only_enabled,
|
||||
}
|
||||
}
|
||||
const updatedProvider = await updateProvider(props.providerId, payload)
|
||||
success('高级设置已保存')
|
||||
emit('saved', updatedProvider)
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -125,267 +125,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced toggle -->
|
||||
<div class="pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
{{ showAdvanced ? '收起高级参数' : '展开高级参数' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Advanced options -->
|
||||
<div
|
||||
v-if="showAdvanced"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Cooldown & Health -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
冷却与健康
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">健康策略</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按上游错误自动冷却并跳过账号
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.health_policy_enabled"
|
||||
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
429 冷却
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.rate_limit_cooldown_seconds ?? ''"
|
||||
type="number"
|
||||
min="10"
|
||||
max="3600"
|
||||
placeholder="300"
|
||||
@update:model-value="(v) => form.rate_limit_cooldown_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
529 冷却
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.overload_cooldown_seconds ?? ''"
|
||||
type="number"
|
||||
min="5"
|
||||
max="600"
|
||||
placeholder="30"
|
||||
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
粘性会话 TTL
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.sticky_session_ttl_seconds ?? ''"
|
||||
type="number"
|
||||
min="60"
|
||||
max="86400"
|
||||
placeholder="3600 (留空禁用)"
|
||||
@update:model-value="(v) => form.sticky_session_ttl_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
全局优先级
|
||||
<span class="text-xs text-muted-foreground">(global_key)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.global_priority ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="999999"
|
||||
placeholder="留空回退 provider_priority"
|
||||
@update:model-value="(v) => form.global_priority = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude Code -->
|
||||
<div
|
||||
v-if="isClaudeCode"
|
||||
class="space-y-3"
|
||||
>
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
Claude Code
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Session ID 伪装</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
固定 metadata.user_id 中 session 片段
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_id_masking_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">仅限 CLI 客户端</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅允许 Claude Code CLI 格式请求
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cli_only_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Cache TTL 统一</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
强制所有 cache_control 使用相同 TTL 类型
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cache_ttl_override_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="claudeForm.cache_ttl_override_enabled"
|
||||
class="pl-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>TTL 类型</Label>
|
||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md w-fit">
|
||||
<button
|
||||
v-for="opt in ['ephemeral']"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
claudeForm.cache_ttl_override_target === opt
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="claudeForm.cache_ttl_override_target = opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">会话数量控制</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
限制单 Key 同时活跃会话数
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_control_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="claudeForm.session_control_enabled"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
最大会话数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.max_sessions ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
空闲超时
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="5"
|
||||
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Control -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
成本控制
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
成本窗口
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_window_seconds ?? ''"
|
||||
type="number"
|
||||
min="3600"
|
||||
max="86400"
|
||||
placeholder="18000 (5 小时)"
|
||||
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
Key 窗口限额
|
||||
<span class="text-xs text-muted-foreground">(tokens)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_limit_per_key_tokens ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
软阈值
|
||||
<span class="text-xs text-muted-foreground">(%)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_soft_threshold_percent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="80"
|
||||
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -409,7 +148,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { GripVertical } from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
|
||||
import { Dialog, Button, Switch } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
@@ -417,7 +156,6 @@ import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
|
||||
import type { PoolPresetMeta } from '@/api/endpoints/pool'
|
||||
import type {
|
||||
PoolAdvancedConfig,
|
||||
ClaudeCodeAdvancedConfig,
|
||||
SchedulingPresetItem,
|
||||
ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints/types/provider'
|
||||
@@ -444,7 +182,6 @@ const props = defineProps<{
|
||||
providerId: string
|
||||
providerType?: string
|
||||
currentConfig: PoolAdvancedConfig | null
|
||||
currentClaudeConfig?: ClaudeCodeAdvancedConfig | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -546,7 +283,6 @@ const DEFAULT_ENABLED_PRESETS = new Set(['lru', 'quota_balanced'])
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const showAdvanced = ref(false)
|
||||
const presetDefs = ref<PoolPresetMeta[]>([])
|
||||
const presetDefsLoaded = ref(false)
|
||||
const loadingPresetDefs = ref(false)
|
||||
@@ -555,45 +291,6 @@ const draggedIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
const presetList = ref<PresetListItem[]>([])
|
||||
|
||||
const form = ref({
|
||||
global_priority: null as number | null | undefined,
|
||||
sticky_session_ttl_seconds: null as number | null | undefined,
|
||||
health_policy_enabled: true,
|
||||
rate_limit_cooldown_seconds: null as number | null | undefined,
|
||||
overload_cooldown_seconds: null as number | null | undefined,
|
||||
cost_window_seconds: null as number | null | undefined,
|
||||
cost_limit_per_key_tokens: null as number | null | undefined,
|
||||
cost_soft_threshold_percent: null as number | null | undefined,
|
||||
})
|
||||
|
||||
const isClaudeCode = computed(() => normalizeProviderType(props.providerType) === 'claude_code')
|
||||
|
||||
interface ClaudeFormState {
|
||||
session_control_enabled: boolean
|
||||
max_sessions: number | undefined
|
||||
session_idle_timeout_minutes: number
|
||||
session_id_masking_enabled: boolean
|
||||
cache_ttl_override_enabled: boolean
|
||||
cache_ttl_override_target: string
|
||||
cli_only_enabled: boolean
|
||||
}
|
||||
|
||||
const claudeForm = ref<ClaudeFormState>({
|
||||
session_control_enabled: true,
|
||||
max_sessions: undefined,
|
||||
session_idle_timeout_minutes: 5,
|
||||
session_id_masking_enabled: true,
|
||||
cache_ttl_override_enabled: false,
|
||||
cache_ttl_override_target: 'ephemeral',
|
||||
cli_only_enabled: false,
|
||||
})
|
||||
|
||||
function parseNum(v: string | number): number | undefined {
|
||||
if (v === '' || v === null || v === undefined) return undefined
|
||||
const n = Number(v)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
function normalizeProviderType(value: string | undefined): string {
|
||||
return (value || '').trim().toLowerCase()
|
||||
}
|
||||
@@ -1006,32 +703,8 @@ function handleDrop(dropIndex: number) {
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
showAdvanced.value = false
|
||||
await ensurePresetDefsLoaded()
|
||||
presetList.value = normalizeMutexSelection(loadFromConfig(props.currentConfig))
|
||||
|
||||
const cfg = props.currentConfig
|
||||
form.value = {
|
||||
global_priority: cfg?.global_priority ?? null,
|
||||
sticky_session_ttl_seconds: cfg?.sticky_session_ttl_seconds ?? null,
|
||||
health_policy_enabled: cfg?.health_policy_enabled !== false,
|
||||
rate_limit_cooldown_seconds: cfg?.rate_limit_cooldown_seconds ?? null,
|
||||
overload_cooldown_seconds: cfg?.overload_cooldown_seconds ?? null,
|
||||
cost_window_seconds: cfg?.cost_window_seconds ?? null,
|
||||
cost_limit_per_key_tokens: cfg?.cost_limit_per_key_tokens ?? null,
|
||||
cost_soft_threshold_percent: cfg?.cost_soft_threshold_percent ?? null,
|
||||
}
|
||||
|
||||
const cc = props.currentClaudeConfig
|
||||
claudeForm.value = {
|
||||
session_control_enabled: cc?.max_sessions !== null,
|
||||
max_sessions: cc?.max_sessions ?? undefined,
|
||||
session_idle_timeout_minutes: cc?.session_idle_timeout_minutes ?? 5,
|
||||
session_id_masking_enabled: cc?.session_id_masking_enabled !== false,
|
||||
cache_ttl_override_enabled: cc?.cache_ttl_override_enabled ?? false,
|
||||
cache_ttl_override_target: cc?.cache_ttl_override_target ?? 'ephemeral',
|
||||
cli_only_enabled: cc?.cli_only_enabled ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
@@ -1049,32 +722,13 @@ async function handleSave() {
|
||||
return result
|
||||
})
|
||||
|
||||
const payload: Parameters<typeof updateProvider>[1] = {
|
||||
pool_advanced: {
|
||||
global_priority: form.value.global_priority ?? undefined,
|
||||
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
|
||||
scheduling_presets: schedulingPresets,
|
||||
scoring_weights: undefined,
|
||||
latency_window_seconds: undefined,
|
||||
latency_sample_limit: undefined,
|
||||
cost_window_seconds: form.value.cost_window_seconds ?? undefined,
|
||||
cost_limit_per_key_tokens: form.value.cost_limit_per_key_tokens ?? undefined,
|
||||
cost_soft_threshold_percent: form.value.cost_soft_threshold_percent ?? undefined,
|
||||
rate_limit_cooldown_seconds: form.value.rate_limit_cooldown_seconds ?? undefined,
|
||||
overload_cooldown_seconds: form.value.overload_cooldown_seconds ?? undefined,
|
||||
health_policy_enabled: form.value.health_policy_enabled,
|
||||
},
|
||||
// 合并已有配置,仅覆盖 scheduling_presets,保留其他字段
|
||||
const mergedAdvanced: Record<string, unknown> = {
|
||||
...(props.currentConfig ?? {}),
|
||||
scheduling_presets: schedulingPresets,
|
||||
}
|
||||
if (isClaudeCode.value) {
|
||||
const cf = claudeForm.value
|
||||
payload.claude_code_advanced = {
|
||||
max_sessions: cf.session_control_enabled ? (cf.max_sessions ?? null) : null,
|
||||
session_idle_timeout_minutes: cf.session_control_enabled ? cf.session_idle_timeout_minutes : null,
|
||||
session_id_masking_enabled: cf.session_id_masking_enabled,
|
||||
cache_ttl_override_enabled: cf.cache_ttl_override_enabled,
|
||||
cache_ttl_override_target: cf.cache_ttl_override_enabled ? cf.cache_ttl_override_target : undefined,
|
||||
cli_only_enabled: cf.cli_only_enabled,
|
||||
}
|
||||
const payload: Parameters<typeof updateProvider>[1] = {
|
||||
pool_advanced: mergedAdvanced as PoolAdvancedConfig,
|
||||
}
|
||||
const updatedProvider = await updateProvider(props.providerId, payload)
|
||||
success('号池调度已保存')
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="nodeId ? 'text-blue-600' : ''"
|
||||
:disabled="saving"
|
||||
:title="title"
|
||||
>
|
||||
<Globe class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-72 p-3"
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium">提供商代理节点</span>
|
||||
<Button
|
||||
v-if="nodeId"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-[10px] text-muted-foreground"
|
||||
:disabled="saving"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
:model-value="nodeId || ''"
|
||||
trigger-class="h-8"
|
||||
@update:model-value="emit('select', $event)"
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground">
|
||||
{{ nodeId ? '当前使用提供商独立代理' : '未设置,使用系统默认网络出口' }}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Globe } from 'lucide-vue-next'
|
||||
import { Button, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
nodeId: string | null | undefined
|
||||
saving: boolean
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
select: [nodeId: string]
|
||||
clear: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -709,6 +709,28 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
const attempts = audit.attempts
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return []
|
||||
|
||||
const providerNameById = new Map<string, string>()
|
||||
for (const candidate of rawTimeline.value) {
|
||||
const providerId = String(candidate.provider_id || '').trim()
|
||||
const providerName = String(candidate.provider_name || '').trim()
|
||||
if (!providerId || !providerName) continue
|
||||
if (!providerNameById.has(providerId)) {
|
||||
providerNameById.set(providerId, providerName)
|
||||
}
|
||||
}
|
||||
const providerTypeLikeNames = new Set<string>([
|
||||
'codex',
|
||||
'kiro',
|
||||
'antigravity',
|
||||
'claude_code',
|
||||
'claude code',
|
||||
'gemini_cli',
|
||||
'gemini cli',
|
||||
'oauth',
|
||||
'api_key',
|
||||
'api key',
|
||||
])
|
||||
|
||||
const traceMap = new Map<string, CandidateRecord>()
|
||||
for (const candidate of rawTimeline.value) {
|
||||
traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate)
|
||||
@@ -757,6 +779,21 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
pool_group_id: finalPoolGroupId,
|
||||
}
|
||||
}
|
||||
|
||||
const mergedProviderId = String(merged.provider_id || '').trim()
|
||||
if (mergedProviderId) {
|
||||
const inferredProviderName = providerNameById.get(mergedProviderId)
|
||||
const currentProviderName = String(merged.provider_name || '').trim()
|
||||
if (
|
||||
inferredProviderName
|
||||
&& (
|
||||
!currentProviderName
|
||||
|| providerTypeLikeNames.has(currentProviderName.toLowerCase())
|
||||
)
|
||||
) {
|
||||
merged.provider_name = inferredProviderName
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
.filter((item): item is CandidateRecord => item !== null)
|
||||
@@ -807,14 +844,23 @@ const normalizeProviderName = (value: string): string => {
|
||||
return text.replace(/反代$/u, '').trim() || text
|
||||
}
|
||||
|
||||
const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): string => {
|
||||
const getProviderDisplayName = (
|
||||
attempt: CandidateRecord | null | undefined,
|
||||
options: { allowAuthTypeFallback?: boolean } = {},
|
||||
): string => {
|
||||
const allowAuthTypeFallback = options.allowAuthTypeFallback ?? true
|
||||
if (!attempt) return '未知'
|
||||
const authType = String(attempt.key_auth_type || '').trim().toLowerCase()
|
||||
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
|
||||
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
|
||||
}
|
||||
// 优先使用提供商名称(管理后台设置的名称)
|
||||
const providerName = String(attempt.provider_name || '').trim()
|
||||
return providerName ? normalizeProviderName(providerName) : '未知'
|
||||
if (providerName) return normalizeProviderName(providerName)
|
||||
if (allowAuthTypeFallback) {
|
||||
// 回退:根据 auth_type 推断显示名称
|
||||
const authType = String(attempt.key_auth_type || '').trim().toLowerCase()
|
||||
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
|
||||
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
|
||||
}
|
||||
}
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const normalizeProviderIdentity = (value: unknown): string => {
|
||||
@@ -898,7 +944,7 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
|
||||
poolGroups.push({
|
||||
id: `pool:${groupId}`,
|
||||
providerName: getProviderDisplayName(poolPrimary),
|
||||
providerName: getProviderDisplayName(poolPrimary, { allowAuthTypeFallback: false }),
|
||||
primary: poolPrimary,
|
||||
primaryStatus: poolPrimaryStatus,
|
||||
allAttempts: attempts,
|
||||
|
||||
@@ -12,14 +12,13 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-semibold">
|
||||
号池管理
|
||||
<span
|
||||
v-if="poolHeaderMetaText"
|
||||
class="ml-2 text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
| {{ poolHeaderMetaText }}
|
||||
</span>
|
||||
</h3>
|
||||
<Badge
|
||||
v-if="selectedProviderType"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 h-5 text-muted-foreground"
|
||||
>
|
||||
{{ selectedProviderType }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
@@ -32,13 +31,33 @@
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<ProviderProxyPopover
|
||||
v-if="selectedProviderId"
|
||||
:open="providerProxyMobilePopoverOpen"
|
||||
:node-id="selectedProviderData?.proxy?.node_id"
|
||||
:saving="savingProviderProxy"
|
||||
:title="getProviderProxyButtonTitle()"
|
||||
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('mobile', open)"
|
||||
@select="setProviderProxy"
|
||||
@clear="clearProviderProxy"
|
||||
/>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="高级设置"
|
||||
@click="showAdvancedDialog = true"
|
||||
>
|
||||
<Settings2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
title="调整号池调度"
|
||||
@click="showSchedulingDialog = true"
|
||||
title="号池调度"
|
||||
@click="openSchedulingDialog()"
|
||||
>
|
||||
调度
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground" />
|
||||
@@ -47,11 +66,23 @@
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="清理已知封号账号"
|
||||
@click="handleCleanupBannedKeys"
|
||||
class="h-8 w-8"
|
||||
title="账号"
|
||||
@click="showAccountBatchDialog = true"
|
||||
>
|
||||
<Ban class="w-3.5 h-3.5" />
|
||||
<Users class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="getProviderToggleButtonClass()"
|
||||
:disabled="togglingProviderStatus"
|
||||
:title="getProviderToggleButtonTitle()"
|
||||
@click="toggleSelectedProviderStatus"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="refreshCurrentPageLoading"
|
||||
@@ -80,6 +111,10 @@
|
||||
>
|
||||
{{ item.provider_name }}
|
||||
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
|
||||
<span
|
||||
v-if="!item.pool_enabled"
|
||||
class="ml-1 text-[10px] text-amber-600"
|
||||
>未启用</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -122,14 +157,13 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-semibold">
|
||||
号池管理
|
||||
<span
|
||||
v-if="poolHeaderMetaText"
|
||||
class="ml-2 text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
| {{ poolHeaderMetaText }}
|
||||
</span>
|
||||
</h3>
|
||||
<Badge
|
||||
v-if="selectedProviderType"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 h-5 text-muted-foreground"
|
||||
>
|
||||
{{ selectedProviderType }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
@@ -150,6 +184,10 @@
|
||||
>
|
||||
{{ item.provider_name }}
|
||||
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
|
||||
<span
|
||||
v-if="!item.pool_enabled"
|
||||
class="ml-1 text-[10px] text-amber-600"
|
||||
>未启用</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -193,7 +231,7 @@
|
||||
v-if="selectedProviderId"
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
title="点击调整号池调度"
|
||||
@click="showSchedulingDialog = true"
|
||||
@click="openSchedulingDialog()"
|
||||
>
|
||||
<span class="text-muted-foreground/80 hidden lg:inline">调度:</span>
|
||||
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
|
||||
@@ -213,15 +251,47 @@
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<ProviderProxyPopover
|
||||
v-if="selectedProviderId"
|
||||
:open="providerProxyDesktopPopoverOpen"
|
||||
:node-id="selectedProviderData?.proxy?.node_id"
|
||||
:saving="savingProviderProxy"
|
||||
:title="getProviderProxyButtonTitle()"
|
||||
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('desktop', open)"
|
||||
@select="setProviderProxy"
|
||||
@clear="clearProviderProxy"
|
||||
/>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="清理已知封号账号"
|
||||
@click="handleCleanupBannedKeys"
|
||||
class="h-8 w-8"
|
||||
title="高级设置"
|
||||
@click="showAdvancedDialog = true"
|
||||
>
|
||||
<Ban class="w-3.5 h-3.5" />
|
||||
<Settings2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="账号"
|
||||
@click="showAccountBatchDialog = true"
|
||||
>
|
||||
<Users class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="getProviderToggleButtonClass()"
|
||||
:disabled="togglingProviderStatus"
|
||||
:title="getProviderToggleButtonTitle()"
|
||||
@click="toggleSelectedProviderStatus"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="refreshCurrentPageLoading"
|
||||
@@ -283,28 +353,44 @@
|
||||
v-if="keyPage.keys.length > 0"
|
||||
class="hidden xl:block overflow-x-auto"
|
||||
>
|
||||
<Table class="min-w-[1400px]">
|
||||
<Table class="w-full table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[320px] font-semibold whitespace-nowrap">
|
||||
<TableHead
|
||||
class="font-semibold whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.name }"
|
||||
>
|
||||
名称
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="showAccountQuotaColumn"
|
||||
class="w-[240px] font-semibold whitespace-nowrap"
|
||||
class="font-semibold whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.quota }"
|
||||
>
|
||||
配额
|
||||
</TableHead>
|
||||
<TableHead class="w-24 font-semibold whitespace-nowrap">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-24 font-semibold whitespace-nowrap">
|
||||
最后使用
|
||||
</TableHead>
|
||||
<TableHead class="w-[160px] font-semibold whitespace-nowrap">
|
||||
<TableHead
|
||||
class="px-2 font-semibold text-center whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.stats }"
|
||||
>
|
||||
统计
|
||||
</TableHead>
|
||||
<TableHead class="w-[220px] font-semibold text-center whitespace-nowrap">
|
||||
<TableHead
|
||||
class="font-semibold text-center whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.lastUsed }"
|
||||
>
|
||||
最后使用
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class="font-semibold text-center whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.status }"
|
||||
>
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class="px-2 font-semibold text-center whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.actions }"
|
||||
>
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -319,13 +405,13 @@
|
||||
<TableCell
|
||||
class="py-3"
|
||||
>
|
||||
<div class="max-w-[320px] min-w-0">
|
||||
<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 gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
|
||||
<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"
|
||||
@@ -419,7 +505,7 @@
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="showAccountQuotaColumn"
|
||||
class="py-3"
|
||||
class="py-3 align-middle"
|
||||
>
|
||||
<div
|
||||
v-if="quotaProgressMap[key.key_id]?.length"
|
||||
@@ -464,22 +550,8 @@
|
||||
class="text-xs text-muted-foreground"
|
||||
>-</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<Badge
|
||||
:variant="getSchedulingBadgeVariant(key)"
|
||||
class="text-[10px]"
|
||||
:title="getSchedulingTitle(key)"
|
||||
>
|
||||
{{ getSchedulingBadgeLabel(key) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<div class="grid grid-rows-3 gap-0.5 w-[150px] text-[10px] leading-4">
|
||||
<TableCell class="py-3 px-2 align-middle">
|
||||
<div class="grid grid-rows-3 gap-0.5 w-[136px] mx-auto text-[10px] leading-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted-foreground">请求</span>
|
||||
<span class="tabular-nums text-foreground/90">
|
||||
@@ -500,7 +572,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3">
|
||||
<TableCell class="py-3 text-center">
|
||||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 text-center">
|
||||
<Badge
|
||||
:variant="getSchedulingBadgeVariant(key)"
|
||||
class="text-[10px]"
|
||||
:title="getSchedulingTitle(key)"
|
||||
>
|
||||
{{ getSchedulingBadgeLabel(key) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3 px-2 align-middle">
|
||||
<div class="flex justify-center gap-0.5">
|
||||
<Button
|
||||
v-if="key.cooldown_reason"
|
||||
@@ -975,9 +1061,25 @@
|
||||
:provider-id="selectedProviderId"
|
||||
:provider-type="selectedProviderType"
|
||||
:current-config="selectedProviderConfig"
|
||||
@saved="handleSchedulingSaved"
|
||||
/>
|
||||
<PoolAdvancedDialog
|
||||
v-if="selectedProviderId"
|
||||
v-model="showAdvancedDialog"
|
||||
:provider-id="selectedProviderId"
|
||||
:provider-type="selectedProviderType"
|
||||
:current-config="selectedProviderConfig"
|
||||
:current-claude-config="selectedProviderClaudeConfig"
|
||||
@saved="handleSchedulingSaved"
|
||||
/>
|
||||
<PoolAccountBatchDialog
|
||||
v-if="selectedProviderId"
|
||||
v-model="showAccountBatchDialog"
|
||||
:provider-id="selectedProviderId"
|
||||
:provider-name="selectedProviderData?.name || ''"
|
||||
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
|
||||
@changed="handleAccountBatchChanged"
|
||||
/>
|
||||
<KeyFormDialog
|
||||
v-if="selectedProviderId"
|
||||
:open="keyFormDialogOpen"
|
||||
@@ -1020,7 +1122,8 @@ import {
|
||||
Globe,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
Ban,
|
||||
Users,
|
||||
Settings2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import {
|
||||
@@ -1055,7 +1158,6 @@ import {
|
||||
getPoolSchedulingPresets,
|
||||
listPoolKeys,
|
||||
clearPoolCooldown,
|
||||
cleanupBannedPoolKeys,
|
||||
} from '@/api/endpoints/pool'
|
||||
import {
|
||||
revealEndpointKey,
|
||||
@@ -1073,9 +1175,12 @@ import type {
|
||||
PoolPresetMeta,
|
||||
} from '@/api/endpoints/pool'
|
||||
import type { ClaudeCodeAdvancedConfig, EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
|
||||
import { getProvider } 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'
|
||||
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
|
||||
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue'
|
||||
import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue'
|
||||
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
|
||||
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
||||
@@ -1105,7 +1210,8 @@ async function loadOverview() {
|
||||
try {
|
||||
const res = await getPoolOverview()
|
||||
if (requestId !== overviewRequestId) return
|
||||
const enabledProviders = res.items.filter(item => item.pool_enabled)
|
||||
const allProviders = Array.isArray(res.items) ? res.items : []
|
||||
const enabledProviders = allProviders.filter(item => item.pool_enabled)
|
||||
poolProviders.value = enabledProviders
|
||||
|
||||
// Keep selected provider aligned with dropdown options.
|
||||
@@ -1121,6 +1227,8 @@ async function loadOverview() {
|
||||
} else {
|
||||
selectedProviderId.value = null
|
||||
selectedProviderData.value = null
|
||||
showAccountBatchDialog.value = false
|
||||
closeProviderProxyPopovers()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1139,6 +1247,7 @@ async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSumma
|
||||
selectedProviderData.value = updatedProvider
|
||||
}
|
||||
showSchedulingDialog.value = false
|
||||
showAdvancedDialog.value = false
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
@@ -1197,7 +1306,17 @@ async function loadSchedulingPresetMetas(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProviderOverview = computed<PoolOverviewItem | null>(() => {
|
||||
const selectedId = selectedProviderId.value
|
||||
if (!selectedId) return null
|
||||
return poolProviders.value.find(item => item.provider_id === selectedId) || null
|
||||
})
|
||||
|
||||
const poolSchedulingLabel = computed(() => {
|
||||
if (!selectedProviderConfig.value && selectedProviderOverview.value?.pool_enabled === false) {
|
||||
return '未启用'
|
||||
}
|
||||
|
||||
const cfg = selectedProviderConfig.value
|
||||
const presets = Array.isArray(cfg?.scheduling_presets) ? cfg.scheduling_presets : []
|
||||
const presetLabels = presetLabelsByName.value
|
||||
@@ -1239,23 +1358,63 @@ const poolSchedulingLabel = computed(() => {
|
||||
const selectedProviderType = computed(() => {
|
||||
const fromDetail = String(selectedProviderData.value?.provider_type || '').trim().toLowerCase()
|
||||
if (fromDetail) return fromDetail
|
||||
const fromOverview = poolProviders.value.find(item => item.provider_id === selectedProviderId.value)?.provider_type
|
||||
const fromOverview = selectedProviderOverview.value?.provider_type
|
||||
return String(fromOverview || '').trim().toLowerCase()
|
||||
})
|
||||
|
||||
const selectedProviderStatusText = computed(() => {
|
||||
if (!selectedProviderId.value) return ''
|
||||
const providerActive = selectedProviderData.value?.is_active
|
||||
if (providerActive === false) return '禁用'
|
||||
if (providerActive === true) return '启用'
|
||||
if (selectedProviderOverview.value?.pool_enabled === false) return '禁用'
|
||||
if (selectedProviderOverview.value?.pool_enabled === true) return '启用'
|
||||
return ''
|
||||
})
|
||||
|
||||
const poolHeaderMetaText = computed(() => {
|
||||
const providerType = selectedProviderType.value
|
||||
const status = selectedProviderStatusText.value
|
||||
if (providerType && status) return `${providerType} | ${status}`
|
||||
return providerType || status || ''
|
||||
})
|
||||
|
||||
const showAccountQuotaColumn = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
})
|
||||
|
||||
const desktopColumnWidths = computed(() => {
|
||||
if (showAccountQuotaColumn.value) {
|
||||
return {
|
||||
name: '28%',
|
||||
quota: '23%',
|
||||
stats: '15%',
|
||||
lastUsed: '10%',
|
||||
status: '8%',
|
||||
actions: '16%',
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: '40%',
|
||||
quota: '0%',
|
||||
stats: '18%',
|
||||
lastUsed: '12%',
|
||||
status: '10%',
|
||||
actions: '20%',
|
||||
}
|
||||
})
|
||||
|
||||
async function selectProvider(id: string) {
|
||||
const requestId = ++selectProviderRequestId
|
||||
selectedProviderId.value = id
|
||||
editingKeyDetail.value = null
|
||||
showAccountBatchDialog.value = false
|
||||
keyPermissionsDialogOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
oauthKeyEditDialogOpen.value = false
|
||||
closeProviderProxyPopovers()
|
||||
proxyDesktopPopoverOpenKeyId.value = null
|
||||
proxyMobilePopoverOpenKeyId.value = null
|
||||
suppressFiltersWatch = true
|
||||
@@ -1859,29 +2018,139 @@ async function toggleKeyActive(key: PoolKeyDetail) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCleanupBannedKeys() {
|
||||
if (!selectedProviderId.value) return
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '清理封号账号',
|
||||
message: '将删除该 Provider 下已识别为封号/封禁的账号。此操作不可恢复,是否继续?',
|
||||
confirmText: '确认清理',
|
||||
variant: 'destructive',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await cleanupBannedPoolKeys(selectedProviderId.value)
|
||||
success(res.message || `已清理 ${res.affected} 个账号`)
|
||||
await Promise.all([loadKeys(), loadOverview()])
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '清理封号账号失败'))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialogs ---
|
||||
const showImportDialog = ref(false)
|
||||
const showSchedulingDialog = ref(false)
|
||||
const showAdvancedDialog = ref(false)
|
||||
const showAccountBatchDialog = ref(false)
|
||||
const providerProxyMobilePopoverOpen = ref(false)
|
||||
const providerProxyDesktopPopoverOpen = ref(false)
|
||||
const savingProviderProxy = ref(false)
|
||||
const togglingProviderStatus = ref(false)
|
||||
|
||||
function openSchedulingDialog() {
|
||||
showSchedulingDialog.value = true
|
||||
}
|
||||
|
||||
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> {
|
||||
if (togglingProviderStatus.value) return
|
||||
const providerId = selectedProviderId.value
|
||||
const current = selectedProviderData.value
|
||||
if (!providerId || !current) return
|
||||
|
||||
const nextStatus = !current.is_active
|
||||
if (!nextStatus) {
|
||||
const confirmed = await confirm({
|
||||
title: '禁用提供商',
|
||||
message: `禁用后该提供商(${current.name})将不再参与调度,是否继续?`,
|
||||
confirmText: '确认禁用',
|
||||
variant: 'destructive',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
togglingProviderStatus.value = true
|
||||
try {
|
||||
const updated = await updateProvider(providerId, { is_active: nextStatus })
|
||||
if (selectedProviderId.value === providerId) {
|
||||
selectedProviderData.value = updated
|
||||
}
|
||||
success(nextStatus ? '提供商已启用' : '提供商已禁用')
|
||||
await loadOverview()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
|
||||
} finally {
|
||||
togglingProviderStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccountBatchChanged(): Promise<void> {
|
||||
await Promise.all([loadKeys(), loadOverview()])
|
||||
}
|
||||
|
||||
async function handleAccountDialogSaved() {
|
||||
showImportDialog.value = false
|
||||
|
||||
Reference in New Issue
Block a user