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
@@ -391,104 +391,14 @@
@batch-assign="handleBatchAssign"
/>
<!-- 别名映射预览 -->
<Card
v-if="aliasMappingLoading || (aliasMappingPreview && aliasMappingPreview.total_matches > 0)"
class="overflow-hidden"
>
<div class="px-4 py-3 border-b border-border/60">
<h3 class="text-sm font-semibold">
别名映射预览
</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>
<!-- 模型映射 -->
<ModelMappingTab
v-if="provider"
ref="modelMappingTabRef"
:key="`mapping-${provider.id}`"
:provider="provider"
@refresh="handleModelMappingChanged"
/>
</div>
</template>
</Card>
@@ -597,7 +507,6 @@ import { ref, watch, computed, nextTick } from 'vue'
import {
Plus,
Key,
ChevronRight,
Edit,
Trash2,
RefreshCw,
@@ -614,13 +523,14 @@ import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints'
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
import {
KeyFormDialog,
KeyAllowedModelsEditDialog,
ModelsTab,
BatchAssignModelsDialog
} from '@/features/providers/components'
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import AlertDialog from '@/components/common/AlertDialog.vue'
@@ -697,84 +607,7 @@ const editingModel = ref<Model | null>(null)
const deleteModelConfirmOpen = ref(false)
const modelToDelete = ref<Model | null>(null)
const batchAssignDialogOpen = ref(false)
// 别名映射预览状态
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 modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
const dragState = ref({
@@ -813,7 +646,8 @@ const hasBlockingDialogOpen = computed(() =>
deleteKeyConfirmOpen.value ||
modelFormDialogOpen.value ||
deleteModelConfirmOpen.value ||
batchAssignDialogOpen.value
batchAssignDialogOpen.value ||
modelMappingTabRef.value?.dialogOpen
)
// 所有密钥的扁平列表(带端点信息)
@@ -851,7 +685,6 @@ watch(() => props.providerId, (newId) => {
if (newId && props.open) {
loadProvider()
loadEndpoints()
loadAliasMappingPreview()
}
}, { immediate: true })
@@ -860,7 +693,6 @@ watch(() => props.open, (newOpen) => {
if (newOpen && props.providerId) {
loadProvider()
loadEndpoints()
loadAliasMappingPreview()
// 启动倒计时定时器
startCountdownTimer()
} else if (!newOpen) {
@@ -888,10 +720,6 @@ watch(() => props.open, (newOpen) => {
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
// 重置别名映射预览
aliasMappingPreview.value = null
aliasExpandedIndex.value = null
}
})
@@ -1159,6 +987,11 @@ async function handleBatchAssignChanged() {
emit('refresh')
}
// 处理模型映射变更
async function handleModelMappingChanged() {
emit('refresh')
}
// 处理模型保存完成
async function handleModelSaved() {
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 键监听
useEscapeKey(() => {
if (props.open) {