Files
Aether/frontend/src/views/admin/ProviderManagement.vue
2025-12-10 20:52:44 +08:00

562 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-4">
<!-- 提供商表格 -->
<Card variant="default" class="overflow-hidden">
<!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/50">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold text-foreground">提供商管理</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
<Input
id="provider-search"
v-model="searchQuery"
type="text"
placeholder="搜索提供商..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<div class="h-4 w-px bg-border" />
<!-- 调度策略 -->
<button
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
@click="openPriorityDialog"
title="点击调整调度策略"
>
<span class="text-muted-foreground/80">调度:</span>
<span class="font-medium text-foreground/90">{{ priorityModeConfig.label }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button>
<div class="h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openAddProviderDialog"
title="新增提供商"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loading" @click="loadProviders" />
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- 空状态 -->
<div v-else-if="filteredProviders.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<div class="text-muted-foreground mb-2">
<template v-if="searchQuery">
未找到匹配 "{{ searchQuery }}" 的提供商
</template>
<template v-else>
暂无提供商点击右上角添加
</template>
</div>
<Button v-if="searchQuery" variant="outline" size="sm" @click="searchQuery = ''">
清除搜索
</Button>
</div>
<!-- 桌面端表格 -->
<div v-else class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/40 hover:bg-transparent">
<TableHead class="w-[150px] h-11 font-medium text-foreground/80">提供商信息</TableHead>
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">计费类型</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80">官网</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">资源统计</TableHead>
<TableHead class="w-[240px] h-11 font-medium text-foreground/80">端点健康</TableHead>
<TableHead class="w-[140px] h-11 font-medium text-foreground/80">配额/限流</TableHead>
<TableHead class="w-[80px] h-11 font-medium text-foreground/80 text-center">状态</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="provider in paginatedProviders"
:key="provider.id"
class="border-b border-border/30 hover:bg-muted/20 transition-colors cursor-pointer"
@mousedown="handleMouseDown"
@click="handleRowClick($event, provider.id)"
>
<TableCell class="py-3.5">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{{ provider.display_name }}</span>
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
</div>
</TableCell>
<TableCell class="py-3.5">
<Badge variant="outline" class="text-xs font-normal border-border/50">
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
</TableCell>
<TableCell class="py-3.5">
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[100px]"
@click.stop
:title="provider.website"
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
<span v-else class="text-xs text-muted-foreground/50">-</span>
</TableCell>
<TableCell class="py-3.5 text-center">
<div class="space-y-0.5 text-xs">
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">端点:</span>
<span class="font-medium text-foreground/90">{{ provider.active_endpoints }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_endpoints }}</span>
</div>
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">密钥:</span>
<span class="font-medium text-foreground/90">{{ provider.active_keys }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_keys }}</span>
</div>
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">模型:</span>
<span class="font-medium text-foreground/90">{{ provider.active_models }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_models }}</span>
</div>
</div>
</TableCell>
<TableCell class="py-3.5 align-middle">
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="flex flex-wrap gap-1.5 max-w-[280px]"
>
<span
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-[10px] font-medium tracking-wide uppercase leading-none"
:class="getEndpointTagClass(endpoint, provider)"
:title="getEndpointTooltip(endpoint, provider)"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="getEndpointDotColor(endpoint, provider)"
></span>
{{ endpoint.api_format }}
</span>
</div>
<span v-else class="text-xs text-muted-foreground/50">暂无端点</span>
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5 text-xs">
<div v-if="provider.billing_type === 'monthly_quota'" class="text-muted-foreground/70">
配额: <span class="font-semibold" :class="getQuotaUsedColorClass(provider)">${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</div>
<div v-if="rpmUsage(provider)" class="flex items-center gap-1">
<span class="text-muted-foreground/70">RPM:</span>
<span class="font-medium text-foreground/80">{{ rpmUsage(provider) }}</span>
</div>
<div v-if="provider.billing_type !== 'monthly_quota' && !rpmUsage(provider)" class="text-muted-foreground/50">
无限制
</div>
</div>
</TableCell>
<TableCell class="py-3.5 text-center">
<Badge :variant="provider.is_active ? 'success' : 'secondary'" class="text-xs">
{{ provider.is_active ? '活跃' : '已停用' }}
</Badge>
</TableCell>
<TableCell class="py-3.5" @click.stop>
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="openProviderDrawer(provider.id)"
title="查看详情"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="openEditProviderDialog(provider)"
title="编辑提供商"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="toggleProviderStatus(provider)"
:title="provider.is_active ? '停用提供商' : '启用提供商'"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
@click="handleDeleteProvider(provider)"
title="删除提供商"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && filteredProviders.length > 0"
:current="currentPage"
:total="filteredProviders.length"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</Card>
</div>
<!-- 对话框 -->
<ProviderFormDialog
v-model="providerDialogOpen"
:provider="providerToEdit"
@provider-created="handleProviderAdded"
@provider-updated="handleProviderUpdated"
/>
<PriorityManagementDialog
v-model="priorityDialogOpen"
:providers="providers"
@saved="handlePrioritySaved"
/>
<ProviderDetailDrawer
:open="providerDrawerOpen"
:provider-id="selectedProviderId"
@update:open="providerDrawerOpen = $event"
@edit="openEditProviderDialog"
@toggle-status="toggleProviderStatus"
@refresh="loadProviders"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
Plus,
Search,
Edit,
Eye,
Trash2,
ChevronDown,
Power
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import Input from '@/components/ui/input.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { ProviderFormDialog, PriorityManagementDialog } from '@/features/providers/components'
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useRowClick } from '@/composables/useRowClick'
import {
getProvidersSummary,
deleteProvider,
updateProvider,
type ProviderWithEndpointsSummary
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { formatBillingType } from '@/utils/format'
const { error: showError, success: showSuccess } = useToast()
const { confirmDanger } = useConfirm()
// 状态
const loading = ref(false)
const providers = ref<ProviderWithEndpointsSummary[]>([])
const providerDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
// 搜索
const searchQuery = ref('')
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
// 优先级模式配置
const priorityModeConfig = computed(() => {
return {
label: priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'
}
})
// 筛选后的提供商列表
const filteredProviders = computed(() => {
let result = [...providers.value]
// 搜索筛选
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
result = result.filter(p =>
p.display_name.toLowerCase().includes(query) ||
p.name.toLowerCase().includes(query)
)
}
// 排序
return result.sort((a, b) => {
// 1. 优先显示活跃的提供商
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1
}
// 2. 按优先级排序
if (a.provider_priority !== b.provider_priority) {
return a.provider_priority - b.provider_priority
}
// 3. 按名称排序
return a.display_name.localeCompare(b.display_name)
})
})
// 分页
const paginatedProviders = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredProviders.value.slice(start, end)
})
// 搜索时重置分页
watch(searchQuery, () => {
currentPage.value = 1
})
// 加载优先级模式
async function loadPriorityMode() {
try {
const response = await adminApi.getSystemConfig('provider_priority_mode')
if (response.value) {
priorityMode.value = response.value as 'provider' | 'global_key'
}
} catch {
priorityMode.value = 'provider'
}
}
// 加载提供商列表
async function loadProviders() {
loading.value = true
try {
providers.value = await getProvidersSummary()
} catch (err: any) {
showError(err.response?.data?.detail || '加载提供商列表失败', '错误')
} finally {
loading.value = false
}
}
// 格式化官网显示
function formatWebsiteDisplay(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace(/^www\./, '')
} catch {
return url
}
}
// 端点排序
function sortEndpoints(endpoints: any[]) {
return [...endpoints].sort((a, b) => {
const order = ['CLAUDE', 'OPENAI', 'CLAUDE_COMPATIBLE', 'OPENAI_COMPATIBLE', 'GEMINI', 'GEMINI_COMPATIBLE']
return order.indexOf(a.api_format) - order.indexOf(b.api_format)
})
}
// 判断端点是否可用(有 key
function isEndpointAvailable(endpoint: any, _provider: ProviderWithEndpointsSummary): boolean {
// 检查该端点是否有活跃的密钥
return (endpoint.active_keys ?? 0) > 0
}
// 端点标签样式
function getEndpointTagClass(endpoint: any, provider: ProviderWithEndpointsSummary): string {
if (!isEndpointAvailable(endpoint, provider)) {
return 'border-red-300/50 bg-red-50/50 text-red-600/80 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400/80'
}
return 'border-border/40 bg-muted/20 text-foreground/70'
}
// 端点圆点颜色
function getEndpointDotColor(endpoint: any, provider: ProviderWithEndpointsSummary): string {
if (!isEndpointAvailable(endpoint, provider)) {
return 'bg-red-400'
}
const score = endpoint.health_score
if (score === undefined || score === null) {
return 'bg-muted-foreground/40'
}
if (score >= 0.8) {
return 'bg-green-500'
}
if (score >= 0.5) {
return 'bg-amber-500'
}
return 'bg-red-500'
}
// 端点提示文本
function getEndpointTooltip(endpoint: any, provider: ProviderWithEndpointsSummary): string {
if (provider.active_keys === 0) {
return `${endpoint.api_format}: 无可用密钥`
}
const score = endpoint.health_score
if (score === undefined || score === null) {
return `${endpoint.api_format}: 暂无健康数据`
}
return `${endpoint.api_format}: 健康度 ${(score * 100).toFixed(0)}%`
}
// 配额已用颜色(根据使用比例)
function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string {
const used = provider.monthly_used_usd ?? 0
const quota = provider.monthly_quota_usd ?? 0
if (quota <= 0) return 'text-foreground'
const ratio = used / quota
if (ratio >= 0.9) return 'text-red-600 dark:text-red-400'
if (ratio >= 0.7) return 'text-amber-600 dark:text-amber-400'
return 'text-foreground'
}
function rpmUsage(provider: ProviderWithEndpointsSummary): string | null {
const rpmLimit = provider.rpm_limit
const rpmUsed = provider.rpm_used ?? 0
if (rpmLimit === null || rpmLimit === undefined) {
return rpmUsed > 0 ? `${rpmUsed}` : null
}
if (rpmLimit === 0) {
return '已完全禁止'
}
return `${rpmUsed} / ${rpmLimit}`
}
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
// 处理行点击 - 只在非选中文本时打开抽屉
function handleRowClick(event: MouseEvent, providerId: string) {
if (!shouldTriggerRowClick(event)) return
openProviderDrawer(providerId)
}
// 打开添加提供商对话框
function openAddProviderDialog() {
providerToEdit.value = null
providerDialogOpen.value = true
}
// 打开优先级管理对话框
function openPriorityDialog() {
priorityDialogOpen.value = true
}
// 打开提供商详情抽屉
function openProviderDrawer(providerId: string) {
selectedProviderId.value = providerId
providerDrawerOpen.value = true
}
// 打开编辑提供商对话框
function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
providerToEdit.value = provider
providerDialogOpen.value = true
}
// 处理提供商编辑完成
function handleProviderUpdated() {
loadProviders()
}
// 优先级保存成功回调
async function handlePrioritySaved() {
await loadProviders()
await loadPriorityMode()
}
// 处理提供商添加
function handleProviderAdded() {
loadProviders()
}
// 删除提供商
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
const confirmed = await confirmDanger(
'删除提供商',
`确定要删除提供商 "${provider.display_name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复`
)
if (!confirmed) return
try {
await deleteProvider(provider.id)
showSuccess('提供商已删除')
loadProviders()
} catch (err: any) {
showError(err.response?.data?.detail || '删除提供商失败', '错误')
}
}
// 切换提供商状态
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
try {
await updateProvider(provider.id, { is_active: !provider.is_active })
provider.is_active = !provider.is_active
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
}
}
onMounted(() => {
loadProviders()
loadPriorityMode()
})
</script>