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:
fawney19
2026-03-05 15:15:26 +08:00
parent fdb50a065b
commit b1be413dc0
29 changed files with 3098 additions and 852 deletions

View 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.05小时0分钟后重置"
// -> "周0.0% 5d3h | 5H100.0% 5h"
return raw.split('|').map((seg) => {
let s = seg.trim()
s = s.replace(/剩余\s*/g, '')
s = s.replace(//g, '%')
s = s.replace(/[(]\s*(\d+)\s*天\s*(\d+)\s*小时.*?[)]/g, ' $1d$2h')
s = s.replace(/[(]\s*(\d+)\s*小时\s*(\d+)\s*分钟.*?[)]/g, ' $1h$2m')
s = s.replace(/[(]\s*(\d+)\s*小时.*?[)]/g, ' $1h')
s = s.replace(/[(]\s*(\d+)\s*分钟.*?[)]/g, ' $1m')
s = s.replace(/[(]\s*(\d+)\s*天.*?[)]/g, ' $1d')
s = s.replace(/[(].*?[)]/g, '')
return s.trim()
}).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>

View 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>

View File

@@ -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('号池调度已保存')

View File

@@ -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>

View File

@@ -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,