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
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()">← 返回列表</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>
|
|
|