mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-14 05:25:19 +08:00
feat: 实现 GlobalModel 别名匹配系统
主要更改: - GlobalModel 支持 model_aliases 配置,允许使用正则表达式定义别名规则 - Provider Key 的 allowed_models 现在可以通过别名规则匹配 GlobalModel - 新增 ModelAliasesTab 组件用于管理模型别名配置 - Provider 详情页新增别名映射预览功能,展示 Key 白名单与 GlobalModel 别名的匹配关系 - 路由预览 API 返回 Key 的 allowed_models 信息 安全特性: - 使用 regex 库的原生超时保护(100ms)防止 ReDoS 攻击 - 别名规则数量限制(50 条/模型)和长度限制(200 字符) - 别名映射预览 API 添加超时保护和结果截断 其他改进: - GlobalModel 更新/删除时使用行级锁防止并发竞态 - 缓存失效逻辑优化,支持异步清理和正则缓存清空 - 路由 Tab 布局重构,使用 flexbox 替代绝对定位
This commit is contained in:
@@ -95,3 +95,50 @@ export async function testModel(data: TestModelRequest): Promise<TestModelRespon
|
||||
const response = await client.post('/api/admin/provider-query/test-model', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 别名映射预览相关类型
|
||||
*/
|
||||
export interface AliasMatchedModel {
|
||||
allowed_model: string
|
||||
alias_pattern: string
|
||||
}
|
||||
|
||||
export interface AliasMatchingGlobalModel {
|
||||
global_model_id: string
|
||||
global_model_name: string
|
||||
display_name: string
|
||||
is_active: boolean
|
||||
matched_models: AliasMatchedModel[]
|
||||
}
|
||||
|
||||
export interface AliasMatchingKey {
|
||||
key_id: string
|
||||
key_name: string
|
||||
masked_key: string
|
||||
is_active: boolean
|
||||
allowed_models: string[]
|
||||
matching_global_models: AliasMatchingGlobalModel[]
|
||||
}
|
||||
|
||||
export interface ProviderAliasMappingPreviewResponse {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
keys: AliasMatchingKey[]
|
||||
total_keys: number
|
||||
total_matches: number
|
||||
// 截断提示
|
||||
truncated: boolean
|
||||
truncated_keys: number
|
||||
truncated_models: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 别名映射预览
|
||||
*/
|
||||
export async function getProviderAliasMappingPreview(
|
||||
providerId: string
|
||||
): Promise<ProviderAliasMappingPreviewResponse> {
|
||||
const response = await client.get(`/api/admin/providers/${providerId}/alias-mapping-preview`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -641,6 +641,7 @@ export interface RoutingKeyInfo {
|
||||
health_score: number
|
||||
is_active: boolean
|
||||
api_formats: string[]
|
||||
allowed_models?: string[] | null // 允许的模型列表,null 表示不限制
|
||||
circuit_breaker_open: boolean
|
||||
circuit_breaker_formats: string[]
|
||||
}
|
||||
|
||||
523
frontend/src/features/models/components/ModelAliasesTab.vue
Normal file
523
frontend/src/features/models/components/ModelAliasesTab.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<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">
|
||||
支持正则表达式 ({{ localAliases.length }}/{{ MAX_ALIASES_PER_MODEL }})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="添加规则"
|
||||
:disabled="localAliases.length >= MAX_ALIASES_PER_MODEL"
|
||||
@click="addAlias"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则列表 -->
|
||||
<div v-if="localAliases.length > 0" class="divide-y">
|
||||
<div
|
||||
v-for="(alias, index) in localAliases"
|
||||
: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="localAliases[index]"
|
||||
placeholder="例如: claude-haiku-.*"
|
||||
:class="`font-mono text-sm ${alias.trim() && !getAliasValidation(alias).valid ? 'border-destructive' : ''}`"
|
||||
@click.stop
|
||||
@input="markDirty"
|
||||
/>
|
||||
<!-- 验证错误提示 -->
|
||||
<div
|
||||
v-if="alias.trim() && !getAliasValidation(alias).valid"
|
||||
class="flex items-center gap-1 mt-1 text-xs text-destructive"
|
||||
>
|
||||
<AlertCircle class="w-3 h-3" />
|
||||
<span>{{ getAliasValidation(alias).error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 匹配统计 -->
|
||||
<Badge
|
||||
v-if="getAliasValidation(alias).valid && getMatchCount(alias) > 0"
|
||||
variant="secondary"
|
||||
class="text-xs flex-shrink-0 h-6 leading-none"
|
||||
>
|
||||
{{ getMatchCount(alias) }} 匹配
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="alias.trim() && getAliasValidation(alias).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="saveAliases"
|
||||
>
|
||||
<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="removeAlias(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="getMatchedKeysForAlias(alias).length === 0" class="text-center py-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ alias.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入别名规则' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="item in getMatchedKeysForAlias(alias)"
|
||||
:key="item.keyId"
|
||||
class="bg-background rounded-md border p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm mb-2">
|
||||
<span class="text-muted-foreground">{{ item.providerName }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="font-medium">{{ item.keyName }}</span>
|
||||
<span class="text-xs text-muted-foreground font-mono ml-auto">
|
||||
{{ item.maskedKey }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
v-for="model in item.matchedModels"
|
||||
:key="model"
|
||||
variant="secondary"
|
||||
class="text-xs font-mono"
|
||||
>
|
||||
{{ model }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-8"
|
||||
>
|
||||
<GitMerge class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
暂无别名规则
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
添加别名可匹配 Provider Key 白名单中的模型
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { Card, Button, Input, Badge } from '@/components/ui'
|
||||
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle } 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'
|
||||
|
||||
const props = defineProps<{
|
||||
globalModelId: string
|
||||
modelName: string
|
||||
aliases: string[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
update: [aliases: string[]]
|
||||
}>()
|
||||
// 安全限制常量(与后端保持一致)
|
||||
const MAX_ALIASES_PER_MODEL = 50
|
||||
const MAX_ALIAS_LENGTH = 200
|
||||
|
||||
// 危险的正则模式(可能导致 ReDoS,与后端 model_permissions.py 保持一致)
|
||||
// 注意:这些是用于检测用户输入字符串中的危险正则构造
|
||||
const DANGEROUS_REGEX_PATTERNS = [
|
||||
/\([^)]*[+*]\)[+*]/, // (x+)+, (x*)*, (x+)*, (x*)+
|
||||
/\([^)]*\)\{[0-9]+,\}/, // (x){n,} 无上限
|
||||
/\(\.\*\)\{[0-9]+,\}/, // (.*){n,} 贪婪量词 + 高重复
|
||||
/\(\.\+\)\{[0-9]+,\}/, // (.+){n,} 贪婪量词 + 高重复
|
||||
/\([^)]*\|[^)]*\)[+*]/, // (a|b)+ 选择分支 + 量词
|
||||
/\(\.\*\)\+/, // (.*)+
|
||||
/\(\.\+\)\+/, // (.+)+
|
||||
/\([^)]*\*\)[+*]/, // 嵌套量词: (a*)+
|
||||
/\(\\w\+\)\+/, // (\w+)+ - 检测字面量 \w
|
||||
/\(\.\*\)\*/, // (.*)*
|
||||
/\(.*\+.*\)\+/, // (a+b)+ 更通用的嵌套量词检测
|
||||
/\[.*\]\{[0-9]+,\}\{/, // [x]{n,}{m,} 嵌套量词
|
||||
/\.{2,}\*/, // ..* 连续通配
|
||||
/\([^)]*\|[^)]*\)\*/, // (a|a)* 选择分支 + 星号
|
||||
/\{[0-9]{2,},\}/, // {10,} 高重复次数无上限
|
||||
/\(\[.*\]\+\)\+/, // ([x]+)+ 字符类嵌套量词
|
||||
// 补充的危险模式(与后端保持一致)
|
||||
/\([^)]*[+*]\)\{[0-9]+,/, // (a+){n,} 量词后跟大括号量词
|
||||
/\(\([^)]*[+*]\)[+*]\)/, // ((a+)+) 三层嵌套量词
|
||||
/\(\?:[^)]*[+*]\)[+*]/, // (?:a+)+ 非捕获组嵌套量词
|
||||
]
|
||||
|
||||
// 正则匹配安全限制(与后端保持一致)
|
||||
const REGEX_MATCH_MAX_INPUT_LENGTH = 200
|
||||
|
||||
const { success: toastSuccess, error: toastError } = useToast()
|
||||
|
||||
// 本地状态
|
||||
const localAliases = ref<string[]>([...props.aliases])
|
||||
const originalAliases = ref<string[]>([...props.aliases]) // 用于保存失败时恢复
|
||||
const isDirty = ref(false)
|
||||
const saving = ref(false)
|
||||
const expandedIndex = ref<number | null>(null)
|
||||
|
||||
// 匹配预览状态
|
||||
const loadingPreview = ref(false)
|
||||
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
|
||||
|
||||
// 正则编译缓存(简单的 LRU 实现)
|
||||
const REGEX_CACHE_MAX_SIZE = 100
|
||||
|
||||
class LRURegexCache {
|
||||
private cache = new Map<string, RegExp | null>()
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize
|
||||
}
|
||||
|
||||
get(key: string): RegExp | null | undefined {
|
||||
if (!this.cache.has(key)) return undefined
|
||||
// 移到最后(LRU)
|
||||
const value = this.cache.get(key)!
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
set(key: string, value: RegExp | null): void {
|
||||
// 如果已存在,先删除(会重新添加到最后)
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// 缓存已满,删除最早的条目
|
||||
const firstKey = this.cache.keys().next().value as string | undefined
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
}
|
||||
|
||||
const regexCache = new LRURegexCache(REGEX_CACHE_MAX_SIZE)
|
||||
|
||||
interface MatchedKeyForAlias {
|
||||
keyId: string
|
||||
keyName: string
|
||||
maskedKey: string
|
||||
providerName: string
|
||||
matchedModels: string[]
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证别名规则是否安全
|
||||
*/
|
||||
function validateAliasPattern(pattern: string): ValidationResult {
|
||||
if (!pattern || !pattern.trim()) {
|
||||
return { valid: false, error: '规则不能为空' }
|
||||
}
|
||||
|
||||
if (pattern.length > MAX_ALIAS_LENGTH) {
|
||||
return { valid: false, error: `规则过长 (最大 ${MAX_ALIAS_LENGTH} 字符)` }
|
||||
}
|
||||
|
||||
// 检查危险模式
|
||||
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
|
||||
if (dangerous.test(pattern)) {
|
||||
return { valid: false, error: '规则包含潜在危险的正则构造' }
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试编译验证语法
|
||||
try {
|
||||
new RegExp(`^${pattern}$`, 'i')
|
||||
} catch {
|
||||
return { valid: false, error: `正则表达式语法错误` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取别名的验证状态
|
||||
*/
|
||||
function getAliasValidation(alias: string): ValidationResult {
|
||||
if (!alias.trim()) {
|
||||
return { valid: true } // 空值暂不报错,保存时过滤
|
||||
}
|
||||
return validateAliasPattern(alias)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有验证错误
|
||||
*/
|
||||
const hasValidationErrors = computed(() => {
|
||||
return localAliases.value.some(alias => {
|
||||
if (!alias.trim()) return false
|
||||
return !validateAliasPattern(alias).valid
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 安全的正则匹配(带缓存和保护)
|
||||
*/
|
||||
function matchPattern(pattern: string, text: string): boolean {
|
||||
// 快速路径:精确匹配
|
||||
if (pattern.toLowerCase() === text.toLowerCase()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (pattern.length > MAX_ALIAS_LENGTH) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 危险模式检查
|
||||
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
|
||||
if (dangerous.test(pattern)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 LRU 缓存
|
||||
let regex = regexCache.get(pattern)
|
||||
if (regex === undefined) {
|
||||
try {
|
||||
regex = new RegExp(`^${pattern}$`, 'i')
|
||||
regexCache.set(pattern, regex)
|
||||
} catch {
|
||||
regexCache.set(pattern, null)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (regex === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 额外保护:限制正则匹配的输入长度(与后端保持一致)
|
||||
const matchInput = text.slice(0, REGEX_MATCH_MAX_INPUT_LENGTH)
|
||||
return regex.test(matchInput)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取指定别名匹配的 Key 列表
|
||||
function getMatchedKeysForAlias(alias: string): MatchedKeyForAlias[] {
|
||||
if (!routingData.value || !alias.trim()) return []
|
||||
|
||||
// 使用 Map 按 keyId 去重并合并匹配结果
|
||||
const keyMap = new Map<string, MatchedKeyForAlias>()
|
||||
|
||||
for (const provider of routingData.value.providers) {
|
||||
for (const endpoint of provider.endpoints) {
|
||||
for (const key of endpoint.keys) {
|
||||
if (!key.allowed_models || key.allowed_models.length === 0) continue
|
||||
|
||||
const matchedModels: string[] = []
|
||||
for (const allowedModel of key.allowed_models) {
|
||||
if (matchPattern(alias, allowedModel)) {
|
||||
matchedModels.push(allowedModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedModels.length > 0) {
|
||||
const existing = keyMap.get(key.id)
|
||||
if (existing) {
|
||||
// 合并匹配结果(去重)
|
||||
const mergedModels = new Set([...existing.matchedModels, ...matchedModels])
|
||||
existing.matchedModels = Array.from(mergedModels)
|
||||
} else {
|
||||
keyMap.set(key.id, {
|
||||
keyId: key.id,
|
||||
keyName: key.name,
|
||||
maskedKey: key.masked_key,
|
||||
providerName: provider.name,
|
||||
matchedModels,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(keyMap.values())
|
||||
}
|
||||
|
||||
// 获取指定别名的匹配数量
|
||||
function getMatchCount(alias: string): number {
|
||||
return getMatchedKeysForAlias(alias).reduce((sum, item) => sum + item.matchedModels.length, 0)
|
||||
}
|
||||
|
||||
function toggleExpand(index: number) {
|
||||
expandedIndex.value = expandedIndex.value === index ? null : index
|
||||
}
|
||||
|
||||
watch(() => props.aliases, (newAliases) => {
|
||||
localAliases.value = [...newAliases]
|
||||
originalAliases.value = [...newAliases]
|
||||
isDirty.value = false
|
||||
}, { deep: true })
|
||||
|
||||
// globalModelId 变化时清空缓存并重新加载预览
|
||||
watch(() => props.globalModelId, () => {
|
||||
regexCache.clear()
|
||||
loadMatchPreview()
|
||||
})
|
||||
|
||||
function markDirty() {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
function addAlias() {
|
||||
if (localAliases.value.length >= MAX_ALIASES_PER_MODEL) {
|
||||
toastError(`最多支持 ${MAX_ALIASES_PER_MODEL} 条别名规则`)
|
||||
return
|
||||
}
|
||||
localAliases.value.push('')
|
||||
isDirty.value = true
|
||||
expandedIndex.value = localAliases.value.length - 1
|
||||
}
|
||||
|
||||
function removeAlias(index: number) {
|
||||
localAliases.value.splice(index, 1)
|
||||
isDirty.value = true
|
||||
if (expandedIndex.value === index) {
|
||||
expandedIndex.value = null
|
||||
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
|
||||
expandedIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAliases() {
|
||||
const cleanedAliases = localAliases.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_aliases: cleanedAliases.length > 0 ? cleanedAliases : undefined,
|
||||
}
|
||||
|
||||
if (!updatedConfig.model_aliases || updatedConfig.model_aliases.length === 0) {
|
||||
delete updatedConfig.model_aliases
|
||||
}
|
||||
|
||||
await updateGlobalModel(props.globalModelId, {
|
||||
config: updatedConfig,
|
||||
})
|
||||
|
||||
localAliases.value = cleanedAliases
|
||||
originalAliases.value = [...cleanedAliases] // 更新原始值
|
||||
isDirty.value = false
|
||||
|
||||
toastSuccess('别名规则已保存')
|
||||
emit('update', cleanedAliases)
|
||||
} catch (err) {
|
||||
log.error('保存别名规则失败:', err)
|
||||
toastError('保存失败,请重试')
|
||||
// 保存失败时恢复到原始值
|
||||
localAliases.value = [...originalAliases.value]
|
||||
isDirty.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMatchPreview() {
|
||||
// 清空正则缓存,确保使用最新数据
|
||||
regexCache.clear()
|
||||
loadingPreview.value = true
|
||||
try {
|
||||
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
|
||||
} catch (err) {
|
||||
log.error('加载匹配预览失败:', err)
|
||||
} finally {
|
||||
loadingPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMatchPreview()
|
||||
})
|
||||
|
||||
// 组件卸载时清理缓存,防止内存泄漏
|
||||
onUnmounted(() => {
|
||||
regexCache.clear()
|
||||
})
|
||||
</script>
|
||||
@@ -104,6 +104,19 @@
|
||||
<span class="hidden sm:inline">链路控制</span>
|
||||
<span class="sm:hidden">链路</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md transition-all duration-200"
|
||||
:class="[
|
||||
detailTab === 'aliases'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="detailTab = 'aliases'"
|
||||
>
|
||||
<span class="hidden sm:inline">模型映射</span>
|
||||
<span class="sm:hidden">映射</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
@@ -419,6 +432,17 @@
|
||||
@delete-provider="handleDeleteProviderFromRouting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: 模型映射 -->
|
||||
<div v-show="detailTab === 'aliases'">
|
||||
<ModelAliasesTab
|
||||
v-if="model"
|
||||
:global-model-id="model.id"
|
||||
:model-name="model.name"
|
||||
:aliases="model.config?.model_aliases || []"
|
||||
@update="handleAliasesUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -456,6 +480,7 @@ import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import RoutingTab from './RoutingTab.vue'
|
||||
import ModelAliasesTab from './ModelAliasesTab.vue'
|
||||
|
||||
// 使用外部类型定义
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
@@ -518,6 +543,13 @@ function refreshRoutingData() {
|
||||
routingTabRef.value?.loadRoutingData?.()
|
||||
}
|
||||
|
||||
// 处理模型别名更新
|
||||
function handleAliasesUpdate(_aliases: string[]) {
|
||||
// 别名已在 ModelAliasesTab 内部保存到服务器
|
||||
// 刷新路由数据以反映可能的候选变化
|
||||
refreshRoutingData()
|
||||
}
|
||||
|
||||
// 暴露刷新方法给父组件
|
||||
defineExpose({
|
||||
refreshRoutingData
|
||||
|
||||
@@ -76,21 +76,19 @@
|
||||
>
|
||||
<!-- 格式标题栏 -->
|
||||
<div
|
||||
class="px-3 py-2 bg-muted/30 border-b border-border/40 flex items-center justify-between cursor-pointer"
|
||||
class="px-4 py-3 bg-muted/30 flex items-center justify-between cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
@click="toggleFormat(formatGroup.api_format)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs font-medium"
|
||||
class="text-xs font-semibold px-2.5 py-1"
|
||||
>
|
||||
{{ formatGroup.api_format }}
|
||||
</Badge>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatGroup.active_keys }}/{{ formatGroup.total_keys }} Keys
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">·</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
<span class="mx-1.5">·</span>
|
||||
{{ formatGroup.active_providers }}/{{ formatGroup.total_providers }} 提供商
|
||||
</span>
|
||||
</div>
|
||||
@@ -105,203 +103,220 @@
|
||||
<div v-if="isFormatExpanded(formatGroup.api_format)">
|
||||
<!-- ========== 全局 Key 优先模式 ========== -->
|
||||
<template v-if="isGlobalKeyMode">
|
||||
<div class="relative">
|
||||
<!-- 垂直主线 -->
|
||||
<div
|
||||
v-if="formatGroup.keyGroups.length > 0"
|
||||
class="absolute left-5 top-0 bottom-0 w-0.5 bg-border"
|
||||
/>
|
||||
|
||||
<div class="py-2">
|
||||
<template
|
||||
v-for="(keyGroup, groupIndex) in formatGroup.keyGroups"
|
||||
:key="groupIndex"
|
||||
<div class="py-2 pl-3">
|
||||
<template
|
||||
v-for="(keyGroup, groupIndex) in formatGroup.keyGroups"
|
||||
:key="groupIndex"
|
||||
>
|
||||
<!-- 第一组且有多个 key 时显示负载均衡标签 -->
|
||||
<div
|
||||
v-if="groupIndex === 0 && keyGroup.keys.length > 1"
|
||||
class="ml-6 mr-3 mb-1 flex items-center gap-1 text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
<!-- 第一组且有多个 key 时显示负载均衡标签 -->
|
||||
<div
|
||||
v-if="groupIndex === 0 && keyGroup.keys.length > 1"
|
||||
class="ml-10 mr-3 mb-1 flex items-center gap-1 text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
<span>负载均衡</span>
|
||||
</div>
|
||||
<span>负载均衡</span>
|
||||
</div>
|
||||
|
||||
<!-- 该优先级组内的 Keys -->
|
||||
<div
|
||||
v-for="(keyEntry, keyIndex) in keyGroup.keys"
|
||||
:key="keyEntry.key.id"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 该优先级组内的 Keys -->
|
||||
<div
|
||||
v-for="(keyEntry, keyIndex) in keyGroup.keys"
|
||||
:key="keyEntry.key.id"
|
||||
class="flex py-1"
|
||||
>
|
||||
<!-- 左侧:节点 + 连线 -->
|
||||
<div class="w-6 flex flex-col items-center shrink-0">
|
||||
<!-- 上半段连线 -->
|
||||
<div
|
||||
class="w-0.5 flex-1"
|
||||
:class="groupIndex === 0 && keyIndex === 0 ? 'bg-transparent' : 'bg-border'"
|
||||
/>
|
||||
<!-- 节点圆点 -->
|
||||
<div
|
||||
class="absolute left-[14px] top-4 w-3 h-3 rounded-full border-2 z-10"
|
||||
class="w-3 h-3 rounded-full border-2 shrink-0"
|
||||
:class="getGlobalKeyNodeClass(keyEntry, groupIndex, keyIndex)"
|
||||
/>
|
||||
|
||||
<!-- Key 卡片(无展开,直接显示所有信息) -->
|
||||
<!-- 下半段连线 -->
|
||||
<div
|
||||
class="ml-10 mr-3 mb-2"
|
||||
:class="!keyEntry.key.is_active ? 'opacity-50' : ''"
|
||||
class="w-0.5 flex-1"
|
||||
:class="isLastKeyInFormat(formatGroup, groupIndex, keyIndex) ? 'bg-transparent' : 'bg-border'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Key 卡片 -->
|
||||
<div
|
||||
class="flex-1 mr-3"
|
||||
:class="!keyEntry.key.is_active ? 'opacity-50' : ''"
|
||||
>
|
||||
<div
|
||||
class="group rounded-lg transition-all p-2.5"
|
||||
:class="getGlobalKeyCardClass(keyEntry, groupIndex, keyIndex)"
|
||||
>
|
||||
<div
|
||||
class="group rounded-lg transition-all p-2.5"
|
||||
:class="getGlobalKeyCardClass(keyEntry, groupIndex, keyIndex)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 第一列:优先级标签 -->
|
||||
<div
|
||||
v-if="keyEntry.key.is_active"
|
||||
class="px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0"
|
||||
:class="groupIndex === 0 && keyIndex === 0
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted-foreground/20 text-muted-foreground'"
|
||||
>
|
||||
<span v-if="groupIndex === 0 && keyIndex === 0">首选</span>
|
||||
<span v-else>P{{ keyGroup.priority ?? '?' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 第一列:优先级标签 -->
|
||||
<div
|
||||
v-if="keyEntry.key.is_active"
|
||||
class="px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0"
|
||||
:class="groupIndex === 0 && keyIndex === 0
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted-foreground/20 text-muted-foreground'"
|
||||
>
|
||||
<span v-if="groupIndex === 0 && keyIndex === 0">首选</span>
|
||||
<span v-else>P{{ keyGroup.priority ?? '?' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 第二列:状态指示灯 -->
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="getKeyStatusClass(keyEntry.key)"
|
||||
/>
|
||||
|
||||
<!-- 第三列:Key 名称 + Provider 信息 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
class="text-sm font-medium truncate"
|
||||
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
|
||||
>
|
||||
{{ keyEntry.key.name }}
|
||||
</span>
|
||||
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0">
|
||||
{{ keyEntry.key.masked_key }}
|
||||
</code>
|
||||
<Zap
|
||||
v-if="keyEntry.key.circuit_breaker_open"
|
||||
class="w-3 h-3 text-destructive shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<!-- Provider 和 Endpoint 信息 -->
|
||||
<div class="text-[10px] text-muted-foreground truncate">
|
||||
{{ keyEntry.provider.name }}
|
||||
<span v-if="hasModelMapping(keyEntry.provider)">
|
||||
({{ keyEntry.provider.provider_model_name }})
|
||||
</span>
|
||||
<span v-if="keyEntry.provider.billing_type">
|
||||
· {{ getBillingLabel(keyEntry.provider) }}
|
||||
</span>
|
||||
<span v-if="keyEntry.endpoint">
|
||||
· {{ keyEntry.endpoint.base_url }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二列:状态指示灯 -->
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="getKeyStatusClass(keyEntry.key)"
|
||||
/>
|
||||
|
||||
<!-- 第三列:Key 名称 + Provider 信息 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
class="text-sm font-medium truncate"
|
||||
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
|
||||
>
|
||||
{{ keyEntry.key.name }}
|
||||
</span>
|
||||
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0">
|
||||
{{ keyEntry.key.masked_key }}
|
||||
</code>
|
||||
<Zap
|
||||
v-if="keyEntry.key.circuit_breaker_open"
|
||||
class="w-3 h-3 text-destructive shrink-0"
|
||||
<!-- 第四列:健康度 + RPM + 操作按钮 -->
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- 健康度 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="getHealthScoreBarColor(keyEntry.key.health_score)"
|
||||
:style="{ width: `${keyEntry.key.health_score}%` }"
|
||||
/>
|
||||
</div>
|
||||
<!-- Provider 和 Endpoint 信息 -->
|
||||
<div class="text-[10px] text-muted-foreground truncate">
|
||||
{{ keyEntry.provider.name }}
|
||||
<span v-if="hasModelMapping(keyEntry.provider)">
|
||||
({{ keyEntry.provider.provider_model_name }})
|
||||
</span>
|
||||
<span v-if="keyEntry.provider.billing_type">
|
||||
· {{ getBillingLabel(keyEntry.provider) }}
|
||||
</span>
|
||||
<span v-if="keyEntry.endpoint">
|
||||
· {{ keyEntry.endpoint.base_url }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四列:健康度 + RPM + 操作按钮 -->
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- 健康度 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="getHealthScoreBarColor(keyEntry.key.health_score)"
|
||||
:style="{ width: `${keyEntry.key.health_score}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-medium tabular-nums"
|
||||
:class="getHealthScoreTextColor(keyEntry.key.health_score)"
|
||||
>
|
||||
{{ Math.round(keyEntry.key.health_score) }}%
|
||||
</span>
|
||||
</div>
|
||||
<!-- RPM -->
|
||||
<span
|
||||
v-if="keyEntry.key.effective_rpm"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
class="text-[10px] font-medium tabular-nums"
|
||||
:class="getHealthScoreTextColor(keyEntry.key.health_score)"
|
||||
>
|
||||
{{ keyEntry.key.is_adaptive ? '~' : '' }}{{ keyEntry.key.effective_rpm }}
|
||||
{{ Math.round(keyEntry.key.health_score) }}%
|
||||
</span>
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="编辑此关联"
|
||||
@click.stop="$emit('editProvider', keyEntry.provider)"
|
||||
>
|
||||
<Edit class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="keyEntry.provider.model_is_active ? '停用此关联' : '启用此关联'"
|
||||
@click.stop="$emit('toggleProviderStatus', keyEntry.provider)"
|
||||
>
|
||||
<Power class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="删除此关联"
|
||||
@click.stop="$emit('deleteProvider', keyEntry.provider)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- RPM -->
|
||||
<span
|
||||
v-if="keyEntry.key.effective_rpm"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
{{ keyEntry.key.is_adaptive ? '~' : '' }}{{ keyEntry.key.effective_rpm }}
|
||||
</span>
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="编辑此关联"
|
||||
@click.stop="$emit('editProvider', keyEntry.provider)"
|
||||
>
|
||||
<Edit class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:title="keyEntry.provider.model_is_active ? '停用此关联' : '启用此关联'"
|
||||
@click.stop="$emit('toggleProviderStatus', keyEntry.provider)"
|
||||
>
|
||||
<Power class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="删除此关联"
|
||||
@click.stop="$emit('deleteProvider', keyEntry.provider)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 熔断详情(如果有) -->
|
||||
<div
|
||||
v-if="keyEntry.key.circuit_breaker_open"
|
||||
class="text-[10px] text-destructive mt-1.5 ml-6"
|
||||
>
|
||||
熔断中: {{ keyEntry.key.circuit_breaker_formats.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 熔断详情(如果有) -->
|
||||
<div
|
||||
v-if="keyEntry.key.circuit_breaker_open"
|
||||
class="text-[10px] text-destructive mt-1.5 ml-6"
|
||||
>
|
||||
熔断中: {{ keyEntry.key.circuit_breaker_formats.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 降级标记(如果下一组有多个 key,显示"降级 · 负载均衡") -->
|
||||
<div
|
||||
v-if="groupIndex < formatGroup.keyGroups.length - 1"
|
||||
class="ml-10 -mt-1 mb-1 flex items-center gap-1"
|
||||
>
|
||||
<ArrowDown class="w-3 h-3 text-muted-foreground/50" />
|
||||
<span class="text-[10px] text-muted-foreground/50">
|
||||
<!-- 降级标记(如果下一组有多个 key,显示"降级 · 负载均衡") -->
|
||||
<div
|
||||
v-if="groupIndex < formatGroup.keyGroups.length - 1"
|
||||
class="flex py-0.5"
|
||||
>
|
||||
<div class="w-6 flex justify-center shrink-0">
|
||||
<div class="w-0.5 h-full bg-border" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/50">
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
<span>
|
||||
{{ formatGroup.keyGroups[groupIndex + 1].keys.length > 1 ? '降级 · 负载均衡' : '降级' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== 提供商优先模式 ========== -->
|
||||
<template v-else>
|
||||
<div class="relative">
|
||||
<!-- 垂直主线 -->
|
||||
<div class="py-2 pl-3">
|
||||
<div
|
||||
v-if="formatGroup.providers.length > 0"
|
||||
class="absolute left-5 top-0 bottom-0 w-0.5 bg-border"
|
||||
/>
|
||||
|
||||
<div class="py-2">
|
||||
<div
|
||||
v-for="(providerEntry, providerIndex) in formatGroup.providers"
|
||||
:key="`${providerEntry.provider.id}-${providerEntry.endpoint?.id || providerIndex}`"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 节点圆点 -->
|
||||
<div
|
||||
class="absolute left-[14px] top-4 w-3 h-3 rounded-full border-2 z-10"
|
||||
:class="getFormatProviderNodeClass(providerEntry, providerIndex)"
|
||||
/>
|
||||
v-for="(providerEntry, providerIndex) in formatGroup.providers"
|
||||
:key="`${providerEntry.provider.id}-${providerEntry.endpoint?.id || providerIndex}`"
|
||||
>
|
||||
<!-- 提供商行 -->
|
||||
<div class="flex py-1">
|
||||
<!-- 左侧:节点 + 连线 -->
|
||||
<div class="w-6 flex flex-col items-center shrink-0">
|
||||
<!-- 上半段连线 -->
|
||||
<div
|
||||
class="w-0.5 flex-1"
|
||||
:class="providerIndex === 0 ? 'bg-transparent' : 'bg-border'"
|
||||
/>
|
||||
<!-- 节点圆点 -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border-2 shrink-0"
|
||||
:class="getFormatProviderNodeClass(providerEntry, providerIndex)"
|
||||
/>
|
||||
<!-- 下半段连线 -->
|
||||
<div
|
||||
class="w-0.5 flex-1"
|
||||
:class="providerIndex === formatGroup.providers.length - 1 ? 'bg-transparent' : 'bg-border'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提供商卡片 -->
|
||||
<div
|
||||
class="ml-10 mr-3 mb-2"
|
||||
class="flex-1 mr-3"
|
||||
:class="!providerEntry.provider.is_active || !providerEntry.provider.model_is_active ? 'opacity-50' : ''"
|
||||
>
|
||||
<div
|
||||
@@ -536,14 +551,19 @@
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 降级标记 -->
|
||||
<div
|
||||
v-if="providerIndex < formatGroup.providers.length - 1"
|
||||
class="ml-10 -mt-1 mb-1 flex items-center gap-1"
|
||||
>
|
||||
<ArrowDown class="w-3 h-3 text-muted-foreground/50" />
|
||||
<span class="text-[10px] text-muted-foreground/50">降级</span>
|
||||
<!-- 降级标记 -->
|
||||
<div
|
||||
v-if="providerIndex < formatGroup.providers.length - 1"
|
||||
class="flex py-0.5"
|
||||
>
|
||||
<div class="w-6 flex justify-center shrink-0">
|
||||
<div class="w-0.5 h-full bg-border" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/50">
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
<span>降级</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -905,6 +925,13 @@ function getGlobalKeyNodeClass(entry: GlobalKeyEntry, groupIndex: number, keyInd
|
||||
return 'bg-background border-border'
|
||||
}
|
||||
|
||||
// 判断是否为格式组中的最后一个 Key
|
||||
function isLastKeyInFormat(formatGroup: ApiFormatGroup, groupIndex: number, keyIndex: number): boolean {
|
||||
const isLastGroup = groupIndex === formatGroup.keyGroups.length - 1
|
||||
const isLastKeyInGroup = keyIndex === formatGroup.keyGroups[groupIndex].keys.length - 1
|
||||
return isLastGroup && isLastKeyInGroup
|
||||
}
|
||||
|
||||
// 获取全局 Key 卡片样式(全局 Key 优先模式)
|
||||
function getGlobalKeyCardClass(entry: GlobalKeyEntry, groupIndex: number, keyIndex: number): string {
|
||||
if (!entry.key.is_active || !entry.provider.is_active || !entry.provider.model_is_active) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as GlobalModelFormDialog } from './GlobalModelFormDialog.vue'
|
||||
export { default as ModelDetailDrawer } from './ModelDetailDrawer.vue'
|
||||
export { default as TieredPricingEditor } from './TieredPricingEditor.vue'
|
||||
export { default as ModelAliasesTab } from './ModelAliasesTab.vue'
|
||||
|
||||
@@ -367,17 +367,106 @@
|
||||
@edit-model="handleEditModel"
|
||||
@delete-model="handleDeleteModel"
|
||||
@batch-assign="handleBatchAssign"
|
||||
@add-mapping="handleAddMapping"
|
||||
/>
|
||||
|
||||
<!-- 模型名称映射 -->
|
||||
<ModelAliasesTab
|
||||
v-if="provider"
|
||||
ref="modelAliasesTabRef"
|
||||
:key="`aliases-${provider.id}`"
|
||||
:provider="provider"
|
||||
@refresh="handleRelatedDataRefresh"
|
||||
/>
|
||||
<!-- 别名映射预览 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
@@ -485,7 +574,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Key,
|
||||
ChevronRight,
|
||||
@@ -493,13 +581,9 @@ import {
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
X,
|
||||
Loader2,
|
||||
Power,
|
||||
GripVertical,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
Shield
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
@@ -508,12 +592,11 @@ import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||
import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
KeyAllowedModelsEditDialog,
|
||||
ModelsTab,
|
||||
ModelAliasesTab,
|
||||
BatchAssignModelsDialog
|
||||
} from '@/features/providers/components'
|
||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||
@@ -592,8 +675,83 @@ const deleteModelConfirmOpen = ref(false)
|
||||
const modelToDelete = ref<Model | null>(null)
|
||||
const batchAssignDialogOpen = ref(false)
|
||||
|
||||
// ModelAliasesTab 组件引用
|
||||
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | 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({
|
||||
@@ -625,9 +783,7 @@ const hasBlockingDialogOpen = computed(() =>
|
||||
deleteKeyConfirmOpen.value ||
|
||||
modelFormDialogOpen.value ||
|
||||
deleteModelConfirmOpen.value ||
|
||||
batchAssignDialogOpen.value ||
|
||||
// 检测 ModelAliasesTab 子组件的 Dialog 是否打开
|
||||
modelAliasesTabRef.value?.dialogOpen
|
||||
batchAssignDialogOpen.value
|
||||
)
|
||||
|
||||
// 所有密钥的扁平列表(带端点信息)
|
||||
@@ -665,6 +821,7 @@ watch(() => props.providerId, (newId) => {
|
||||
if (newId && props.open) {
|
||||
loadProvider()
|
||||
loadEndpoints()
|
||||
loadAliasMappingPreview()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -673,6 +830,7 @@ watch(() => props.open, (newOpen) => {
|
||||
if (newOpen && props.providerId) {
|
||||
loadProvider()
|
||||
loadEndpoints()
|
||||
loadAliasMappingPreview()
|
||||
} else if (!newOpen) {
|
||||
// 重置所有状态
|
||||
provider.value = null
|
||||
@@ -696,6 +854,10 @@ watch(() => props.open, (newOpen) => {
|
||||
|
||||
// 清除已显示的密钥(安全考虑)
|
||||
revealedKeys.value.clear()
|
||||
|
||||
// 重置别名映射预览
|
||||
aliasMappingPreview.value = null
|
||||
aliasExpandedIndex.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -722,11 +884,6 @@ function toggleEndpoint(endpointId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelatedDataRefresh() {
|
||||
await loadProvider()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 显示端点管理对话框
|
||||
function showAddEndpointDialog() {
|
||||
endpointDialogOpen.value = true
|
||||
@@ -962,11 +1119,6 @@ function handleBatchAssign() {
|
||||
batchAssignDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理添加映射(从 ModelsTab 触发)
|
||||
function handleAddMapping(model: Model) {
|
||||
modelAliasesTabRef.value?.openAddDialogForModel(model.id)
|
||||
}
|
||||
|
||||
// 处理批量关联完成
|
||||
async function handleBatchAssignChanged() {
|
||||
await loadProvider()
|
||||
@@ -1375,6 +1527,25 @@ 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) {
|
||||
|
||||
@@ -8,7 +8,5 @@ export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vu
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
export { default as ModelAliasDialog } from './ModelAliasDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ModelAliasesTab } from './provider-tabs/ModelAliasesTab.vue'
|
||||
|
||||
@@ -117,15 +117,6 @@
|
||||
</td>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="添加映射"
|
||||
@click="addMapping(model)"
|
||||
>
|
||||
<Link class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -179,7 +170,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Box, Edit, Trash2, Layers, Power, Copy, Link } from 'lucide-vue-next'
|
||||
import { Box, Edit, Trash2, Layers, Power, Copy } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -195,7 +186,6 @@ const emit = defineEmits<{
|
||||
'editModel': [model: Model]
|
||||
'deleteModel': [model: Model]
|
||||
'batchAssign': []
|
||||
'addMapping': [model: Model]
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
@@ -315,11 +305,6 @@ function deleteModel(model: Model) {
|
||||
emit('deleteModel', model)
|
||||
}
|
||||
|
||||
// 添加映射
|
||||
function addMapping(model: Model) {
|
||||
emit('addMapping', model)
|
||||
}
|
||||
|
||||
// 打开批量关联对话框
|
||||
function openBatchAssignDialog() {
|
||||
emit('batchAssign')
|
||||
|
||||
Reference in New Issue
Block a user