mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(pool): 增加 OAuth 账号池管理功能
- 新增 pool manager / strategy / health_policy / cost_tracker / redis_ops 等核心模块 - 新增 pool admin API 路由与 schemas - 新增 OAuth 账号类型解析 (oauth_plan) - 前端增加 PoolManagement 页面、PoolConfigDialog、PoolImportDialog、PoolStatusCard 组件 - 补充 pool config / cost tracker / health policy / manager / strategy / trace 等测试
This commit is contained in:
391
frontend/src/features/pool/components/PoolConfigDialog.vue
Normal file
391
frontend/src/features/pool/components/PoolConfigDialog.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<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>
|
||||
</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>
|
||||
</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>({
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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 = {
|
||||
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,
|
||||
}
|
||||
} else {
|
||||
// 默认值:全部开启
|
||||
claudeForm.value = {
|
||||
session_control_enabled: true,
|
||||
max_sessions: undefined,
|
||||
session_idle_timeout_minutes: 5,
|
||||
enable_tls_fingerprint: true,
|
||||
session_id_masking_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
pool_advanced: {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
await updateProvider(props.providerId, payload)
|
||||
success('号池配置已保存')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
156
frontend/src/features/pool/components/PoolImportDialog.vue
Normal file
156
frontend/src/features/pool/components/PoolImportDialog.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="批量导入账号"
|
||||
description="以 JSON 格式批量导入 API Key 到号池"
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>JSON 数据</Label>
|
||||
<textarea
|
||||
v-model="jsonText"
|
||||
class="w-full h-48 p-3 text-sm font-mono border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="[{"name": "key-01", "api_key": "sk-xxx"}, ...]"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
格式: [{"name": "名称", "api_key": "密钥", "auth_type": "api_key"}],auth_type 可选
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="parseError"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ parseError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="parsedCount > 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
已解析 {{ parsedCount }} 个账号
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="space-y-1 text-sm"
|
||||
>
|
||||
<p class="text-green-600">
|
||||
成功导入: {{ importResult.imported }}
|
||||
</p>
|
||||
<p
|
||||
v-if="importResult.errors.length > 0"
|
||||
class="text-destructive"
|
||||
>
|
||||
失败: {{ importResult.errors.length }}
|
||||
</p>
|
||||
<div
|
||||
v-for="err in importResult.errors.slice(0, 5)"
|
||||
:key="err.index"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
#{{ err.index }}: {{ err.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
{{ importResult ? '关闭' : '取消' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!importResult"
|
||||
:disabled="loading || parsedCount === 0"
|
||||
@click="handleImport"
|
||||
>
|
||||
{{ loading ? '导入中...' : `导入 ${parsedCount} 个账号` }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, Button, Label } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { batchImportPoolKeys } from '@/api/endpoints/pool'
|
||||
import type { BatchImportResponse, PoolKeyImportItem } from '@/api/endpoints/pool'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
imported: []
|
||||
}>()
|
||||
|
||||
const { error: showError } = useToast()
|
||||
const jsonText = ref('')
|
||||
const loading = ref(false)
|
||||
const parseError = ref('')
|
||||
const importResult = ref<BatchImportResponse | null>(null)
|
||||
|
||||
const parsedKeys = computed<PoolKeyImportItem[]>(() => {
|
||||
if (!jsonText.value.trim()) return []
|
||||
try {
|
||||
const data = JSON.parse(jsonText.value)
|
||||
if (!Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
return data.map((item: Record<string, unknown>) => ({
|
||||
name: String(item.name || ''),
|
||||
api_key: String(item.api_key || ''),
|
||||
auth_type: String(item.auth_type || 'api_key'),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
watch(jsonText, (val) => {
|
||||
if (!val.trim()) {
|
||||
parseError.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(val)
|
||||
parseError.value = Array.isArray(data) ? '' : 'JSON 必须是数组格式'
|
||||
} catch {
|
||||
parseError.value = 'JSON 格式无效'
|
||||
}
|
||||
})
|
||||
|
||||
const parsedCount = computed(() => parsedKeys.value.length)
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) {
|
||||
jsonText.value = ''
|
||||
importResult.value = null
|
||||
parseError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsedKeys.value.length) return
|
||||
loading.value = true
|
||||
try {
|
||||
importResult.value = await batchImportPoolKeys(props.providerId, parsedKeys.value)
|
||||
if (importResult.value.imported > 0) {
|
||||
emit('imported')
|
||||
}
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
288
frontend/src/features/providers/components/PoolStatusCard.vue
Normal file
288
frontend/src/features/providers/components/PoolStatusCard.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
号池状态
|
||||
</h3>
|
||||
<Badge
|
||||
v-if="poolStatus"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ poolStatus.total_keys }} 个密钥
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="poolStatus && poolStatus.total_sticky_sessions > 0"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ poolStatus.total_sticky_sessions }} 个粘性会话
|
||||
</Badge>
|
||||
</div>
|
||||
<RefreshButton
|
||||
:loading="refreshing"
|
||||
title="刷新号池状态"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="initialLoading"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- Pool not enabled -->
|
||||
<div
|
||||
v-else-if="poolStatus && !poolStatus.pool_enabled"
|
||||
class="p-6 text-center text-muted-foreground"
|
||||
>
|
||||
<p class="text-sm">
|
||||
号池未启用
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
请在提供商编辑中配置号池参数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Key list -->
|
||||
<div
|
||||
v-else-if="poolStatus && poolStatus.keys.length > 0"
|
||||
class="divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
v-for="key in poolStatus.keys"
|
||||
:key="key.key_id"
|
||||
class="px-4 py-3 hover:bg-muted/30 transition-colors"
|
||||
:class="{ 'opacity-40': !key.is_active }"
|
||||
>
|
||||
<!-- Row 1: name + cooldown + actions -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm font-medium truncate">{{ key.key_name || '未命名' }}</span>
|
||||
<Badge
|
||||
v-if="key.cooldown_reason"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ formatCooldownReason(key.cooldown_reason) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="key.cooldown_ttl_seconds"
|
||||
class="text-[10px] text-destructive tabular-nums shrink-0"
|
||||
>
|
||||
{{ formatTTL(key.cooldown_ttl_seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
v-if="key.cooldown_reason"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground hover:text-green-600"
|
||||
title="清除冷却"
|
||||
:disabled="actionLoading === key.key_id"
|
||||
@click="handleClearCooldown(key.key_id)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': actionLoading === key.key_id }"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="key.cost_limit != null && key.cost_window_usage > 0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="重置成本窗口"
|
||||
:disabled="actionLoading === key.key_id"
|
||||
@click="handleResetCost(key.key_id)"
|
||||
>
|
||||
<RotateCcw class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: cost + sticky + lru -->
|
||||
<div class="flex items-center gap-3 mt-1.5 text-[11px] text-muted-foreground">
|
||||
<!-- Cost with limit -->
|
||||
<div
|
||||
v-if="key.cost_limit != null"
|
||||
class="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
>
|
||||
<span class="shrink-0">成本</span>
|
||||
<div class="flex-1 h-1.5 bg-border rounded-full overflow-hidden max-w-[120px]">
|
||||
<div
|
||||
class="h-full transition-all duration-300 rounded-full"
|
||||
:class="getCostBarColor(key.cost_window_usage, key.cost_limit)"
|
||||
:style="{
|
||||
width: `${Math.min((key.cost_window_usage / key.cost_limit) * 100, 100)}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="tabular-nums shrink-0">
|
||||
{{ formatTokens(key.cost_window_usage) }} / {{ formatTokens(key.cost_limit) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Cost without limit -->
|
||||
<div
|
||||
v-else-if="key.cost_window_usage > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span>成本</span>
|
||||
<span class="tabular-nums">{{ formatTokens(key.cost_window_usage) }}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="(key.cost_limit != null || key.cost_window_usage > 0) && key.sticky_sessions > 0"
|
||||
class="text-muted-foreground/40"
|
||||
>|</span>
|
||||
|
||||
<!-- Sticky sessions -->
|
||||
<span v-if="key.sticky_sessions > 0">
|
||||
{{ key.sticky_sessions }} 粘性会话
|
||||
</span>
|
||||
|
||||
<!-- LRU score -->
|
||||
<template v-if="key.lru_score != null">
|
||||
<span class="text-muted-foreground/40">|</span>
|
||||
<span>LRU {{ formatLruScore(key.lru_score) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else
|
||||
class="p-6 text-center text-muted-foreground"
|
||||
>
|
||||
<p class="text-sm">
|
||||
暂无密钥数据
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RefreshCw, RotateCcw } from 'lucide-vue-next'
|
||||
|
||||
import { getPoolStatus, clearPoolCooldown, resetPoolCost } from '@/api/endpoints/pool'
|
||||
import type { PoolStatusResponse } from '@/api/endpoints/pool'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
providerId: string
|
||||
poolEnabled: boolean
|
||||
}>()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
|
||||
const poolStatus = ref<PoolStatusResponse | null>(null)
|
||||
const initialLoading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const actionLoading = ref<string | null>(null)
|
||||
|
||||
async function loadPoolStatus() {
|
||||
try {
|
||||
poolStatus.value = await getPoolStatus(props.providerId)
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await loadPoolStatus()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCooldown(keyId: string) {
|
||||
actionLoading.value = keyId
|
||||
try {
|
||||
const res = await clearPoolCooldown(props.providerId, keyId)
|
||||
success(res.message)
|
||||
await loadPoolStatus()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetCost(keyId: string) {
|
||||
actionLoading.value = keyId
|
||||
try {
|
||||
const res = await resetPoolCost(props.providerId, keyId)
|
||||
success(res.message)
|
||||
await loadPoolStatus()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const COOLDOWN_REASON_MAP: Record<string, string> = {
|
||||
rate_limited_429: '429 限流',
|
||||
forbidden_403: '403 禁止',
|
||||
overloaded_529: '529 过载',
|
||||
auth_failed_401: '401 认证失败',
|
||||
payment_required_402: '402 欠费',
|
||||
server_error_500: '500 错误',
|
||||
}
|
||||
|
||||
function formatCooldownReason(reason: string): string {
|
||||
return COOLDOWN_REASON_MAP[reason] || reason
|
||||
}
|
||||
|
||||
function formatTTL(seconds: number): string {
|
||||
if (seconds <= 0) return ''
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
function getCostBarColor(usage: number, limit: number): string {
|
||||
const ratio = usage / limit
|
||||
if (ratio >= 0.9) return 'bg-red-500'
|
||||
if (ratio >= 0.7) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
function formatLruScore(score: number): string {
|
||||
const now = Date.now() / 1000
|
||||
const diff = now - score
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m 前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h 前`
|
||||
return `${Math.floor(diff / 86400)}d 前`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPoolStatus()
|
||||
initialLoading.value = false
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user