mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
refactor(ui): 重构模型映射功能,简化交互并提取独立组件
- ModelMappingDialog: 简化为单栏选择面板布局,移除拖拽排序和优先级编辑 - ProviderDetailDrawer: 移出别名映射预览,使用独立 ModelMappingTab 组件 - 新增 ModelMappingTab 组件: 统一展示精确映射和正则映射,支持连接测试
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -391,104 +391,14 @@
|
|||||||
@batch-assign="handleBatchAssign"
|
@batch-assign="handleBatchAssign"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 别名映射预览 -->
|
<!-- 模型映射 -->
|
||||||
<Card
|
<ModelMappingTab
|
||||||
v-if="aliasMappingLoading || (aliasMappingPreview && aliasMappingPreview.total_matches > 0)"
|
v-if="provider"
|
||||||
class="overflow-hidden"
|
ref="modelMappingTabRef"
|
||||||
>
|
:key="`mapping-${provider.id}`"
|
||||||
<div class="px-4 py-3 border-b border-border/60">
|
:provider="provider"
|
||||||
<h3 class="text-sm font-semibold">
|
@refresh="handleModelMappingChanged"
|
||||||
别名映射预览
|
/>
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<div v-if="aliasMappingLoading" class="flex items-center justify-center py-8">
|
|
||||||
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GlobalModel 列表 -->
|
|
||||||
<div v-else class="divide-y divide-border/40">
|
|
||||||
<div
|
|
||||||
v-for="(gmInfo, gmIndex) in computedAliasMappingByModel"
|
|
||||||
:key="gmInfo.global_model_id"
|
|
||||||
>
|
|
||||||
<!-- GlobalModel 行 -->
|
|
||||||
<div
|
|
||||||
class="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-muted/30 transition-colors"
|
|
||||||
@click="toggleAliasExpand(gmIndex)"
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
class="w-4 h-4 text-muted-foreground transition-transform flex-shrink-0"
|
|
||||||
:class="{ 'rotate-90': aliasExpandedIndex === gmIndex }"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-medium truncate">{{ gmInfo.display_name }}</span>
|
|
||||||
<Badge
|
|
||||||
v-if="!gmInfo.is_active"
|
|
||||||
variant="outline"
|
|
||||||
class="text-[10px] px-1.5 py-0 text-muted-foreground flex-shrink-0"
|
|
||||||
>
|
|
||||||
停用
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span class="font-mono">{{ gmInfo.global_model_name }}</span>
|
|
||||||
<span class="text-muted-foreground/50">|</span>
|
|
||||||
<span class="font-mono text-primary/80">{{ gmInfo.alias_patterns.join(' / ') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
{{ gmInfo.matched_keys.length }} Key · {{ gmInfo.total_models }} 模型
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 展开内容:匹配的 Key 列表 -->
|
|
||||||
<div
|
|
||||||
v-if="aliasExpandedIndex === gmIndex"
|
|
||||||
class="border-t bg-muted/10 px-4 py-3"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="keyItem in gmInfo.matched_keys"
|
|
||||||
:key="keyItem.key_id"
|
|
||||||
class="bg-background rounded-md border p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 text-sm mb-2">
|
|
||||||
<Key class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
|
||||||
<span class="font-medium truncate">{{ keyItem.key_name || '未命名密钥' }}</span>
|
|
||||||
<span class="text-xs text-muted-foreground font-mono ml-auto flex-shrink-0">
|
|
||||||
{{ keyItem.masked_key }}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
v-if="!keyItem.is_active"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-[10px] px-1.5 py-0 flex-shrink-0"
|
|
||||||
>
|
|
||||||
禁用
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<Badge
|
|
||||||
v-for="match in keyItem.matches"
|
|
||||||
:key="match.allowed_model"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs font-mono"
|
|
||||||
:title="`匹配规则: ${match.alias_pattern}`"
|
|
||||||
>
|
|
||||||
{{ match.allowed_model }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -597,7 +507,6 @@ import { ref, watch, computed, nextTick } from 'vue'
|
|||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Key,
|
Key,
|
||||||
ChevronRight,
|
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -614,13 +523,14 @@ import Card from '@/components/ui/card.vue'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
||||||
import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints'
|
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||||
import {
|
import {
|
||||||
KeyFormDialog,
|
KeyFormDialog,
|
||||||
KeyAllowedModelsEditDialog,
|
KeyAllowedModelsEditDialog,
|
||||||
ModelsTab,
|
ModelsTab,
|
||||||
BatchAssignModelsDialog
|
BatchAssignModelsDialog
|
||||||
} from '@/features/providers/components'
|
} from '@/features/providers/components'
|
||||||
|
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
|
||||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||||
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
@@ -697,84 +607,7 @@ const editingModel = ref<Model | null>(null)
|
|||||||
const deleteModelConfirmOpen = ref(false)
|
const deleteModelConfirmOpen = ref(false)
|
||||||
const modelToDelete = ref<Model | null>(null)
|
const modelToDelete = ref<Model | null>(null)
|
||||||
const batchAssignDialogOpen = ref(false)
|
const batchAssignDialogOpen = ref(false)
|
||||||
|
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
|
||||||
// 别名映射预览状态
|
|
||||||
const aliasMappingPreview = ref<ProviderAliasMappingPreviewResponse | null>(null)
|
|
||||||
const aliasMappingLoading = ref(false)
|
|
||||||
const aliasExpandedIndex = ref<number | null>(null)
|
|
||||||
|
|
||||||
// 切换别名展开
|
|
||||||
function toggleAliasExpand(index: number) {
|
|
||||||
aliasExpandedIndex.value = aliasExpandedIndex.value === index ? null : index
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按 GlobalModel 分组的别名映射数据
|
|
||||||
interface MatchedKeyItem {
|
|
||||||
key_id: string
|
|
||||||
key_name: string
|
|
||||||
masked_key: string
|
|
||||||
is_active: boolean
|
|
||||||
matches: { allowed_model: string; alias_pattern: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlobalModelAliasInfo {
|
|
||||||
global_model_id: string
|
|
||||||
global_model_name: string
|
|
||||||
display_name: string
|
|
||||||
is_active: boolean
|
|
||||||
alias_patterns: string[]
|
|
||||||
matched_keys: MatchedKeyItem[]
|
|
||||||
total_models: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const computedAliasMappingByModel = computed<GlobalModelAliasInfo[]>(() => {
|
|
||||||
if (!aliasMappingPreview.value) return []
|
|
||||||
|
|
||||||
// 按 GlobalModel 分组
|
|
||||||
const modelMap = new Map<string, GlobalModelAliasInfo>()
|
|
||||||
|
|
||||||
for (const keyInfo of aliasMappingPreview.value.keys) {
|
|
||||||
for (const gm of keyInfo.matching_global_models) {
|
|
||||||
if (!modelMap.has(gm.global_model_id)) {
|
|
||||||
// 收集所有匹配用到的别名规则(去重)
|
|
||||||
const patterns = new Set<string>()
|
|
||||||
for (const match of gm.matched_models) {
|
|
||||||
patterns.add(match.alias_pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
modelMap.set(gm.global_model_id, {
|
|
||||||
global_model_id: gm.global_model_id,
|
|
||||||
global_model_name: gm.global_model_name,
|
|
||||||
display_name: gm.display_name,
|
|
||||||
is_active: gm.is_active,
|
|
||||||
alias_patterns: Array.from(patterns),
|
|
||||||
matched_keys: [],
|
|
||||||
total_models: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelInfo = modelMap.get(gm.global_model_id)!
|
|
||||||
|
|
||||||
// 更新别名规则集合(可能来自不同 Key 的匹配)
|
|
||||||
for (const match of gm.matched_models) {
|
|
||||||
if (!modelInfo.alias_patterns.includes(match.alias_pattern)) {
|
|
||||||
modelInfo.alias_patterns.push(match.alias_pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modelInfo.matched_keys.push({
|
|
||||||
key_id: keyInfo.key_id,
|
|
||||||
key_name: keyInfo.key_name,
|
|
||||||
masked_key: keyInfo.masked_key,
|
|
||||||
is_active: keyInfo.is_active,
|
|
||||||
matches: gm.matched_models,
|
|
||||||
})
|
|
||||||
modelInfo.total_models += gm.matched_models.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(modelMap.values())
|
|
||||||
})
|
|
||||||
|
|
||||||
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
|
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
|
||||||
const dragState = ref({
|
const dragState = ref({
|
||||||
@@ -813,7 +646,8 @@ const hasBlockingDialogOpen = computed(() =>
|
|||||||
deleteKeyConfirmOpen.value ||
|
deleteKeyConfirmOpen.value ||
|
||||||
modelFormDialogOpen.value ||
|
modelFormDialogOpen.value ||
|
||||||
deleteModelConfirmOpen.value ||
|
deleteModelConfirmOpen.value ||
|
||||||
batchAssignDialogOpen.value
|
batchAssignDialogOpen.value ||
|
||||||
|
modelMappingTabRef.value?.dialogOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
// 所有密钥的扁平列表(带端点信息)
|
// 所有密钥的扁平列表(带端点信息)
|
||||||
@@ -851,7 +685,6 @@ watch(() => props.providerId, (newId) => {
|
|||||||
if (newId && props.open) {
|
if (newId && props.open) {
|
||||||
loadProvider()
|
loadProvider()
|
||||||
loadEndpoints()
|
loadEndpoints()
|
||||||
loadAliasMappingPreview()
|
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -860,7 +693,6 @@ watch(() => props.open, (newOpen) => {
|
|||||||
if (newOpen && props.providerId) {
|
if (newOpen && props.providerId) {
|
||||||
loadProvider()
|
loadProvider()
|
||||||
loadEndpoints()
|
loadEndpoints()
|
||||||
loadAliasMappingPreview()
|
|
||||||
// 启动倒计时定时器
|
// 启动倒计时定时器
|
||||||
startCountdownTimer()
|
startCountdownTimer()
|
||||||
} else if (!newOpen) {
|
} else if (!newOpen) {
|
||||||
@@ -888,10 +720,6 @@ watch(() => props.open, (newOpen) => {
|
|||||||
|
|
||||||
// 清除已显示的密钥(安全考虑)
|
// 清除已显示的密钥(安全考虑)
|
||||||
revealedKeys.value.clear()
|
revealedKeys.value.clear()
|
||||||
|
|
||||||
// 重置别名映射预览
|
|
||||||
aliasMappingPreview.value = null
|
|
||||||
aliasExpandedIndex.value = null
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1159,6 +987,11 @@ async function handleBatchAssignChanged() {
|
|||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理模型映射变更
|
||||||
|
async function handleModelMappingChanged() {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
|
||||||
// 处理模型保存完成
|
// 处理模型保存完成
|
||||||
async function handleModelSaved() {
|
async function handleModelSaved() {
|
||||||
editingModel.value = null
|
editingModel.value = null
|
||||||
@@ -1705,25 +1538,6 @@ async function loadEndpoints() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载别名映射预览
|
|
||||||
async function loadAliasMappingPreview() {
|
|
||||||
if (!props.providerId) return
|
|
||||||
|
|
||||||
aliasMappingLoading.value = true
|
|
||||||
try {
|
|
||||||
aliasMappingPreview.value = await getProviderAliasMappingPreview(props.providerId)
|
|
||||||
} catch (err: any) {
|
|
||||||
// 404 静默处理(Provider 不存在或无别名配置)
|
|
||||||
if (err.response?.status !== 404) {
|
|
||||||
console.warn('加载别名映射预览失败:', err)
|
|
||||||
showError('加载别名映射预览失败')
|
|
||||||
}
|
|
||||||
aliasMappingPreview.value = null
|
|
||||||
} finally {
|
|
||||||
aliasMappingLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 ESC 键监听
|
// 添加 ESC 键监听
|
||||||
useEscapeKey(() => {
|
useEscapeKey(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
<template>
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<!-- 标题头部 -->
|
||||||
|
<div class="p-4 border-b border-border/60">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||||
|
模型映射
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8"
|
||||||
|
@click="openAddDialog"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
添加映射
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 映射列表 -->
|
||||||
|
<div
|
||||||
|
v-else-if="combinedMappings.length > 0"
|
||||||
|
class="divide-y divide-border/40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in combinedMappings"
|
||||||
|
:key="item.key"
|
||||||
|
class="transition-colors"
|
||||||
|
>
|
||||||
|
<!-- 行头部(可点击展开) -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
|
||||||
|
@click="toggleExpand(index)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<!-- 展开/收起图标 -->
|
||||||
|
<ChevronRight
|
||||||
|
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform self-start mt-0.5"
|
||||||
|
:class="{ 'rotate-90': expandedItems.has(index) }"
|
||||||
|
/>
|
||||||
|
<!-- 精确映射:两行显示 -->
|
||||||
|
<template v-if="item.type === 'exact'">
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="font-semibold text-sm truncate">
|
||||||
|
{{ item.targetModelName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.group?.model.provider_model_name"
|
||||||
|
class="text-xs text-muted-foreground truncate"
|
||||||
|
>
|
||||||
|
{{ item.group.model.provider_model_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 类型标签 -->
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
class="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
精确
|
||||||
|
</Badge>
|
||||||
|
<!-- 分隔符 + 映射数量 -->
|
||||||
|
<span class="text-xs text-muted-foreground shrink-0">
|
||||||
|
| {{ item.mappings.length }} 个映射
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<!-- 正则映射:两行显示 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="font-semibold text-sm truncate">
|
||||||
|
{{ item.targetModelName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.globalModelName"
|
||||||
|
class="text-xs text-muted-foreground truncate"
|
||||||
|
>
|
||||||
|
{{ item.globalModelName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 类型标签 -->
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
正则
|
||||||
|
</Badge>
|
||||||
|
<!-- 分隔符 + 映射数量 -->
|
||||||
|
<span class="text-xs text-muted-foreground shrink-0">
|
||||||
|
| {{ item.mappings.length }} 个映射
|
||||||
|
</span>
|
||||||
|
<!-- 正则映射显示匹配的 Key 数量 -->
|
||||||
|
<span
|
||||||
|
v-if="item.matchedKeys && item.matchedKeys.length > 0"
|
||||||
|
class="text-xs text-muted-foreground shrink-0"
|
||||||
|
>
|
||||||
|
· {{ item.matchedKeys.length }} Key
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- 操作按钮(仅精确映射可编辑/删除) -->
|
||||||
|
<div
|
||||||
|
v-if="item.type === 'exact'"
|
||||||
|
class="flex items-center gap-1.5 ml-4 shrink-0"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="编辑映射"
|
||||||
|
@click="editGroup(item.group!)"
|
||||||
|
>
|
||||||
|
<Edit class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
title="删除映射"
|
||||||
|
@click="deleteGroup(item.group!)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开的映射详情 -->
|
||||||
|
<div
|
||||||
|
v-show="expandedItems.has(index)"
|
||||||
|
class="bg-muted/30 border-t border-border/30"
|
||||||
|
>
|
||||||
|
<!-- 精确映射详情 -->
|
||||||
|
<div
|
||||||
|
v-if="item.type === 'exact'"
|
||||||
|
class="px-4 py-2 space-y-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="mapping in item.mappings"
|
||||||
|
:key="mapping.name"
|
||||||
|
class="flex items-center justify-between gap-2 py-1"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-sm truncate">
|
||||||
|
{{ mapping.name }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0"
|
||||||
|
title="测试映射"
|
||||||
|
:disabled="testingMapping === `${item.key}-${mapping.name}`"
|
||||||
|
@click="testMapping(item, mapping)"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="testingMapping === `${item.key}-${mapping.name}`"
|
||||||
|
class="w-3 h-3 animate-spin"
|
||||||
|
/>
|
||||||
|
<Play
|
||||||
|
v-else
|
||||||
|
class="w-3 h-3"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 正则映射详情(按 Key 分组显示) -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="px-4 py-3 space-y-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="keyItem in item.matchedKeys"
|
||||||
|
:key="keyItem.keyId"
|
||||||
|
class="bg-background rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<!-- Key 信息 -->
|
||||||
|
<div class="flex items-center gap-2 text-sm mb-2">
|
||||||
|
<Key class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span class="font-medium truncate">{{ keyItem.keyName || '未命名密钥' }}</span>
|
||||||
|
<span class="text-xs text-muted-foreground font-mono ml-auto shrink-0">
|
||||||
|
{{ keyItem.maskedKey }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 匹配的模型列表 -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="match in keyItem.matches"
|
||||||
|
:key="match.name"
|
||||||
|
class="flex items-center justify-between gap-2 py-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span class="font-mono text-sm truncate">{{ match.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="match.pattern"
|
||||||
|
class="text-xs text-muted-foreground truncate"
|
||||||
|
:title="match.pattern"
|
||||||
|
>
|
||||||
|
{{ match.pattern }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0"
|
||||||
|
title="测试映射"
|
||||||
|
:disabled="testingMapping === `${item.key}-${keyItem.keyId}-${match.name}`"
|
||||||
|
@click="testRegexMapping(item, keyItem, match)"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="testingMapping === `${item.key}-${keyItem.keyId}-${match.name}`"
|
||||||
|
class="w-3 h-3 animate-spin"
|
||||||
|
/>
|
||||||
|
<Play
|
||||||
|
v-else
|
||||||
|
class="w-3 h-3"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="p-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Tag class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p class="text-sm">
|
||||||
|
暂无模型映射
|
||||||
|
</p>
|
||||||
|
<p class="text-xs mt-1">
|
||||||
|
点击上方"添加映射"按钮为模型创建名称映射
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 添加/编辑映射对话框 -->
|
||||||
|
<ModelMappingDialog
|
||||||
|
v-model:open="dialogOpen"
|
||||||
|
:provider-id="provider.id"
|
||||||
|
:models="models"
|
||||||
|
:editing-group="editingGroup"
|
||||||
|
:preselected-model-id="preselectedModelId"
|
||||||
|
@saved="onDialogSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 删除确认对话框 -->
|
||||||
|
<AlertDialog
|
||||||
|
v-model="deleteConfirmOpen"
|
||||||
|
title="删除映射"
|
||||||
|
:description="deleteConfirmDescription"
|
||||||
|
confirm-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
type="danger"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
@cancel="deleteConfirmOpen = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play, Key } from 'lucide-vue-next'
|
||||||
|
import { Card, Button, Badge } from '@/components/ui'
|
||||||
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
|
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import {
|
||||||
|
getProviderModels,
|
||||||
|
getProviderAliasMappingPreview,
|
||||||
|
testModel,
|
||||||
|
type Model,
|
||||||
|
type ProviderModelAlias,
|
||||||
|
type ProviderAliasMappingPreviewResponse
|
||||||
|
} from '@/api/endpoints'
|
||||||
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
|
import { parseTestModelError } from '@/utils/errorParser'
|
||||||
|
|
||||||
|
interface MappingItem {
|
||||||
|
name: string
|
||||||
|
priority?: number
|
||||||
|
pattern?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchedKeyInfo {
|
||||||
|
keyId: string
|
||||||
|
keyName: string
|
||||||
|
maskedKey: string
|
||||||
|
matches: MappingItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombinedMapping {
|
||||||
|
key: string
|
||||||
|
type: 'exact' | 'regex'
|
||||||
|
targetModelName: string
|
||||||
|
targetModelId?: string
|
||||||
|
globalModelName?: string // 正则映射的全局模型名称
|
||||||
|
mappings: MappingItem[]
|
||||||
|
matchedKeys?: MatchedKeyInfo[] // 正则映射的 Key 信息
|
||||||
|
group?: AliasGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
provider: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'refresh': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const models = ref<Model[]>([])
|
||||||
|
const aliasMappingPreview = ref<ProviderAliasMappingPreviewResponse | null>(null)
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
const deleteConfirmOpen = ref(false)
|
||||||
|
const editingGroup = ref<AliasGroup | null>(null)
|
||||||
|
const deletingGroup = ref<AliasGroup | null>(null)
|
||||||
|
const testingMapping = ref<string | null>(null)
|
||||||
|
const preselectedModelId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 展开状态
|
||||||
|
const expandedItems = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
// 生成作用域唯一键
|
||||||
|
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||||
|
if (!formats || formats.length === 0) return ''
|
||||||
|
return [...formats].sort().join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确映射分组(来自 provider_model_mappings)
|
||||||
|
const exactMappingGroups = computed<AliasGroup[]>(() => {
|
||||||
|
const groups: AliasGroup[] = []
|
||||||
|
const groupMap = new Map<string, AliasGroup>()
|
||||||
|
|
||||||
|
for (const model of models.value) {
|
||||||
|
if (!model.provider_model_mappings || !Array.isArray(model.provider_model_mappings)) continue
|
||||||
|
|
||||||
|
for (const alias of model.provider_model_mappings) {
|
||||||
|
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
|
||||||
|
const groupKey = `${model.id}|${apiFormatsKey}`
|
||||||
|
|
||||||
|
if (!groupMap.has(groupKey)) {
|
||||||
|
const group: AliasGroup = {
|
||||||
|
model,
|
||||||
|
apiFormatsKey,
|
||||||
|
apiFormats: alias.api_formats || [],
|
||||||
|
aliases: []
|
||||||
|
}
|
||||||
|
groupMap.set(groupKey, group)
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
groupMap.get(groupKey)!.aliases.push(alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
group.aliases.sort((a, b) => a.priority - b.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// 正则映射(来自 aliasMappingPreview,按 GlobalModel 分组并保留 Key 信息)
|
||||||
|
const regexMappings = computed<CombinedMapping[]>(() => {
|
||||||
|
if (!aliasMappingPreview.value) return []
|
||||||
|
|
||||||
|
const result: CombinedMapping[] = []
|
||||||
|
const modelMap = new Map<string, CombinedMapping>()
|
||||||
|
|
||||||
|
for (const keyInfo of aliasMappingPreview.value.keys) {
|
||||||
|
for (const gm of keyInfo.matching_global_models) {
|
||||||
|
if (!modelMap.has(gm.global_model_id)) {
|
||||||
|
modelMap.set(gm.global_model_id, {
|
||||||
|
key: `regex-${gm.global_model_id}`,
|
||||||
|
type: 'regex',
|
||||||
|
targetModelName: gm.display_name,
|
||||||
|
targetModelId: gm.global_model_id,
|
||||||
|
globalModelName: gm.global_model_name,
|
||||||
|
mappings: [],
|
||||||
|
matchedKeys: []
|
||||||
|
})
|
||||||
|
result.push(modelMap.get(gm.global_model_id)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = modelMap.get(gm.global_model_id)!
|
||||||
|
|
||||||
|
// 添加 Key 信息
|
||||||
|
const keyMatches: MappingItem[] = gm.matched_models.map(m => ({
|
||||||
|
name: m.allowed_model,
|
||||||
|
pattern: m.alias_pattern
|
||||||
|
}))
|
||||||
|
|
||||||
|
mapping.matchedKeys!.push({
|
||||||
|
keyId: keyInfo.key_id,
|
||||||
|
keyName: keyInfo.key_name,
|
||||||
|
maskedKey: keyInfo.masked_key,
|
||||||
|
matches: keyMatches
|
||||||
|
})
|
||||||
|
|
||||||
|
// 收集所有映射(去重)
|
||||||
|
for (const match of gm.matched_models) {
|
||||||
|
if (!mapping.mappings.some(m => m.name === match.allowed_model)) {
|
||||||
|
mapping.mappings.push({
|
||||||
|
name: match.allowed_model,
|
||||||
|
pattern: match.alias_pattern
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// 合并后的映射列表
|
||||||
|
const combinedMappings = computed<CombinedMapping[]>(() => {
|
||||||
|
const result: CombinedMapping[] = []
|
||||||
|
|
||||||
|
// 添加精确映射
|
||||||
|
for (const group of exactMappingGroups.value) {
|
||||||
|
result.push({
|
||||||
|
key: `exact-${group.model.id}-${group.apiFormatsKey}`,
|
||||||
|
type: 'exact',
|
||||||
|
targetModelName: group.model.global_model_display_name || group.model.provider_model_name,
|
||||||
|
targetModelId: group.model.id,
|
||||||
|
mappings: group.aliases.map(a => ({
|
||||||
|
name: a.name,
|
||||||
|
priority: a.priority
|
||||||
|
})),
|
||||||
|
group
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加正则映射
|
||||||
|
for (const mapping of regexMappings.value) {
|
||||||
|
result.push(mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
// 精确映射排在前面
|
||||||
|
if (a.type !== b.type) return a.type === 'exact' ? -1 : 1
|
||||||
|
return a.targetModelName.localeCompare(b.targetModelName)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const [modelsData, previewData] = await Promise.all([
|
||||||
|
getProviderModels(props.provider.id),
|
||||||
|
getProviderAliasMappingPreview(props.provider.id).catch(() => null)
|
||||||
|
])
|
||||||
|
models.value = modelsData
|
||||||
|
aliasMappingPreview.value = previewData
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '加载失败', '错误')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除确认描述
|
||||||
|
const deleteConfirmDescription = computed(() => {
|
||||||
|
if (!deletingGroup.value) return ''
|
||||||
|
const { model, aliases } = deletingGroup.value
|
||||||
|
const modelName = model.global_model_display_name || model.provider_model_name
|
||||||
|
const aliasNames = aliases.map(a => a.name).join(', ')
|
||||||
|
return `确定要删除模型「${modelName}」的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换展开状态
|
||||||
|
function toggleExpand(index: number) {
|
||||||
|
if (expandedItems.value.has(index)) {
|
||||||
|
expandedItems.value.delete(index)
|
||||||
|
} else {
|
||||||
|
expandedItems.value.add(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开添加对话框
|
||||||
|
function openAddDialog() {
|
||||||
|
editingGroup.value = null
|
||||||
|
preselectedModelId.value = null
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑分组
|
||||||
|
function editGroup(group: AliasGroup) {
|
||||||
|
editingGroup.value = group
|
||||||
|
preselectedModelId.value = null
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除分组
|
||||||
|
function deleteGroup(group: AliasGroup) {
|
||||||
|
deletingGroup.value = group
|
||||||
|
deleteConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deletingGroup.value) return
|
||||||
|
|
||||||
|
const { model, aliases, apiFormatsKey } = deletingGroup.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentAliases = model.provider_model_mappings || []
|
||||||
|
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
|
||||||
|
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||||
|
const currentKey = getApiFormatsKey(a.api_formats)
|
||||||
|
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateModel(props.provider.id, model.id, {
|
||||||
|
provider_model_mappings: newAliases.length > 0 ? newAliases : null
|
||||||
|
})
|
||||||
|
|
||||||
|
showSuccess('映射已删除')
|
||||||
|
deleteConfirmOpen.value = false
|
||||||
|
deletingGroup.value = null
|
||||||
|
await loadData()
|
||||||
|
emit('refresh')
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '删除失败', '错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话框保存后回调
|
||||||
|
async function onDialogSaved() {
|
||||||
|
await loadData()
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试模型映射(精确映射)
|
||||||
|
async function testMapping(item: CombinedMapping, mapping: MappingItem) {
|
||||||
|
const testingKey = `${item.key}-${mapping.name}`
|
||||||
|
testingMapping.value = testingKey
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testModel({
|
||||||
|
provider_id: props.provider.id,
|
||||||
|
model_name: mapping.name,
|
||||||
|
message: "hello"
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess(`映射 "${mapping.name}" 测试成功`)
|
||||||
|
} else {
|
||||||
|
showError(`映射测试失败: ${parseTestModelError(result)}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||||
|
showError(`映射测试失败: ${errorMsg}`)
|
||||||
|
} finally {
|
||||||
|
testingMapping.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试正则映射(指定 Key)
|
||||||
|
async function testRegexMapping(item: CombinedMapping, keyItem: MatchedKeyInfo, match: MappingItem) {
|
||||||
|
const testingKey = `${item.key}-${keyItem.keyId}-${match.name}`
|
||||||
|
testingMapping.value = testingKey
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testModel({
|
||||||
|
provider_id: props.provider.id,
|
||||||
|
model_name: match.name,
|
||||||
|
message: "hello",
|
||||||
|
api_key_id: keyItem.keyId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess(`映射 "${match.name}" 测试成功`)
|
||||||
|
} else {
|
||||||
|
showError(`映射测试失败: ${parseTestModelError(result)}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||||
|
showError(`映射测试失败: ${errorMsg}`)
|
||||||
|
} finally {
|
||||||
|
testingMapping.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 provider 变化
|
||||||
|
watch(() => props.provider?.id, (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 暴露给父组件
|
||||||
|
defineExpose({
|
||||||
|
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value),
|
||||||
|
reload: loadData
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user