feat: 添加右键菜单系统

- 安装 @radix-ui/react-context-menu 依赖
- 创建基础 ContextMenu UI 组件
- 为工具箱卡片添加右键菜单(启用/禁用、复制信息、删除)
- 为插件中心已安装列表添加右键菜单(启用/禁用、卸载)
- 为 Flow Monitor 请求记录添加右键菜单(复制、查看详情、清除)
- 为凭证池凭证卡片添加右键菜单(刷新、复制、删除)
- 为配置管理配置项添加右键菜单(编辑、复制、删除)
- 修复菜单背景透明问题

版本更新至 v0.23.0
This commit is contained in:
coso
2025-12-30 02:02:10 +08:00
parent bfc70505d5
commit f73254db03
18 changed files with 1227 additions and 54 deletions
+31 -2
View File
@@ -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",
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -3668,7 +3668,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.22.0"
version = "0.23.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
@@ -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 (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 应用配置 */}
<ContextMenuItem onClick={onApply} disabled={isActive}>
<Play className="mr-2 h-4 w-4" />
应用配置
{isActive && (
<ContextMenuShortcut className="text-muted-foreground/50">
当前
</ContextMenuShortcut>
)}
{!isActive && <ContextMenuShortcut>↵</ContextMenuShortcut>}
</ContextMenuItem>
{/* 编辑 */}
<ContextMenuItem onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
编辑
<ContextMenuShortcut>E</ContextMenuShortcut>
</ContextMenuItem>
{/* 复制 */}
<ContextMenuItem onClick={handleDuplicate}>
<Copy className="mr-2 h-4 w-4" />
复制
<ContextMenuShortcut>D</ContextMenuShortcut>
</ContextMenuItem>
{/* 导出 */}
<ContextMenuItem onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
导出
<ContextMenuShortcut>X</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{/* 删除 */}
<ContextMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-red-600 focus:text-red-600"
disabled={isActive}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
{isActive && (
<ContextMenuShortcut className="text-muted-foreground/50">
当前
</ContextMenuShortcut>
)}
{!isActive && <ContextMenuShortcut>⌫</ContextMenuShortcut>}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={showDeleteDialog}
title="确认删除配置"
message={`确定要删除配置 "${config.name}" 吗?此操作无法撤销。`}
confirmText="删除"
onConfirm={handleConfirmDelete}
onCancel={() => setShowDeleteDialog(false)}
/>
</>
);
}
+33 -7
View File
@@ -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) {
) : (
<div className="grid gap-2 sm:grid-cols-2">
{providers.map((provider) => (
<ProviderCard
<ConfigItemContextMenu
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProvider?.id}
switching={switchingId === provider.id}
onSwitch={async () => {
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)}
/>
>
<div>
<ProviderCard
provider={provider}
isCurrent={provider.id === currentProvider?.id}
switching={switchingId === provider.id}
onSwitch={async () => {
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)}
/>
</div>
</ConfigItemContextMenu>
))}
</div>
)}
+23 -15
View File
@@ -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({
) : (
<div className="divide-y max-h-[600px] overflow-y-auto">
{flows.map((flow) => (
<FlowListItem
<FlowRecordContextMenu
key={flow.id}
flow={flow}
expanded={expandedId === flow.id}
selected={selectedFlowId === flow.id}
thresholdWarning={thresholdWarnings.get(flow.id)}
onToggleExpand={() =>
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)}
>
<div>
<FlowListItem
flow={flow}
expanded={expandedId === flow.id}
selected={selectedFlowId === flow.id}
thresholdWarning={thresholdWarnings.get(flow.id)}
onToggleExpand={() =>
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}
/>
</div>
</FlowRecordContextMenu>
))}
</div>
)}
@@ -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<string, string> = {
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 (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 查看详情 */}
<ContextMenuItem onClick={onViewDetail}>
<ExternalLink className="mr-2 h-4 w-4" />
查看详情
<ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
{/* 复制请求 ID */}
<ContextMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
复制请求 ID
<ContextMenuShortcut>C</ContextMenuShortcut>
</ContextMenuItem>
{/* 复制为 cURL */}
<ContextMenuItem onClick={handleCopyAsCurl}>
<Terminal className="mr-2 h-4 w-4" />
复制为 cURL
<ContextMenuShortcut>⇧C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{/* 导出为 JSON */}
<ContextMenuItem onClick={handleExportJson}>
<FileJson className="mr-2 h-4 w-4" />
导出为 JSON
<ContextMenuShortcut>E</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
+1
View File
@@ -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";
@@ -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 (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 启用/禁用 */}
<ContextMenuItem onClick={onToggleEnabled}>
{plugin.enabled ? (
<>
<PowerOff className="mr-2 h-4 w-4" />
禁用插件
</>
) : (
<>
<Power className="mr-2 h-4 w-4" />
启用插件
</>
)}
<ContextMenuShortcut>E</ContextMenuShortcut>
</ContextMenuItem>
{/* 打开插件目录 */}
<ContextMenuItem onClick={handleOpenFolder}>
<FolderOpen className="mr-2 h-4 w-4" />
打开插件目录
<ContextMenuShortcut>O</ContextMenuShortcut>
</ContextMenuItem>
{/* 检查更新 */}
<ContextMenuItem onClick={handleCheckUpdate} disabled>
<RefreshCw className="mr-2 h-4 w-4" />
检查更新
<ContextMenuShortcut className="text-muted-foreground/50">
即将推出
</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{/* 卸载 */}
<ContextMenuItem
onClick={() => setShowUninstallDialog(true)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
卸载
<ContextMenuShortcut>⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* 卸载确认对话框 */}
<ConfirmDialog
isOpen={showUninstallDialog}
title="确认卸载插件"
message={`确定要卸载插件 "${plugin.name}" 吗?此操作无法撤销。`}
confirmText="卸载"
onConfirm={handleConfirmUninstall}
onCancel={() => setShowUninstallDialog(false)}
/>
</>
);
}
+25 -2
View File
@@ -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() {
</div>
<div className="divide-y">
{installedPlugins.map((plugin) => (
<InstalledPluginItem
<PluginItemContextMenu
key={plugin.id}
plugin={plugin}
onToggleEnabled={async () => {
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)}
/>
>
<div>
<InstalledPluginItem
plugin={plugin}
onUninstall={() => setPluginToUninstall(plugin)}
/>
</div>
</PluginItemContextMenu>
))}
</div>
</div>
+7
View File
@@ -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: 本地文件安装
@@ -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 (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 复制凭证 ID */}
<ContextMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
复制凭证 ID
<ContextMenuShortcut>C</ContextMenuShortcut>
</ContextMenuItem>
{/* 刷新 Token - 仅 OAuth 凭证显示 */}
{isOAuth && onRefreshToken && (
<ContextMenuItem onClick={handleRefreshToken}>
<RefreshCw className="mr-2 h-4 w-4" />
刷新 Token
<ContextMenuShortcut>R</ContextMenuShortcut>
</ContextMenuItem>
)}
{/* 查看详情 */}
<ContextMenuItem onClick={handleViewDetail}>
<Info className="mr-2 h-4 w-4" />
查看详情
<ContextMenuShortcut>I</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{/* 启用/禁用 */}
<ContextMenuItem onClick={onToggle}>
{credential.is_disabled ? (
<>
<Power className="mr-2 h-4 w-4" />
启用凭证
</>
) : (
<>
<PowerOff className="mr-2 h-4 w-4" />
禁用凭证
</>
)}
<ContextMenuShortcut>E</ContextMenuShortcut>
</ContextMenuItem>
{/* 删除 */}
<ContextMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
删除凭证
<ContextMenuShortcut>⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={showDeleteDialog}
title="确认删除凭证"
message={`确定要删除凭证 "${credential.name || credential.uuid.slice(0, 8)}" 吗?此操作无法撤销。`}
confirmText="删除"
onConfirm={handleConfirmDelete}
onCancel={() => setShowDeleteDialog(false)}
/>
</>
);
}
@@ -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<ProviderPoolPageRef>(
}
return (
<CredentialCard
<CredentialCardContextMenu
key={credential.uuid}
credential={credential}
onToggle={() => 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}
>
<div>
<CredentialCard
credential={credential}
onToggle={() => 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
}
/>
</div>
</CredentialCardContextMenu>
);
})}
</div>
@@ -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 (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 打开工具 */}
<ContextMenuItem onClick={handleOpen} disabled={isDisabledTool}>
<ExternalLink className="mr-2 h-4 w-4" />
打开工具
<ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
{/* 查看详情 - 仅插件显示 */}
{isPlugin && (
<ContextMenuItem onClick={handleViewDetail}>
<Info className="mr-2 h-4 w-4" />
查看详情
<ContextMenuShortcut>I</ContextMenuShortcut>
</ContextMenuItem>
)}
{/* 插件操作 */}
{isPlugin && (
<>
<ContextMenuSeparator />
{/* 启用/禁用 */}
<ContextMenuItem onClick={handleToggleEnabled}>
{isEnabled ? (
<>
<PowerOff className="mr-2 h-4 w-4" />
禁用插件
</>
) : (
<>
<Power className="mr-2 h-4 w-4" />
启用插件
</>
)}
<ContextMenuShortcut>E</ContextMenuShortcut>
</ContextMenuItem>
{/* 卸载 */}
<ContextMenuItem
onClick={() => setShowUninstallDialog(true)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
卸载插件
<ContextMenuShortcut>⌫</ContextMenuShortcut>
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
{/* 卸载确认对话框 */}
<ConfirmDialog
isOpen={showUninstallDialog}
title="确认卸载插件"
message={`确定要卸载插件 "${tool.title}" 吗?此操作无法撤销。`}
confirmText="卸载"
onConfirm={handleConfirmUninstall}
onCancel={() => setShowUninstallDialog(false)}
/>
</>
);
}
+58 -9
View File
@@ -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) {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{allTools.map((tool) => (
<ToolCard
<ToolCardContextMenu
key={tool.id}
title={tool.title}
description={tool.description}
icon={renderIcon(tool.icon, tool.disabled)}
status={tool.status}
disabled={tool.disabled}
source={tool.source}
onClick={() => handleToolClick(tool)}
/>
tool={tool}
onNavigate={onNavigate}
onToggleEnabled={handleTogglePluginEnabled}
onUninstall={handleUninstallPlugin}
isEnabled={true}
>
<div>
<ToolCard
title={tool.title}
description={tool.description}
icon={renderIcon(tool.icon, tool.disabled)}
status={tool.status}
disabled={tool.disabled}
source={tool.source}
onClick={() => handleToolClick(tool)}
/>
</div>
</ToolCardContextMenu>
))}
</div>
+211
View File
@@ -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<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-none",
"bg-white dark:bg-zinc-900",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md backdrop-blur-none",
"bg-white dark:bg-zinc-900",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};