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.
524 lines
18 KiB
524 lines
18 KiB
/**
|
|
* MCP 服务管理组件
|
|
* 支持 SSE / stdio 两种传输类型的 MCP Server 配置管理
|
|
* 功能:列表展示、新建/编辑/删除、启用/禁用切换、测试连接、刷新连接、分页
|
|
*/
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import * as api from '../js/api.js'
|
|
import { toast, formatDate } from '../js/utils.js'
|
|
|
|
// 传输类型选项
|
|
const TRANSPORT_OPTIONS = [
|
|
{ value: 'sse', label: 'SSE(HTTP 服务)' },
|
|
{ value: 'stdio', label: 'Stdio(本地进程)' }
|
|
]
|
|
|
|
export default {
|
|
template: `
|
|
<div class="card">
|
|
<h2>MCP 服务管理</h2>
|
|
<p style="font-size:13px;color:var(--sub);margin:-4px 0 16px;">
|
|
管理 MCP (Model Context Protocol) 服务端配置,支持 SSE 和 Stdio 两种传输方式
|
|
</p>
|
|
|
|
<!-- 操作栏 -->
|
|
<div class="input-row" style="flex-wrap:wrap;margin-bottom:16px;">
|
|
<button class="btn btn-primary btn-sm" @click="openAddModal">新建配置</button>
|
|
<button class="btn btn-outline btn-sm" @click="refreshConnections" :disabled="refreshLoading">
|
|
{{ refreshLoading ? '刷新中...' : '刷新连接' }}
|
|
</button>
|
|
<button class="btn btn-outline btn-sm" @click="load(currentPage)">刷新列表</button>
|
|
<span style="margin-left:auto;font-size:12px;color:var(--sub);">
|
|
共 {{ total }} 条配置
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 列表表格 -->
|
|
<div style="overflow-x:auto;">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>名称</th>
|
|
<th>传输类型</th>
|
|
<th>URL / 命令</th>
|
|
<th>启用状态</th>
|
|
<th>描述</th>
|
|
<th>创建时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="servers.length === 0">
|
|
<td colspan="7" style="text-align:center;color:var(--sub);">暂无 MCP 服务配置</td>
|
|
</tr>
|
|
<tr v-for="s in servers" :key="s.id">
|
|
<td><strong>{{ s.name || '-' }}</strong></td>
|
|
<td>
|
|
<span class="badge" :class="s.transport_type === 'sse' ? 'badge-get' : ''">
|
|
{{ s.transport_type === 'sse' ? 'SSE' : 'Stdio' }}
|
|
</span>
|
|
</td>
|
|
<td style="font-size:12px;color:#6b7280;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
|
:title="getDisplayUrl(s)">
|
|
{{ getDisplayUrl(s) || '-' }}
|
|
</td>
|
|
<td>
|
|
<label class="toggle-switch" :title="s.is_active ? '已启用' : '已禁用'">
|
|
<input type="checkbox" :checked="s.is_active" @change="toggleActive(s)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td style="font-size:12px;color:#6b7280;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
|
:title="s.description">
|
|
{{ s.description || '-' }}
|
|
</td>
|
|
<td style="font-size:12px;color:#6b7280;">{{ formatDate(s.create_time) }}</td>
|
|
<td style="white-space:nowrap;">
|
|
<button class="btn btn-sm btn-outline" @click="testConnection(s)" :disabled="testLoading[s.id]" title="测试连接">
|
|
{{ testLoading[s.id] ? '测试中...' : '测试' }}
|
|
</button>
|
|
<button class="btn btn-sm btn-outline" @click="openEditModal(s)" title="编辑">编辑</button>
|
|
<button class="btn btn-sm btn-danger" @click="remove(s.id, s.name)" title="删除">删除</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 分页 -->
|
|
<div class="pagination" v-if="totalPages > 1">
|
|
<button :disabled="currentPage <= 1" @click="load(currentPage - 1)">上一页</button>
|
|
<template v-for="i in totalPages" :key="i">
|
|
<button v-if="i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)"
|
|
:class="{ active: i === currentPage }" @click="load(i)">{{ i }}</button>
|
|
<span v-else-if="i === currentPage - 3 || i === currentPage + 3" style="padding:6px;">...</span>
|
|
</template>
|
|
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button>
|
|
</div>
|
|
|
|
<!-- 测试结果提示 -->
|
|
<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 ? '#16a34a' : '#dc2626' }">
|
|
<div style="font-weight:600;">{{ testResult.success ? '连接成功' : '连接失败' }}</div>
|
|
<div v-if="testResult.message" style="font-size:12px;margin-top:4px;opacity:0.9;">{{ testResult.message }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新建/编辑弹窗 -->
|
|
<div class="modal-overlay" :class="{ active: showModal }">
|
|
<div class="modal-box" style="max-width:600px;">
|
|
<button class="modal-close" @click="closeModal">×</button>
|
|
<h2 style="margin-bottom:16px;">{{ modalMode === 'add' ? '新建 MCP 服务配置' : '编辑 MCP 服务配置' }}</h2>
|
|
|
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
|
<!-- 名称 -->
|
|
<div>
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
|
|
服务名称 <span style="color:#dc2626;">*</span>
|
|
</label>
|
|
<input type="text" class="input" v-model.trim="form.name" placeholder="如:天气查询服务">
|
|
</div>
|
|
|
|
<!-- 传输类型 -->
|
|
<div>
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">
|
|
传输类型 <span style="color:#dc2626;">*</span>
|
|
</label>
|
|
<div style="display:flex;gap:8px;">
|
|
<button v-for="opt in transportOptions" :key="opt.value"
|
|
type="button"
|
|
@click="form.transportType = opt.value"
|
|
style="padding:6px 16px;border-radius:6px;font-size:13px;cursor:pointer;transition:all 0.15s;"
|
|
:style="form.transportType === opt.value
|
|
? 'background:#2563eb;color:white;border:2px solid #2563eb;font-weight:600;'
|
|
: 'background:white;color:#374151;border:2px solid #e5e7eb;font-weight:400;'">
|
|
{{ opt.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SSE 模式:URL -->
|
|
<div v-if="form.transportType === 'sse'">
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
|
|
Server URL <span style="color:#dc2626;">*</span>
|
|
</label>
|
|
<input type="text" class="input" v-model.trim="form.url" placeholder="如:http://localhost:3001/sse">
|
|
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
|
|
MCP 服务的 SSE 端点地址
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stdio 模式:Command + Args -->
|
|
<template v-if="form.transportType === 'stdio'">
|
|
<div>
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
|
|
Command <span style="color:#dc2626;">*</span>
|
|
</label>
|
|
<input type="text" class="input" v-model.trim="form.command" placeholder="如:npx, python, node">
|
|
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
|
|
启动 MCP 服务的可执行命令
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
|
|
Args(命令参数,每行一个)
|
|
</label>
|
|
<textarea class="input" v-model="form.argsText" rows="3"
|
|
placeholder="-m mcp-server-fetch --verbose"></textarea>
|
|
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
|
|
每行一个参数,留空则不传参
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 环境变量 -->
|
|
<div>
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
|
|
环境变量(可选)
|
|
</label>
|
|
<textarea class="input" v-model="form.envText" rows="2"
|
|
placeholder="KEY1=value1 KEY2=value2"></textarea>
|
|
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
|
|
格式:KEY=value,每行一组,留空则不设置
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 描述 -->
|
|
<div>
|
|
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label>
|
|
<textarea class="input" v-model.trim="form.description" rows="2" placeholder="可选,填写服务用途说明"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 按钮 -->
|
|
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
|
|
<button class="btn btn-outline" @click="closeModal">取消</button>
|
|
<button class="btn btn-primary" @click="save" :disabled="saving">
|
|
{{ saving ? '保存中...' : '保存' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
|
|
setup() {
|
|
// ==================== 列表状态 ====================
|
|
const servers = ref([])
|
|
const currentPage = ref(1)
|
|
const totalPages = ref(1)
|
|
const total = ref(0)
|
|
const pageSize = 10
|
|
|
|
// ==================== 弹窗状态 ====================
|
|
const showModal = ref(false)
|
|
const modalMode = ref('add') // 'add' | 'edit'
|
|
const editId = ref(null)
|
|
const saving = ref(false)
|
|
|
|
// 表单数据
|
|
const form = ref(createEmptyForm())
|
|
|
|
function createEmptyForm() {
|
|
return {
|
|
name: '',
|
|
transportType: 'sse',
|
|
url: '',
|
|
command: '',
|
|
argsText: '',
|
|
envText: '',
|
|
description: ''
|
|
}
|
|
}
|
|
|
|
// ==================== 测试连接状态 ====================
|
|
const testLoading = ref({})
|
|
const testResult = ref({ visible: false, success: false, message: '' })
|
|
let testResultTimer = null
|
|
|
|
// ==================== 刷新连接状态 ====================
|
|
const refreshLoading = ref(false)
|
|
|
|
// ==================== 传输类型选项 ====================
|
|
const transportOptions = TRANSPORT_OPTIONS
|
|
|
|
// ==================== 数据加载 ====================
|
|
|
|
async function load(p = 1) {
|
|
currentPage.value = p
|
|
try {
|
|
const json = await api.listMcpServers(p, pageSize)
|
|
if (json.success) {
|
|
servers.value = json.data || []
|
|
total.value = json.total || 0
|
|
totalPages.value = json.pages || 1
|
|
} else {
|
|
toast(json.message || '查询失败', 'error')
|
|
}
|
|
} catch (e) {
|
|
toast('加载列表失败:' + e.message, 'error')
|
|
}
|
|
}
|
|
|
|
// ==================== 弹窗操作 ====================
|
|
|
|
function openAddModal() {
|
|
modalMode.value = 'add'
|
|
editId.value = null
|
|
form.value = createEmptyForm()
|
|
showModal.value = true
|
|
}
|
|
|
|
function openEditModal(server) {
|
|
modalMode.value = 'edit'
|
|
editId.value = server.id
|
|
|
|
// 解析 args 数组为换行文本
|
|
let argsText = ''
|
|
if (server.args && Array.isArray(server.args)) {
|
|
argsText = server.args.join('\n')
|
|
} else if (typeof server.args === 'string' && server.args) {
|
|
try {
|
|
const parsed = JSON.parse(server.args)
|
|
if (Array.isArray(parsed)) argsText = parsed.join('\n')
|
|
else argsText = server.args
|
|
} catch (e) {
|
|
argsText = server.args
|
|
}
|
|
}
|
|
|
|
// 解析环境变量对象为换行文本
|
|
let envText = ''
|
|
if (server.env_vars && typeof server.env_vars === 'object') {
|
|
envText = Object.entries(server.env_vars).map(([k, v]) => k + '=' + v).join('\n')
|
|
} else if (typeof server.env_vars === 'string' && server.env_vars) {
|
|
try {
|
|
const parsed = JSON.parse(server.env_vars)
|
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
envText = Object.entries(parsed).map(([k, v]) => k + '=' + v).join('\n')
|
|
} else {
|
|
envText = server.env_vars
|
|
}
|
|
} catch (e) {
|
|
envText = server.env_vars
|
|
}
|
|
}
|
|
|
|
form.value = {
|
|
name: server.name || '',
|
|
transportType: server.transport_type || 'sse',
|
|
url: server.server_url || '',
|
|
command: server.command || '',
|
|
argsText: argsText,
|
|
envText: envText,
|
|
description: server.description || ''
|
|
}
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
}
|
|
|
|
// ==================== 保存 ====================
|
|
|
|
function buildSaveData() {
|
|
const f = form.value
|
|
const data = {
|
|
name: f.name,
|
|
transportType: f.transportType,
|
|
description: f.description
|
|
}
|
|
|
|
if (f.transportType === 'sse') {
|
|
data.serverUrl = f.url
|
|
} else {
|
|
data.command = f.command
|
|
// 解析 args:按换行拆分,过滤空行
|
|
if (f.argsText && f.argsText.trim()) {
|
|
data.args = f.argsText.split('\n').map(s => s.trim()).filter(Boolean)
|
|
} else {
|
|
data.args = []
|
|
}
|
|
}
|
|
|
|
// 解析环境变量:按换行拆分,解析 KEY=value
|
|
if (f.envText && f.envText.trim()) {
|
|
const envObj = {}
|
|
f.envText.split('\n').forEach(line => {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) return
|
|
const eqIdx = trimmed.indexOf('=')
|
|
if (eqIdx > 0) {
|
|
envObj[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1)
|
|
}
|
|
})
|
|
data.envVars = envObj
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
async function save() {
|
|
const f = form.value
|
|
|
|
// 校验
|
|
if (!f.name || !f.name.trim()) {
|
|
toast('请填写服务名称', 'error')
|
|
return
|
|
}
|
|
if (f.transportType === 'sse') {
|
|
if (!f.url || !f.url.trim()) {
|
|
toast('请填写 Server URL', 'error')
|
|
return
|
|
}
|
|
} else {
|
|
if (!f.command || !f.command.trim()) {
|
|
toast('请填写 Command', 'error')
|
|
return
|
|
}
|
|
}
|
|
|
|
saving.value = true
|
|
try {
|
|
const data = buildSaveData()
|
|
let json
|
|
if (modalMode.value === 'add') {
|
|
json = await api.createMcpServer(data)
|
|
} else {
|
|
json = await api.updateMcpServer(editId.value, data)
|
|
}
|
|
|
|
if (json.success) {
|
|
toast(modalMode.value === 'add' ? '创建成功' : '更新成功', 'success')
|
|
closeModal()
|
|
load(currentPage.value)
|
|
} else {
|
|
toast(json.message || '操作失败', 'error')
|
|
}
|
|
} catch (e) {
|
|
toast('保存失败:' + e.message, 'error')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ==================== 删除 ====================
|
|
|
|
async function remove(id, name) {
|
|
if (!confirm('确定删除配置「' + (name || id) + '」?删除后不可恢复。')) return
|
|
try {
|
|
const json = await api.deleteMcpServer(id)
|
|
if (json.success) {
|
|
toast('删除成功', 'success')
|
|
load(currentPage.value)
|
|
} else {
|
|
toast(json.message || '删除失败', 'error')
|
|
}
|
|
} catch (e) {
|
|
toast('删除失败:' + e.message, 'error')
|
|
}
|
|
}
|
|
|
|
// ==================== 启用/禁用 ====================
|
|
|
|
async function toggleActive(server) {
|
|
const newActive = !server.is_active
|
|
try {
|
|
const json = await api.toggleMcpServer(server.id, newActive)
|
|
if (json.success) {
|
|
toast(newActive ? '已启用' : '已禁用', 'success')
|
|
// 乐观更新本地状态,避免刷新列表
|
|
server.is_active = newActive
|
|
} else {
|
|
toast(json.message || '操作失败', 'error')
|
|
}
|
|
} catch (e) {
|
|
toast('操作失败:' + e.message, 'error')
|
|
}
|
|
}
|
|
|
|
// ==================== 测试连接 ====================
|
|
|
|
async function testConnection(server) {
|
|
testLoading.value = { ...testLoading.value, [server.id]: true }
|
|
try {
|
|
const json = await api.testMcpServer(server.id)
|
|
showTestResult(json)
|
|
} catch (e) {
|
|
showTestResult({ success: false, message: e.message })
|
|
} finally {
|
|
testLoading.value = { ...testLoading.value, [server.id]: false }
|
|
}
|
|
}
|
|
|
|
function showTestResult(result) {
|
|
if (testResultTimer) clearTimeout(testResultTimer)
|
|
testResult.value = {
|
|
visible: true,
|
|
success: result.success,
|
|
message: result.message || ''
|
|
}
|
|
testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000)
|
|
}
|
|
|
|
// ==================== 刷新连接 ====================
|
|
|
|
async function refreshConnections() {
|
|
refreshLoading.value = true
|
|
try {
|
|
const json = await api.refreshMcpServers()
|
|
if (json.success) {
|
|
toast('连接已刷新', 'success')
|
|
load(currentPage.value)
|
|
} else {
|
|
toast(json.message || '刷新失败', 'error')
|
|
}
|
|
} catch (e) {
|
|
toast('刷新失败:' + e.message, 'error')
|
|
} finally {
|
|
refreshLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
function getDisplayUrl(server) {
|
|
if (server.transport_type === 'sse') {
|
|
return server.server_url || '-'
|
|
}
|
|
// stdio 模式:显示 command + args
|
|
let display = server.command || ''
|
|
if (server.args) {
|
|
// args 可能是字符串或数组
|
|
const argsStr = Array.isArray(server.args) ? server.args.join(' ') : server.args
|
|
display += ' ' + argsStr
|
|
}
|
|
return display || '-'
|
|
}
|
|
|
|
// ==================== 生命周期 ====================
|
|
|
|
onMounted(() => {
|
|
load()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (testResultTimer) clearTimeout(testResultTimer)
|
|
})
|
|
|
|
return {
|
|
// 列表
|
|
servers, currentPage, totalPages, total,
|
|
// 弹窗
|
|
showModal, modalMode, form, saving, transportOptions,
|
|
// 测试
|
|
testLoading, testResult,
|
|
// 刷新
|
|
refreshLoading,
|
|
// 方法
|
|
load, openAddModal, openEditModal, closeModal, save, remove,
|
|
toggleActive, testConnection, refreshConnections, getDisplayUrl, formatDate
|
|
}
|
|
}
|
|
}
|