mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 提取正则工具模块并清理废弃代码
前端: - 新增 model-mapping-regex.ts 工具模块,统一正则验证和 LRU 缓存 - ModelMappingsTab/RoutingTab 重构为使用 computed 缓存匹配结果 - 移除多个组件中未使用的变量和函数 - 添加 HTMLImageElement/HTMLIFrameElement 到 ESLint 全局类型 - 修复 vitest 需要 --experimental-require-module 的问题 后端: - 移除废弃的异步数据库支持 (get_async_db, AsyncSession) - 将 async_utils 从 database/ 迁移到 utils/ - 改进 database.py 类型标注 - 修复 email 模块的 aiosmtplib 可选导入类型问题
This commit is contained in:
@@ -338,7 +338,7 @@ onMounted(async () => {
|
||||
} else {
|
||||
authType.value = 'local'
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
|
||||
allowRegistration.value = false
|
||||
requireEmailVerification.value = false
|
||||
|
||||
@@ -470,7 +470,6 @@ import {
|
||||
BarChart3
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -506,7 +505,6 @@ const emit = defineEmits<{
|
||||
'linkProvider': [providerId: string]
|
||||
'linkProviders': [providerIds: string[]]
|
||||
}>()
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<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>
|
||||
<h4 class="text-sm font-semibold">
|
||||
映射规则
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
|
||||
</span>
|
||||
@@ -28,14 +30,20 @@
|
||||
:disabled="props.loading"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': props.loading }" />
|
||||
<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-if="localMappings.length > 0"
|
||||
class="divide-y"
|
||||
>
|
||||
<div
|
||||
v-for="(mapping, index) in localMappings"
|
||||
:key="index"
|
||||
@@ -53,29 +61,29 @@
|
||||
<Input
|
||||
v-model="localMappings[index]"
|
||||
placeholder="例如: claude-haiku-.*"
|
||||
:class="`font-mono text-sm ${mapping.trim() && !getMappingValidation(mapping).valid ? 'border-destructive' : ''}`"
|
||||
:class="`font-mono text-sm ${normalizedMappings[index] && !mappingValidations[index].valid ? 'border-destructive' : ''}`"
|
||||
@click.stop
|
||||
@input="markDirty"
|
||||
/>
|
||||
<!-- 验证错误提示 -->
|
||||
<div
|
||||
v-if="mapping.trim() && !getMappingValidation(mapping).valid"
|
||||
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>{{ getMappingValidation(mapping).error }}</span>
|
||||
<span>{{ mappingValidations[index].error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 匹配统计 -->
|
||||
<Badge
|
||||
v-if="getMappingValidation(mapping).valid && getMatchCount(mapping) > 0"
|
||||
v-if="mappingValidations[index].valid && mappingMatchCounts[index] > 0"
|
||||
variant="secondary"
|
||||
class="text-xs flex-shrink-0 h-6 leading-none"
|
||||
>
|
||||
{{ getMatchCount(mapping) }} 匹配
|
||||
{{ mappingMatchCounts[index] }} 匹配
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="mapping.trim() && getMappingValidation(mapping).valid"
|
||||
v-else-if="normalizedMappings[index] && mappingValidations[index].valid"
|
||||
variant="outline"
|
||||
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
|
||||
>
|
||||
@@ -92,8 +100,14 @@
|
||||
: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" />
|
||||
<Save
|
||||
v-if="!saving"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<RefreshCw
|
||||
v-else
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -113,20 +127,29 @@
|
||||
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">
|
||||
<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="getMatchedKeysGroupedByProvider(mapping).length === 0" class="text-center py-4">
|
||||
<div
|
||||
v-else-if="expandedGroups.length === 0"
|
||||
class="text-center py-4"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ mapping.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
||||
{{ normalizedMappings[index] ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<!-- 按提供商分组 -->
|
||||
<div
|
||||
v-for="group in getMatchedKeysGroupedByProvider(mapping)"
|
||||
v-for="group in expandedGroups"
|
||||
:key="group.providerId"
|
||||
class="bg-background rounded-md border overflow-hidden"
|
||||
>
|
||||
@@ -204,6 +227,14 @@ import { updateGlobalModel, getGlobalModel, getGlobalModelRoutingPreview } from
|
||||
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
|
||||
@@ -217,37 +248,6 @@ const emit = defineEmits<{
|
||||
linkProvider: [providerId: string]
|
||||
linkProviders: [providerIds: string[]] // 批量关联
|
||||
}>()
|
||||
// 安全限制常量(与后端保持一致)
|
||||
const MAX_MAPPINGS_PER_MODEL = 50
|
||||
const MAX_MAPPING_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()
|
||||
|
||||
@@ -258,54 +258,22 @@ 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 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)
|
||||
const regexCache = createLRURegexCache(REGEX_CACHE_MAX_SIZE)
|
||||
const matchCountCache = new Map<string, number>()
|
||||
|
||||
interface MatchedKeyForMapping {
|
||||
keyId: string
|
||||
@@ -323,109 +291,75 @@ interface ProviderGroup {
|
||||
isLinked: boolean // 是否已关联到当前模型
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证映射规则是否安全
|
||||
*/
|
||||
function validateMappingPattern(pattern: string): ValidationResult {
|
||||
if (!pattern || !pattern.trim()) {
|
||||
return { valid: false, error: '规则不能为空' }
|
||||
}
|
||||
|
||||
if (pattern.length > MAX_MAPPING_LENGTH) {
|
||||
return { valid: false, error: `规则过长 (最大 ${MAX_MAPPING_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 getMappingValidation(mapping: string): ValidationResult {
|
||||
if (!mapping.trim()) {
|
||||
return { valid: true } // 空值暂不报错,保存时过滤
|
||||
}
|
||||
return validateMappingPattern(mapping)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有验证错误
|
||||
*/
|
||||
const hasValidationErrors = computed(() => {
|
||||
return localMappings.value.some(mapping => {
|
||||
if (!mapping.trim()) return false
|
||||
return !validateMappingPattern(mapping).valid
|
||||
return mappingValidations.value.some((result, index) => {
|
||||
return normalizedMappings.value[index] !== '' && !result.valid
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 安全的正则匹配(带缓存和保护)
|
||||
*/
|
||||
function matchPattern(pattern: string, text: string): boolean {
|
||||
// 快速路径:精确匹配
|
||||
if (pattern.toLowerCase() === text.toLowerCase()) {
|
||||
return true
|
||||
function computeMatchCount(pattern: string): number {
|
||||
if (!routingData.value) return 0
|
||||
|
||||
const cached = matchCountCache.get(pattern)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (pattern.length > MAX_MAPPING_LENGTH) {
|
||||
return false
|
||||
const regex = getCompiledModelMappingRegex(pattern, regexCache)
|
||||
if (!regex) {
|
||||
matchCountCache.set(pattern, 0)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 危险模式检查
|
||||
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
|
||||
if (dangerous.test(pattern)) {
|
||||
return false
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 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
|
||||
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 || !mapping.trim()) return []
|
||||
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>()
|
||||
|
||||
@@ -435,9 +369,8 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
||||
|
||||
const matchedModels: string[] = []
|
||||
for (const allowedModel of keyItem.allowed_models) {
|
||||
if (matchPattern(mapping, allowedModel)) {
|
||||
matchedModels.push(allowedModel)
|
||||
}
|
||||
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
|
||||
if (regex.test(allowedModel)) matchedModels.push(allowedModel)
|
||||
}
|
||||
|
||||
if (matchedModels.length > 0) {
|
||||
@@ -488,15 +421,22 @@ function getMatchedKeysGroupedByProvider(mapping: string): ProviderGroup[] {
|
||||
return Array.from(providerMap.values())
|
||||
}
|
||||
|
||||
// 获取指定映射的匹配数量
|
||||
function getMatchCount(mapping: string): number {
|
||||
return getMatchedKeysForMapping(mapping).reduce((sum, item) => sum + item.matchedModels.length, 0)
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -530,11 +470,21 @@ async function removeMapping(index: number) {
|
||||
} 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)
|
||||
@@ -595,6 +545,7 @@ async function saveMappings() {
|
||||
async function loadMatchPreview() {
|
||||
// 清空正则缓存,确保使用最新数据
|
||||
regexCache.clear()
|
||||
matchCountCache.clear()
|
||||
loadingPreview.value = true
|
||||
try {
|
||||
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
|
||||
@@ -612,6 +563,7 @@ onMounted(() => {
|
||||
// 组件卸载时清理缓存,防止内存泄漏
|
||||
onUnmounted(() => {
|
||||
regexCache.clear()
|
||||
matchCountCache.clear()
|
||||
})
|
||||
|
||||
// 暴露刷新方法给父组件
|
||||
|
||||
@@ -607,6 +607,7 @@ import { API_FORMAT_ORDER } from '@/api/endpoints/types'
|
||||
import { recoverKeyHealth } from '@/api/endpoints/health'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCountdownTimer, getProbeCountdown } from '@/composables/useCountdownTimer'
|
||||
import { MAX_MODEL_NAME_LENGTH, createLRURegexCache, getCompiledModelMappingRegex } from '@/features/models/utils/model-mapping-regex'
|
||||
|
||||
const props = defineProps<{
|
||||
globalModelId: string
|
||||
@@ -626,6 +627,9 @@ const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const routingData = ref<ModelRoutingPreviewResponse | null>(null)
|
||||
const modelMappingRegexCache = createLRURegexCache(200)
|
||||
const keyMatchedModelsCache = new Map<string, string[]>()
|
||||
const compiledGlobalModelMappingRegexes = ref<RegExp[]>([])
|
||||
|
||||
// 是否为全局 Key 优先模式
|
||||
const isGlobalKeyMode = computed(() => routingData.value?.priority_mode === 'global_key')
|
||||
@@ -804,11 +808,24 @@ function toggleProviderInFormat(format: string, providerId: string, endpointId?:
|
||||
async function loadRoutingData() {
|
||||
if (!props.globalModelId) return
|
||||
|
||||
modelMappingRegexCache.clear()
|
||||
keyMatchedModelsCache.clear()
|
||||
compiledGlobalModelMappingRegexes.value = []
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
routingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
|
||||
const data = await getGlobalModelRoutingPreview(props.globalModelId)
|
||||
|
||||
const compiled: RegExp[] = []
|
||||
for (const pattern of data.global_model_mappings || []) {
|
||||
const regex = getCompiledModelMappingRegex(pattern, modelMappingRegexCache)
|
||||
if (regex) compiled.push(regex)
|
||||
}
|
||||
|
||||
routingData.value = data
|
||||
compiledGlobalModelMappingRegexes.value = compiled
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '加载失败'
|
||||
} finally {
|
||||
@@ -843,12 +860,24 @@ function hasModelMapping(provider: RoutingProviderInfo): boolean {
|
||||
// 获取 Key 的 allowed_models 中匹配当前 GlobalModel 的所有模型名
|
||||
// 逻辑:用 GlobalModel 的 model_mappings(正则模式)去匹配 Key 的 allowed_models 中的值
|
||||
function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
|
||||
const cached = keyMatchedModelsCache.get(key.id)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!key.allowed_models || key.allowed_models.length === 0) {
|
||||
keyMatchedModelsCache.set(key.id, [])
|
||||
return []
|
||||
}
|
||||
const globalModelName = routingData.value?.global_model_name
|
||||
const globalModelMappings = routingData.value?.global_model_mappings || []
|
||||
if (!globalModelName) {
|
||||
keyMatchedModelsCache.set(key.id, [])
|
||||
return []
|
||||
}
|
||||
|
||||
const patterns = compiledGlobalModelMappingRegexes.value
|
||||
if (patterns.length === 0) {
|
||||
keyMatchedModelsCache.set(key.id, [])
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -859,18 +888,17 @@ function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
|
||||
if (allowedModel === globalModelName) {
|
||||
continue
|
||||
}
|
||||
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
|
||||
|
||||
// 用 GlobalModel 的映射模式匹配白名单中的模型名
|
||||
for (const pattern of globalModelMappings) {
|
||||
try {
|
||||
if (new RegExp(`^${pattern}$`, 'i').test(allowedModel)) {
|
||||
matched.push(allowedModel)
|
||||
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
|
||||
}
|
||||
} catch {
|
||||
// 正则无效,跳过
|
||||
for (const regex of patterns) {
|
||||
if (regex.test(allowedModel)) {
|
||||
matched.push(allowedModel)
|
||||
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
|
||||
}
|
||||
}
|
||||
}
|
||||
keyMatchedModelsCache.set(key.id, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
|
||||
@@ -2,3 +2,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 ModelMappingsTab } from './ModelMappingsTab.vue'
|
||||
export { default as RoutingTab } from './RoutingTab.vue'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
MAX_MAPPING_LENGTH,
|
||||
MAX_MODEL_NAME_LENGTH,
|
||||
createLRURegexCache,
|
||||
safeTestModelMappingPattern,
|
||||
validateModelMappingPattern,
|
||||
} from '@/features/models/utils/model-mapping-regex'
|
||||
|
||||
describe('model-mapping-regex', () => {
|
||||
it('validateModelMappingPattern: rejects empty', () => {
|
||||
expect(validateModelMappingPattern('').valid).toBe(false)
|
||||
expect(validateModelMappingPattern(' ').valid).toBe(false)
|
||||
})
|
||||
|
||||
it('validateModelMappingPattern: rejects too long', () => {
|
||||
const tooLong = 'a'.repeat(MAX_MAPPING_LENGTH + 1)
|
||||
const result = validateModelMappingPattern(tooLong)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('validateModelMappingPattern: rejects potentially dangerous patterns', () => {
|
||||
const result = validateModelMappingPattern('(a+)+')
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('validateModelMappingPattern: accepts basic patterns', () => {
|
||||
expect(validateModelMappingPattern('claude-haiku-.*').valid).toBe(true)
|
||||
expect(validateModelMappingPattern('gpt-4o').valid).toBe(true)
|
||||
})
|
||||
|
||||
it('safeTestModelMappingPattern: matches case-insensitively and anchors', () => {
|
||||
const cache = createLRURegexCache(10)
|
||||
expect(safeTestModelMappingPattern('gpt-4o', 'GPT-4O', cache)).toBe(true)
|
||||
expect(safeTestModelMappingPattern('gpt-4o', 'gpt-4o-mini', cache)).toBe(false)
|
||||
})
|
||||
|
||||
it('safeTestModelMappingPattern: rejects overly long model names', () => {
|
||||
const cache = createLRURegexCache(10)
|
||||
const longName = 'a'.repeat(MAX_MODEL_NAME_LENGTH + 1)
|
||||
expect(safeTestModelMappingPattern('a.*', longName, cache)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
150
frontend/src/features/models/utils/model-mapping-regex.ts
Normal file
150
frontend/src/features/models/utils/model-mapping-regex.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export const MAX_MAPPINGS_PER_MODEL = 50
|
||||
export const MAX_MAPPING_LENGTH = 200
|
||||
export const MAX_MODEL_NAME_LENGTH = 200
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 危险的正则模式(可能导致 ReDoS)
|
||||
// 注意:后端使用 regex 库的 timeout 做强制保护;前端无法中断 JS 正则执行,只能做启发式拦截。
|
||||
const DANGEROUS_REGEX_PATTERNS: RegExp[] = [
|
||||
/\([^)]*[+*]\)[+*]/, // (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+)+ 非捕获组嵌套量词
|
||||
]
|
||||
|
||||
function isPotentiallyDangerousRegex(pattern: string): boolean {
|
||||
return DANGEROUS_REGEX_PATTERNS.some(re => re.test(pattern))
|
||||
}
|
||||
|
||||
export interface LRURegexCache {
|
||||
get: (key: string) => RegExp | null | undefined
|
||||
set: (key: string, value: RegExp | null) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function createLRURegexCache(maxSize: number): LRURegexCache {
|
||||
const cache = new Map<string, RegExp | null>()
|
||||
|
||||
return {
|
||||
get: (key: string) => {
|
||||
if (!cache.has(key)) return undefined
|
||||
const value = cache.get(key)!
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
return value
|
||||
},
|
||||
set: (key: string, value: RegExp | null) => {
|
||||
if (cache.has(key)) {
|
||||
cache.delete(key)
|
||||
} else if (cache.size >= maxSize) {
|
||||
const firstKey = cache.keys().next().value as string | undefined
|
||||
if (firstKey !== undefined) {
|
||||
cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
cache.set(key, value)
|
||||
},
|
||||
clear: () => {
|
||||
cache.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateModelMappingPattern(pattern: string): ValidationResult {
|
||||
if (!pattern || !pattern.trim()) {
|
||||
return { valid: false, error: '规则不能为空' }
|
||||
}
|
||||
|
||||
if (pattern.length > MAX_MAPPING_LENGTH) {
|
||||
return { valid: false, error: `规则过长 (最大 ${MAX_MAPPING_LENGTH} 字符)` }
|
||||
}
|
||||
|
||||
if (isPotentiallyDangerousRegex(pattern)) {
|
||||
return { valid: false, error: '规则包含潜在危险的正则构造' }
|
||||
}
|
||||
|
||||
try {
|
||||
new RegExp(`^${pattern}$`, 'i')
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { valid: false, error: `正则表达式语法错误: ${message}` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function getCompiledModelMappingRegex(
|
||||
pattern: string,
|
||||
cache: LRURegexCache,
|
||||
): RegExp | null {
|
||||
const normalized = pattern.trim()
|
||||
if (!normalized) return null
|
||||
|
||||
if (normalized.length > MAX_MAPPING_LENGTH) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isPotentiallyDangerousRegex(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let regex = cache.get(normalized)
|
||||
if (regex === undefined) {
|
||||
try {
|
||||
regex = new RegExp(`^${normalized}$`, 'i')
|
||||
cache.set(normalized, regex)
|
||||
} catch {
|
||||
cache.set(normalized, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return regex
|
||||
}
|
||||
|
||||
export function safeTestModelMappingPattern(
|
||||
pattern: string,
|
||||
modelName: string,
|
||||
cache: LRURegexCache,
|
||||
): boolean {
|
||||
if (!pattern) return false
|
||||
|
||||
if (pattern.toLowerCase() === modelName.toLowerCase()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (pattern.length > MAX_MAPPING_LENGTH || modelName.length > MAX_MODEL_NAME_LENGTH) {
|
||||
return false
|
||||
}
|
||||
|
||||
const regex = getCompiledModelMappingRegex(pattern, cache)
|
||||
if (regex === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return regex.test(modelName)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,14 @@
|
||||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||
@click="toggleLock(model, $event)"
|
||||
>
|
||||
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
|
||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
||||
<Lock
|
||||
v-if="isLocked(model)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<LockOpen
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,8 +187,14 @@
|
||||
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||
@click="toggleLock(model.name, $event)"
|
||||
>
|
||||
<Lock v-if="isLocked(model.name)" class="w-3.5 h-3.5" />
|
||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
||||
<Lock
|
||||
v-if="isLocked(model.name)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<LockOpen
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,8 +255,14 @@
|
||||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||
@click="toggleLock(model, $event)"
|
||||
>
|
||||
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
|
||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
||||
<Lock
|
||||
v-if="isLocked(model)"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<LockOpen
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -600,7 +618,7 @@ async function loadGlobalModels() {
|
||||
name: m.name,
|
||||
display_name: m.display_name
|
||||
}))
|
||||
} catch (err) {
|
||||
} catch {
|
||||
if (loadingCancelled) return
|
||||
showError('加载全局模型失败', '错误')
|
||||
} finally {
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
|
||||
import { Key, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -259,10 +259,7 @@ function handleDrop(targetIndex: number) {
|
||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||
let currentPriority = 1
|
||||
|
||||
// 找到被拖动项在原数组中的索引对应的原始优先级
|
||||
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
|
||||
|
||||
items.forEach((alias, newIdx) => {
|
||||
items.forEach(alias => {
|
||||
// 找到这个映射在原数组中的索引
|
||||
const originalIdx = aliases.value.findIndex(a => a === alias)
|
||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||
|
||||
@@ -416,20 +416,6 @@
|
||||
@endpoint-updated="handleEndpointChanged"
|
||||
/>
|
||||
|
||||
<!-- 删除端点确认对话框 -->
|
||||
<AlertDialog
|
||||
v-if="open"
|
||||
:model-value="deleteEndpointConfirmOpen"
|
||||
title="删除端点"
|
||||
:description="`确定要删除端点 ${endpointToDelete?.api_format} 吗?这将同时删除其所有密钥。`"
|
||||
confirm-text="删除"
|
||||
cancel-text="取消"
|
||||
type="danger"
|
||||
@update:model-value="deleteEndpointConfirmOpen = $event"
|
||||
@confirm="confirmDeleteEndpoint"
|
||||
@cancel="deleteEndpointConfirmOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 密钥编辑对话框 -->
|
||||
<KeyFormDialog
|
||||
v-if="open"
|
||||
@@ -535,11 +521,9 @@ import EndpointFormDialog from '@/features/providers/components/EndpointFormDial
|
||||
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import {
|
||||
deleteEndpoint as deleteEndpointAPI,
|
||||
deleteEndpointKey,
|
||||
recoverKeyHealth,
|
||||
getProviderKeys,
|
||||
updateEndpoint,
|
||||
updateProviderKey,
|
||||
revealEndpointKey,
|
||||
type ProviderEndpoint,
|
||||
@@ -579,12 +563,9 @@ const loading = ref(false)
|
||||
const provider = ref<any>(null)
|
||||
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
||||
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
||||
const expandedEndpoints = ref<Set<string>>(new Set())
|
||||
|
||||
// 端点相关状态
|
||||
const endpointDialogOpen = ref(false)
|
||||
const deleteEndpointConfirmOpen = ref(false)
|
||||
const endpointToDelete = ref<ProviderEndpoint | null>(null)
|
||||
|
||||
// 密钥相关状态
|
||||
const keyFormDialogOpen = ref(false)
|
||||
@@ -593,13 +574,10 @@ const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const editingKey = ref<EndpointAPIKey | null>(null)
|
||||
const deleteKeyConfirmOpen = ref(false)
|
||||
const keyToDelete = ref<EndpointAPIKey | null>(null)
|
||||
const recoveringEndpointId = ref<string | null>(null)
|
||||
const togglingEndpointId = ref<string | null>(null)
|
||||
const togglingKeyId = ref<string | null>(null)
|
||||
|
||||
// 密钥显示状态:key_id -> 完整密钥
|
||||
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||
const revealingKeyId = ref<string | null>(null)
|
||||
|
||||
// 模型相关状态
|
||||
const modelFormDialogOpen = ref(false)
|
||||
@@ -609,14 +587,6 @@ const modelToDelete = ref<Model | null>(null)
|
||||
const batchAssignDialogOpen = ref(false)
|
||||
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
|
||||
|
||||
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
|
||||
const dragState = ref({
|
||||
isDragging: false,
|
||||
draggedKeyId: null as string | null,
|
||||
targetKeyId: null as string | null,
|
||||
dragEndpointId: null as string | null
|
||||
})
|
||||
|
||||
// 密钥列表拖拽排序状态
|
||||
const keyDragState = ref({
|
||||
isDragging: false,
|
||||
@@ -640,7 +610,6 @@ const multiplierSaving = ref(false)
|
||||
// 任意模态窗口打开时,阻止抽屉被误关闭
|
||||
const hasBlockingDialogOpen = computed(() =>
|
||||
endpointDialogOpen.value ||
|
||||
deleteEndpointConfirmOpen.value ||
|
||||
keyFormDialogOpen.value ||
|
||||
keyPermissionsDialogOpen.value ||
|
||||
deleteKeyConfirmOpen.value ||
|
||||
@@ -702,18 +671,15 @@ watch(() => props.open, (newOpen) => {
|
||||
provider.value = null
|
||||
endpoints.value = []
|
||||
providerKeys.value = [] // 清空 Provider 级别的 keys
|
||||
expandedEndpoints.value.clear()
|
||||
|
||||
// 重置所有对话框状态
|
||||
endpointDialogOpen.value = false
|
||||
deleteEndpointConfirmOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
keyPermissionsDialogOpen.value = false
|
||||
deleteKeyConfirmOpen.value = false
|
||||
batchAssignDialogOpen.value = false
|
||||
|
||||
// 重置临时数据
|
||||
endpointToDelete.value = null
|
||||
currentEndpoint.value = null
|
||||
editingKey.value = null
|
||||
keyToDelete.value = null
|
||||
@@ -737,15 +703,6 @@ function handleClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换端点展开/收起
|
||||
function toggleEndpoint(endpointId: string) {
|
||||
if (expandedEndpoints.value.has(endpointId)) {
|
||||
expandedEndpoints.value.delete(endpointId)
|
||||
} else {
|
||||
expandedEndpoints.value.add(endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示端点管理对话框
|
||||
function showAddEndpointDialog() {
|
||||
endpointDialogOpen.value = true
|
||||
@@ -757,27 +714,6 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
||||
endpointDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteEndpoint(endpoint: ProviderEndpoint) {
|
||||
endpointToDelete.value = endpoint
|
||||
deleteEndpointConfirmOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteEndpoint() {
|
||||
if (!endpointToDelete.value) return
|
||||
|
||||
try {
|
||||
await deleteEndpointAPI(endpointToDelete.value.id)
|
||||
showSuccess('端点已删除')
|
||||
await loadEndpoints()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '删除失败', '错误')
|
||||
} finally {
|
||||
deleteEndpointConfirmOpen.value = false
|
||||
endpointToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndpointChanged() {
|
||||
await Promise.all([loadProvider(), loadEndpoints()])
|
||||
emit('refresh')
|
||||
@@ -808,37 +744,18 @@ function handleKeyPermissions(key: EndpointAPIKey) {
|
||||
keyPermissionsDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 切换密钥显示/隐藏
|
||||
async function toggleKeyReveal(key: EndpointAPIKey) {
|
||||
if (revealedKeys.value.has(key.id)) {
|
||||
// 已显示,隐藏它
|
||||
revealedKeys.value.delete(key.id)
|
||||
return
|
||||
}
|
||||
|
||||
// 未显示,调用 API 获取完整密钥
|
||||
revealingKeyId.value = key.id
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
revealedKeys.value.set(key.id, result.api_key)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||
} finally {
|
||||
revealingKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制完整密钥
|
||||
async function copyFullKey(key: EndpointAPIKey) {
|
||||
// 如果已经显示了,直接复制
|
||||
if (revealedKeys.value.has(key.id)) {
|
||||
copyToClipboard(revealedKeys.value.get(key.id)!)
|
||||
const cached = revealedKeys.value.get(key.id)
|
||||
if (cached) {
|
||||
copyToClipboard(cached)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则先获取再复制
|
||||
try {
|
||||
const result = await revealEndpointKey(key.id)
|
||||
revealedKeys.value.set(key.id, result.api_key)
|
||||
copyToClipboard(result.api_key)
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||
@@ -878,79 +795,11 @@ async function handleRecoverKey(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查端点是否有不健康的密钥
|
||||
function hasUnhealthyKeys(endpoint: ProviderEndpointWithKeys): boolean {
|
||||
if (!endpoint.keys || endpoint.keys.length === 0) return false
|
||||
return endpoint.keys.some(key =>
|
||||
key.circuit_breaker_open ||
|
||||
(key.health_score !== undefined && key.health_score < 1)
|
||||
)
|
||||
}
|
||||
|
||||
// 批量恢复端点下所有密钥的健康状态
|
||||
async function handleRecoverAllKeys(endpoint: ProviderEndpointWithKeys) {
|
||||
if (!endpoint.keys || endpoint.keys.length === 0) return
|
||||
|
||||
const keysToRecover = endpoint.keys.filter(key =>
|
||||
key.circuit_breaker_open ||
|
||||
(key.health_score !== undefined && key.health_score < 1)
|
||||
)
|
||||
|
||||
if (keysToRecover.length === 0) {
|
||||
showSuccess('所有密钥已处于健康状态')
|
||||
return
|
||||
}
|
||||
|
||||
recoveringEndpointId.value = endpoint.id
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
try {
|
||||
for (const key of keysToRecover) {
|
||||
try {
|
||||
await recoverKeyHealth(key.id)
|
||||
successCount++
|
||||
} catch {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showSuccess(`已恢复 ${successCount} 个密钥的健康状态`)
|
||||
} else {
|
||||
showSuccess(`恢复完成: ${successCount} 成功, ${failCount} 失败`)
|
||||
}
|
||||
|
||||
await loadEndpoints()
|
||||
emit('refresh')
|
||||
} finally {
|
||||
recoveringEndpointId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyChanged() {
|
||||
await loadEndpoints()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 切换端点启用状态
|
||||
async function toggleEndpointActive(endpoint: ProviderEndpointWithKeys) {
|
||||
if (togglingEndpointId.value) return
|
||||
|
||||
togglingEndpointId.value = endpoint.id
|
||||
try {
|
||||
const newStatus = !endpoint.is_active
|
||||
await updateEndpoint(endpoint.id, { is_active: newStatus })
|
||||
endpoint.is_active = newStatus
|
||||
showSuccess(newStatus ? '端点已启用' : '端点已停用')
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
togglingEndpointId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 切换密钥启用状态
|
||||
async function toggleKeyActive(key: EndpointAPIKey) {
|
||||
if (togglingKeyId.value) return
|
||||
@@ -1022,123 +871,6 @@ async function confirmDeleteModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 拖动排序处理 =====
|
||||
function handleDragStart(event: DragEvent, key: EndpointAPIKey, endpoint: ProviderEndpointWithKeys) {
|
||||
dragState.value.isDragging = true
|
||||
dragState.value.draggedKeyId = key.id
|
||||
dragState.value.dragEndpointId = endpoint.id
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragState.value.isDragging = false
|
||||
dragState.value.draggedKeyId = null
|
||||
dragState.value.targetKeyId = null
|
||||
dragState.value.dragEndpointId = null
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent, targetKey: EndpointAPIKey) {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
if (dragState.value.draggedKeyId !== targetKey.id) {
|
||||
dragState.value.targetKeyId = targetKey.id
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragState.value.targetKeyId = null
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent, targetKey: EndpointAPIKey, endpoint: ProviderEndpointWithKeys) {
|
||||
event.preventDefault()
|
||||
|
||||
const draggedKeyId = dragState.value.draggedKeyId
|
||||
if (!draggedKeyId || !endpoint.keys || draggedKeyId === targetKey.id) {
|
||||
handleDragEnd()
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许在同一端点内拖动
|
||||
if (dragState.value.dragEndpointId !== endpoint.id) {
|
||||
showError('不能跨端点拖动密钥')
|
||||
handleDragEnd()
|
||||
return
|
||||
}
|
||||
|
||||
const keys = [...endpoint.keys]
|
||||
const draggedIndex = keys.findIndex(k => k.id === draggedKeyId)
|
||||
const targetIndex = keys.findIndex(k => k.id === targetKey.id)
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
handleDragEnd()
|
||||
return
|
||||
}
|
||||
|
||||
// 记录原始优先级分组(排除被拖动的密钥)
|
||||
// key: 原始优先级值, value: 密钥ID数组
|
||||
const originalGroups = new Map<number, string[]>()
|
||||
for (const key of keys) {
|
||||
if (key.id === draggedKeyId) continue // 被拖动的密钥离开原组
|
||||
const priority = key.internal_priority ?? 0
|
||||
if (!originalGroups.has(priority)) {
|
||||
originalGroups.set(priority, [])
|
||||
}
|
||||
originalGroups.get(priority)!.push(key.id)
|
||||
}
|
||||
|
||||
// 重排数组
|
||||
const [removed] = keys.splice(draggedIndex, 1)
|
||||
keys.splice(targetIndex, 0, removed)
|
||||
endpoint.keys = keys
|
||||
|
||||
// 按新顺序为每个组分配新的优先级
|
||||
// 同组的密钥保持相同的优先级
|
||||
const priorities: { key_id: string; internal_priority: number }[] = []
|
||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||
let currentPriority = 0
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.id === draggedKeyId) {
|
||||
// 被拖动的密钥是独立的新组,获得当前优先级
|
||||
priorities.push({ key_id: key.id, internal_priority: currentPriority })
|
||||
currentPriority++
|
||||
} else {
|
||||
const originalPriority = key.internal_priority ?? 0
|
||||
|
||||
if (groupNewPriority.has(originalPriority)) {
|
||||
// 这个组已经分配过优先级,使用相同的值
|
||||
priorities.push({ key_id: key.id, internal_priority: groupNewPriority.get(originalPriority)! })
|
||||
} else {
|
||||
// 这个组第一次出现,分配新优先级
|
||||
groupNewPriority.set(originalPriority, currentPriority)
|
||||
priorities.push({ key_id: key.id, internal_priority: currentPriority })
|
||||
currentPriority++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDragEnd()
|
||||
|
||||
// 调用 API 批量更新(使用循环调用 updateProviderKey 替代已废弃的 batchUpdateKeyPriority)
|
||||
try {
|
||||
await Promise.all(
|
||||
priorities.map(p => updateProviderKey(p.key_id, { internal_priority: p.internal_priority }))
|
||||
)
|
||||
showSuccess('优先级已更新')
|
||||
// 重新加载以获取更新后的数据
|
||||
await loadEndpoints()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '更新优先级失败', '错误')
|
||||
// 回滚 - 重新加载
|
||||
await loadEndpoints()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 点击编辑优先级 =====
|
||||
function startEditPriority(key: EndpointAPIKey) {
|
||||
editingPriorityKey.value = key.id
|
||||
@@ -1386,25 +1118,6 @@ async function handleKeyDrop(event: DragEvent, targetIndex: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化探测时间
|
||||
function formatProbeTime(probeTime: string): string {
|
||||
if (!probeTime) return '-'
|
||||
const now = new Date()
|
||||
const probe = new Date(probeTime)
|
||||
const diffMs = probe.getTime() - now.getTime()
|
||||
|
||||
if (diffMs < 0) return '待探测'
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return `${diffDays}天后`
|
||||
if (diffHours > 0) return `${diffHours}小时后`
|
||||
if (diffMinutes > 0) return `${diffMinutes}分钟后`
|
||||
return '即将探测'
|
||||
}
|
||||
|
||||
// 获取密钥的 API 格式列表(按指定顺序排序)
|
||||
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
|
||||
let formats: string[] = []
|
||||
@@ -1491,7 +1204,7 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
|
||||
const now = new Date()
|
||||
const diffMs = nextProbe.getTime() - now.getTime()
|
||||
if (diffMs > 0) {
|
||||
return ' ' + formatCountdown(diffMs)
|
||||
return ` ${formatCountdown(diffMs)}`
|
||||
} else {
|
||||
return ' 探测中'
|
||||
}
|
||||
|
||||
@@ -129,8 +129,14 @@
|
||||
:disabled="testingModelId === model.id"
|
||||
@click="handleTestClick(model)"
|
||||
>
|
||||
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Play v-else class="w-3.5 h-3.5" />
|
||||
<Loader2
|
||||
v-if="testingModelId === model.id"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
v-else
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</Button>
|
||||
<!-- 格式选择下拉菜单 -->
|
||||
<div
|
||||
|
||||
@@ -479,7 +479,6 @@ const filters = ref({
|
||||
})
|
||||
|
||||
const filtersDaysString = ref('7')
|
||||
const filtersLimitString = ref('50')
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
@@ -808,7 +808,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
|
||||
@@ -868,11 +868,6 @@ function formatNumber(value?: number | null): string {
|
||||
return numericValue.toLocaleString()
|
||||
}
|
||||
|
||||
function formatCurrency(value?: number | null, fractionDigits = 4): string {
|
||||
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
return numericValue.toFixed(fractionDigits)
|
||||
}
|
||||
|
||||
async function toggleUserStatus(user: any) {
|
||||
const action = user.is_active ? '禁用' : '启用'
|
||||
const confirmed = await confirmDanger(
|
||||
|
||||
@@ -370,7 +370,7 @@ import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
|
||||
@@ -351,7 +351,6 @@ import {
|
||||
Image as ImageIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -375,7 +374,6 @@ const emit = defineEmits<{
|
||||
'toggleCapability': [modelName: string, capName: string]
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
|
||||
Reference in New Issue
Block a user