Files
map-client-vue/web/src/components/ModelService.vue
2025-11-01 17:57:04 +08:00

1244 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="model-service-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<h1>模型服务</h1>
<p>管理和配置 AI 模型服务连接</p>
</div>
<div class="header-actions">
<n-button type="primary" @click="showAddModal = true">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
添加服务
</n-button>
</div>
</div>
<!-- 服务列表 -->
<div class="services-content">
<div v-if="services.length === 0" class="empty-state">
<div class="empty-icon">
<n-icon :component="ServerIcon" size="48" />
</div>
<h3>暂无模型服务</h3>
<p>添加您的第一个模型服务开始使用</p>
<n-button type="primary" @click="showAddModal = true">添加服务</n-button>
</div>
<div v-else class="services-grid">
<n-card
v-for="service in services"
:key="service.id"
class="service-card"
:class="{ active: service.status === 'connected' }"
>
<template #header>
<div class="service-header">
<div class="service-info">
<div class="service-title">
<span class="service-name">{{ service.name }}</span>
<n-tag
:type="service.status === 'connected' ? 'success' :
service.status === 'connecting' ? 'warning' : 'error'"
size="small"
>
{{ getStatusText(service.status) }}
</n-tag>
</div>
<div class="service-url">{{ service.url }}</div>
</div>
<div class="service-actions">
<n-button
size="small"
:type="service.status === 'connected' ? 'warning' : 'primary'"
:loading="service.status === 'connecting'"
@click="toggleConnection(service)"
>
{{ service.status === 'connected' ? '断开' : '连接' }}
</n-button>
<n-dropdown :options="getServiceActions(service)" @select="handleServiceAction">
<n-button size="small" quaternary>
<n-icon :component="DotsVerticalIcon" />
</n-button>
</n-dropdown>
</div>
</div>
</template>
<div class="service-details">
<div class="detail-row">
<span class="detail-label">服务类型:</span>
<span class="detail-value">{{ service.type }}</span>
</div>
<div class="detail-row">
<span class="detail-label">API Key:</span>
<span class="detail-value">{{ maskApiKey(service.apiKey) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">模型数量:</span>
<span class="detail-value">{{ service.models?.length || 0 }}</span>
</div>
<div v-if="service.lastUsed" class="detail-row">
<span class="detail-label">上次使用:</span>
<span class="detail-value">{{ formatDate(service.lastUsed) }}</span>
</div>
<div v-if="service.errorMessage" class="detail-row error">
<span class="detail-label">错误信息:</span>
<span class="detail-value">{{ service.errorMessage }}</span>
</div>
</div>
<!-- 模型列表 -->
<div v-if="service.models && service.models.length > 0" class="models-section">
<div class="models-header">
<span>可用模型</span>
</div>
<div class="models-list">
<n-tag
v-for="model in service.models.slice(0, 3)"
:key="model"
size="small"
class="model-tag"
>
{{ model }}
</n-tag>
<n-tag v-if="service.models.length > 3" size="small" type="info">
+{{ service.models.length - 3 }}
</n-tag>
</div>
</div>
</n-card>
</div>
</div>
<!-- 添加/编辑服务模态框 -->
<n-modal v-model:show="showAddModal" class="service-modal">
<n-card
style="width: 600px"
:title="editingService ? '编辑服务' : '添加服务'"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra>
<n-button quaternary @click="showAddModal = false">
<n-icon :component="XIcon" />
</n-button>
</template>
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="top"
>
<n-form-item label="服务名称" path="name">
<n-input v-model:value="formData.name" placeholder="输入服务名称" />
</n-form-item>
<n-form-item label="服务类型" path="type">
<n-select
v-model:value="formData.type"
:options="serviceTypes"
placeholder="选择服务类型"
@update:value="onServiceTypeChange"
/>
</n-form-item>
<n-form-item label="服务地址" path="url">
<n-input v-model:value="formData.url" placeholder="输入API地址" />
</n-form-item>
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="formData.apiKey"
type="password"
show-password-on="mousedown"
placeholder="输入API密钥"
/>
</n-form-item>
<n-form-item v-if="formData.type === 'custom'" label="自定义配置">
<n-input
v-model:value="formData.customConfig"
type="textarea"
:rows="4"
placeholder='{"headers": {}, "timeout": 30000}'
/>
</n-form-item>
</n-form>
<template #footer>
<div class="modal-actions">
<n-button @click="showAddModal = false">取消</n-button>
<n-button type="primary" @click="handleSaveService" :loading="saving">
{{ editingService ? '保存' : '添加' }}
</n-button>
</div>
</template>
</n-card>
</n-modal>
<!-- 测试连接模态框 -->
<n-modal v-model:show="showTestModal">
<n-card style="width: 600px" title="测试连接" :bordered="false" size="huge">
<div class="test-result">
<div v-if="testResult.status === 'testing'" class="test-loading">
<n-spin size="large" />
<p>正在测试连接...</p>
</div>
<div v-else-if="testResult.status === 'success'" class="test-success">
<n-icon :component="CheckIcon" size="48" color="#18a058" />
<h3>连接成功</h3>
<p>服务响应正常已获取到 {{ testResult.modelCount }} 个可用模型</p>
<div v-if="testResult.models.length > 0" class="models-preview">
<h4>可用模型:</h4>
<div class="model-tags">
<n-tag
v-for="model in testResult.models.slice(0, 5)"
:key="model"
size="small"
class="model-tag"
>
{{ model }}
</n-tag>
<n-tag v-if="testResult.models.length > 5" size="small" type="info">
+{{ testResult.models.length - 5 }}
</n-tag>
</div>
</div>
</div>
<div v-else-if="testResult.status === 'error'" class="test-error">
<n-icon :component="XIcon" size="48" color="#d03050" />
<h3>连接失败</h3>
<p>{{ testResult.error }}</p>
<div class="error-details">
<p><strong>可能的原因:</strong></p>
<ul>
<li>API密钥无效或已过期</li>
<li>服务地址不正确</li>
<li>网络连接问题或CORS限制</li>
<li>服务暂时不可用</li>
</ul>
</div>
</div>
</div>
<template #footer>
<n-button @click="showTestModal = false">关闭</n-button>
</template>
</n-card>
</n-modal>
<!-- 健康检测模态框 -->
<n-modal v-model:show="showHealthCheckModal">
<n-card style="width: 700px" title="模型健康检测" :bordered="false" size="huge">
<div class="health-check-content">
<div v-if="healthCheckResult.status === 'checking'" class="checking">
<n-spin size="large" />
<p>正在检测模型健康状态...</p>
<div class="progress-info">
<p>
<strong>当前进度:</strong>
{{ healthCheckResult.progress.current }} / {{ healthCheckResult.progress.total }}
</p>
<p><strong>当前模型:</strong> {{ healthCheckResult.progress.modelId }}</p>
</div>
<n-progress
type="line"
:percentage="healthCheckResult.progress.total > 0 ?
(healthCheckResult.progress.current / healthCheckResult.progress.total * 100) : 0"
:show-indicator="false"
/>
<div class="progress-details">
<span>{{ healthCheckResult.progress.current }}</span>
<span>{{ healthCheckResult.progress.total }}</span>
<span>{{ healthCheckResult.progress.current > 0 ? Math.round((healthCheckResult.progress.current / healthCheckResult.progress.total) * 100) : 0 }}%</span>
</div>
</div>
<div v-else-if="healthCheckResult.status === 'success'" class="check-success">
<n-icon :component="CheckIcon" size="48" color="#18a058" />
<h3>健康检测完成</h3>
<div class="summary-cards">
<div class="summary-card available">
<div class="card-icon"></div>
<div class="card-info">
<div class="card-number">{{ healthCheckResult.availableModels.length }}</div>
<div class="card-label">可用模型</div>
</div>
</div>
<div class="summary-card unavailable">
<div class="card-icon"></div>
<div class="card-info">
<div class="card-number">{{ healthCheckResult.unavailableModels.length }}</div>
<div class="card-label">不可用模型</div>
</div>
</div>
</div>
<!-- 详细结果 -->
<div class="detailed-results">
<div v-if="healthCheckResult.availableModels.length > 0" class="result-section">
<h4> 可用模型 ({{ healthCheckResult.availableModels.length }})</h4>
<div class="model-list">
<n-tag
v-for="result in healthCheckResult.results.filter(r => r.available)"
:key="result.modelId"
type="success"
size="small"
class="model-tag"
>
{{ result.modelId }}
<span v-if="result.latency" class="latency">{{ result.latency }}ms</span>
</n-tag>
</div>
</div>
<div v-if="healthCheckResult.unavailableModels.length > 0" class="result-section">
<h4> 不可用模型 ({{ healthCheckResult.unavailableModels.length }})</h4>
<div class="model-list">
<n-tag
v-for="result in healthCheckResult.results.filter(r => !r.available)"
:key="result.modelId"
type="error"
size="small"
class="model-tag"
>
{{ result.modelId }}
<span v-if="result.error" class="error-info">{{ result.error }}</span>
</n-tag>
</div>
</div>
</div>
<n-alert type="info" style="margin-top: 16px">
已自动更新服务配置,只保留可用模型
</n-alert>
</div>
</div>
<template #footer>
<n-button @click="showHealthCheckModal = false">关闭</n-button>
</template>
</n-card>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import {
NCard,
NButton,
NIcon,
NTag,
NModal,
NForm,
NFormItem,
NInput,
NInputGroup,
NAlert,
NProgress,
NSelect,
NDropdown,
NSpin,
useMessage
} from 'naive-ui'
import {
Plus as PlusIcon,
Server as ServerIcon,
DotsVertical as DotsVerticalIcon,
X as XIcon,
Check as CheckIcon
} from '@vicons/tabler'
import { modelServiceManager, type ModelService } from '../services/modelServiceManager'
const message = useMessage()
// 响应式数据
const services = ref<ModelService[]>([])
const showAddModal = ref(false)
const showTestModal = ref(false)
const editingService = ref<ModelService | null>(null)
const saving = ref(false)
// 表单数据
const formData = reactive({
name: '',
type: '',
url: '',
apiKey: '',
customConfig: ''
})
// 测试结果
const testResult = reactive({
status: 'idle' as 'idle' | 'testing' | 'success' | 'error',
error: '',
modelCount: 0,
models: [] as string[]
})
// 检测结果
const detectResult = reactive({
status: 'idle' as 'idle' | 'detecting' | 'success' | 'error',
error: '',
models: [] as string[]
})
// 健康检测状态
const showHealthCheckModal = ref(false)
const healthCheckResult = reactive({
status: 'idle' as 'idle' | 'checking' | 'success' | 'error',
currentService: null as ModelService | null,
progress: { current: 0, total: 0, modelId: '' },
availableModels: [] as string[],
unavailableModels: [] as string[],
results: [] as Array<{
modelId: string
available: boolean
latency?: number
error?: string
}>
})
// 服务类型选项
const serviceTypes = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'Claude', value: 'claude' },
{ label: 'Gemini', value: 'gemini' },
{ label: 'Azure OpenAI', value: 'azure' },
{ label: '阿里云 DashScope', value: 'dashscope' },
{ label: '火山引擎', value: 'volcengine' },
{ label: '本地模型', value: 'local' },
{ label: '自定义', value: 'custom' }
]
// 表单验证规则
const formRules = {
name: [
{ required: true, message: '请输入服务名称', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择服务类型', trigger: 'change' }
],
url: [
{ required: true, message: '请输入服务地址', trigger: 'blur' }
],
apiKey: [
{ required: true, message: '请输入API密钥', trigger: 'blur' }
]
}
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap = {
connected: '已连接',
disconnected: '未连接',
connecting: '连接中',
error: '连接错误'
}
return statusMap[status as keyof typeof statusMap] || status
}
// 掩码API Key
const maskApiKey = (apiKey: string) => {
if (!apiKey) return '-'
if (apiKey.length <= 8) return '*'.repeat(apiKey.length)
return apiKey.slice(0, 4) + '*'.repeat(apiKey.length - 8) + apiKey.slice(-4)
}
// 格式化日期
const formatDate = (date: Date) => {
const now = new Date()
const diff = Math.floor((now.getTime() - new Date(date).getTime()) / 1000)
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return `${Math.floor(diff / 86400)} 天前`
}
// 获取服务操作菜单
const getServiceActions = (service: ModelService) => [
{
label: '编辑',
key: `edit-${service.id}`
},
{
label: '测试连接',
key: `test-${service.id}`
},
{
label: '健康检测',
key: `health-${service.id}`,
disabled: !service.models || service.models.length === 0
},
{
label: '复制配置',
key: `copy-${service.id}`
},
{
label: '删除',
key: `delete-${service.id}`,
props: { style: { color: '#d03050' } }
}
]
// 处理服务操作
const handleServiceAction = (key: string) => {
const [action, id] = key.split('-')
const service = services.value.find(s => s.id === id)
if (!service) return
switch (action) {
case 'edit':
editService(service)
break
case 'test':
testConnection(service)
break
case 'health':
healthCheckModels(service)
break
case 'copy':
copyServiceConfig(service)
break
case 'delete':
deleteService(service)
break
}
}
// 编辑服务
const editService = (service: ModelService) => {
editingService.value = service
Object.assign(formData, {
name: service.name,
type: service.type,
url: service.url,
apiKey: service.apiKey,
customConfig: service.customConfig || ''
})
showAddModal.value = true
}
// 健康检测所有模型
const healthCheckModels = async (service: ModelService) => {
showHealthCheckModal.value = true
healthCheckResult.status = 'checking'
healthCheckResult.currentService = service
healthCheckResult.progress = { current: 0, total: 0, modelId: '' }
healthCheckResult.availableModels = []
healthCheckResult.unavailableModels = []
healthCheckResult.results = []
try {
console.log('🩺 开始健康检测:', service.name)
const result = await modelServiceManager.healthCheckAllModels(
service,
(current, total, modelId) => {
// 直接更新进度,不使用防抖
healthCheckResult.progress = { current, total, modelId }
}
)
healthCheckResult.status = 'success'
healthCheckResult.availableModels = result.availableModels
healthCheckResult.unavailableModels = result.unavailableModels
healthCheckResult.results = result.results
// 更新服务的模型列表为可用模型
service.models = result.availableModels
saveServices()
message.success(
`健康检测完成! 可用: ${result.availableModels.length}, 不可用: ${result.unavailableModels.length}`
)
console.log('✅ 健康检测完成:', result)
} catch (error) {
healthCheckResult.status = 'error'
const errorMsg = error instanceof Error ? error.message : '健康检测失败'
message.error(errorMsg)
console.error('❌ 健康检测失败:', error)
}
}
// 测试连接 - 使用真实API
const testConnection = async (service: ModelService) => {
showTestModal.value = true
testResult.status = 'testing'
testResult.error = ''
testResult.models = []
testResult.modelCount = 0
try {
console.log('🔍 测试连接:', service.name)
// 使用真实的API测试
const result = await modelServiceManager.testConnection(service)
if (result.success && result.data) {
testResult.status = 'success'
testResult.models = result.data.models
testResult.modelCount = result.data.models.length
// 更新服务状态
service.status = 'connected'
service.models = result.data.models
service.errorMessage = undefined
service.lastUsed = new Date()
console.log('✅ 连接测试成功:', result.data.models)
message.success(`${service.name} 连接测试成功,发现 ${testResult.modelCount} 个模型`)
} else {
throw new Error(result.error || '连接失败')
}
} catch (error) {
testResult.status = 'error'
testResult.error = error instanceof Error ? error.message : '未知错误'
// 更新服务状态
service.status = 'error'
service.errorMessage = testResult.error
service.models = []
console.error('❌ 连接测试失败:', testResult.error)
message.error(`${service.name} 连接测试失败: ${testResult.error}`)
}
// 保存更新后的服务状态
saveServices()
}
// 复制服务配置
const copyServiceConfig = (service: ModelService) => {
const config = {
name: service.name,
type: service.type,
url: service.url,
apiKey: '***masked***'
}
navigator.clipboard.writeText(JSON.stringify(config, null, 2))
.then(() => message.success('配置已复制到剪贴板'))
.catch(() => message.error('复制失败'))
}
// 删除服务
const deleteService = (service: ModelService) => {
const index = services.value.findIndex(s => s.id === service.id)
if (index !== -1) {
services.value.splice(index, 1)
modelServiceManager.removeService(service.id)
saveServices()
message.success(`已删除服务 ${service.name}`)
}
}
// 切换连接状态 - 使用真实连接
const toggleConnection = async (service: ModelService) => {
const serviceIndex = services.value.findIndex(s => s.id === service.id)
if (serviceIndex === -1) return
if (service.status === 'connected') {
// 断开连接
modelServiceManager.disconnectService(service.id)
services.value[serviceIndex] = {
...services.value[serviceIndex],
status: 'disconnected',
models: [],
errorMessage: undefined
}
saveServices()
message.info(`已断开 ${service.name}`)
} else {
// 连接服务 - 先更新为连接中状态
services.value[serviceIndex] = {
...services.value[serviceIndex],
status: 'connecting'
}
try {
await modelServiceManager.connectService(service.id)
// 从 manager 获取更新后的服务状态
const updatedService = modelServiceManager.getService(service.id)
if (updatedService) {
services.value[serviceIndex] = {
...services.value[serviceIndex],
status: updatedService.status,
models: updatedService.models,
errorMessage: updatedService.errorMessage,
lastUsed: updatedService.lastUsed
}
}
saveServices()
message.success(`${service.name} 连接成功`)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '连接失败'
services.value[serviceIndex] = {
...services.value[serviceIndex],
status: 'error',
errorMessage: errorMsg
}
saveServices()
message.error(`${service.name} 连接失败: ${errorMsg}`)
}
}
}
// 服务类型变化处理
const onServiceTypeChange = (type: string) => {
// 根据服务类型设置默认URL
const defaultUrls: Record<string, string> = {
openai: 'https://api.openai.com/v1',
claude: 'https://api.anthropic.com/v1',
gemini: 'https://generativelanguage.googleapis.com/v1',
azure: 'https://your-resource.openai.azure.com',
dashscope: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
volcengine: 'https://ark.cn-beijing.volces.com/api/v3',
local: 'http://localhost:1234/v1'
}
if (defaultUrls[type]) {
formData.url = defaultUrls[type]
}
}
// 保存服务
const handleSaveService = async () => {
saving.value = true
try {
const serviceData: ModelService = {
id: editingService.value?.id || generateId(),
name: formData.name,
type: formData.type as ModelService['type'],
url: formData.url,
apiKey: formData.apiKey,
status: 'disconnected',
customConfig: formData.customConfig
}
if (editingService.value) {
// 编辑现有服务
const index = services.value.findIndex(s => s.id === editingService.value!.id)
if (index !== -1) {
services.value[index] = { ...services.value[index], ...serviceData }
modelServiceManager.updateService(services.value[index])
}
message.success('服务已更新')
} else {
// 添加新服务
services.value.push(serviceData)
modelServiceManager.addService(serviceData)
message.success('服务已添加')
}
saveServices()
resetForm()
showAddModal.value = false
} catch (error) {
message.error('保存失败')
} finally {
saving.value = false
}
}
// 重置表单
const resetForm = () => {
Object.assign(formData, {
name: '',
type: '',
url: '',
apiKey: '',
customConfig: ''
})
editingService.value = null
}
// 生成ID
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 保存到本地存储
const saveServices = () => {
try {
// 使用统一的 key: model-providers (与 modelStore 保持一致)
localStorage.setItem('model-providers', JSON.stringify(services.value))
} catch (error) {
console.error('保存服务列表失败:', error)
}
}
// 从本地存储加载
const loadServices = () => {
try {
// 使用统一的 key: model-providers (与 modelStore 保持一致)
const saved = localStorage.getItem('model-providers')
if (saved) {
const loadedServices = JSON.parse(saved)
services.value = loadedServices
// 将服务添加到管理器中
loadedServices.forEach((service: ModelService) => {
modelServiceManager.addService(service)
})
}
} catch (error) {
console.error('加载服务列表失败:', error)
}
}
// 生命周期
onMounted(() => {
loadServices()
})
</script>
<style scoped>
.model-service-page {
padding: 32px;
background: #f8fafc;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.header-info h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1e293b;
}
.header-info p {
margin: 0;
color: #64748b;
font-size: 16px;
}
.services-content {
max-width: 1200px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-icon {
margin-bottom: 24px;
color: #cbd5e1;
}
.empty-state h3 {
margin: 0 0 8px 0;
font-size: 20px;
color: #475569;
}
.empty-state p {
margin: 0 0 24px 0;
color: #64748b;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 24px;
}
.service-card {
transition: all 0.2s ease;
}
.service-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.service-card.active {
border-color: #18a058;
}
.service-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.service-info {
flex: 1;
}
.service-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.service-name {
font-weight: 600;
color: #1e293b;
}
.service-url {
font-size: 14px;
color: #64748b;
font-family: monospace;
}
.service-actions {
display: flex;
gap: 8px;
}
.service-details {
margin-top: 16px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.detail-row.error .detail-value {
color: #d03050;
}
.detail-label {
font-size: 14px;
color: #64748b;
}
.detail-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
}
.models-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.models-header {
font-size: 14px;
color: #64748b;
margin-bottom: 8px;
}
.models-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.model-tag {
font-family: monospace;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.test-result {
text-align: center;
padding: 40px 20px;
}
.test-loading p,
.test-success p,
.test-error p {
margin: 16px 0 0 0;
color: #64748b;
}
.test-success h3,
.test-error h3 {
margin: 16px 0 0 0;
font-size: 18px;
}
.test-success h3 {
color: #18a058;
}
.test-error h3 {
color: #d03050;
}
.models-preview {
margin-top: 16px;
text-align: left;
}
.models-preview h4 {
margin: 0 0 8px 0;
font-size: 14px;
color: #64748b;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.error-details {
margin-top: 16px;
text-align: left;
}
.error-details p {
margin: 0 0 8px 0;
font-weight: 500;
}
.error-details ul {
margin: 0;
padding-left: 20px;
}
.error-details li {
margin-bottom: 4px;
color: #64748b;
}
/* 深色模式 */
[data-theme="dark"] .model-service-page {
background: #0f172a;
}
[data-theme="dark"] .header-info h1 {
color: #f8fafc;
}
[data-theme="dark"] .header-info p {
color: #94a3b8;
}
[data-theme="dark"] .empty-state h3 {
color: #e2e8f0;
}
[data-theme="dark"] .empty-state p {
color: #94a3b8;
}
[data-theme="dark"] .service-name {
color: #f8fafc;
}
[data-theme="dark"] .service-url {
color: #94a3b8;
}
[data-theme="dark"] .detail-label {
color: #94a3b8;
}
[data-theme="dark"] .detail-value {
color: #f8fafc;
}
[data-theme="dark"] .models-section {
border-top-color: #334155;
}
/* 检测结果样式 */
.detect-result {
margin-top: 12px;
}
.detected-models {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
/* 健康检测样式 */
.health-check-content {
min-height: 300px;
}
.checking,
.check-success {
text-align: center;
padding: 20px;
}
.checking p {
margin: 16px 0;
color: #64748b;
}
.progress-info {
margin: 20px 0;
text-align: left;
background: #f8f9fa;
padding: 16px;
border-radius: 6px;
}
.progress-info p {
margin: 8px 0;
font-size: 14px;
}
.progress-details {
display: flex;
justify-content: space-between;
margin-top: 8px;
padding: 8px 12px;
background: #f0f0f0;
border-radius: 4px;
font-size: 14px;
color: #333;
}
.progress-details span {
font-weight: 500;
}
.check-success h3 {
margin: 16px 0;
color: #18a058;
font-size: 20px;
}
.summary-cards {
display: flex;
gap: 16px;
justify-content: center;
margin: 24px 0;
}
.summary-card {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: #f8f9fa;
}
.summary-card.available {
border: 2px solid #18a058;
background: #f0f9ff;
}
.summary-card.unavailable {
border: 2px solid #d03050;
background: #fff5f5;
}
.card-icon {
font-size: 32px;
font-weight: bold;
}
.summary-card.available .card-icon {
color: #18a058;
}
.summary-card.unavailable .card-icon {
color: #d03050;
}
.card-info {
flex: 1;
}
.card-number {
font-size: 28px;
font-weight: bold;
line-height: 1;
}
.summary-card.available .card-number {
color: #18a058;
}
.summary-card.unavailable .card-number {
color: #d03050;
}
.card-label {
font-size: 14px;
color: #64748b;
margin-top: 4px;
}
.detailed-results {
margin-top: 24px;
text-align: left;
}
.result-section {
margin-bottom: 20px;
}
.result-section h4 {
margin: 0 0 12px 0;
font-size: 16px;
color: #1e293b;
}
.model-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.model-tag {
font-family: monospace;
display: flex;
align-items: center;
gap: 6px;
}
.latency {
font-size: 11px;
opacity: 0.7;
}
.error-info {
font-size: 11px;
opacity: 0.8;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-theme="dark"] .models-header {
color: #94a3b8;
}
</style>