2026-01-13 16:04:15 +08:00
|
|
|
|
<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">
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<h4 class="text-sm font-semibold">
|
|
|
|
|
|
映射规则
|
|
|
|
|
|
</h4>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">
|
2026-01-14 19:35:41 +08:00
|
|
|
|
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="添加规则"
|
2026-01-14 19:35:41 +08:00
|
|
|
|
:disabled="localMappings.length >= MAX_MAPPINGS_PER_MODEL"
|
|
|
|
|
|
@click="addMapping"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="刷新"
|
|
|
|
|
|
:disabled="props.loading"
|
|
|
|
|
|
@click="$emit('refresh')"
|
|
|
|
|
|
>
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-4 h-4"
|
|
|
|
|
|
:class="{ 'animate-spin': props.loading }"
|
|
|
|
|
|
/>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 规则列表 -->
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="localMappings.length > 0"
|
|
|
|
|
|
class="divide-y"
|
|
|
|
|
|
>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
2026-01-14 19:35:41 +08:00
|
|
|
|
v-for="(mapping, index) in localMappings"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
: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
|
2026-01-14 19:35:41 +08:00
|
|
|
|
v-model="localMappings[index]"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
placeholder="例如: claude-haiku-.*"
|
2026-01-15 17:03:19 +08:00
|
|
|
|
:class="`font-mono text-sm ${normalizedMappings[index] && !mappingValidations[index].valid ? 'border-destructive' : ''}`"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
@click.stop
|
|
|
|
|
|
@input="markDirty"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<!-- 验证错误提示 -->
|
|
|
|
|
|
<div
|
2026-01-15 17:03:19 +08:00
|
|
|
|
v-if="normalizedMappings[index] && !mappingValidations[index].valid"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
class="flex items-center gap-1 mt-1 text-xs text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<AlertCircle class="w-3 h-3" />
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<span>{{ mappingValidations[index].error }}</span>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 匹配统计 -->
|
|
|
|
|
|
<Badge
|
2026-01-15 17:03:19 +08:00
|
|
|
|
v-if="mappingValidations[index].valid && mappingMatchCounts[index] > 0"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-xs flex-shrink-0 h-6 leading-none"
|
|
|
|
|
|
>
|
2026-01-15 17:03:19 +08:00
|
|
|
|
{{ mappingMatchCounts[index] }} 匹配
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
2026-01-15 17:03:19 +08:00
|
|
|
|
v-else-if="normalizedMappings[index] && mappingValidations[index].valid"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
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"
|
2026-01-14 19:35:41 +08:00
|
|
|
|
@click.stop="saveMappings"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<Save
|
|
|
|
|
|
v-if="!saving"
|
|
|
|
|
|
class="w-4 h-4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-4 h-4 animate-spin"
|
|
|
|
|
|
/>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
title="删除"
|
|
|
|
|
|
:disabled="saving"
|
2026-01-14 19:35:41 +08:00
|
|
|
|
@click.stop="removeMapping(index)"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-15 02:11:44 +08:00
|
|
|
|
<!-- 展开内容:匹配的 Key 列表(按提供商分组) -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="expandedIndex === index"
|
|
|
|
|
|
class="border-t bg-muted/10 px-4 py-3"
|
|
|
|
|
|
>
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loadingPreview"
|
|
|
|
|
|
class="flex items-center justify-center py-4"
|
|
|
|
|
|
>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="expandedGroups.length === 0"
|
|
|
|
|
|
class="text-center py-4"
|
|
|
|
|
|
>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<p class="text-sm text-muted-foreground">
|
2026-01-15 17:03:19 +08:00
|
|
|
|
{{ normalizedMappings[index] ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="space-y-3"
|
|
|
|
|
|
>
|
2026-01-15 02:11:44 +08:00
|
|
|
|
<!-- 按提供商分组 -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
2026-01-15 17:03:19 +08:00
|
|
|
|
v-for="group in expandedGroups"
|
2026-01-15 02:11:44 +08:00
|
|
|
|
:key="group.providerId"
|
|
|
|
|
|
class="bg-background rounded-md border overflow-hidden"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
2026-01-15 02:11:44 +08:00
|
|
|
|
<!-- 提供商标题 -->
|
|
|
|
|
|
<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>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<Badge
|
2026-01-15 02:11:44 +08:00
|
|
|
|
v-if="group.isLinked"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
variant="secondary"
|
2026-01-15 02:11:44 +08:00
|
|
|
|
class="text-xs"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
2026-01-15 02:11:44 +08:00
|
|
|
|
已关联
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</Badge>
|
2026-01-15 02:11:44 +08:00
|
|
|
|
<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>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
2026-01-14 21:07:28 +08:00
|
|
|
|
class="text-center py-32"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
|
|
|
|
|
<GitMerge class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
|
|
|
|
|
|
<p class="text-sm text-muted-foreground">
|
2026-01-14 19:35:41 +08:00
|
|
|
|
暂无映射规则
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-02-10 23:47:47 +08:00
|
|
|
|
import { ref, watch, onUnmounted, computed } from 'vue'
|
2026-01-13 16:04:15 +08:00
|
|
|
|
import { Card, Button, Input, Badge } from '@/components/ui'
|
2026-01-15 02:11:44 +08:00
|
|
|
|
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle, Link } from 'lucide-vue-next'
|
2026-01-13 16:04:15 +08:00
|
|
|
|
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'
|
2026-01-15 17:03:19 +08:00
|
|
|
|
import {
|
|
|
|
|
|
MAX_MAPPINGS_PER_MODEL,
|
|
|
|
|
|
MAX_MODEL_NAME_LENGTH,
|
|
|
|
|
|
createLRURegexCache,
|
|
|
|
|
|
getCompiledModelMappingRegex,
|
|
|
|
|
|
validateModelMappingPattern,
|
|
|
|
|
|
type ValidationResult,
|
|
|
|
|
|
} from '@/features/models/utils/model-mapping-regex'
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
globalModelId: string
|
|
|
|
|
|
modelName: string
|
2026-01-14 19:35:41 +08:00
|
|
|
|
mappings: string[]
|
2026-01-13 18:42:15 +08:00
|
|
|
|
loading?: boolean
|
2026-02-10 23:47:47 +08:00
|
|
|
|
routingData?: ModelRoutingPreviewResponse | null // 外部传入的 routing 数据
|
|
|
|
|
|
loadingPreview?: boolean // 外部传入的加载状态
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
const emit = defineEmits<{
|
2026-01-14 19:35:41 +08:00
|
|
|
|
update: [mappings: string[]]
|
2026-01-13 18:42:15 +08:00
|
|
|
|
refresh: []
|
2026-01-15 02:11:44 +08:00
|
|
|
|
linkProvider: [providerId: string]
|
|
|
|
|
|
linkProviders: [providerIds: string[]] // 批量关联
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { success: toastSuccess, error: toastError } = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
// 本地状态
|
2026-01-14 19:35:41 +08:00
|
|
|
|
const localMappings = ref<string[]>([...props.mappings])
|
|
|
|
|
|
const originalMappings = ref<string[]>([...props.mappings]) // 用于保存失败时恢复
|
2026-01-13 16:04:15 +08:00
|
|
|
|
const isDirty = ref(false)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const expandedIndex = ref<number | null>(null)
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
// 统一以 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 匹配预览状态 - 优先使用外部传入的数据
|
|
|
|
|
|
const internalLoadingPreview = ref(false)
|
|
|
|
|
|
const internalRoutingData = ref<ModelRoutingPreviewResponse | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算属性:优先使用外部传入的数据
|
|
|
|
|
|
const loadingPreview = computed(() => props.loadingPreview ?? internalLoadingPreview.value)
|
|
|
|
|
|
const routingData = computed(() => props.routingData ?? internalRoutingData.value)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
|
|
|
|
|
const REGEX_CACHE_MAX_SIZE = 100
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const regexCache = createLRURegexCache(REGEX_CACHE_MAX_SIZE)
|
|
|
|
|
|
const matchCountCache = new Map<string, number>()
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
interface MatchedKeyForMapping {
|
2026-01-13 16:04:15 +08:00
|
|
|
|
keyId: string
|
|
|
|
|
|
keyName: string
|
|
|
|
|
|
maskedKey: string
|
|
|
|
|
|
providerName: string
|
2026-01-15 02:11:44 +08:00
|
|
|
|
providerId: string
|
2026-01-13 16:04:15 +08:00
|
|
|
|
matchedModels: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 02:11:44 +08:00
|
|
|
|
interface ProviderGroup {
|
|
|
|
|
|
providerId: string
|
|
|
|
|
|
providerName: string
|
|
|
|
|
|
keys: MatchedKeyForMapping[]
|
|
|
|
|
|
isLinked: boolean // 是否已关联到当前模型
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:04:15 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 检查是否有验证错误
|
|
|
|
|
|
*/
|
|
|
|
|
|
const hasValidationErrors = computed(() => {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
return mappingValidations.value.some((result, index) => {
|
|
|
|
|
|
return normalizedMappings.value[index] !== '' && !result.valid
|
2026-01-13 16:04:15 +08:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
function computeMatchCount(pattern: string): number {
|
|
|
|
|
|
if (!routingData.value) return 0
|
|
|
|
|
|
|
|
|
|
|
|
const cached = matchCountCache.get(pattern)
|
|
|
|
|
|
if (cached !== undefined) {
|
|
|
|
|
|
return cached
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const regex = getCompiledModelMappingRegex(pattern, regexCache)
|
|
|
|
|
|
if (!regex) {
|
|
|
|
|
|
matchCountCache.set(pattern, 0)
|
|
|
|
|
|
return 0
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
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)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
let total = 0
|
|
|
|
|
|
for (const models of keyToMatchedModels.values()) {
|
|
|
|
|
|
total += models.size
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
2026-01-15 17:03:19 +08:00
|
|
|
|
matchCountCache.set(pattern, total)
|
|
|
|
|
|
return total
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-15 00:35:28 +08:00
|
|
|
|
// 获取指定映射匹配的 Key 列表(使用全局 Key 白名单数据做实时匹配)
|
2026-01-14 19:35:41 +08:00
|
|
|
|
function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
if (!routingData.value) return []
|
|
|
|
|
|
const pattern = mapping.trim()
|
|
|
|
|
|
if (!pattern) return []
|
|
|
|
|
|
|
|
|
|
|
|
const regex = getCompiledModelMappingRegex(pattern, regexCache)
|
|
|
|
|
|
if (!regex) return []
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
const keyMap = new Map<string, MatchedKeyForMapping>()
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
2026-01-15 00:35:28 +08:00
|
|
|
|
// 使用 all_keys_whitelist 进行实时匹配(包含所有 Provider 的 Key)
|
|
|
|
|
|
for (const keyItem of routingData.value.all_keys_whitelist || []) {
|
|
|
|
|
|
if (!keyItem.allowed_models || keyItem.allowed_models.length === 0) continue
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
2026-01-15 00:35:28 +08:00
|
|
|
|
const matchedModels: string[] = []
|
|
|
|
|
|
for (const allowedModel of keyItem.allowed_models) {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
|
|
|
|
|
|
if (regex.test(allowedModel)) matchedModels.push(allowedModel)
|
2026-01-15 00:35:28 +08:00
|
|
|
|
}
|
2026-01-13 16:04:15 +08:00
|
|
|
|
|
2026-01-15 00:35:28 +08:00
|
|
|
|
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,
|
2026-01-15 02:11:44 +08:00
|
|
|
|
providerId: keyItem.provider_id,
|
2026-01-15 00:35:28 +08:00
|
|
|
|
matchedModels,
|
|
|
|
|
|
})
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(keyMap.values())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 02:11:44 +08:00
|
|
|
|
// 按提供商分组匹配的 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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:04:15 +08:00
|
|
|
|
function toggleExpand(index: number) {
|
|
|
|
|
|
expandedIndex.value = expandedIndex.value === index ? null : index
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
watch(() => props.mappings, (newAliases) => {
|
|
|
|
|
|
localMappings.value = [...newAliases]
|
|
|
|
|
|
originalMappings.value = [...newAliases]
|
2026-01-13 16:04:15 +08:00
|
|
|
|
isDirty.value = false
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
|
|
|
|
|
// globalModelId 变化时清空缓存并重新加载预览
|
|
|
|
|
|
watch(() => props.globalModelId, () => {
|
|
|
|
|
|
regexCache.clear()
|
|
|
|
|
|
loadMatchPreview()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function markDirty() {
|
|
|
|
|
|
isDirty.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
function addMapping() {
|
|
|
|
|
|
if (localMappings.value.length >= MAX_MAPPINGS_PER_MODEL) {
|
|
|
|
|
|
toastError(`最多支持 ${MAX_MAPPINGS_PER_MODEL} 条映射规则`)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-14 19:35:41 +08:00
|
|
|
|
localMappings.value.push('')
|
2026-01-13 16:04:15 +08:00
|
|
|
|
isDirty.value = true
|
2026-01-14 19:35:41 +08:00
|
|
|
|
expandedIndex.value = localMappings.value.length - 1
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 21:07:28 +08:00
|
|
|
|
async function removeMapping(index: number) {
|
2026-01-14 19:35:41 +08:00
|
|
|
|
localMappings.value.splice(index, 1)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
if (expandedIndex.value === index) {
|
|
|
|
|
|
expandedIndex.value = null
|
|
|
|
|
|
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
|
|
|
|
|
|
expandedIndex.value--
|
|
|
|
|
|
}
|
2026-01-15 17:03:19 +08:00
|
|
|
|
// 删除后自动保存(仅在当前无校验错误时)
|
|
|
|
|
|
if (hasValidationErrors.value) {
|
|
|
|
|
|
toastError('存在无效映射规则,请修正后再保存')
|
|
|
|
|
|
isDirty.value = true
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-14 21:07:28 +08:00
|
|
|
|
await saveMappings()
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
async function saveMappings() {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
if (hasValidationErrors.value) {
|
|
|
|
|
|
toastError('存在无效映射规则,无法保存')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
const cleanedMappings = localMappings.value
|
2026-01-13 16:04:15 +08:00
|
|
|
|
.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,
|
2026-01-14 19:35:41 +08:00
|
|
|
|
model_mappings: cleanedMappings.length > 0 ? cleanedMappings : undefined,
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
if (!updatedConfig.model_mappings || updatedConfig.model_mappings.length === 0) {
|
|
|
|
|
|
delete updatedConfig.model_mappings
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updateGlobalModel(props.globalModelId, {
|
|
|
|
|
|
config: updatedConfig,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-14 19:35:41 +08:00
|
|
|
|
localMappings.value = cleanedMappings
|
|
|
|
|
|
originalMappings.value = [...cleanedMappings] // 更新原始值
|
2026-01-13 16:04:15 +08:00
|
|
|
|
isDirty.value = false
|
|
|
|
|
|
|
2026-01-15 02:11:44 +08:00
|
|
|
|
// 收集所有未关联的提供商 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('映射规则已保存')
|
|
|
|
|
|
}
|
2026-02-11 03:09:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 保存成功后刷新数据
|
|
|
|
|
|
emit('update', cleanedMappings)
|
|
|
|
|
|
emit('refresh')
|
2026-01-13 16:04:15 +08:00
|
|
|
|
} catch (err) {
|
2026-01-14 19:35:41 +08:00
|
|
|
|
log.error('保存映射规则失败:', err)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
toastError('保存失败,请重试')
|
|
|
|
|
|
// 保存失败时恢复到原始值
|
2026-01-14 19:35:41 +08:00
|
|
|
|
localMappings.value = [...originalMappings.value]
|
2026-01-13 16:04:15 +08:00
|
|
|
|
isDirty.value = false
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 刷新数据(通知父组件刷新,或在无外部数据时自行加载)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
async function loadMatchPreview() {
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 如果有外部传入的数据,通知父组件刷新
|
|
|
|
|
|
if (props.routingData !== undefined) {
|
|
|
|
|
|
emit('refresh')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:04:15 +08:00
|
|
|
|
// 清空正则缓存,确保使用最新数据
|
|
|
|
|
|
regexCache.clear()
|
2026-01-15 17:03:19 +08:00
|
|
|
|
matchCountCache.clear()
|
2026-02-10 23:47:47 +08:00
|
|
|
|
internalLoadingPreview.value = true
|
2026-01-13 16:04:15 +08:00
|
|
|
|
try {
|
2026-02-10 23:47:47 +08:00
|
|
|
|
internalRoutingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
|
2026-01-13 16:04:15 +08:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载匹配预览失败:', err)
|
|
|
|
|
|
} finally {
|
2026-02-10 23:47:47 +08:00
|
|
|
|
internalLoadingPreview.value = false
|
2026-01-13 16:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 监听外部 routingData 变化,清空缓存
|
|
|
|
|
|
watch(() => props.routingData, () => {
|
|
|
|
|
|
regexCache.clear()
|
|
|
|
|
|
matchCountCache.clear()
|
2026-01-13 16:04:15 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 组件卸载时清理缓存,防止内存泄漏
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
regexCache.clear()
|
2026-01-15 17:03:19 +08:00
|
|
|
|
matchCountCache.clear()
|
2026-01-13 16:04:15 +08:00
|
|
|
|
})
|
2026-01-15 02:11:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 暴露刷新方法给父组件
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
refresh: loadMatchPreview
|
|
|
|
|
|
})
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</script>
|