Files
qihuaiflow/frontend/src/pages/admin/Mcp.tsx
T

408 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' },
{ value: 'streamable_http', label: 'HTTP 流式' },
];
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 labels: Record<string, string> = { sse: 'SSE', stdio: '标准IO', streamable_http: 'HTTP 流式' };
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : type === 'streamable_http' ? { bg: '#d1fae5', color: '#065f46' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[type] || 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>
);
}