feat(ui): 新增模型请求链路预览功能

- 添加后端 API 获取 GlobalModel 的请求链路信息 (/api/admin/models/global/{id}/routing)
- 新增 RoutingTab 组件展示模型的请求链路树状结构
- 支持全局 Key 优先和提供商优先两种模式的可视化
- 重构批量模型管理对话框为单列勾选模式
- 修复 errorParser.ts 字符串拼接格式问题
- 若干 Vue 模板格式优化
This commit is contained in:
fawney19
2026-01-12 23:28:37 +08:00
parent bde5bf4c76
commit 9fea71a70c
15 changed files with 1897 additions and 654 deletions

View File

@@ -6,6 +6,16 @@ import type {
GlobalModelWithStats,
GlobalModelListResponse,
ModelCatalogProviderDetail,
ModelRoutingPreviewResponse,
} from './types'
// 重新导出路由相关类型供外部使用
export type {
RoutingKeyInfo,
RoutingEndpointInfo,
RoutingModelMapping,
RoutingProviderInfo,
ModelRoutingPreviewResponse,
} from './types'
/**
@@ -97,3 +107,15 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
)
return response.data
}
/**
* 获取 GlobalModel 的请求链路预览
*/
export async function getGlobalModelRoutingPreview(
globalModelId: string
): Promise<ModelRoutingPreviewResponse> {
const response = await client.get(
`/api/admin/models/global/${globalModelId}/routing`
)
return response.data
}

View File

@@ -622,3 +622,83 @@ export interface ImportFromUpstreamResponse {
success: ImportFromUpstreamSuccessItem[]
errors: ImportFromUpstreamErrorItem[]
}
// ========== 路由预览相关类型 ==========
/**
* Key 路由信息
*/
export interface RoutingKeyInfo {
id: string
name: string
masked_key: string
internal_priority: number
global_priority?: number | null
rpm_limit?: number | null
is_adaptive: boolean
effective_rpm?: number | null
cache_ttl_minutes: number
health_score: number
is_active: boolean
api_formats: string[]
circuit_breaker_open: boolean
circuit_breaker_formats: string[]
}
/**
* Endpoint 路由信息
*/
export interface RoutingEndpointInfo {
id: string
api_format: string
base_url: string
custom_path?: string | null
is_active: boolean
keys: RoutingKeyInfo[]
total_keys: number
active_keys: number
}
/**
* 模型名称映射信息
*/
export interface RoutingModelMapping {
name: string
priority: number
api_formats?: string[] | null
}
/**
* Provider 路由信息
*/
export interface RoutingProviderInfo {
id: string
name: string
model_id: string
provider_priority: number
billing_type?: string | null
monthly_quota_usd?: number | null
monthly_used_usd?: number | null
is_active: boolean
provider_model_name: string
model_mappings: RoutingModelMapping[]
model_is_active: boolean
endpoints: RoutingEndpointInfo[]
total_endpoints: number
active_endpoints: number
}
/**
* 模型请求链路预览响应
*/
export interface ModelRoutingPreviewResponse {
global_model_id: string
global_model_name: string
display_name: string
is_active: boolean
providers: RoutingProviderInfo[]
total_providers: number
active_providers: number
scheduling_mode: string
priority_mode: string
}

View File

@@ -21,4 +21,5 @@ export {
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
getGlobalModelRoutingPreview,
} from './endpoints/global-models'

View File

@@ -5,7 +5,10 @@ const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger v-bind="props" as-child>
<CollapsibleTrigger
v-bind="props"
as-child
>
<slot />
</CollapsibleTrigger>
</template>

View File

