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.
 
 
 
 
 
 

269 lines
10 KiB

<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">MCP 配置</h2>
<p class="text-xs text-slate-400 mt-1">MCP(模型上下文协议)服务器,按区域分组,所有应用共享。修改后各服务约 10 分钟内自动同步。</p>
</div>
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增 MCP 服务</button>
</div>
<div class="flex items-center gap-3 mb-4">
<span class="text-sm text-slate-500">区域筛选</span>
<select v-model.number="regionFilter" class="select select-bordered select-sm" @change="loadData">
<option :value="0">全部区域</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<input v-model="kwFilter" class="input input-bordered input-sm w-48" placeholder="按ID/名称搜索" />
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>服务ID</th><th>名称</th><th>区域</th><th>类型</th><th>地址</th><th>工具</th><th>状态</th><th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!filteredRows.length">
<td colspan="8" class="text-center text-slate-400 py-8">暂无 MCP 服务</td>
</tr>
<tr v-for="s in filteredRows" :key="s.id">
<td class="font-mono text-sm font-medium">{{ s.id || '' }}</td>
<td class="text-sm">{{ s.name || '' }}</td>
<td><span class="badge badge-ghost badge-sm">{{ regionLabel(s.region) }}</span></td>
<td><span class="badge badge-sm badge-outline">{{ typeLabel(s.type) }}</span></td>
<td class="text-sm text-slate-500 max-w-xs truncate" :title="s.url || ''">{{ s.url || '' }}</td>
<td class="text-sm text-slate-400">
<span v-if="s.tools && s.tools.trim()">{{ s.tools }}</span>
<span v-else class="text-slate-300">全部</span>
</td>
<td>
<span v-if="s.enable" class="badge badge-success badge-sm text-white">启用</span>
<span v-else class="badge badge-ghost badge-sm">停用</span>
</td>
<td class="text-right">
<div class="inline-flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(s)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delMcp(s.id)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑 MCP 服务' : '新增 MCP 服务' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveMcp">
<div class="grid grid-cols-2 gap-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">服务ID *</span></label>
<input
v-model="form.id"
type="text"
class="input input-bordered w-full"
:class="{ 'bg-slate-100': isEditing }"
:readonly="isEditing"
required
placeholder="如 gaode_map"
/>
<span class="text-xs text-slate-400 mt-1">{{ isEditing ? '编辑模式下不可修改' : '唯一标识,创建后不可修改' }}</span>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">名称</span></label>
<input v-model="form.name" type="text" class="input input-bordered w-full" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">区域 *</span></label>
<select v-model.number="form.region" class="select select-bordered w-full">
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">类型</span></label>
<select v-model.number="form.type" class="select select-bordered w-full">
<option :value="0">HTTP</option>
<option :value="1">SSE</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">服务地址 (url)</span></label>
<input v-model="form.url" type="text" class="input input-bordered w-full font-mono text-sm" placeholder="https://..." />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">启用工具 (tools)</span></label>
<input v-model="form.tools" type="text" class="input input-bordered w-full" placeholder="逗号分隔,留空表示全部启用" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="form-control mb-6">
<label class="label cursor-pointer justify-start gap-3">
<input v-model="form.enable" type="checkbox" class="toggle toggle-primary" />
<span class="label-text font-medium">启用该服务</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: 'MCP配置' })
interface McpServer {
id: string
name: string
region: number
type: number
url: string
tools: string
description: string
enable: boolean
}
// 区域枚举(与 pb.Region 一一对应)
const REGIONS = [
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
function regionLabel(v?: number): string {
const n = Number(v) || 0
const r = REGIONS.find((x) => x.value === n)
if (r) return r.label
return n === 0 ? '通用' : '区域' + n
}
const TYPE_LABELS: Record<number, string> = { 0: 'HTTP', 1: 'SSE' }
function typeLabel(t?: number): string {
const n = Number(t) || 0
return TYPE_LABELS[n] || String(t ?? 'HTTP')
}
const { webApi } = useApi()
const { success, error, warn } = useToast()
const view = ref<'list' | 'form'>('list')
const servers = ref<McpServer[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const regionFilter = ref(0)
const kwFilter = ref('')
const form = reactive<McpServer>({
id: '', name: '', region: 1, type: 0, url: '', tools: '', description: '', enable: true,
})
const filteredRows = computed(() => {
const kw = kwFilter.value.trim().toLowerCase()
if (!kw) return servers.value
return servers.value.filter((s) => ((s.id || '') + (s.name || '')).toLowerCase().includes(kw))
})
async function loadData() {
loading.value = true
try {
const d = await webApi<{ servers: McpServer[] }>('getmcpservers', { region: regionFilter.value })
servers.value = d?.servers ?? []
} catch (e: any) {
error(e?.message ?? '获取 MCP 服务失败')
servers.value = []
} finally {
loading.value = false
}
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(s?: McpServer) {
if (s) {
isEditing.value = true
form.id = s.id || ''
form.name = s.name || ''
form.region = s.region || 1
form.type = s.type != null ? s.type : 0
form.url = s.url || ''
form.tools = s.tools || ''
form.description = s.description || ''
form.enable = !!s.enable
} else {
isEditing.value = false
form.id = ''
form.name = ''
form.region = regionFilter.value > 0 ? regionFilter.value : 1
form.type = 0
form.url = ''
form.tools = ''
form.description = ''
form.enable = true
}
view.value = 'form'
}
async function saveMcp() {
const data = {
id: form.id.trim(),
name: form.name.trim(),
region: Number(form.region) || 0,
type: Number(form.type) || 0,
url: form.url.trim(),
tools: form.tools.trim(),
description: form.description.trim(),
enable: form.enable,
}
if (!data.id) {
warn('服务ID 必填')
return
}
submitting.value = true
try {
await webApi(isEditing.value ? 'updatemcpserver' : 'addmcpserver', data)
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delMcp(id: string) {
if (!window.confirm(`确定删除 MCP 服务 "${id}" 吗?`)) return
webApi('delmcpserver', { ids: [id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadData)
</script>