Files
Aether/frontend/src/features/models/components/RoutingTab.vue

1042 lines
42 KiB
Vue
Raw Normal View History

<template>
<Card class="overflow-hidden">
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold">
请求链路预览
</h4>
<template v-if="routingData">
<span class="text-muted-foreground">·</span>
<span class="text-xs text-muted-foreground">
{{ getSchedulingModeLabel(routingData.scheduling_mode) }}
</span>
<span class="text-muted-foreground">·</span>
<span class="text-xs text-muted-foreground">
{{ getPriorityModeLabel(routingData.priority_mode) }}
</span>
</template>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="添加关联"
@click="$emit('addProvider')"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="刷新"
@click="loadRoutingData"
>
<RefreshCw
class="w-3.5 h-3.5"
:class="loading ? 'animate-spin' : ''"
/>
</Button>
</div>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<template v-else-if="routingData">
<div class="p-4 space-y-4">
<!-- 空状态 -->
<div
v-if="apiFormatGroups.length === 0"
class="text-center py-8"
>
<Route class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">
暂无关联提供商
</p>
<p class="text-xs text-muted-foreground mt-1">
请先为此模型添加提供商关联
</p>
</div>
<!-- API 格式分组 -->
<div
v-for="formatGroup in apiFormatGroups"
:key="formatGroup.api_format"
class="border border-border/60 rounded-lg overflow-hidden"
>
<!-- 格式标题栏 -->
<div
class="px-4 py-3 bg-muted/30 flex items-center justify-between cursor-pointer hover:bg-muted/50 transition-colors"
@click="toggleFormat(formatGroup.api_format)"
>
<div class="flex items-center gap-3">
<Badge
variant="secondary"
class="text-xs font-semibold px-2.5 py-1"
>
{{ formatGroup.api_format }}
</Badge>
<span class="text-sm text-muted-foreground">
{{ formatGroup.active_keys }}/{{ formatGroup.total_keys }} Keys
<span class="mx-1.5">·</span>
{{ formatGroup.active_providers }}/{{ formatGroup.total_providers }} 提供商
</span>
</div>
<ChevronDown
class="w-4 h-4 text-muted-foreground transition-transform"
:class="isFormatExpanded(formatGroup.api_format) ? 'rotate-180' : ''"
/>
</div>
<!-- 展开的内容 -->
<Transition name="collapse">
<div v-if="isFormatExpanded(formatGroup.api_format)">
<!-- ========== 全局 Key 优先模式 ========== -->
<template v-if="isGlobalKeyMode">
<div class="py-2 pl-3">
<template
v-for="(keyGroup, groupIndex) in formatGroup.keyGroups"
:key="groupIndex"
>
<!-- 第一组且有多个 key 时显示负载均衡标签 -->
<div
v-if="groupIndex === 0 && keyGroup.keys.length > 1"
class="ml-6 mr-3 mb-1 flex items-center gap-1 text-[10px] text-muted-foreground/60"
>
<span>负载均衡</span>
</div>
<!-- 该优先级组内的 Keys -->
<div
v-for="(keyEntry, keyIndex) in keyGroup.keys"
:key="keyEntry.key.id"
class="flex py-1"
>
<!-- 左侧节点 + 连线 -->
<div class="w-6 flex flex-col items-center shrink-0">
<!-- 上半段连线 -->
<div
class="w-0.5 flex-1"
:class="groupIndex === 0 && keyIndex === 0 ? 'bg-transparent' : 'bg-border'"
/>
<!-- 节点圆点 -->
<div
class="w-3 h-3 rounded-full border-2 shrink-0"
:class="getGlobalKeyNodeClass(keyEntry, groupIndex, keyIndex)"
/>
<!-- 下半段连线 -->
<div
class="w-0.5 flex-1"
:class="isLastKeyInFormat(formatGroup, groupIndex, keyIndex) ? 'bg-transparent' : 'bg-border'"
/>
</div>
<!-- Key 卡片 -->
<div
class="flex-1 mr-3"
:class="!keyEntry.key.is_active ? 'opacity-50' : ''"
>
<div
class="group rounded-lg transition-all p-2.5"
:class="getGlobalKeyCardClass(keyEntry, groupIndex, keyIndex)"
>
<div class="flex items-center gap-2">
<!-- 第一列优先级标签 -->
<div
v-if="keyEntry.key.is_active"
class="px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0"
:class="groupIndex === 0 && keyIndex === 0
? 'bg-primary text-primary-foreground'
: 'bg-muted-foreground/20 text-muted-foreground'"
>
<span v-if="groupIndex === 0 && keyIndex === 0">首选</span>
<span v-else>P{{ keyGroup.priority ?? '?' }}</span>
</div>
<!-- 第二列状态指示灯 -->
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="getKeyStatusClass(keyEntry.key)"
/>
<!-- 第三列Key 名称 + Provider 信息 -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
<span
class="text-sm font-medium truncate"
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
>
{{ keyEntry.key.name }}
</span>
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0">
{{ keyEntry.key.masked_key }}
</code>
<Zap
v-if="keyEntry.key.circuit_breaker_open"
class="w-3 h-3 text-destructive shrink-0"
/>
</div>
<!-- Provider Endpoint 信息 -->
<div class="text-[10px] text-muted-foreground truncate">
{{ keyEntry.provider.name }}
<span v-if="hasModelMapping(keyEntry.provider)">
({{ keyEntry.provider.provider_model_name }})
</span>
<span v-if="keyEntry.provider.billing_type">
· {{ getBillingLabel(keyEntry.provider) }}
</span>
<span v-if="keyEntry.endpoint">
· {{ keyEntry.endpoint.base_url }}
</span>
</div>
</div>
<!-- 第四列健康度 + RPM + 操作按钮 -->
<div class="flex items-center gap-1.5 shrink-0">
<!-- 健康度 -->
<div class="flex items-center gap-1">
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="getHealthScoreBarColor(keyEntry.key.health_score)"
:style="{ width: `${keyEntry.key.health_score}%` }"
/>
</div>
<span
class="text-[10px] font-medium tabular-nums"
:class="getHealthScoreTextColor(keyEntry.key.health_score)"
>
{{ Math.round(keyEntry.key.health_score) }}%
</span>
</div>
<!-- RPM -->
<span
v-if="keyEntry.key.effective_rpm"
class="text-[10px] text-muted-foreground/60"
>
{{ keyEntry.key.is_adaptive ? '~' : '' }}{{ keyEntry.key.effective_rpm }}
</span>
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="编辑此关联"
@click.stop="$emit('editProvider', keyEntry.provider)"
>
<Edit class="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:title="keyEntry.provider.model_is_active ? '停用此关联' : '启用此关联'"
@click.stop="$emit('toggleProviderStatus', keyEntry.provider)"
>
<Power class="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="删除此关联"
@click.stop="$emit('deleteProvider', keyEntry.provider)"
>
<Trash2 class="w-3 h-3" />
</Button>
</div>
</div>
<!-- 熔断详情如果有 -->
<div
v-if="keyEntry.key.circuit_breaker_open"
class="text-[10px] text-destructive mt-1.5 ml-6"
>
熔断中: {{ keyEntry.key.circuit_breaker_formats.join(', ') }}
</div>
</div>
</div>
</div>
<!-- 降级标记如果下一组有多个 key显示"降级 · 负载均衡" -->
<div
v-if="groupIndex < formatGroup.keyGroups.length - 1"
class="flex py-0.5"
>
<div class="w-6 flex justify-center shrink-0">
<div class="w-0.5 h-full bg-border" />
</div>
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/50">
<ArrowDown class="w-3 h-3" />
<span>
{{ formatGroup.keyGroups[groupIndex + 1].keys.length > 1 ? '降级 · 负载均衡' : '降级' }}
</span>
</div>
</div>
</template>
</div>
</template>
<!-- ========== 提供商优先模式 ========== -->
<template v-else>
<div class="py-2 pl-3">
<div
v-for="(providerEntry, providerIndex) in formatGroup.providers"
:key="`${providerEntry.provider.id}-${providerEntry.endpoint?.id || providerIndex}`"
>
<!-- 提供商行 -->
<div class="flex py-1">
<!-- 左侧节点 + 连线 -->
<div class="w-6 flex flex-col items-center shrink-0">
<!-- 上半段连线 -->
<div
class="w-0.5 flex-1"
:class="providerIndex === 0 ? 'bg-transparent' : 'bg-border'"
/>
<!-- 节点圆点 -->
<div
class="w-3 h-3 rounded-full border-2 shrink-0"
:class="getFormatProviderNodeClass(providerEntry, providerIndex)"
/>
<!-- 下半段连线 -->
<div
class="w-0.5 flex-1"
:class="providerIndex === formatGroup.providers.length - 1 ? 'bg-transparent' : 'bg-border'"
/>
</div>
<!-- 提供商卡片 -->
<div
class="flex-1 mr-3"
:class="!providerEntry.provider.is_active || !providerEntry.provider.model_is_active ? 'opacity-50' : ''"
>
<div
class="group rounded-lg transition-all"
:class="getFormatProviderCardClass(providerEntry, providerIndex)"
>
<!-- 卡片头部 -->
<div
class="p-2.5 cursor-pointer"
@click="toggleProviderInFormat(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)"
>
<div class="flex items-center gap-2">
<!-- 第一列优先级标签 -->
<div
v-if="providerEntry.provider.is_active && providerEntry.provider.model_is_active"
class="px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0"
:class="providerIndex === 0
? 'bg-primary text-primary-foreground'
: 'bg-muted-foreground/20 text-muted-foreground'"
>
<span v-if="providerIndex === 0">首选</span>
<span v-else>P{{ providerEntry.provider.provider_priority }}</span>
</div>
<!-- 第二列状态指示灯 -->
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="getProviderStatusClass(providerEntry.provider)"
/>
<!-- 第三列名称(第一行) + URL(第二行) -->
<div class="min-w-0 flex-1">
<!-- 第一行提供商名称 + 模型映射 -->
<div class="flex items-center gap-1">
<span class="text-sm font-medium truncate">{{ providerEntry.provider.name }}</span>
<span
v-if="hasModelMapping(providerEntry.provider)"
class="text-[10px] text-muted-foreground shrink-0"
>
({{ providerEntry.provider.provider_model_name }})
</span>
</div>
<!-- 第二行Endpoint URL -->
<div
v-if="providerEntry.endpoint"
class="text-[10px] text-muted-foreground truncate"
>
{{ providerEntry.endpoint.base_url }}{{ providerEntry.endpoint.custom_path || '' }}
</div>
</div>
<!-- 第四列操作区域 -->
<div class="flex items-center gap-1 shrink-0">
<!-- 计费标签 -->
<span
v-if="providerEntry.provider.billing_type"
class="text-[10px] text-muted-foreground mr-1"
>
{{ getBillingLabel(providerEntry.provider) }}
</span>
<!-- Keys 统计 -->
<span class="text-[10px] text-muted-foreground">
{{ providerEntry.active_keys }}/{{ providerEntry.keys.length }} Keys
</span>
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="编辑此关联"
@click.stop="$emit('editProvider', providerEntry.provider)"
>
<Edit class="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:title="providerEntry.provider.model_is_active ? '停用此关联' : '启用此关联'"
@click.stop="$emit('toggleProviderStatus', providerEntry.provider)"
>
<Power class="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="删除此关联"
@click.stop="$emit('deleteProvider', providerEntry.provider)"
>
<Trash2 class="w-3 h-3" />
</Button>
<!-- 展开图标 -->
<ChevronDown
class="w-3.5 h-3.5 text-muted-foreground transition-transform"
:class="isProviderInFormatExpanded(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id) ? 'rotate-180' : ''"
/>
</div>
</div>
</div>
<!-- 展开的 Keys 详情 -->
<Transition name="collapse">
<div
v-if="isProviderInFormatExpanded(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)"
class="border-t border-border/30 p-2.5"
>
<!-- Keys 列表 -->
<div
v-if="providerEntry.keys.length > 0"
class="relative ml-1"
>
<div
v-for="(group, groupIndex) in getKeyPriorityGroups(providerEntry.keys)"
:key="groupIndex"
>
<!-- 第一组且有多个 key 时显示负载均衡标签 -->
<div
v-if="groupIndex === 0 && group.keys.length > 1"
class="flex items-center gap-1 text-[10px] text-muted-foreground/60 mb-0.5"
>
<span>负载均衡</span>
</div>
<!-- 该优先级组内的 Keys -->
<div class="relative">
<!-- 垂直主干线 -->
<div
v-if="group.keys.length > 1"
class="absolute left-1 top-2 w-px bg-border"
:style="{ height: `calc(100% - 1rem)` }"
/>
<div
v-for="(key, keyIndex) in group.keys"
:key="key.id"
class="relative flex items-center"
>
<!-- 分支结构 -->
<div class="flex items-center shrink-0">
<div
class="w-2 h-2 rounded-full border-2 z-10"
:class="groupIndex === 0 && keyIndex === 0
? 'bg-primary border-primary'
: 'bg-background border-muted-foreground/40'"
/>
<div class="w-2 h-px bg-border" />
</div>
<!-- Key 信息 -->
<div
class="flex-1 min-w-0 flex items-center gap-1.5 px-2 py-0.5 my-0.5 rounded text-xs"
:class="[
groupIndex === 0 ? 'bg-primary/5' : 'bg-muted/30',
!key.is_active ? 'opacity-50' : ''
]"
:title="getKeyTooltip(key)"
>
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="getKeyStatusClass(key)"
/>
<span
class="font-medium truncate"
:class="key.circuit_breaker_open ? 'text-destructive' : ''"
>
{{ key.name }}
</span>
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0">
{{ key.masked_key }}
</code>
<Zap
v-if="key.circuit_breaker_open"
class="w-3 h-3 text-destructive shrink-0"
/>
<span class="flex-1" />
<!-- 健康度进度条 + 百分比 -->
<div class="flex items-center gap-1 shrink-0">
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="getHealthScoreBarColor(key.health_score)"
:style="{ width: `${key.health_score}%` }"
/>
</div>
<span
class="text-[10px] font-medium tabular-nums"
:class="getHealthScoreTextColor(key.health_score)"
>
{{ Math.round(key.health_score) }}%
</span>
</div>
<span
v-if="key.effective_rpm"
class="text-[10px] text-muted-foreground/60 shrink-0"
>
{{ key.is_adaptive ? '~' : '' }}{{ key.effective_rpm }}
</span>
</div>
</div>
</div>
<!-- 优先级组之间的降级标记如果下一组有多个 key显示"降级 · 负载均衡" -->
<div
v-if="groupIndex < getKeyPriorityGroups(providerEntry.keys).length - 1"
class="flex items-center gap-1 my-0.5 text-[10px] text-muted-foreground/50"
>
<ArrowDown class="w-3 h-3" />
<span>
{{ getKeyPriorityGroups(providerEntry.keys)[groupIndex + 1].keys.length > 1 ? '降级 · 负载均衡' : '降级' }}
</span>
</div>
</div>
<!-- 熔断详情 -->
<div
v-for="key in providerEntry.keys.filter(k => k.circuit_breaker_open)"
:key="`cb-${key.id}`"
class="text-[10px] text-destructive mt-1 ml-4"
>
{{ key.name }} 熔断: {{ key.circuit_breaker_formats.join(', ') }}
</div>
</div>
<div
v-else
class="text-[10px] text-muted-foreground"
>
暂无可用 Key
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- 降级标记 -->
<div
v-if="providerIndex < formatGroup.providers.length - 1"
class="flex py-0.5"
>
<div class="w-6 flex justify-center shrink-0">
<div class="w-0.5 h-full bg-border" />
</div>
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/50">
<ArrowDown class="w-3 h-3" />
<span>降级</span>
</div>
</div>
</div>
</div>
</template>
</div>
</Transition>
</div>
</div>
</template>
<!-- 错误状态 -->
<div
v-else-if="error"
class="p-8 text-center"
>
<AlertCircle class="w-12 h-12 mx-auto text-destructive/50 mb-3" />
<p class="text-sm text-destructive">
{{ error }}
</p>
<Button
variant="outline"
size="sm"
class="mt-4"
@click="loadRoutingData"
>
重试
</Button>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import {
RefreshCw,
Loader2,
ArrowDown,
ChevronDown,
Route,
AlertCircle,
Zap,
Edit,
Power,
Trash2,
Plus
} from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import {
getGlobalModelRoutingPreview,
type ModelRoutingPreviewResponse,
type RoutingProviderInfo,
type RoutingKeyInfo,
type RoutingEndpointInfo
} from '@/api/global-models'
import { API_FORMAT_ORDER } from '@/api/endpoints/types'
const props = defineProps<{
globalModelId: string
}>()
defineEmits<{
editProvider: [provider: RoutingProviderInfo]
toggleProviderStatus: [provider: RoutingProviderInfo]
deleteProvider: [provider: RoutingProviderInfo]
addProvider: []
refresh: []
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
// 是否为全局 Key 优先模式
const isGlobalKeyMode = computed(() => routingData.value?.priority_mode === 'global_key')
// ========== 数据结构定义 ==========
interface FormatProviderEntry {
provider: RoutingProviderInfo
endpoint: RoutingEndpointInfo | null
keys: RoutingKeyInfo[]
active_keys: number
}
// 全局 Key 模式下的 Key 条目(包含 Provider 信息)
interface GlobalKeyEntry {
key: RoutingKeyInfo
provider: RoutingProviderInfo
endpoint: RoutingEndpointInfo | null
}
// 全局 Key 模式下的优先级分组
interface GlobalKeyGroup {
priority: number | null
keys: GlobalKeyEntry[]
}
interface ApiFormatGroup {
api_format: string
// 提供商优先模式使用
providers: FormatProviderEntry[]
// 全局 Key 优先模式使用
keyGroups: GlobalKeyGroup[]
total_providers: number
active_providers: number
total_keys: number
active_keys: number
}
// 按 API 格式分组的计算属性
const apiFormatGroups = computed<ApiFormatGroup[]>(() => {
if (!routingData.value) return []
const formatMap = new Map<string, {
providers: FormatProviderEntry[]
allKeys: GlobalKeyEntry[]
}>()
// 遍历所有提供商和它们的 endpoints
for (const provider of routingData.value.providers) {
for (const endpoint of provider.endpoints || []) {
const format = endpoint.api_format
if (!formatMap.has(format)) {
formatMap.set(format, { providers: [], allKeys: [] })
}
const data = formatMap.get(format)!
// 添加 provider entry
data.providers.push({
provider,
endpoint,
keys: endpoint.keys || [],
active_keys: endpoint.active_keys || 0
})
// 添加 key entries用于全局 Key 模式)
for (const key of endpoint.keys || []) {
data.allKeys.push({
key,
provider,
endpoint
})
}
}
}
// 转换为数组并计算统计
const groups: ApiFormatGroup[] = []
for (const [format, data] of formatMap) {
// Provider 排序(提供商优先模式)
const sortedProviders = [...data.providers].sort((a, b) => {
const aActive = a.provider.is_active && a.provider.model_is_active
const bActive = b.provider.is_active && b.provider.model_is_active
if (aActive !== bActive) return bActive ? 1 : -1
return a.provider.provider_priority - b.provider.provider_priority
})
// Key 按全局优先级分组排序(全局 Key 优先模式)
const keyGroupMap = new Map<number, GlobalKeyEntry[]>()
for (const keyEntry of data.allKeys) {
const priority = keyEntry.key.global_priority ?? 999
if (!keyGroupMap.has(priority)) {
keyGroupMap.set(priority, [])
}
keyGroupMap.get(priority)!.push(keyEntry)
}
// 转换为分组数组并排序
const keyGroups: GlobalKeyGroup[] = Array.from(keyGroupMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([priority, keys]) => ({
priority: priority === 999 ? null : priority,
keys: keys.sort((a, b) => {
// 同优先级内按活跃状态和健康度排序
const aActive = a.key.is_active && a.provider.is_active && a.provider.model_is_active
const bActive = b.key.is_active && b.provider.is_active && b.provider.model_is_active
if (aActive !== bActive) return bActive ? 1 : -1
return b.key.health_score - a.key.health_score
})
}))
const activeProviders = sortedProviders.filter(
e => e.provider.is_active && e.provider.model_is_active && e.endpoint?.is_active
).length
const totalKeys = sortedProviders.reduce((sum, e) => sum + e.keys.length, 0)
const activeKeys = sortedProviders.reduce((sum, e) => sum + e.active_keys, 0)
groups.push({
api_format: format,
providers: sortedProviders,
keyGroups,
total_providers: sortedProviders.length,
active_providers: activeProviders,
total_keys: totalKeys,
active_keys: activeKeys
})
}
// 按 API 格式排序
groups.sort((a, b) => {
const aIndex = API_FORMAT_ORDER.indexOf(a.api_format)
const bIndex = API_FORMAT_ORDER.indexOf(b.api_format)
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
return groups
})
// ========== 展开状态管理 ==========
const expandedFormats = ref<Set<string>>(new Set())
function isFormatExpanded(format: string): boolean {
return expandedFormats.value.has(format)
}
function toggleFormat(format: string) {
if (expandedFormats.value.has(format)) {
expandedFormats.value.delete(format)
} else {
expandedFormats.value.add(format)
}
}
// 提供商模式:格式内提供商级别的展开状态
const expandedProvidersInFormat = ref<Set<string>>(new Set())
function isProviderInFormatExpanded(format: string, providerId: string, endpointId?: string): boolean {
const key = endpointId ? `${format}:${providerId}:${endpointId}` : `${format}:${providerId}`
return expandedProvidersInFormat.value.has(key)
}
function toggleProviderInFormat(format: string, providerId: string, endpointId?: string) {
const key = endpointId ? `${format}:${providerId}:${endpointId}` : `${format}:${providerId}`
if (expandedProvidersInFormat.value.has(key)) {
expandedProvidersInFormat.value.delete(key)
} else {
expandedProvidersInFormat.value.add(key)
}
}
// 加载数据
async function loadRoutingData() {
if (!props.globalModelId) return
loading.value = true
error.value = null
try {
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
} catch (err: any) {
error.value = err.response?.data?.detail || '加载失败'
} finally {
loading.value = false
}
}
// 获取调度模式标签
function getSchedulingModeLabel(mode: string): string {
const labels: Record<string, string> = {
cache_affinity: '缓存亲和',
fixed_order: '固定顺序',
load_balance: '负载均衡'
}
return labels[mode] || mode
}
// 获取优先级模式标签
function getPriorityModeLabel(mode: string): string {
const labels: Record<string, string> = {
provider: '提供商优先',
global_key: '全局 Key 优先'
}
return labels[mode] || mode
}
// 判断是否存在模型映射转换
function hasModelMapping(provider: RoutingProviderInfo): boolean {
return provider.provider_model_name !== routingData.value?.global_model_name
}
// 按优先级分组 Keys提供商优先模式下使用
interface KeyPriorityGroup {
priority: number | null
keys: RoutingKeyInfo[]
}
function getKeyPriorityGroups(keys: RoutingKeyInfo[]): KeyPriorityGroup[] {
const groups = new Map<number, KeyPriorityGroup>()
for (const key of keys) {
// 提供商优先模式:按 internal_priority 分组
const priority = key.internal_priority ?? 999
if (!groups.has(priority)) {
groups.set(priority, {
priority: priority === 999 ? null : priority,
keys: []
})
}
groups.get(priority)!.keys.push(key)
}
return Array.from(groups.values()).sort((a, b) => {
const pa = a.priority ?? 999
const pb = b.priority ?? 999
return pa - pb
})
}
// 获取提供商状态样式
function getProviderStatusClass(provider: RoutingProviderInfo): string {
if (!provider.is_active) {
return 'bg-gray-400'
}
if (!provider.model_is_active) {
return 'bg-yellow-500'
}
return 'bg-green-500'
}
// 获取格式分组中提供商节点圆点样式(提供商优先模式)
function getFormatProviderNodeClass(entry: FormatProviderEntry, index: number): string {
if (!entry.provider.is_active || !entry.provider.model_is_active) {
return 'bg-muted border-muted-foreground/30'
}
if (index === 0) {
return 'bg-primary border-primary'
}
return 'bg-background border-border'
}
// 获取格式分组中提供商卡片样式(提供商优先模式)
function getFormatProviderCardClass(entry: FormatProviderEntry, index: number): string {
if (!entry.provider.is_active || !entry.provider.model_is_active) {
return 'bg-muted/30 border border-border/40'
}
if (index === 0) {
return 'bg-primary/5 border border-primary/30 shadow-sm'
}
return 'bg-muted/20 border border-border/50 hover:border-border'
}
// 获取全局 Key 节点圆点样式(全局 Key 优先模式)
function getGlobalKeyNodeClass(entry: GlobalKeyEntry, groupIndex: number, keyIndex: number): string {
if (!entry.key.is_active || !entry.provider.is_active || !entry.provider.model_is_active) {
return 'bg-muted border-muted-foreground/30'
}
if (groupIndex === 0 && keyIndex === 0) {
return 'bg-primary border-primary'
}
return 'bg-background border-border'
}
// 判断是否为格式组中的最后一个 Key
function isLastKeyInFormat(formatGroup: ApiFormatGroup, groupIndex: number, keyIndex: number): boolean {
const isLastGroup = groupIndex === formatGroup.keyGroups.length - 1
const isLastKeyInGroup = keyIndex === formatGroup.keyGroups[groupIndex].keys.length - 1
return isLastGroup && isLastKeyInGroup
}
// 获取全局 Key 卡片样式(全局 Key 优先模式)
function getGlobalKeyCardClass(entry: GlobalKeyEntry, groupIndex: number, keyIndex: number): string {
if (!entry.key.is_active || !entry.provider.is_active || !entry.provider.model_is_active) {
return 'bg-muted/30 border border-border/40'
}
if (groupIndex === 0 && keyIndex === 0) {
return 'bg-primary/5 border border-primary/30 shadow-sm'
}
return 'bg-muted/20 border border-border/50 hover:border-border'
}
// 健康度进度条颜色
function getHealthScoreBarColor(score: number): string {
if (score >= 80) return 'bg-green-500 dark:bg-green-400'
if (score >= 50) return 'bg-yellow-500 dark:bg-yellow-400'
return 'bg-red-500 dark:bg-red-400'
}
// 健康度文字颜色
function getHealthScoreTextColor(score: number): string {
if (score >= 80) return 'text-green-600 dark:text-green-400'
if (score >= 50) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
// 获取计费标签
function getBillingLabel(provider: RoutingProviderInfo): string {
if (provider.billing_type === 'monthly_quota') {
const used = provider.monthly_used_usd || 0
const quota = provider.monthly_quota_usd
return quota ? `$${used.toFixed(0)}/$${quota.toFixed(0)}` : '月卡'
}
if (provider.billing_type === 'pay_as_you_go') {
return '按量'
}
return '免费'
}
// 获取 Key 状态样式
function getKeyStatusClass(key: RoutingKeyInfo): string {
if (!key.is_active) {
return 'bg-gray-400'
}
if (key.circuit_breaker_open) {
return 'bg-red-500'
}
if (key.health_score < 50) {
return 'bg-red-500'
}
if (key.health_score < 80) {
return 'bg-yellow-500'
}
return 'bg-green-500'
}
// 获取 Key 提示信息
function getKeyTooltip(key: RoutingKeyInfo): string {
const parts: string[] = []
parts.push(`名称: ${key.name}`)
parts.push(`健康度: ${Math.round(key.health_score)}%`)
if (key.effective_rpm) {
parts.push(`RPM: ${key.is_adaptive ? '~' : ''}${key.effective_rpm}`)
}
if (!key.is_active) {
parts.push('状态: 已禁用')
} else if (key.circuit_breaker_open) {
parts.push(`熔断中: ${key.circuit_breaker_formats.join(', ')}`)
}
return parts.join('\n')
}
// 监听 globalModelId 变化
watch(() => props.globalModelId, () => {
expandedFormats.value.clear()
expandedProvidersInFormat.value.clear()
loadRoutingData()
}, { immediate: false })
onMounted(() => {
loadRoutingData()
})
// 暴露方法给父组件
defineExpose({
loadRoutingData
})
</script>
<style scoped>
.collapse-enter-active,
.collapse-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
}
.collapse-enter-to,
.collapse-leave-from {
opacity: 1;
max-height: 500px;
}
</style>