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:
PeanutSplash
2026-02-04 10:10:45 +08:00
committed by GitHub
parent f0e8ba1d8f
commit e65360e68a
9 changed files with 254 additions and 302 deletions
+31
View File
@@ -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>
);
};
+44
View File
@@ -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>
);
};
+18
View File
@@ -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>
);
};
+52 -136
View File
@@ -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>
);
};
+2 -2
View File
@@ -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 {
+54 -151
View File
@@ -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;
+40
View File
@@ -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",
},
};
+5 -5
View File
@@ -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 };
+8 -8
View File
@@ -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 });
},