@@ -95,14 +95,14 @@
type="button"
class="flex-1 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md transition-all duration-200"
:class="[
detailTab === 'providers'
detailTab === 'routing'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
]"
@click="detailTab = 'providers'"
@click="detailTab = 'routing'"
>
<span class="hidden sm:inline">关联提供商</span>
<span class="sm:hidden">提供商</span>
<span class="hidden sm:inline">链路控制</span>
<span class="sm:hidden">链路</span>
</button>
</div>
@@ -407,269 +407,17 @@
</div>
</div>
<!-- Tab 2: 关联提供商 -->
<div v-show="detailTab === 'providers'">
<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>
<h4 class="text-sm font-semibold">
关联提供商列表
</h4>
</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="$emit('refreshProviders')"
>
<RefreshCw
class="w-3.5 h-3.5"
:class="loadingProviders ? 'animate-spin' : ''"
/>
</Button>
</div>
</div>
</div>
<!-- 表格内容 -->
<div
v-if="loadingProviders"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<template v-else-if="providers.length > 0">
<!-- 桌面端表格 -->
<Table class="hidden sm:table">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">
Provider
</TableHead>
<TableHead class="w-[100px] h-10 font-semibold">
能力
</TableHead>
<TableHead class="w-[200px] h-10 font-semibold">
价格 ($/M)
</TableHead>
<TableHead class="w-[100px] h-10 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="provider in providers"
:key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/>
<span class="font-medium truncate">{{ provider.name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 -->
<span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 缓存价格 -->
<div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div>
<!-- 无定价 -->
<span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑此关联"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除此关联"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="provider in providers"
:key="provider.id"
class="p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
/>
<span class="font-medium truncate">{{ provider.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<div class="flex gap-1">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</div>
<div
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
class="text-muted-foreground font-mono"
>
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
</div>
</div>
</div>
</div>
</template>
<div
v-else
class="text-center py-12"
>
<!-- 空状态 -->
<Building2 class="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">
暂无关联提供商
</p>
<Button
size="sm"
variant="outline"
class="mt-4"
@click="$emit('addProvider')"
>
<Plus class="w-4 h-4 mr-1" />
添加第一个关联
</Button>
</div>
</Card>
<!-- Tab 2: 链路控制 -->
<div v-show="detailTab === 'routing'">
<RoutingTab
v-if="model"
ref="routingTabRef"
:global-model-id="model.id"
@add-provider="$emit('addProvider')"
@edit-provider="handleEditProviderFromRouting"
@toggle-provider-status="handleToggleProviderFromRouting"
@delete-provider="handleDeleteProviderFromRouting"
/>
</div>
</div>
</Card>
@@ -688,12 +436,8 @@ import {
Zap,
Image,
Building2,
Plus,
Edit,
Trash2,
Power,
Loader2,
RefreshCw,
Copy,
Layers,
BarChart3
@@ -711,14 +455,15 @@ 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 RoutingTab from './RoutingTab.vue'
// 使用外部类型定义
import type { GlobalModelResponse } from '@/api/global-models'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { CapabilityDefinition } from '@/api/endpoints'
import type { RoutingProviderInfo } from '@/api/global-models'
const props = withDefaults(defineProps<Props>(), {
loadingProviders: false,
hasBlockingDialogOpen: false,
})
const emit = defineEmits<{
@@ -729,7 +474,6 @@ const emit = defineEmits<{
'editProvider': [provider: any]
'deleteProvider': [provider: any]
'toggleProviderStatus': [provider: any]
'refreshProviders': []
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
@@ -737,12 +481,48 @@ const { copyToClipboard } = useClipboard()
interface Props {
model: GlobalModelResponse | null
open: boolean
providers: any[]
loadingProviders?: boolean
hasBlockingDialogOpen?: boolean
capabilities?: CapabilityDefinition[]
}
// RoutingTab 引用
const routingTabRef = ref<InstanceType<typeof RoutingTab> | null>(null)
// 将 RoutingProviderInfo 转换为父组件期望的格式
function convertRoutingProviderToLegacyFormat(provider: RoutingProviderInfo) {
return {
id: provider.id,
model_id: provider.model_id,
name: provider.name,
is_active: provider.model_is_active
}
}
// 处理从 RoutingTab 来的编辑事件
function handleEditProviderFromRouting(provider: RoutingProviderInfo) {
emit('editProvider', convertRoutingProviderToLegacyFormat(provider))
}
// 处理从 RoutingTab 来的状态切换事件
function handleToggleProviderFromRouting(provider: RoutingProviderInfo) {
emit('toggleProviderStatus', convertRoutingProviderToLegacyFormat(provider))
}
// 处理从 RoutingTab 来的删除事件
function handleDeleteProviderFromRouting(provider: RoutingProviderInfo) {
emit('deleteProvider', convertRoutingProviderToLegacyFormat(provider))
}
// 刷新路由数据
function refreshRoutingData() {
routingTabRef.value?.loadRoutingData?.()
}
// 暴露刷新方法给父组件
defineExpose({
refreshRoutingData
})
// 根据能力名称获取显示名称
function getCapabilityDisplayName(capName: string): string {
const cap = props.capabilities?.find(c => c.name === capName)

File diff suppressed because it is too large Load Diff

View File

@@ -107,8 +107,12 @@
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
<p class="text-sm font-medium truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
</div>
</div>
</div>
@@ -159,8 +163,12 @@
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.id }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.owned_by || model.id }}</p>
<p class="text-sm font-medium truncate">
{{ model.id }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.owned_by || model.id }}
</p>
</div>
</div>
</div>
@@ -172,7 +180,9 @@
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Layers class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可用模型' }}</p>
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可用模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"

View File

@@ -76,7 +76,10 @@
</div>
<!-- 模型列表 -->
<div v-else class="space-y-2">
<div
v-else
class="space-y-2"
>
<!-- 全选/取消 -->
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">

View File

@@ -131,7 +131,10 @@
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model)" class="w-3 h-3 text-primary-foreground" />
<Check
v-if="selectedModels.includes(model)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model }}</span>
</div>
@@ -174,18 +177,28 @@
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.name) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model.name)" class="w-3 h-3 text-primary-foreground" />
<Check
v-if="selectedModels.includes(model.name)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ model.display_name }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ model.name }}</p>
<p class="text-sm font-medium truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
</div>
</div>
</div>
</div>
<!-- 上游模型组 -->
<div v-for="group in filteredUpstreamGroups" :key="group.api_format">
<div
v-for="group in filteredUpstreamGroups"
:key="group.api_format"
>
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse(group.api_format)"
@@ -222,7 +235,10 @@
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
>
<Check v-if="selectedModels.includes(model.id)" class="w-3 h-3 text-primary-foreground" />
<Check
v-if="selectedModels.includes(model.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model.id }}</span>
</div>
@@ -235,8 +251,15 @@
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Shield class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}</p>
<p v-if="!upstreamModelsLoaded" class="text-xs mt-1">点击闪电按钮从上游获取模型</p>
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"
>
点击闪电按钮从上游获取模型
</p>
</div>
</template>
</div>
@@ -250,10 +273,18 @@
{{ hasChanges ? '有未保存的更改' : '' }}
</p>
<div class="flex items-center gap-2">
<Button :disabled="saving || !hasChanges" @click="handleSave">
<Button
:disabled="saving || !hasChanges"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
<Button variant="outline" @click="handleCancel">取消</Button>
<Button
variant="outline"
@click="handleCancel"
>
取消
</Button>
</div>
</div>
</template>

