mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-16 17:59:32 +08:00
refactor(ui): extract shared components and deduplicate MCP/Skills panels (#897)
* refactor(ui): add tooltips and icons to MCP and Skills panels * refactor: deduplicate UnifiedSkillsPanel and UnifiedMcpPanel shared code
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
|
||||
interface AppCountBarProps {
|
||||
totalLabel: string;
|
||||
counts: Record<AppId, number>;
|
||||
}
|
||||
|
||||
export const AppCountBar: React.FC<AppCountBarProps> = ({ totalLabel, counts }) => {
|
||||
return (
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||
<Badge variant="outline" className="bg-background/50 h-7 px-3">
|
||||
{totalLabel}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||
{APP_IDS.map((app) => (
|
||||
<Badge
|
||||
key={app}
|
||||
variant="secondary"
|
||||
className={APP_ICON_MAP[app].badgeClass}
|
||||
>
|
||||
<span className="opacity-75">{APP_ICON_MAP[app].label}:</span>
|
||||
<span className="font-bold ml-1">{counts[app]}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
|
||||
interface AppToggleGroupProps {
|
||||
apps: Record<AppId, boolean>;
|
||||
onToggle: (app: AppId, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({ apps, onToggle }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{APP_IDS.map((app) => {
|
||||
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||
const enabled = apps[app];
|
||||
return (
|
||||
<Tooltip key={app}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(app, !enabled)}
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
|
||||
enabled
|
||||
? activeClass
|
||||
: "opacity-35 hover:opacity-70"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{label}{enabled ? " ✓" : ""}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
interface ListItemRowProps {
|
||||
isLast?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ListItemRow: React.FC<ListItemRowProps> = ({ isLast, children }) => {
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
|
||||
!isLast ? "border-b border-border-default" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useAllMcpServers,
|
||||
useToggleMcpApp,
|
||||
@@ -13,19 +13,19 @@ import type { McpServer } from "@/types";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import McpFormModal from "./McpFormModal";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { Edit3, Trash2 } from "lucide-react";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 MCP 管理面板
|
||||
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
||||
*/
|
||||
export interface UnifiedMcpPanelHandle {
|
||||
openAdd: () => void;
|
||||
openImport: () => void;
|
||||
@@ -45,26 +45,22 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
// Queries and Mutations
|
||||
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||
const toggleAppMutation = useToggleMcpApp();
|
||||
const deleteServerMutation = useDeleteMcpServer();
|
||||
const importMutation = useImportMcpFromApps();
|
||||
|
||||
// Convert serversMap to array for easier rendering
|
||||
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||
if (!serversMap) return [];
|
||||
return Object.entries(serversMap);
|
||||
}, [serversMap]);
|
||||
|
||||
// Count enabled servers per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
if (server.apps.claude) counts.claude++;
|
||||
if (server.apps.codex) counts.codex++;
|
||||
if (server.apps.gemini) counts.gemini++;
|
||||
if (server.apps.opencode) counts.opencode++;
|
||||
for (const app of APP_IDS) {
|
||||
if (server.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [serverEntries]);
|
||||
@@ -77,9 +73,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,9 +100,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,9 +120,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("common.success"), { closeButton: true });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -143,18 +133,11 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||
{t("mcp.unifiedPanel.apps.opencode")}: {enabledCounts.opencode}
|
||||
</div>
|
||||
</div>
|
||||
<AppCountBar
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
/>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -173,22 +156,24 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{serverEntries.map(([id, server]) => (
|
||||
<UnifiedMcpListItem
|
||||
key={id}
|
||||
id={id}
|
||||
server={server}
|
||||
onToggleApp={handleToggleApp}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Form Modal */}
|
||||
{isFormOpen && (
|
||||
<McpFormModal
|
||||
editingId={editingId || undefined}
|
||||
@@ -205,7 +190,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
@@ -221,16 +205,13 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
|
||||
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
|
||||
|
||||
/**
|
||||
* 统一 MCP 列表项组件
|
||||
* 展示服务器名称、描述,以及三个应用的复选框
|
||||
*/
|
||||
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> = ({
|
||||
@@ -239,12 +220,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
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;
|
||||
@@ -261,126 +242,61 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:服务器信息 */}
|
||||
<ListItemRow isLast={isLast}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{name}</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm text-foreground truncate">{name}</span>
|
||||
{docsUrl && (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
|
||||
title={t("mcp.presets.docs")}
|
||||
>
|
||||
{t("mcp.presets.docs")}
|
||||
</Button>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
<p className="text-xs text-muted-foreground truncate" title={description}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{!description && tags && tags.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground/70 truncate">
|
||||
<p className="text-xs text-muted-foreground/60 truncate">
|
||||
{tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-claude`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-claude`}
|
||||
checked={server.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AppToggleGroup
|
||||
apps={server.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-codex`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-codex`}
|
||||
checked={server.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-gemini`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-gemini`}
|
||||
checked={server.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-opencode`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.opencode")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-opencode`}
|
||||
checked={server.apps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(id, "opencode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<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={16} />
|
||||
<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)}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItemRow>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ import {
|
||||
useSkillRepos,
|
||||
useAddSkillRepo,
|
||||
useRemoveSkillRepo,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppType;
|
||||
initialApp?: AppId;
|
||||
}
|
||||
|
||||
export interface SkillsPageHandle {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useInstalledSkills,
|
||||
useToggleSkillApp,
|
||||
@@ -11,20 +11,20 @@ import {
|
||||
useImportSkillsFromApps,
|
||||
useInstallSkillsFromZip,
|
||||
type InstalledSkill,
|
||||
type AppType,
|
||||
} from "@/hooks/useSkills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi, skillsApi } from "@/lib/api";
|
||||
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 UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 Skills 管理面板
|
||||
* v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端
|
||||
*/
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
@@ -44,7 +44,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
|
||||
// Queries and Mutations
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
@@ -53,30 +52,26 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
const installFromZipMutation = useInstallSkillsFromZip();
|
||||
|
||||
// Count enabled skills per app
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
if (skill.apps.claude) counts.claude++;
|
||||
if (skill.apps.codex) counts.codex++;
|
||||
if (skill.apps.gemini) counts.gemini++;
|
||||
if (skill.apps.opencode) counts.opencode++;
|
||||
for (const app of APP_IDS) {
|
||||
if (skill.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [skills]);
|
||||
|
||||
const handleToggleApp = async (
|
||||
id: string,
|
||||
app: AppType,
|
||||
app: AppId,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
try {
|
||||
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,9 +88,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -110,9 +103,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
setImportDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,25 +115,16 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFromZip = async () => {
|
||||
try {
|
||||
// 打开文件选择对话框
|
||||
const filePath = await skillsApi.openZipFileDialog();
|
||||
if (!filePath) {
|
||||
// 用户取消选择
|
||||
return;
|
||||
}
|
||||
if (!filePath) return;
|
||||
|
||||
// 默认使用 claude 作为当前应用
|
||||
const currentApp: AppType = "claude";
|
||||
|
||||
// 安装 Skills
|
||||
const currentApp: AppId = "claude";
|
||||
const installed = await installFromZipMutation.mutateAsync({
|
||||
filePath,
|
||||
currentApp,
|
||||
@@ -166,9 +148,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("skills.installFailed"), {
|
||||
description: String(error),
|
||||
});
|
||||
toast.error(t("skills.installFailed"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,18 +160,11 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* Info Section */}
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("skills.installed", { count: skills?.length || 0 })} ·{" "}
|
||||
{t("skills.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||
{t("skills.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||
{t("skills.apps.gemini")}: {enabledCounts.gemini} ·{" "}
|
||||
{t("skills.apps.opencode")}: {enabledCounts.opencode}
|
||||
</div>
|
||||
</div>
|
||||
<AppCountBar
|
||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||
counts={enabledCounts}
|
||||
/>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
@@ -210,20 +183,22 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{skills.map((skill) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="rounded-xl border border-border-default overflow-hidden">
|
||||
{skills.map((skill, index) => (
|
||||
<InstalledSkillListItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onToggleApp={handleToggleApp}
|
||||
onUninstall={() => handleUninstall(skill)}
|
||||
isLast={index === skills.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
@@ -234,7 +209,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import Dialog */}
|
||||
{importDialogOpen && unmanagedSkills && (
|
||||
<ImportSkillsDialog
|
||||
skills={unmanagedSkills}
|
||||
@@ -248,19 +222,18 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
|
||||
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
|
||||
|
||||
/**
|
||||
* 已安装 Skill 列表项组件
|
||||
*/
|
||||
interface InstalledSkillListItemProps {
|
||||
skill: InstalledSkill;
|
||||
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
|
||||
onToggleApp: (id: string, app: AppId, enabled: boolean) => void;
|
||||
onUninstall: () => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
skill,
|
||||
onToggleApp,
|
||||
onUninstall,
|
||||
isLast,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -273,7 +246,6 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 生成来源标签
|
||||
const sourceLabel = useMemo(() => {
|
||||
if (skill.repoOwner && skill.repoName) {
|
||||
return `${skill.repoOwner}/${skill.repoName}`;
|
||||
@@ -282,118 +254,49 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
}, [skill.repoOwner, skill.repoName, t]);
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
|
||||
{/* 左侧:Skill 信息 */}
|
||||
<ListItemRow isLast={isLast}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{skill.name}</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm text-foreground truncate">{skill.name}</span>
|
||||
{skill.readmeUrl && (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openDocs}
|
||||
className="h-6 px-2"
|
||||
className="text-muted-foreground/60 hover:text-foreground flex-shrink-0"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground/50 flex-shrink-0">{sourceLabel}</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
<p className="text-xs text-muted-foreground truncate" title={skill.description}>
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
|
||||
</div>
|
||||
|
||||
{/* 中间:应用开关 */}
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-claude`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.claude")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-claude`}
|
||||
checked={skill.apps.claude}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "claude", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AppToggleGroup
|
||||
apps={skill.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-codex`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.codex")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-codex`}
|
||||
checked={skill.apps.codex}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "codex", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-gemini`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.gemini")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-gemini`}
|
||||
checked={skill.apps.gemini}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "gemini", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${skill.id}-opencode`}
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("skills.apps.opencode")}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${skill.id}-opencode`}
|
||||
checked={skill.apps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
onToggleApp(skill.id, "opencode", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:删除按钮 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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={onUninstall}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
title={t("skills.uninstall")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItemRow>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入 Skills 对话框
|
||||
*/
|
||||
interface ImportSkillsDialogProps {
|
||||
skills: Array<{
|
||||
directory: string;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
export interface AppConfig {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
activeClass: string;
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APP_IDS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
claude: {
|
||||
label: "Claude",
|
||||
icon: <ClaudeIcon size={14} />,
|
||||
activeClass: "bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
||||
badgeClass: "bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5",
|
||||
},
|
||||
codex: {
|
||||
label: "Codex",
|
||||
icon: <CodexIcon size={14} />,
|
||||
activeClass: "bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
badgeClass: "bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5",
|
||||
},
|
||||
gemini: {
|
||||
label: "Gemini",
|
||||
icon: <GeminiIcon size={14} />,
|
||||
activeClass: "bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400",
|
||||
badgeClass: "bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
|
||||
},
|
||||
opencode: {
|
||||
label: "OpenCode",
|
||||
icon: <ProviderIcon icon="opencode" name="OpenCode" size={14} showFallback={false} />,
|
||||
activeClass: "bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
|
||||
badgeClass: "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5",
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type AppType,
|
||||
type DiscoverableSkill,
|
||||
type InstalledSkill,
|
||||
} from "@/lib/api/skills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
/**
|
||||
* 查询所有已安装的 Skills
|
||||
@@ -38,7 +38,7 @@ export function useInstallSkill() {
|
||||
currentApp,
|
||||
}: {
|
||||
skill: DiscoverableSkill;
|
||||
currentApp: AppType;
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.installUnified(skill, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
@@ -73,7 +73,7 @@ export function useToggleSkillApp() {
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
app: AppType;
|
||||
app: AppId;
|
||||
enabled: boolean;
|
||||
}) => skillsApi.toggleApp(id, app, enabled),
|
||||
onSuccess: () => {
|
||||
@@ -158,7 +158,7 @@ export function useInstallSkillsFromZip() {
|
||||
currentApp,
|
||||
}: {
|
||||
filePath: string;
|
||||
currentApp: AppType;
|
||||
currentApp: AppId;
|
||||
}) => skillsApi.installFromZip(filePath, currentApp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||
@@ -169,4 +169,4 @@ export function useInstallSkillsFromZip() {
|
||||
|
||||
// ========== 辅助类型 ==========
|
||||
|
||||
export type { InstalledSkill, DiscoverableSkill, AppType };
|
||||
export type { InstalledSkill, DiscoverableSkill, AppId };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
/** Skill 应用启用状态 */
|
||||
export interface SkillApps {
|
||||
@@ -80,7 +80,7 @@ export const skillsApi = {
|
||||
/** 安装 Skill(统一安装) */
|
||||
async installUnified(
|
||||
skill: DiscoverableSkill,
|
||||
currentApp: AppType,
|
||||
currentApp: AppId,
|
||||
): Promise<InstalledSkill> {
|
||||
return await invoke("install_skill_unified", { skill, currentApp });
|
||||
},
|
||||
@@ -93,7 +93,7 @@ export const skillsApi = {
|
||||
/** 切换 Skill 的应用启用状态 */
|
||||
async toggleApp(
|
||||
id: string,
|
||||
app: AppType,
|
||||
app: AppId,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
return await invoke("toggle_skill_app", { id, app, enabled });
|
||||
@@ -117,7 +117,7 @@ export const skillsApi = {
|
||||
// ========== 兼容旧 API ==========
|
||||
|
||||
/** 获取技能列表(兼容旧 API) */
|
||||
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
||||
async getAll(app: AppId = "claude"): Promise<Skill[]> {
|
||||
if (app === "claude") {
|
||||
return await invoke("get_skills");
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export const skillsApi = {
|
||||
},
|
||||
|
||||
/** 安装技能(兼容旧 API) */
|
||||
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
||||
async install(directory: string, app: AppId = "claude"): Promise<boolean> {
|
||||
if (app === "claude") {
|
||||
return await invoke("install_skill", { directory });
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export const skillsApi = {
|
||||
/** 卸载技能(兼容旧 API) */
|
||||
async uninstall(
|
||||
directory: string,
|
||||
app: AppType = "claude",
|
||||
app: AppId = "claude",
|
||||
): Promise<boolean> {
|
||||
if (app === "claude") {
|
||||
return await invoke("uninstall_skill", { directory });
|
||||
@@ -170,7 +170,7 @@ export const skillsApi = {
|
||||
/** 从 ZIP 文件安装 Skills */
|
||||
async installFromZip(
|
||||
filePath: string,
|
||||
currentApp: AppType,
|
||||
currentApp: AppId,
|
||||
): Promise<InstalledSkill[]> {
|
||||
return await invoke("install_skills_from_zip", { filePath, currentApp });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user