mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加右键菜单系统
- 安装 @radix-ui/react-context-menu 依赖 - 创建基础 ContextMenu UI 组件 - 为工具箱卡片添加右键菜单(启用/禁用、复制信息、删除) - 为插件中心已安装列表添加右键菜单(启用/禁用、卸载) - 为 Flow Monitor 请求记录添加右键菜单(复制、查看详情、清除) - 为凭证池凭证卡片添加右键菜单(刷新、复制、删除) - 为配置管理配置项添加右键菜单(编辑、复制、删除) - 修复菜单背景透明问题 版本更新至 v0.23.0
This commit is contained in:
Generated
+31
-2
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -3668,7 +3668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.22.0"
|
||||
version = "0.23.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -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,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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user