View File

@@ -83,7 +83,10 @@
</div>
<!-- 端点 API 格式 -->
<div class="flex items-center gap-1.5 flex-wrap mt-3">
<template v-for="endpoint in endpoints" :key="endpoint.id">
<template
v-for="endpoint in endpoints"
:key="endpoint.id"
>
<span
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
:class="{ 'opacity-40': !endpoint.is_active }"
@@ -324,7 +327,10 @@
v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
:key="format"
>
<span v-if="idx > 0" class="text-muted-foreground/40">/</span>
<span
v-if="idx > 0"
class="text-muted-foreground/40"
>/</span>
<span>{{ API_FORMAT_SHORT[format] || format }} {{ getKeyRateMultiplier(key, format) }}x</span>
</template>
<span v-if="key.rate_limit">| {{ key.rate_limit }}rpm</span>

View File

@@ -68,9 +68,15 @@
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly_quota">月卡额度</SelectItem>
<SelectItem value="pay_as_you_go">按量付费</SelectItem>
<SelectItem value="free_tier">免费套餐</SelectItem>
<SelectItem value="monthly_quota">
月卡额度
</SelectItem>
<SelectItem value="pay_as_you_go">
按量付费
</SelectItem>
<SelectItem value="free_tier">
免费套餐
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -314,11 +314,11 @@ export function parseUpstreamModelError(error: string): string {
}
// 没有匹配特定关键词,但有详细信息,使用它作为补充
if (friendlyMsg) {
const truncated = detailMsg.length > 80 ? detailMsg.substring(0, 80) + '...' : detailMsg
const truncated = detailMsg.length > 80 ? `${detailMsg.substring(0, 80) }...` : detailMsg
return `${friendlyMsg}: ${truncated}`
}
// 没有友好消息,直接使用详细信息
const truncated = detailMsg.length > 100 ? detailMsg.substring(0, 100) + '...' : detailMsg
const truncated = detailMsg.length > 100 ? `${detailMsg.substring(0, 100) }...` : detailMsg
return truncated
}
} catch {
@@ -358,7 +358,7 @@ export function parseUpstreamModelError(error: string): string {
// 默认返回原始错误(截断过长的部分)
if (error.length > 100) {
return error.substring(0, 100) + '...'
return `${error.substring(0, 100) }...`
}
return error
}

View File

@@ -418,6 +418,7 @@
<!-- 模型详情抽屉 -->
<ModelDetailDrawer
ref="modelDetailDrawerRef"
:model="selectedModel"
:open="!!selectedModel"
:providers="selectedModelProviders"
@@ -437,235 +438,125 @@
<!-- 批量添加关联提供商对话框 -->
<Dialog
:model-value="batchAddProvidersDialogOpen"
title="批量添加关联提供商"
description="为模型批量添加 Provider 实现, 提供商将自动继承模型的价格和能力, 可在添加后单独修改"
:title="selectedModel ? `批量管理提供商 - ${selectedModel.display_name}` : '批量管理提供商'"
description="选中的提供商将被关联到模型,取消选中将移除关联"
:icon="Server"
size="4xl"
size="2xl"
@update:model-value="handleBatchAddProvidersDialogUpdate"
>
<template #default>
<div
v-if="selectedModel"
class="space-y-4"
>
<!-- 模型信息头部 -->
<div class="rounded-lg border bg-muted/30 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">
{{ selectedModel.display_name }}
</p>
<p class="text-sm text-muted-foreground font-mono">
{{ selectedModel.name }}
</p>
</div>
<Badge
variant="outline"
class="text-xs"
>
当前 {{ selectedModelProviders.length }} Provider
</Badge>
<div class="space-y-4">
<!-- 搜索栏 -->
<div class="flex items-center gap-2">
<div class="flex-1 relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="batchProviderSearchQuery"
placeholder="搜索提供商..."
class="pl-8 h-9"
/>
</div>
</div>
<!-- 左右对比布局 -->
<div class="flex gap-2 items-stretch">
<!-- 左侧可添加的提供商 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">
可添加
</p>
<Button
v-if="availableProvidersForBatchAdd.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllLeft"
>
{{ isAllLeftSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge
variant="secondary"
class="text-xs"
>
{{ availableProvidersForBatchAdd.length }}
</Badge>
<!-- 单列提供商列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
v-if="loadingProviderOptions"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div
v-if="loadingProviderOptions"
class="flex items-center justify-center h-full"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
<template v-else>
<!-- 提供商组 -->
<div v-if="filteredBatchProviders.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium">提供商</span>
<span class="text-xs text-muted-foreground">({{ filteredBatchProviders.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click="toggleAllBatchProviders"
>
{{ isAllBatchProvidersSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="space-y-1 p-2">
<div
v-for="provider in filteredBatchProviders"
:key="provider.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleBatchProviderSelection(provider.id)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="isBatchProviderSelected(provider.id) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="isBatchProviderSelected(provider.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ provider.name }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
:class="provider.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else-if="availableProvidersForBatchAdd.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
v-if="filteredBatchProviders.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
所有 Provider 均已关联
{{ batchProviderSearchQuery ? '无匹配结果' : '暂无可用提供商' }}
</p>
</div>
<div
v-else
class="p-2 space-y-1"
>
<div
v-for="provider in availableProvidersForBatchAdd"
:key="provider.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
:class="selectedLeftProviderIds.includes(provider.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50 cursor-pointer'"
@click="toggleLeftSelection(provider.id)"
>
<Checkbox
:checked="selectedLeftProviderIds.includes(provider.id)"
@update:checked="toggleLeftSelection(provider.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ provider.name }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
:class="provider.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="flex flex-col items-center justify-center w-12 shrink-0 gap-2">
<Button
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'border-primary' : ''"
:disabled="selectedLeftProviderIds.length === 0 || submittingBatchAddProviders"
title="添加选中"
@click="batchAddSelectedProviders"
>
<Loader2
v-if="submittingBatchAddProviders"
class="w-4 h-4 animate-spin"
/>
<ChevronRight
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'text-primary' : ''"
/>
</Button>
<Button
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'border-primary' : ''"
:disabled="selectedRightProviderIds.length === 0 || submittingBatchRemoveProviders"
title="移除选中"
@click="batchRemoveSelectedProviders"
>
<Loader2
v-if="submittingBatchRemoveProviders"
class="w-4 h-4 animate-spin"
/>
<ChevronLeft
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'text-primary' : ''"
/>
</Button>
</div>
<!-- 右侧已添加的提供商 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">
已添加
</p>
<Button
v-if="selectedModelProviders.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllRight"
>
{{ isAllRightSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge
variant="secondary"
class="text-xs"
>
{{ selectedModelProviders.length }}
</Badge>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div
v-if="selectedModelProviders.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
暂无关联提供商
</p>
</div>
<div
v-else
class="p-2 space-y-1"
>
<!-- 已存在的可选中删除 -->
<div
v-for="provider in selectedModelProviders"
:key="'existing-' + provider.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedRightProviderIds.includes(provider.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleRightSelection(provider.id)"
>
<Checkbox
:checked="selectedRightProviderIds.includes(provider.id)"
@update:checked="toggleRightSelection(provider.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ provider.name }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
:class="provider.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<template #footer>
<Button
variant="outline"
@click="closeBatchAddProvidersDialog"
>
关闭
</Button>
<div class="flex items-center justify-between w-full">
<p class="text-xs text-muted-foreground">
{{ hasBatchProviderChanges ? `${batchProviderPendingChangesCount} 项更改待保存` : '' }}
</p>
<div class="flex items-center gap-2">
<Button
:disabled="!hasBatchProviderChanges || submittingBatchProviders"
@click="saveBatchProviderChanges"
>
<Loader2
v-if="submittingBatchProviders"
class="w-4 h-4 mr-1 animate-spin"
/>
{{ submittingBatchProviders ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
@click="closeBatchAddProvidersDialog"
>
关闭
</Button>
</div>
</div>
</template>
</Dialog>
@@ -698,8 +589,7 @@ import {
Power,
Copy,
Server,
ChevronRight,
ChevronLeft,
Check,
} from 'lucide-vue-next'
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
@@ -722,7 +612,6 @@ import {
TableCell,
Badge,
Dialog,
Checkbox,
Pagination,
RefreshButton,
} from '@/components/ui'
@@ -743,10 +632,10 @@ const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
const submitting = ref(false)
const detailTab = ref('basic')
const searchQuery = ref('')
const selectedModel = ref<GlobalModelResponse | null>(null)
const modelDetailDrawerRef = ref<InstanceType<typeof ModelDetailDrawer> | null>(null)
const createModelDialogOpen = ref(false)
const editingModel = ref<GlobalModelResponse | null>(null)
@@ -765,12 +654,15 @@ const loadingModelProviders = ref(false)
// 批量添加关联提供商
const batchAddProvidersDialogOpen = ref(false)
const selectedProviderIds = ref<string[]>([])
const submittingBatchAddProviders = ref(false)
const submittingBatchRemoveProviders = ref(false)
const submittingBatchProviders = ref(false)
const providerOptions = ref<any[]>([])
const loadingProviderOptions = ref(false)
// 单列勾选模式所需状态
const batchProviderSearchQuery = ref('')
const selectedBatchProviderIds = ref<Set<string>>(new Set())
const initialBatchProviderIds = ref<Set<string>>(new Set())
// 编辑提供商模型
const editProviderDialogOpen = ref(false)
const editingProvider = ref<any>(null)
@@ -844,154 +736,152 @@ const capabilityFilters = ref({
extendedThinking: false,
})
// 左侧选中的 Provider用于添加
const selectedLeftProviderIds = ref<string[]>([])
// 右侧选中的 Provider用于移除只能选新增的
const selectedRightProviderIds = ref<string[]>([])
// 可用于批量添加的 Provider (排除已有实现的和已选中的)
const availableProvidersForBatchAdd = computed(() => {
if (!selectedModel.value) return []
const existingProviderIds = new Set(
selectedModelProviders.value.map(p => p.id)
)
const selectedIds = new Set(selectedProviderIds.value)
return providerOptions.value.filter(
provider => !existingProviderIds.has(provider.id) && !selectedIds.has(provider.id)
)
})
// 是否全选了左侧
const isAllLeftSelected = computed(() => {
return availableProvidersForBatchAdd.value.length > 0 &&
selectedLeftProviderIds.value.length === availableProvidersForBatchAdd.value.length
})
// 是否全选了右侧
const isAllRightSelected = computed(() => {
return selectedModelProviders.value.length > 0 &&
selectedRightProviderIds.value.length === selectedModelProviders.value.length
})
// 切换左侧选择
function toggleLeftSelection(providerId: string) {
const index = selectedLeftProviderIds.value.indexOf(providerId)
if (index === -1) {
selectedLeftProviderIds.value.push(providerId)
} else {
selectedLeftProviderIds.value.splice(index, 1)
}
}
// 切换右侧选择(只能选新增的)
function toggleRightSelection(providerId: string) {
const index = selectedRightProviderIds.value.indexOf(providerId)
if (index === -1) {
selectedRightProviderIds.value.push(providerId)
} else {
selectedRightProviderIds.value.splice(index, 1)
}
}
// 全选/取消全选左侧
function toggleSelectAllLeft() {
if (isAllLeftSelected.value) {
selectedLeftProviderIds.value = []
} else {
selectedLeftProviderIds.value = availableProvidersForBatchAdd.value.map(p => p.id)
}
}
// 全选/取消全选右侧
function toggleSelectAllRight() {
if (isAllRightSelected.value) {
selectedRightProviderIds.value = []
} else {
selectedRightProviderIds.value = selectedModelProviders.value.map(p => p.id)
}
}
// 批量添加选中的 Provider直接调用 API
async function batchAddSelectedProviders() {
if (!selectedModel.value || selectedLeftProviderIds.value.length === 0) return
try {
submittingBatchAddProviders.value = true
const result = await batchAssignToProviders(selectedModel.value.id, {
provider_ids: selectedLeftProviderIds.value,
create_models: true
})
if (result.success.length > 0) {
success(`成功添加 ${result.success.length} 个 Provider`)
// 过滤后的提供商列表
const filteredBatchProviders = computed(() => {
const query = batchProviderSearchQuery.value.toLowerCase().trim()
return providerOptions.value.filter(p => {
if (query && !p.name.toLowerCase().includes(query)) {
return false
}
return true
})
})
if (result.errors.length > 0) {
const errorMessages = result.errors
.map(e => {
const provider = providerOptions.value.find(p => p.id === e.provider_id)
const providerName = provider?.name || e.provider_id
return `${providerName}: ${e.error}`
})
.join('\n')
showError(errorMessages, '部分 Provider 添加失败')
}
// 清空左侧选择,刷新右侧列表和外层表格
selectedLeftProviderIds.value = []
await loadModelProviders(selectedModel.value.id)
// 刷新外层模型列表以更新 provider_count
await loadGlobalModels()
} catch (err: any) {
showError(parseApiError(err, '批量添加失败'), '错误')
} finally {
submittingBatchAddProviders.value = false
}
// 检查提供商是否已选中
function isBatchProviderSelected(providerId: string): boolean {
return selectedBatchProviderIds.value.has(providerId)
}
// 批量移除选中的 Provider直接调用 API
async function batchRemoveSelectedProviders() {
if (!selectedModel.value || selectedRightProviderIds.value.length === 0) return
// 是否全选
const isAllBatchProvidersSelected = computed(() => {
if (filteredBatchProviders.value.length === 0) return false
return filteredBatchProviders.value.every(p => isBatchProviderSelected(p.id))
})
// 计算待添加的提供商
const batchProvidersToAdd = computed(() => {
const toAdd: string[] = []
for (const id of selectedBatchProviderIds.value) {
if (!initialBatchProviderIds.value.has(id)) {
toAdd.push(id)
}
}
return toAdd
})
// 计算待移除的提供商
const batchProvidersToRemove = computed(() => {
const toRemove: string[] = []
for (const id of initialBatchProviderIds.value) {
if (!selectedBatchProviderIds.value.has(id)) {
toRemove.push(id)
}
}
return toRemove
})
// 是否有变更
const hasBatchProviderChanges = computed(() => {
return batchProvidersToAdd.value.length > 0 || batchProvidersToRemove.value.length > 0
})
// 待变更数量
const batchProviderPendingChangesCount = computed(() => {
return batchProvidersToAdd.value.length + batchProvidersToRemove.value.length
})
// 切换提供商选择
function toggleBatchProviderSelection(providerId: string) {
if (selectedBatchProviderIds.value.has(providerId)) {
selectedBatchProviderIds.value.delete(providerId)
} else {
selectedBatchProviderIds.value.add(providerId)
}
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
}
// 全选/取消全选
function toggleAllBatchProviders() {
const allIds = filteredBatchProviders.value.map(p => p.id)
if (isAllBatchProvidersSelected.value) {
for (const id of allIds) {
selectedBatchProviderIds.value.delete(id)
}
} else {
for (const id of allIds) {
selectedBatchProviderIds.value.add(id)
}
}
selectedBatchProviderIds.value = new Set(selectedBatchProviderIds.value)
}
// 同步初始选择状态
function syncBatchProviderSelection() {
const existingIds = new Set(selectedModelProviders.value.map((p: any) => p.id))
selectedBatchProviderIds.value = new Set(existingIds)
initialBatchProviderIds.value = new Set(existingIds)
}
// 保存变更
async function saveBatchProviderChanges() {
if (!hasBatchProviderChanges.value || submittingBatchProviders.value || !selectedModel.value) return
submittingBatchProviders.value = true
try {
submittingBatchRemoveProviders.value = true
const { deleteModel } = await import('@/api/endpoints')
let totalSuccess = 0
const allErrors: string[] = []
let successCount = 0
const errors: string[] = []
// 并行移除提供商
if (batchProvidersToRemove.value.length > 0) {
const { deleteModel } = await import('@/api/endpoints')
const removePromises = batchProvidersToRemove.value.map(async (providerId) => {
const existingProvider = selectedModelProviders.value.find((p: any) => p.id === providerId)
if (existingProvider && existingProvider.model_id) {
return deleteModel(providerId, existingProvider.model_id)
}
return null
})
for (const providerId of selectedRightProviderIds.value) {
const provider = selectedModelProviders.value.find(p => p.id === providerId)
if (!provider?.model_id) continue
try {
await deleteModel(providerId, provider.model_id)
successCount++
} catch (err: any) {
errors.push(`${provider.name}: ${parseApiError(err, '删除失败')}`)
const results = await Promise.allSettled(removePromises)
for (const result of results) {
if (result.status === 'fulfilled' && result.value !== null) {
totalSuccess++
} else if (result.status === 'rejected') {
allErrors.push(parseApiError(result.reason, '移除失败'))
}
}
}
if (successCount > 0) {
success(`成功移除 ${successCount} 个 Provider`)
// 添加提供商
if (batchProvidersToAdd.value.length > 0) {
const result = await batchAssignToProviders(selectedModel.value.id, {
provider_ids: batchProvidersToAdd.value,
create_models: true
})
totalSuccess += result.success.length
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => e.error))
}
}
if (errors.length > 0) {
showError(errors.join('\n'), '部分 Provider 移除失败')
if (totalSuccess > 0) {
success(`成功处理 ${totalSuccess} 个提供商`)
}
// 清空右侧选择,刷新列表和外层表格
selectedRightProviderIds.value = []
if (allErrors.length > 0) {
showError(`部分操作失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
}
// 刷新数据并关闭对话框
await loadModelProviders(selectedModel.value.id)
// 刷新外层模型列表以更新 provider_count
await loadGlobalModels()
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
closeBatchAddProvidersDialog()
} catch (err: any) {
showError(parseApiError(err, '批量移除失败'), '错误')
showError(parseApiError(err, '保存失败'), '错误')
} finally {
submittingBatchRemoveProviders.value = false
submittingBatchProviders.value = false
}
}
@@ -1135,15 +1025,18 @@ async function ensureProviderOptions() {
// 打开添加关联提供商对话框
function openAddProviderDialog() {
if (!selectedModel.value) return
selectedProviderIds.value = []
batchProviderSearchQuery.value = ''
batchAddProvidersDialogOpen.value = true
ensureProviderOptions()
ensureProviderOptions().then(() => {
// 同步选择状态
syncBatchProviderSelection()
})
}
// 处理批量添加 Provider 对话框关闭事件
function handleBatchAddProvidersDialogUpdate(value: boolean) {
// 只有在不处于提交状态时才允许关闭
if (!value && submittingBatchAddProviders.value) {
if (!value && submittingBatchProviders.value) {
return
}
batchAddProvidersDialogOpen.value = value
@@ -1152,10 +1045,10 @@ function handleBatchAddProvidersDialogUpdate(value: boolean) {
// 关闭批量添加对话框
function closeBatchAddProvidersDialog() {
batchAddProvidersDialogOpen.value = false
selectedProviderIds.value = []
selectedLeftProviderIds.value = []
selectedRightProviderIds.value = []
submittingBatchAddProviders.value = false
batchProviderSearchQuery.value = ''
selectedBatchProviderIds.value = new Set()
initialBatchProviderIds.value = new Set()
submittingBatchProviders.value = false
}
// 抽屉控制函数
@@ -1199,6 +1092,8 @@ async function toggleProviderStatus(provider: any) {
await updateModel(provider.id, provider.model_id, { is_active: newStatus })
provider.is_active = newStatus
success(newStatus ? '已启用此关联提供商' : '已停用此关联提供商')
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: any) {
showError(parseApiError(err, '更新状态失败'))
}
@@ -1212,7 +1107,7 @@ async function confirmDeleteProviderImplementation(provider: any) {
}
const confirmed = await confirmDanger(
`确定要删除 ${provider.name} 的模型关联吗?\n\n模型: ${provider.target_model}\n\n此操作不可恢复!`,
`确定要删除 ${provider.name} 的模型关联吗?\n\n此操作不可恢复`,
'删除关联提供商'
)
if (!confirmed) return
@@ -1221,10 +1116,12 @@ async function confirmDeleteProviderImplementation(provider: any) {
const { deleteModel } = await import('@/api/endpoints')
await deleteModel(provider.id, provider.model_id)
success(`已删除 ${provider.name} 的模型实现`)
// 重新加载 Provider 列表
// 同步更新 selectedModelProviders 确保状态一致
if (selectedModel.value) {
await loadModelProviders(selectedModel.value.id)
}
// 刷新路由数据
modelDetailDrawerRef.value?.refreshRoutingData?.()
} catch (err: any) {
showError(parseApiError(err, '删除模型失败'))
}