diff --git a/web/src/components/extensions/McpCardList.vue b/web/src/components/extensions/McpCardList.vue index c3d10da1..c193e135 100644 --- a/web/src/components/extensions/McpCardList.vue +++ b/web/src/components/extensions/McpCardList.vue @@ -26,43 +26,126 @@ 已添加 - + {{ server.icon || '🔌' }} + + + + + + 可添加 - + {{ server.icon || '🔌' }} + + + + + + + + + + {{ previewServer.icon || '🔌' }} + + + + {{ formatExtensionCardTitle(previewServer.name) }} + + + {{ previewServer.transport || '未知传输类型' }} + + 内置 + + + + + + + + 描述 + {{ previewServer.description || '暂无描述' }} + + + 传输类型 + {{ previewServer.transport || '-' }} + + + 标签 + + {{ tag }} + + + + 创建人 + {{ previewServer.created_by || '-' }} + + + + + + + import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' -import { message } from 'ant-design-vue' -import { Plus, RefreshCw } from 'lucide-vue-next' +import { message, Modal } from 'ant-design-vue' +import { Check, Plus, RefreshCw, Trash2 } from 'lucide-vue-next' import { mcpApi } from '@/apis/mcp_api' import ExtensionCardGrid from './ExtensionCardGrid.vue' import InfoCard from '@/components/shared/InfoCard.vue' @@ -89,6 +172,9 @@ const loading = ref(false) const servers = ref([]) const searchQuery = ref('') const formModalVisible = ref(false) +const basicInfoVisible = ref(false) +const previewServer = ref(null) +const actionLoadingSlug = ref('') const filteredServers = computed(() => { const sorted = [...servers.value].sort((a, b) => @@ -111,23 +197,30 @@ const filteredDisabledServers = computed(() => filteredServers.value.filter((item) => !item.enabled) ) -const getTransportColor = (transport) => { - const colors = { sse: 'orange', stdio: 'green', streamable_http: 'blue' } - return colors[transport] || 'blue' -} - -const mcpTags = (server) => { - const tags = [] - if (server.created_by === 'system') tags.push({ name: '内置' }) - if (server.transport) - tags.push({ name: server.transport, color: getTransportColor(server.transport) }) - return tags -} - const navigateToDetail = (server) => { router.push({ path: `/extensions/mcp/${encodeURIComponent(server.slug)}` }) } +const handleCardClick = (server) => { + if (server.enabled) { + navigateToDetail(server) + return + } + openBasicInfo(server) +} + +const openBasicInfo = (server) => { + previewServer.value = server + basicInfoVisible.value = true +} + +const closeBasicInfo = () => { + basicInfoVisible.value = false + previewServer.value = null +} + +const isActionLoading = (server) => actionLoadingSlug.value === server?.slug + const handleMcpAdd = () => { formModalVisible.value = true } @@ -139,18 +232,56 @@ const handleFormSubmitted = async () => { const handleSetServerEnabled = async (server, enabled) => { try { + actionLoadingSlug.value = server.slug const result = await mcpApi.updateMcpServerStatus(server.slug, enabled) if (result.success) { message.success(result.message || `MCP 已${enabled ? '添加' : '移除'}`) + if (enabled) closeBasicInfo() await fetchServers() } else { message.error(result.message || '操作失败') } } catch (err) { message.error(err.message || '操作失败') + } finally { + actionLoadingSlug.value = '' } } +const handleRemoveServer = (server) => { + if (server.created_by === 'system') { + handleSetServerEnabled(server, false) + return + } + confirmDeleteServer(server) +} + +const confirmDeleteServer = (server) => { + Modal.confirm({ + title: '确认删除 MCP', + content: `确定要删除 MCP "${server.name}" 吗?此操作不可撤销。`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + try { + actionLoadingSlug.value = server.slug + const result = await mcpApi.deleteMcpServer(server.slug) + if (result.success) { + message.success('MCP 删除成功') + await fetchServers() + } else { + message.error(result.message || '删除失败') + } + } catch (err) { + message.error(err.message || '删除失败') + } finally { + actionLoadingSlug.value = '' + } + } + }) +} + const fetchServers = async () => { try { loading.value = true @@ -179,4 +310,164 @@ defineExpose({ fetchServers, loading }) font-size: 18px; line-height: 1; } + +.mcp-card-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--gray-150); + border-radius: 8px; + background: var(--gray-0); + color: var(--main-color); + cursor: pointer; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease; + + &:hover, + &:focus { + outline: none; + border-color: var(--main-200); + background: var(--main-50); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } + + &.mcp-card-action-danger { + color: var(--color-success-700); + + .action-icon-trash { + display: none; + } + + &:hover, + &:focus { + border-color: var(--color-error-100); + background: var(--color-error-50); + color: var(--color-error-700); + + .action-icon-check { + display: none; + } + + .action-icon-trash { + display: block; + } + } + } +} + +.action-icon { + flex-shrink: 0; +} + +.mcp-basic-info-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.mcp-basic-info-header { + display: flex; + align-items: center; + gap: 10px; +} + +.mcp-basic-info-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 9px; + border: 1px solid var(--gray-150); + background: var(--main-50); + color: var(--main-color); + font-size: 18px; +} + +.mcp-basic-info-title-area { + min-width: 0; +} + +.mcp-basic-info-title { + overflow: hidden; + color: var(--gray-900); + font-size: 16px; + font-weight: 700; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-basic-info-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; + color: var(--gray-500); + font-size: 12px; + line-height: 18px; +} + +.mcp-basic-info-tag { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: var(--gray-100); + color: var(--gray-600); + font-size: 11px; + font-weight: 600; +} + +.mcp-basic-info-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + border: 1px solid var(--gray-150); + border-radius: 12px; + background: var(--gray-25); +} + +.mcp-basic-info-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 12px; + color: var(--gray-700); + font-size: 13px; + line-height: 20px; + + label { + color: var(--gray-500); + font-weight: 600; + } + + span { + min-width: 0; + overflow-wrap: anywhere; + } +} + +.mcp-basic-info-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.mcp-basic-info-footer { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/web/src/components/extensions/McpDetailView.vue b/web/src/components/extensions/McpDetailView.vue index fb1c0001..2f9293b9 100644 --- a/web/src/components/extensions/McpDetailView.vue +++ b/web/src/components/extensions/McpDetailView.vue @@ -673,6 +673,12 @@ const fetchServer = async () => { loading.value = true const result = await mcpApi.getMcpServer(slug.value) if (result.success) { + if (result.data?.enabled === false) { + server.value = null + message.info('请先添加 MCP 后再查看详情') + router.replace({ path: '/extensions', query: { tab: 'mcp' } }) + return + } server.value = result.data } else { message.error(result.message || '获取 MCP 详情失败')