mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-13 04:58:28 +08:00
feat(ui): 新增模型请求链路预览功能
- 添加后端 API 获取 GlobalModel 的请求链路信息 (/api/admin/models/global/{id}/routing)
- 新增 RoutingTab 组件展示模型的请求链路树状结构
- 支持全局 Key 优先和提供商优先两种模式的可视化
- 重构批量模型管理对话框为单列勾选模式
- 修复 errorParser.ts 字符串拼接格式问题
- 若干 Vue 模板格式优化
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ export {
|
||||
deleteGlobalModel,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
getGlobalModelRoutingPreview,
|
||||
} from './endpoints/global-models'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
1014
frontend/src/features/models/components/RoutingTab.vue
Normal file
1014
frontend/src/features/models/components/RoutingTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, '删除模型失败'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user