feat(admin): 移除后台 Agent/技能/MCP 手动创建按钮,改为仅支持 URL 导入/AI 开发/AI 安装 - 后台 Agent管理/技能管理/MCP服务 移除 创建/添加 按钮 - 后端上传/创建按操作者角色设定默认值:管理员=官方+已发布+免费;用户=用户+待审核+定价自选 - 应用市场 Hub 导入新增 mcp.so / MCPMarket 平台,导入表单增加定价输入(用户可选收费) - 清理全部演示数据(market_apps/mcp_servers/mcp_tool_configs/evolution_skills)

This commit is contained in:
qyyfw committed 2026-07-10 16:21:07 +08:00
1 parent d62848ee98
commit 27ba91ae5e
8 files changed
+7414 -6115

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+405 -404
View File
@@ -1,404 +1,405 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const styles: Record<string, React.CSSProperties> = {
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
title: { fontSize: 24, fontWeight: 700, color: '#1a1a2e' },
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
statValue: { fontSize: 28, fontWeight: 700, color: '#1a1a2e' },
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1a1a2e' },
formGroup: { marginBottom: 16 },
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
detailsItem: { fontSize: 13 },
detailsLabel: { color: '#6b7280', marginBottom: 4 },
};
const TRANSPORT_TYPES = [
{ value: '', label: 'All Types' },
{ value: 'stdio', label: 'STDIO' },
{ value: 'sse', label: 'SSE' },
];
const STATUS_TYPES = [
{ value: '', label: 'All Status' },
{ value: 'connected', label: 'Connected' },
{ value: 'disconnected', label: 'Disconnected' },
{ value: 'error', label: 'Error' },
];
interface MCPServer {
id: string;
name: string;
description: string;
transport_type: string;
transport_type_label: string;
command: string;
args: Record<string, any>;
env_vars: Record<string, string>;
url: string;
headers: Record<string, string>;
is_enabled: boolean;
status: string;
last_connected_at: string;
tools_count: number;
resources_count: number;
created_at: string;
updated_at: string;
}
export default function AdminMcp() {
const [servers, setServers] = useState<MCPServer[]>([]);
const [stats, setStats] = useState<any>({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [filterTransport, setFilterTransport] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
const pageSize = 20;
const showToast = (message: string, type: string = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const fetchStats = useCallback(async () => {
try {
const result = await adminAPI.getAdminMcpStats() as any;
if (result.code === 0) setStats(result.data);
} catch (e) { /* ignore */ }
}, []);
const fetchServers = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (search) params.search = search;
if (filterTransport) params.transport_type = filterTransport;
if (filterStatus) params.status = filterStatus;
const result = await adminAPI.getAdminMcpServers(params) as any;
if (result.code === 0) {
setServers(result.data.items || []);
setTotal(result.data.total || 0);
}
} catch (e) {
showToast('Failed to load servers', 'error');
} finally {
setLoading(false);
}
}, [page, search, filterTransport, filterStatus]);
useEffect(() => {
fetchStats();
fetchServers();
}, [fetchStats, fetchServers]);
const handleCreate = () => {
setEditingServer(null);
setShowModal(true);
};
const handleEdit = (server: MCPServer) => {
setEditingServer(server);
setShowModal(true);
};
const handleDelete = async (server: MCPServer) => {
if (!confirm(`Delete MCP server "${server.name}"?`)) return;
try {
await adminAPI.deleteAdminMcpServer(server.id);
showToast('Server deleted');
fetchServers();
fetchStats();
} catch (e) {
showToast('Delete failed', 'error');
}
};
const handleToggle = async (server: MCPServer) => {
try {
await adminAPI.toggleAdminMcpServer(server.id);
showToast(server.is_enabled ? 'Disabled' : 'Enabled');
fetchServers();
fetchStats();
} catch (e) {
showToast('Toggle failed', 'error');
}
};
const handleTest = async (server: MCPServer) => {
try {
const result = await adminAPI.testAdminMcpServer(server.id) as any;
if (result.code === 0) {
showToast(result.data.success ? 'Connection successful' : `Failed: ${result.data.message}`, result.data.success ? 'success' : 'error');
fetchServers();
fetchStats();
}
} catch (e) {
showToast('Test failed', 'error');
}
};
const handleSubmit = async (formData: any) => {
try {
if (editingServer) {
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
showToast('Server updated');
} else {
await adminAPI.createAdminMcpServer(formData);
showToast('Server created');
}
setShowModal(false);
fetchServers();
fetchStats();
} catch (e: any) {
showToast(e.message || 'Operation failed', 'error');
}
};
const getStatusBadge = (status: string) => {
const colors: Record<string, { bg: string; color: string }> = {
connected: { bg: '#dcfce7', color: '#166534' },
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
error: { bg: '#fee2e2', color: '#991b1b' },
};
const c = colors[status] || colors.disconnected;
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{status}</span>;
};
const getTransportBadge = (type: string) => {
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type.toUpperCase()}</span>;
};
const totalPages = Math.ceil(total / pageSize);
return (
<div style={styles.container}>
{toast && (
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
{toast.message}
</div>
)}
<div style={styles.header}>
<h1 style={styles.title}>MCP Servers</h1>
<button style={styles.addBtn} onClick={handleCreate}>+ Add Server</button>
</div>
<div style={styles.statsRow}>
<div style={styles.statCard}>
<div style={styles.statLabel}>Total Servers</div>
<div style={styles.statValue}>{stats.total_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>Enabled</div>
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>Connected</div>
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>By Transport</div>
<div style={{ fontSize: 13, color: '#374151' }}>
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
))}
</div>
</div>
</div>
<div style={styles.toolbar}>
<input
style={styles.searchInput}
placeholder="Search servers..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
/>
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Transport</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Tools</th>
<th style={styles.th}>Last Connected</th>
<th style={styles.th}>Enabled</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>Loading...</td></tr>
) : servers.length === 0 ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>No MCP servers found</td></tr>
) : servers.map(s => (
<tr key={s.id}>
<td style={styles.td}>
<div style={{ fontWeight: 600 }}>{s.name}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
</td>
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
<td style={styles.td}>{getStatusBadge(s.status)}</td>
<td style={styles.td}>{s.tools_count || '-'}</td>
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : 'Never'}</td>
<td style={styles.td}>
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
{s.is_enabled ? 'Yes' : 'No'}
</span>
</td>
<td style={styles.td}>
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>Edit</button>
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
{s.is_enabled ? 'Disable' : 'Enable'}
</button>
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>Test</button>
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>Del</button>
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div style={styles.pagination}>
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
<span style={{ padding: '8px 12px', fontSize: 14 }}>Page {page} / {totalPages}</span>
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)}
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
</div>
);
}
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
const [form, setForm] = useState({
name: server?.name || '',
description: server?.description || '',
transport_type: server?.transport_type || 'stdio',
command: server?.command || '',
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
url: server?.url || '',
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
});
const handleChange = (field: string, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: any = {
name: form.name,
description: form.description,
transport_type: form.transport_type,
};
if (form.transport_type === 'stdio') {
data.command = form.command;
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
} else {
data.url = form.url;
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
}
onSubmit(data);
};
return (
<div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalTitle}>{server ? 'Edit MCP Server' : 'Add MCP Server'}</div>
<form onSubmit={handleSubmit}>
<div style={styles.formGroup}>
<label style={styles.label}>Name *</label>
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="e.g., Filesystem Server" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Description</label>
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="Optional description" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Transport Type</label>
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
<option value="stdio">STDIO (command-based)</option>
<option value="sse">SSE (HTTP-based)</option>
</select>
</div>
{form.transport_type === 'stdio' ? (
<>
<div style={styles.formGroup}>
<label style={styles.label}>Command</label>
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="e.g., npx @modelcontextprotocol/server-filesystem" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Args (JSON)</label>
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Env Vars (JSON)</label>
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
</div>
</>
) : (
<>
<div style={styles.formGroup}>
<label style={styles.label}>SSE URL</label>
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Headers (JSON)</label>
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
</div>
</>
)}
<div style={styles.modalActions}>
<button type="button" style={styles.cancelBtn} onClick={onClose}>Cancel</button>
<button type="submit" style={styles.submitBtn}>{server ? 'Update' : 'Create'}</button>
</div>
</form>
</div>
</div>
);
}
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const styles: Record<string, React.CSSProperties> = {
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
title: { fontSize: 24, fontWeight: 700, color: '#1A1F36' },
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' },
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1A1F36' },
formGroup: { marginBottom: 16 },
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
detailsItem: { fontSize: 13 },
detailsLabel: { color: '#6b7280', marginBottom: 4 },
};
const TRANSPORT_TYPES = [
{ value: '', label: '全部类型' },
{ value: 'stdio', label: 'STDIO' },
{ value: 'sse', label: 'SSE' },
];
const STATUS_TYPES = [
{ value: '', label: '全部状态' },
{ value: 'connected', label: '已连接' },
{ value: 'disconnected', label: '未连接' },
{ value: 'error', label: '错误' },
];
interface MCPServer {
id: string;
name: string;
description: string;
transport_type: string;
transport_type_label: string;
command: string;
args: Record<string, any>;
env_vars: Record<string, string>;
url: string;
headers: Record<string, string>;
is_enabled: boolean;
status: string;
last_connected_at: string;
tools_count: number;
resources_count: number;
created_at: string;
updated_at: string;
}
export default function AdminMcp() {
const [servers, setServers] = useState<MCPServer[]>([]);
const [stats, setStats] = useState<any>({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [filterTransport, setFilterTransport] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
const pageSize = 20;
const showToast = (message: string, type: string = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const fetchStats = useCallback(async () => {
try {
const result = await adminAPI.getAdminMcpStats() as any;
if (result.code === 0) setStats(result.data);
} catch (e) { /* ignore */ }
}, []);
const fetchServers = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (search) params.search = search;
if (filterTransport) params.transport_type = filterTransport;
if (filterStatus) params.status = filterStatus;
const result = await adminAPI.getAdminMcpServers(params) as any;
if (result.code === 0) {
setServers(result.data.items || []);
setTotal(result.data.total || 0);
}
} catch (e) {
showToast('加载服务失败', 'error');
} finally {
setLoading(false);
}
}, [page, search, filterTransport, filterStatus]);
useEffect(() => {
fetchStats();
fetchServers();
}, [fetchStats, fetchServers]);
const handleCreate = () => {
setEditingServer(null);
setShowModal(true);
};
const handleEdit = (server: MCPServer) => {
setEditingServer(server);
setShowModal(true);
};
const handleDelete = async (server: MCPServer) => {
if (!confirm(`确认删除 MCP 服务 "${server.name}"?`)) return;
try {
await adminAPI.deleteAdminMcpServer(server.id);
showToast('服务已删除');
fetchServers();
fetchStats();
} catch (e) {
showToast('删除失败', 'error');
}
};
const handleToggle = async (server: MCPServer) => {
try {
await adminAPI.toggleAdminMcpServer(server.id);
showToast(server.is_enabled ? '已禁用' : '已启用');
fetchServers();
fetchStats();
} catch (e) {
showToast('切换失败', 'error');
}
};
const handleTest = async (server: MCPServer) => {
try {
const result = await adminAPI.testAdminMcpServer(server.id) as any;
if (result.code === 0) {
showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error');
fetchServers();
fetchStats();
}
} catch (e) {
showToast('测试失败', 'error');
}
};
const handleSubmit = async (formData: any) => {
try {
if (editingServer) {
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
showToast('服务已更新');
} else {
await adminAPI.createAdminMcpServer(formData);
showToast('服务已创建');
}
setShowModal(false);
fetchServers();
fetchStats();
} catch (e: any) {
showToast(e.message || '操作失败', 'error');
}
};
const getStatusBadge = (status: string) => {
const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' };
const colors: Record<string, { bg: string; color: string }> = {
connected: { bg: '#dcfce7', color: '#166534' },
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
error: { bg: '#fee2e2', color: '#991b1b' },
};
const c = colors[status] || colors.disconnected;
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>;
};
const getTransportBadge = (type: string) => {
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>;
};
const totalPages = Math.ceil(total / pageSize);
return (
<div style={styles.container}>
{toast && (
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
{toast.message}
</div>
)}
<div style={styles.header}>
<h1 style={styles.title}>MCP 服务管理</h1>
{/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */}
</div>
<div style={styles.statsRow}>
<div style={styles.statCard}>
<div style={styles.statLabel}>服务总数</div>
<div style={styles.statValue}>{stats.total_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>已启用</div>
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>已连接</div>
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>传输方式分布</div>
<div style={{ fontSize: 13, color: '#374151' }}>
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
))}
</div>
</div>
</div>
<div style={styles.toolbar}>
<input
style={styles.searchInput}
placeholder="搜索服务..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
/>
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>名称</th>
<th style={styles.th}>传输方式</th>
<th style={styles.th}>状态</th>
<th style={styles.th}>工具数</th>
<th style={styles.th}>最后连接</th>
<th style={styles.th}>启用</th>
<th style={styles.th}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
) : servers.length === 0 ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
) : servers.map(s => (
<tr key={s.id}>
<td style={styles.td}>
<div style={{ fontWeight: 600 }}>{s.name}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
</td>
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
<td style={styles.td}>{getStatusBadge(s.status)}</td>
<td style={styles.td}>{s.tools_count || '-'}</td>
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
<td style={styles.td}>
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
{s.is_enabled ? '是' : '否'}
</span>
</td>
<td style={styles.td}>
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button>
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
{s.is_enabled ? '禁用' : '启用'}
</button>
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button>
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div style={styles.pagination}>
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span>
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
)}
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
</div>
);
}
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
const [form, setForm] = useState({
name: server?.name || '',
description: server?.description || '',
transport_type: server?.transport_type || 'stdio',
command: server?.command || '',
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
url: server?.url || '',
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
});
const handleChange = (field: string, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: any = {
name: form.name,
description: form.description,
transport_type: form.transport_type,
};
if (form.transport_type === 'stdio') {
data.command = form.command;
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
} else {
data.url = form.url;
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
}
onSubmit(data);
};
return (
<div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
<form onSubmit={handleSubmit}>
<div style={styles.formGroup}>
<label style={styles.label}>名称 *</label>
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>描述</label>
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>传输类型</label>
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
<option value="stdio">STDIO(命令行方式)</option>
<option value="sse">SSE(HTTP 方式)</option>
</select>
</div>
{form.transport_type === 'stdio' ? (
<>
<div style={styles.formGroup}>
<label style={styles.label}>命令</label>
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>参数(JSON)</label>
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>环境变量(JSON)</label>
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
</div>
</>
) : (
<>
<div style={styles.formGroup}>
<label style={styles.label}>SSE 地址</label>
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>请求头(JSON)</label>
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
</div>
</>
)}
<div style={styles.modalActions}>
<button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button>
<button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button>
</div>
</form>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff