You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
123 lines
10 KiB
123 lines
10 KiB
<template>
|
|
<t-card title="🔌 MCP 服务管理" :bordered="false">
|
|
<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="load(currentPage)">刷新列表</t-button>
|
|
<div style="flex:1;" />
|
|
<span class="total-hint">共 {{ total }} 条配置</span>
|
|
</div>
|
|
|
|
<t-table :data="servers" :columns="columns" row-key="id"
|
|
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
|
|
<template #transport_type="{ row }"><t-tag size="small" :theme="row.transport_type==='sse'?'primary':'default'" variant="light">{{ row.transport_type==='sse'?'SSE':'Stdio' }}</t-tag></template>
|
|
<template #displayUrl="{ row }"><span class="ellipsis" :title="getDisplayUrl(row)">{{ getDisplayUrl(row)||'-' }}</span></template>
|
|
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" @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="openEditModal(row)">编辑</t-button>
|
|
<t-button size="small" variant="text" theme="danger" @click="remove(row.id,row.name)">删除</t-button>
|
|
</t-space>
|
|
</template>
|
|
</t-table>
|
|
|
|
<!-- 测试结果 -->
|
|
<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">
|
|
<t-form-item label="服务名称 *"><t-input v-model="form.name" placeholder="如:天气查询服务" /></t-form-item>
|
|
<t-form-item label="传输类型 *">
|
|
<t-radio-group v-model="form.transportType">
|
|
<t-radio-button value="sse">SSE(HTTP 服务)</t-radio-button>
|
|
<t-radio-button value="stdio">Stdio(本地进程)</t-radio-button>
|
|
</t-radio-group>
|
|
</t-form-item>
|
|
<t-form-item v-if="form.transportType==='sse'" label="Server URL *"><t-input v-model="form.url" placeholder="如:http://localhost:3001/sse" /></t-form-item>
|
|
<t-form-item v-if="form.transportType==='sse'" label="请求头(可选)">
|
|
<t-textarea v-model="form.headersText" :autosize="{minRows:2,maxRows:4}" placeholder="Authorization: Bearer sk-xxx\nX-API-Key: abc123" />
|
|
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:4px;">💡 敏感头(Authorization 等)已脱敏,未修改则保留原值</div>
|
|
</t-form-item>
|
|
<template v-if="form.transportType==='stdio'">
|
|
<t-form-item label="Command *"><t-input v-model="form.command" placeholder="如:npx, python, node" /></t-form-item>
|
|
<t-form-item label="Args(每行一个)"><t-textarea v-model="form.argsText" :autosize="{minRows:2,maxRows:4}" placeholder="-m\nmcp-server-fetch" /></t-form-item>
|
|
</template>
|
|
<t-form-item label="环境变量(可选)"><t-textarea v-model="form.envText" :autosize="{minRows:2,maxRows:3}" placeholder="KEY1=value1\nKEY2=value2" /></t-form-item>
|
|
<t-form-item label="描述说明"><t-textarea v-model="form.description" :autosize="{minRows:2,maxRows:3}" placeholder="可选" /></t-form-item>
|
|
</t-form>
|
|
<div class="dialog-footer">
|
|
<t-button variant="outline" @click="showModal=false">取消</t-button>
|
|
<t-button theme="primary" @click="save" :loading="saving">保存</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
</t-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
import * as api from '@/api/mcp-server'
|
|
import { toast } from '@/utils/toast'
|
|
import { formatDate } from '@/utils/format'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
|
|
const { confirm } = useConfirm()
|
|
|
|
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0)
|
|
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 refreshLoading=ref(false)
|
|
|
|
const columns=[{colKey:'name',title:'名称',width:130},{colKey:'transport_type',title:'传输类型',width:90},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70},{colKey:'description',title:'描述',width:140,ellipsis:true},{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
|
|
|
|
onMounted(()=>load())
|
|
onUnmounted(()=>{if(testTimer)clearTimeout(testTimer)})
|
|
|
|
async function load(p=1){currentPage.value=p
|
|
try{const r=await api.listMcpServers(p,pageSize.value);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')}}
|
|
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
|
|
|
|
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}}
|
|
function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}
|
|
function openEditModal(s:any){modalMode.value='edit';editId.value=s.id
|
|
let at='',et='',ht=''
|
|
if(s.args){if(Array.isArray(s.args))at=s.args.join('\n');else{try{const p=JSON.parse(s.args);if(Array.isArray(p))at=p.join('\n');else at=s.args}catch{at=s.args}}}
|
|
if(s.env_vars){if(typeof s.env_vars==='object')et=Object.entries(s.env_vars).map(([k,v])=>k+'='+v).join('\n');else{try{const p=JSON.parse(s.env_vars);if(typeof p==='object')et=Object.entries(p).map(([k,v]:any)=>k+'='+v).join('\n');else et=s.env_vars}catch{et=s.env_vars}}}
|
|
if(s.headers){if(typeof s.headers==='object')ht=Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n');else{try{const p=JSON.parse(s.headers);if(typeof p==='object')ht=Object.entries(p).map(([k,v]:any)=>k+': '+v).join('\n');else ht=s.headers}catch{ht=s.headers}}}
|
|
form.value={name:s.name||'',transportType:s.transport_type||'sse',url:s.server_url||'',command:s.command||'',argsText:at,envText:et,headersText:ht,description:s.description||''};showModal.value=true}
|
|
|
|
async function save(){
|
|
const f=form.value;if(!f.name.trim()){toast('请填写服务名称','error');return}
|
|
if(f.transportType==='sse'){if(!f.url.trim()){toast('请填写 URL','error');return}}else{if(!f.command.trim()){toast('请填写 Command','error');return}}
|
|
const d:any={name:f.name,transportType:f.transportType,description:f.description}
|
|
if(f.transportType==='sse'){d.serverUrl=f.url;if(f.headersText.trim()){const h:any={};f.headersText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf(':');if(i>0)h[t.substring(0,i).trim()]=t.substring(i+1).trim()});d.headers=h}}
|
|
else{d.command=f.command;d.args=f.argsText.trim()?f.argsText.split('\n').map(s=>s.trim()).filter(Boolean):[]}
|
|
if(f.envText.trim()){const e:any={};f.envText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf('=');if(i>0)e[t.substring(0,i)]=t.substring(i+1)});d.envVars=e}
|
|
saving.value=true
|
|
try{let r;if(modalMode.value==='add')r=await api.createMcpServer(d);else r=await api.updateMcpServer(editId.value,d)
|
|
if(r.success){toast(modalMode.value==='add'?'创建成功':'更新成功','success');showModal.value=false;load(currentPage.value)}else toast(r.message||'操作失败','error')}catch(e:any){toast('保存失败:'+e.message,'error')}finally{saving.value=false}}
|
|
|
|
async function remove(id:string,name:string){if(!await confirm('确定删除「'+(name||id)+'」?'))return
|
|
try{const r=await api.deleteMcpServer(id);if(r.success){toast('删除成功','success');load(currentPage.value)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
|
|
|
|
async function toggleActive(s:any){const na=!s.is_active
|
|
try{const r=await api.toggleMcpServer(s.id,na);if(r.success){toast(na?'已启用':'已禁用','success');s.is_active=na;s._active=na}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
|
|
|
|
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)}
|
|
|
|
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}}
|
|
|
|
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>
|