refactor(mcp): redesign MCP management panel UI

- Redesign MCP panel to match main interface style
- Add toggle switch for each MCP server to enable/disable
- Use emerald theme color consistent with MCP button
- Create card-based layout with one MCP per row
- Add dedicated form modal for add/edit operations
- Implement proper empty state with friendly prompts
- Add comprehensive i18n support (zh/en)
- Extend McpServer type to support enabled field
- Backend already supports enabled field via serde_json::Value

Components:
- McpPanel: Main panel container with header and list
- McpListItem: Card-based list item with toggle and actions
- McpFormModal: Independent modal for add/edit forms
- McpToggle: Emerald-themed toggle switch component

All changes passed TypeScript type checking and production build.
This commit is contained in:
Jason
2025-10-09 11:04:36 +08:00
parent 96a4b4fe95
commit 59c13c3366
9 changed files with 566 additions and 301 deletions

View File

@@ -1,54 +1,37 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Plus, Save, Trash2, Wrench } from "lucide-react";
import { X, Plus, Server } from "lucide-react";
import { McpServer, McpStatus } from "../../types";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
interface McpPanelProps {
onClose: () => void;
onNotify?: (message: string, type: "success" | "error", duration?: number) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
}
const emptyServer: McpServer & { id?: string } = {
type: "stdio",
command: "",
args: [],
env: {},
};
const parseEnvText = (text: string): Record<string, string> => {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const env: Record<string, string> = {};
for (const l of lines) {
const idx = l.indexOf("=");
if (idx > 0) {
const k = l.slice(0, idx).trim();
const v = l.slice(idx + 1).trim();
if (k) env[k] = v;
}
}
return env;
};
const formatEnvText = (env?: Record<string, string>): string => {
if (!env) return "";
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
};
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
const { t } = useTranslation();
const [status, setStatus] = useState<McpStatus | null>(null);
const [servers, setServers] = useState<Record<string, McpServer>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<McpServer & { id?: string }>(emptyServer);
const [formEnvText, setFormEnvText] = useState<string>("");
const [formArgsText, setFormArgsText] = useState<string>("");
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const reload = async () => {
setLoading(true);
@@ -77,92 +60,67 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
reload();
}, []);
// 用户级 MCP不需要项目级启用开关
const resetForm = () => {
setEditingId(null);
setForm(emptyServer);
setFormArgsText("");
setFormEnvText("");
};
const beginEdit = (id?: string) => {
if (!id) {
resetForm();
return;
}
const spec = servers[id];
setEditingId(id);
setForm({ id, ...spec });
setFormArgsText((spec.args || []).join(" "));
setFormEnvText(formatEnvText(spec.env));
};
const submitForm = async () => {
if (!form.id || !form.id.trim()) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return;
}
if (!form.command || !form.command.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return;
}
setSaving(true);
const handleToggle = async (id: string, enabled: boolean) => {
try {
const spec: McpServer = {
type: form.type,
command: form.command.trim(),
args: formArgsText
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0),
env: parseEnvText(formEnvText),
...(form.cwd ? { cwd: form.cwd } : {}),
};
await window.api.upsertClaudeMcpServer(form.id.trim(), spec);
await reload();
resetForm();
onNotify?.(t("mcp.msg.saved"), "success", 1500);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 6000);
} finally {
setSaving(false);
}
};
const server = servers[id];
if (!server) return;
const removeServer = async (id: string) => {
try {
await window.api.deleteClaudeMcpServer(id);
const updatedServer = { ...server, enabled };
await window.api.upsertClaudeMcpServer(id, updatedServer);
await reload();
if (editingId === id) resetForm();
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.deleteFailed"), "error", 5000);
}
};
const addTemplateFetch = async () => {
try {
await window.api.upsertClaudeMcpServer("mcp-fetch", {
type: "stdio",
command: "uvx",
args: ["mcp-server-fetch"],
});
await reload();
onNotify?.(t("mcp.msg.templateAdded"), "success", 1500);
onNotify?.(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
"success",
1500,
);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 5000);
}
};
const validateCommand = async () => {
if (!form.command) return;
const ok = await window.api.validateMcpCommand(form.command.trim());
onNotify?.(
ok ? t("mcp.validation.ok") : t("mcp.validation.fail"),
ok ? "success" : "error",
1500,
);
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await window.api.deleteClaudeMcpServer(id);
await reload();
setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.deleteFailed"), "error", 5000);
}
},
});
};
const handleSave = async (id: string, server: McpServer) => {
try {
await window.api.upsertClaudeMcpServer(id, server);
await reload();
setIsFormOpen(false);
setEditingId(null);
onNotify?.(t("mcp.msg.saved"), "success", 1500);
} catch (e: any) {
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 6000);
}
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(() => Object.entries(servers), [servers]);
@@ -170,203 +128,105 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-4xl w-full mx-4 overflow-hidden">
{/* Panel */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-4xl w-full mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("mcp.title")}
</h3>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
<div className="flex items-center gap-3">
<button
onClick={handleAdd}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700 transition-colors"
>
<Plus size={16} />
{t("mcp.add")}
</button>
<button
onClick={onClose}
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Content */}
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: status & list */}
<div>
<div className="mb-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.userLevelPath")}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 break-all">
{status?.userConfigPath}
</div>
</div>
<div className="flex items-center gap-3 mb-3">
<button
onClick={() => beginEdit(undefined)}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md bg-blue-500 text-white hover:bg-blue-600"
>
<Plus size={16} /> {t("mcp.add")}
</button>
<button
onClick={addTemplateFetch}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md bg-emerald-500 text-white hover:bg-emerald-600"
>
<Wrench size={16} /> {t("mcp.template.fetch")}
</button>
</div>
<div className="border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden">
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
<span>
{t("mcp.serverList")} ({status?.serverCount || 0})
</span>
<span className="text-gray-400">{status?.userConfigPath}</span>
</div>
<div className="max-h-64 overflow-auto divide-y divide-gray-200 dark:divide-gray-800">
{loading && (
<div className="p-4 text-sm text-gray-500">{t("mcp.loading")}</div>
)}
{!loading && serverEntries.length === 0 && (
<div className="p-4 text-sm text-gray-500">{t("mcp.empty")}</div>
)}
{!loading &&
serverEntries.map(([id, spec]) => (
<div key={id} className="p-3 flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{id}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{spec.type} · {spec.command} {spec.args?.join(" ")}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => beginEdit(id)}
className="px-2 py-1 text-xs rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("common.edit")}
</button>
<button
onClick={() => removeServer(id)}
className="px-2 py-1 text-xs rounded-md bg-red-500 text-white hover:bg-red-600 flex items-center gap-1"
>
<Trash2 size={14} /> {t("common.delete")}
</button>
</div>
</div>
))}
</div>
</div>
{/* Info Section */}
<div className="flex-shrink-0 px-6 pt-4 pb-2">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.configPath")}:{" "}
<span className="text-xs break-all">{status?.userConfigPath}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t("mcp.serverCount", { count: status?.serverCount || 0 })}
</div>
</div>
{/* Right: form */}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{editingId ? t("mcp.editServer") : t("mcp.addServer")}
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs text-gray-500 mb-1">
{t("mcp.id")}
</label>
<input
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 outline-none focus:ring-2 focus:ring-blue-500"
placeholder="my-mcp"
value={form.id || ""}
onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))}
{serverEntries.map(([id, server]) => (
<McpListItem
key={id}
id={id}
server={server}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">
{t("mcp.type")}
</label>
<select
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
value={form.type}
onChange={(e) =>
setForm((s) => ({ ...s, type: e.target.value as any }))
}
>
<option value="stdio">stdio</option>
<option value="sse">sse</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
{t("mcp.cwd")}
</label>
<input
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
placeholder="/path/to/project"
value={form.cwd || ""}
onChange={(e) => setForm((s) => ({ ...s, cwd: e.target.value }))}
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
{t("mcp.command")}
</label>
<div className="flex gap-2">
<input
className="flex-1 px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
placeholder="uvx"
value={form.command}
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
/>
<button
type="button"
onClick={validateCommand}
className="px-3 py-2 rounded-md bg-emerald-500 text-white hover:bg-emerald-600 text-sm inline-flex items-center gap-1"
>
<Wrench size={16} /> {t("mcp.validateCommand")}
</button>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{t("mcp.args")}</label>
<input
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
placeholder={t("mcp.argsPlaceholder")}
value={formArgsText}
onChange={(e) => setFormArgsText(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{t("mcp.env")}</label>
<textarea
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 h-24"
placeholder={t("mcp.envPlaceholder")}
value={formEnvText}
onChange={(e) => setFormEnvText(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<button
onClick={submitForm}
disabled={saving}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-60"
>
<Save size={16} /> {editingId ? t("common.save") : t("common.add")}
</button>
<button
onClick={resetForm}
className="px-4 py-2 text-sm rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("mcp.reset")}
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
onSave={handleSave}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
};