refactor(ui): 重构模型映射功能,简化交互并提取独立组件

- ModelMappingDialog: 简化为单栏选择面板布局,移除拖拽排序和优先级编辑
- ProviderDetailDrawer: 移出别名映射预览,使用独立 ModelMappingTab 组件
- 新增 ModelMappingTab 组件: 统一展示精确映射和正则映射,支持连接测试
This commit is contained in:
fawney19
2026-01-14 14:28:13 +08:00
parent a8813cfb89
commit 68737b9ed6
3 changed files with 971 additions and 786 deletions
@@ -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>