ForcePilot/web/src/components/extensions/McpCardList.vue
2026-06-03 21:50:10 +08:00

474 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="mcp-cards-page extension-page-root">
<PageShoulder search-placeholder="搜索 MCP..." v-model:search="searchQuery">
<template #actions>
<a-button type="primary" @click="handleMcpAdd" class="lucide-icon-btn">
<Plus :size="14" />
<span>添加 MCP</span>
</a-button>
<a-tooltip title="刷新 MCP" placement="bottom">
<a-button class="lucide-icon-btn" :disabled="loading" @click="fetchServers">
<RefreshCw :size="14" />
</a-button>
</a-tooltip>
</template>
</PageShoulder>
<div
v-if="filteredEnabledServers.length === 0 && filteredDisabledServers.length === 0"
class="extension-card-grid-empty-state"
>
<a-empty
:image="false"
:description="searchQuery ? '无匹配 MCP' : '暂无 MCP点击上方按钮添加'"
/>
</div>
<template v-else>
<div v-if="filteredEnabledServers.length" class="extension-section-header">已添加</div>
<ExtensionCardGrid :min-width="360">
<InfoCard
v-for="server in filteredEnabledServers"
:key="server.slug"
variant="mini"
:title="formatExtensionCardTitle(server.name)"
:description="server.description || '暂无描述'"
@click="handleCardClick(server)"
>
<template #icon>
<span class="info-card-emoji-icon">{{ server.icon || '🔌' }}</span>
</template>
<template #action>
<button
type="button"
class="mcp-card-action mcp-card-action-danger"
:disabled="isActionLoading(server)"
:aria-label="server.created_by === 'system' ? '移除 MCP' : '删除 MCP'"
@click.stop="handleRemoveServer(server)"
>
<Check :size="15" class="action-icon action-icon-check" />
<Trash2 :size="15" class="action-icon action-icon-trash" />
</button>
</template>
</InfoCard>
</ExtensionCardGrid>
<div v-if="filteredDisabledServers.length" class="extension-section-header">可添加</div>
<ExtensionCardGrid v-if="filteredDisabledServers.length" :min-width="360">
<InfoCard
v-for="server in filteredDisabledServers"
:key="server.slug"
variant="mini"
:title="formatExtensionCardTitle(server.name)"
:description="server.description || '暂无描述'"
@click="openBasicInfo(server)"
>
<template #icon>
<span class="info-card-emoji-icon">{{ server.icon || '🔌' }}</span>
</template>
<template #action>
<button
type="button"
class="mcp-card-action"
:disabled="isActionLoading(server)"
aria-label="添加 MCP"
@click.stop="handleSetServerEnabled(server, true)"
>
<Plus :size="15" class="action-icon" />
</button>
</template>
</InfoCard>
</ExtensionCardGrid>
</template>
<a-modal
v-model:open="basicInfoVisible"
class="mcp-basic-info-modal"
:footer="null"
width="560px"
:destroy-on-close="true"
@cancel="closeBasicInfo"
>
<div v-if="previewServer" class="mcp-basic-info-panel">
<div class="mcp-basic-info-header">
<div class="mcp-basic-info-icon">
<span>{{ previewServer.icon || '🔌' }}</span>
</div>
<div class="mcp-basic-info-title-area">
<div class="mcp-basic-info-title">
{{ formatExtensionCardTitle(previewServer.name) }}
</div>
<div class="mcp-basic-info-meta">
<span>{{ previewServer.transport || '未知传输类型' }}</span>
<span v-if="previewServer.created_by === 'system'" class="mcp-basic-info-tag">
内置
</span>
</div>
</div>
</div>
<div class="mcp-basic-info-body">
<div class="mcp-basic-info-row">
<label>描述</label>
<span>{{ previewServer.description || '暂无描述' }}</span>
</div>
<div class="mcp-basic-info-row">
<label>传输类型</label>
<span>{{ previewServer.transport || '-' }}</span>
</div>
<div
v-if="Array.isArray(previewServer.tags) && previewServer.tags.length > 0"
class="mcp-basic-info-row"
>
<label>标签</label>
<span class="mcp-basic-info-tags">
<a-tag v-for="tag in previewServer.tags" :key="tag">{{ tag }}</a-tag>
</span>
</div>
<div class="mcp-basic-info-row">
<label>创建人</label>
<span>{{ previewServer.created_by || '-' }}</span>
</div>
</div>
<div class="mcp-basic-info-footer">
<a-button @click="closeBasicInfo">关闭</a-button>
<a-button
type="primary"
class="lucide-icon-btn"
:loading="isActionLoading(previewServer)"
@click="handleSetServerEnabled(previewServer, true)"
>
<template #icon><Plus :size="14" /></template>
添加
</a-button>
</div>
</div>
</a-modal>
<McpFormModal
v-model:open="formModalVisible"
:edit-mode="false"
@submitted="handleFormSubmitted"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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'
import PageShoulder from '@/components/shared/PageShoulder.vue'
import McpFormModal from './McpFormModal.vue'
import { formatExtensionCardTitle } from '@/utils/extensionDisplayName'
const router = useRouter()
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) =>
String(a.name || '').localeCompare(String(b.name || ''), 'zh-Hans-CN', {
sensitivity: 'base',
numeric: true
})
)
if (!searchQuery.value) return sorted
const q = searchQuery.value.toLowerCase()
return sorted.filter(
(s) => s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
)
})
const filteredEnabledServers = computed(() =>
filteredServers.value.filter((item) => !!item.enabled)
)
const filteredDisabledServers = computed(() =>
filteredServers.value.filter((item) => !item.enabled)
)
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
}
const handleFormSubmitted = async () => {
formModalVisible.value = false
await fetchServers()
}
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
const result = await mcpApi.getMcpServers()
if (result.success) {
servers.value = result.data || []
}
} catch (err) {
message.error(err.message || '获取 MCP 列表失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchServers()
})
defineExpose({ fetchServers, loading })
</script>
<style lang="less" scoped>
@import '@/assets/css/extensions.less';
.info-card-emoji-icon {
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;
}
</style>