refactor(web): 重新组织设置和技能管理组件

- 从SettingsModal.vue中移除了非超级管理员的MCP管理功能。
- 更新了SkillsManagerComponent.vue以增强布局和功能。
- 引入了新的ExtensionsView.vue来管理技能和MCP服务器。
- 更新路由将/skills更改为/extensions。
- 调整了AppLayout.vue以使用新的图标和路径进行扩展管理。
This commit is contained in:
Wenjie Zhang 2026-03-03 11:49:50 +08:00
parent b144b72de0
commit a963241088
8 changed files with 1176 additions and 1350 deletions

View 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;
}

View File

@ -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>

View File

@ -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">
管理 MCPModel 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>

View File

@ -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({

View File

@ -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; }

View File

@ -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
})
}

View File

@ -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',

View 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>