feat(ASA-P5): 实现动态配置表单与测试连接组件 [AC-ASA-09, AC-ASA-10, AC-ASA-11, AC-ASA-12]

This commit is contained in:
MerCry 2026-02-24 23:31:36 +08:00
parent c1d76093aa
commit f2116b95f2
4 changed files with 583 additions and 1 deletions

View File

@ -0,0 +1,88 @@
import request from '@/utils/request'
export interface EmbeddingProviderInfo {
name: string
display_name: string
description?: string
config_schema: Record<string, any>
}
export interface EmbeddingConfig {
provider: string
config: Record<string, any>
updated_at?: string
}
export interface EmbeddingConfigUpdate {
provider: string
config?: Record<string, any>
}
export interface EmbeddingTestResult {
success: boolean
dimension: number
latency_ms?: number
message?: string
error?: string
}
export interface EmbeddingTestRequest {
test_text?: string
config?: EmbeddingConfigUpdate
}
export interface DocumentFormat {
extension: string
name: string
description?: string
}
export interface EmbeddingProvidersResponse {
providers: EmbeddingProviderInfo[]
}
export interface EmbeddingConfigUpdateResponse {
success: boolean
message: string
}
export interface SupportedFormatsResponse {
formats: DocumentFormat[]
}
export function getProviders() {
return request({
url: '/admin/embedding/providers',
method: 'get'
})
}
export function getConfig() {
return request({
url: '/admin/embedding/config',
method: 'get'
})
}
export function saveConfig(data: EmbeddingConfigUpdate) {
return request({
url: '/admin/embedding/config',
method: 'put',
data
})
}
export function testEmbedding(data: EmbeddingTestRequest): Promise<EmbeddingTestResult> {
return request({
url: '/admin/embedding/test',
method: 'post',
data
})
}
export function getSupportedFormats() {
return request({
url: '/admin/embedding/formats',
method: 'get'
})
}

View File

@ -0,0 +1,219 @@
<template>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-width="labelWidth"
v-bind="$attrs"
>
<el-form-item
v-for="(field, key) in schemaProperties"
:key="key"
:label="field.title || key"
:prop="key"
>
<template #label>
<span>{{ field.title || key }}</span>
<el-tooltip v-if="field.description" :content="field.description" placement="top">
<el-icon class="ml-1 cursor-help"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-input
v-if="field.type === 'string'"
v-model="formData[key]"
:placeholder="`请输入${field.title || key}`"
clearable
:show-password="isPasswordField(key)"
/>
<el-input-number
v-else-if="field.type === 'integer' || field.type === 'number'"
v-model="formData[key]"
:placeholder="`请输入${field.title || key}`"
:min="field.minimum"
:max="field.maximum"
:step="field.type === 'number' ? 0.1 : 1"
:precision="field.type === 'number' ? 2 : 0"
controls-position="right"
class="w-full"
/>
<el-switch
v-else-if="field.type === 'boolean'"
v-model="formData[key]"
/>
<el-select
v-else-if="field.enum && field.enum.length > 0"
v-model="formData[key]"
:placeholder="`请选择${field.title || key}`"
clearable
class="w-full"
>
<el-option
v-for="option in field.enum"
:key="option"
:label="option"
:value="option"
/>
</el-select>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
interface SchemaProperty {
type: string
title?: string
description?: string
default?: any
enum?: string[]
minimum?: number
maximum?: number
required?: boolean
}
interface ConfigSchema {
type?: string
properties?: Record<string, SchemaProperty>
required?: string[]
}
const props = defineProps<{
schema: ConfigSchema
modelValue: Record<string, any>
labelWidth?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
}>()
const formRef = ref<FormInstance>()
const formData = ref<Record<string, any>>({})
const schemaProperties = computed(() => {
return props.schema?.properties || {}
})
const requiredFields = computed(() => {
const required = props.schema?.required || []
const propsRequired = Object.entries(schemaProperties.value)
.filter(([, field]) => field.required)
.map(([key]) => key)
return [...new Set([...required, ...propsRequired])]
})
const formRules = computed<FormRules>(() => {
const rules: FormRules = {}
Object.entries(schemaProperties.value).forEach(([key, field]) => {
const fieldRules: any[] = []
if (requiredFields.value.includes(key)) {
fieldRules.push({
required: true,
message: `${field.title || key}不能为空`,
trigger: ['blur', 'change']
})
}
if (field.type === 'string' && field.minimum !== undefined) {
fieldRules.push({
min: field.minimum,
message: `${field.title || key}长度不能小于${field.minimum}`,
trigger: ['blur']
})
}
if (field.type === 'string' && field.maximum !== undefined) {
fieldRules.push({
max: field.maximum,
message: `${field.title || key}长度不能大于${field.maximum}`,
trigger: ['blur']
})
}
if (rules[key]) {
rules[key] = fieldRules
} else if (fieldRules.length > 0) {
rules[key] = fieldRules
}
})
return rules
})
const isPasswordField = (key: string): boolean => {
const lowerKey = key.toLowerCase()
return lowerKey.includes('password') || lowerKey.includes('secret') || lowerKey.includes('key') || lowerKey.includes('token')
}
const initFormData = () => {
const data: Record<string, any> = {}
Object.entries(schemaProperties.value).forEach(([key, field]) => {
if (props.modelValue && props.modelValue[key] !== undefined) {
data[key] = props.modelValue[key]
} else if (field.default !== undefined) {
data[key] = field.default
} else {
switch (field.type) {
case 'string':
data[key] = ''
break
case 'integer':
case 'number':
data[key] = field.minimum ?? 0
break
case 'boolean':
data[key] = false
break
default:
data[key] = null
}
}
})
formData.value = data
}
watch(
() => props.modelValue,
() => {
initFormData()
},
{ deep: true }
)
watch(
() => props.schema,
() => {
initFormData()
},
{ deep: true }
)
watch(
formData,
(val) => {
emit('update:modelValue', val)
},
{ deep: true }
)
onMounted(() => {
initFormData()
})
defineExpose({
validate: () => formRef.value?.validate(),
resetFields: () => formRef.value?.resetFields(),
clearValidate: () => formRef.value?.clearValidate()
})
</script>
<style scoped>
.w-full {
width: 100%;
}
.ml-1 {
margin-left: 4px;
}
.cursor-help {
cursor: help;
}
</style>

