2713 lines
106 KiB
Vue
2713 lines
106 KiB
Vue
<template>
|
||
<div class="initialization-content">
|
||
<!-- 顶部Ollama服务状态 -->
|
||
<div class="ollama-summary-card">
|
||
<div class="summary-header">
|
||
<span class="title"><t-icon name="server" />Ollama 服务状态</span>
|
||
<t-tag :theme="ollamaStatus.available ? 'success' : 'danger'" size="small" class="state">
|
||
{{ ollamaStatus.available ? `正常 (${ollamaStatus.version||'v?'} )` : (ollamaStatus.error || '未运行') }}
|
||
</t-tag>
|
||
<t-tooltip content="刷新状态">
|
||
<t-icon name="refresh" class="refresh-icon" :class="{ spinning: summaryRefreshing }" @click="refreshOllamaSummary" />
|
||
</t-tooltip>
|
||
</div>
|
||
<div class="summary-body">
|
||
<div class="models">
|
||
<span class="label">Ollama 服务地址</span>
|
||
<div class="model-list">
|
||
<t-tag size="small" theme="default" class="model-pill">{{ ollamaStatus.baseUrl || '未配置' }}</t-tag>
|
||
</div>
|
||
</div>
|
||
<div class="models">
|
||
<span class="label">已安装模型</span>
|
||
<div class="model-list">
|
||
<t-tag v-for="m in installedModels" :key="m" size="small" theme="default" class="model-pill">{{ m }}</t-tag>
|
||
<span v-if="installedModels.length===0" class="empty">暂无</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主配置表单 -->
|
||
<t-form ref="form" :data="formData" :rules="rules" @submit.prevent layout="vertical">
|
||
<!-- LLM 大语言模型配置区域 -->
|
||
<div class="config-section">
|
||
<h3><t-icon name="chat" class="section-icon" />LLM 大语言模型配置</h3>
|
||
<div class="form-row">
|
||
<t-form-item label="模型来源" name="llm.source">
|
||
<t-radio-group v-model="formData.llm.source" @change="onModelSourceChange('llm')">
|
||
<t-radio value="local">Ollama (本地)</t-radio>
|
||
<t-radio value="remote">Remote API (远程)</t-radio>
|
||
</t-radio-group>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<t-form-item label="模型名称" name="llm.modelName">
|
||
<div class="model-input-with-status">
|
||
<t-input v-model="formData.llm.modelName" placeholder="例如: qwen3:0.6b"
|
||
@blur="onModelNameChange('llm')"
|
||
@input="onModelNameInput('llm')"
|
||
@keyup.enter="onModelNameChange('llm')" />
|
||
<div class="model-status-icon">
|
||
<t-icon
|
||
v-if="formData.llm.source === 'local' && formData.llm.modelName && modelStatus.llm.checked"
|
||
:name="modelStatus.llm.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.llm.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.llm.available ? '已安装' : '未安装'"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.llm.source === 'local' && formData.llm.modelName && !modelStatus.llm.checked"
|
||
name="help-circle"
|
||
class="status-icon unknown"
|
||
title="未检查"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.llm.source === 'local' && formData.llm.modelName && modelStatus.llm.downloading"
|
||
name="loading"
|
||
class="status-icon downloading spinning"
|
||
title="下载中"
|
||
/>
|
||
</div>
|
||
<!-- 下载按钮:未安装时显示 -->
|
||
<div v-if="formData.llm.source === 'local' && formData.llm.modelName && modelStatus.llm.checked && !modelStatus.llm.available && !modelStatus.llm.downloading" class="download-action">
|
||
<t-tooltip content="下载模型">
|
||
<t-button
|
||
size="small"
|
||
theme="primary"
|
||
@click="downloadModel('llm', formData.llm.modelName)"
|
||
class="download-btn"
|
||
:disabled="modelStatus.llm.downloading"
|
||
>
|
||
<t-icon name="download" />
|
||
</t-button>
|
||
</t-tooltip>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
|
||
<!-- 下载进度:下载中时显示 -->
|
||
<div v-if="formData.llm.source === 'local' && formData.llm.modelName && modelStatus.llm.downloading" class="download-progress">
|
||
<div class="progress-info">
|
||
<t-icon name="loading" class="loading-icon spinning" />
|
||
<span class="progress-text">下载中</span>
|
||
</div>
|
||
<t-progress :percentage="Number(modelStatus.llm.progress.toFixed(1))" :show-info="false" size="small" class="progress-bar" />
|
||
<div class="progress-message">{{ modelStatus.llm.message }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 远程 API 配置区域 -->
|
||
<div v-if="formData.llm.source === 'remote'" class="remote-config">
|
||
<div class="form-row">
|
||
<t-form-item label="Base URL" name="llm.baseUrl">
|
||
<div class="url-input-with-check">
|
||
<t-input v-model="formData.llm.baseUrl" placeholder="例如: https://api.openai.com/v1, 去除末尾/chat/completions路径后的URL的前面部分"
|
||
@blur="onRemoteConfigChange('llm')"
|
||
@input="onRemoteConfigInput('llm')" />
|
||
<div v-if="formData.llm.modelName && formData.llm.baseUrl" class="check-action">
|
||
<t-tooltip v-if="!modelStatus.llm.available && modelStatus.llm.checked" content="重新检查连接">
|
||
<t-icon
|
||
name="refresh"
|
||
class="refresh-icon"
|
||
:class="{ spinning: modelStatus.llm.checking }"
|
||
@click="checkRemoteModelStatus('llm')"
|
||
/>
|
||
</t-tooltip>
|
||
<t-icon
|
||
v-else-if="modelStatus.llm.checked"
|
||
:name="modelStatus.llm.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.llm.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.llm.available ? '连接正常' : '连接失败'"
|
||
/>
|
||
<t-icon
|
||
v-else
|
||
name="loading"
|
||
class="status-icon checking spinning"
|
||
title="检查连接中"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item label="API Key (可选)" name="llm.apiKey">
|
||
<t-input v-model="formData.llm.apiKey" type="password" placeholder="请输入API Key (可选)"
|
||
@blur="onRemoteConfigChange('llm')"
|
||
@input="onRemoteConfigInput('llm')" />
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 错误信息显示 -->
|
||
<div v-if="modelStatus.llm.checked && !modelStatus.llm.available && modelStatus.llm.message" class="error-message">
|
||
<t-icon name="error-circle" />
|
||
<span>{{ modelStatus.llm.message }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Embedding 嵌入模型配置区域 -->
|
||
<div class="config-section">
|
||
<h3><t-icon name="layers" class="section-icon" />Embedding 嵌入模型配置</h3>
|
||
|
||
<!-- 已有文件时的禁用提示 -->
|
||
<div v-if="hasFiles" class="embedding-warning">
|
||
<t-alert theme="warning" message="知识库中已有文件,无法修改Embedding模型配置" />
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<t-form-item label="模型来源" name="embedding.source">
|
||
<t-radio-group v-model="formData.embedding.source" @change="onModelSourceChange('embedding')" :disabled="hasFiles">
|
||
<t-radio value="local">Ollama (本地)</t-radio>
|
||
<t-radio value="remote">Remote API (远程)</t-radio>
|
||
</t-radio-group>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<t-form-item label="模型名称" name="embedding.modelName">
|
||
<div class="model-input-with-status">
|
||
<t-input v-model="formData.embedding.modelName" placeholder="例如: nomic-embed-text:latest"
|
||
@blur="onModelNameChange('embedding')"
|
||
@input="onModelNameInput('embedding')"
|
||
@keyup.enter="onModelNameChange('embedding')"
|
||
:disabled="hasFiles" />
|
||
<div class="model-status-icon">
|
||
<t-icon
|
||
v-if="formData.embedding.source === 'local' && formData.embedding.modelName && modelStatus.embedding.checked"
|
||
:name="modelStatus.embedding.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.embedding.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.embedding.available ? '已安装' : '未安装'"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.embedding.source === 'local' && formData.embedding.modelName && !modelStatus.embedding.checked"
|
||
name="help-circle"
|
||
class="status-icon unknown"
|
||
title="未检查"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.embedding.source === 'local' && formData.embedding.modelName && modelStatus.embedding.downloading"
|
||
name="loading"
|
||
class="status-icon downloading spinning"
|
||
title="下载中"
|
||
/>
|
||
</div>
|
||
<!-- 下载按钮:未安装时显示 -->
|
||
<div v-if="formData.embedding.source === 'local' && formData.embedding.modelName && modelStatus.embedding.checked && !modelStatus.embedding.available && !modelStatus.embedding.downloading" class="download-action">
|
||
<t-tooltip content="下载模型">
|
||
<t-button
|
||
size="small"
|
||
theme="primary"
|
||
@click="downloadModel('embedding', formData.embedding.modelName)"
|
||
class="download-btn"
|
||
:disabled="modelStatus.embedding.downloading"
|
||
>
|
||
<t-icon name="download" />
|
||
</t-button>
|
||
</t-tooltip>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 向量维度设置 -->
|
||
<div class="form-row">
|
||
<t-form-item label="维度" name="embedding.dimension">
|
||
<div class="dimension-input-with-action">
|
||
<t-input v-model="formData.embedding.dimension"
|
||
:disabled="hasFiles"
|
||
placeholder="请输入向量维度"
|
||
style="width: 100px;"
|
||
@input="onDimensionInput" />
|
||
<t-button
|
||
size="small"
|
||
variant="outline"
|
||
class="detect-dim-btn"
|
||
:loading="embeddingDimDetecting"
|
||
:disabled="hasFiles"
|
||
@click="detectEmbeddingDimension"
|
||
>
|
||
检测维度
|
||
</t-button>
|
||
</div>
|
||
</t-form-item>
|
||
|
||
<!-- 下载进度:下载中时显示 -->
|
||
<div v-if="formData.embedding.source === 'local' && formData.embedding.modelName && modelStatus.embedding.downloading" class="download-progress">
|
||
<div class="progress-info">
|
||
<t-icon name="loading" class="loading-icon spinning" />
|
||
<span class="progress-text">下载中 {{ modelStatus.embedding.progress.toFixed(1) }}%</span>
|
||
</div>
|
||
<t-progress :percentage="Number(modelStatus.embedding.progress.toFixed(1))" :show-info="false" size="small" class="progress-bar" />
|
||
<div class="progress-message">{{ modelStatus.embedding.message }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 远程 Embedding API 配置 -->
|
||
<div v-if="formData.embedding.source === 'remote'" class="remote-config">
|
||
<div class="form-row">
|
||
<t-form-item label="Base URL" name="embedding.baseUrl">
|
||
<div class="url-input-with-check">
|
||
<t-input v-model="formData.embedding.baseUrl" placeholder="例如: https://api.openai.com/v1, 去除末尾/embeddings路径后的URL的前面部分"
|
||
:disabled="hasFiles" @blur="onRemoteConfigChange('embedding')"
|
||
@input="onRemoteConfigInput('embedding')" />
|
||
<div v-if="formData.embedding.modelName && formData.embedding.baseUrl && !hasFiles" class="check-action">
|
||
<t-tooltip v-if="!modelStatus.embedding.available && modelStatus.embedding.checked" content="重新检查连接">
|
||
<t-icon
|
||
name="refresh"
|
||
class="refresh-icon"
|
||
:class="{ spinning: modelStatus.embedding.checking }"
|
||
@click="checkEmbeddingModelStatus()"
|
||
/>
|
||
</t-tooltip>
|
||
<t-icon
|
||
v-else-if="modelStatus.embedding.checked"
|
||
:name="modelStatus.embedding.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.embedding.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.embedding.available ? '连接正常' : '连接失败'"
|
||
/>
|
||
<t-icon
|
||
v-else
|
||
name="loading"
|
||
class="input-icon checking spinning"
|
||
title="检查连接中"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item label="API Key (可选)" name="embedding.apiKey">
|
||
<t-input v-model="formData.embedding.apiKey" type="password" placeholder="请输入API Key (可选)"
|
||
:disabled="hasFiles" @blur="onRemoteConfigChange('embedding')"
|
||
@input="onRemoteConfigInput('embedding')" />
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 错误信息显示 -->
|
||
<div v-if="modelStatus.embedding.checked && !modelStatus.embedding.available && modelStatus.embedding.message" class="error-message">
|
||
<t-icon name="error-circle" />
|
||
<span>{{ modelStatus.embedding.message }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rerank 重排模型配置区域 -->
|
||
<div class="config-section">
|
||
<h3><t-icon name="swap" class="section-icon" />Rerank 重排模型配置</h3>
|
||
|
||
<div class="form-row">
|
||
<t-form-item name="rerank.enabled">
|
||
<div class="switch-container">
|
||
<t-switch v-model="formData.rerank.enabled" @change="onRerankChange" />
|
||
<span class="switch-label">启用Rerank重排模型</span>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- Rerank 详细配置 -->
|
||
<div v-if="formData.rerank.enabled" class="rerank-config">
|
||
<div class="form-row">
|
||
<t-form-item label="模型名称" name="rerank.modelName">
|
||
<div class="model-input-with-status">
|
||
<t-input v-model="formData.rerank.modelName" placeholder="例如: bge-reranker-v2-m3"
|
||
@blur="onRerankConfigChange"
|
||
@input="onRerankConfigInput" />
|
||
<div class="model-status-icon">
|
||
<t-icon
|
||
v-if="formData.rerank.modelName && modelStatus.rerank.checked"
|
||
:name="modelStatus.rerank.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.rerank.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.rerank.available ? '连接正常' : '连接失败'"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.rerank.modelName && !modelStatus.rerank.checked"
|
||
name="help-circle"
|
||
class="status-icon unknown"
|
||
title="未检查"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<t-form-item label="Base URL" name="rerank.baseUrl">
|
||
<div class="url-input-with-check">
|
||
<t-input v-model="formData.rerank.baseUrl" placeholder="例如: http://localhost:11434, 去除末尾/rerank路径后的URL的前面部分"
|
||
@blur="onRerankConfigChange"
|
||
@input="onRerankConfigInput" />
|
||
<div v-if="formData.rerank.modelName && formData.rerank.baseUrl" class="check-action">
|
||
<t-tooltip v-if="!modelStatus.rerank.available && modelStatus.rerank.checked" content="重新检查连接">
|
||
<t-icon
|
||
name="refresh"
|
||
class="refresh-icon"
|
||
:class="{ spinning: modelStatus.rerank.checking }"
|
||
@click="checkRerankModelStatus"
|
||
/>
|
||
</t-tooltip>
|
||
<t-icon
|
||
v-else-if="modelStatus.rerank.checked"
|
||
:name="modelStatus.rerank.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.rerank.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.rerank.available ? '连接正常' : '连接失败'"
|
||
/>
|
||
<t-icon
|
||
v-else
|
||
name="loading"
|
||
class="input-icon checking spinning"
|
||
title="检查连接中"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<t-form-item label="API Key" name="rerank.apiKey">
|
||
<t-input v-model="formData.rerank.apiKey" type="password" placeholder="请输入API Key (可选)"
|
||
@blur="onRerankConfigChange"
|
||
@input="onRerankConfigInput" />
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 错误信息显示 -->
|
||
<div v-if="modelStatus.rerank.checked && !modelStatus.rerank.available && modelStatus.rerank.message" class="error-message">
|
||
<t-icon name="error-circle" />
|
||
<span>{{ modelStatus.rerank.message }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 多模态配置区域 -->
|
||
<div class="config-section">
|
||
<h3><t-icon name="image" class="section-icon" />多模态配置</h3>
|
||
<div class="form-row">
|
||
<t-form-item name="multimodal.enabled">
|
||
<div class="switch-container">
|
||
<t-switch v-model="formData.multimodal.enabled" @change="onMultimodalChange" />
|
||
<span class="switch-label">启用多模态图片信息提取</span>
|
||
</div>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 多模态详细配置 -->
|
||
<div v-if="formData.multimodal.enabled" class="multimodal-config">
|
||
<!-- VLM 视觉语言模型配置 -->
|
||
<h4>视觉语言模型配置</h4>
|
||
<div class="form-row">
|
||
<t-form-item label="模型名称" name="multimodal.vlm.modelName">
|
||
<div class="model-input-with-status">
|
||
<t-input v-model="formData.multimodal.vlm.modelName" placeholder="例如: qwen2.5vl:3b"
|
||
@blur="onModelNameChange('vlm')"
|
||
@input="onModelNameInput('vlm')"
|
||
@keyup.enter="onModelNameChange('vlm')" />
|
||
<div class="model-status-icon">
|
||
<t-icon
|
||
v-if="formData.multimodal.vlm.interfaceType === 'ollama' && formData.multimodal.vlm.modelName && modelStatus.vlm.checked"
|
||
:name="modelStatus.vlm.available ? 'check-circle-filled' : 'close-circle-filled'"
|
||
:class="['status-icon', modelStatus.vlm.available ? 'installed' : 'not-installed']"
|
||
:title="modelStatus.vlm.available ? '已安装' : '未安装'"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.multimodal.vlm.interfaceType === 'ollama' && formData.multimodal.vlm.modelName && !modelStatus.vlm.checked"
|
||
name="help-circle"
|
||
class="status-icon unknown"
|
||
title="未检查"
|
||
/>
|
||
<t-icon
|
||
v-else-if="formData.multimodal.vlm.interfaceType === 'ollama' && formData.multimodal.vlm.modelName && modelStatus.vlm.downloading"
|
||
name="loading"
|
||
class="status-icon downloading spinning"
|
||
title="下载中"
|
||
/>
|
||
</div>
|
||
<!-- 下载按钮:未安装时显示 -->
|
||
<div v-if="formData.multimodal.vlm.interfaceType === 'ollama' && formData.multimodal.vlm.modelName && modelStatus.vlm.checked && !modelStatus.vlm.available && !modelStatus.vlm.downloading" class="download-action">
|
||
<t-tooltip content="下载模型">
|
||
<t-button
|
||
size="small"
|
||
theme="primary"
|
||
@click="downloadModel('vlm', formData.multimodal.vlm.modelName)"
|
||
class="download-btn"
|
||
:disabled="modelStatus.vlm.downloading"
|
||
>
|
||
<t-icon name="download" />
|
||
</t-button>
|
||
</t-tooltip>
|
||
</div>
|
||
</div>
|
||
</t-form-item>
|
||
|
||
<!-- 下载进度:下载中时显示 -->
|
||
<div v-if="formData.multimodal.vlm.interfaceType === 'ollama' && formData.multimodal.vlm.modelName && modelStatus.vlm.downloading" class="download-progress">
|
||
<div class="progress-info">
|
||
<t-icon name="loading" class="loading-icon spinning" />
|
||
<span class="progress-text">下载中 {{ modelStatus.vlm.progress.toFixed(1) }}%</span>
|
||
</div>
|
||
<t-progress :percentage="Number(modelStatus.vlm.progress.toFixed(1))" :show-info="false" size="small" class="progress-bar" />
|
||
<div class="progress-message">{{ modelStatus.vlm.message }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item label="接口类型" name="multimodal.vlm.interfaceType">
|
||
<t-radio-group v-model="formData.multimodal.vlm.interfaceType" @change="onVlmInterfaceTypeChange">
|
||
<t-radio value="ollama">Ollama (本地)</t-radio>
|
||
<t-radio value="openai">OpenAI 兼容接口</t-radio>
|
||
</t-radio-group>
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row" v-if="formData.multimodal.vlm.interfaceType === 'openai'">
|
||
<t-form-item label="Base URL" name="multimodal.vlm.baseUrl">
|
||
<t-input v-model="formData.multimodal.vlm.baseUrl" placeholder="例如: http://localhost:11434/v1,去除末尾/chat/completions路径后的URL的前面部分"
|
||
@blur="onVlmBaseUrlChange"
|
||
@input="onVlmBaseUrlInput" />
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row" v-if="formData.multimodal.vlm.interfaceType === 'openai'">
|
||
<t-form-item label="API Key" name="multimodal.vlm.apiKey">
|
||
<t-input v-model="formData.multimodal.vlm.apiKey" type="password" placeholder="请输入API Key (可选)"
|
||
@blur="onVlmApiKeyChange" />
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 对象存储服务配置 -->
|
||
<h4>对象存储服务配置</h4>
|
||
<div class="form-row">
|
||
<t-form-item label="存储类型">
|
||
<t-radio-group v-model="formData.multimodal.storageType" @change="onStorageTypeChange">
|
||
<t-radio value="cos">COS</t-radio>
|
||
<t-radio value="minio">MinIO</t-radio>
|
||
</t-radio-group>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- MinIO 配置区域 -->
|
||
<div v-if="formData.multimodal.storageType === 'minio'">
|
||
<div class="form-row">
|
||
<t-form-item label="Bucket Name" name="multimodal.minio.bucketName">
|
||
<t-input v-model="formData.multimodal.minio.bucketName" placeholder="请输入Bucket名称" />
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<t-form-item label="Path Prefix" name="multimodal.minio.pathPrefix">
|
||
<t-input v-model="formData.multimodal.minio.pathPrefix" placeholder="例如: images" />
|
||
</t-form-item>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- COS 配置区域 -->
|
||
<div class="form-row">
|
||
<t-form-item v-if="formData.multimodal.storageType === 'cos'" label="Secret ID" name="multimodal.cos.secretId">
|
||
<t-input v-model="formData.multimodal.cos.secretId" placeholder="请输入COS Secret ID"
|
||
@blur="onCosConfigChange" />
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item v-if="formData.multimodal.storageType === 'cos'" label="Secret Key" name="multimodal.cos.secretKey">
|
||
<t-input v-model="formData.multimodal.cos.secretKey" type="password" placeholder="请输入COS Secret Key"
|
||
@blur="onCosConfigChange" />
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item v-if="formData.multimodal.storageType === 'cos'" label="Region" name="multimodal.cos.region">
|
||
<t-input v-model="formData.multimodal.cos.region" placeholder="例如: ap-beijing"
|
||
@blur="onCosConfigChange" />
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item v-if="formData.multimodal.storageType === 'cos'" label="Bucket Name" name="multimodal.cos.bucketName">
|
||
<t-input v-model="formData.multimodal.cos.bucketName" placeholder="请输入Bucket名称"
|
||
@blur="onCosConfigChange" />
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item v-if="formData.multimodal.storageType === 'cos'" label="App ID" name="multimodal.cos.appId">
|
||
<t-input v-model="formData.multimodal.cos.appId" placeholder="请输入App ID"
|
||
@blur="onCosConfigChange" />
|
||
</t-form-item>
|
||
</div>
|
||
<div class="form-row">
|
||
<t-form-item v-if="formData.multimodal.storageType === 'cos'" label="Path Prefix" name="multimodal.cos.pathPrefix">
|
||
<t-input v-model="formData.multimodal.cos.pathPrefix" placeholder="例如: images"
|
||
@blur="onCosConfigChange" />
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 多模态功能测试区域 -->
|
||
<div v-if="canTestMultimodal" class="multimodal-test">
|
||
<h5>功能测试</h5>
|
||
<p class="test-desc">上传图片测试VLM模型的图片描述和文字识别功能</p>
|
||
|
||
<div class="test-area">
|
||
<!-- 上传区域 -->
|
||
<div class="upload-section">
|
||
<div class="upload-buttons">
|
||
<t-upload
|
||
ref="imageUpload"
|
||
v-model="multimodalTest.uploadedFiles"
|
||
:show-upload-list="false"
|
||
:auto-upload="false"
|
||
:accept="'image/*'"
|
||
:size-limit="10485760"
|
||
@change="onImageChange"
|
||
>
|
||
<t-button theme="default" variant="outline" size="small">
|
||
<t-icon name="upload" />
|
||
选择图片
|
||
</t-button>
|
||
</t-upload>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图片预览 -->
|
||
<div v-if="multimodalTest.selectedFile" class="image-preview">
|
||
<img :src="multimodalTest.previewUrl" alt="测试图片" />
|
||
<div class="image-meta">
|
||
<span class="file-name">{{ multimodalTest.selectedFile.name }}</span>
|
||
<span class="file-size">{{ formatFileSize(multimodalTest.selectedFile.size) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="test-button-wrapper">
|
||
<t-button
|
||
v-if="multimodalTest.selectedFile"
|
||
theme="primary"
|
||
size="small"
|
||
:loading="multimodalTest.testing"
|
||
@click="startMultimodalTest"
|
||
>
|
||
开始测试
|
||
</t-button>
|
||
</div>
|
||
<!-- 测试结果 -->
|
||
<div v-if="multimodalTest.result" class="test-result">
|
||
<div v-if="multimodalTest.result.success" class="result-success">
|
||
<h6>测试结果</h6>
|
||
|
||
<div v-if="multimodalTest.result.caption" class="result-item">
|
||
<label>图片描述:</label>
|
||
<div class="result-text">{{ multimodalTest.result.caption }}</div>
|
||
</div>
|
||
|
||
<div v-if="multimodalTest.result.ocr" class="result-item">
|
||
<label>文字识别:</label>
|
||
<div class="result-text">{{ multimodalTest.result.ocr }}</div>
|
||
</div>
|
||
|
||
<div v-if="multimodalTest.result.processing_time" class="result-time">
|
||
处理时间: {{ multimodalTest.result.processing_time }}ms
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="result-error">
|
||
<h6>测试失败</h6>
|
||
<div class="error-msg">
|
||
<t-icon name="error-circle" />
|
||
{{ multimodalTest.result.message || '多模态处理失败' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文档分割配置区域 -->
|
||
<div class="config-section">
|
||
<h3><t-icon name="cut" class="section-icon" />文档分割配置</h3>
|
||
|
||
<!-- 预设配置选择 -->
|
||
<div class="form-row preset-row">
|
||
<t-form-item label="分割策略">
|
||
<t-radio-group v-model="selectedPreset" @change="onPresetChange" class="preset-radio-group">
|
||
<t-radio value="balanced" class="preset-radio">
|
||
<div class="preset-content">
|
||
<div class="preset-title">均衡模式</div>
|
||
<div class="preset-desc">块大小: 1000 / 重叠: 200</div>
|
||
</div>
|
||
</t-radio>
|
||
<t-radio value="precision" class="preset-radio">
|
||
<div class="preset-content">
|
||
<div class="preset-title">精准模式</div>
|
||
<div class="preset-desc">块大小: 512 / 重叠: 100</div>
|
||
</div>
|
||
</t-radio>
|
||
<t-radio value="context" class="preset-radio">
|
||
<div class="preset-content">
|
||
<div class="preset-title">上下文模式</div>
|
||
<div class="preset-desc">块大小: 2048 / 重叠: 400</div>
|
||
</div>
|
||
</t-radio>
|
||
<t-radio value="custom" class="preset-radio">
|
||
<div class="preset-content">
|
||
<div class="preset-title">自定义</div>
|
||
<div class="preset-desc">手动配置参数</div>
|
||
</div>
|
||
</t-radio>
|
||
</t-radio-group>
|
||
</t-form-item>
|
||
</div>
|
||
|
||
<!-- 参数配置网格 -->
|
||
<div class="parameters-grid" :class="{ 'disabled-grid': selectedPreset !== 'custom' }">
|
||
<div class="parameter-group">
|
||
<div class="parameter-label">分块大小</div>
|
||
<div class="parameter-control">
|
||
<t-slider
|
||
v-model="formData.documentSplitting.chunkSize"
|
||
:min="100"
|
||
:max="4000"
|
||
:step="1"
|
||
:disabled="selectedPreset !== 'custom'"
|
||
:marks="{ 100: '100', 1000: '1000', 2000: '2000', 4000: '4000' }"
|
||
class="parameter-slider"
|
||
/>
|
||
<div class="parameter-value">{{ formData.documentSplitting.chunkSize }} 字符</div>
|
||
</div>
|
||
<div class="parameter-desc">控制每个文档分块的大小,影响检索精度</div>
|
||
</div>
|
||
|
||
<div class="parameter-group">
|
||
<div class="parameter-label">分块重叠</div>
|
||
<div class="parameter-control">
|
||
<t-slider
|
||
v-model="formData.documentSplitting.chunkOverlap"
|
||
:min="0"
|
||
:max="1000"
|
||
:step="1"
|
||
:disabled="selectedPreset !== 'custom'"
|
||
:marks="{ 0: '0', 200: '200', 500: '500', 1000: '1000' }"
|
||
class="parameter-slider"
|
||
/>
|
||
<div class="parameter-value">{{ formData.documentSplitting.chunkOverlap }} 字符</div>
|
||
</div>
|
||
<div class="parameter-desc">分块间重叠的字符数,保持上下文连贯性</div>
|
||
</div>
|
||
|
||
<div class="parameter-group">
|
||
<div class="parameter-label">分隔符设置</div>
|
||
<div class="parameter-control">
|
||
<t-select
|
||
v-model="formData.documentSplitting.separators"
|
||
multiple
|
||
:disabled="selectedPreset !== 'custom'"
|
||
placeholder="选择或自定义分隔符"
|
||
class="parameter-select"
|
||
clearable
|
||
creatable
|
||
:options="separatorOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提交按钮区域 -->
|
||
<div class="submit-section">
|
||
<t-button theme="primary" type="button" size="large"
|
||
:loading="submitting" :disabled="!canSubmit || isSubmitDebounced"
|
||
@click="handleSubmit">
|
||
{{ isUpdateMode ? '更新配置信息' : '完成配置' }}
|
||
</t-button>
|
||
|
||
<!-- 提交状态提示 -->
|
||
<div v-if="!canSubmit && hasOllamaModels" class="submit-tips">
|
||
<t-icon name="info-circle" class="tip-icon" />
|
||
<span>请等待所有Ollama模型下载完成后再进行配置更新</span>
|
||
</div>
|
||
|
||
<!-- 远程API配置提示 -->
|
||
<div v-if="!canSubmit && !hasOllamaModels" class="submit-tips">
|
||
<t-icon name="info-circle" class="tip-icon" />
|
||
<span>请完善模型配置信息</span>
|
||
</div>
|
||
</div>
|
||
</t-form>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* 导入必要的 Vue 组合式 API 和外部依赖
|
||
*/
|
||
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { MessagePlugin } from 'tdesign-vue-next';
|
||
import {
|
||
initializeSystem,
|
||
checkOllamaStatus,
|
||
checkOllamaModels,
|
||
downloadOllamaModel,
|
||
getDownloadProgress,
|
||
getCurrentConfig,
|
||
checkRemoteModel,
|
||
type DownloadTask,
|
||
checkRerankModel,
|
||
testMultimodalFunction,
|
||
listOllamaModels,
|
||
testEmbeddingModel
|
||
} from '@/api/initialization';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
|
||
const router = useRouter();
|
||
const authStore = useAuthStore();
|
||
type TFormRef = {
|
||
validate: (fields?: string[] | undefined) => Promise<true | any>;
|
||
clearValidate?: (fields?: string | string[]) => void;
|
||
} | null;
|
||
const form = ref<TFormRef>(null);
|
||
const submitting = ref(false);
|
||
const hasFiles = ref(false);
|
||
const isUpdateMode = ref(false); // 是否为更新模式
|
||
|
||
// 防抖机制:防止按钮快速重复点击
|
||
const submitDebounceTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||
const isSubmitDebounced = ref(false);
|
||
|
||
// Ollama服务状态
|
||
const ollamaStatus = reactive({
|
||
checked: false,
|
||
available: false,
|
||
version: '',
|
||
error: '',
|
||
baseUrl: ''
|
||
});
|
||
// 顶部摘要:已安装模型
|
||
const installedModels = ref<string[]>([]);
|
||
const summaryRefreshing = ref<boolean>(false);
|
||
|
||
// 下载任务管理
|
||
const downloadTasks = reactive<Record<string, DownloadTask>>({});
|
||
const progressTimers = reactive<Record<string, any>>({});
|
||
|
||
// 模型状态管理
|
||
const modelStatus = reactive({
|
||
llm: {
|
||
checked: false,
|
||
available: false,
|
||
downloading: false,
|
||
checking: false,
|
||
taskId: '',
|
||
progress: 0,
|
||
message: ''
|
||
},
|
||
embedding: {
|
||
checked: false,
|
||
available: false,
|
||
downloading: false,
|
||
checking: false,
|
||
taskId: '',
|
||
progress: 0,
|
||
message: ''
|
||
},
|
||
vlm: {
|
||
checked: false,
|
||
available: false,
|
||
downloading: false,
|
||
checking: false,
|
||
taskId: '',
|
||
progress: 0,
|
||
message: ''
|
||
},
|
||
rerank: {
|
||
checked: false,
|
||
available: false,
|
||
downloading: false,
|
||
checking: false,
|
||
taskId: '',
|
||
progress: 0,
|
||
message: ''
|
||
}
|
||
});
|
||
|
||
// 表单数据
|
||
const formData = reactive({
|
||
llm: {
|
||
source: 'local',
|
||
modelName: '',
|
||
baseUrl: '',
|
||
apiKey: ''
|
||
},
|
||
embedding: {
|
||
source: 'local',
|
||
modelName: '',
|
||
baseUrl: '',
|
||
apiKey: '',
|
||
dimension: 0 // 默认嵌入维度值
|
||
},
|
||
rerank: {
|
||
enabled: false,
|
||
modelName: '',
|
||
baseUrl: '',
|
||
apiKey: ''
|
||
},
|
||
multimodal: {
|
||
enabled: false,
|
||
storageType: 'minio' as 'minio' | 'cos',
|
||
vlm: {
|
||
modelName: '',
|
||
baseUrl: '',
|
||
apiKey: '',
|
||
interfaceType: 'ollama'
|
||
},
|
||
cos: {
|
||
secretId: '',
|
||
secretKey: '',
|
||
region: '',
|
||
bucketName: '',
|
||
appId: '',
|
||
pathPrefix: ''
|
||
},
|
||
minio: {
|
||
bucketName: '',
|
||
useSSL: false,
|
||
pathPrefix: ''
|
||
}
|
||
},
|
||
documentSplitting: {
|
||
chunkSize: 1000,
|
||
chunkOverlap: 200,
|
||
separators: ['\n\n', '\n', '。', '!', '?', ';', ';']
|
||
}
|
||
});
|
||
|
||
// 输入防抖定时器
|
||
const inputDebounceTimers = reactive<Record<string, any>>({});
|
||
|
||
// Embedding 维度检测状态
|
||
const embeddingDimDetecting = ref(false);
|
||
|
||
// 预设配置选择
|
||
const selectedPreset = ref('balanced');
|
||
|
||
// 分隔符选项
|
||
const separatorOptions = [
|
||
{ label: '段落分隔符 (\\n\\n)', value: '\n\n' },
|
||
{ label: '换行符 (\\n)', value: '\n' },
|
||
{ label: '句号 (。)', value: '。' },
|
||
{ label: '感叹号 (!)', value: '!' },
|
||
{ label: '问号 (?)', value: '?' },
|
||
{ label: '分号 (;)', value: ';' },
|
||
{ label: '中文分号 (;)', value: ';' },
|
||
{ label: '逗号 (,)', value: ',' },
|
||
{ label: '中文逗号 (,)', value: ',' }
|
||
];
|
||
|
||
// 多模态测试状态
|
||
const multimodalTest = reactive({
|
||
uploadedFiles: [],
|
||
selectedFile: null as File | null,
|
||
previewUrl: '',
|
||
testing: false,
|
||
result: null as any
|
||
});
|
||
|
||
// 计算属性
|
||
const isVlmOllama = computed(() => {
|
||
return formData.multimodal.vlm.interfaceType === 'ollama';
|
||
});
|
||
|
||
const hasOllamaModels = computed(() => {
|
||
// 检查是否真正需要Ollama模型
|
||
const needsLlmOllama = formData.llm.source === 'local' && formData.llm.modelName;
|
||
const needsEmbeddingOllama = formData.embedding.source === 'local' && formData.embedding.modelName;
|
||
const needsVlmOllama = formData.multimodal.enabled &&
|
||
formData.multimodal.vlm.interfaceType === 'ollama' &&
|
||
formData.multimodal.vlm.modelName;
|
||
|
||
return needsLlmOllama || needsEmbeddingOllama || needsVlmOllama;
|
||
});
|
||
|
||
const canTestMultimodal = computed(() => {
|
||
// 检查VLM模型配置是否完整
|
||
const vlmConfigComplete = formData.multimodal.vlm.modelName &&
|
||
(formData.multimodal.vlm.interfaceType === 'ollama' ||
|
||
(formData.multimodal.vlm.interfaceType === 'openai' && formData.multimodal.vlm.baseUrl));
|
||
|
||
// 检查存储配置是否完整
|
||
let storageConfigComplete = false;
|
||
if (formData.multimodal.storageType === 'cos') {
|
||
storageConfigComplete = !!(formData.multimodal.cos.secretId &&
|
||
formData.multimodal.cos.secretKey &&
|
||
formData.multimodal.cos.region &&
|
||
formData.multimodal.cos.bucketName &&
|
||
formData.multimodal.cos.appId);
|
||
} else if (formData.multimodal.storageType === 'minio') {
|
||
storageConfigComplete = !!formData.multimodal.minio.bucketName;
|
||
}
|
||
|
||
return vlmConfigComplete && storageConfigComplete;
|
||
});
|
||
|
||
const canSubmit = computed(() => {
|
||
// 基本必填项检查
|
||
const basicRequired = formData.llm.modelName &&
|
||
formData.embedding.modelName;
|
||
|
||
if (!basicRequired) return false;
|
||
|
||
// LLM模型检查
|
||
let llmOk = true;
|
||
if (formData.llm.source === 'local') {
|
||
// Ollama模型需要检查可用性
|
||
llmOk = modelStatus.llm.available && !modelStatus.llm.downloading;
|
||
} else {
|
||
// 远程API只需要有BaseURL即可(如果需要的话,在表单验证中处理)
|
||
llmOk = true;
|
||
}
|
||
|
||
// Embedding模型检查
|
||
let embeddingOk = true;
|
||
if (formData.embedding.source === 'local') {
|
||
// Ollama模型需要检查可用性
|
||
embeddingOk = modelStatus.embedding.available && !modelStatus.embedding.downloading;
|
||
} else {
|
||
// 远程API只需要有BaseURL即可
|
||
embeddingOk = true;
|
||
}
|
||
|
||
// Rerank模型检查(如果启用)
|
||
let rerankOk = true;
|
||
if (formData.rerank.enabled && formData.rerank.modelName) {
|
||
if (formData.rerank.baseUrl) {
|
||
// 远程API,不需要特殊检查
|
||
rerankOk = true;
|
||
} else {
|
||
// 本地Ollama模型需要检查可用性
|
||
rerankOk = modelStatus.rerank.available && !modelStatus.rerank.downloading;
|
||
}
|
||
}
|
||
|
||
// VLM模型检查(如果启用多模态)
|
||
let vlmOk = true;
|
||
if (formData.multimodal.enabled && formData.multimodal.vlm.modelName) {
|
||
if (formData.multimodal.vlm.interfaceType === 'ollama') {
|
||
// Ollama模型需要检查可用性
|
||
vlmOk = modelStatus.vlm.available && !modelStatus.vlm.downloading;
|
||
} else {
|
||
// OpenAI兼容API,不需要特殊检查
|
||
vlmOk = true;
|
||
}
|
||
}
|
||
|
||
return llmOk && embeddingOk && rerankOk && vlmOk;
|
||
});
|
||
|
||
const imageUpload = ref(null);
|
||
|
||
// 清理完成,开始函数定义
|
||
|
||
// 验证Embedding维度
|
||
const validateEmbeddingDimension = (val: any) => {
|
||
val = Number(val);
|
||
if (!val || isNaN(val)) {
|
||
return false;
|
||
}
|
||
// 验证是否为整数且在合理范围内
|
||
return Number.isInteger(val) ;
|
||
};
|
||
|
||
// 表单验证规则
|
||
const rules = {
|
||
'llm.modelName': [{ required: true, message: '请输入LLM模型名称', type: 'error' }],
|
||
'llm.baseUrl': [
|
||
{ required: (t: any) => formData.llm.source === 'remote', message: '请输入BaseURL', type: 'error' }
|
||
],
|
||
'embedding.modelName': [{ required: true, message: '请输入Embedding模型名称', type: 'error' }],
|
||
'embedding.baseUrl': [
|
||
{ required: (t: any) => formData.embedding.source === 'remote', message: '请输入BaseURL', type: 'error' }
|
||
],
|
||
'embedding.dimension': [
|
||
{ required: true, message: '请输入Embedding维度', type: 'error' },
|
||
{ validator: validateEmbeddingDimension, message: '维度必须为有效整数值,常见取值为768, 1024, 1536, 3584等', type: 'error' }
|
||
]
|
||
};
|
||
|
||
// 检查Ollama服务状态
|
||
const checkOllama = async () => {
|
||
try {
|
||
const result = await checkOllamaStatus();
|
||
ollamaStatus.checked = true;
|
||
ollamaStatus.available = result.available;
|
||
ollamaStatus.version = result.version || '';
|
||
ollamaStatus.error = result.error || '';
|
||
ollamaStatus.baseUrl = result.baseUrl || '';
|
||
|
||
if (ollamaStatus.available) {
|
||
// 如果Ollama可用,检查已配置的模型
|
||
await checkAllOllamaModels();
|
||
}
|
||
} catch (error) {
|
||
console.error('检查Ollama状态失败:', error);
|
||
ollamaStatus.checked = true;
|
||
ollamaStatus.available = false;
|
||
ollamaStatus.error = '检查失败';
|
||
}
|
||
};
|
||
|
||
// 刷新顶部摘要(状态 + 已安装模型)
|
||
const refreshOllamaSummary = async () => {
|
||
if (summaryRefreshing.value) return;
|
||
summaryRefreshing.value = true;
|
||
try {
|
||
await checkOllama();
|
||
const models = await listOllamaModels();
|
||
installedModels.value = models;
|
||
} catch (e) {
|
||
installedModels.value = [];
|
||
} finally {
|
||
// 略延时以保证旋转动画可见
|
||
setTimeout(() => { summaryRefreshing.value = false; }, 300);
|
||
}
|
||
};
|
||
|
||
// 检查所有Ollama模型状态
|
||
const checkAllOllamaModels = async () => {
|
||
const modelsToCheck = [];
|
||
|
||
if (formData.llm.source === 'local' && formData.llm.modelName) {
|
||
modelsToCheck.push(formData.llm.modelName);
|
||
}
|
||
|
||
if (formData.embedding.source === 'local' && formData.embedding.modelName) {
|
||
modelsToCheck.push(formData.embedding.modelName);
|
||
}
|
||
|
||
if (modelsToCheck.length === 0) return;
|
||
|
||
try {
|
||
const result = await checkOllamaModels(modelsToCheck);
|
||
|
||
// 更新模型状态
|
||
if (formData.llm.source === 'local' && formData.llm.modelName) {
|
||
modelStatus.llm.checked = true;
|
||
modelStatus.llm.available = result.models[formData.llm.modelName] || false;
|
||
}
|
||
|
||
if (formData.embedding.source === 'local' && formData.embedding.modelName) {
|
||
modelStatus.embedding.checked = true;
|
||
modelStatus.embedding.available = result.models[formData.embedding.modelName] || false;
|
||
}
|
||
} catch (error) {
|
||
console.error('检查模型状态失败:', error);
|
||
}
|
||
};
|
||
|
||
// 下载模型
|
||
const downloadModel = async (type: 'llm' | 'embedding' | 'vlm', modelName: string) => {
|
||
// 防止重复点击
|
||
if (modelStatus[type].downloading) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 立即更新状态,防止重复点击
|
||
modelStatus[type].downloading = true;
|
||
modelStatus[type].progress = 0;
|
||
modelStatus[type].message = '正在启动下载...';
|
||
|
||
// 启动下载任务
|
||
const result = await downloadOllamaModel(modelName);
|
||
|
||
// 更新任务ID和初始进度
|
||
modelStatus[type].taskId = result.taskId;
|
||
modelStatus[type].progress = result.progress || 0;
|
||
modelStatus[type].message = '下载已开始';
|
||
|
||
// 如果已经完成,直接更新状态
|
||
if (result.status === 'completed') {
|
||
modelStatus[type].available = true;
|
||
modelStatus[type].downloading = false;
|
||
modelStatus[type].progress = 100;
|
||
modelStatus[type].message = '下载完成';
|
||
MessagePlugin.success(`模型 ${modelName} 下载成功`);
|
||
return;
|
||
}
|
||
|
||
// 开始轮询进度
|
||
startProgressPolling(type, result.taskId, modelName);
|
||
|
||
} catch (error) {
|
||
console.error(`启动模型 ${modelName} 下载失败:`, error);
|
||
MessagePlugin.error(`启动模型 ${modelName} 下载失败`);
|
||
modelStatus[type].downloading = false;
|
||
modelStatus[type].message = '下载启动失败';
|
||
}
|
||
};
|
||
|
||
// 开始轮询下载进度
|
||
const startProgressPolling = (type: 'llm' | 'embedding' | 'vlm', taskId: string, modelName: string) => {
|
||
// 清除之前的定时器
|
||
if (progressTimers[taskId]) {
|
||
clearInterval(progressTimers[taskId]);
|
||
}
|
||
|
||
// 每2秒查询一次进度
|
||
progressTimers[taskId] = setInterval(async () => {
|
||
try {
|
||
const task = await getDownloadProgress(taskId);
|
||
|
||
// 更新模型状态
|
||
modelStatus[type].progress = task.progress;
|
||
modelStatus[type].message = task.message;
|
||
|
||
// 检查是否完成
|
||
if (task.status === 'completed') {
|
||
modelStatus[type].available = true;
|
||
modelStatus[type].downloading = false;
|
||
modelStatus[type].progress = 100;
|
||
modelStatus[type].message = '下载完成';
|
||
|
||
// 清除定时器
|
||
clearInterval(progressTimers[taskId]);
|
||
delete progressTimers[taskId];
|
||
|
||
MessagePlugin.success(`模型 ${modelName} 下载成功`);
|
||
|
||
} else if (task.status === 'failed') {
|
||
modelStatus[type].downloading = false;
|
||
modelStatus[type].message = task.message || '下载失败';
|
||
|
||
// 清除定时器
|
||
clearInterval(progressTimers[taskId]);
|
||
delete progressTimers[taskId];
|
||
|
||
MessagePlugin.error(`模型 ${modelName} 下载失败: ${task.message}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('查询下载进度失败:', error);
|
||
// 如果查询失败,停止轮询
|
||
clearInterval(progressTimers[taskId]);
|
||
delete progressTimers[taskId];
|
||
modelStatus[type].downloading = false;
|
||
modelStatus[type].message = '查询进度失败';
|
||
}
|
||
}, 2000);
|
||
};
|
||
|
||
// 配置回填
|
||
const loadCurrentConfig = async () => {
|
||
try {
|
||
const config = await getCurrentConfig();
|
||
|
||
// 设置hasFiles状态
|
||
hasFiles.value = config.hasFiles || false;
|
||
|
||
// 检查是否已有配置(判断是否为更新模式)
|
||
const hasExistingConfig = config.llm?.modelName || config.embedding?.modelName || config.rerank?.modelName;
|
||
isUpdateMode.value = !!hasExistingConfig;
|
||
|
||
// 回填表单数据
|
||
if (config.llm) {
|
||
Object.assign(formData.llm, config.llm);
|
||
console.log('LLM config loaded:', formData.llm);
|
||
}
|
||
if (config.embedding) {
|
||
Object.assign(formData.embedding, config.embedding);
|
||
console.log('Embedding config loaded:', formData.embedding);
|
||
}
|
||
if (config.rerank) {
|
||
Object.assign(formData.rerank, config.rerank);
|
||
console.log('Rerank config loaded:', formData.rerank);
|
||
}
|
||
if (config.multimodal) {
|
||
Object.assign(formData.multimodal, config.multimodal);
|
||
// 强制同步 storageType 到单选框
|
||
await nextTick();
|
||
formData.multimodal.storageType = (config.multimodal.storageType || 'minio') as 'minio' | 'cos';
|
||
console.log('Multimodal config loaded:', formData.multimodal);
|
||
}
|
||
if (config.documentSplitting) {
|
||
Object.assign(formData.documentSplitting, config.documentSplitting);
|
||
|
||
// 根据回填的配置设置预设模式
|
||
const { chunkSize, chunkOverlap } = config.documentSplitting;
|
||
if (chunkSize === 1000 && chunkOverlap === 200) {
|
||
selectedPreset.value = 'balanced';
|
||
} else if (chunkSize === 512 && chunkOverlap === 100) {
|
||
selectedPreset.value = 'precision';
|
||
} else if (chunkSize === 2048 && chunkOverlap === 400) {
|
||
selectedPreset.value = 'context';
|
||
} else {
|
||
selectedPreset.value = 'custom';
|
||
}
|
||
}
|
||
|
||
// 在配置加载完成后,检查模型状态
|
||
await checkModelsAfterLoading(config);
|
||
|
||
} catch (error) {
|
||
console.warn('Failed to load current configuration:', error);
|
||
// 如果加载失败,使用默认配置
|
||
isUpdateMode.value = false;
|
||
}
|
||
};
|
||
|
||
// 加载配置后检查模型状态
|
||
const checkModelsAfterLoading = async (config: any) => {
|
||
// 延迟一点执行,确保DOM已经更新
|
||
setTimeout(async () => {
|
||
// 检查所有已配置模型的状态
|
||
await checkAllConfiguredModels();
|
||
|
||
// 检查Rerank模型状态
|
||
if (formData.rerank.enabled && formData.rerank.modelName && formData.rerank.baseUrl) {
|
||
await checkRerankModelStatus();
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
// 事件处理
|
||
const onModelSourceChange = async (type: 'llm' | 'embedding') => {
|
||
// 重置模型状态
|
||
modelStatus[type].checked = false;
|
||
modelStatus[type].available = false;
|
||
modelStatus[type].downloading = false;
|
||
|
||
// 如果切换到本地,检查Ollama状态
|
||
if (formData[type].source === 'local' && !ollamaStatus.checked) {
|
||
await checkOllama();
|
||
}
|
||
};
|
||
|
||
const onModelNameChange = async (type: 'llm' | 'embedding' | 'vlm') => {
|
||
if (type === 'vlm') {
|
||
// 总是重置VLM模型状态
|
||
modelStatus.vlm.checked = false;
|
||
modelStatus.vlm.available = false;
|
||
modelStatus.vlm.downloading = false;
|
||
|
||
if (formData.multimodal.enabled && isVlmOllama.value && formData.multimodal.vlm.modelName) {
|
||
if (ollamaStatus.available) {
|
||
await checkAllOllamaModels();
|
||
|
||
// 触发表单验证
|
||
setTimeout(() => {
|
||
form.value?.validate(['multimodal.vlm.modelName']);
|
||
}, 100);
|
||
}
|
||
}
|
||
} else {
|
||
// 总是重置模型状态
|
||
modelStatus[type].checked = false;
|
||
modelStatus[type].available = false;
|
||
modelStatus[type].downloading = false;
|
||
|
||
if (formData[type].source === 'local' && formData[type].modelName) {
|
||
if (ollamaStatus.available) {
|
||
await checkAllOllamaModels();
|
||
|
||
// 触发表单验证
|
||
setTimeout(() => {
|
||
form.value?.validate([`${type}.modelName`]);
|
||
}, 100);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const onModelNameInput = (type: 'llm' | 'embedding' | 'vlm') => {
|
||
// 清除之前的定时器
|
||
if (inputDebounceTimers[type]) {
|
||
clearTimeout(inputDebounceTimers[type]);
|
||
}
|
||
|
||
// 重置模型状态
|
||
if (type === 'vlm') {
|
||
modelStatus.vlm.checked = false;
|
||
modelStatus.vlm.available = false;
|
||
modelStatus.vlm.message = '';
|
||
} else {
|
||
modelStatus[type].checked = false;
|
||
modelStatus[type].available = false;
|
||
modelStatus[type].message = '';
|
||
}
|
||
|
||
// 设置防抖延迟
|
||
inputDebounceTimers[type] = setTimeout(async () => {
|
||
const modelName = type === 'vlm' ? formData.multimodal.vlm.modelName : formData[type].modelName;
|
||
|
||
// 只有输入了模型名称才进行校验
|
||
if (modelName && modelName.trim()) {
|
||
// 触发表单验证
|
||
form.value?.validate([type === 'vlm' ? 'multimodal.vlm.modelName' : `${type}.modelName`]);
|
||
|
||
// 如果是远程API,自动检查模型状态
|
||
if (type === 'llm' && formData.llm.source === 'remote' && formData.llm.baseUrl) {
|
||
await checkRemoteModelStatus('llm');
|
||
} else if (type === 'embedding' && formData.embedding.source === 'remote' && formData.embedding.baseUrl) {
|
||
await checkEmbeddingModelStatus();
|
||
} else if (type === 'vlm' && !isVlmOllama.value) {
|
||
// VLM远程API校验可以在这里添加
|
||
}
|
||
|
||
// 如果是本地模型且Ollama可用,检查模型状态
|
||
if ((type === 'llm' || type === 'embedding') && formData[type].source === 'local' && ollamaStatus.available) {
|
||
await checkAllOllamaModels();
|
||
} else if (type === 'vlm' && isVlmOllama.value && ollamaStatus.available) {
|
||
await checkAllOllamaModels();
|
||
}
|
||
}
|
||
}, 500); // 500ms防抖延迟
|
||
};
|
||
|
||
// 远程配置改变时的处理
|
||
const onRemoteConfigChange = async (type: 'llm' | 'embedding') => {
|
||
// 重置模型状态
|
||
modelStatus[type].checked = false;
|
||
modelStatus[type].available = false;
|
||
modelStatus[type].message = '';
|
||
|
||
// 如果配置完整,检查模型
|
||
if (formData[type].modelName && formData[type].baseUrl) {
|
||
if (type === 'llm') {
|
||
await checkRemoteModelStatus(type);
|
||
} else if (type === 'embedding') {
|
||
await checkEmbeddingModelStatus();
|
||
}
|
||
}
|
||
};
|
||
|
||
// 远程配置输入时的处理
|
||
const onRemoteConfigInput = async (type: 'llm' | 'embedding') => {
|
||
// 清除之前的定时器
|
||
const timerKey = `${type}_remote`;
|
||
if (inputDebounceTimers[timerKey]) {
|
||
clearTimeout(inputDebounceTimers[timerKey]);
|
||
}
|
||
|
||
// 重置模型状态
|
||
modelStatus[type].checked = false;
|
||
modelStatus[type].available = false;
|
||
modelStatus[type].message = '';
|
||
|
||
// 设置防抖延迟
|
||
inputDebounceTimers[timerKey] = setTimeout(async () => {
|
||
// 只有在有模型名称和Base URL时才进行校验
|
||
if (formData[type].modelName && formData[type].modelName.trim() &&
|
||
formData[type].baseUrl && formData[type].baseUrl.trim()) {
|
||
|
||
// 触发表单验证
|
||
form.value?.validate([`${type}.modelName`, `${type}.baseUrl`]);
|
||
|
||
// 自动检查远程API模型状态
|
||
if (type === 'llm') {
|
||
await checkRemoteModelStatus(type);
|
||
} else if (type === 'embedding') {
|
||
await checkEmbeddingModelStatus();
|
||
}
|
||
}
|
||
}, 500); // 500ms防抖延迟
|
||
};
|
||
|
||
// 检查远程模型
|
||
const checkRemoteModelStatus = async (type: 'llm') => {
|
||
if (!formData[type].modelName || !formData[type].baseUrl) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
modelStatus[type].checking = true;
|
||
modelStatus[type].checked = false;
|
||
modelStatus[type].available = false;
|
||
modelStatus[type].message = '';
|
||
|
||
const result = await checkRemoteModel({
|
||
modelName: formData[type].modelName,
|
||
baseUrl: formData[type].baseUrl,
|
||
apiKey: formData[type].apiKey
|
||
});
|
||
|
||
modelStatus[type].checked = true;
|
||
modelStatus[type].available = result.available;
|
||
modelStatus[type].message = result.message || '';
|
||
|
||
// 触发表单验证
|
||
setTimeout(() => {
|
||
form.value?.validate([`${type}.modelName`]);
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error(`检查远程${type}模型失败:`, error);
|
||
modelStatus[type].checked = true;
|
||
modelStatus[type].available = false;
|
||
const err = error as any;
|
||
modelStatus[type].message = (err && err.message) || '网络连接失败';
|
||
} finally {
|
||
modelStatus[type].checking = false;
|
||
}
|
||
};
|
||
|
||
// 检查所有已配置模型的状态
|
||
const checkAllConfiguredModels = async () => {
|
||
// 检查LLM模型
|
||
if (formData.llm.source === 'local' && formData.llm.modelName && ollamaStatus.available) {
|
||
await checkAllOllamaModels();
|
||
} else if (formData.llm.source === 'remote' && formData.llm.modelName && formData.llm.baseUrl) {
|
||
await checkRemoteModelStatus('llm');
|
||
}
|
||
|
||
// 检查Embedding模型(如果没有文件)
|
||
if (!hasFiles.value) {
|
||
if (formData.embedding.source === 'local' && formData.embedding.modelName && ollamaStatus.available) {
|
||
await checkAllOllamaModels();
|
||
} else if (formData.embedding.source === 'remote' && formData.embedding.modelName && formData.embedding.baseUrl) {
|
||
await checkEmbeddingModelStatus();
|
||
}
|
||
}
|
||
};
|
||
|
||
const onDimensionInput = (event: any) => {
|
||
formData.embedding.dimension = Number(event.target.value);
|
||
};
|
||
|
||
// 检查Embedding模型状态
|
||
const checkEmbeddingModelStatus = async () => {
|
||
if (!formData.embedding.modelName) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
modelStatus.embedding.checking = true;
|
||
modelStatus.embedding.checked = false;
|
||
modelStatus.embedding.available = false;
|
||
modelStatus.embedding.message = '';
|
||
|
||
const result = await testEmbeddingModel({
|
||
source: formData.embedding.source as 'local' | 'remote',
|
||
modelName: formData.embedding.modelName,
|
||
baseUrl: formData.embedding.source === 'remote' ? formData.embedding.baseUrl : undefined,
|
||
apiKey: formData.embedding.apiKey || undefined,
|
||
dimension: formData.embedding.dimension || undefined,
|
||
});
|
||
|
||
modelStatus.embedding.checked = true;
|
||
modelStatus.embedding.available = result.available || false;
|
||
modelStatus.embedding.message = result.message || '';
|
||
|
||
// 如果检测到维度信息,自动更新
|
||
if (result.available && result.dimension && result.dimension > 0) {
|
||
formData.embedding.dimension = result.dimension;
|
||
}
|
||
|
||
// 触发表单验证
|
||
setTimeout(() => {
|
||
form.value?.validate(['embedding.modelName']);
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('检查Embedding模型失败:', error);
|
||
modelStatus.embedding.checked = true;
|
||
modelStatus.embedding.available = false;
|
||
const err = error as any;
|
||
modelStatus.embedding.message = (err && err.message) || '检查失败';
|
||
} finally {
|
||
modelStatus.embedding.checking = false;
|
||
}
|
||
};
|
||
|
||
// 检测并自动填写 Embedding 维度
|
||
const detectEmbeddingDimension = async () => {
|
||
if (hasFiles.value) return;
|
||
// 校验必填:模型来源 + 模型名称;若远程还需 BaseURL
|
||
const source = (formData.embedding.source || '').trim();
|
||
const modelName = (formData.embedding.modelName || '').trim();
|
||
const baseUrl = (formData.embedding.baseUrl || '').trim();
|
||
|
||
if (!source || !modelName || (source === 'remote' && !baseUrl)) {
|
||
// 触发对应字段校验提示
|
||
const fields: string[] = ['embedding.source', 'embedding.modelName'];
|
||
if (source === 'remote') fields.push('embedding.baseUrl');
|
||
try { await form.value?.validate(fields); } catch {}
|
||
MessagePlugin.warning('请先完整填写Embedding配置');
|
||
return;
|
||
}
|
||
|
||
embeddingDimDetecting.value = true;
|
||
try {
|
||
const res = await testEmbeddingModel({
|
||
source: source as 'local' | 'remote',
|
||
modelName,
|
||
baseUrl: source === 'remote' ? baseUrl : undefined,
|
||
apiKey: formData.embedding.apiKey || undefined,
|
||
dimension: formData.embedding.dimension || undefined,
|
||
});
|
||
const available = !!res.available;
|
||
const message = res.message || '';
|
||
const dim = Number(res.dimension || 0);
|
||
if (available && dim > 0) {
|
||
formData.embedding.dimension = dim;
|
||
MessagePlugin.success(`检测成功,维度已自动填写为 ${dim}`);
|
||
} else {
|
||
MessagePlugin.error(message || '检测失败');
|
||
}
|
||
} catch (e: any) {
|
||
const msg = e?.message || '检测失败,请检查配置';
|
||
MessagePlugin.error(msg);
|
||
} finally {
|
||
embeddingDimDetecting.value = false;
|
||
}
|
||
};
|
||
|
||
// 第一个handleSubmit函数已删除,使用下面完整的版本
|
||
|
||
// 停止所有进度轮询
|
||
const stopAllProgressPolling = () => {
|
||
Object.keys(progressTimers).forEach(taskId => {
|
||
clearInterval(progressTimers[taskId]);
|
||
delete progressTimers[taskId];
|
||
});
|
||
};
|
||
|
||
// 组件卸载时清理定时器
|
||
onUnmounted(() => {
|
||
stopAllProgressPolling();
|
||
|
||
// 清理输入防抖定时器
|
||
Object.keys(inputDebounceTimers).forEach(key => {
|
||
if (inputDebounceTimers[key]) {
|
||
clearTimeout(inputDebounceTimers[key]);
|
||
delete inputDebounceTimers[key];
|
||
}
|
||
});
|
||
|
||
// 清理提交防抖定时器
|
||
if (submitDebounceTimer.value) {
|
||
clearTimeout(submitDebounceTimer.value);
|
||
submitDebounceTimer.value = null;
|
||
}
|
||
});
|
||
|
||
// 监听表单变化
|
||
watch(() => formData.llm.source, () => onModelSourceChange('llm'));
|
||
watch(() => formData.embedding.source, () => onModelSourceChange('embedding'));
|
||
|
||
// 添加缺失的函数
|
||
const onRerankChange = () => {
|
||
console.log('Rerank enabled:', formData.rerank.enabled);
|
||
};
|
||
|
||
const onMultimodalChange = async () => {
|
||
if (formData.multimodal.enabled) {
|
||
// 设置默认的VLM接口类型
|
||
if (!formData.multimodal.vlm.interfaceType) {
|
||
formData.multimodal.vlm.interfaceType = 'ollama';
|
||
}
|
||
|
||
// 如果是Ollama接口,设置默认的Base URL
|
||
if (formData.multimodal.vlm.interfaceType === 'ollama' && !formData.multimodal.vlm.baseUrl) {
|
||
formData.multimodal.vlm.baseUrl = 'http://localhost:11434/v1';
|
||
}
|
||
}
|
||
};
|
||
|
||
const onPresetChange = () => {
|
||
if (selectedPreset.value === 'balanced') {
|
||
formData.documentSplitting.chunkSize = 1000;
|
||
formData.documentSplitting.chunkOverlap = 200;
|
||
formData.documentSplitting.separators = ['\n\n', '\n', '。', '!', '?', ';', ';'];
|
||
} else if (selectedPreset.value === 'precision') {
|
||
formData.documentSplitting.chunkSize = 512;
|
||
formData.documentSplitting.chunkOverlap = 100;
|
||
formData.documentSplitting.separators = ['\n\n', '\n', '。', '!', '?', ';', ';'];
|
||
} else if (selectedPreset.value === 'context') {
|
||
formData.documentSplitting.chunkSize = 2048;
|
||
formData.documentSplitting.chunkOverlap = 400;
|
||
formData.documentSplitting.separators = ['\n\n', '\n', '。', '!', '?', ';', ';'];
|
||
}
|
||
};
|
||
|
||
const onRerankConfigChange = async () => {
|
||
// 重置模型状态
|
||
modelStatus.rerank.checked = false;
|
||
modelStatus.rerank.available = false;
|
||
modelStatus.rerank.message = '';
|
||
|
||
// 如果配置完整,检查模型
|
||
if (formData.rerank.modelName && formData.rerank.baseUrl) {
|
||
await checkRerankModelStatus();
|
||
}
|
||
};
|
||
|
||
const onRerankConfigInput = async () => {
|
||
// 清除之前的定时器
|
||
const timerKey = 'rerank_remote';
|
||
if (inputDebounceTimers[timerKey]) {
|
||
clearTimeout(inputDebounceTimers[timerKey]);
|
||
}
|
||
|
||
// 重置模型状态
|
||
modelStatus.rerank.checked = false;
|
||
modelStatus.rerank.available = false;
|
||
modelStatus.rerank.message = '';
|
||
|
||
// 设置防抖延迟
|
||
inputDebounceTimers[timerKey] = setTimeout(async () => {
|
||
// 只有在有模型名称和Base URL时才进行校验
|
||
if (formData.rerank.modelName && formData.rerank.modelName.trim() &&
|
||
formData.rerank.baseUrl && formData.rerank.baseUrl.trim()) {
|
||
|
||
// 触发表单验证
|
||
form.value?.validate(['rerank.modelName', 'rerank.baseUrl']);
|
||
|
||
// 自动检查Rerank模型状态
|
||
await checkRerankModelStatus();
|
||
}
|
||
}, 500); // 500ms防抖延迟
|
||
};
|
||
|
||
const checkRerankModelStatus = async () => {
|
||
if (!formData.rerank.modelName || !formData.rerank.baseUrl) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
modelStatus.rerank.checking = true;
|
||
modelStatus.rerank.checked = false;
|
||
modelStatus.rerank.available = false;
|
||
modelStatus.rerank.message = '';
|
||
|
||
const result = await checkRerankModel({
|
||
modelName: formData.rerank.modelName,
|
||
baseUrl: formData.rerank.baseUrl,
|
||
apiKey: formData.rerank.apiKey
|
||
});
|
||
|
||
modelStatus.rerank.checked = true;
|
||
modelStatus.rerank.available = result.available;
|
||
modelStatus.rerank.message = result.message || '';
|
||
|
||
// 触发表单验证
|
||
setTimeout(() => {
|
||
form.value?.validate(['rerank.modelName']);
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('检查Rerank模型失败:', error);
|
||
modelStatus.rerank.checked = true;
|
||
modelStatus.rerank.available = false;
|
||
const err = error as any;
|
||
modelStatus.rerank.message = (err && err.message) || '网络连接失败';
|
||
} finally {
|
||
modelStatus.rerank.checking = false;
|
||
}
|
||
};
|
||
|
||
// 添加一些基本的多模态函数(简化版)
|
||
const onVlmInterfaceTypeChange = () => {
|
||
console.log('VLM interface type changed:', formData.multimodal.vlm.interfaceType);
|
||
};
|
||
|
||
const onVlmBaseUrlChange = async () => {
|
||
console.log('VLM base URL changed:', formData.multimodal.vlm.baseUrl);
|
||
};
|
||
|
||
const onVlmBaseUrlInput = () => {
|
||
console.log('VLM base URL input');
|
||
};
|
||
|
||
const onVlmApiKeyChange = () => {
|
||
console.log('VLM API key changed');
|
||
};
|
||
|
||
const onCosConfigChange = () => {
|
||
console.log('COS config changed');
|
||
};
|
||
|
||
const onStorageTypeChange = () => {
|
||
console.log('Storage type changed:', formData.multimodal.storageType);
|
||
};
|
||
|
||
const onImageChange = (files: any) => {
|
||
if (files && files.length > 0) {
|
||
// 检查多模态配置是否完整
|
||
const missingConfigs: string[] = [];
|
||
|
||
// 根据存储类型检查必填项
|
||
if (formData.multimodal.storageType === 'cos') {
|
||
if (!formData.multimodal.cos.secretId) missingConfigs.push('COS Secret ID');
|
||
if (!formData.multimodal.cos.secretKey) missingConfigs.push('COS Secret Key');
|
||
if (!formData.multimodal.cos.region) missingConfigs.push('COS Region');
|
||
if (!formData.multimodal.cos.bucketName) missingConfigs.push('COS Bucket Name');
|
||
if (!formData.multimodal.cos.appId) missingConfigs.push('COS App ID');
|
||
} else if (formData.multimodal.storageType === 'minio') {
|
||
if (!formData.multimodal.minio.bucketName) missingConfigs.push('MinIO Bucket Name');
|
||
}
|
||
|
||
// 检查VLM配置
|
||
if (!formData.multimodal.vlm.modelName) {
|
||
missingConfigs.push('VLM 模型名称');
|
||
}
|
||
|
||
// 如果是OpenAI兼容接口,还需要检查Base URL
|
||
if (formData.multimodal.vlm.interfaceType === 'openai' && !formData.multimodal.vlm.baseUrl) {
|
||
missingConfigs.push('VLM Base URL');
|
||
}
|
||
|
||
if (missingConfigs.length > 0) {
|
||
const missingList = missingConfigs.join('、');
|
||
MessagePlugin.error(`多模态配置不完整,请先完成多模态配置后再上传图片`);
|
||
return;
|
||
}
|
||
|
||
const file = files[0].raw || files[0];
|
||
multimodalTest.selectedFile = file;
|
||
|
||
// 创建预览URL
|
||
if (multimodalTest.previewUrl) {
|
||
URL.revokeObjectURL(multimodalTest.previewUrl);
|
||
}
|
||
multimodalTest.previewUrl = URL.createObjectURL(file);
|
||
|
||
// 清除之前的测试结果
|
||
multimodalTest.result = null;
|
||
|
||
MessagePlugin.success('图片上传成功,可以开始测试');
|
||
}
|
||
};
|
||
|
||
const startMultimodalTest = async () => {
|
||
if (!multimodalTest.selectedFile) {
|
||
MessagePlugin.warning('请先选择一张图片');
|
||
return;
|
||
}
|
||
|
||
multimodalTest.testing = true;
|
||
multimodalTest.result = null;
|
||
|
||
try {
|
||
// 准备API参数
|
||
const apiParams: {
|
||
image: File;
|
||
vlm_model: string;
|
||
vlm_base_url: string;
|
||
vlm_api_key: string;
|
||
vlm_interface_type: string;
|
||
storage_type?: 'cos' | 'minio';
|
||
cos_secret_id?: string;
|
||
cos_secret_key?: string;
|
||
cos_region?: string;
|
||
cos_bucket_name?: string;
|
||
cos_app_id?: string;
|
||
cos_path_prefix?: string;
|
||
minio_bucket_name?: string;
|
||
minio_use_ssl?: boolean;
|
||
minio_path_prefix?: string;
|
||
chunk_size: number;
|
||
chunk_overlap: number;
|
||
separators: string[];
|
||
} = {
|
||
image: multimodalTest.selectedFile,
|
||
vlm_model: formData.multimodal.vlm.modelName,
|
||
vlm_base_url: '', // 将在下方设置
|
||
vlm_api_key: '', // 将在下方设置
|
||
vlm_interface_type: formData.multimodal.vlm.interfaceType,
|
||
chunk_size: formData.documentSplitting.chunkSize,
|
||
chunk_overlap: formData.documentSplitting.chunkOverlap,
|
||
separators: formData.documentSplitting.separators
|
||
};
|
||
|
||
// 根据存储类型附带不同的参数
|
||
if (formData.multimodal.storageType === 'cos') {
|
||
apiParams.storage_type = 'cos';
|
||
apiParams.cos_secret_id = formData.multimodal.cos.secretId;
|
||
apiParams.cos_secret_key = formData.multimodal.cos.secretKey;
|
||
apiParams.cos_region = formData.multimodal.cos.region;
|
||
apiParams.cos_bucket_name = formData.multimodal.cos.bucketName;
|
||
apiParams.cos_app_id = formData.multimodal.cos.appId;
|
||
apiParams.cos_path_prefix = formData.multimodal.cos.pathPrefix || '';
|
||
} else if (formData.multimodal.storageType === 'minio') {
|
||
apiParams.storage_type = 'minio';
|
||
apiParams.minio_bucket_name = formData.multimodal.minio.bucketName;
|
||
apiParams.minio_use_ssl = formData.multimodal.minio.useSSL;
|
||
apiParams.minio_path_prefix = formData.multimodal.minio.pathPrefix;
|
||
}
|
||
|
||
// 如果是OpenAI兼容接口,设置baseUrl和apiKey
|
||
if (formData.multimodal.vlm.interfaceType === 'openai') {
|
||
apiParams.vlm_base_url = formData.multimodal.vlm.baseUrl;
|
||
apiParams.vlm_api_key = formData.multimodal.vlm.apiKey || '';
|
||
} else {
|
||
// Ollama接口使用默认的本地URL
|
||
apiParams.vlm_base_url = 'http://localhost:11434/v1';
|
||
apiParams.vlm_api_key = '';
|
||
}
|
||
|
||
const result = await testMultimodalFunction(apiParams);
|
||
|
||
multimodalTest.result = result;
|
||
|
||
if (result.success) {
|
||
MessagePlugin.success('多模态测试成功');
|
||
} else {
|
||
MessagePlugin.error(`多模态测试失败: ${result.message}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('多模态测试失败:', error);
|
||
multimodalTest.result = {
|
||
success: false,
|
||
message: (error as any)?.message || '测试过程中发生错误'
|
||
};
|
||
MessagePlugin.error('多模态测试失败');
|
||
} finally {
|
||
multimodalTest.testing = false;
|
||
}
|
||
};
|
||
|
||
// 处理按钮提交
|
||
const handleSubmit = async () => {
|
||
// 防止重复提交和防抖
|
||
if (submitting.value || isSubmitDebounced.value) {
|
||
return;
|
||
}
|
||
|
||
// 启动防抖机制
|
||
isSubmitDebounced.value = true;
|
||
if (submitDebounceTimer.value) {
|
||
clearTimeout(submitDebounceTimer.value);
|
||
}
|
||
submitDebounceTimer.value = setTimeout(() => {
|
||
isSubmitDebounced.value = false;
|
||
}, 3000); // 3秒防抖
|
||
|
||
try {
|
||
// 表单验证
|
||
const isValid = await form.value?.validate();
|
||
if (!isValid) {
|
||
MessagePlugin.error('请检查表单填写是否正确');
|
||
return;
|
||
}
|
||
|
||
submitting.value = true;
|
||
|
||
// 确保embedding.dimension是数字类型
|
||
if (formData.embedding.dimension) {
|
||
formData.embedding.dimension = Number(formData.embedding.dimension);
|
||
}
|
||
|
||
// 直接使用formData,API期望原始结构
|
||
const result = await initializeSystem(formData);
|
||
|
||
if (result.success) {
|
||
MessagePlugin.success(isUpdateMode.value ? '配置更新成功' : '系统初始化完成');
|
||
|
||
// 如果是初始化模式,跳转到主页面
|
||
if (!isUpdateMode.value) {
|
||
setTimeout(() => {
|
||
router.push('/platform/knowledgeBase');
|
||
}, 1500);
|
||
}
|
||
} else {
|
||
MessagePlugin.error(result.message || '操作失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('提交失败:', error);
|
||
MessagePlugin.error('操作失败,请检查网络连接');
|
||
} finally {
|
||
submitting.value = false;
|
||
|
||
// 清理防抖定时器
|
||
if (submitDebounceTimer.value) {
|
||
clearTimeout(submitDebounceTimer.value);
|
||
submitDebounceTimer.value = null;
|
||
}
|
||
isSubmitDebounced.value = false;
|
||
}
|
||
};
|
||
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
};
|
||
|
||
// 组件挂载时检查Ollama状态
|
||
onMounted(async () => {
|
||
// 加载当前配置
|
||
await loadCurrentConfig();
|
||
|
||
// 总是检查Ollama状态,因为这是独立于具体配置的
|
||
await refreshOllamaSummary();
|
||
|
||
// 检查已配置模型状态
|
||
await checkAllConfiguredModels();
|
||
});
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.initialization-content {
|
||
padding: 0;
|
||
|
||
.ollama-summary-card {
|
||
margin-bottom: 24px;
|
||
background: #ffffff;
|
||
border: 1px solid #e9edf5;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||
padding: 16px 20px;
|
||
|
||
.summary-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
|
||
.title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
font-size: 16px;
|
||
}
|
||
.state{ margin-left: 8px; }
|
||
.refresh-icon{
|
||
margin-left: 8px;
|
||
cursor: pointer;
|
||
color: #6b7280;
|
||
transition: transform .2s ease, color .2s ease;
|
||
}
|
||
.refresh-icon:hover{ color: #0ea5e9; }
|
||
.refresh-icon.spinning{ animation: spin 1s linear infinite; }
|
||
}
|
||
|
||
.summary-body {
|
||
.models {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.label {
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
margin-top: 2px;
|
||
min-width: 100px;
|
||
}
|
||
.model-list {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.model-pill {
|
||
border-radius: 8px;
|
||
}
|
||
.empty {
|
||
color: #9ca3af;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.config-section {
|
||
margin-bottom: 32px;
|
||
background: #fff;
|
||
border: 1px solid #eef4f0;
|
||
border-radius: 12px;
|
||
box-shadow: 0 6px 18px rgba(7, 192, 95, 0.04);
|
||
padding: 24px;
|
||
|
||
h3 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 0 0 20px;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
|
||
.section-icon {
|
||
color: #07c05f;
|
||
font-size: 20px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.form-row {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.model-input-with-status {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.model-input-with-status :deep(.t-input) {
|
||
flex: 1;
|
||
min-width: 300px;
|
||
}
|
||
|
||
.model-status-icon {
|
||
flex-shrink: 0;
|
||
width: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.model-status-icon .status-icon {
|
||
font-size: 18px;
|
||
cursor: help;
|
||
}
|
||
|
||
.model-status-icon .status-icon.installed {
|
||
color: #00a870;
|
||
}
|
||
|
||
.model-status-icon .status-icon.not-installed {
|
||
color: #e34d59;
|
||
}
|
||
|
||
.model-status-icon .status-icon.unknown {
|
||
color: #d54941;
|
||
}
|
||
|
||
.model-status-icon .status-icon.downloading {
|
||
color: #0052d9;
|
||
}
|
||
|
||
.download-action {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.download-action .download-btn {
|
||
height: 28px;
|
||
width: 28px;
|
||
min-width: 28px;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #07c05f, #00a651);
|
||
border: none;
|
||
color: white;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.2);
|
||
|
||
&:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #00a651, #008f47);
|
||
box-shadow: 0 4px 12px rgba(7, 192, 95, 0.3);
|
||
}
|
||
|
||
&:disabled {
|
||
background: #e5e7eb;
|
||
color: #9ca3af;
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.t-icon {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.download-progress {
|
||
margin-top: 12px;
|
||
padding: 12px 16px;
|
||
background: linear-gradient(135deg, #f0fdf4, #f8fafc);
|
||
border-radius: 8px;
|
||
border: 1px solid #dcfce7;
|
||
box-shadow: 0 1px 4px rgba(7, 192, 95, 0.08);
|
||
|
||
.progress-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
|
||
.loading-icon {
|
||
color: #07c05f;
|
||
font-size: 14px;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
.progress-text {
|
||
font-size: 14px;
|
||
color: #166534;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.progress-bar {
|
||
margin-bottom: 8px;
|
||
|
||
:deep(.t-progress__bar) {
|
||
background: linear-gradient(90deg, #07c05f, #00a651);
|
||
border-radius: 4px;
|
||
height: 6px;
|
||
box-shadow: 0 1px 2px rgba(7, 192, 95, 0.2);
|
||
}
|
||
|
||
:deep(.t-progress__track) {
|
||
background-color: #e5e7eb;
|
||
border-radius: 4px;
|
||
height: 6px;
|
||
}
|
||
}
|
||
|
||
.progress-message {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
text-align: center;
|
||
line-height: 1.2;
|
||
font-style: italic;
|
||
}
|
||
}
|
||
|
||
.remote-config {
|
||
margin-top: 20px;
|
||
padding: 20px;
|
||
background: #f9fcff;
|
||
border-radius: 12px;
|
||
border: 1px solid #edf2f7;
|
||
}
|
||
|
||
.url-input-with-check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.url-input-with-check :deep(.t-input) {
|
||
flex: 1;
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.check-action {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 24px;
|
||
}
|
||
|
||
.check-action .status-icon,
|
||
.check-action .input-icon {
|
||
font-size: 18px;
|
||
cursor: help;
|
||
color: #666;
|
||
}
|
||
|
||
.check-action .status-icon.installed,
|
||
.check-action .input-icon.installed {
|
||
color: #00a870;
|
||
}
|
||
|
||
.check-action .status-icon.not-installed,
|
||
.check-action .input-icon.not-installed {
|
||
color: #e34d59;
|
||
}
|
||
|
||
.check-action .status-icon.checking,
|
||
.check-action .input-icon.checking {
|
||
color: #0052d9;
|
||
}
|
||
|
||
.error-message {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
padding: 12px 16px;
|
||
background-color: #fff2f0;
|
||
border: 1px solid #ffccc7;
|
||
border-radius: 8px;
|
||
color: #d4380d;
|
||
font-size: 14px;
|
||
|
||
.t-icon {
|
||
color: #d4380d;
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
span {
|
||
line-height: 1.4;
|
||
}
|
||
}
|
||
|
||
.dimension-input-with-action {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.dimension-input-with-action :deep(.t-input) {
|
||
flex: 0 0 auto;
|
||
}
|
||
.detect-dim-btn {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.embedding-warning {
|
||
margin-bottom: 20px;
|
||
|
||
:deep(.t-alert) {
|
||
background-color: #fffbe6;
|
||
border-color: #ffe58f;
|
||
border-radius: 8px;
|
||
|
||
.t-alert__message {
|
||
color: #d48806;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.t-alert__icon {
|
||
color: #faad14;
|
||
}
|
||
}
|
||
}
|
||
|
||
.rerank-config, .multimodal-config {
|
||
margin-top: 20px;
|
||
padding: 20px;
|
||
background: #f9fcff;
|
||
border-radius: 12px;
|
||
border: 1px solid #edf2f7;
|
||
|
||
h4 {
|
||
color: #333;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
margin: 20px 0 15px 0;
|
||
padding-left: 12px;
|
||
border-left: 3px solid #07c05f;
|
||
}
|
||
}
|
||
|
||
.switch-container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
margin-left: 0;
|
||
|
||
.switch-label {
|
||
margin-left: 10px;
|
||
font-size: 14px;
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.preset-row {
|
||
margin-bottom: 30px;
|
||
|
||
.preset-radio-group {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 15px;
|
||
|
||
:deep(.t-radio) {
|
||
border: 2px solid #e8f5e8;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
transition: all 0.3s ease;
|
||
cursor: pointer;
|
||
background: white;
|
||
|
||
&:hover {
|
||
border-color: #07c05f;
|
||
background-color: #f8fdf8;
|
||
box-shadow: 0 4px 12px rgba(7, 192, 95, 0.1);
|
||
}
|
||
|
||
&.t-is-checked {
|
||
border-color: #07c05f;
|
||
background: linear-gradient(135deg, #07c05f, #00a651);
|
||
color: white;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 20px rgba(7, 192, 95, 0.2);
|
||
|
||
.preset-title {
|
||
color: white !important;
|
||
}
|
||
|
||
.preset-desc {
|
||
color: rgba(255, 255, 255, 0.9) !important;
|
||
}
|
||
}
|
||
|
||
.preset-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
}
|
||
|
||
.preset-title {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.preset-desc {
|
||
font-size: 12px;
|
||
color: #666;
|
||
line-height: 1.4;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.parameters-grid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
margin-top: 20px;
|
||
padding: 20px 16px;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
border: 1px solid #edf2f7;
|
||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
||
|
||
&.disabled-grid {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
background-color: #f8f9fa;
|
||
border-color: #ddd;
|
||
}
|
||
|
||
.parameter-group {
|
||
.parameter-label {
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 12px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.parameter-control {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
|
||
.parameter-slider {
|
||
flex: 1;
|
||
|
||
:deep(.t-slider__rail) {
|
||
background-color: #e8f5e8;
|
||
}
|
||
|
||
:deep(.t-slider__track) {
|
||
background-color: #07c05f;
|
||
}
|
||
|
||
:deep(.t-slider__handle) {
|
||
background-color: #07c05f;
|
||
border-color: #07c05f;
|
||
}
|
||
}
|
||
|
||
.parameter-value {
|
||
font-size: 14px;
|
||
color: #666;
|
||
font-weight: 500;
|
||
min-width: 80px;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.parameter-select {
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.parameter-desc {
|
||
font-size: 12px;
|
||
color: #999;
|
||
line-height: 1.4;
|
||
margin-top: 8px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.multimodal-test {
|
||
margin-top: 20px;
|
||
padding: 20px;
|
||
background: #f8fff9;
|
||
border-radius: 10px;
|
||
border: 1px solid #e8f5e8;
|
||
|
||
h5 {
|
||
font-size: 16px;
|
||
color: #07c05f;
|
||
margin-bottom: 8px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.test-desc {
|
||
margin-bottom: 16px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.test-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.upload-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
|
||
.upload-buttons {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
|
||
.image-preview {
|
||
text-align: center;
|
||
padding: 20px;
|
||
background: white;
|
||
border-radius: 10px;
|
||
border: 1px solid #e8f5e8;
|
||
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.08);
|
||
|
||
img {
|
||
max-width: 100%;
|
||
max-height: 200px;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
margin-bottom: 12px;
|
||
border: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.image-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
background: #f8fff9;
|
||
border-radius: 6px;
|
||
border: 1px solid #e8f5e8;
|
||
|
||
.file-name {
|
||
font-weight: 500;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
max-width: 70%;
|
||
color: #333;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.file-size {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
|
||
.test-button-wrapper {
|
||
text-align: center;
|
||
margin-top: 16px;
|
||
padding: 12px 20px;
|
||
background: #f8fff9;
|
||
border-radius: 8px;
|
||
border: 1px solid #e8f5e8;
|
||
|
||
.t-button {
|
||
min-width: 100px;
|
||
height: 32px;
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.test-result {
|
||
background: white;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
border: 1px solid #e8f5e8;
|
||
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.08);
|
||
|
||
h6 {
|
||
font-size: 15px;
|
||
margin-bottom: 16px;
|
||
color: #07c05f;
|
||
font-weight: 600;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid #f0fdf4;
|
||
}
|
||
|
||
.result-item {
|
||
margin-bottom: 16px;
|
||
|
||
label {
|
||
display: block;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
color: #333;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.result-text {
|
||
background: #f8fff9;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
color: #333;
|
||
white-space: pre-wrap;
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
border: 1px solid #e8f5e8;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
}
|
||
|
||
.result-time {
|
||
text-align: right;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
margin-top: 12px;
|
||
padding: 8px 12px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
border: 1px solid #e9ecef;
|
||
}
|
||
|
||
.result-error {
|
||
color: #e34d59;
|
||
|
||
.error-msg {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: #fff2f0;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
border: 1px solid #ffccc7;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.submit-section {
|
||
text-align: center;
|
||
padding: 32px 0 0;
|
||
// border-top: 1px solid #f0f0f0;
|
||
// margin-top: 32px;
|
||
|
||
:deep(.t-button--theme-primary) {
|
||
background: linear-gradient(135deg, #07c05f, #00a651);
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: 600;
|
||
padding: 12px 32px;
|
||
font-size: 16px;
|
||
transition: all 0.2s ease;
|
||
min-height: 44px;
|
||
min-width: 160px;
|
||
|
||
&:hover:not(.t-is-disabled) {
|
||
background: linear-gradient(135deg, #00a651, #009645);
|
||
box-shadow: 0 8px 25px rgba(7, 192, 95, 0.3);
|
||
}
|
||
|
||
&.t-is-disabled {
|
||
background: #ccc;
|
||
color: #999;
|
||
box-shadow: none;
|
||
}
|
||
|
||
&.t-is-loading {
|
||
background: linear-gradient(135deg, #07c05f, #00a651);
|
||
color: white;
|
||
box-shadow: 0 4px 15px rgba(7, 192, 95, 0.2);
|
||
}
|
||
}
|
||
|
||
.submit-tips {
|
||
margin-top: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fa8c16;
|
||
font-size: 14px;
|
||
|
||
.tip-icon {
|
||
margin-right: 6px;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 动画定义
|
||
@keyframes spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
// 表单样式优化
|
||
:deep(.t-form-item__label) {
|
||
font-weight: 500;
|
||
color: #333;
|
||
font-size: 14px;
|
||
}
|
||
|
||
:deep(.t-input), :deep(.t-radio-group), :deep(.t-input-number) {
|
||
width: 100%;
|
||
|
||
.t-input__inner {
|
||
border-radius: 6px;
|
||
transition: all 0.3s ease;
|
||
|
||
&:focus {
|
||
border-color: #07c05f;
|
||
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.2);
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.t-input) {
|
||
border-radius: 8px;
|
||
|
||
&.t-is-focused {
|
||
border-color: #07c05f;
|
||
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
|
||
}
|
||
|
||
.t-input__inner {
|
||
border-radius: 8px;
|
||
transition: all 0.2s ease;
|
||
|
||
&:focus {
|
||
border-color: #07c05f;
|
||
box-shadow: none;
|
||
outline: none;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: #07c05f;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|