1244 lines
31 KiB
Vue
1244 lines
31 KiB
Vue
<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>
|