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