feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面

后端:
- 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理
- 完善 scheduler-core 候选排序与请求候选逻辑
- 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新
- 改进 AI pipeline 响应转换与流式处理
- 扩展 global_models/provider_catalog 数据层查询能力
- 增强 video-tasks-core 多 provider 支持
- 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor

前端:
- 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块
- 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试
- 改进 Dialog 组件与 provider tabs 显示

部署:
- 更新 Rust CI workflow 和 Dockerfile 构建配置

Closes #275
Co-authored-by: AAEE86 <[email protected]>
This commit is contained in:
fawney19
2026-04-09 13:51:50 +08:00
parent 4fc95adfb9
commit b0b40c16ff
97 changed files with 5816 additions and 1881 deletions
@@ -3,71 +3,96 @@
:model-value="modelValue"
title="高级设置"
description="冷却、健康、成本控制与其他高级参数"
size="lg"
size="3xl"
@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 class="max-h-[calc(100dvh-13rem)] space-y-5 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:space-y-6 sm:pr-2">
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
冷却与健康
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
核心策略
</span>
</div>
<Switch
:model-value="form.health_policy_enabled"
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
/>
<p class="text-xs leading-5 text-muted-foreground">
控制自动冷却、主动探测、异常清理和全局调度优先级。
</p>
</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 class="grid gap-3 lg:grid-cols-3">
<div
v-for="item in healthToggleCards"
:key="item.key"
class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between lg:items-center"
>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium">{{ item.label }}</span>
<TooltipProvider
:delay-duration="100"
>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
:title="item.description"
:aria-label="`${item.label} 说明`"
class="hidden lg:inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
>
<CircleHelp class="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
:side-offset="8"
class="max-w-xs px-3 py-2 text-xs leading-5"
>
{{ item.description }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p class="text-xs leading-5 text-muted-foreground lg:hidden">
{{ item.description }}
</p>
</div>
<Switch
:model-value="getHealthToggleValue(item.key)"
class="shrink-0"
@update:model-value="(v: boolean) => updateHealthToggleValue(item.key, 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">
仅在检测到不可恢复的账号异常时自动从号池中移除,不处理纯 Token 失效
</p>
<div
v-if="form.probing_enabled"
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
>
<div class="grid gap-3 sm:grid-cols-2">
<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>
<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="grid gap-3 sm:grid-cols-2"
:class="cooldownFieldLayout.desktopColumnsClass"
>
<div class="space-y-1.5">
<Label>
429 冷却
@@ -96,8 +121,6 @@
@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
@@ -115,7 +138,6 @@
<div class="space-y-1.5">
<Label>
全局优先级
<span class="text-xs text-muted-foreground">(global_key)</span>
</Label>
<Input
:model-value="form.global_priority ?? ''"
@@ -127,93 +149,200 @@
/>
</div>
</div>
</div>
</section>
<!-- 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 / 额度等操作的并行请求数
<div :class="secondarySectionLayout.wrapperClass">
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
成本控制
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
额度保护
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制窗口期、Key 限额与软阈值,防止个别账号短时间内过度消耗。
</p>
</div>
</div>
<div
class="grid gap-3 sm:grid-cols-2"
:class="costFieldLayout.desktopColumnsClass"
>
<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>
</section>
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
批量操作
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
任务效率
</span>
</div>
<p class="text-xs leading-5 text-muted-foreground">
控制刷新 OAuth、主动探测和批量额度处理时的并行请求数。
</p>
</div>
<div class="rounded-xl bg-muted/30 p-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] leading-5 text-muted-foreground">
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
</p>
</div>
</div>
</section>
</div>
<!-- Claude Code -->
<div
<section
v-if="isClaudeCode"
class="space-y-3"
class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5"
>
<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 class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
Claude Code
</h3>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
请求约束
</span>
</div>
<Switch
:model-value="claudeForm.session_id_masking_enabled"
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
/>
<p class="text-xs leading-5 text-muted-foreground">
管理 CLI 请求限制、会话控制和 metadata / cache 相关的兼容行为。
</p>
</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 class="grid gap-3 lg:grid-cols-2">
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">Session ID 伪装</span>
<p class="text-xs leading-5 text-muted-foreground">
固定 metadata.user_id 中 session 片段。
</p>
</div>
<Switch
:model-value="claudeForm.session_id_masking_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
/>
</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 class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">仅限 CLI 客户端</span>
<p class="text-xs leading-5 text-muted-foreground">
仅允许 Claude Code CLI 格式请求。
</p>
</div>
<Switch
:model-value="claudeForm.cli_only_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
/>
</div>
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">Cache TTL 统一</span>
<p class="text-xs leading-5 text-muted-foreground">
强制所有 cache_control 使用同一种 TTL 类型。
</p>
</div>
<Switch
:model-value="claudeForm.cache_ttl_override_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
/>
</div>
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<span class="text-sm font-medium">会话数量控制</span>
<p class="text-xs leading-5 text-muted-foreground">
限制单 Key 同时活跃会话数,降低长期占用风险。
</p>
</div>
<Switch
:model-value="claudeForm.session_control_enabled"
class="shrink-0"
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
/>
</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"
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
>
<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">
<div class="flex w-fit gap-0.5 rounded-md bg-muted/40 p-0.5">
<button
v-for="opt in ['ephemeral']"
:key="opt"
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
class="rounded px-2.5 py-1 text-xs font-medium 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'
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'
]"
@click="claudeForm.cache_ttl_override_target = opt"
>
@@ -222,112 +351,55 @@
</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"
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-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 class="grid gap-3 sm:grid-cols-2">
<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>
</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>
</section>
</div>
<template #footer>
<Button
variant="outline"
class="min-w-[96px] flex-1 sm:flex-none"
:disabled="loading"
@click="emit('update:modelValue', false)"
>
取消
</Button>
<Button
class="min-w-[96px] flex-1 sm:flex-none"
:disabled="loading"
@click="handleSave"
>
@@ -339,10 +411,18 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { CircleHelp } from 'lucide-vue-next'
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { updateProvider } from '@/api/endpoints'
import {
buildPoolCooldownFieldLayout,
buildPoolCostFieldLayout,
buildPoolHealthToggleCards,
buildPoolSecondarySectionLayout,
type PoolHealthToggleKey,
} from '@/features/pool/utils/poolAdvancedDialog'
import type {
PoolAdvancedConfig,
ClaudeCodeAdvancedConfig,
@@ -369,6 +449,11 @@ const isClaudeCode = computed(() => {
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
})
const healthToggleCards = buildPoolHealthToggleCards()
const cooldownFieldLayout = buildPoolCooldownFieldLayout()
const costFieldLayout = buildPoolCostFieldLayout()
const secondarySectionLayout = buildPoolSecondarySectionLayout()
const form = ref({
global_priority: null as number | null | undefined,
sticky_session_ttl_seconds: null as number | null | undefined,
@@ -410,6 +495,30 @@ function parseNum(v: string | number): number | undefined {
return Number.isNaN(n) ? undefined : n
}
function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
switch (key) {
case 'health_policy_enabled':
return form.value.health_policy_enabled
case 'probing_enabled':
return form.value.probing_enabled
case 'auto_remove_banned_keys':
return form.value.auto_remove_banned_keys
}
}
function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void {
switch (key) {
case 'health_policy_enabled':
form.value.health_policy_enabled = value
return
case 'probing_enabled':
form.value.probing_enabled = value
return
case 'auto_remove_banned_keys':
form.value.auto_remove_banned_keys = value
}
}
watch(() => props.modelValue, (open) => {
if (!open) return