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:
fawney19
2026-02-27 13:58:58 +08:00
parent 579b5e4623
commit 76a6d0ce8e
35 changed files with 5761 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
import client from '../client'
export interface PoolKeyStatus {
key_id: string
key_name: string
is_active: boolean
cooldown_reason: string | null
cooldown_ttl_seconds: number | null
cost_window_usage: number
cost_limit: number | null
sticky_sessions: number
lru_score: number | null
}
export interface PoolStatusResponse {
provider_id: string
provider_name: string
pool_enabled: boolean
total_keys: number
total_sticky_sessions: number
keys: PoolKeyStatus[]
}
/**
* 获取 Provider 的号池状态
*/
export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/pool-status`)
return response.data
}
/**
* 清除指定 Key 的号池冷却状态
*/
export async function clearPoolCooldown(
providerId: string,
keyId: string,
): Promise<{ message: string }> {
const response = await client.post(
`/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`,
)
return response.data
}
/**
* 重置指定 Key 的号池成本窗口
*/
export async function resetPoolCost(
providerId: string,
keyId: string,
): Promise<{ message: string }> {
const response = await client.post(
`/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`,
)
return response.data
}
// ---------------------------------------------------------------------------
// Pool management API (standalone page)
// ---------------------------------------------------------------------------
export interface PoolOverviewItem {
provider_id: string
provider_name: string
provider_type: string
total_keys: number
active_keys: number
cooldown_count: number
pool_enabled: boolean
}
export interface PoolOverviewResponse {
items: PoolOverviewItem[]
}
export interface PoolKeyDetail {
key_id: string
key_name: string
is_active: boolean
auth_type: string
cooldown_reason: string | null
cooldown_ttl_seconds: number | null
cost_window_usage: number
cost_limit: number | null
sticky_sessions: number
lru_score: number | null
created_at: string | null
last_used_at: string | null
}
export interface PoolKeysPageResponse {
total: number
page: number
page_size: number
keys: PoolKeyDetail[]
}
export interface PoolKeysQuery {
page?: number
page_size?: number
search?: string
status?: 'all' | 'active' | 'cooldown' | 'inactive'
}
export interface PoolKeyImportItem {
name: string
api_key: string
auth_type?: string
}
export interface BatchImportResponse {
imported: number
skipped: number
errors: { index: number; reason: string }[]
}
export interface PoolBatchAction {
key_ids: string[]
action: 'enable' | 'disable' | 'delete' | 'clear_cooldown' | 'reset_cost'
}
export async function getPoolOverview(): Promise<PoolOverviewResponse> {
const response = await client.get('/api/admin/pool/overview')
return response.data
}
export async function listPoolKeys(
providerId: string,
params: PoolKeysQuery = {},
): Promise<PoolKeysPageResponse> {
const response = await client.get(`/api/admin/pool/${providerId}/keys`, { params })
return response.data
}
export async function batchImportPoolKeys(
providerId: string,
keys: PoolKeyImportItem[],
): Promise<BatchImportResponse> {
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-import`, { keys })
return response.data
}
export async function batchActionPoolKeys(
providerId: string,
body: PoolBatchAction,
): Promise<{ affected: number; message: string }> {
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-action`, body)
return response.data
}

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

View 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="[{&quot;name&quot;: &quot;key-01&quot;, &quot;api_key&quot;: &quot;sk-xxx&quot;}, ...]"
/>
<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>

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

View File

@@ -0,0 +1,70 @@
const PLAN_TYPE_LABELS: Record<string, string> = {
free: 'Free',
plus: 'Plus',
team: 'Team',
enterprise: 'Enterprise',
paid: 'Paid',
pro: 'Pro',
'pro+': 'Pro+',
power: 'Power',
ultra: 'Ultra',
}
const PLAN_TYPE_CLASS_NAMES: Record<string, string> = {
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
free: 'border-primary/50 text-primary',
paid: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
export function normalizeOAuthPlanType(planType?: string | null): string | null {
if (typeof planType !== 'string') {
return null
}
const normalized = planType.trim().toLowerCase()
if (!normalized) {
return null
}
return normalized
}
export function formatOAuthPlanType(planType?: string | null): string {
const normalized = normalizeOAuthPlanType(planType)
if (!normalized) {
return ''
}
const knownLabel = PLAN_TYPE_LABELS[normalized]
if (knownLabel) {
return knownLabel
}
return normalized
.replace(/[_-]+/g, ' ')
.split(/\s+/)
.filter(Boolean)
.map(part => part[0].toUpperCase() + part.slice(1))
.join(' ')
}
export function getOAuthPlanTypeClass(planType?: string | null): string {
const normalized = normalizeOAuthPlanType(planType)
if (!normalized) {
return ''
}
return PLAN_TYPE_CLASS_NAMES[normalized] || ''
}
export function isNonFreeOAuthPlan(planType?: string | null): boolean {
const normalized = normalizeOAuthPlanType(planType)
if (!normalized) {
return false
}
return normalized !== 'free'
}

View File

@@ -0,0 +1,855 @@
<template>
<div class="space-y-6 pb-8">
<Card
variant="default"
class="overflow-hidden"
>
<!-- Header -->
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<!-- Mobile -->
<div class="flex flex-col gap-3 sm:hidden">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
号池管理
</h3>
<div class="flex items-center gap-1.5">
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="批量导入"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="号池配置"
@click="showConfigDialog = true"
>
<Settings class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="overviewLoading || keysLoading"
@click="refresh"
/>
</div>
</div>
<!-- Filters (mobile) -->
<div class="flex items-center gap-2">
<Select v-model="selectedProviderIdProxy">
<SelectTrigger class="flex-1 h-8 text-xs border-border/60">
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in poolProviders"
:key="item.provider_id"
:value="item.provider_id"
>
{{ item.provider_name }}
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
</SelectItem>
</SelectContent>
</Select>
<Select v-model="statusFilter">
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="cooldown">
冷却中
</SelectItem>
<SelectItem value="inactive">
已禁用
</SelectItem>
</SelectContent>
</Select>
</div>
<div
v-if="selectedProviderId"
class="relative"
>
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchQuery"
type="text"
placeholder="搜索账号..."
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
</div>
<!-- Desktop -->
<div class="hidden sm:flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">
号池管理
</h3>
<div class="flex items-center gap-2">
<Select v-model="selectedProviderIdProxy">
<SelectTrigger class="w-44 h-8 text-xs border-border/60">
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in poolProviders"
:key="item.provider_id"
:value="item.provider_id"
>
{{ item.provider_name }}
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
</SelectItem>
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<div
v-if="selectedProviderId"
class="relative"
>
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchQuery"
type="text"
placeholder="搜索账号..."
class="w-40 pl-8 pr-2 h-8 text-xs bg-background/50 border-border/60"
/>
</div>
<Select v-model="statusFilter">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="cooldown">
冷却中
</SelectItem>
<SelectItem value="inactive">
已禁用
</SelectItem>
</SelectContent>
</Select>
<div
v-if="selectedProviderId"
class="h-4 w-px bg-border"
/>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="批量导入"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<Button
v-if="selectedProviderId"
variant="ghost"
size="icon"
class="h-8 w-8"
title="号池配置"
@click="showConfigDialog = true"
>
<Settings class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="overviewLoading || keysLoading"
@click="refresh"
/>
</div>
</div>
</div>
<!-- Loading (initial) -->
<div
v-if="overviewLoading"
class="flex items-center justify-center py-16"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- No providers -->
<div
v-else-if="poolProviders.length === 0"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Database class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
暂无 Provider
</p>
<p class="text-xs text-muted-foreground mt-1">
请先添加 Provider
</p>
</div>
<!-- No provider selected -->
<div
v-else-if="!selectedProviderId"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Database class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
请选择一个 Provider 查看账号
</p>
</div>
<!-- Loading keys -->
<div
v-else-if="keysLoading && keyPage.keys.length === 0"
class="flex items-center justify-center py-16"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<template v-else>
<!-- Batch action bar -->
<div
v-if="selectedKeys.size > 0"
class="flex items-center gap-2 px-4 sm:px-6 py-2.5 bg-muted/40 border-b border-border/40"
>
<span class="text-xs font-medium text-muted-foreground mr-1">
已选 {{ selectedKeys.size }}
</span>
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
@click="batchAction('enable')"
>
启用
</Button>
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
@click="batchAction('disable')"
>
禁用
</Button>
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
@click="batchAction('clear_cooldown')"
>
清除冷却
</Button>
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
@click="batchAction('reset_cost')"
>
重置成本
</Button>
<Button
variant="destructive"
size="sm"
class="h-7 text-xs"
@click="batchAction('delete')"
>
删除
</Button>
</div>
<!-- Desktop table -->
<div
v-if="keyPage.keys.length > 0"
class="hidden xl:block overflow-x-auto"
>
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-10 h-12">
<input
type="checkbox"
:checked="allSelected"
class="rounded"
@change="toggleSelectAll"
>
</TableHead>
<TableHead class="font-semibold">
名称
</TableHead>
<TableHead class="w-20 font-semibold">
状态
</TableHead>
<TableHead class="w-32 font-semibold">
冷却
</TableHead>
<TableHead class="w-40 font-semibold">
成本
</TableHead>
<TableHead class="w-16 font-semibold text-center">
会话
</TableHead>
<TableHead class="w-24 font-semibold">
最后使用
</TableHead>
<TableHead class="w-20 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="key in keyPage.keys"
:key="key.key_id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
:class="{ 'opacity-50': !key.is_active }"
>
<TableCell class="py-3">
<input
type="checkbox"
:checked="selectedKeys.has(key.key_id)"
class="rounded"
@change="toggleSelect(key.key_id)"
>
</TableCell>
<TableCell class="py-3">
<span class="text-sm truncate max-w-[200px] block">
{{ key.key_name || '未命名' }}
</span>
</TableCell>
<TableCell class="py-3">
<Badge
:variant="key.is_active ? (key.cooldown_reason ? 'destructive' : 'default') : 'secondary'"
class="text-[10px]"
>
{{ key.is_active ? (key.cooldown_reason ? '冷却' : '活跃') : '禁用' }}
</Badge>
</TableCell>
<TableCell class="py-3">
<template v-if="key.cooldown_reason">
<div class="flex items-center gap-1">
<span class="text-xs text-destructive">
{{ formatCooldownReason(key.cooldown_reason) }}
</span>
<span
v-if="key.cooldown_ttl_seconds"
class="text-[10px] text-muted-foreground"
>
{{ formatTTL(key.cooldown_ttl_seconds) }}
</span>
</div>
</template>
<span
v-else
class="text-xs text-muted-foreground"
>-</span>
</TableCell>
<TableCell class="py-3">
<div
v-if="key.cost_limit != null"
class="flex items-center gap-2"
>
<div class="flex-1 h-1.5 bg-border rounded-full overflow-hidden max-w-[80px]">
<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="text-[10px] tabular-nums text-muted-foreground whitespace-nowrap">
{{ formatTokens(key.cost_window_usage) }}/{{ formatTokens(key.cost_limit) }}
</span>
</div>
<span
v-else-if="key.cost_window_usage > 0"
class="text-[10px] tabular-nums text-muted-foreground"
>
{{ formatTokens(key.cost_window_usage) }}
</span>
<span
v-else
class="text-xs text-muted-foreground"
>-</span>
</TableCell>
<TableCell class="py-3 text-center">
<span
v-if="key.sticky_sessions > 0"
class="text-xs tabular-nums"
>
{{ key.sticky_sessions }}
</span>
<span
v-else
class="text-xs text-muted-foreground"
>-</span>
</TableCell>
<TableCell class="py-3">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
</span>
</TableCell>
<TableCell class="py-3">
<div class="flex justify-center gap-0.5">
<Button
v-if="key.cooldown_reason"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-green-600"
title="清除冷却"
@click="clearCooldown(key.key_id)"
>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-foreground"
:title="key.is_active ? '禁用' : '启用'"
@click="toggleKeyActive(key)"
>
<component
:is="key.is_active ? Ban : Check"
class="w-3.5 h-3.5"
/>
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Mobile card list -->
<div
v-if="keyPage.keys.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="key in keyPage.keys"
:key="key.key_id"
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
:class="{ 'opacity-50': !key.is_active }"
>
<div class="flex items-center gap-3">
<input
type="checkbox"
:checked="selectedKeys.has(key.key_id)"
class="rounded shrink-0"
@change="toggleSelect(key.key_id)"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">
{{ key.key_name || '未命名' }}
</span>
<Badge
:variant="key.is_active ? (key.cooldown_reason ? 'destructive' : 'default') : 'secondary'"
class="text-[10px] shrink-0"
>
{{ key.is_active ? (key.cooldown_reason ? '冷却' : '活跃') : '禁用' }}
</Badge>
</div>
</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="清除冷却"
@click="clearCooldown(key.key_id)"
>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-foreground"
:title="key.is_active ? '禁用' : '启用'"
@click="toggleKeyActive(key)"
>
<component
:is="key.is_active ? Ban : Check"
class="w-3.5 h-3.5"
/>
</Button>
</div>
</div>
<div class="mt-2.5 ml-7 grid grid-cols-3 gap-2">
<div class="p-2 bg-muted/50 rounded-lg text-xs">
<div class="text-muted-foreground mb-0.5">
冷却
</div>
<div
v-if="key.cooldown_reason"
class="font-medium text-destructive text-[11px]"
>
{{ formatCooldownReason(key.cooldown_reason) }}
<span
v-if="key.cooldown_ttl_seconds"
class="text-muted-foreground font-normal"
>
{{ formatTTL(key.cooldown_ttl_seconds) }}
</span>
</div>
<div
v-else
class="text-muted-foreground"
>
-
</div>
</div>
<div class="p-2 bg-muted/50 rounded-lg text-xs">
<div class="text-muted-foreground mb-0.5">
成本
</div>
<div
v-if="key.cost_limit != null"
class="font-medium tabular-nums text-[11px]"
>
{{ formatTokens(key.cost_window_usage) }}/{{ formatTokens(key.cost_limit) }}
</div>
<div
v-else-if="key.cost_window_usage > 0"
class="tabular-nums text-[11px]"
>
{{ formatTokens(key.cost_window_usage) }}
</div>
<div
v-else
class="text-muted-foreground"
>
-
</div>
</div>
<div class="p-2 bg-muted/50 rounded-lg text-xs">
<div class="text-muted-foreground mb-0.5">
最后使用
</div>
<div class="text-[11px]">
{{ key.last_used_at ? formatRelativeTime(key.last_used_at) : '-' }}
</div>
</div>
</div>
</div>
</div>
<!-- Empty keys -->
<div
v-if="keyPage.keys.length === 0 && !keysLoading"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<KeyRound class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
暂无账号
</p>
<Button
variant="outline"
size="sm"
class="mt-3"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5 mr-1.5" />
批量导入
</Button>
</div>
<!-- Pagination -->
<Pagination
v-if="keyPage.keys.length > 0"
:current="currentPage"
:total="keyPage.total"
:page-size="pageSize"
cache-key="pool-keys-page-size"
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</template>
</Card>
<!-- Dialogs -->
<PoolImportDialog
v-if="selectedProviderId"
v-model="showImportDialog"
:provider-id="selectedProviderId"
@imported="loadKeys"
/>
<PoolConfigDialog
v-if="selectedProviderId"
v-model="showConfigDialog"
:provider-id="selectedProviderId"
:provider-type="selectedProviderData?.provider_type"
:current-config="selectedProviderConfig"
:current-claude-config="selectedProviderData?.claude_code_advanced"
@saved="loadOverview"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Search, Upload, Settings, RefreshCw, Ban, Check, Database, KeyRound } from 'lucide-vue-next'
import {
Card,
Badge,
Button,
Input,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Pagination,
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import {
getPoolOverview,
listPoolKeys,
clearPoolCooldown,
batchActionPoolKeys,
} from '@/api/endpoints/pool'
import type {
PoolOverviewItem,
PoolKeyDetail,
PoolKeysPageResponse,
} from '@/api/endpoints/pool'
import type { PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
import { getProvider } from '@/api/endpoints'
import PoolImportDialog from '@/features/pool/components/PoolImportDialog.vue'
import PoolConfigDialog from '@/features/pool/components/PoolConfigDialog.vue'
const { success, error: showError } = useToast()
// --- Overview ---
const poolProviders = ref<PoolOverviewItem[]>([])
const overviewLoading = ref(true)
async function loadOverview() {
overviewLoading.value = true
try {
const res = await getPoolOverview()
poolProviders.value = res.items
// Auto-select first provider if none selected
if (!selectedProviderId.value && res.items.length > 0) {
await selectProvider(res.items[0].provider_id)
}
} catch (err) {
showError(parseApiError(err))
} finally {
overviewLoading.value = false
}
}
// --- Provider Selection ---
const selectedProviderId = ref<string | null>(null)
const selectedProviderData = ref<ProviderWithEndpointsSummary | null>(null)
// Proxy for Select v-model (string, not string|null)
const selectedProviderIdProxy = computed({
get: () => selectedProviderId.value ?? '',
set: (val: string) => {
if (val && val !== selectedProviderId.value) {
selectProvider(val)
}
},
})
const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => {
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
})
async function selectProvider(id: string) {
selectedProviderId.value = id
selectedKeys.value.clear()
currentPage.value = 1
searchQuery.value = ''
statusFilter.value = 'all'
await Promise.all([loadKeys(), loadProviderData(id)])
}
async function loadProviderData(id: string) {
try {
selectedProviderData.value = await getProvider(id)
} catch {
selectedProviderData.value = null
}
}
async function refresh() {
await loadOverview()
if (selectedProviderId.value) {
await loadKeys()
}
}
// --- Keys ---
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
const keysLoading = ref(false)
const searchQuery = ref('')
const statusFilter = ref('all')
const currentPage = ref(1)
const pageSize = ref(50)
async function loadKeys() {
if (!selectedProviderId.value) return
keysLoading.value = true
try {
keyPage.value = await listPoolKeys(selectedProviderId.value, {
page: currentPage.value,
page_size: pageSize.value,
search: searchQuery.value || undefined,
status: statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive',
})
} catch (err) {
showError(parseApiError(err))
} finally {
keysLoading.value = false
}
}
watch([currentPage, pageSize], () => loadKeys())
watch([searchQuery, statusFilter], () => {
currentPage.value = 1
loadKeys()
})
// --- Key Selection ---
const selectedKeys = ref(new Set<string>())
const allSelected = computed(() => {
if (keyPage.value.keys.length === 0) return false
return keyPage.value.keys.every(k => selectedKeys.value.has(k.key_id))
})
function toggleSelectAll() {
if (allSelected.value) {
selectedKeys.value.clear()
} else {
keyPage.value.keys.forEach(k => selectedKeys.value.add(k.key_id))
}
}
function toggleSelect(id: string) {
if (selectedKeys.value.has(id)) {
selectedKeys.value.delete(id)
} else {
selectedKeys.value.add(id)
}
}
// --- Actions ---
async function clearCooldown(keyId: string) {
if (!selectedProviderId.value) return
try {
const res = await clearPoolCooldown(selectedProviderId.value, keyId)
success(res.message)
await loadKeys()
} catch (err) {
showError(parseApiError(err))
}
}
async function toggleKeyActive(key: PoolKeyDetail) {
if (!selectedProviderId.value) return
try {
const action = key.is_active ? 'disable' : 'enable'
await batchActionPoolKeys(selectedProviderId.value, {
key_ids: [key.key_id],
action,
})
await loadKeys()
} catch (err) {
showError(parseApiError(err))
}
}
async function batchAction(action: 'enable' | 'disable' | 'delete' | 'clear_cooldown' | 'reset_cost') {
if (!selectedProviderId.value || selectedKeys.value.size === 0) return
try {
const res = await batchActionPoolKeys(selectedProviderId.value, {
key_ids: Array.from(selectedKeys.value),
action,
})
success(res.message)
selectedKeys.value.clear()
await loadKeys()
} catch (err) {
showError(parseApiError(err))
}
}
// --- Dialogs ---
const showImportDialog = ref(false)
const showConfigDialog = ref(false)
// --- Formatting ---
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 formatRelativeTime(isoStr: string): string {
const diff = (Date.now() - new Date(isoStr).getTime()) / 1000
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 前`
}
// --- Init ---
onMounted(async () => {
await loadOverview()
})
</script>