feat(ui): 增加模型连接性测试功能并优化对话框 ESC 键处理

- 模型测试: 新增测试按钮,支持多 API 格式选择
- ESC 键: 重构为全局栈管理,支持嵌套对话框
- 修复: check_endpoint 使用 FORMAT_ID 替代 name
- 优化: Redis 连接池添加健康检查

Co-authored-by: htmambo <htmambo@users.noreply.github.com>
This commit is contained in:
fawney19
2026-01-13 20:08:47 +08:00
parent 6fdb944b1e
commit 8c4c5fa394
5 changed files with 196 additions and 31 deletions

View File

@@ -1,10 +1,42 @@
import { onMounted, onUnmounted, ref } from 'vue'
/**
* ESC 键监听 Composable简化版本直接使用独立监听器
* 全局对话框栈,用于管理嵌套对话框的 ESC 键处理顺序
* 栈顶的对话框优先处理 ESC 键
*/
const dialogStack = ref<Array<() => void | boolean>>([])
/**
* 全局 ESC 键监听器(只有一个)
*/
let globalListenerAttached = false
function globalEscapeHandler(event: KeyboardEvent) {
// 只处理 ESC 键
if (event.key !== 'Escape') return
// 从栈顶向栈底查找能处理 ESC 键的对话框
// 倒序遍历,先检查最后加入的(栈顶)
for (let i = dialogStack.value.length - 1; i >= 0; i--) {
const handler = dialogStack.value[i]
const handled = handler()
if (handled === true) {
// 该对话框已处理事件,阻止传播
event.stopPropagation()
event.stopImmediatePropagation()
return
}
}
}
/**
* ESC 键监听 Composable栈管理版本
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
*
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件,阻止其他监听器执行
* 支持嵌套对话框场景:只有最上层的对话框(最后打开的)会响应 ESC 键
*
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件
* @param options - 配置选项
*/
export function useEscapeKey(
@@ -18,13 +50,12 @@ export function useEscapeKey(
) {
const { disableOnInput = true, once = false } = options
const isActive = ref(true)
const isInStack = ref(false)
function handleKeyDown(event: KeyboardEvent) {
// 只处理 ESC 键
if (event.key !== 'Escape') return
// 包装原始回调,添加输入框检查
function wrappedCallback(): void | boolean {
// 检查组件是否还活跃
if (!isActive.value) return
if (!isActive.value) return false
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
if (disableOnInput) {
@@ -39,13 +70,15 @@ export function useEscapeKey(
)
// 如果焦点在输入框中,不处理 ESC 键
if (isInputElement) return
if (isInputElement) return false
}
// 执行回调,如果返回 true 则阻止其他监听器
// 执行原始回调
const handled = callback()
if (handled === true) {
event.stopImmediatePropagation()
// 如果只监听一次,则从栈中移除
if (once && handled === true) {
removeFromStack()
}
// 移除当前元素的焦点,避免残留样式
@@ -53,31 +86,51 @@ export function useEscapeKey(
document.activeElement.blur()
}
// 如果只监听一次,则移除监听器
if (once) {
removeEventListener()
return handled
}
function addToStack() {
if (isInStack.value) return
// 将当前处理器加入栈顶
dialogStack.value.push(wrappedCallback)
isInStack.value = true
// 确保全局监听器已添加
if (!globalListenerAttached) {
document.addEventListener('keydown', globalEscapeHandler, true) // 使用捕获阶段
globalListenerAttached = true
}
}
function addEventListener() {
document.addEventListener('keydown', handleKeyDown)
}
function removeFromStack() {
if (!isInStack.value) return
function removeEventListener() {
document.removeEventListener('keydown', handleKeyDown)
// 从栈中移除当前处理器
const index = dialogStack.value.indexOf(wrappedCallback)
if (index > -1) {
dialogStack.value.splice(index, 1)
}
isInStack.value = false
// 如果栈为空,移除全局监听器
if (dialogStack.value.length === 0 && globalListenerAttached) {
document.removeEventListener('keydown', globalEscapeHandler, true)
globalListenerAttached = false
}
}
onMounted(() => {
addEventListener()
addToStack()
})
onUnmounted(() => {
isActive.value = false
removeEventListener()
removeFromStack()
})
return {
addEventListener,
removeEventListener
addToStack,
removeFromStack
}
}

View File

@@ -374,6 +374,7 @@
v-if="provider"
:key="`models-${provider.id}`"
:provider="provider"
:endpoints="endpoints"
@edit-model="handleEditModel"
@delete-model="handleDeleteModel"
@batch-assign="handleBatchAssign"

View File

@@ -109,14 +109,42 @@
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/
</span>
</template>
<!-- 无计<EFBFBD><EFBFBD>配置 -->
<!-- 无计配置 -->
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
<span class="text-muted-foreground"></span>
</template>
</div>
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-center gap-1.5">
<div class="flex justify-center gap-1">
<!-- 测试按钮支持多格式选择 -->
<div class="relative">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
: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" />
</Button>
<!-- 格式选择下拉菜单 -->
<div
v-if="formatMenuModelId === model.id && availableApiFormats.length > 1"
class="absolute top-full left-0 mt-1 z-10 bg-popover border rounded-md shadow-md py-1 min-w-[120px]"
>
<button
v-for="fmt in availableApiFormats"
:key="fmt"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-muted transition-colors"
@click="testModelConnection(model, fmt)"
>
{{ fmt }}
</button>
</div>
</div>
<Button
variant="ghost"
size="icon"
@@ -169,17 +197,26 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Box, Edit, Trash2, Layers, Power, Copy } from 'lucide-vue-next'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Box, Edit, Trash2, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { getProviderModels, type Model } from '@/api/endpoints'
import { getProviderModels, type Model, testModel } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { parseTestModelError } from '@/utils/errorParser'
interface Endpoint {
id: string
api_format: string
is_active: boolean
active_keys?: number
}
const props = defineProps<{
provider: any
endpoints?: Endpoint[]
}>()
const emit = defineEmits<{
@@ -195,6 +232,16 @@ const { copyToClipboard } = useClipboard()
const loading = ref(false)
const models = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
const formatMenuModelId = ref<string | null>(null)
// 获取可用的 API 格式(有活跃端点且有活跃 Key
const availableApiFormats = computed(() => {
if (!props.endpoints) return []
return props.endpoints
.filter(ep => ep.is_active && (ep.active_keys ?? 0) > 0)
.map(ep => ep.api_format)
})
// 按名称排序的模型列表
const sortedModels = computed(() => {
@@ -295,7 +342,7 @@ function getStatusTitle(model: Model): string {
return '活跃但不可用'
}
// <EFBFBD><EFBFBD>辑模型
// 辑模型
function editModel(model: Model) {
emit('editModel', model)
}
@@ -327,7 +374,69 @@ async function toggleModelActive(model: Model) {
}
}
// 测试模型连接性
async function testModelConnection(model: Model, apiFormat?: string) {
if (testingModelId.value) return
testingModelId.value = model.id
formatMenuModelId.value = null
try {
const result = await testModel({
provider_id: props.provider.id,
model_name: model.provider_model_name,
message: "hello",
api_format: apiFormat
})
if (result.success) {
// 根据响应内容显示不同的成功消息
if (result.data?.response?.choices?.[0]?.message?.content) {
const content = result.data.response.choices[0].message.content
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
} else if (result.data?.content_preview) {
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
} else {
showSuccess(`模型 "${model.provider_model_name}" 测试成功`)
}
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModelId.value = null
}
}
// 处理测试按钮点击
function handleTestClick(model: Model) {
const formats = availableApiFormats.value
if (formats.length === 0) {
// 没有可用格式信息,使用默认行为
testModelConnection(model)
} else if (formats.length === 1) {
// 只有一种格式,直接测试
testModelConnection(model, formats[0])
} else {
// 多种格式,显示选择菜单
formatMenuModelId.value = formatMenuModelId.value === model.id ? null : model.id
}
}
// 点击外部关闭格式选择菜单
function handleClickOutside(event: MouseEvent) {
if (formatMenuModelId.value && !(event.target as Element).closest('.relative')) {
formatMenuModelId.value = null
}
}
onMounted(() => {
loadModels()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -641,7 +641,7 @@ class ChatAdapterBase(ApiAdapter):
url=url,
headers=headers,
json_body=body,
api_format=cls.name,
api_format=cls.FORMAT_ID,
# 用量计算参数(现在强制记录)
db=db,
user=user,

View File

@@ -172,6 +172,7 @@ class RedisClientManager:
max_connections=redis_max_conn,
decode_responses=True,
socket_connect_timeout=5.0,
health_check_interval=30, # 每 30 秒检查连接健康状态
)
safe_url = f"sentinel://{sentinel_service}"
else:
@@ -182,6 +183,7 @@ class RedisClientManager:
socket_timeout=5.0,
socket_connect_timeout=5.0,
max_connections=redis_max_conn,
health_check_interval=30, # 每 30 秒检查连接健康状态
)
safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url