Files
Aether/frontend/src/features/models/components/ModelMappingsTab.vue
T
AAEE86 0fa15604dc refactor(frontend): 优化模型/提供商组件的数据刷新逻辑
- ModelDetailDrawer: 移除 handleMappingsUpdate 中冗余的 refreshRoutingData 调用,路由刷新改由 @refresh 事件统一处理
- ModelMappingsTab: saveMappings 成功后始终 emit update 和 refresh 事件,确保数据一致性
- BatchAssignModelsDialog: 为批量添加/导入操作增加 try-catch 错误处理,部分操作失败时仍通知父组件刷新
- ProviderDetailDrawer: 移除 modelsTabRef 直接调用子组件 reload 的模式,统一通过 emit('refresh') 由父组件协调刷新
2026-02-11 03:09:26 +08:00

591 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Card class="overflow-hidden">
<!-- 表头 -->
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between">
<div class="flex items-baseline gap-2">
<h4 class="text-sm font-semibold">
映射规则
</h4>
<span class="text-xs text-muted-foreground">
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
</span>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="添加规则"
:disabled="localMappings.length >= MAX_MAPPINGS_PER_MODEL"
@click="addMapping"
>
<Plus class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="刷新"
:disabled="props.loading"
@click="$emit('refresh')"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': props.loading }"
/>
</Button>
</div>
</div>
</div>
<!-- 规则列表 -->
<div
v-if="localMappings.length > 0"
class="divide-y"
>
<div
v-for="(mapping, index) in localMappings"
:key="index"
>
<!-- 规则行 -->
<div
class="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-muted/30 transition-colors"
@click="toggleExpand(index)"
>
<ChevronRight
class="w-4 h-4 text-muted-foreground transition-transform flex-shrink-0"
:class="{ 'rotate-90': expandedIndex === index }"
/>
<div class="flex-1 min-w-0">
<Input
v-model="localMappings[index]"
placeholder="例如: claude-haiku-.*"
:class="`font-mono text-sm ${normalizedMappings[index] && !mappingValidations[index].valid ? 'border-destructive' : ''}`"
@click.stop
@input="markDirty"
/>
<!-- 验证错误提示 -->
<div
v-if="normalizedMappings[index] && !mappingValidations[index].valid"
class="flex items-center gap-1 mt-1 text-xs text-destructive"
>
<AlertCircle class="w-3 h-3" />
<span>{{ mappingValidations[index].error }}</span>
</div>
</div>
<!-- 匹配统计 -->
<Badge
v-if="mappingValidations[index].valid && mappingMatchCounts[index] > 0"
variant="secondary"
class="text-xs flex-shrink-0 h-6 leading-none"
>
{{ mappingMatchCounts[index] }} 匹配
</Badge>
<Badge
v-else-if="normalizedMappings[index] && mappingValidations[index].valid"
variant="outline"
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
>
无匹配
</Badge>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 flex-shrink-0">
<Button
v-if="isDirty"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-primary"
title="保存"
:disabled="saving || hasValidationErrors"
@click.stop="saveMappings"
>
<Save
v-if="!saving"
class="w-4 h-4"
/>
<RefreshCw
v-else
class="w-4 h-4 animate-spin"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-destructive"
title="删除"
:disabled="saving"
@click.stop="removeMapping(index)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
<!-- 展开内容:匹配的 Key 列表(按提供商分组) -->
<div
v-if="expandedIndex === index"
class="border-t bg-muted/10 px-4 py-3"
>
<div
v-if="loadingPreview"
class="flex items-center justify-center py-4"
>
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div
v-else-if="expandedGroups.length === 0"
class="text-center py-4"
>
<p class="text-sm text-muted-foreground">
{{ normalizedMappings[index] ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
</p>
</div>
<div
v-else
class="space-y-3"
>
<!-- 按提供商分组 -->
<div
v-for="group in expandedGroups"
:key="group.providerId"
class="bg-background rounded-md border overflow-hidden"
>
<!-- 提供商标题 -->
<div class="px-3 py-2 bg-muted/30 border-b flex items-center justify-between">
<div>
<span class="text-sm font-medium">{{ group.providerName }}</span>
<span class="text-xs text-muted-foreground ml-2">({{ group.keys.length }} Key)</span>
</div>
<Badge
v-if="group.isLinked"
variant="secondary"
class="text-xs"
>
已关联
</Badge>
<Button
v-else
variant="ghost"
size="icon"
class="h-7 w-7"
title="关联到当前模型"
@click="$emit('linkProvider', group.providerId)"
>
<Link class="w-3.5 h-3.5" />
</Button>
</div>
<!-- Key 列表 -->
<div class="divide-y divide-border/50">
<div
v-for="keyItem in group.keys"
:key="keyItem.keyId"
class="px-3 py-2"
>
<div class="flex items-center gap-1.5 text-sm mb-1.5">
<span class="font-medium">{{ keyItem.keyName }}</span>
<code class="text-xs text-muted-foreground/70">{{ keyItem.maskedKey }}</code>
</div>
<div class="flex flex-wrap gap-1">
<Badge
v-for="model in keyItem.matchedModels"
:key="model"
variant="secondary"
class="text-xs font-mono"
>
{{ model }}
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="text-center py-32"
>
<GitMerge class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">
暂无映射规则
</p>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, computed } from 'vue'
import { Card, Button, Input, Badge } from '@/components/ui'
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle, Link } from 'lucide-vue-next'
import { updateGlobalModel, getGlobalModel, getGlobalModelRoutingPreview } from '@/api/global-models'
import type { ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import { log } from '@/utils/logger'
import { useToast } from '@/composables/useToast'
import {
MAX_MAPPINGS_PER_MODEL,
MAX_MODEL_NAME_LENGTH,
createLRURegexCache,
getCompiledModelMappingRegex,
validateModelMappingPattern,
type ValidationResult,
} from '@/features/models/utils/model-mapping-regex'
const props = defineProps<{
globalModelId: string
modelName: string
mappings: string[]
loading?: boolean
routingData?: ModelRoutingPreviewResponse | null // 外部传入的 routing 数据
loadingPreview?: boolean // 外部传入的加载状态
}>()
const emit = defineEmits<{
update: [mappings: string[]]
refresh: []
linkProvider: [providerId: string]
linkProviders: [providerIds: string[]] // 批量关联
}>()
const { success: toastSuccess, error: toastError } = useToast()
// 本地状态
const localMappings = ref<string[]>([...props.mappings])
const originalMappings = ref<string[]>([...props.mappings]) // 用于保存失败时恢复
const isDirty = ref(false)
const saving = ref(false)
const expandedIndex = ref<number | null>(null)
// 统一以 trim 后的规则做预览/校验(保存时也会 trim),避免前后端行为不一致
const normalizedMappings = computed(() => localMappings.value.map(m => m.trim()))
const mappingValidations = computed<ValidationResult[]>(() => {
return normalizedMappings.value.map(pattern => {
if (!pattern) return { valid: true }
return validateModelMappingPattern(pattern)
})
})
// 匹配预览状态 - 优先使用外部传入的数据
const internalLoadingPreview = ref(false)
const internalRoutingData = ref<ModelRoutingPreviewResponse | null>(null)
// 计算属性:优先使用外部传入的数据
const loadingPreview = computed(() => props.loadingPreview ?? internalLoadingPreview.value)
const routingData = computed(() => props.routingData ?? internalRoutingData.value)
const REGEX_CACHE_MAX_SIZE = 100
const regexCache = createLRURegexCache(REGEX_CACHE_MAX_SIZE)
const matchCountCache = new Map<string, number>()
interface MatchedKeyForMapping {
keyId: string
keyName: string
maskedKey: string
providerName: string
providerId: string
matchedModels: string[]
}
interface ProviderGroup {
providerId: string
providerName: string
keys: MatchedKeyForMapping[]
isLinked: boolean // 是否已关联到当前模型
}
/**
* 检查是否有验证错误
*/
const hasValidationErrors = computed(() => {
return mappingValidations.value.some((result, index) => {
return normalizedMappings.value[index] !== '' && !result.valid
})
})
function computeMatchCount(pattern: string): number {
if (!routingData.value) return 0
const cached = matchCountCache.get(pattern)
if (cached !== undefined) {
return cached
}
const regex = getCompiledModelMappingRegex(pattern, regexCache)
if (!regex) {
matchCountCache.set(pattern, 0)
return 0
}
const keyToMatchedModels = new Map<string, Set<string>>()
for (const keyItem of routingData.value.all_keys_whitelist || []) {
if (!keyItem.allowed_models || keyItem.allowed_models.length === 0) continue
for (const allowedModel of keyItem.allowed_models) {
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
if (!regex.test(allowedModel)) continue
let modelSet = keyToMatchedModels.get(keyItem.key_id)
if (!modelSet) {
modelSet = new Set()
keyToMatchedModels.set(keyItem.key_id, modelSet)
}
modelSet.add(allowedModel)
}
}
let total = 0
for (const models of keyToMatchedModels.values()) {
total += models.size
}
matchCountCache.set(pattern, total)
return total
}
const mappingMatchCounts = computed(() => {
if (!routingData.value) {
return normalizedMappings.value.map(() => 0)
}
return normalizedMappings.value.map((pattern, index) => {
if (!pattern) return 0
if (!mappingValidations.value[index]?.valid) return 0
return computeMatchCount(pattern)
})
})
// 获取指定映射匹配的 Key 列表(使用全局 Key 白名单数据做实时匹配)
function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
if (!routingData.value) return []
const pattern = mapping.trim()
if (!pattern) return []
const regex = getCompiledModelMappingRegex(pattern, regexCache)
if (!regex) return []
const keyMap = new Map<string, MatchedKeyForMapping>()
// 使用 all_keys_whitelist 进行实时匹配(包含所有 Provider 的 Key)
for (const keyItem of routingData.value.all_keys_whitelist || []) {
if (!keyItem.allowed_models || keyItem.allowed_models.length === 0) continue
const matchedModels: string[] = []
for (const allowedModel of keyItem.allowed_models) {
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
if (regex.test(allowedModel)) matchedModels.push(allowedModel)
}
if (matchedModels.length > 0) {
const existing = keyMap.get(keyItem.key_id)
if (existing) {
const mergedModels = new Set([...existing.matchedModels, ...matchedModels])
existing.matchedModels = Array.from(mergedModels)
} else {
keyMap.set(keyItem.key_id, {
keyId: keyItem.key_id,
keyName: keyItem.key_name,
maskedKey: keyItem.masked_key,
providerName: keyItem.provider_name,
providerId: keyItem.provider_id,
matchedModels,
})
}
}
}
return Array.from(keyMap.values())
}
// 按提供商分组匹配的 Key
function getMatchedKeysGroupedByProvider(mapping: string): ProviderGroup[] {
const keys = getMatchedKeysForMapping(mapping)
const providerMap = new Map<string, ProviderGroup>()
// 获取已关联的提供商 ID 集合
const linkedProviderIds = new Set(
(routingData.value?.providers || []).map(p => p.id)
)
for (const key of keys) {
const existing = providerMap.get(key.providerId)
if (existing) {
existing.keys.push(key)
} else {
providerMap.set(key.providerId, {
providerId: key.providerId,
providerName: key.providerName,
keys: [key],
isLinked: linkedProviderIds.has(key.providerId),
})
}
}
return Array.from(providerMap.values())
}
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index
}
const expandedGroups = computed<ProviderGroup[]>(() => {
if (expandedIndex.value === null) return []
const pattern = normalizedMappings.value[expandedIndex.value] || ''
if (!pattern) return []
const validation = mappingValidations.value[expandedIndex.value]
if (validation && !validation.valid) return []
return getMatchedKeysGroupedByProvider(pattern)
})
watch(() => props.mappings, (newAliases) => {
localMappings.value = [...newAliases]
originalMappings.value = [...newAliases]
isDirty.value = false
}, { deep: true })
// globalModelId 变化时清空缓存并重新加载预览
watch(() => props.globalModelId, () => {
regexCache.clear()
loadMatchPreview()
})
function markDirty() {
isDirty.value = true
}
function addMapping() {
if (localMappings.value.length >= MAX_MAPPINGS_PER_MODEL) {
toastError(`最多支持 ${MAX_MAPPINGS_PER_MODEL} 条映射规则`)
return
}
localMappings.value.push('')
isDirty.value = true
expandedIndex.value = localMappings.value.length - 1
}
async function removeMapping(index: number) {
localMappings.value.splice(index, 1)
if (expandedIndex.value === index) {
expandedIndex.value = null
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
expandedIndex.value--
}
// 删除后自动保存(仅在当前无校验错误时)
if (hasValidationErrors.value) {
toastError('存在无效映射规则,请修正后再保存')
isDirty.value = true
return
}
await saveMappings()
}
async function saveMappings() {
if (hasValidationErrors.value) {
toastError('存在无效映射规则,无法保存')
return
}
const cleanedMappings = localMappings.value
.map(a => a.trim())
.filter(a => a.length > 0)
saving.value = true
try {
const currentModel = await getGlobalModel(props.globalModelId)
const currentConfig = currentModel.config || {}
const updatedConfig = {
...currentConfig,
model_mappings: cleanedMappings.length > 0 ? cleanedMappings : undefined,
}
if (!updatedConfig.model_mappings || updatedConfig.model_mappings.length === 0) {
delete updatedConfig.model_mappings
}
await updateGlobalModel(props.globalModelId, {
config: updatedConfig,
})
localMappings.value = cleanedMappings
originalMappings.value = [...cleanedMappings] // 更新原始值
isDirty.value = false
// 收集所有未关联的提供商 ID
const unlinkedProviderIds: string[] = []
for (const mapping of cleanedMappings) {
const groups = getMatchedKeysGroupedByProvider(mapping)
for (const group of groups) {
if (!group.isLinked && !unlinkedProviderIds.includes(group.providerId)) {
unlinkedProviderIds.push(group.providerId)
}
}
}
// 自动关联未关联的提供商
if (unlinkedProviderIds.length > 0) {
toastSuccess(`映射规则已保存,正在关联 ${unlinkedProviderIds.length} 个提供商...`)
emit('linkProviders', unlinkedProviderIds)
} else {
toastSuccess('映射规则已保存')
}
// 保存成功后刷新数据
emit('update', cleanedMappings)
emit('refresh')
} catch (err) {
log.error('保存映射规则失败:', err)
toastError('保存失败,请重试')
// 保存失败时恢复到原始值
localMappings.value = [...originalMappings.value]
isDirty.value = false
} finally {
saving.value = false
}
}
// 刷新数据(通知父组件刷新,或在无外部数据时自行加载)
async function loadMatchPreview() {
// 如果有外部传入的数据,通知父组件刷新
if (props.routingData !== undefined) {
emit('refresh')
return
}
// 清空正则缓存,确保使用最新数据
regexCache.clear()
matchCountCache.clear()
internalLoadingPreview.value = true
try {
internalRoutingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
} catch (err) {
log.error('加载匹配预览失败:', err)
} finally {
internalLoadingPreview.value = false
}
}
// 监听外部 routingData 变化,清空缓存
watch(() => props.routingData, () => {
regexCache.clear()
matchCountCache.clear()
})
// 组件卸载时清理缓存,防止内存泄漏
onUnmounted(() => {
regexCache.clear()
matchCountCache.clear()
})
// 暴露刷新方法给父组件
defineExpose({
refresh: loadMatchPreview
})
</script>