mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 10:25:05 +08:00
182015264c
Expand McpApps interface, APP_IDS, APP_ICON_MAP, enabledCounts initializers, and test mock data to include the openclaw key, resolving TypeScript errors after AppId union was extended.
309 lines
8.9 KiB
TypeScript
309 lines
8.9 KiB
TypeScript
import React, { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Server } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import {
|
|
useAllMcpServers,
|
|
useToggleMcpApp,
|
|
useDeleteMcpServer,
|
|
useImportMcpFromApps,
|
|
} from "@/hooks/useMcp";
|
|
import type { McpServer } from "@/types";
|
|
import type { AppId } from "@/lib/api/types";
|
|
import McpFormModal from "./McpFormModal";
|
|
import { ConfirmDialog } from "../ConfirmDialog";
|
|
import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
|
import { settingsApi } from "@/lib/api";
|
|
import { mcpPresets } from "@/config/mcpPresets";
|
|
import { toast } from "sonner";
|
|
import { APP_IDS } from "@/config/appConfig";
|
|
import { AppCountBar } from "@/components/common/AppCountBar";
|
|
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
|
import { ListItemRow } from "@/components/common/ListItemRow";
|
|
|
|
interface UnifiedMcpPanelProps {
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export interface UnifiedMcpPanelHandle {
|
|
openAdd: () => void;
|
|
openImport: () => void;
|
|
}
|
|
|
|
const UnifiedMcpPanel = React.forwardRef<
|
|
UnifiedMcpPanelHandle,
|
|
UnifiedMcpPanelProps
|
|
>(({ onOpenChange: _onOpenChange }, ref) => {
|
|
const { t } = useTranslation();
|
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
isOpen: boolean;
|
|
title: string;
|
|
message: string;
|
|
onConfirm: () => void;
|
|
} | null>(null);
|
|
|
|
const { data: serversMap, isLoading } = useAllMcpServers();
|
|
const toggleAppMutation = useToggleMcpApp();
|
|
const deleteServerMutation = useDeleteMcpServer();
|
|
const importMutation = useImportMcpFromApps();
|
|
|
|
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
|
if (!serversMap) return [];
|
|
return Object.entries(serversMap);
|
|
}, [serversMap]);
|
|
|
|
const enabledCounts = useMemo(() => {
|
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
|
serverEntries.forEach(([_, server]) => {
|
|
for (const app of APP_IDS) {
|
|
if (server.apps[app]) counts[app]++;
|
|
}
|
|
});
|
|
return counts;
|
|
}, [serverEntries]);
|
|
|
|
const handleToggleApp = async (
|
|
serverId: string,
|
|
app: AppId,
|
|
enabled: boolean,
|
|
) => {
|
|
try {
|
|
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
|
} catch (error) {
|
|
toast.error(t("common.error"), { description: String(error) });
|
|
}
|
|
};
|
|
|
|
const handleEdit = (id: string) => {
|
|
setEditingId(id);
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const handleAdd = () => {
|
|
setEditingId(null);
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const handleImport = async () => {
|
|
try {
|
|
const count = await importMutation.mutateAsync();
|
|
if (count === 0) {
|
|
toast.success(t("mcp.unifiedPanel.noImportFound"), {
|
|
closeButton: true,
|
|
});
|
|
} else {
|
|
toast.success(t("mcp.unifiedPanel.importSuccess", { count }), {
|
|
closeButton: true,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast.error(t("common.error"), { description: String(error) });
|
|
}
|
|
};
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
openAdd: handleAdd,
|
|
openImport: handleImport,
|
|
}));
|
|
|
|
const handleDelete = (id: string) => {
|
|
setConfirmDialog({
|
|
isOpen: true,
|
|
title: t("mcp.unifiedPanel.deleteServer"),
|
|
message: t("mcp.unifiedPanel.deleteConfirm", { id }),
|
|
onConfirm: async () => {
|
|
try {
|
|
await deleteServerMutation.mutateAsync(id);
|
|
setConfirmDialog(null);
|
|
toast.success(t("common.success"), { closeButton: true });
|
|
} catch (error) {
|
|
toast.error(t("common.error"), { description: String(error) });
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleCloseForm = () => {
|
|
setIsFormOpen(false);
|
|
setEditingId(null);
|
|
};
|
|
|
|
return (
|
|
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
|
<AppCountBar
|
|
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
|
counts={enabledCounts}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
|
{isLoading ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
{t("mcp.loading")}
|
|
</div>
|
|
) : serverEntries.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
|
<Server size={24} className="text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
{t("mcp.unifiedPanel.noServers")}
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("mcp.emptyDescription")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<TooltipProvider delayDuration={300}>
|
|
<div className="rounded-xl border border-border-default overflow-hidden">
|
|
{serverEntries.map(([id, server], index) => (
|
|
<UnifiedMcpListItem
|
|
key={id}
|
|
id={id}
|
|
server={server}
|
|
onToggleApp={handleToggleApp}
|
|
onEdit={handleEdit}
|
|
onDelete={handleDelete}
|
|
isLast={index === serverEntries.length - 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
|
|
{isFormOpen && (
|
|
<McpFormModal
|
|
editingId={editingId || undefined}
|
|
initialData={
|
|
editingId && serversMap ? serversMap[editingId] : undefined
|
|
}
|
|
existingIds={serversMap ? Object.keys(serversMap) : []}
|
|
defaultFormat="json"
|
|
onSave={async () => {
|
|
setIsFormOpen(false);
|
|
setEditingId(null);
|
|
}}
|
|
onClose={handleCloseForm}
|
|
/>
|
|
)}
|
|
|
|
{confirmDialog && (
|
|
<ConfirmDialog
|
|
isOpen={confirmDialog.isOpen}
|
|
title={confirmDialog.title}
|
|
message={confirmDialog.message}
|
|
onConfirm={confirmDialog.onConfirm}
|
|
onCancel={() => setConfirmDialog(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
|
|
|
interface UnifiedMcpListItemProps {
|
|
id: string;
|
|
server: McpServer;
|
|
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
|
onEdit: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
isLast?: boolean;
|
|
}
|
|
|
|
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|
id,
|
|
server,
|
|
onToggleApp,
|
|
onEdit,
|
|
onDelete,
|
|
isLast,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const name = server.name || id;
|
|
const description = server.description || "";
|
|
|
|
const meta = mcpPresets.find((p) => p.id === id);
|
|
const docsUrl = server.docs || meta?.docs;
|
|
const homepageUrl = server.homepage || meta?.homepage;
|
|
const tags = server.tags || meta?.tags;
|
|
|
|
const openDocs = async () => {
|
|
const url = docsUrl || homepageUrl;
|
|
if (!url) return;
|
|
try {
|
|
await settingsApi.openExternal(url);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ListItemRow isLast={isLast}>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-medium text-sm text-foreground truncate">
|
|
{name}
|
|
</span>
|
|
{docsUrl && (
|
|
<button
|
|
type="button"
|
|
onClick={openDocs}
|
|
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
|
|
title={t("mcp.presets.docs")}
|
|
>
|
|
<ExternalLink size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{description && (
|
|
<p
|
|
className="text-xs text-muted-foreground truncate"
|
|
title={description}
|
|
>
|
|
{description}
|
|
</p>
|
|
)}
|
|
{!description && tags && tags.length > 0 && (
|
|
<p className="text-xs text-muted-foreground/60 truncate">
|
|
{tags.join(", ")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<AppToggleGroup
|
|
apps={server.apps}
|
|
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
|
/>
|
|
|
|
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => onEdit(id)}
|
|
title={t("common.edit")}
|
|
>
|
|
<Edit3 size={14} />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
|
onClick={() => onDelete(id)}
|
|
title={t("common.delete")}
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</ListItemRow>
|
|
);
|
|
};
|
|
|
|
export default UnifiedMcpPanel;
|