Browse Source

refactor(frontend): 消息提示统一改用 TDesign 标准组件

- 删除 McpServerManager/ModelConfigManager 手写 fixed 浮层,连接测试结果改用 toast()(MessagePlugin)
- 按钮加载态统一使用 :loading 替代 emoji/纯文字切换(刷新连接、测试连接、获取模型)
- 登录错误、文档详情错误提示改 t-alert;目录树 / AI 执行链加载态改 t-loading
- 导出图片等提示统一走 utils/toast,移除 PipelineFlow 对 MessagePlugin 的直接 import
- 同步清理死代码与孤立样式(components.d.ts 补登记 TAlert)
Spring-AI-1.1.2
wanghanlin 4 days ago
parent
commit
f71e83bf7c
  1. 1
      frontend/components.d.ts
  2. 9
      frontend/src/views/DocDetail.vue
  3. 3
      frontend/src/views/DocList.vue
  4. 3
      frontend/src/views/LoginPage.vue
  5. 19
      frontend/src/views/McpServerManager.vue
  6. 50
      frontend/src/views/ModelConfigManager.vue
  7. 36
      frontend/src/views/PipelineFlow.vue

1
frontend/components.d.ts

@ -14,6 +14,7 @@ declare module 'vue' {
PromptSearchHighlight: typeof import('./src/components/PromptSearchHighlight.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TAlert: typeof import('tdesign-vue-next')['Alert']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']

9
frontend/src/views/DocDetail.vue

@ -19,9 +19,7 @@
<span class="info-label">标签</span>
<t-tag v-for="t in getTags()" :key="t" size="small" variant="light" theme="primary" style="margin-right:4px;">{{ t }}</t-tag>
</div>
<div v-if="doc.errorMessage" class="info-error">
<span class="info-label">错误信息</span><span class="error-text">{{ doc.errorMessage }}</span>
</div>
<t-alert v-if="doc.errorMessage" theme="error" title="错误信息" :message="doc.errorMessage" style="margin-top:8px;" />
</t-tab-panel>
<t-tab-panel value="content" label="📄 原文内容">
@ -36,7 +34,7 @@
</t-tab-panel>
<t-tab-panel value="chunks" label="🧩 分块列表">
<div v-if="chunksError" class="error-msg">{{ chunksError }}</div>
<t-alert v-if="chunksError" theme="error" :message="chunksError" style="margin:8px 0;" />
<div v-else-if="chunks.length" class="chunks-wrapper">
<div class="chunks-summary"> {{ chunks.length }} 个分块</div>
<div v-for="(chunk, idx) in chunks" :key="idx" class="chunk-card">
@ -104,8 +102,6 @@ function getTags(): string[] {
.info-label { width: 70px; flex: none; font-size: 13px; color: var(--color-text-tertiary); }
.info-val { font-size: 13px; color: var(--color-text-primary); word-break: break-all; }
.info-tags { margin-top: 12px; display: flex; align-items: center; }
.info-error { margin-top: 8px; display: flex; align-items: flex-start; gap: 8px; }
.error-text { color: var(--color-error); font-size: 12px; }
/* 原文 */
.raw-content { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; }
@ -125,6 +121,5 @@ function getTags(): string[] {
.chunk-footer { margin-top: 8px; padding-top: 6px; border-top: 1px dashed var(--color-border); display: flex; justify-content: flex-end; }
.chunk-len { font-size: 11px; color: var(--color-text-disabled); }
.error-msg { color: var(--color-error); padding: 8px 0; }
.empty-detail { color: var(--color-text-tertiary); padding: 40px; text-align: center; }
</style>

3
frontend/src/views/DocList.vue

@ -8,7 +8,7 @@
<t-button variant="text" size="small" @click="loadTreeData">刷新</t-button>
</div>
<div class="tree-panel-body">
<div v-if="folderTreeLoading" class="tree-loading">加载中...</div>
<t-loading v-if="folderTreeLoading" text="加载中..." size="small" />
<t-tree v-else
:data="treeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
@ -680,7 +680,6 @@ loadData()
.tree-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.tree-panel-title { font-size: 13px; font-weight: 600; }
.tree-loading { padding: 16px; font-size: 13px; color: var(--td-text-color-placeholder); }
/* 树节点 hover 操作按钮 */
.tree-node { display: flex; align-items: center; width: 100%; min-width: 0; }

3
frontend/src/views/LoginPage.vue

@ -14,7 +14,7 @@
<t-button type="submit" theme="primary" block size="large" :loading="loading"> </t-button>
</t-form-item>
</t-form>
<p class="login-error" v-if="errorMsg">{{ errorMsg }}</p>
<t-alert v-if="errorMsg" theme="error" :message="errorMsg" style="margin-top:12px;" />
</div>
<p class="login-footer">Powered by Spring AI + TDesign</p>
</div>
@ -74,6 +74,5 @@ async function handleLogin() {
}
.login-card h2 { text-align: center; margin-bottom: 6px; color: var(--color-text-primary); }
.login-subtitle { text-align: center; color: var(--color-text-tertiary); font-size: 13px; margin-bottom: 32px; }
.login-error { color: var(--color-error); font-size: 13px; text-align: center; margin-top: 8px; }
.login-footer { color: rgba(255,255,255,.6); font-size: 12px; margin-top: 24px; }
</style>

19
frontend/src/views/McpServerManager.vue

@ -3,7 +3,7 @@
<p class="desc-text">管理 MCP (Model Context Protocol) 服务端配置支持 SSE Stdio 两种传输方式</p>
<div class="toolbar">
<t-button theme="primary" size="small" @click="openAddModal">新建配置</t-button>
<t-button variant="outline" size="small" @click="refreshConnections" :disabled="refreshLoading">{{ refreshLoading?'刷新中...':'刷新连接' }}</t-button>
<t-button variant="outline" size="small" @click="refreshConnections" :loading="refreshLoading">刷新连接</t-button>
<t-button variant="outline" size="small" @click="load(currentPage)">刷新列表</t-button>
<div style="flex:1;" />
<span class="total-hint"> {{ total }} 条配置</span>
@ -16,19 +16,13 @@
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" :disabled="!!row._toggling" @change="toggleActive(row)" /></template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]">{{ testLoading[row.id]?'测试中...':'测试' }}</t-button>
<t-button size="small" variant="text" @click="testConnection(row)" :loading="testLoading[row.id]">测试</t-button>
<t-button size="small" variant="text" @click="openEditModal(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id,row.name)">删除</t-button>
</t-space>
</template>
</BaseTable>
<!-- 测试结果 -->
<div v-if="testResult.visible" class="test-toast" :style="{background:testResult.success?'var(--color-success-text)':'var(--color-error-text)'}">
<strong>{{ testResult.success?'连接成功':'连接失败' }}</strong>
<span v-if="testResult.message" class="test-msg">{{ testResult.message }}</span>
</div>
<!-- 编辑弹窗 -->
<t-dialog v-model:visible="showModal" :header="modalMode==='add'?'新建 MCP 服务配置':'编辑 MCP 服务配置'" width="600px" :footer="false">
<t-form label-align="top">
@ -60,7 +54,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted } from 'vue'
import * as api from '@/api/mcp-server'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
@ -71,14 +65,13 @@ const { confirm } = useConfirm()
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0); const loading=ref(false)
const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false)
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''})
const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null
const testLoading=ref<Record<string,boolean>>({})
const refreshLoading=ref(false)
const columns=[{colKey:'name',title:'名称',width:130,sorter:true},{colKey:'transport_type',title:'传输类型',width:90,sorter:true},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70,sorter:true},{colKey:'description',title:'描述',width:140,ellipsis:true,sorter:true},{colKey:'create_time',title:'创建时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>load())
onUnmounted(()=>{if(testTimer)clearTimeout(testTimer)})
async function load(p=1){currentPage.value=p;loading.value=true
try{const r=await api.listMcpServers(p,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
@ -117,7 +110,7 @@ async function toggleActive(s:any){
async function testConnection(s:any){testLoading.value={...testLoading.value,[s.id]:true}
try{const r=await api.testMcpServer(s.id);showResult(r)}catch(e:any){showResult({success:false,message:e.message})}finally{testLoading.value={...testLoading.value,[s.id]:false}}}
function showResult(r:any){if(testTimer)clearTimeout(testTimer);testResult.value={visible:true,success:r.success,message:r.message||''};testTimer=setTimeout(()=>{testResult.value.visible=false},5000)}
function showResult(r:any){if(r.success){toast('连接成功'+(r.message?':'+r.message:''),'success')}else{toast('连接失败:'+(r.message||'未知错误'),'error')}}
async function refreshConnections(){refreshLoading.value=true
try{const r=await api.refreshMcpServers();if(r.success){toast('连接已刷新','success');load(currentPage.value)}else toast(r.message||'刷新失败','error')}catch(e:any){toast('刷新失败:'+e.message,'error')}finally{refreshLoading.value=false}}
@ -125,4 +118,4 @@ async function refreshConnections(){refreshLoading.value=true
function getDisplayUrl(s:any){if(s.transport_type==='sse')return s.server_url||'-';let d=s.command||'';if(s.args)d+=' '+(Array.isArray(s.args)?s.args.join(' '):s.args);return d||'-'}
</script>
<style scoped>
.ellipsis{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;}.test-toast{position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:8px;z-index:9999;color:var(--td-text-color-anti);max-width:400px;box-shadow:0 4px 12px rgba(0,0,0,.15);}.test-msg{font-size:12px;margin-top:4px;opacity:.9;display:block;}</style>
.ellipsis{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;}</style>

50
frontend/src/views/ModelConfigManager.vue

@ -35,13 +35,12 @@
<div v-if="fallbackChains.length > 0" class="fallback-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:13px;font-weight:600;color:var(--color-info-text-strong);">🔗 Fallback 按优先级排列</span>
<t-tag size="small" variant="light" theme="primary" style="cursor:pointer;" @click="showFallbackHelp = !showFallbackHelp"> 帮助</t-tag>
</div>
<div v-if="showFallbackHelp" class="fallback-help">
<div style="font-weight:600;color:var(--color-info-text-strong);margin-bottom:8px;">📖 Fallback 链使用说明</div>
<p>Fallback 链是<span style="color:var(--color-success-text-strong);font-weight:600;">高可用性机制</span>主模型故障时自动切换到备用模型</p>
<p> 勾选设为活跃配置启用 | 📊 调整优先级数值越大越优先| 🔀 拖拽列表项快速调整</p>
<t-button size="small" variant="text" @click="showFallbackHelp = !showFallbackHelp"> 帮助</t-button>
</div>
<t-alert v-if="showFallbackHelp" theme="info" title="📖 Fallback 链使用说明" style="margin:0 0 8px;">
<p style="margin:4px 0;">Fallback 链是<span style="color:var(--color-success-text-strong);font-weight:600;">高可用性机制</span>主模型故障时自动切换到备用模型</p>
<p style="margin:4px 0;"> 勾选设为活跃配置启用 | 📊 调整优先级数值越大越优先| 🔀 拖拽列表项快速调整</p>
</t-alert>
<div v-for="chain in fallbackChains" :key="chain.appType" style="margin-bottom:8px;">
<div style="font-size:12px;color:var(--color-info-text-strong);font-weight:600;">{{ getAppTypeLabel(chain.appType) }}</div>
<div v-for="(item, idx) in chain.configs" :key="item.id"
@ -96,7 +95,7 @@
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="openEditModal(row)" title="编辑"></t-button>
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]" title="测试连接">{{ testLoading[row.id] ? '⏳' : '🔌' }}</t-button>
<t-button size="small" variant="text" @click="testConnection(row)" :loading="testLoading[row.id]" title="测试连接">测试</t-button>
<t-button size="small" variant="text" @click="duplicateConfig(row)" title="复制">📋</t-button>
<t-button v-show="!row.is_active" size="small" variant="text" theme="primary" @click="activate(row.id)" title="激活"></t-button>
<t-button v-show="row.is_active" size="small" variant="text" @click="deactivateConfig(row.id)" title="停用"></t-button>
@ -105,15 +104,6 @@
</template>
</BaseTable>
<!-- F1: 测试结果提示 -->
<div v-if="testResult.visible" class="test-result-toast"
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
background: testResult.success ? 'var(--color-success-text)' : 'var(--color-error-text)' }">
<div style="font-weight:600;">{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}</div>
<div style="font-size:12px;margin-top:4px;">延迟{{ testResult.latencyMs }}ms<span v-if="testResult.dimensions"> · 维度{{ testResult.dimensions }}</span></div>
<div v-if="testResult.errorMessage" style="font-size:11px;margin-top:4px;opacity:0.9;">{{ testResult.errorMessage }}</div>
</div>
<!-- ==================== 编辑/新建弹窗 ==================== -->
<t-dialog
v-model:visible="editModal.visible"
@ -168,9 +158,9 @@
@click="editModal.form.model_name = ''; modelDropdownOpen = false; updateNameSuggestion()" style="color:var(--color-text-tertiary);"></t-button>
</template>
</t-input>
<t-button variant="outline" size="small" @click="fetchModelsFromApi" :disabled="fetchLoading"
<t-button variant="outline" size="small" @click="fetchModelsFromApi" :loading="fetchLoading"
style="border-color:var(--td-brand-color);color:var(--td-brand-color);white-space:nowrap;">
{{ fetchLoading ? '⏳ 获取中...' : '🔍 获取模型' }}
获取模型
</t-button>
</t-space>
<!-- 自定义下拉列表 -->
@ -264,8 +254,8 @@
<div class="dialog-footer" style="gap:10px;margin-top:20px;">
<t-button variant="outline" @click="editModal.visible = false">取消</t-button>
<t-button v-if="editModal.mode === 'edit'" variant="outline" @click="testConnectionById(editModal.editId!)"
:disabled="testLoading[editModal.editId!]" style="border-color:var(--td-brand-color);color:var(--td-brand-color);">
{{ testLoading[editModal.editId!] ? '⏳ 测试中...' : '🔌 测试连接' }}
:loading="testLoading[editModal.editId!]" style="border-color:var(--td-brand-color);color:var(--td-brand-color);">
测试连接
</t-button>
<t-button theme="primary" @click="saveConfig">💾 保存</t-button>
</div>
@ -298,8 +288,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import * as api from '@/api/model-config'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
@ -396,8 +385,6 @@ let healthTimer: any = null
//
const testLoading = ref<Record<string, boolean>>({})
const testResult = reactive({ visible: false, success: false, latencyMs: 0, dimensions: null as any, errorMessage: null as any })
let testResultTimer: any = null
// Fallback
const fallbackChains = ref<any[]>([])
@ -645,9 +632,14 @@ async function testConnectionById(id: string) {
}
function showTestResult(r: any) {
if (testResultTimer) clearTimeout(testResultTimer)
testResult.visible = true; testResult.success = r.success; testResult.latencyMs = r.latencyMs || 0; testResult.dimensions = r.dimensions || null; testResult.errorMessage = r.errorMessage || r.message || null
testResultTimer = setTimeout(() => { testResult.visible = false }, 5000)
if (r.success) {
let msg = '连接成功'
if (r.latencyMs) msg += '(延迟 ' + r.latencyMs + 'ms' + (r.dimensions ? ' · 维度 ' + r.dimensions : '') + ')'
toast(msg, 'success')
} else {
const reason = r.errorMessage || r.message
toast('连接失败' + (reason ? ':' + reason : ''), 'error')
}
}
// ==================== /// ====================
@ -709,11 +701,9 @@ function getProviderBaseUrl(p: string) { return PROVIDER_BASE_URLS[p] || '' }
// ==================== ====================
onMounted(() => { load(); healthTimer = setInterval(loadHealthStatus, 30000); document.addEventListener('click', onDocumentClick, true) })
onUnmounted(() => { clearInterval(healthTimer); clearTimeout(testResultTimer); document.removeEventListener('click', onDocumentClick, true) })
onUnmounted(() => { clearInterval(healthTimer); document.removeEventListener('click', onDocumentClick, true) })
</script>
<style scoped>
.fallback-section { margin:12px 0; padding:12px; background:var(--color-info-bg); border:1px solid var(--color-info-border); border-radius:8px; }
.fallback-help { margin-bottom:12px; padding:12px; background:white; border:1px solid var(--color-info-border); border-radius:6px; font-size:12px; line-height:1.6; color:var(--color-text-primary); }
.fallback-help p { margin:4px 0; }
</style>

36
frontend/src/views/PipelineFlow.vue

@ -26,13 +26,11 @@
<!-- 流程图 -->
<div style="margin-top:16px;">
<div v-if="error" class="pipeline-error">
<p> 图表渲染失败: {{ error }}</p>
<t-button variant="outline" size="small" @click="retry">重试</t-button>
</div>
<div v-else-if="!svg" class="pipeline-loading">
<p> 正在生成流程图...</p>
</div>
<t-alert v-if="error" theme="error">
<template #default>图表渲染失败{{ error }}</template>
<template #operation><t-button variant="text" size="small" @click="retry">重试</t-button></template>
</t-alert>
<t-loading v-else-if="!svg" text="正在生成流程图..." style="text-align:center;padding:40px 0;" />
<div v-else class="pipeline-diagram" v-html="svg" />
</div>
</t-card>
@ -40,7 +38,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { toast } from '@/utils/toast'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import mermaid from 'mermaid'
import { palette, paletteBg, paletteBorder } from '@/utils/palette'
@ -181,7 +179,7 @@ function retry() {
async function exportImage() {
const svgEl = document.querySelector('.pipeline-diagram svg') as SVGSVGElement | null
if (!svgEl) {
MessagePlugin.warning('流程图尚未渲染完成,请稍后再试')
toast('流程图尚未渲染完成,请稍后再试', 'warning')
return
}
@ -231,7 +229,7 @@ async function exportImage() {
link.download = `ai-执行链-${timestamp}.svg`
link.click()
URL.revokeObjectURL(svgUrl)
MessagePlugin.warning('PNG 导出受限,已降级为 SVG 矢量图下载')
toast('PNG 导出受限,已降级为 SVG 矢量图下载', 'warning')
}
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
@ -248,7 +246,7 @@ async function exportImage() {
const ctx = canvas.getContext('2d')
if (!ctx) {
URL.revokeObjectURL(url)
MessagePlugin.error('Canvas 上下文创建失败')
toast('Canvas 上下文创建失败', 'error')
return
}
// /线
@ -262,7 +260,7 @@ async function exportImage() {
link.href = pngUrl
link.download = `ai-执行链-${timestamp}.png`
link.click()
MessagePlugin.success('图片导出成功')
toast('图片导出成功', 'success')
} catch (e: any) {
URL.revokeObjectURL(url)
console.error('Canvas 导出失败,降级为 SVG:', e)
@ -278,7 +276,7 @@ async function exportImage() {
} catch (e: any) {
if (url) URL.revokeObjectURL(url)
console.error('导出图片失败:', e)
MessagePlugin.error(e.message || '导出失败')
toast(e.message || '导出失败', 'error')
}
}
@ -327,16 +325,4 @@ onMounted(() => {
display: block;
margin: 0 auto;
}
.pipeline-loading {
text-align: center;
padding: 40px;
color: var(--color-text-tertiary);
font-size: 14px;
}
.pipeline-error {
text-align: center;
padding: 24px;
color: var(--color-error);
font-size: 14px;
}
</style>
Loading…
Cancel
Save