refactor(web): 重新组织设置和技能管理组件
- 从SettingsModal.vue中移除了非超级管理员的MCP管理功能。 - 更新了SkillsManagerComponent.vue以增强布局和功能。 - 引入了新的ExtensionsView.vue来管理技能和MCP服务器。 - 更新路由将/skills更改为/extensions。 - 调整了AppLayout.vue以使用新的图标和路径进行扩展管理。
This commit is contained in:
parent
b144b72de0
commit
a963241088
218
web/src/assets/css/extensions.less
Normal file
218
web/src/assets/css/extensions.less
Normal file
@ -0,0 +1,218 @@
|
||||
@border-color: var(--gray-150);
|
||||
@bg-secondary: var(--gray-10);
|
||||
@radius: 8px;
|
||||
|
||||
// Root container for extension pages to ensure 100% height
|
||||
.extension-page-root {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--gray-0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Sidebar List */
|
||||
.sidebar-list {
|
||||
width: 280px;
|
||||
border-right: 1px solid @border-color;
|
||||
background-color: @bg-secondary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-box {
|
||||
padding: 12px 12px 0;
|
||||
.search-input :deep(.ant-input) {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: @radius;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover { background-color: var(--gray-100); }
|
||||
|
||||
&.active {
|
||||
background-color: var(--gray-0);
|
||||
border-color: @border-color;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.item-icon, .server-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--gray-400);
|
||||
font-size: 14px; // For emoji icons
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.item-icon, .server-icon { color: var(--main-color); }
|
||||
.item-name { color: var(--gray-900); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Main Panel */
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray-0);
|
||||
|
||||
.unselected-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: @bg-secondary;
|
||||
.hint-box {
|
||||
text-align: center;
|
||||
p { margin-top: 12px; color: var(--gray-400); }
|
||||
}
|
||||
}
|
||||
|
||||
.panel-top-bar {
|
||||
padding: 10px 16px 0 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Tabs inside main panel */
|
||||
.detail-tabs, .minimal-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid @border-color;
|
||||
&::before { border-bottom: none; }
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Common helpers */
|
||||
.text-muted { color: var(--gray-400); }
|
||||
.empty-text { padding: 40px 0; text-align: center; color: var(--gray-400); }
|
||||
|
||||
// Spin wrapper for full height
|
||||
.full-height-spin {
|
||||
height: 100%;
|
||||
:deep(.ant-spin-nested-loading) { height: 100%; }
|
||||
:deep(.ant-spin-container) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading-bar-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
.loading-bar {
|
||||
height: 100%;
|
||||
background: var(--main-color);
|
||||
width: 30%;
|
||||
position: absolute;
|
||||
animation: loading-bar-anim 1.5s infinite linear;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-bar-anim {
|
||||
0% { left: -30%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
.content-loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
@ -1,660 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="server?.name || 'MCP 服务器详情'"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
class="mcp-detail-modal"
|
||||
>
|
||||
<div class="detail-container" v-if="server">
|
||||
<!-- 头部状态 -->
|
||||
<div class="detail-header">
|
||||
<div class="server-info">
|
||||
<span class="server-icon">{{ server.icon || '🔌' }}</span>
|
||||
<div class="server-meta">
|
||||
<h3 class="server-name">{{ server.name }}</h3>
|
||||
<span class="server-status" :class="{ enabled: server.enabled }">
|
||||
{{ server.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button @click="handleTestConnection" :loading="testLoading">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
测试连接
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<a-tabs v-model:activeKey="activeTab" class="detail-tabs">
|
||||
<a-tab-pane key="general" tab="通用">
|
||||
<div class="tab-content">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>传输类型</label>
|
||||
<span>
|
||||
<a-tag :color="getTransportColor(server.transport)">
|
||||
{{ server.transport }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- HTTP 类型显示 URL -->
|
||||
<template v-if="server.transport === 'streamable_http' || server.transport === 'sse'">
|
||||
<div class="info-item">
|
||||
<label>服务器 URL</label>
|
||||
<span class="url-text">{{ server.url || '-' }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-item"
|
||||
v-if="server.headers && Object.keys(server.headers).length > 0"
|
||||
>
|
||||
<label>请求头</label>
|
||||
<pre class="headers-pre">{{ JSON.stringify(server.headers, null, 2) }}</pre>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.timeout">
|
||||
<label>HTTP 超时</label>
|
||||
<span>{{ server.timeout }} 秒</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.sse_read_timeout">
|
||||
<label>SSE 读取超时</label>
|
||||
<span>{{ server.sse_read_timeout }} 秒</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- StdIO 类型显示 command/args -->
|
||||
<template v-if="server.transport === 'stdio'">
|
||||
<div class="info-item">
|
||||
<label>命令</label>
|
||||
<span class="command-text">{{ server.command || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.args && server.args.length > 0">
|
||||
<label>参数</label>
|
||||
<span>
|
||||
<a-tag v-for="(arg, index) in server.args" :key="index" size="small">
|
||||
{{ arg }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.env && Object.keys(server.env).length > 0">
|
||||
<label>环境变量</label>
|
||||
<pre class="headers-pre">{{ JSON.stringify(server.env, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="info-item" v-if="server.description">
|
||||
<label>描述</label>
|
||||
<span>{{ server.description }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.tags && server.tags.length > 0">
|
||||
<label>标签</label>
|
||||
<span>
|
||||
<a-tag v-for="tag in server.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建时间</label>
|
||||
<span>{{ formatTime(server.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>更新时间</label>
|
||||
<span>{{ formatTime(server.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建人</label>
|
||||
<span>{{ server.created_by }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tools" :tab="`工具 (${tools.length})`">
|
||||
<div class="tab-content tools-tab">
|
||||
<!-- 工具栏 -->
|
||||
<div class="tools-toolbar">
|
||||
<a-input-search
|
||||
v-model:value="toolSearchText"
|
||||
placeholder="搜索工具..."
|
||||
style="width: 240px"
|
||||
allowClear
|
||||
/>
|
||||
<a-button @click="handleRefreshTools" :loading="toolsLoading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新工具
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 工具列表 -->
|
||||
<a-spin :spinning="toolsLoading">
|
||||
<div v-if="filteredTools.length === 0" class="empty-tools">
|
||||
<a-empty :description="toolsError || '暂无工具'" />
|
||||
</div>
|
||||
<div v-else class="tools-list">
|
||||
<div
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.name"
|
||||
class="tool-card"
|
||||
:class="{ disabled: !tool.enabled }"
|
||||
>
|
||||
<div class="tool-header">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<a-tooltip :title="`ID: ${tool.id}`">
|
||||
<InfoCircleOutlined class="info-icon" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="tool-actions">
|
||||
<a-switch
|
||||
:checked="tool.enabled"
|
||||
@change="handleToggleTool(tool)"
|
||||
:loading="toggleToolLoading === tool.name"
|
||||
size="small"
|
||||
/>
|
||||
<a-tooltip title="复制工具名称">
|
||||
<a-button type="text" size="small" @click="copyToolName(tool.name)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-description" v-if="tool.description">
|
||||
{{ tool.description }}
|
||||
</div>
|
||||
<a-collapse
|
||||
v-if="tool.parameters && Object.keys(tool.parameters).length > 0"
|
||||
ghost
|
||||
>
|
||||
<a-collapse-panel key="params" header="参数">
|
||||
<div class="params-list">
|
||||
<div
|
||||
v-for="(param, paramName) in tool.parameters"
|
||||
:key="paramName"
|
||||
class="param-item"
|
||||
>
|
||||
<div class="param-header">
|
||||
<span class="param-name">{{ paramName }}</span>
|
||||
<span class="param-required" v-if="tool.required?.includes(paramName)"
|
||||
>必填</span
|
||||
>
|
||||
<span class="param-type">{{ param.type || 'any' }}</span>
|
||||
</div>
|
||||
<div class="param-desc" v-if="param.description">
|
||||
{{ param.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="prompts" tab="提示">
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="提示功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📝</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="resources" tab="资源">
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="资源功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📦</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { notification } from 'ant-design-vue'
|
||||
import {
|
||||
ApiOutlined,
|
||||
ReloadOutlined,
|
||||
InfoCircleOutlined,
|
||||
CopyOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { mcpApi } from '@/apis/mcp_api'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
server: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'update'])
|
||||
|
||||
// 状态
|
||||
const activeTab = ref('general')
|
||||
const tools = ref([])
|
||||
const toolsLoading = ref(false)
|
||||
const toolsError = ref(null)
|
||||
const toolSearchText = ref('')
|
||||
const testLoading = ref(false)
|
||||
const toggleToolLoading = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const modalVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
if (!toolSearchText.value) return tools.value
|
||||
const search = toolSearchText.value.toLowerCase()
|
||||
return tools.value.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(search) ||
|
||||
(t.description && t.description.toLowerCase().includes(search))
|
||||
)
|
||||
})
|
||||
|
||||
// 监听服务器变化,加载工具列表
|
||||
watch(
|
||||
() => props.server,
|
||||
(newServer) => {
|
||||
if (newServer) {
|
||||
activeTab.value = 'general'
|
||||
fetchTools()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 获取工具列表
|
||||
const fetchTools = async () => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
toolsLoading.value = true
|
||||
toolsError.value = null
|
||||
const result = await mcpApi.getMcpServerTools(props.server.name)
|
||||
if (result.success) {
|
||||
tools.value = result.data || []
|
||||
} else {
|
||||
toolsError.value = result.message || '获取工具列表失败'
|
||||
tools.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取工具列表失败:', err)
|
||||
toolsError.value = err.message || '获取工具列表失败'
|
||||
tools.value = []
|
||||
} finally {
|
||||
toolsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新工具列表
|
||||
const handleRefreshTools = async () => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
toolsLoading.value = true
|
||||
const result = await mcpApi.refreshMcpServerTools(props.server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
await fetchTools()
|
||||
} else {
|
||||
notification.error({ message: result.message || '刷新失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('刷新工具列表失败:', err)
|
||||
notification.error({ message: err.message || '刷新失败' })
|
||||
} finally {
|
||||
toolsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const handleTestConnection = async () => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
testLoading.value = true
|
||||
const result = await mcpApi.testMcpServer(props.server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
} else {
|
||||
notification.warning({ message: result.message || '连接失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('测试连接失败:', err)
|
||||
notification.error({ message: err.message || '测试失败' })
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换工具启用状态
|
||||
const handleToggleTool = async (tool) => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
toggleToolLoading.value = tool.name
|
||||
const result = await mcpApi.toggleMcpServerTool(props.server.name, tool.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
// 更新本地状态
|
||||
const targetTool = tools.value.find((t) => t.name === tool.name)
|
||||
if (targetTool) {
|
||||
targetTool.enabled = result.enabled
|
||||
}
|
||||
emit('update')
|
||||
} else {
|
||||
notification.error({ message: result.message || '操作失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('切换工具状态失败:', err)
|
||||
notification.error({ message: err.message || '操作失败' })
|
||||
} finally {
|
||||
toggleToolLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制工具名称
|
||||
const copyToolName = async (name) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(name)
|
||||
notification.success({ message: '已复制到剪贴板' })
|
||||
} catch {
|
||||
notification.error({ message: '复制失败' })
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr) => formatDateTime(timeStr)
|
||||
|
||||
// 获取传输类型颜色
|
||||
const getTransportColor = (transport) => {
|
||||
const colors = {
|
||||
sse: 'orange',
|
||||
stdio: 'green',
|
||||
streamable_http: 'blue'
|
||||
}
|
||||
return colors[transport] || 'blue'
|
||||
}
|
||||
|
||||
// 关闭弹框
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mcp-detail-modal {
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
background: var(--gray-25);
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.server-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.server-meta {
|
||||
.server-name {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.server-status {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
padding: 2px 8px;
|
||||
background: var(--gray-100);
|
||||
border-radius: 4px;
|
||||
|
||||
&.enabled {
|
||||
background: var(--color-success-50);
|
||||
color: var(--color-success-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
:deep(.ant-tabs-nav) {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px 24px;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.url-text {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.command-text {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.headers-pre {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--gray-50);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-tab {
|
||||
.tools-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-tools {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.tool-card {
|
||||
background: var(--gray-0);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-200);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tool-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.param-item {
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
.param-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.param-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--gray-900);
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.param-required {
|
||||
font-size: 11px;
|
||||
color: var(--color-error-500);
|
||||
background: var(--color-error-50);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.param-desc {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,128 +1,288 @@
|
||||
<template>
|
||||
<div class="mcp-servers">
|
||||
<!-- 头部区域 -->
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<h3 class="title">MCP 服务器管理</h3>
|
||||
<p class="description">
|
||||
管理 MCP(Model Context Protocol)服务器配置。添加、编辑或删除 MCP 服务器以扩展 AI
|
||||
的能力。
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showAddModal" class="add-btn">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加服务器
|
||||
</a-button>
|
||||
<div class="mcp-servers extension-page-root">
|
||||
<div v-if="loading" class="loading-bar-wrapper">
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section" v-if="servers.length > 0">
|
||||
<span class="stats-text">
|
||||
已配置 {{ servers.length }} 个 MCP 服务器: HTTP: {{ httpCount }} · SSE: {{ sseCount }} ·
|
||||
StdIO: {{ stdioCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="content-section">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="error" class="error-message">
|
||||
<a-alert type="error" :message="error" show-icon />
|
||||
<div class="layout-wrapper" :class="{ 'content-loading': loading }">
|
||||
<!-- 左侧:服务器列表 -->
|
||||
<div class="sidebar-list">
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box">
|
||||
<a-input v-model:value="searchQuery" placeholder="搜索服务器..." allow-clear class="search-input">
|
||||
<template #prefix><Search :size="14" class="text-muted" /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<div class="cards-container">
|
||||
<div v-if="servers.length === 0" class="empty-state">
|
||||
<a-empty description="暂无 MCP 服务器配置">
|
||||
<a-button type="primary" @click="showAddModal">添加服务器</a-button>
|
||||
</a-empty>
|
||||
<!-- 统计信息 -->
|
||||
<!-- <div class="stats-section" v-if="filteredServers.length > 0">
|
||||
<span class="stats-text">
|
||||
{{ filteredServers.length }} 个服务器: HTTP: {{ httpCount }} · SSE: {{ sseCount }} · StdIO: {{ stdioCount }}
|
||||
</span>
|
||||
</div> -->
|
||||
|
||||
<!-- 服务器列表 -->
|
||||
<div class="list-container">
|
||||
<div v-if="filteredServers.length === 0" class="empty-text">
|
||||
<a-empty :image="false" :description="searchQuery ? '无匹配服务器' : '暂无服务器'" />
|
||||
</div>
|
||||
<div v-else class="server-cards-grid">
|
||||
<div
|
||||
v-for="server in servers"
|
||||
:key="server.name"
|
||||
class="server-card"
|
||||
:class="{ disabled: !server.enabled }"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="server-info">
|
||||
<span class="server-icon">{{ server.icon || '🔌' }}</span>
|
||||
<div class="server-basic-info">
|
||||
<h4 class="server-name" @click="showDetailModal(server)">{{ server.name }}</h4>
|
||||
<div class="server-transport">
|
||||
<a-tag size="small" class="transport-tag">
|
||||
{{ server.transport }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="server.enabled"
|
||||
@change="handleToggleServer(server)"
|
||||
:loading="toggleLoading === server.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="server-description">
|
||||
{{ server.description || '暂无描述' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<a-tooltip title="查看详情">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showDetailModal(server)"
|
||||
class="action-btn"
|
||||
>
|
||||
<EyeOutlined />
|
||||
<span>详情</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="测试连接">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleTestServer(server)"
|
||||
class="action-btn"
|
||||
:loading="testLoading === server.name"
|
||||
>
|
||||
<ApiOutlined v-if="testLoading !== server.name" />
|
||||
<span>测试</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="编辑配置">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showEditModal(server)"
|
||||
class="action-btn"
|
||||
>
|
||||
<EditOutlined />
|
||||
<span>编辑</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip
|
||||
:title="server.created_by === 'system' ? '内置 MCP 无法删除' : '删除服务器'"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="server.created_by === 'system'"
|
||||
@click="confirmDeleteServer(server)"
|
||||
class="action-btn"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-for="server in filteredServers"
|
||||
:key="server.name"
|
||||
class="list-item"
|
||||
:class="{ active: currentServer?.name === server.name, disabled: !server.enabled }"
|
||||
@click="selectServer(server)"
|
||||
>
|
||||
<div class="item-header">
|
||||
<span class="server-icon">{{ server.icon || '🔌' }}</span>
|
||||
<span class="item-name">{{ server.name }}</span>
|
||||
<a-switch
|
||||
size="small"
|
||||
:checked="server.enabled"
|
||||
@change="handleToggleServer(server)"
|
||||
@click.stop
|
||||
:loading="toggleLoading === server.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<a-tag size="small" class="transport-tag">{{ server.transport }}</a-tag>
|
||||
<span class="item-desc">{{ server.description || '暂无描述' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:详情面板 -->
|
||||
<div class="main-panel">
|
||||
<div v-if="!currentServer" class="unselected-state">
|
||||
<div class="hint-box">
|
||||
<Plug :size="40" class="text-muted" />
|
||||
<p>请在左侧选择服务器进行操作</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel-top-bar">
|
||||
<h2 style="min-height: 32px;">
|
||||
<span class="server-icon-lg">{{ currentServer.icon || '🔌' }}</span>
|
||||
<span><strong>{{ currentServer.name }}</strong></span>
|
||||
</h2>
|
||||
<div class="panel-actions">
|
||||
<a-space :size="8">
|
||||
<a-button size="small" @click="handleTestServer(currentServer)" :loading="testLoading === currentServer.name" class="lucide-icon-btn">
|
||||
<Zap :size="14" v-if="testLoading !== currentServer.name" />
|
||||
<span>测试</span>
|
||||
</a-button>
|
||||
<a-button size="small" @click="showEditModal(currentServer)" class="lucide-icon-btn">
|
||||
<Pencil :size="14" />
|
||||
<span>编辑</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
danger
|
||||
ghost
|
||||
:disabled="currentServer.created_by === 'system'"
|
||||
@click="confirmDeleteServer(currentServer)"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<a-tabs v-model:activeKey="detailTab" class="detail-tabs">
|
||||
<a-tab-pane key="general">
|
||||
<template #tab>
|
||||
<span class="tab-title"><Settings2 :size="14" />通用</span>
|
||||
</template>
|
||||
<div class="tab-content">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>传输类型</label>
|
||||
<span>
|
||||
<a-tag :color="getTransportColor(currentServer.transport)">
|
||||
{{ currentServer.transport }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- HTTP 类型显示 URL -->
|
||||
<template v-if="currentServer.transport === 'streamable_http' || currentServer.transport === 'sse'">
|
||||
<div class="info-item" v-if="currentServer.url">
|
||||
<label>服务器 URL</label>
|
||||
<span class="url-text">{{ currentServer.url }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="currentServer.headers && Object.keys(currentServer.headers).length > 0">
|
||||
<label>请求头</label>
|
||||
<pre class="headers-pre">{{ JSON.stringify(currentServer.headers, null, 2) }}</pre>
|
||||
</div>
|
||||
<div class="info-item" v-if="currentServer.timeout">
|
||||
<label>HTTP 超时</label>
|
||||
<span>{{ currentServer.timeout }} 秒</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="currentServer.sse_read_timeout">
|
||||
<label>SSE 读取超时</label>
|
||||
<span>{{ currentServer.sse_read_timeout }} 秒</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- StdIO 类型显示 command/args -->
|
||||
<template v-if="currentServer.transport === 'stdio'">
|
||||
<div class="info-item" v-if="currentServer.command">
|
||||
<label>命令</label>
|
||||
<span class="command-text">{{ currentServer.command }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="currentServer.args && currentServer.args.length > 0">
|
||||
<label>参数</label>
|
||||
<span>
|
||||
<a-tag v-for="(arg, index) in currentServer.args" :key="index" size="small">
|
||||
{{ arg }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="currentServer.env && Object.keys(currentServer.env).length > 0">
|
||||
<label>环境变量</label>
|
||||
<pre class="headers-pre">{{ JSON.stringify(currentServer.env, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="info-item" v-if="currentServer.description">
|
||||
<label>描述</label>
|
||||
<span>{{ currentServer.description }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="Array.isArray(currentServer.tags) && currentServer.tags.length > 0">
|
||||
<label>标签</label>
|
||||
<span>
|
||||
<a-tag v-for="tag in currentServer.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建时间</label>
|
||||
<span>{{ formatTime(currentServer.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>更新时间</label>
|
||||
<span>{{ formatTime(currentServer.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建人</label>
|
||||
<span>{{ currentServer.created_by }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tools">
|
||||
<template #tab>
|
||||
<span class="tab-title"><Wrench :size="14" />工具 ({{ tools.length }})</span>
|
||||
</template>
|
||||
<div class="tab-content tools-tab">
|
||||
<div class="tools-toolbar">
|
||||
<a-input-search
|
||||
v-model:value="toolSearchText"
|
||||
placeholder="搜索工具..."
|
||||
style="width: 200px"
|
||||
allowClear
|
||||
/>
|
||||
<a-button @click="fetchTools" :loading="toolsLoading" class="lucide-icon-btn">
|
||||
<RotateCw :size="14" />
|
||||
<span>刷新</span>
|
||||
</a-button>
|
||||
</div>
|
||||
<a-spin :spinning="toolsLoading">
|
||||
<div v-if="filteredTools.length === 0" class="empty-tools">
|
||||
<a-empty :description="toolsError || '暂无工具'" />
|
||||
</div>
|
||||
<div v-else class="tools-list">
|
||||
<div
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.name"
|
||||
class="tool-card"
|
||||
:class="{ disabled: !tool.enabled }"
|
||||
>
|
||||
<div class="tool-header">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<a-tooltip :title="`ID: ${tool.id}`">
|
||||
<Info :size="14" class="info-icon" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="tool-actions">
|
||||
<a-switch
|
||||
:checked="tool.enabled"
|
||||
@change="handleToggleTool(tool)"
|
||||
:loading="toggleToolLoading === tool.name"
|
||||
size="small"
|
||||
/>
|
||||
<a-tooltip title="复制工具名称">
|
||||
<a-button type="text" size="small" @click="copyToolName(tool.name)" class="lucide-icon-btn">
|
||||
<Copy :size="14" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-description" v-if="tool.description">
|
||||
{{ tool.description }}
|
||||
</div>
|
||||
<a-collapse
|
||||
v-if="tool.parameters && Object.keys(tool.parameters).length > 0"
|
||||
ghost
|
||||
>
|
||||
<a-collapse-panel key="params" header="参数">
|
||||
<div class="params-list">
|
||||
<div
|
||||
v-for="(param, paramName) in tool.parameters"
|
||||
:key="paramName"
|
||||
class="param-item"
|
||||
>
|
||||
<div class="param-header">
|
||||
<span class="param-name">{{ paramName }}</span>
|
||||
<span class="param-required" v-if="tool.required?.includes(paramName)">必填</span>
|
||||
<span class="param-type">{{ param.type || 'any' }}</span>
|
||||
</div>
|
||||
<div class="param-desc" v-if="param.description">
|
||||
{{ param.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="prompts">
|
||||
<template #tab>
|
||||
<span class="tab-title"><MessageSquare :size="14" />提示</span>
|
||||
</template>
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="提示功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📝</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="resources">
|
||||
<template #tab>
|
||||
<span class="tab-title"><Box :size="14" />资源</span>
|
||||
</template>
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="资源功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📦</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑服务器模态框 -->
|
||||
@ -266,12 +426,6 @@
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 服务器详情模态框 -->
|
||||
<McpServerDetailModal
|
||||
v-model:visible="detailModalVisible"
|
||||
:server="selectedServer"
|
||||
@update="handleServerUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -279,14 +433,21 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { notification, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
ApiOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
Search,
|
||||
Plug,
|
||||
Zap,
|
||||
Pencil,
|
||||
Trash2,
|
||||
RotateCw,
|
||||
Info,
|
||||
Copy,
|
||||
Settings2,
|
||||
Wrench,
|
||||
MessageSquare,
|
||||
Box
|
||||
} from 'lucide-vue-next'
|
||||
import { mcpApi } from '@/apis/mcp_api'
|
||||
import McpServerDetailModal from './McpServerDetailModal.vue'
|
||||
import { formatFullDateTime } from '@/utils/time'
|
||||
import McpEnvEditor from './McpEnvEditor.vue'
|
||||
|
||||
// 状态
|
||||
@ -295,6 +456,16 @@ const error = ref(null)
|
||||
const servers = ref([])
|
||||
const toggleLoading = ref(null)
|
||||
const testLoading = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const currentServer = ref(null)
|
||||
const detailTab = ref('general')
|
||||
|
||||
// 工具相关状态
|
||||
const tools = ref([])
|
||||
const toolsLoading = ref(false)
|
||||
const toolsError = ref(null)
|
||||
const toolSearchText = ref('')
|
||||
const toggleToolLoading = ref(null)
|
||||
|
||||
// 表单相关
|
||||
const formModalVisible = ref(false)
|
||||
@ -317,21 +488,31 @@ const form = reactive({
|
||||
icon: ''
|
||||
})
|
||||
|
||||
// 详情模态框
|
||||
const detailModalVisible = ref(false)
|
||||
const selectedServer = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const httpCount = computed(
|
||||
() => servers.value.filter((s) => s.transport === 'streamable_http').length
|
||||
)
|
||||
const sseCount = computed(() => servers.value.filter((s) => s.transport === 'sse').length)
|
||||
const stdioCount = computed(() => servers.value.filter((s) => s.transport === 'stdio').length)
|
||||
const envEditorKey = computed(() => `${form.name}-${form.transport}`)
|
||||
const filteredServers = computed(() => {
|
||||
if (!searchQuery.value) return servers.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return servers.value.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const isStdioTransport = computed(
|
||||
() => String(form.transport || '').trim().toLowerCase() === 'stdio'
|
||||
)
|
||||
|
||||
// 工具相关计算属性
|
||||
const filteredTools = computed(() => {
|
||||
if (!toolSearchText.value) return tools.value
|
||||
const search = toolSearchText.value.toLowerCase()
|
||||
return tools.value.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(search) ||
|
||||
(t.description && t.description.toLowerCase().includes(search))
|
||||
)
|
||||
})
|
||||
|
||||
// 获取服务器列表
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
@ -340,6 +521,15 @@ const fetchServers = async () => {
|
||||
const result = await mcpApi.getMcpServers()
|
||||
if (result.success) {
|
||||
servers.value = result.data || []
|
||||
// 默认选中第一个服务器
|
||||
if (!currentServer.value && servers.value.length > 0) {
|
||||
selectServer(servers.value[0])
|
||||
} else if (currentServer.value) {
|
||||
const latest = servers.value.find((s) => s.name === currentServer.value.name)
|
||||
if (latest) {
|
||||
currentServer.value = latest
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error.value = result.message || '获取服务器列表失败'
|
||||
}
|
||||
@ -351,6 +541,83 @@ const fetchServers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取工具列表
|
||||
const fetchTools = async () => {
|
||||
if (!currentServer.value) return
|
||||
|
||||
try {
|
||||
toolsLoading.value = true
|
||||
toolsError.value = null
|
||||
const result = await mcpApi.getMcpServerTools(currentServer.value.name)
|
||||
if (result.success) {
|
||||
tools.value = result.data || []
|
||||
} else {
|
||||
toolsError.value = result.message || '获取工具列表失败'
|
||||
tools.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取工具列表失败:', err)
|
||||
toolsError.value = err.message || '获取工具列表失败'
|
||||
tools.value = []
|
||||
} finally {
|
||||
toolsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换工具启用状态
|
||||
const handleToggleTool = async (tool) => {
|
||||
if (!currentServer.value) return
|
||||
|
||||
try {
|
||||
toggleToolLoading.value = tool.name
|
||||
const result = await mcpApi.toggleMcpServerTool(currentServer.value.name, tool.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
const targetTool = tools.value.find((t) => t.name === tool.name)
|
||||
if (targetTool) {
|
||||
targetTool.enabled = result.enabled
|
||||
}
|
||||
} else {
|
||||
notification.error({ message: result.message || '操作失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('切换工具状态失败:', err)
|
||||
notification.error({ message: err.message || '操作失败' })
|
||||
} finally {
|
||||
toggleToolLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制工具名称
|
||||
const copyToolName = async (name) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(name)
|
||||
notification.success({ message: '已复制到剪贴板' })
|
||||
} catch {
|
||||
notification.error({ message: '复制失败' })
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr) => formatFullDateTime(timeStr)
|
||||
|
||||
// 获取传输类型颜色
|
||||
const getTransportColor = (transport) => {
|
||||
const colors = {
|
||||
sse: 'orange',
|
||||
stdio: 'green',
|
||||
streamable_http: 'blue'
|
||||
}
|
||||
return colors[transport] || 'blue'
|
||||
}
|
||||
|
||||
// 选择服务器
|
||||
const selectServer = (server) => {
|
||||
currentServer.value = server
|
||||
detailTab.value = 'general'
|
||||
fetchTools()
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
editMode.value = false
|
||||
@ -407,12 +674,6 @@ const showEditModal = async (server) => {
|
||||
applyServerToForm(server)
|
||||
}
|
||||
|
||||
// 显示详情模态框
|
||||
const showDetailModal = (server) => {
|
||||
selectedServer.value = server
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
const handleFormSubmit = async () => {
|
||||
try {
|
||||
@ -577,11 +838,6 @@ const confirmDeleteServer = (server) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理服务器更新(来自详情模态框)
|
||||
const handleServerUpdate = () => {
|
||||
fetchServers()
|
||||
}
|
||||
|
||||
// 格式化 JSON
|
||||
const formatJson = () => {
|
||||
try {
|
||||
@ -621,193 +877,207 @@ const parseJsonToForm = () => {
|
||||
onMounted(() => {
|
||||
fetchServers()
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
fetchServers,
|
||||
showAddModal
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mcp-servers {
|
||||
margin-top: 12px;
|
||||
min-height: 50vh;
|
||||
@import '@/assets/css/extensions.less';
|
||||
|
||||
.header-section {
|
||||
.stats-section {
|
||||
padding: 8px 12px;
|
||||
.stats-text { font-size: 12px; color: var(--gray-500); }
|
||||
}
|
||||
|
||||
.list-item {
|
||||
&.disabled { opacity: 0.6; }
|
||||
|
||||
.server-icon { font-size: 18px; }
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.transport-tag {
|
||||
background: var(--gray-100);
|
||||
border: none;
|
||||
color: var(--gray-600);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 12px;
|
||||
color: var(--gray-400);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 右侧面板 */
|
||||
.main-panel {
|
||||
.detail-tabs {
|
||||
.tab-content {
|
||||
padding: 16px;
|
||||
min-height: 300px;
|
||||
height: 100%;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.empty-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label { font-size: 12px; color: var(--gray-500); font-weight: 500; }
|
||||
|
||||
span { font-size: 14px; color: var(--gray-900); }
|
||||
|
||||
.url-text {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.command-text {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.headers-pre {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--gray-50);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具列表样式 */
|
||||
.tools-tab {
|
||||
.tools-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stats-text {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
overflow: hidden;
|
||||
.empty-tools { padding: 40px 0; }
|
||||
|
||||
.error-message {
|
||||
padding: 16px 0;
|
||||
}
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.cards-container {
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
.tool-card {
|
||||
background: var(--gray-0);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover { border-color: var(--gray-200); }
|
||||
|
||||
&.disabled { opacity: 0.6; }
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tool-name { font-weight: 600; font-size: 14px; color: var(--gray-900); }
|
||||
|
||||
.info-icon {
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
&:hover { color: var(--gray-600); }
|
||||
}
|
||||
}
|
||||
|
||||
.tool-actions { display: flex; align-items: center; gap: 8px; }
|
||||
}
|
||||
|
||||
.server-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
.tool-description { font-size: 13px; color: var(--gray-600); line-height: 1.4; margin-bottom: 8px; }
|
||||
|
||||
.server-card {
|
||||
background: var(--gray-0);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
padding-bottom: 8px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
:deep(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--gray-200);
|
||||
}
|
||||
.ant-collapse-header { padding: 8px 0; font-size: 13px; color: var(--gray-600); }
|
||||
.ant-collapse-content-box { padding: 0; }
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.card-header {
|
||||
.param-item {
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
.param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.server-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.server-basic-info {
|
||||
.server-name {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.server-transport {
|
||||
.transport-tag {
|
||||
background: var(--gray-100);
|
||||
border: none;
|
||||
color: var(--gray-600);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.param-name { font-weight: 500; font-size: 13px; color: var(--gray-900); font-family: 'Monaco', 'Consolas', monospace; }
|
||||
.param-required { font-size: 11px; color: var(--color-error-500); background: var(--color-error-50); padding: 1px 6px; border-radius: 3px; }
|
||||
.param-type { font-size: 11px; color: var(--gray-500); background: var(--gray-100); padding: 1px 6px; border-radius: 3px; font-family: 'Monaco', 'Consolas', monospace; }
|
||||
}
|
||||
|
||||
.card-content {
|
||||
min-height: 44px;
|
||||
|
||||
.server-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--gray-25);
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-25);
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous:hover {
|
||||
background: var(--gray-25);
|
||||
border-color: var(--color-error-500);
|
||||
color: var(--color-error-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
.param-desc { font-size: 12px; color: var(--gray-600); line-height: 1.4; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.server-modal {
|
||||
.mode-switch {
|
||||
margin-bottom: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.server-form {
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-switch { margin-bottom: 16px; text-align: right; }
|
||||
.server-form { .form-item { margin-bottom: 16px; } }
|
||||
.json-mode {
|
||||
.json-textarea {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.json-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.json-textarea { font-family: 'Monaco', 'Consolas', monospace; font-size: 13px; }
|
||||
.json-actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -49,15 +49,6 @@
|
||||
<TeamOutlined class="icon" />
|
||||
<span>部门管理</span>
|
||||
</div>
|
||||
<div
|
||||
class="sider-item"
|
||||
:class="{ activesec: activeTab === 'mcp' }"
|
||||
@click="activeTab = 'mcp'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
<ApiOutlined class="icon" />
|
||||
<span>MCP 管理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 (Mobile) -->
|
||||
@ -86,14 +77,6 @@
|
||||
>
|
||||
用户管理
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: activeTab === 'mcp' }"
|
||||
@click="activeTab = 'mcp'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
>
|
||||
MCP 管理
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: activeTab === 'department' }"
|
||||
@ -119,10 +102,6 @@
|
||||
<UserManagementComponent />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'mcp'" v-if="userStore.isSuperAdmin">
|
||||
<McpServersComponent />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'department'" v-if="userStore.isSuperAdmin">
|
||||
<DepartmentManagementComponent />
|
||||
</div>
|
||||
@ -139,13 +118,11 @@ import {
|
||||
SettingOutlined,
|
||||
CodeOutlined,
|
||||
UserOutlined,
|
||||
ApiOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
|
||||
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
|
||||
import UserManagementComponent from '@/components/UserManagementComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
import DepartmentManagementComponent from '@/components/DepartmentManagementComponent.vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -1,186 +1,163 @@
|
||||
<template>
|
||||
<div class="skills-manager-container">
|
||||
<HeaderComponent title="Skills 管理" description="技能包管理系统。支持文件系统编辑与数据库元数据同步。" :loading="loading" class="main-header">
|
||||
<template #actions>
|
||||
<a-space :size="12">
|
||||
<a-upload
|
||||
accept=".zip"
|
||||
:show-upload-list="false"
|
||||
:custom-request="handleImportUpload"
|
||||
:disabled="loading || importing"
|
||||
<div class="skills-manager-container extension-page-root">
|
||||
<div v-if="loading" class="loading-bar-wrapper">
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
<div class="layout-wrapper" :class="{ 'content-loading': loading }">
|
||||
<!-- 左侧:技能列表 -->
|
||||
<div class="sidebar-list">
|
||||
<div class="search-box">
|
||||
<a-input v-model:value="searchQuery" placeholder="搜索技能..." allow-clear class="search-input">
|
||||
<template #prefix><Search :size="14" class="text-muted" /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<div class="list-container">
|
||||
<div v-if="filteredSkills.length === 0" class="empty-text">
|
||||
<a-empty :image="false" description="无匹配技能" />
|
||||
</div>
|
||||
<div
|
||||
v-for="skill in filteredSkills"
|
||||
:key="skill.slug"
|
||||
class="list-item"
|
||||
:class="{ active: currentSkill?.slug === skill.slug }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<a-button :loading="importing" class="lucide-icon-btn">
|
||||
<Upload :size="14" />
|
||||
<span>导入 ZIP</span>
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-button type="primary" @click="fetchSkills" :disabled="loading" class="lucide-icon-btn">
|
||||
<RotateCw :size="14" />
|
||||
<span>刷新</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</HeaderComponent>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="layout-wrapper">
|
||||
<!-- 左侧:技能列表 -->
|
||||
<div class="sidebar-list">
|
||||
<div class="search-box">
|
||||
<a-input v-model:value="searchQuery" placeholder="搜索技能..." allow-clear class="search-input">
|
||||
<template #prefix><Search :size="14" class="text-muted" /></template>
|
||||
</a-input>
|
||||
<div class="item-header">
|
||||
<Box :size="16" class="item-icon" />
|
||||
<span class="item-name">{{ skill.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="list-container">
|
||||
<div v-if="filteredSkills.length === 0" class="empty-text">
|
||||
<a-empty :image="false" description="无匹配技能" />
|
||||
<div class="item-details">
|
||||
<span class="item-slug">{{ skill.slug }}</span>
|
||||
<div class="item-badges">
|
||||
<span v-if="skill.tool_dependencies?.length" class="dot-badge blue" title="工具依赖"></span>
|
||||
<span v-if="skill.mcp_dependencies?.length" class="dot-badge green" title="MCP依赖"></span>
|
||||
</div>
|
||||
<div
|
||||
v-for="skill in filteredSkills"
|
||||
:key="skill.slug"
|
||||
class="list-item"
|
||||
:class="{ active: currentSkill?.slug === skill.slug }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="item-header">
|
||||
<Box :size="16" class="item-icon" />
|
||||
<span class="item-name">{{ skill.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:详情面板 -->
|
||||
<div class="main-panel">
|
||||
<div v-if="!currentSkill" class="unselected-state">
|
||||
<div class="hint-box">
|
||||
<FileCode :size="40" class="text-muted" />
|
||||
<p>请在左侧选择技能包进行编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel-top-bar">
|
||||
<div class="skill-summary">
|
||||
<h2>{{ currentSkill.name }}</h2>
|
||||
<!-- <code>{{ currentSkill.slug }}</code> -->
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<a-space :size="8">
|
||||
<a-button size="small" @click="handleExport" class="lucide-icon-btn">
|
||||
<Download :size="14" />
|
||||
<span>导出</span>
|
||||
</a-button>
|
||||
<a-button size="small" danger ghost @click="confirmDeleteSkill" class="lucide-icon-btn">
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="minimal-tabs">
|
||||
<a-tab-pane key="editor">
|
||||
<template #tab>
|
||||
<span class="tab-title"><FileText :size="14" />代码管理</span>
|
||||
</template>
|
||||
<div class="workspace">
|
||||
<div class="tree-container">
|
||||
<div class="tree-header">
|
||||
<span class="label">项目结构</span>
|
||||
<div class="tree-actions">
|
||||
<a-tooltip title="新建文件"><button @click="openCreateModal(false)"><FilePlus :size="14" /></button></a-tooltip>
|
||||
<a-tooltip title="新建目录"><button @click="openCreateModal(true)"><FolderPlus :size="14" /></button></a-tooltip>
|
||||
<a-tooltip title="刷新"><button @click="reloadTree"><RotateCw :size="14" /></button></a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-content">
|
||||
<FileTreeComponent
|
||||
v-model:selectedKeys="selectedTreeKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
:tree-data="treeData"
|
||||
@select="handleTreeSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<span class="item-slug">{{ skill.slug }}</span>
|
||||
<div class="item-badges">
|
||||
<span v-if="skill.tool_dependencies?.length" class="dot-badge blue" title="工具依赖"></span>
|
||||
<span v-if="skill.mcp_dependencies?.length" class="dot-badge green" title="MCP依赖"></span>
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="current-path">
|
||||
<File :size="14" />
|
||||
<span>{{ selectedPath || '未选择文件' }}</span>
|
||||
<span v-if="canSave" class="save-hint">●</span>
|
||||
</div>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="saveCurrentFile"
|
||||
:disabled="!canSave"
|
||||
:loading="savingFile"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<Save :size="14" />
|
||||
<span>保存</span>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="editor-main">
|
||||
<a-empty v-if="!selectedPath || selectedIsDir" description="选择文件以开始编辑" class="mt-40" />
|
||||
<a-textarea
|
||||
v-else
|
||||
v-model:value="fileContent"
|
||||
class="pure-editor"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 右侧:详情面板 -->
|
||||
<div class="main-panel">
|
||||
<div v-if="!currentSkill" class="unselected-state">
|
||||
<div class="hint-box">
|
||||
<FileCode :size="40" class="text-muted" />
|
||||
<p>请在左侧选择技能包进行编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel-top-bar">
|
||||
<div class="skill-summary">
|
||||
<h2>{{ currentSkill.name }}</h2>
|
||||
<!-- <code>{{ currentSkill.slug }}</code> -->
|
||||
<a-tab-pane key="dependencies">
|
||||
<template #tab>
|
||||
<span class="tab-title"><Layers :size="14" />依赖管理</span>
|
||||
</template>
|
||||
<div class="config-view">
|
||||
<div class="config-header">
|
||||
<div class="text">
|
||||
<h3>依赖声明</h3>
|
||||
<p>配置此 Skill 所需的工具、MCP 服务器及其他 Skill 依赖。</p>
|
||||
</div>
|
||||
<a-button type="primary" :loading="savingDependencies" @click="saveDependencies" class="lucide-icon-btn">
|
||||
<Save :size="14" />
|
||||
<span>更新依赖</span>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<a-space :size="8">
|
||||
<a-button size="small" @click="handleExport" class="lucide-icon-btn">
|
||||
<Download :size="14" />
|
||||
<span>导出</span>
|
||||
</a-button>
|
||||
<a-button size="small" danger ghost @click="confirmDeleteSkill" class="lucide-icon-btn">
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<div class="config-form">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="工具依赖 (Tools)">
|
||||
<a-select v-model:value="dependencyForm.tool_dependencies" mode="multiple" :options="toolDependencyOptions" placeholder="选择工具..." allow-clear show-search />
|
||||
</a-form-item>
|
||||
<a-form-item label="MCP 依赖 (Model Context Protocol)">
|
||||
<a-select v-model:value="dependencyForm.mcp_dependencies" mode="multiple" :options="mcpDependencyOptions" placeholder="选择 MCP 服务..." allow-clear show-search />
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill 依赖">
|
||||
<a-select v-model:value="dependencyForm.skill_dependencies" mode="multiple" :options="skillDependencyOptions" placeholder="选择 Skill..." allow-clear show-search />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs-area">
|
||||
<a-tabs v-model:activeKey="activeTab" class="minimal-tabs">
|
||||
<a-tab-pane key="editor">
|
||||
<template #tab>
|
||||
<span class="tab-title"><FileText :size="14" />代码管理</span>
|
||||
</template>
|
||||
<div class="workspace">
|
||||
<div class="tree-container">
|
||||
<div class="tree-header">
|
||||
<span class="label">项目结构</span>
|
||||
<div class="tree-actions">
|
||||
<a-tooltip title="新建文件"><button @click="openCreateModal(false)"><FilePlus :size="14" /></button></a-tooltip>
|
||||
<a-tooltip title="新建目录"><button @click="openCreateModal(true)"><FolderPlus :size="14" /></button></a-tooltip>
|
||||
<a-tooltip title="刷新"><button @click="reloadTree"><RotateCw :size="14" /></button></a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-content">
|
||||
<FileTreeComponent
|
||||
v-model:selectedKeys="selectedTreeKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
:tree-data="treeData"
|
||||
@select="handleTreeSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="current-path">
|
||||
<File :size="14" />
|
||||
<span>{{ selectedPath || '未选择文件' }}</span>
|
||||
<span v-if="canSave" class="save-hint">●</span>
|
||||
</div>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="saveCurrentFile"
|
||||
:disabled="!canSave"
|
||||
:loading="savingFile"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<Save :size="14" />
|
||||
<span>保存</span>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="editor-main">
|
||||
<a-empty v-if="!selectedPath || selectedIsDir" description="选择文件以开始编辑" class="mt-40" />
|
||||
<a-textarea
|
||||
v-else
|
||||
v-model:value="fileContent"
|
||||
class="pure-editor"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="dependencies">
|
||||
<template #tab>
|
||||
<span class="tab-title"><Layers :size="14" />依赖管理</span>
|
||||
</template>
|
||||
<div class="config-view">
|
||||
<div class="config-header">
|
||||
<div class="text">
|
||||
<h3>依赖声明</h3>
|
||||
<p>配置此 Skill 所需的工具、MCP 服务器及其他 Skill 依赖。</p>
|
||||
</div>
|
||||
<a-button type="primary" :loading="savingDependencies" @click="saveDependencies" class="lucide-icon-btn">
|
||||
<Save :size="14" />
|
||||
<span>更新依赖</span>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="config-form">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="工具依赖 (Tools)">
|
||||
<a-select v-model:value="dependencyForm.tool_dependencies" mode="multiple" :options="toolDependencyOptions" placeholder="选择工具..." allow-clear show-search />
|
||||
</a-form-item>
|
||||
<a-form-item label="MCP 依赖 (Model Context Protocol)">
|
||||
<a-select v-model:value="dependencyForm.mcp_dependencies" mode="multiple" :options="mcpDependencyOptions" placeholder="选择 MCP 服务..." allow-clear show-search />
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill 依赖">
|
||||
<a-select v-model:value="dependencyForm.skill_dependencies" mode="multiple" :options="skillDependencyOptions" placeholder="选择 Skill..." allow-clear show-search />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
@ -214,7 +191,6 @@ import {
|
||||
FilePlus, FolderPlus, File, Search, Box, FileCode
|
||||
} from 'lucide-vue-next'
|
||||
import { skillApi } from '@/apis/skill_api'
|
||||
import HeaderComponent from '@/components/HeaderComponent.vue'
|
||||
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
@ -274,18 +250,9 @@ const resetFileState = () => {
|
||||
originalFileContent.value = ''
|
||||
}
|
||||
|
||||
const expandAllKeys = (nodes) => {
|
||||
let keys = []
|
||||
nodes.forEach(node => {
|
||||
if (node.is_dir) {
|
||||
keys.push(node.key)
|
||||
if (node.children) {
|
||||
keys = keys.concat(expandAllKeys(node.children))
|
||||
}
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
||||
const expandAllKeys = (nodes) => nodes.flatMap(node =>
|
||||
node.is_dir ? [node.key, ...expandAllKeys(node.children || [])] : []
|
||||
)
|
||||
|
||||
const fetchSkills = async () => {
|
||||
loading.value = true
|
||||
@ -366,7 +333,7 @@ const selectSkill = async (record) => {
|
||||
resetFileState()
|
||||
|
||||
// 并行执行:加载树结构和获取 SKILL.md
|
||||
const [treeResult] = await Promise.all([
|
||||
await Promise.all([
|
||||
reloadTree(),
|
||||
loadSkillFile(record.slug)
|
||||
])
|
||||
@ -507,121 +474,18 @@ const saveDependencies = async () => {
|
||||
}
|
||||
|
||||
onMounted(fetchSkills)
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
fetchSkills,
|
||||
handleImportUpload
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 基础变量模拟 Shadcn/UI */
|
||||
@border-color: var(--gray-150);
|
||||
@bg-secondary: var(--gray-10); /* 调浅背景颜色 */
|
||||
@radius: 8px;
|
||||
|
||||
.skills-manager-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background-color: var(--gray-0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
height: 74px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid @border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 74px);
|
||||
}
|
||||
|
||||
.full-spin {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
:deep(.ant-spin-container) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
:deep(.ant-spin-nested-loading) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 左侧列表 */
|
||||
.sidebar-list {
|
||||
width: 280px;
|
||||
border-right: 1px solid @border-color;
|
||||
background-color: @bg-secondary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-box {
|
||||
padding: 12px 12px;
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-input {
|
||||
:deep(.ant-input) {
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
@import '@/assets/css/extensions.less';
|
||||
|
||||
.list-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: @radius;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover { background-color: var(--gray-100); }
|
||||
|
||||
&.active {
|
||||
background-color: var(--gray-0);
|
||||
border-color: @border-color;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
.item-icon { color: var(--main-color); }
|
||||
.item-name { color: var(--gray-900); }
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
.item-icon { color: var(--gray-400); }
|
||||
.item-name { font-size: 14px; font-weight: 500; color: var(--gray-700); }
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -640,77 +504,26 @@ onMounted(fetchSkills)
|
||||
|
||||
/* 右侧面板 */
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray-0);
|
||||
|
||||
.unselected-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: @bg-secondary;
|
||||
.hint-box { text-align: center; p { margin-top: 12px; color: var(--gray-400); } }
|
||||
}
|
||||
|
||||
.panel-top-bar {
|
||||
padding: 10px 16px 0 16px;
|
||||
box-sizing: border-box;
|
||||
// border-bottom: 1px solid @border-color;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.skill-summary {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: 1.2; }
|
||||
code { font-size: 12px; color: var(--gray-500); background: @bg-secondary; padding: 2px 6px; border-radius: 4px; margin-top: 4px; display: inline-block; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Tabs & 内部布局 */
|
||||
.tabs-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.minimal-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid @border-color;
|
||||
flex-shrink: 0;
|
||||
&::before { border-bottom: none; }
|
||||
}
|
||||
:deep(.ant-tabs-content) { flex: 1; min-height: 0; height: 100%; overflow: hidden; }
|
||||
:deep(.ant-tabs-tabpane) { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
}
|
||||
|
||||
.tab-title { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 文件树 */
|
||||
/* 文件 tree */
|
||||
.tree-container {
|
||||
width: 240px;
|
||||
border-right: 1px solid @border-color;
|
||||
@ -806,8 +619,6 @@ onMounted(fetchSkills)
|
||||
.config-form { max-width: 600px; :deep(.ant-form-item-label label) { font-weight: 500; font-size: 13px; } }
|
||||
}
|
||||
|
||||
/* 辅助类 */
|
||||
.text-muted { color: var(--gray-400); }
|
||||
.mt-40 { margin-top: 40px; }
|
||||
.pt-12 { padding-top: 12px; }
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { ref, reactive, onMounted, computed, provide } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
import { GithubOutlined } from '@ant-design/icons-vue'
|
||||
import { Bot, Waypoints, LibraryBig, BarChart3, CircleCheck, Folder } from 'lucide-vue-next'
|
||||
import { Bot, Waypoints, LibraryBig, BarChart3, CircleCheck, Blocks } from 'lucide-vue-next'
|
||||
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
@ -112,10 +112,10 @@ const mainList = computed(() => {
|
||||
|
||||
if (userStore.isSuperAdmin) {
|
||||
items.push({
|
||||
name: 'Skills 管理',
|
||||
path: '/skills',
|
||||
icon: Folder,
|
||||
activeIcon: Folder
|
||||
name: '扩展管理',
|
||||
path: '/extensions',
|
||||
icon: Blocks,
|
||||
activeIcon: Blocks
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -91,14 +91,14 @@ const router = createRouter({
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/skills',
|
||||
name: 'skills',
|
||||
path: '/extensions',
|
||||
name: 'extensions',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'SkillsComp',
|
||||
component: () => import('../views/SkillsView.vue'),
|
||||
name: 'ExtensionsComp',
|
||||
component: () => import('../views/ExtensionsView.vue'),
|
||||
meta: {
|
||||
keepAlive: false,
|
||||
requiresAuth: true,
|
||||
@ -108,6 +108,11 @@ const router = createRouter({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/skills',
|
||||
name: 'skills',
|
||||
redirect: '/extensions'
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
|
||||
205
web/src/views/ExtensionsView.vue
Normal file
205
web/src/views/ExtensionsView.vue
Normal file
@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="extensions-view">
|
||||
<div class="extensions-header">
|
||||
<a-tabs v-model:activeKey="activeTab" class="extensions-tabs">
|
||||
<a-tab-pane key="skills" tab="Skills 管理" />
|
||||
<a-tab-pane key="mcp" tab="MCP 服务器" />
|
||||
</a-tabs>
|
||||
<div class="header-actions">
|
||||
<!-- Skills Tab 的按钮 -->
|
||||
<template v-if="activeTab === 'skills'">
|
||||
<a-upload
|
||||
accept=".zip"
|
||||
:show-upload-list="false"
|
||||
:custom-request="handleImportUpload"
|
||||
:disabled="skillsLoading || skillsImporting"
|
||||
>
|
||||
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
|
||||
<Upload :size="14" />
|
||||
<span>导入 ZIP</span>
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-button @click="handleSkillsRefresh" :disabled="skillsLoading" class="lucide-icon-btn">
|
||||
<RotateCw :size="14" />
|
||||
<span>刷新</span>
|
||||
</a-button>
|
||||
</template>
|
||||
<!-- MCP Tab 的按钮 -->
|
||||
<template v-else-if="activeTab === 'mcp'">
|
||||
<a-button type="primary" @click="handleMcpAdd" class="lucide-icon-btn">
|
||||
<Plus :size="14" />
|
||||
<span>添加服务器</span>
|
||||
</a-button>
|
||||
<a-button @click="handleMcpRefresh" :disabled="mcpLoading" class="lucide-icon-btn">
|
||||
<RotateCw :size="14" />
|
||||
<span>刷新</span>
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="extensions-content">
|
||||
<div v-show="activeTab === 'skills'" class="tab-panel">
|
||||
<SkillsManagerComponent
|
||||
ref="skillsRef"
|
||||
@import="handleSkillsImport"
|
||||
@refresh="handleSkillsRefresh"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeTab === 'mcp'" class="tab-panel">
|
||||
<McpServersComponent
|
||||
ref="mcpRef"
|
||||
@add="handleMcpAdd"
|
||||
@refresh="handleMcpRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Upload, RotateCw, Plus } from 'lucide-vue-next'
|
||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
|
||||
const activeTab = ref('skills')
|
||||
const skillsRef = ref(null)
|
||||
const mcpRef = ref(null)
|
||||
|
||||
// Skills 相关状态(从子组件透传)
|
||||
const skillsLoading = ref(false)
|
||||
const skillsImporting = ref(false)
|
||||
const mcpLoading = ref(false)
|
||||
|
||||
// 暴露给子组件的状态更新
|
||||
const updateSkillsState = (loading, importing) => {
|
||||
skillsLoading.value = loading
|
||||
skillsImporting.value = importing
|
||||
}
|
||||
|
||||
const updateMcpState = (loading) => {
|
||||
mcpLoading.value = loading
|
||||
}
|
||||
|
||||
// Skills 事件处理
|
||||
const handleSkillsImport = () => {
|
||||
// 导入完成后自动刷新
|
||||
handleSkillsRefresh()
|
||||
}
|
||||
|
||||
const handleSkillsRefresh = () => {
|
||||
if (skillsRef.value?.fetchSkills) {
|
||||
updateSkillsState(true, skillsImporting.value)
|
||||
skillsRef.value.fetchSkills().finally(() => {
|
||||
updateSkillsState(false, skillsImporting.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MCP 事件处理
|
||||
const handleMcpAdd = () => {
|
||||
if (mcpRef.value?.showAddModal) {
|
||||
mcpRef.value.showAddModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMcpRefresh = () => {
|
||||
if (mcpRef.value?.fetchServers) {
|
||||
updateMcpState(true)
|
||||
mcpRef.value.fetchServers().finally(() => {
|
||||
updateMcpState(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理导入上传
|
||||
const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
if (skillsRef.value?.handleImportUpload) {
|
||||
updateSkillsState(skillsLoading.value, true)
|
||||
try {
|
||||
await skillsRef.value.handleImportUpload({ file, onSuccess, onError })
|
||||
handleSkillsImport()
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
} finally {
|
||||
updateSkillsState(skillsLoading.value, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.extensions-view {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray-0);
|
||||
|
||||
.extensions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
background-color: var(--gray-0);
|
||||
|
||||
.extensions-tabs {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-nav::after) {
|
||||
content: none;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-nav-left-bar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-items) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user