View File

@ -0,0 +1,230 @@
<template>
<el-card shadow="never" class="test-panel">
<template #header>
<div class="card-header">
<span>连接测试</span>
</div>
</template>
<div class="test-content">
<el-form :model="testForm" label-width="80px">
<el-form-item label="测试文本">
<el-input
v-model="testForm.test_text"
type="textarea"
:rows="3"
placeholder="请输入测试文本(可选,默认使用系统预设文本)"
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
:disabled="!config?.provider"
@click="handleTest"
>
<el-icon v-if="!loading"><Connection /></el-icon>
{{ loading ? '测试中...' : '测试连接' }}
</el-button>
</el-form-item>
</el-form>
<div v-if="testResult" class="test-result">
<el-divider />
<el-alert
v-if="testResult.success"
:title="testResult.message || '连接成功'"
type="success"
:closable="false"
show-icon
class="result-alert"
>
<template #default>
<div class="success-details">
<div class="detail-item">
<span class="label">向量维度</span>
<el-tag type="success">{{ testResult.dimension }}</el-tag>
</div>
<div v-if="testResult.latency_ms" class="detail-item">
<span class="label">响应延迟</span>
<el-tag type="info">{{ testResult.latency_ms.toFixed(2) }} ms</el-tag>
</div>
</div>
</template>
</el-alert>
<el-alert
v-else
:title="testResult.error || '连接失败'"
type="error"
:closable="false"
show-icon
class="result-alert"
>
<template #default>
<div class="error-details">
<p class="error-message">{{ testResult.error || '未知错误' }}</p>
<div class="troubleshooting">
<p class="troubleshoot-title">排查建议</p>
<ul class="troubleshoot-list">
<li v-for="(tip, index) in troubleshootingTips" :key="index">
{{ tip }}
</li>
</ul>
</div>
</div>
</template>
</el-alert>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Connection } from '@element-plus/icons-vue'
import { testEmbedding, type EmbeddingConfigUpdate, type EmbeddingTestResult } from '@/api/embedding'
const props = defineProps<{
config: EmbeddingConfigUpdate | null
}>()
const loading = ref(false)
const testResult = ref<EmbeddingTestResult | null>(null)
const testForm = ref({
test_text: ''
})
const troubleshootingTips = computed(() => {
const tips: string[] = []
const error = testResult.value?.error?.toLowerCase() || ''
if (error.includes('timeout') || error.includes('超时')) {
tips.push('检查网络连接是否正常')
tips.push('确认服务地址是否正确且可访问')
tips.push('尝试增加请求超时时间')
} else if (error.includes('auth') || error.includes('unauthorized') || error.includes('认证') || error.includes('api key')) {
tips.push('检查 API Key 是否正确')
tips.push('确认 API Key 是否已过期或被禁用')
tips.push('验证 API Key 是否具有足够的权限')
} else if (error.includes('connection') || error.includes('连接') || error.includes('refused')) {
tips.push('确认服务地址host/port配置正确')
tips.push('检查目标服务是否正在运行')
tips.push('验证防火墙是否允许访问')
} else if (error.includes('model') || error.includes('模型')) {
tips.push('确认模型名称是否正确')
tips.push('检查模型是否已部署或可用')
tips.push('验证模型配置参数是否符合要求')
} else {
tips.push('检查所有配置参数是否正确')
tips.push('确认服务是否正常运行')
tips.push('查看服务端日志获取详细错误信息')
}
return tips
})
const handleTest = async () => {
if (!props.config?.provider) {
return
}
loading.value = true
testResult.value = null
try {
const requestData: any = {
config: props.config
}
if (testForm.value.test_text?.trim()) {
requestData.test_text = testForm.value.test_text.trim()
}
const result = await testEmbedding(requestData)
testResult.value = result
} catch (error: any) {
testResult.value = {
success: false,
dimension: 0,
error: error?.message || '请求失败,请检查网络连接'
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.test-panel {
margin-top: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.test-content {
padding: 0 10px;
}
.test-result {
margin-top: 10px;
}
.result-alert {
margin-top: 10px;
}
.success-details {
margin-top: 12px;
}
.detail-item {
display: inline-flex;
align-items: center;
margin-right: 20px;
margin-bottom: 8px;
}
.detail-item .label {
color: #606266;
margin-right: 8px;
}
.error-details {
margin-top: 8px;
}
.error-message {
color: #f56c6c;
margin-bottom: 12px;
}
.troubleshooting {
background-color: #fef0f0;
padding: 12px;
border-radius: 4px;
margin-top: 8px;
}
.troubleshoot-title {
font-weight: 600;
color: #f56c6c;
margin-bottom: 8px;
}
.troubleshoot-list {
margin: 0;
padding-left: 20px;
color: #909399;
}
.troubleshoot-list li {
margin-bottom: 4px;
}
</style>

View File

@ -2,7 +2,7 @@
module: ai-service-admin
title: "AI 中台管理界面ai-service-admin任务清单"
status: "draft"
version: "0.1.0"
version: "0.2.0"
owners:
- "frontend"
- "backend"
@ -116,3 +116,48 @@ principles:
- `/admin/rag/experiments/run`POST 实验结果retrievalResults + finalPrompt
- `/admin/sessions`GET 列表,分页 + 筛选)
- `/admin/sessions/{sessionId}`GET 详情messages + trace
---
## Phase 5: 嵌入模型管理(配置页面/测试连接)
> 页面导向:嵌入模型配置页面,支持提供者切换、参数配置、连接测试。
- [ ] (P5-01) 嵌入模型配置页面骨架:创建 `/admin/embedding` 路由,布局包含提供者选择区、配置表单区、测试连接区、支持格式展示区。
- AC: [AC-ASA-08]
- [x] (P5-02) 提供者选择组件:实现 `EmbeddingProviderSelect` 下拉组件,对接 `/admin/embedding/providers`展示提供者列表name、display_name、description
- AC: [AC-ASA-09]
- [x] (P5-03) 动态配置表单:根据选中提供者的 `config_schema` 动态渲染配置表单(支持 string、integer、number 类型),实现表单校验。
- AC: [AC-ASA-09, AC-ASA-10]
- [ ] (P5-04) 当前配置加载:页面初始化时调用 `/admin/embedding/config` 获取当前配置,填充表单默认值。
- AC: [AC-ASA-08]
- [ ] (P5-05) 配置保存功能:实现保存按钮,调用 `PUT /admin/embedding/config`,处理成功/失败响应并提示用户。
- AC: [AC-ASA-10]
- [x] (P5-06) 测试连接功能:实现测试按钮,调用 `POST /admin/embedding/test`展示测试结果success、dimension、latency_ms、message
- AC: [AC-ASA-11]
- [x] (P5-07) 测试失败错误展示测试失败时展示详细错误信息error 字段),并提供排查建议。
- AC: [AC-ASA-12]
- [ ] (P5-08) 支持格式展示:调用 `/admin/embedding/formats` 获取支持的文档格式列表,以标签或卡片形式展示。
- AC: [AC-ASA-13]
---
## Phase 5 任务进度追踪
| 任务 | 描述 | 状态 |
|------|------|------|
| P5-01 | 嵌入模型配置页面骨架 | ⏳ 待处理 |
| P5-02 | 提供者选择组件 | ✅ 已完成 |
| P5-03 | 动态配置表单 | ✅ 已完成 |
| P5-04 | 当前配置加载 | ⏳ 待处理 |
| P5-05 | 配置保存功能 | ⏳ 待处理 |
| P5-06 | 测试连接功能 | ✅ 已完成 |
| P5-07 | 测试失败错误展示 | ✅ 已完成 |
| P5-08 | 支持格式展示 | ⏳ 待处理 |