From f73254db0376bd4612917b020d7769c71ed31210 Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 30 Dec 2025 02:02:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装 @radix-ui/react-context-menu 依赖 - 创建基础 ContextMenu UI 组件 - 为工具箱卡片添加右键菜单(启用/禁用、复制信息、删除) - 为插件中心已安装列表添加右键菜单(启用/禁用、卸载) - 为 Flow Monitor 请求记录添加右键菜单(复制、查看详情、清除) - 为凭证池凭证卡片添加右键菜单(刷新、复制、删除) - 为配置管理配置项添加右键菜单(编辑、复制、删除) - 修复菜单背景透明问题 版本更新至 v0.23.0 --- package-lock.json | 33 ++- package.json | 3 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- .../clients/ConfigItemContextMenu.tsx | 166 ++++++++++++++ src/components/clients/ProviderList.tsx | 40 +++- src/components/flow-monitor/FlowList.tsx | 38 ++-- .../flow-monitor/FlowRecordContextMenu.tsx | 160 +++++++++++++ src/components/flow-monitor/index.ts | 1 + .../plugins/PluginItemContextMenu.tsx | 149 +++++++++++++ src/components/plugins/PluginManager.tsx | 27 ++- src/components/plugins/README.md | 7 + .../CredentialCardContextMenu.tsx | 149 +++++++++++++ .../provider-pool/ProviderPoolPage.tsx | 47 ++-- src/components/tools/ToolCardContextMenu.tsx | 177 +++++++++++++++ src/components/tools/ToolsPage.tsx | 67 +++++- src/components/ui/context-menu.tsx | 211 ++++++++++++++++++ 18 files changed, 1227 insertions(+), 54 deletions(-) create mode 100644 src/components/clients/ConfigItemContextMenu.tsx create mode 100644 src/components/flow-monitor/FlowRecordContextMenu.tsx create mode 100644 src/components/plugins/PluginItemContextMenu.tsx create mode 100644 src/components/provider-pool/CredentialCardContextMenu.tsx create mode 100644 src/components/tools/ToolCardContextMenu.tsx create mode 100644 src/components/ui/context-menu.tsx diff --git a/package-lock.json b/package-lock.json index 829defe54..f751b3197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "proxycast", - "version": "0.20.5", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.20.5", + "version": "0.22.0", "dependencies": { "@fabianlars/tauri-plugin-oauth": "^2", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", @@ -1544,6 +1545,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", diff --git a/package.json b/package.json index 2d9850a7f..90325e9b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.22.0", + "version": "0.23.0", "type": "module", "repository": { "type": "git", @@ -22,6 +22,7 @@ "dependencies": { "@fabianlars/tauri-plugin-oauth": "^2", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d28cc2f7..7d2e01c41 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3668,7 +3668,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.22.0" +version = "0.23.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7adb3a44a..85282e244 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.22.0" +version = "0.23.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0a15f1899..44cab4fab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.22.0", + "version": "0.23.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/clients/ConfigItemContextMenu.tsx b/src/components/clients/ConfigItemContextMenu.tsx new file mode 100644 index 000000000..2c80f53c9 --- /dev/null +++ b/src/components/clients/ConfigItemContextMenu.tsx @@ -0,0 +1,166 @@ +/** + * 配置项右键菜单组件 + * + * 为配置管理的配置项提供右键菜单功能 + * 支持应用配置、编辑、复制、导出、删除等操作 + * + * @module components/clients/ConfigItemContextMenu + */ + +import React, { useState } from "react"; +import { Play, Edit, Copy, Download, Trash2 } from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { toast } from "sonner"; + +/** 配置项数据结构 */ +interface ConfigItem { + id: string; + name: string; + provider?: string; + [key: string]: unknown; +} + +interface ConfigItemContextMenuProps { + /** 配置项数据 */ + config: ConfigItem; + /** 是否为当前激活的配置 */ + isActive: boolean; + /** 子元素 */ + children: React.ReactNode; + /** 应用配置回调 */ + onApply: () => void; + /** 编辑回调 */ + onEdit: () => void; + /** 复制回调 */ + onDuplicate?: () => void; + /** 导出回调 */ + onExport?: () => void; + /** 删除回调 */ + onDelete: () => void; +} + +export function ConfigItemContextMenu({ + config, + isActive, + children, + onApply, + onEdit, + onDuplicate, + onExport, + onDelete, +}: ConfigItemContextMenuProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // 复制配置 + const handleDuplicate = () => { + if (onDuplicate) { + onDuplicate(); + } else { + toast.info("复制功能即将推出"); + } + }; + + // 导出配置 + const handleExport = () => { + if (onExport) { + onExport(); + } else { + // 默认导出行为 + const jsonStr = JSON.stringify(config, null, 2); + const blob = new Blob([jsonStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `config-${config.name || config.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success("已导出配置文件"); + } + }; + + // 确认删除 + const handleConfirmDelete = () => { + onDelete(); + setShowDeleteDialog(false); + }; + + return ( + <> + + {children} + + {/* 应用配置 */} + + + 应用配置 + {isActive && ( + + 当前 + + )} + {!isActive && ↵} + + + {/* 编辑 */} + + + 编辑 + E + + + {/* 复制 */} + + + 复制 + D + + + {/* 导出 */} + + + 导出 + X + + + + + {/* 删除 */} + setShowDeleteDialog(true)} + className="text-red-600 focus:text-red-600" + disabled={isActive} + > + + 删除 + {isActive && ( + + 当前 + + )} + {!isActive && ⌫} + + + + + {/* 删除确认对话框 */} + setShowDeleteDialog(false)} + /> + + ); +} diff --git a/src/components/clients/ProviderList.tsx b/src/components/clients/ProviderList.tsx index 650f10202..e15f96f06 100644 --- a/src/components/clients/ProviderList.tsx +++ b/src/components/clients/ProviderList.tsx @@ -18,6 +18,7 @@ import { ProviderCard } from "./ProviderCard"; import { ProviderForm } from "./ProviderForm"; import { LiveConfigModal } from "./LiveConfigModal"; import { ConfigSyncDialog } from "./ConfigSyncDialog"; +import { ConfigItemContextMenu } from "./ConfigItemContextMenu"; import { ConfirmDialog } from "@/components/ConfirmDialog"; // 敏感字段关键词 @@ -529,16 +530,18 @@ export function ProviderList({ appType }: ProviderListProps) { ) : (
{providers.map((provider) => ( - { + config={{ + id: provider.id, + name: provider.name, + provider: provider.category, + }} + isActive={provider.id === currentProvider?.id} + onApply={async () => { try { setSwitchingId(provider.id); await switchToProvider(provider.id); - // 切换后重新读取实际配置 const config = await switchApi.readLiveSettings(appType); setLiveConfig(config); } catch (e) { @@ -549,7 +552,30 @@ export function ProviderList({ appType }: ProviderListProps) { }} onEdit={() => handleEdit(provider)} onDelete={() => handleDeleteClick(provider.id)} - /> + > +
+ { + try { + setSwitchingId(provider.id); + await switchToProvider(provider.id); + // 切换后重新读取实际配置 + const config = await switchApi.readLiveSettings(appType); + setLiveConfig(config); + } catch (e) { + console.error("切换失败:", e); + } finally { + setSwitchingId(null); + } + }} + onEdit={() => handleEdit(provider)} + onDelete={() => handleDeleteClick(provider.id)} + /> +
+ ))}
)} diff --git a/src/components/flow-monitor/FlowList.tsx b/src/components/flow-monitor/FlowList.tsx index c3ef16a31..2828b42cd 100644 --- a/src/components/flow-monitor/FlowList.tsx +++ b/src/components/flow-monitor/FlowList.tsx @@ -40,6 +40,7 @@ import { import { useFlowEvents } from "@/hooks/useFlowEvents"; import { useFlowNotifications } from "@/hooks/useFlowNotifications"; import { NotificationSettings } from "./NotificationSettings"; +import { FlowRecordContextMenu } from "./FlowRecordContextMenu"; import { cn } from "@/lib/utils"; interface FlowListProps { @@ -605,23 +606,30 @@ export function FlowList({ ) : (
{flows.map((flow) => ( - - setExpandedId(expandedId === flow.id ? null : flow.id) - } - onSelect={() => onFlowSelect?.(flow)} - onToggleStar={(e) => handleToggleStar(e, flow.id)} - onCopyId={(e) => handleCopyId(e, flow.id)} - getStateIcon={getStateIcon} - getProviderColor={getProviderColor} - getDisplayProvider={getDisplayProvider} - formatTime={formatTime} - /> + onViewDetail={() => onFlowSelect?.(flow)} + > +
+ + setExpandedId(expandedId === flow.id ? null : flow.id) + } + onSelect={() => onFlowSelect?.(flow)} + onToggleStar={(e) => handleToggleStar(e, flow.id)} + onCopyId={(e) => handleCopyId(e, flow.id)} + getStateIcon={getStateIcon} + getProviderColor={getProviderColor} + getDisplayProvider={getDisplayProvider} + formatTime={formatTime} + /> +
+ ))}
)} diff --git a/src/components/flow-monitor/FlowRecordContextMenu.tsx b/src/components/flow-monitor/FlowRecordContextMenu.tsx new file mode 100644 index 000000000..fa68ec1a8 --- /dev/null +++ b/src/components/flow-monitor/FlowRecordContextMenu.tsx @@ -0,0 +1,160 @@ +/** + * Flow 记录右键菜单组件 + * + * 为 Flow Monitor 的请求记录提供右键菜单功能 + * 支持查看详情、复制 ID、复制为 cURL、导出 JSON 等操作 + * + * @module components/flow-monitor/FlowRecordContextMenu + */ + +import React from "react"; +import { ExternalLink, Copy, Terminal, FileJson } from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { toast } from "sonner"; +import type { LLMFlow } from "@/lib/api/flowMonitor"; + +interface FlowRecordContextMenuProps { + /** Flow 记录数据 */ + flow: LLMFlow; + /** 子元素 */ + children: React.ReactNode; + /** 查看详情回调 */ + onViewDetail: () => void; + /** 导出 JSON 回调 */ + onExportJson?: (flowId: string) => void; +} + +/** + * 生成 cURL 命令 + */ +function generateCurlCommand(flow: LLMFlow): string { + const { request, metadata } = flow; + + // 基础 URL(根据 provider 推断) + const baseUrls: Record = { + Kiro: "https://codewhisperer.us-east-1.amazonaws.com", + OpenAI: "https://api.openai.com/v1/chat/completions", + Claude: "https://api.anthropic.com/v1/messages", + Gemini: "https://generativelanguage.googleapis.com/v1beta/models", + Qwen: "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", + }; + + const url = + baseUrls[metadata.provider] || + "https://api.example.com/v1/chat/completions"; + + // 构建请求体(避免 stream 重复) + const { stream: _stream, ...otherParams } = request.parameters; + const body = { + model: request.model, + messages: request.messages, + stream: request.parameters.stream, + ...otherParams, + }; + + // 构建 cURL 命令 + const parts = [ + "curl", + `-X ${request.method || "POST"}`, + `'${url}'`, + "-H 'Content-Type: application/json'", + "-H 'Authorization: Bearer YOUR_API_KEY'", + `-d '${JSON.stringify(body, null, 2)}'`, + ]; + + return parts.join(" \\\n "); +} + +export function FlowRecordContextMenu({ + flow, + children, + onViewDetail, + onExportJson, +}: FlowRecordContextMenuProps) { + // 复制请求 ID + const handleCopyId = async () => { + try { + await navigator.clipboard.writeText(flow.id); + toast.success("已复制请求 ID"); + } catch (error) { + console.error("复制失败:", error); + toast.error("复制失败"); + } + }; + + // 复制为 cURL + const handleCopyAsCurl = async () => { + try { + const curlCommand = generateCurlCommand(flow); + await navigator.clipboard.writeText(curlCommand); + toast.success("已复制 cURL 命令"); + } catch (error) { + console.error("复制失败:", error); + toast.error("复制失败"); + } + }; + + // 导出为 JSON + const handleExportJson = () => { + if (onExportJson) { + onExportJson(flow.id); + } else { + // 默认导出行为:下载 JSON 文件 + const jsonStr = JSON.stringify(flow, null, 2); + const blob = new Blob([jsonStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `flow-${flow.id.slice(0, 8)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success("已导出 JSON 文件"); + } + }; + + return ( + + {children} + + {/* 查看详情 */} + + + 查看详情 + ↵ + + + {/* 复制请求 ID */} + + + 复制请求 ID + C + + + {/* 复制为 cURL */} + + + 复制为 cURL + ⇧C + + + + + {/* 导出为 JSON */} + + + 导出为 JSON + E + + + + ); +} diff --git a/src/components/flow-monitor/index.ts b/src/components/flow-monitor/index.ts index f800b28a7..34669a3f6 100644 --- a/src/components/flow-monitor/index.ts +++ b/src/components/flow-monitor/index.ts @@ -17,6 +17,7 @@ export { QuickFilterPanel } from "./QuickFilterPanel"; export { RelatedFlows } from "./RelatedFlows"; export { BookmarkPanel } from "./BookmarkPanel"; export { StatsExport, StatsExportDropdown } from "./StatsExport"; +export { FlowRecordContextMenu } from "./FlowRecordContextMenu"; export { useFlowEvents } from "@/hooks/useFlowEvents"; export { useFlowActions } from "@/hooks/useFlowActions"; diff --git a/src/components/plugins/PluginItemContextMenu.tsx b/src/components/plugins/PluginItemContextMenu.tsx new file mode 100644 index 000000000..dd1862ec1 --- /dev/null +++ b/src/components/plugins/PluginItemContextMenu.tsx @@ -0,0 +1,149 @@ +/** + * 插件项右键菜单组件 + * + * 为插件中心已安装插件列表提供右键菜单功能 + * 支持启用/禁用、打开目录、检查更新、卸载等操作 + * + * @module components/plugins/PluginItemContextMenu + */ + +import React, { useState } from "react"; +import { open } from "@tauri-apps/plugin-shell"; +import { Power, PowerOff, FolderOpen, RefreshCw, Trash2 } from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { toast } from "sonner"; + +/** 安装来源 */ +interface InstallSource { + type: "local" | "url" | "github"; + path?: string; + url?: string; + owner?: string; + repo?: string; + tag?: string; +} + +/** 已安装插件信息 */ +interface InstalledPlugin { + id: string; + name: string; + version: string; + description: string; + author: string | null; + install_path: string; + installed_at: string; + source: InstallSource; + enabled: boolean; +} + +interface PluginItemContextMenuProps { + /** 插件信息 */ + plugin: InstalledPlugin; + /** 子元素 */ + children: React.ReactNode; + /** 切换启用状态回调 */ + onToggleEnabled: () => void; + /** 卸载回调 */ + onUninstall: () => void; +} + +export function PluginItemContextMenu({ + plugin, + children, + onToggleEnabled, + onUninstall, +}: PluginItemContextMenuProps) { + const [showUninstallDialog, setShowUninstallDialog] = useState(false); + + // 打开插件目录 + const handleOpenFolder = async () => { + try { + await open(plugin.install_path); + } catch (error) { + console.error("打开插件目录失败:", error); + toast.error("打开插件目录失败"); + } + }; + + // 检查更新(暂未实现) + const handleCheckUpdate = () => { + toast.info("检查更新功能即将推出"); + }; + + // 确认卸载 + const handleConfirmUninstall = () => { + onUninstall(); + setShowUninstallDialog(false); + }; + + return ( + <> + + {children} + + {/* 启用/禁用 */} + + {plugin.enabled ? ( + <> + + 禁用插件 + + ) : ( + <> + + 启用插件 + + )} + E + + + {/* 打开插件目录 */} + + + 打开插件目录 + O + + + {/* 检查更新 */} + + + 检查更新 + + 即将推出 + + + + + + {/* 卸载 */} + setShowUninstallDialog(true)} + className="text-red-600 focus:text-red-600" + > + + 卸载 + ⌫ + + + + + {/* 卸载确认对话框 */} + setShowUninstallDialog(false)} + /> + + ); +} diff --git a/src/components/plugins/PluginManager.tsx b/src/components/plugins/PluginManager.tsx index fdf5f374c..0b2606ea5 100644 --- a/src/components/plugins/PluginManager.tsx +++ b/src/components/plugins/PluginManager.tsx @@ -20,8 +20,10 @@ import { import { BinaryComponents } from "@/components/extensions/BinaryComponents"; import { PluginInstallDialog } from "./PluginInstallDialog"; import { PluginUninstallDialog } from "./PluginUninstallDialog"; +import { PluginItemContextMenu } from "./PluginItemContextMenu"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; interface PluginState { name: string; @@ -359,11 +361,32 @@ export function PluginManager() {
{installedPlugins.map((plugin) => ( - { + try { + if (plugin.enabled) { + await invoke("disable_plugin", { name: plugin.id }); + toast.success("插件已禁用"); + } else { + await invoke("enable_plugin", { name: plugin.id }); + toast.success("插件已启用"); + } + fetchData(); + } catch (_err) { + toast.error("操作失败"); + } + }} onUninstall={() => setPluginToUninstall(plugin)} - /> + > +
+ setPluginToUninstall(plugin)} + /> +
+ ))}
diff --git a/src/components/plugins/README.md b/src/components/plugins/README.md index a748926a0..24c26e149 100644 --- a/src/components/plugins/README.md +++ b/src/components/plugins/README.md @@ -11,6 +11,7 @@ | `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 | | `PluginUninstallDialog.tsx` | 插件卸载确认对话框 | | `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI | +| `PluginItemContextMenu.tsx` | 插件项右键菜单,支持启用/禁用、打开目录、卸载等操作 | | `index.ts` | 模块导出 | ## 功能说明 @@ -38,6 +39,12 @@ - 显示友好的错误提示(插件未找到、加载失败) - 导出 Page 类型定义,支持动态插件路由 +### PluginItemContextMenu +- 为已安装插件列表提供右键菜单 +- 支持启用/禁用插件 +- 支持打开插件目录 +- 支持卸载插件(带确认对话框) + ## 相关需求 - 需求 1.1: 本地文件安装 diff --git a/src/components/provider-pool/CredentialCardContextMenu.tsx b/src/components/provider-pool/CredentialCardContextMenu.tsx new file mode 100644 index 000000000..fb4af4069 --- /dev/null +++ b/src/components/provider-pool/CredentialCardContextMenu.tsx @@ -0,0 +1,149 @@ +/** + * 凭证卡片右键菜单组件 + * + * 为凭证池的凭证卡片提供右键菜单功能 + * 支持复制 ID、刷新 Token、查看详情、启用/禁用、删除等操作 + * + * @module components/provider-pool/CredentialCardContextMenu + */ + +import React, { useState } from "react"; +import { Copy, RefreshCw, Info, Power, PowerOff, Trash2 } from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { toast } from "sonner"; +import type { CredentialDisplay } from "@/lib/api/providerPool"; + +interface CredentialCardContextMenuProps { + /** 凭证数据 */ + credential: CredentialDisplay; + /** 子元素 */ + children: React.ReactNode; + /** 刷新 Token 回调 */ + onRefreshToken?: () => void; + /** 切换启用状态回调 */ + onToggle: () => void; + /** 删除回调 */ + onDelete: () => void; + /** 是否为 OAuth 凭证 */ + isOAuth?: boolean; +} + +export function CredentialCardContextMenu({ + credential, + children, + onRefreshToken, + onToggle, + onDelete, + isOAuth = false, +}: CredentialCardContextMenuProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // 复制凭证 ID + const handleCopyId = async () => { + try { + await navigator.clipboard.writeText(credential.uuid); + toast.success("已复制凭证 ID"); + } catch (error) { + console.error("复制失败:", error); + toast.error("复制失败"); + } + }; + + // 刷新 Token + const handleRefreshToken = () => { + if (onRefreshToken) { + onRefreshToken(); + toast.info("正在刷新 Token..."); + } + }; + + // 查看详情(展开卡片详情) + const handleViewDetail = () => { + // 触发卡片展开,这里通过复制 ID 并提示用户点击卡片查看 + toast.info("请点击卡片查看详细信息"); + }; + + // 确认删除 + const handleConfirmDelete = () => { + onDelete(); + setShowDeleteDialog(false); + }; + + return ( + <> + + {children} + + {/* 复制凭证 ID */} + + + 复制凭证 ID + C + + + {/* 刷新 Token - 仅 OAuth 凭证显示 */} + {isOAuth && onRefreshToken && ( + + + 刷新 Token + R + + )} + + {/* 查看详情 */} + + + 查看详情 + I + + + + + {/* 启用/禁用 */} + + {credential.is_disabled ? ( + <> + + 启用凭证 + + ) : ( + <> + + 禁用凭证 + + )} + E + + + {/* 删除 */} + setShowDeleteDialog(true)} + className="text-red-600 focus:text-red-600" + > + + 删除凭证 + ⌫ + + + + + {/* 删除确认对话框 */} + setShowDeleteDialog(false)} + /> + + ); +} diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index 6905d8f90..b95f8daf8 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import { useProviderPool } from "@/hooks/useProviderPool"; import { CredentialCard } from "./CredentialCard"; +import { CredentialCardContextMenu } from "./CredentialCardContextMenu"; import { AddCredentialModal } from "./AddCredentialModal"; import { EditCredentialModal } from "./EditCredentialModal"; import { ErrorDisplay, useErrorDisplay } from "./ErrorDisplay"; @@ -656,28 +657,44 @@ export const ProviderPoolPage = forwardRef( } return ( - handleToggle(credential)} - onDelete={() => handleDeleteClick(credential.uuid)} - onReset={() => handleReset(credential.uuid)} - onCheckHealth={() => handleCheckHealth(credential.uuid)} onRefreshToken={ isOAuthType ? () => handleRefreshToken(credential.uuid) : undefined } - onEdit={() => handleEdit(credential)} - deleting={deletingCredentials.has(credential.uuid)} - checkingHealth={checkingHealth === credential.uuid} - refreshingToken={refreshingToken === credential.uuid} - isKiroCredential={isKiroCredential} - isLocalActive={isLocalActive} - onSwitchToLocal={ - isKiroCredential ? fetchLocalActiveUuid : undefined - } - /> + onToggle={() => handleToggle(credential)} + onDelete={() => handleDeleteClick(credential.uuid)} + isOAuth={isOAuthType} + > +
+ handleToggle(credential)} + onDelete={() => handleDeleteClick(credential.uuid)} + onReset={() => handleReset(credential.uuid)} + onCheckHealth={() => + handleCheckHealth(credential.uuid) + } + onRefreshToken={ + isOAuthType + ? () => handleRefreshToken(credential.uuid) + : undefined + } + onEdit={() => handleEdit(credential)} + deleting={deletingCredentials.has(credential.uuid)} + checkingHealth={checkingHealth === credential.uuid} + refreshingToken={refreshingToken === credential.uuid} + isKiroCredential={isKiroCredential} + isLocalActive={isLocalActive} + onSwitchToLocal={ + isKiroCredential ? fetchLocalActiveUuid : undefined + } + /> +
+ ); })} diff --git a/src/components/tools/ToolCardContextMenu.tsx b/src/components/tools/ToolCardContextMenu.tsx new file mode 100644 index 000000000..126dc1c65 --- /dev/null +++ b/src/components/tools/ToolCardContextMenu.tsx @@ -0,0 +1,177 @@ +/** + * 工具卡片右键菜单组件 + * + * 为工具箱页面的工具卡片提供右键菜单功能 + * 支持打开工具、查看详情、启用/禁用、卸载等操作 + * + * @module components/tools/ToolCardContextMenu + */ + +import React, { useState } from "react"; +import { ExternalLink, Info, Power, PowerOff, Trash2 } from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; + +/** + * 工具卡片数据结构 + */ +interface DynamicToolCard { + id: string; + title: string; + description: string; + icon: string; + source: "builtin" | "plugin"; + pluginId?: string; + disabled?: boolean; + status?: string; +} + +/** + * 页面类型 + */ +type Page = + | "provider-pool" + | "config-management" + | "api-server" + | "flow-monitor" + | "agent" + | "tools" + | "browser-interceptor" + | "settings" + | "plugins" + | `plugin:${string}`; + +interface ToolCardContextMenuProps { + /** 工具卡片数据 */ + tool: DynamicToolCard; + /** 子元素 */ + children: React.ReactNode; + /** 页面导航回调 */ + onNavigate: (page: Page) => void; + /** 切换插件启用状态回调 */ + onToggleEnabled?: (pluginId: string, enabled: boolean) => void; + /** 卸载插件回调 */ + onUninstall?: (pluginId: string) => void; + /** 插件是否启用 */ + isEnabled?: boolean; +} + +export function ToolCardContextMenu({ + tool, + children, + onNavigate, + onToggleEnabled, + onUninstall, + isEnabled = true, +}: ToolCardContextMenuProps) { + const [showUninstallDialog, setShowUninstallDialog] = useState(false); + + const isPlugin = tool.source === "plugin"; + const isDisabledTool = tool.disabled; + + // 打开工具 + const handleOpen = () => { + if (isDisabledTool) return; + + if (isPlugin && tool.pluginId) { + onNavigate(`plugin:${tool.pluginId}`); + } else { + onNavigate(tool.id as Page); + } + }; + + // 查看详情(导航到插件中心) + const handleViewDetail = () => { + onNavigate("plugins"); + }; + + // 切换启用状态 + const handleToggleEnabled = () => { + if (tool.pluginId && onToggleEnabled) { + onToggleEnabled(tool.pluginId, !isEnabled); + } + }; + + // 确认卸载 + const handleConfirmUninstall = () => { + if (tool.pluginId && onUninstall) { + onUninstall(tool.pluginId); + } + setShowUninstallDialog(false); + }; + + return ( + <> + + {children} + + {/* 打开工具 */} + + + 打开工具 + ↵ + + + {/* 查看详情 - 仅插件显示 */} + {isPlugin && ( + + + 查看详情 + I + + )} + + {/* 插件操作 */} + {isPlugin && ( + <> + + + {/* 启用/禁用 */} + + {isEnabled ? ( + <> + + 禁用插件 + + ) : ( + <> + + 启用插件 + + )} + E + + + {/* 卸载 */} + setShowUninstallDialog(true)} + className="text-red-600 focus:text-red-600" + > + + 卸载插件 + ⌫ + + + )} + + + + {/* 卸载确认对话框 */} + setShowUninstallDialog(false)} + /> + + ); +} diff --git a/src/components/tools/ToolsPage.tsx b/src/components/tools/ToolsPage.tsx index 035dbd97f..67d41716a 100644 --- a/src/components/tools/ToolsPage.tsx +++ b/src/components/tools/ToolsPage.tsx @@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Package, Loader2, Download, type LucideIcon } from "lucide-react"; import * as LucideIcons from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -22,6 +23,8 @@ import { import { Button } from "@/components/ui/button"; import { getPluginsForSurface, type PluginUIInfo } from "@/lib/api/pluginUI"; import { PluginInstallDialog } from "@/components/plugins/PluginInstallDialog"; +import { ToolCardContextMenu } from "./ToolCardContextMenu"; +import { toast } from "sonner"; /** * 页面类型定义 @@ -41,6 +44,7 @@ type Page = | "tools" | "browser-interceptor" | "settings" + | "plugins" | `plugin:${string}`; interface ToolsPageProps { @@ -288,6 +292,41 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) { setShowInstallDialog(true); }, []); + // 处理插件启用/禁用 + const handleTogglePluginEnabled = useCallback( + async (pluginId: string, enabled: boolean) => { + try { + if (enabled) { + await invoke("enable_plugin", { name: pluginId }); + toast.success("插件已启用"); + } else { + await invoke("disable_plugin", { name: pluginId }); + toast.success("插件已禁用"); + } + loadPluginTools(); + } catch (error) { + console.error("切换插件状态失败:", error); + toast.error("操作失败"); + } + }, + [loadPluginTools], + ); + + // 处理插件卸载 + const handleUninstallPlugin = useCallback( + async (pluginId: string) => { + try { + await invoke("uninstall_plugin", { pluginId }); + toast.success("插件已卸载"); + loadPluginTools(); + } catch (error) { + console.error("卸载插件失败:", error); + toast.error("卸载失败"); + } + }, + [loadPluginTools], + ); + // 合并内置工具和插件工具 const allTools = [...builtinTools, ...pluginTools, ...placeholderTools]; const activeToolsCount = builtinTools.length + pluginTools.length; @@ -341,16 +380,26 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) {
{allTools.map((tool) => ( - handleToolClick(tool)} - /> + tool={tool} + onNavigate={onNavigate} + onToggleEnabled={handleTogglePluginEnabled} + onUninstall={handleUninstallPlugin} + isEnabled={true} + > +
+ handleToolClick(tool)} + /> +
+ ))}
diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..f4e55f402 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,211 @@ +/** + * 右键菜单组件 + * + * 基于 Radix UI 封装的右键菜单组件,提供统一的右键菜单功能 + * 支持菜单项、分隔线、子菜单等功能 + * + * @module components/ui/context-menu + */ + +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = "ContextMenuShortcut"; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +};