From e65360e68a346b5542cff6033593f606d12c451f Mon Sep 17 00:00:00 2001 From: PeanutSplash <98582625+PeanutSplash@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:10:45 +0800 Subject: [PATCH] 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 --- src/components/common/AppCountBar.tsx | 31 +++ src/components/common/AppToggleGroup.tsx | 44 ++++ src/components/common/ListItemRow.tsx | 18 ++ src/components/mcp/UnifiedMcpPanel.tsx | 188 +++++------------ src/components/skills/SkillsPage.tsx | 4 +- src/components/skills/UnifiedSkillsPanel.tsx | 205 +++++-------------- src/config/appConfig.tsx | 40 ++++ src/hooks/useSkills.ts | 10 +- src/lib/api/skills.ts | 16 +- 9 files changed, 254 insertions(+), 302 deletions(-) create mode 100644 src/components/common/AppCountBar.tsx create mode 100644 src/components/common/AppToggleGroup.tsx create mode 100644 src/components/common/ListItemRow.tsx create mode 100644 src/config/appConfig.tsx diff --git a/src/components/common/AppCountBar.tsx b/src/components/common/AppCountBar.tsx new file mode 100644 index 00000000..3045e68e --- /dev/null +++ b/src/components/common/AppCountBar.tsx @@ -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; +} + +export const AppCountBar: React.FC = ({ totalLabel, counts }) => { + return ( +
+ + {totalLabel} + +
+ {APP_IDS.map((app) => ( + + {APP_ICON_MAP[app].label}: + {counts[app]} + + ))} +
+
+ ); +}; diff --git a/src/components/common/AppToggleGroup.tsx b/src/components/common/AppToggleGroup.tsx new file mode 100644 index 00000000..27d77bd7 --- /dev/null +++ b/src/components/common/AppToggleGroup.tsx @@ -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; + onToggle: (app: AppId, enabled: boolean) => void; +} + +export const AppToggleGroup: React.FC = ({ apps, onToggle }) => { + return ( +
+ {APP_IDS.map((app) => { + const { label, icon, activeClass } = APP_ICON_MAP[app]; + const enabled = apps[app]; + return ( + + + + + +

{label}{enabled ? " ✓" : ""}

+
+
+ ); + })} +
+ ); +}; diff --git a/src/components/common/ListItemRow.tsx b/src/components/common/ListItemRow.tsx new file mode 100644 index 00000000..8d0c54fd --- /dev/null +++ b/src/components/common/ListItemRow.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +interface ListItemRowProps { + isLast?: boolean; + children: React.ReactNode; +} + +export const ListItemRow: React.FC = ({ isLast, children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 19bf6e29..110e53b6 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -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 (
- {/* Info Section */} -
-
- {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} -
-
+ - {/* Content - Scrollable */}
{isLoading ? (
@@ -173,22 +156,24 @@ const UnifiedMcpPanel = React.forwardRef<

) : ( -
- {serverEntries.map(([id, server]) => ( - - ))} -
+ +
+ {serverEntries.map(([id, server], index) => ( + + ))} +
+
)}
- {/* Form Modal */} {isFormOpen && ( )} - {/* Confirm Dialog */} {confirmDialog && ( void; onEdit: (id: string) => void; onDelete: (id: string) => void; + isLast?: boolean; } const UnifiedMcpListItem: React.FC = ({ @@ -239,12 +220,12 @@ const UnifiedMcpListItem: React.FC = ({ 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 = ({ }; return ( -
- {/* 左侧:服务器信息 */} +
-
-

{name}

+
+ {name} {docsUrl && ( - + + )}
{description && ( -

+

{description}

)} {!description && tags && tags.length > 0 && ( -

+

{tags.join(", ")}

)}
- {/* 中间:应用开关 */} -
-
- - - onToggleApp(id, "claude", checked) - } - /> -
+ onToggleApp(id, app, enabled)} + /> -
- - - onToggleApp(id, "codex", checked) - } - /> -
- -
- - - onToggleApp(id, "gemini", checked) - } - /> -
- -
- - - onToggleApp(id, "opencode", checked) - } - /> -
-
- - {/* 右侧:操作按钮 */} -
+
-
-
+ ); }; diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index adb100e4..1968d779 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -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 { diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 26aec77f..c945299e 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -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 (
- {/* Info Section */} -
-
- {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} -
-
+ - {/* Content - Scrollable */}
{isLoading ? (
@@ -210,20 +183,22 @@ const UnifiedSkillsPanel = React.forwardRef<

) : ( -
- {skills.map((skill) => ( - handleUninstall(skill)} - /> - ))} -
+ +
+ {skills.map((skill, index) => ( + handleUninstall(skill)} + isLast={index === skills.length - 1} + /> + ))} +
+
)}
- {/* Confirm Dialog */} {confirmDialog && ( )} - {/* Import Dialog */} {importDialogOpen && unmanagedSkills && ( void; + onToggleApp: (id: string, app: AppId, enabled: boolean) => void; onUninstall: () => void; + isLast?: boolean; } const InstalledSkillListItem: React.FC = ({ skill, onToggleApp, onUninstall, + isLast, }) => { const { t } = useTranslation(); @@ -273,7 +246,6 @@ const InstalledSkillListItem: React.FC = ({ } }; - // 生成来源标签 const sourceLabel = useMemo(() => { if (skill.repoOwner && skill.repoName) { return `${skill.repoOwner}/${skill.repoName}`; @@ -282,118 +254,49 @@ const InstalledSkillListItem: React.FC = ({ }, [skill.repoOwner, skill.repoName, t]); return ( -
- {/* 左侧:Skill 信息 */} +
-
-

{skill.name}

+
+ {skill.name} {skill.readmeUrl && ( - + + )} + {sourceLabel}
{skill.description && ( -

+

{skill.description}

)} -

{sourceLabel}

- {/* 中间:应用开关 */} -
-
- - - onToggleApp(skill.id, "claude", checked) - } - /> -
+ onToggleApp(skill.id, app, enabled)} + /> -
- - - onToggleApp(skill.id, "codex", checked) - } - /> -
- -
- - - onToggleApp(skill.id, "gemini", checked) - } - /> -
- -
- - - onToggleApp(skill.id, "opencode", checked) - } - /> -
-
- - {/* 右侧:删除按钮 */} -
+
-
+ ); }; -/** - * 导入 Skills 对话框 - */ interface ImportSkillsDialogProps { skills: Array<{ directory: string; diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx new file mode 100644 index 00000000..9ad1d763 --- /dev/null +++ b/src/config/appConfig.tsx @@ -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 = { + claude: { + label: "Claude", + icon: , + 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: , + 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: , + 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: , + 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", + }, +}; diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index 6651e3ef..f3a5830e 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -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 }; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 4f43bd95..f56b8be8 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -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 { 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 { return await invoke("toggle_skill_app", { id, app, enabled }); @@ -117,7 +117,7 @@ export const skillsApi = { // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */ - async getAll(app: AppType = "claude"): Promise { + async getAll(app: AppId = "claude"): Promise { if (app === "claude") { return await invoke("get_skills"); } @@ -125,7 +125,7 @@ export const skillsApi = { }, /** 安装技能(兼容旧 API) */ - async install(directory: string, app: AppType = "claude"): Promise { + async install(directory: string, app: AppId = "claude"): Promise { 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 { 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 { return await invoke("install_skills_from_zip", { filePath, currentApp }); },