mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(pool): 引入多维评分调度策略与账号状态检测
- 新增 multi_score 调度模式,支持 LRU/延迟/健康度/剩余额度多维加权评分 - 新增调度预设维度系统(free_team_first, quota_balanced, recent_refresh, single_account),支持有序对象列表配置格式并兼容旧字符串列表 - 新增 account_state 模块,统一账号封禁/受限检测逻辑,替代分散在 routes 中的判断代码 - 新增 health_cache 模块和 latency 采样(redis_ops.record_latency / batch_get_latency_avgs) - RequestDispatcher 返回 ttfb_ms,PoolManager.on_request_success 记录延迟样本 - 前端:PoolConfigDialog 替换为 PoolSchedulingDialog,支持预设维度可视化配置;号池管理页增加调度模式标签与账号异常 Badge 显示 - 提取前端 accountBlock 工具函数,ProviderDetailDrawer 复用统一判断 - scheduling_dimensions 增加 account_state 和 latency 维度评估 - 补充 account_state、health_cache、multi_score 策略、preset 维度、redis latency 等测试
This commit is contained in:
@@ -75,6 +75,20 @@ export interface PoolOverviewResponse {
|
||||
items: PoolOverviewItem[]
|
||||
}
|
||||
|
||||
export interface PoolPresetModeMeta {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface PoolPresetMeta {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
providers: string[]
|
||||
modes?: PoolPresetModeMeta[] | null
|
||||
default_mode?: string | null
|
||||
}
|
||||
|
||||
export interface PoolKeyDetail {
|
||||
key_id: string
|
||||
key_name: string
|
||||
@@ -182,6 +196,13 @@ export async function getPoolOverview(): Promise<PoolOverviewResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPoolSchedulingPresets(): Promise<PoolPresetMeta[]> {
|
||||
return dedupedRequest('pool:scheduling-presets', async () => {
|
||||
const response = await client.get<PoolPresetMeta[]>('/api/admin/pool/scheduling-presets')
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function listPoolKeys(
|
||||
providerId: string,
|
||||
params: PoolKeysQuery = {},
|
||||
|
||||
@@ -453,11 +453,29 @@ export interface ClaudeCodeAdvancedConfig {
|
||||
cli_only_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SchedulingPresetItem {
|
||||
preset: string
|
||||
enabled: boolean
|
||||
mode?: string | null
|
||||
}
|
||||
|
||||
export interface PoolAdvancedConfig {
|
||||
global_priority?: number | null
|
||||
sticky_session_ttl_seconds?: number | null
|
||||
load_threshold_percent?: number | null
|
||||
// 旧字段(兼容读取)
|
||||
lru_enabled?: boolean
|
||||
scheduling_mode?: 'lru' | 'multi_score' | null
|
||||
// 新格式:对象列表;旧格式:字符串列表
|
||||
scheduling_presets?: SchedulingPresetItem[] | string[] | null
|
||||
scoring_weights?: {
|
||||
lru?: number
|
||||
latency?: number
|
||||
health?: number
|
||||
cost_remaining?: number
|
||||
} | null
|
||||
latency_window_seconds?: number | null
|
||||
latency_sample_limit?: number | null
|
||||
cost_window_seconds?: number | null
|
||||
cost_limit_per_key_tokens?: number | null
|
||||
cost_soft_threshold_percent?: number | null
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="号池配置"
|
||||
description="调整号池调度策略和健康检查参数"
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form
|
||||
class="space-y-5"
|
||||
@submit.prevent="handleSave"
|
||||
>
|
||||
<!-- 调度策略 -->
|
||||
<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">LRU 调度</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
优先选择最久未用的 Key
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.lru_enabled"
|
||||
@update:model-value="(v: boolean) => form.lru_enabled = v"
|
||||
/>
|
||||
</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)"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
同一对话始终路由到同一 Key
|
||||
</p>
|
||||
</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)"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
global_key 模式下号池整体排序值(越小越优先)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 冷却与健康 -->
|
||||
<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">
|
||||
按上游错误码自动冷却/禁用 Key
|
||||
</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>
|
||||
|
||||
<!-- 成本控制 -->
|
||||
<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>
|
||||
|
||||
<!-- Claude Code 特有配置 -->
|
||||
<template v-if="providerType === 'claude_code'">
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
Claude Code
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<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="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-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">最大会话数</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.max_sessions ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
placeholder="例如 20"
|
||||
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">会话空闲超时 (分钟)</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>
|
||||
|
||||
<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">TLS 指纹模拟</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
模拟 Node.js / Claude Code 客户端的 TLS 指纹
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.enable_tls_fingerprint"
|
||||
@update:model-value="(v: boolean) => claudeForm.enable_tls_fingerprint = 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">会话 ID 伪装</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
启用后在 15 分钟内固定 metadata.user_id 中的 session ID
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_id_masking_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Cache TTL 统一</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
强制统一所有请求的 cache_control 类型,避免多人共用时行为指纹不一致
|
||||
</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="space-y-1.5"
|
||||
>
|
||||
<Label class="text-xs">目标 TTL 类型</Label>
|
||||
<select
|
||||
:value="claudeForm.cache_ttl_override_target"
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
@change="(e) => claudeForm.cache_ttl_override_target = (e.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option value="ephemeral">
|
||||
ephemeral (5 分钟)
|
||||
</option>
|
||||
<option value="1h">
|
||||
1h (1 小时)
|
||||
</option>
|
||||
</select>
|
||||
</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">仅限 CLI 客户端</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅允许 Claude Code CLI 客户端访问,拒绝非 CLI 流量
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cli_only_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<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 { 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 } 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: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref<PoolAdvancedConfig>({
|
||||
global_priority: null,
|
||||
sticky_session_ttl_seconds: null,
|
||||
lru_enabled: true,
|
||||
cost_window_seconds: null,
|
||||
cost_limit_per_key_tokens: null,
|
||||
cost_soft_threshold_percent: null,
|
||||
rate_limit_cooldown_seconds: null,
|
||||
overload_cooldown_seconds: null,
|
||||
health_policy_enabled: true,
|
||||
})
|
||||
|
||||
interface ClaudeFormState {
|
||||
session_control_enabled: boolean
|
||||
max_sessions: number | undefined
|
||||
session_idle_timeout_minutes: number
|
||||
enable_tls_fingerprint: boolean
|
||||
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,
|
||||
enable_tls_fingerprint: true,
|
||||
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 isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v && props.currentConfig) {
|
||||
form.value = { ...props.currentConfig }
|
||||
} else if (v) {
|
||||
form.value = {
|
||||
global_priority: null,
|
||||
sticky_session_ttl_seconds: null,
|
||||
lru_enabled: true,
|
||||
cost_window_seconds: null,
|
||||
cost_limit_per_key_tokens: null,
|
||||
cost_soft_threshold_percent: null,
|
||||
rate_limit_cooldown_seconds: null,
|
||||
overload_cooldown_seconds: null,
|
||||
health_policy_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code 配置
|
||||
if (v && props.providerType === 'claude_code') {
|
||||
const cc = props.currentClaudeConfig
|
||||
if (cc) {
|
||||
// max_sessions 为 null 表示用户明确关闭了会话控制,其余情况默认开启
|
||||
const sessionOff = cc.max_sessions === null
|
||||
claudeForm.value = {
|
||||
session_control_enabled: !sessionOff,
|
||||
max_sessions: sessionOff ? undefined : (cc.max_sessions ?? undefined),
|
||||
session_idle_timeout_minutes: cc.session_idle_timeout_minutes ?? 5,
|
||||
enable_tls_fingerprint: cc.enable_tls_fingerprint ?? true,
|
||||
session_id_masking_enabled: cc.session_id_masking_enabled ?? true,
|
||||
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,
|
||||
}
|
||||
} else {
|
||||
// 默认值:全部开启
|
||||
claudeForm.value = {
|
||||
session_control_enabled: true,
|
||||
max_sessions: undefined,
|
||||
session_idle_timeout_minutes: 5,
|
||||
enable_tls_fingerprint: true,
|
||||
session_id_masking_enabled: true,
|
||||
cache_ttl_override_enabled: false,
|
||||
cache_ttl_override_target: 'ephemeral',
|
||||
cli_only_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
pool_advanced: {
|
||||
global_priority: form.value.global_priority ?? undefined,
|
||||
sticky_session_ttl_seconds: form.value.sticky_session_ttl_seconds ?? undefined,
|
||||
lru_enabled: form.value.lru_enabled,
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
// Claude Code 特有配置
|
||||
if (props.providerType === 'claude_code') {
|
||||
payload.claude_code_advanced = {
|
||||
max_sessions: claudeForm.value.session_control_enabled
|
||||
? (claudeForm.value.max_sessions ?? undefined)
|
||||
: null,
|
||||
session_idle_timeout_minutes: claudeForm.value.session_control_enabled
|
||||
? (claudeForm.value.session_idle_timeout_minutes ?? 5)
|
||||
: null,
|
||||
enable_tls_fingerprint: claudeForm.value.enable_tls_fingerprint,
|
||||
session_id_masking_enabled: claudeForm.value.session_id_masking_enabled,
|
||||
cache_ttl_override_enabled: claudeForm.value.cache_ttl_override_enabled,
|
||||
cache_ttl_override_target: claudeForm.value.cache_ttl_override_enabled
|
||||
? claudeForm.value.cache_ttl_override_target
|
||||
: 'ephemeral',
|
||||
cli_only_enabled: claudeForm.value.cli_only_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
await updateProvider(props.providerId, payload)
|
||||
success('号池配置已保存')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
886
frontend/src/features/pool/components/PoolSchedulingDialog.vue
Normal file
886
frontend/src/features/pool/components/PoolSchedulingDialog.vue
Normal file
@@ -0,0 +1,886 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="号池调度"
|
||||
description="拖拽排序调度维度,越靠前优先级越高"
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<!-- Preset List -->
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
调度维度
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
拖拽排序,越靠前优先级越高。不适用当前 Provider 类型的维度已禁用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<div
|
||||
v-for="(item, index) in presetList"
|
||||
:key="item.preset"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
|
||||
:class="[
|
||||
!item.applicable
|
||||
? 'border-border/30 bg-muted/20 opacity-50'
|
||||
: draggedIndex === index
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
: dragOverIndex === index
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
:draggable="item.applicable"
|
||||
@dragstart="item.applicable && handleDragStart(index, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover.prevent="item.applicable && handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="item.applicable && handleDrop(index)"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="p-1 rounded transition-colors shrink-0"
|
||||
:class="item.applicable
|
||||
? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/15 cursor-default'"
|
||||
>
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- Enable/disable switch -->
|
||||
<Switch
|
||||
:model-value="item.enabled"
|
||||
:disabled="!item.applicable"
|
||||
@update:model-value="(v: boolean) => togglePreset(index, v)"
|
||||
/>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="!item.applicable ? 'text-muted-foreground' : ''"
|
||||
>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="!item.applicable"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
(不适用)
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ item.desc }}
|
||||
</p>
|
||||
|
||||
<!-- Mode sub-config -->
|
||||
<div
|
||||
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
|
||||
class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
|
||||
>
|
||||
<button
|
||||
v-for="modeOpt in item.modeOptions"
|
||||
:key="modeOpt.value"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
item.mode === modeOpt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="setPresetMode(index, modeOpt.value)"
|
||||
>
|
||||
{{ modeOpt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">TLS 指纹模拟</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
模拟 Node.js / Claude Code 客户端指纹
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.enable_tls_fingerprint"
|
||||
@update:model-value="(v: boolean) => claudeForm.enable_tls_fingerprint = 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">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>
|
||||
<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 { GripVertical } from 'lucide-vue-next'
|
||||
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 { getPoolSchedulingPresets } from '@/api/endpoints/pool'
|
||||
import type { PoolPresetMeta } from '@/api/endpoints/pool'
|
||||
import type { PoolAdvancedConfig, ClaudeCodeAdvancedConfig, SchedulingPresetItem } from '@/api/endpoints/types/provider'
|
||||
|
||||
interface PresetModeOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface PresetListItem {
|
||||
preset: string
|
||||
label: string
|
||||
desc: string
|
||||
enabled: boolean
|
||||
mode: string | null
|
||||
modeOptions: PresetModeOption[]
|
||||
applicable: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerId: string
|
||||
providerType?: string
|
||||
currentConfig: PoolAdvancedConfig | null
|
||||
currentClaudeConfig?: ClaudeCodeAdvancedConfig | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
||||
{
|
||||
name: 'lru',
|
||||
label: 'LRU 轮转',
|
||||
description: '最久未使用的 Key 优先',
|
||||
providers: [],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
},
|
||||
{
|
||||
name: 'free_team_first',
|
||||
label: 'Free/Team 优先',
|
||||
description: '优先消耗低档账号(依赖 plan_type)',
|
||||
providers: ['codex', 'kiro'],
|
||||
modes: [
|
||||
{ value: 'free_only', label: 'Free' },
|
||||
{ value: 'team_only', label: 'Team' },
|
||||
{ value: 'both', label: '全部' },
|
||||
],
|
||||
default_mode: 'both',
|
||||
},
|
||||
{
|
||||
name: 'quota_balanced',
|
||||
label: '额度平均',
|
||||
description: '优先选额度消耗最少的账号',
|
||||
providers: [],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
},
|
||||
{
|
||||
name: 'recent_refresh',
|
||||
label: '额度刷新优先',
|
||||
description: '优先选即将刷新额度的账号',
|
||||
providers: ['codex', 'kiro'],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
},
|
||||
{
|
||||
name: 'single_account',
|
||||
label: '单号优先',
|
||||
description: '集中使用同一账号(反向 LRU)',
|
||||
providers: [],
|
||||
modes: null,
|
||||
default_mode: null,
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
enable_tls_fingerprint: boolean
|
||||
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,
|
||||
enable_tls_fingerprint: true,
|
||||
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()
|
||||
}
|
||||
|
||||
function normalizePresetName(value: unknown): string {
|
||||
return String(value ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeMode(value: unknown): string | null {
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function normalizePresetDefs(defs: PoolPresetMeta[]): PoolPresetMeta[] {
|
||||
const ordered: PoolPresetMeta[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const raw of defs) {
|
||||
const name = normalizePresetName(raw.name)
|
||||
if (!name || seen.has(name)) continue
|
||||
seen.add(name)
|
||||
const providers = Array.isArray(raw.providers)
|
||||
? raw.providers.map(p => normalizeProviderType(p)).filter(Boolean)
|
||||
: []
|
||||
const modes = Array.isArray(raw.modes)
|
||||
? raw.modes
|
||||
.map(mode => ({
|
||||
value: normalizePresetName(mode.value),
|
||||
label: String(mode.label ?? '').trim() || String(mode.value ?? '').trim(),
|
||||
}))
|
||||
.filter(mode => Boolean(mode.value))
|
||||
: null
|
||||
const defaultMode = normalizeMode(raw.default_mode)
|
||||
ordered.push({
|
||||
name,
|
||||
label: String(raw.label ?? '').trim() || name,
|
||||
description: String(raw.description ?? '').trim(),
|
||||
providers,
|
||||
modes: modes && modes.length > 0 ? modes : null,
|
||||
default_mode: defaultMode,
|
||||
})
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
function getPresetDefs(): PoolPresetMeta[] {
|
||||
if (presetDefs.value.length > 0) {
|
||||
return presetDefs.value
|
||||
}
|
||||
return FALLBACK_PRESET_DEFS
|
||||
}
|
||||
|
||||
async function ensurePresetDefsLoaded(): Promise<void> {
|
||||
if (presetDefsLoaded.value || loadingPresetDefs.value) return
|
||||
loadingPresetDefs.value = true
|
||||
try {
|
||||
const remoteDefs = await getPoolSchedulingPresets()
|
||||
const normalized = normalizePresetDefs(Array.isArray(remoteDefs) ? remoteDefs : [])
|
||||
if (normalized.length > 0) {
|
||||
presetDefs.value = normalized
|
||||
}
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
presetDefsLoaded.value = true
|
||||
loadingPresetDefs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isApplicablePreset(def: PoolPresetMeta): boolean {
|
||||
const providerType = normalizeProviderType(props.providerType)
|
||||
const providers = Array.isArray(def.providers) ? def.providers : []
|
||||
if (providers.length === 0) return true
|
||||
if (!providerType) return true
|
||||
return providers.includes(providerType)
|
||||
}
|
||||
|
||||
function getModeOptions(def: PoolPresetMeta): PresetModeOption[] {
|
||||
const modes = Array.isArray(def.modes) ? def.modes : []
|
||||
return modes
|
||||
.map(mode => ({
|
||||
value: normalizePresetName(mode.value),
|
||||
label: String(mode.label ?? '').trim() || String(mode.value ?? '').trim(),
|
||||
}))
|
||||
.filter(mode => Boolean(mode.value))
|
||||
}
|
||||
|
||||
function defaultModeForPreset(def: PoolPresetMeta): string | null {
|
||||
const options = getModeOptions(def)
|
||||
if (options.length === 0) return null
|
||||
const normalizedDefault = normalizeMode(def.default_mode)
|
||||
if (normalizedDefault && options.some(option => option.value === normalizedDefault)) {
|
||||
return normalizedDefault
|
||||
}
|
||||
return options[0].value
|
||||
}
|
||||
|
||||
function buildDefaultPresetList(): PresetListItem[] {
|
||||
return getPresetDefs().map(def => ({
|
||||
preset: def.name,
|
||||
label: def.label,
|
||||
desc: def.description,
|
||||
enabled: DEFAULT_ENABLED_PRESETS.has(def.name),
|
||||
mode: defaultModeForPreset(def),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
}))
|
||||
}
|
||||
|
||||
function isNewFormatPresetItem(item: unknown): item is SchedulingPresetItem {
|
||||
return typeof item === 'object' && item !== null && 'preset' in item
|
||||
}
|
||||
|
||||
function resolveMode(def: PoolPresetMeta, mode: unknown): string | null {
|
||||
const options = getModeOptions(def)
|
||||
if (options.length === 0) return null
|
||||
const normalized = normalizeMode(mode)
|
||||
if (normalized && options.some(option => option.value === normalized)) {
|
||||
return normalized
|
||||
}
|
||||
return defaultModeForPreset(def)
|
||||
}
|
||||
|
||||
function loadFromConfig(cfg: PoolAdvancedConfig | null): PresetListItem[] {
|
||||
const defs = getPresetDefs()
|
||||
const defsByName = new Map(defs.map(def => [def.name, def]))
|
||||
const defaults = buildDefaultPresetList()
|
||||
if (!cfg) return defaults
|
||||
|
||||
const rawPresets = cfg.scheduling_presets
|
||||
if (!Array.isArray(rawPresets) || rawPresets.length === 0) {
|
||||
if (cfg.scheduling_mode === 'lru' || (!cfg.scheduling_mode && cfg.lru_enabled !== false)) {
|
||||
return defaults.map(item => ({
|
||||
...item,
|
||||
enabled: item.preset === 'lru',
|
||||
}))
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
const first = rawPresets[0]
|
||||
if (isNewFormatPresetItem(first)) {
|
||||
const configItems = rawPresets as SchedulingPresetItem[]
|
||||
const ordered: PresetListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const ci of configItems) {
|
||||
const presetName = normalizePresetName(ci.preset)
|
||||
const def = defsByName.get(presetName)
|
||||
if (!def || seen.has(presetName)) continue
|
||||
seen.add(presetName)
|
||||
ordered.push({
|
||||
preset: presetName,
|
||||
label: def.label,
|
||||
desc: def.description,
|
||||
enabled: ci.enabled !== false,
|
||||
mode: resolveMode(def, ci.mode),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
})
|
||||
}
|
||||
|
||||
for (const def of defs) {
|
||||
if (seen.has(def.name)) continue
|
||||
ordered.push({
|
||||
preset: def.name,
|
||||
label: def.label,
|
||||
desc: def.description,
|
||||
enabled: false,
|
||||
mode: defaultModeForPreset(def),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
})
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
const legacyPresets = rawPresets as string[]
|
||||
const lruEnabled = cfg.lru_enabled !== false
|
||||
const ordered: PresetListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const lruDef = defsByName.get('lru')
|
||||
if (lruDef) {
|
||||
ordered.push({
|
||||
preset: 'lru',
|
||||
label: lruDef.label,
|
||||
desc: lruDef.description,
|
||||
enabled: lruEnabled,
|
||||
mode: null,
|
||||
modeOptions: [],
|
||||
applicable: isApplicablePreset(lruDef),
|
||||
})
|
||||
seen.add('lru')
|
||||
}
|
||||
|
||||
for (const name of legacyPresets) {
|
||||
const presetName = normalizePresetName(name)
|
||||
const def = defsByName.get(presetName)
|
||||
if (!def || seen.has(presetName)) continue
|
||||
seen.add(presetName)
|
||||
ordered.push({
|
||||
preset: presetName,
|
||||
label: def.label,
|
||||
desc: def.description,
|
||||
enabled: true,
|
||||
mode: resolveMode(def, undefined),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
})
|
||||
}
|
||||
|
||||
for (const def of defs) {
|
||||
if (seen.has(def.name)) continue
|
||||
ordered.push({
|
||||
preset: def.name,
|
||||
label: def.label,
|
||||
desc: def.description,
|
||||
enabled: false,
|
||||
mode: defaultModeForPreset(def),
|
||||
modeOptions: getModeOptions(def),
|
||||
applicable: isApplicablePreset(def),
|
||||
})
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
function togglePreset(index: number, enabled: boolean) {
|
||||
presetList.value[index].enabled = enabled
|
||||
}
|
||||
|
||||
function setPresetMode(index: number, mode: string) {
|
||||
presetList.value[index].mode = mode
|
||||
}
|
||||
|
||||
function handleDragStart(index: number, event: DragEvent) {
|
||||
draggedIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/html', '')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDragOver(index: number) {
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDrop(dropIndex: number) {
|
||||
if (draggedIndex.value === null || draggedIndex.value === dropIndex) {
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
return
|
||||
}
|
||||
const items = [...presetList.value]
|
||||
const [draggedItem] = items.splice(draggedIndex.value, 1)
|
||||
items.splice(dropIndex, 0, draggedItem)
|
||||
presetList.value = items
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
showAdvanced.value = false
|
||||
await ensurePresetDefsLoaded()
|
||||
presetList.value = 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,
|
||||
enable_tls_fingerprint: cc?.enable_tls_fingerprint !== false,
|
||||
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 {
|
||||
const schedulingPresets: SchedulingPresetItem[] = presetList.value.map(item => {
|
||||
const result: SchedulingPresetItem = {
|
||||
preset: item.preset,
|
||||
enabled: item.enabled && item.applicable,
|
||||
}
|
||||
if (item.modeOptions.length > 0 && item.mode) {
|
||||
result.mode = item.mode
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
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,
|
||||
enable_tls_fingerprint: cf.enable_tls_fingerprint,
|
||||
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,
|
||||
}
|
||||
}
|
||||
await updateProvider(props.providerId, payload)
|
||||
success('号池调度已保存')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -264,7 +264,7 @@
|
||||
:class="key.is_pool_aggregate
|
||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||
: 'text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary'"
|
||||
:title="key.is_pool_aggregate ? '号池优先级请在号池配置中调整' : '点击编辑优先级'"
|
||||
:title="key.is_pool_aggregate ? '号池优先级请在号池调度中调整' : '点击编辑优先级'"
|
||||
@click.stop="!key.is_pool_aggregate && startEditKeyPriority(format, key)"
|
||||
>
|
||||
{{ key.priority }}
|
||||
|
||||
@@ -1101,6 +1101,7 @@ import {
|
||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { isAccountLevelBlockReason } from '@/utils/accountBlock'
|
||||
|
||||
// 扩展端点类型,包含密钥列表
|
||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
@@ -1568,8 +1569,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
|
||||
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
||||
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
||||
if (!key.oauth_invalid_reason) return false
|
||||
return key.oauth_invalid_reason.startsWith('[ACCOUNT_BLOCK]')
|
||||
return isAccountLevelBlockReason(key.oauth_invalid_reason)
|
||||
}
|
||||
|
||||
// 清除 OAuth 失效标记
|
||||
|
||||
34
frontend/src/utils/accountBlock.ts
Normal file
34
frontend/src/utils/accountBlock.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 账号级别封禁/异常的关键词匹配(用于判断 oauth_invalid_reason 是否属于账号封禁)
|
||||
const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
||||
'account_block',
|
||||
'account blocked',
|
||||
'account has been disabled',
|
||||
'account disabled',
|
||||
'organization has been disabled',
|
||||
'organization_disabled',
|
||||
'validation_required',
|
||||
'verify your account',
|
||||
'suspended',
|
||||
// Kiro quota refresher 写入的确切文本
|
||||
'账户已封禁',
|
||||
// Antigravity quota refresher 写入的确切文本
|
||||
'账户访问被禁止',
|
||||
'封禁',
|
||||
'封号',
|
||||
'被封',
|
||||
'访问被禁止',
|
||||
'账号异常',
|
||||
]
|
||||
|
||||
export function isAccountLevelBlockReason(reason: string | null | undefined): boolean {
|
||||
if (!reason) return false
|
||||
const text = reason.trim()
|
||||
if (!text) return false
|
||||
if (text.startsWith('[ACCOUNT_BLOCK]')) return true
|
||||
const lowered = text.toLowerCase()
|
||||
return ACCOUNT_BLOCK_REASON_KEYWORDS.some(keyword => lowered.includes(keyword))
|
||||
}
|
||||
|
||||
export function cleanAccountBlockReason(reason: string): string {
|
||||
return reason.replace(/^\[ACCOUNT_BLOCK\]\s*/i, '').trim()
|
||||
}
|
||||
@@ -34,13 +34,14 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="号池配置"
|
||||
@click="showConfigDialog = true"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs gap-1"
|
||||
title="调整号池调度"
|
||||
@click="showSchedulingDialog = true"
|
||||
>
|
||||
<Settings class="w-3.5 h-3.5" />
|
||||
调度
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
@@ -53,7 +54,8 @@
|
||||
<Ban class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="keysLoading"
|
||||
:loading="refreshCurrentPageLoading"
|
||||
:title="refreshButtonTitle"
|
||||
@click="refreshCurrentPage"
|
||||
/>
|
||||
</div>
|
||||
@@ -197,16 +199,16 @@
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
<button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="号池配置"
|
||||
@click="showConfigDialog = true"
|
||||
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"
|
||||
>
|
||||
<Settings class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<span class="text-muted-foreground/80 hidden lg:inline">调度:</span>
|
||||
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
<Button
|
||||
v-if="selectedProviderId"
|
||||
variant="ghost"
|
||||
@@ -218,7 +220,8 @@
|
||||
<Ban class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="keysLoading"
|
||||
:loading="refreshCurrentPageLoading"
|
||||
:title="refreshButtonTitle"
|
||||
@click="refreshCurrentPage"
|
||||
/>
|
||||
</div>
|
||||
@@ -370,6 +373,14 @@
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="getAccountAlertLabel(key)"
|
||||
variant="destructive"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getAccountAlertTitle(key)"
|
||||
>
|
||||
{{ getAccountAlertLabel(key) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
@@ -753,6 +764,14 @@
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
</template>
|
||||
<Badge
|
||||
v-if="getAccountAlertLabel(key)"
|
||||
variant="destructive"
|
||||
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getAccountAlertTitle(key)"
|
||||
>
|
||||
{{ getAccountAlertLabel(key) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
@@ -1092,13 +1111,13 @@
|
||||
@close="showImportDialog = false"
|
||||
@saved="handleAccountDialogSaved"
|
||||
/>
|
||||
<PoolConfigDialog
|
||||
<PoolSchedulingDialog
|
||||
v-if="selectedProviderId"
|
||||
v-model="showConfigDialog"
|
||||
v-model="showSchedulingDialog"
|
||||
:provider-id="selectedProviderId"
|
||||
:provider-type="selectedProviderData?.provider_type"
|
||||
:provider-type="selectedProviderType"
|
||||
:current-config="selectedProviderConfig"
|
||||
:current-claude-config="selectedProviderData?.claude_code_advanced"
|
||||
:current-claude-config="selectedProviderClaudeConfig"
|
||||
@saved="loadOverview"
|
||||
/>
|
||||
<KeyFormDialog
|
||||
@@ -1132,7 +1151,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Power,
|
||||
Database,
|
||||
@@ -1176,6 +1195,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
getPoolOverview,
|
||||
getPoolSchedulingPresets,
|
||||
listPoolKeys,
|
||||
clearPoolCooldown,
|
||||
cleanupBannedPoolKeys,
|
||||
@@ -1193,18 +1213,20 @@ import type {
|
||||
PoolOverviewItem,
|
||||
PoolKeyDetail,
|
||||
PoolKeysPageResponse,
|
||||
PoolPresetMeta,
|
||||
} from '@/api/endpoints/pool'
|
||||
import type { EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
|
||||
import type { ClaudeCodeAdvancedConfig, EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
|
||||
import { getProvider } from '@/api/endpoints'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import PoolConfigDialog from '@/features/pool/components/PoolConfigDialog.vue'
|
||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.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'
|
||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { success, error: showError, warning: showWarning } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
||||
@@ -1273,6 +1295,81 @@ const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => {
|
||||
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
|
||||
})
|
||||
|
||||
const selectedProviderClaudeConfig = computed(() => {
|
||||
return (selectedProviderData.value as Record<string, unknown> | null)?.claude_code_advanced as ClaudeCodeAdvancedConfig | null ?? null
|
||||
})
|
||||
|
||||
const DEFAULT_PRESET_LABELS: Record<string, string> = {
|
||||
lru: 'LRU',
|
||||
free_team_first: 'Free/Team',
|
||||
recent_refresh: '刷新优先',
|
||||
quota_balanced: '额度均衡',
|
||||
single_account: '单号优先',
|
||||
}
|
||||
const presetLabelsByName = ref<Record<string, string>>({ ...DEFAULT_PRESET_LABELS })
|
||||
|
||||
function normalizePresetName(value: unknown): string {
|
||||
return String(value ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
async function loadSchedulingPresetMetas(): Promise<void> {
|
||||
try {
|
||||
const metas = await getPoolSchedulingPresets()
|
||||
const next: Record<string, string> = {}
|
||||
for (const meta of metas as PoolPresetMeta[]) {
|
||||
const name = normalizePresetName(meta.name)
|
||||
if (!name) continue
|
||||
const label = String(meta.label ?? '').trim()
|
||||
next[name] = label || name
|
||||
}
|
||||
if (Object.keys(next).length > 0) {
|
||||
presetLabelsByName.value = next
|
||||
}
|
||||
} catch {
|
||||
presetLabelsByName.value = { ...DEFAULT_PRESET_LABELS }
|
||||
}
|
||||
}
|
||||
|
||||
const poolSchedulingLabel = computed(() => {
|
||||
const cfg = selectedProviderConfig.value
|
||||
const presets = Array.isArray(cfg?.scheduling_presets) ? cfg.scheduling_presets : []
|
||||
const presetLabels = presetLabelsByName.value
|
||||
|
||||
if (presets.length > 0) {
|
||||
// New format: object list with { preset, enabled }
|
||||
const first = presets[0]
|
||||
if (typeof first === 'object' && first !== null && 'preset' in first) {
|
||||
const enabledLabels = (presets as Array<{ preset: string; enabled?: boolean }>)
|
||||
.filter(p => p.enabled !== false)
|
||||
.map(p => presetLabels[normalizePresetName(p.preset)])
|
||||
.filter(Boolean)
|
||||
return enabledLabels.length > 0 ? enabledLabels.join('+') : '无启用维度'
|
||||
}
|
||||
|
||||
// Legacy string list format
|
||||
if (typeof first === 'string') {
|
||||
const labels = (presets as string[])
|
||||
.map(p => presetLabels[normalizePresetName(p)])
|
||||
.filter(Boolean)
|
||||
if (labels.length > 0) return labels.join('+')
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: legacy scheduling_mode field
|
||||
if (cfg?.scheduling_mode === 'multi_score') {
|
||||
return '多维评分'
|
||||
}
|
||||
|
||||
const lruEnabled = cfg?.lru_enabled !== false
|
||||
const stickyTtl = Number(cfg?.sticky_session_ttl_seconds ?? 3600)
|
||||
const stickyEnabled = Number.isFinite(stickyTtl) && stickyTtl > 0
|
||||
|
||||
if (lruEnabled && stickyEnabled) return 'LRU + 粘性'
|
||||
if (lruEnabled) return 'LRU'
|
||||
if (stickyEnabled) return '粘性'
|
||||
return '随机'
|
||||
})
|
||||
|
||||
const selectedProviderType = computed(() => {
|
||||
const fromDetail = String(selectedProviderData.value?.provider_type || '').trim().toLowerCase()
|
||||
if (fromDetail) return fromDetail
|
||||
@@ -1330,11 +1427,11 @@ async function refresh() {
|
||||
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
|
||||
const keysLoading = ref(false)
|
||||
const refreshingCurrentPageQuota = ref(false)
|
||||
const queuedCurrentPageQuotaRefresh = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
|
||||
const refreshingOAuthKeyId = ref<string | null>(null)
|
||||
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||
const recoveringHealthKeyId = ref<string | null>(null)
|
||||
@@ -1372,57 +1469,123 @@ const quotaRefreshSupported = computed(() => {
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
})
|
||||
|
||||
function getCurrentPageQuotaKeyIds(): string[] {
|
||||
const ids: string[] = []
|
||||
const refreshCurrentPageLoading = computed(() => {
|
||||
return keysLoading.value || refreshingCurrentPageQuota.value
|
||||
})
|
||||
|
||||
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
|
||||
const value = Number(raw ?? 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return null
|
||||
if (value > 1_000_000_000_000) {
|
||||
return Math.floor(value / 1000)
|
||||
}
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
const currentPageQuotaRefreshStats = computed(() => {
|
||||
void countdownTick.value
|
||||
const seen = new Set<string>()
|
||||
const eligibleIds: string[] = []
|
||||
let cooledDownCount = 0
|
||||
let minRemainingSeconds = 0
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
for (const key of keyPage.value.keys) {
|
||||
const id = String(key.key_id || '').trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
ids.push(id)
|
||||
const updatedAt = normalizeQuotaUpdatedAt(key.quota_updated_at ?? null)
|
||||
if (updatedAt == null) {
|
||||
eligibleIds.push(id)
|
||||
continue
|
||||
}
|
||||
const remaining = MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS - (nowSeconds - updatedAt)
|
||||
if (remaining > 0) {
|
||||
cooledDownCount += 1
|
||||
if (minRemainingSeconds <= 0 || remaining < minRemainingSeconds) {
|
||||
minRemainingSeconds = remaining
|
||||
}
|
||||
continue
|
||||
}
|
||||
eligibleIds.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
return {
|
||||
total: seen.size,
|
||||
eligibleIds,
|
||||
cooledDownCount,
|
||||
minRemainingSeconds,
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshCurrentPageQuotaInBackground(options: { silent?: boolean } = {}) {
|
||||
if (!selectedProviderId.value || !quotaRefreshSupported.value) return
|
||||
async function refreshCurrentPageQuotaInBackground(
|
||||
options: { silent?: boolean; reloadAfter?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
if (!selectedProviderId.value || !quotaRefreshSupported.value) return false
|
||||
|
||||
const providerId = selectedProviderId.value
|
||||
const keyIds = getCurrentPageQuotaKeyIds()
|
||||
if (keyIds.length === 0) return
|
||||
const quotaStats = currentPageQuotaRefreshStats.value
|
||||
if (quotaStats.eligibleIds.length === 0) {
|
||||
if (!options.silent && quotaStats.total > 0 && quotaStats.cooledDownCount > 0) {
|
||||
const waitText = quotaStats.minRemainingSeconds > 0
|
||||
? formatTTL(quotaStats.minRemainingSeconds)
|
||||
: '稍后'
|
||||
showWarning(`当前页额度均在冷却中,请 ${waitText} 后再试`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (refreshingCurrentPageQuota.value) {
|
||||
queuedCurrentPageQuotaRefresh.value = true
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
refreshingCurrentPageQuota.value = true
|
||||
try {
|
||||
const result = await refreshProviderQuota(providerId, keyIds)
|
||||
const result = await refreshProviderQuota(providerId, quotaStats.eligibleIds)
|
||||
const successCount = Number(result.success || 0)
|
||||
const failedCount = Number(result.failed || 0)
|
||||
const skippedCount = Math.max(quotaStats.total - quotaStats.eligibleIds.length, 0)
|
||||
|
||||
// 刷新当前页数据,展示最新额度与状态
|
||||
if (selectedProviderId.value === providerId) {
|
||||
if (selectedProviderId.value === providerId && options.reloadAfter !== false) {
|
||||
await loadKeys()
|
||||
}
|
||||
|
||||
if (!options.silent) {
|
||||
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}`)
|
||||
const skippedText = skippedCount > 0 ? `,冷却跳过 ${skippedCount}` : ''
|
||||
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '刷新当前页额度失败'))
|
||||
return false
|
||||
} finally {
|
||||
refreshingCurrentPageQuota.value = false
|
||||
if (queuedCurrentPageQuotaRefresh.value) {
|
||||
queuedCurrentPageQuotaRefresh.value = false
|
||||
void refreshCurrentPageQuotaInBackground(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshButtonTitle = computed(() => {
|
||||
if (refreshCurrentPageLoading.value) return '刷新中...'
|
||||
if (!selectedProviderId.value) return '刷新'
|
||||
if (!quotaRefreshSupported.value) return '刷新数据'
|
||||
|
||||
const quotaStats = currentPageQuotaRefreshStats.value
|
||||
if (quotaStats.total === 0) return '刷新数据和额度'
|
||||
if (quotaStats.eligibleIds.length === 0 && quotaStats.cooledDownCount > 0) {
|
||||
const waitText = quotaStats.minRemainingSeconds > 0
|
||||
? formatTTL(quotaStats.minRemainingSeconds)
|
||||
: '稍后'
|
||||
return `刷新数据(额度冷却 ${waitText})`
|
||||
}
|
||||
if (quotaStats.cooledDownCount > 0) {
|
||||
return `刷新数据和额度(可刷新 ${quotaStats.eligibleIds.length}/${quotaStats.total})`
|
||||
}
|
||||
return '刷新数据和额度'
|
||||
})
|
||||
|
||||
async function refreshCurrentPage() {
|
||||
await refresh()
|
||||
const quotaDidReload = await refreshCurrentPageQuotaInBackground({ reloadAfter: true })
|
||||
if (!quotaDidReload) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
@@ -1807,11 +1970,13 @@ async function handleCleanupBannedKeys() {
|
||||
|
||||
// --- Dialogs ---
|
||||
const showImportDialog = ref(false)
|
||||
const showConfigDialog = ref(false)
|
||||
const showSchedulingDialog = ref(false)
|
||||
|
||||
async function handleAccountDialogSaved() {
|
||||
showImportDialog.value = false
|
||||
await Promise.all([loadKeys(), loadOverview()])
|
||||
// 导入账号后补一次静默额度刷新,避免新账号在列表里暂无额度信息
|
||||
await refreshCurrentPageQuotaInBackground({ silent: true })
|
||||
}
|
||||
|
||||
// --- Formatting ---
|
||||
@@ -1831,6 +1996,8 @@ function formatCooldownReason(reason: string): string {
|
||||
type PoolStatusVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
||||
|
||||
function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'blocked' {
|
||||
if (getAccountAlertLabel(key)) return 'blocked'
|
||||
|
||||
const status = key.scheduling_status
|
||||
if (status === 'available' || status === 'degraded' || status === 'blocked') {
|
||||
return status
|
||||
@@ -1845,6 +2012,9 @@ function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'bl
|
||||
}
|
||||
|
||||
function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
|
||||
const accountAlert = getAccountAlertLabel(key)
|
||||
if (accountAlert) return accountAlert
|
||||
|
||||
const rawLabel = String(key.scheduling_label || '').trim()
|
||||
if (rawLabel) {
|
||||
if (rawLabel === '禁用' || rawLabel === '停用') return '禁用'
|
||||
@@ -1861,6 +2031,8 @@ function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
|
||||
}
|
||||
|
||||
function getSchedulingBadgeVariant(key: PoolKeyDetail): PoolStatusVariant {
|
||||
if (getAccountAlertLabel(key)) return 'destructive'
|
||||
|
||||
const reason = key.scheduling_reason
|
||||
if (reason === 'manual_disabled') return 'dark'
|
||||
if (reason === 'cooldown' || reason === 'circuit_open' || reason === 'cost_exhausted') return 'destructive'
|
||||
@@ -1875,6 +2047,9 @@ function getSchedulingBadgeVariant(key: PoolKeyDetail): PoolStatusVariant {
|
||||
}
|
||||
|
||||
function getSchedulingTitle(key: PoolKeyDetail): string {
|
||||
const accountAlertTitle = getAccountAlertTitle(key)
|
||||
if (accountAlertTitle) return accountAlertTitle
|
||||
|
||||
if (key.scheduling_dimensions && key.scheduling_dimensions.length > 0) {
|
||||
return key.scheduling_dimensions.map((item) => {
|
||||
const ttl = item.ttl_seconds && item.ttl_seconds > 0 ? ` (${formatTTL(item.ttl_seconds)})` : ''
|
||||
@@ -2048,6 +2223,41 @@ function getOAuthStatusTitle(key: PoolKeyDetail): string {
|
||||
return `Token 剩余有效期: ${status.text}`
|
||||
}
|
||||
|
||||
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
|
||||
|
||||
function getAccountAlertLabel(key: PoolKeyDetail): string | null {
|
||||
const cached = _accountAlertCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
let result: string | null = null
|
||||
const quotaText = String(key.account_quota || '').trim()
|
||||
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
||||
if (quotaText === '账号已封禁' || quotaText === '封禁') result = '账号封禁'
|
||||
else if (quotaText === '访问受限') result = '访问受限'
|
||||
else if (isAccountLevelBlockReason(key.oauth_invalid_reason)) result = '账号异常'
|
||||
|
||||
_accountAlertCache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function getAccountAlertTitle(key: PoolKeyDetail): string {
|
||||
const label = getAccountAlertLabel(key)
|
||||
if (!label) return ''
|
||||
|
||||
const reason = String(key.oauth_invalid_reason || '').trim()
|
||||
if (reason) {
|
||||
if (isAccountLevelBlockReason(reason)) {
|
||||
const cleaned = cleanAccountBlockReason(reason)
|
||||
return cleaned ? `${label}: ${cleaned}` : label
|
||||
}
|
||||
return `${label}: ${reason}`
|
||||
}
|
||||
|
||||
const quotaText = String(key.account_quota || '').trim()
|
||||
if (quotaText) return `${label}: ${quotaText}`
|
||||
return label
|
||||
}
|
||||
|
||||
function normalizeQuotaLabel(label: string): string {
|
||||
const normalized = label.trim()
|
||||
if (!normalized) return '额度'
|
||||
@@ -2214,8 +2424,7 @@ function formatRelativeTime(isoStr: string): string {
|
||||
// --- Init ---
|
||||
onMounted(async () => {
|
||||
startCountdownTimer()
|
||||
await loadOverview()
|
||||
void refreshCurrentPageQuotaInBackground({ silent: true })
|
||||
await Promise.all([loadSchedulingPresetMetas(), loadOverview()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
Reference in New Issue
Block a user