From 7d8b8a3de11ef31d7e54719f20a97e099f2f2dce Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 7 Aug 2025 10:12:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0AI=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=B9=B6=E9=9A=90=E8=97=8F=EF=BC=8C=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9=E7=9B=AE=E4=B8=BA=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增完整的AI画布工具系统 - 可视化节点编辑器,支持拖拽连线 - 多种节点类型:文本输入、图片生成、视频生成等 - 智能连接验证和数据流转换 - 异步处理引擎,支持进度追踪和取消 - 批量处理系统,支持并发处理 - AI服务集成框架,支持多种AI API - 用户体验优化 - 智能弹框定位,防止被遮挡 - 节点删除功能(悬停删除按钮 + 键盘快捷键) - 通知系统和错误处理 - 快速开始模板 - 键盘快捷键支持 - 界面调整 - 暂时隐藏AI画布,保留代码 - 设置项目列表为首页 - 简化导航栏结构 --- apps/desktop/package.json | 1 + apps/desktop/src/App.css | 61 +++ apps/desktop/src/App.tsx | 4 + apps/desktop/src/components/Navigation.tsx | 13 + .../src/components/canvas/AIConfigModal.tsx | 220 ++++++++++ .../src/components/canvas/BaseNode.tsx | 143 +++++++ .../src/components/canvas/CanvasContainer.tsx | 248 +++++++++++ .../src/components/canvas/CanvasToolbar.tsx | 237 +++++++++++ .../components/canvas/ConnectionIndicator.tsx | 275 ++++++++++++ .../src/components/canvas/NodeSelector.tsx | 217 ++++++++++ .../components/canvas/NotificationSystem.tsx | 248 +++++++++++ .../components/canvas/QuickStartTemplates.tsx | 191 +++++++++ .../canvas/nodes/BatchResultGrid.tsx | 140 +++++++ .../canvas/nodes/FolderUploadNode.tsx | 144 +++++++ .../canvas/nodes/ImageDisplayNode.tsx | 59 +++ .../components/canvas/nodes/ImageEditNode.tsx | 140 +++++++ .../canvas/nodes/ImageGenerateNode.tsx | 84 ++++ .../canvas/nodes/ImageUploadNode.tsx | 80 ++++ .../canvas/nodes/PromptOptimizeNode.tsx | 76 ++++ .../canvas/nodes/TextDisplayNode.tsx | 51 +++ .../components/canvas/nodes/TextInputNode.tsx | 53 +++ .../canvas/nodes/VideoGenerateNode.tsx | 181 ++++++++ .../canvas/nodes/VideoPlayerNode.tsx | 190 +++++++++ apps/desktop/src/hooks/useNodeProcessing.ts | 243 +++++++++++ apps/desktop/src/pages/CanvasTool.tsx | 144 +++++++ apps/desktop/src/services/aiServices.ts | 391 ++++++++++++++++++ apps/desktop/src/services/processingEngine.ts | 299 ++++++++++++++ apps/desktop/src/store/canvasStore.ts | 227 ++++++++++ apps/desktop/src/types/canvas.ts | 121 ++++++ apps/desktop/src/utils/batchProcessor.ts | 315 ++++++++++++++ apps/desktop/src/utils/connectionValidator.ts | 344 +++++++++++++++ augmentcode.md | 1 + pnpm-lock.yaml | 382 +++++++++++++++++ 33 files changed, 5523 insertions(+) create mode 100644 apps/desktop/src/components/canvas/AIConfigModal.tsx create mode 100644 apps/desktop/src/components/canvas/BaseNode.tsx create mode 100644 apps/desktop/src/components/canvas/CanvasContainer.tsx create mode 100644 apps/desktop/src/components/canvas/CanvasToolbar.tsx create mode 100644 apps/desktop/src/components/canvas/ConnectionIndicator.tsx create mode 100644 apps/desktop/src/components/canvas/NodeSelector.tsx create mode 100644 apps/desktop/src/components/canvas/NotificationSystem.tsx create mode 100644 apps/desktop/src/components/canvas/QuickStartTemplates.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/BatchResultGrid.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/FolderUploadNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/ImageDisplayNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/ImageEditNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/ImageGenerateNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/ImageUploadNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/PromptOptimizeNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/TextDisplayNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/TextInputNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/VideoGenerateNode.tsx create mode 100644 apps/desktop/src/components/canvas/nodes/VideoPlayerNode.tsx create mode 100644 apps/desktop/src/hooks/useNodeProcessing.ts create mode 100644 apps/desktop/src/pages/CanvasTool.tsx create mode 100644 apps/desktop/src/services/aiServices.ts create mode 100644 apps/desktop/src/services/processingEngine.ts create mode 100644 apps/desktop/src/store/canvasStore.ts create mode 100644 apps/desktop/src/types/canvas.ts create mode 100644 apps/desktop/src/utils/batchProcessor.ts create mode 100644 apps/desktop/src/utils/connectionValidator.ts create mode 100644 augmentcode.md diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0507f21..bc39ba7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,6 +34,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.20.1", "react-window": "^1.8.11", + "reactflow": "^11.11.4", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "zustand": "^4.4.7" diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index a27c3e5..b2fa7df 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -2,6 +2,67 @@ @tailwind components; @tailwind utilities; +/* 节点选择器动画 */ +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slide-down { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slide-left { + from { + opacity: 0; + transform: translateX(10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slide-right { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-up { + animation: slide-up 0.2s ease-out; +} + +.animate-slide-down { + animation: slide-down 0.2s ease-out; +} + +.animate-slide-left { + animation: slide-left 0.2s ease-out; +} + +.animate-slide-right { + animation: slide-right 0.2s ease-out; +} + /* 自定义基础样式 */ @layer base { /* 改进字体渲染 */ diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 295da98..01f7c02 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -37,6 +37,7 @@ import MaterialCenter from './pages/MaterialCenter'; import VideoGeneration from './pages/VideoGeneration'; import { OutfitPhotoGenerationPage } from './pages/OutfitPhotoGeneration'; import ComfyUIManagement from './pages/ComfyUIManagement'; +import CanvasTool from './pages/CanvasTool'; import Navigation from './components/Navigation'; import { NotificationSystem, useNotifications } from './components/NotificationSystem'; @@ -117,6 +118,9 @@ function App() {
} /> + } /> + {/* AI 画布工具暂时隐藏 */} + {/* } /> */} } /> } /> } /> diff --git a/apps/desktop/src/components/Navigation.tsx b/apps/desktop/src/components/Navigation.tsx index dee1beb..11dfb83 100644 --- a/apps/desktop/src/components/Navigation.tsx +++ b/apps/desktop/src/components/Navigation.tsx @@ -8,6 +8,7 @@ import { WrenchScrewdriverIcon, SparklesIcon, CommandLineIcon, + PaintBrushIcon, } from '@heroicons/react/24/outline'; const Navigation: React.FC = () => { @@ -20,6 +21,13 @@ const Navigation: React.FC = () => { icon: FolderIcon, description: '管理项目和素材' }, + // AI画布暂时隐藏 + // { + // name: 'AI画布', + // href: '/canvas-tool', + // icon: PaintBrushIcon, + // description: '可视化AI内容生成画布' + // }, { name: '模特', href: '/models', @@ -68,6 +76,11 @@ const Navigation: React.FC = () => { return location.pathname === '/tools'; } + // 特殊处理:项目页面匹配 /projects 和 /project/:id + if (href === '/projects') { + return location.pathname === '/projects' || location.pathname.startsWith('/project/'); + } + // 其他路径使用 startsWith 匹配 return location.pathname.startsWith(href); }; diff --git a/apps/desktop/src/components/canvas/AIConfigModal.tsx b/apps/desktop/src/components/canvas/AIConfigModal.tsx new file mode 100644 index 0000000..628e959 --- /dev/null +++ b/apps/desktop/src/components/canvas/AIConfigModal.tsx @@ -0,0 +1,220 @@ +import React, { useState, useEffect } from 'react'; +import { AIServiceConfig } from '../../services/aiServices'; + +interface AIConfigModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (config: AIServiceConfig) => void; + currentConfig: AIServiceConfig; +} + +export const AIConfigModal: React.FC = ({ + isOpen, + onClose, + onSave, + currentConfig +}) => { + const [config, setConfig] = useState(currentConfig); + + useEffect(() => { + setConfig(currentConfig); + }, [currentConfig]); + + const handleSave = () => { + onSave(config); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+

AI 服务配置

+ +
+ +
+ {/* OpenAI 配置 */} +
+

OpenAI 配置

+
+
+ + setConfig(prev => ({ + ...prev, + openai: { ...prev.openai, apiKey: e.target.value } + }))} + placeholder="sk-..." + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setConfig(prev => ({ + ...prev, + openai: { ...prev.openai, baseURL: e.target.value } + }))} + placeholder="https://api.openai.com/v1" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+
+ + {/* 图片生成配置 */} +
+

图片生成配置

+
+
+ + +
+
+ + setConfig(prev => ({ + ...prev, + imageGeneration: { + ...prev.imageGeneration, + apiKey: e.target.value + } + }))} + placeholder="API Key for image generation service" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + {/* 视频生成配置 */} +
+

视频生成配置

+
+
+ + +
+
+ + setConfig(prev => ({ + ...prev, + videoGeneration: { + ...prev.videoGeneration, + apiKey: e.target.value + } + }))} + placeholder="API Key for video generation service" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+
+ + {/* 操作按钮 */} +
+ + +
+ + {/* 配置说明 */} +
+

配置说明

+
    +
  • • OpenAI API Key 用于提示词优化和图片生成
  • +
  • • 图片生成服务支持多个提供商,请选择合适的服务
  • +
  • • 视频生成需要专门的 API Key,处理时间较长
  • +
  • • 配置保存后立即生效,无需重启应用
  • +
+
+
+
+
+ ); +}; diff --git a/apps/desktop/src/components/canvas/BaseNode.tsx b/apps/desktop/src/components/canvas/BaseNode.tsx new file mode 100644 index 0000000..308fd76 --- /dev/null +++ b/apps/desktop/src/components/canvas/BaseNode.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { Handle, Position } from 'reactflow'; +import { NodePort, ProcessingStatus } from '../../types/canvas'; +import { useCanvasStore } from '../../store/canvasStore'; +import clsx from 'clsx'; + +interface BaseNodeProps { + id: string; + title: string; + children: React.ReactNode; + inputs?: NodePort[]; + outputs?: NodePort[]; + className?: string; +} + +export const BaseNode: React.FC = ({ + id, + title, + children, + inputs = [], + outputs = [], + className +}) => { + const [isHovered, setIsHovered] = useState(false); + const { processingNodes, deleteNode } = useCanvasStore(); + const processingInfo = processingNodes.get(id); + const status = processingInfo?.status || 'idle'; + const progress = processingInfo?.progress || 0; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + if (confirm('确定要删除这个节点吗?')) { + deleteNode(id); + } + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* 输入端口 */} + {inputs.map(port => ( + + ))} + + {/* 删除按钮 */} + {isHovered && status !== 'loading' && ( + + )} + + {/* 节点内容 */} +
+
+

{title}

+ {status === 'loading' && ( +
+ )} +
+ +
+ {children} +
+ + {/* 进度条 */} + {status === 'loading' && progress !== undefined && ( +
+
+
+
+
+ {Math.round(progress)}% +
+
+ )} + + {/* 错误信息 */} + {status === 'error' && processingInfo?.error && ( +
+ {processingInfo.error} +
+ )} +
+ + {/* 输出端口 */} + {outputs.map(port => ( + + ))} +
+ ); +}; diff --git a/apps/desktop/src/components/canvas/CanvasContainer.tsx b/apps/desktop/src/components/canvas/CanvasContainer.tsx new file mode 100644 index 0000000..d4df236 --- /dev/null +++ b/apps/desktop/src/components/canvas/CanvasContainer.tsx @@ -0,0 +1,248 @@ +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import ReactFlow, { + Background, + Controls, + MiniMap, + useReactFlow, + ReactFlowProvider, + Connection, + Node +} from 'reactflow'; +import 'reactflow/dist/style.css'; + +import { useCanvasStore } from '../../store/canvasStore'; +import { NodeSelector } from './NodeSelector'; +import { TextInputNode } from './nodes/TextInputNode'; +import { PromptOptimizeNode } from './nodes/PromptOptimizeNode'; +import { TextDisplayNode } from './nodes/TextDisplayNode'; +import { ImageUploadNode } from './nodes/ImageUploadNode'; +import { ImageGenerateNode } from './nodes/ImageGenerateNode'; +import { ImageDisplayNode } from './nodes/ImageDisplayNode'; +import { ImageEditNode } from './nodes/ImageEditNode'; +import { VideoGenerateNode } from './nodes/VideoGenerateNode'; +import { VideoPlayerNode } from './nodes/VideoPlayerNode'; +import { FolderUploadNode } from './nodes/FolderUploadNode'; +import { BatchResultGrid } from './nodes/BatchResultGrid'; +import { NodeType } from '../../types/canvas'; +import { ConnectionValidator, DataFlowConverter } from '../../utils/connectionValidator'; +import { NotificationSystem, useGlobalNotifications, globalNotifications } from './NotificationSystem'; +import { ProcessingIndicator, KeyboardShortcutsHint } from './ConnectionIndicator'; + +// 节点类型映射 +const nodeTypes = { + textInput: TextInputNode, + promptOptimize: PromptOptimizeNode, + textDisplay: TextDisplayNode, + imageUpload: ImageUploadNode, + imageGenerate: ImageGenerateNode, + imageDisplay: ImageDisplayNode, + imageEdit: ImageEditNode, + videoGenerate: VideoGenerateNode, + videoPlayer: VideoPlayerNode, + folderUpload: FolderUploadNode, + batchGrid: BatchResultGrid, +}; + +const CanvasFlow: React.FC = () => { + const [showShortcuts, setShowShortcuts] = useState(false); + const { notifications, removeNotification } = useGlobalNotifications(); + + const { + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect: storeOnConnect, + showNodeSelector, + nodeSelectorPosition, + showNodeSelectorAt, + hideNodeSelector, + addNode, + updateNode, + deleteNode, + processingNodes + } = useCanvasStore(); + + const { screenToFlowPosition } = useReactFlow(); + + // 处理画布点击 + const onPaneClick = useCallback((event: React.MouseEvent) => { + // 单击空白区域关闭节点选择器 + if (event.detail === 1 && (event.target as HTMLElement).classList.contains('react-flow__pane')) { + hideNodeSelector(); + } + + // 双击空白区域显示节点选择器 + if (event.detail === 2 && (event.target as HTMLElement).classList.contains('react-flow__pane')) { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + showNodeSelectorAt(position); + } + }, [screenToFlowPosition, showNodeSelectorAt, hideNodeSelector]); + + // 处理连线 + const onConnect = useCallback((connection: Connection) => { + // 验证连接是否有效 + const validation = ConnectionValidator.validateConnection(connection, nodes); + if (!validation.valid) { + globalNotifications.error('连接失败', validation.reason); + return; + } + + // 添加连线到store + storeOnConnect(connection); + + // 触发数据流处理 + const sourceNode = nodes.find(n => n.id === connection.source); + const targetNode = nodes.find(n => n.id === connection.target); + + if (sourceNode && targetNode && sourceNode.data?.result) { + // 使用数据转换器准备数据 + const preparedData = DataFlowConverter.prepareDataForNode( + sourceNode.data.result, + sourceNode.type as NodeType, + targetNode.type as NodeType + ); + + // 更新目标节点数据 + updateNode(connection.target!, { + data: { + ...targetNode.data, + ...preparedData + } + }); + } + }, [storeOnConnect, nodes, updateNode]); + + // 创建节点 + const createNode = useCallback((nodeType: NodeType, position: { x: number; y: number }) => { + const newNode: Node = { + id: `${nodeType}-${Date.now()}`, + type: nodeType, + position, + data: { + label: nodeType + } + }; + + addNode(newNode); + globalNotifications.success('节点已创建', `已添加 ${nodeType} 节点`); + }, [addNode]); + + // 键盘事件处理 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'F1') { + e.preventDefault(); + setShowShortcuts(!showShortcuts); + } + if (e.key === 'Escape') { + hideNodeSelector(); + } + if (e.key === 'Delete' || e.key === 'Backspace') { + // 删除选中的节点 + const selectedNodes = nodes.filter(node => node.selected); + if (selectedNodes.length > 0) { + const nodeNames = selectedNodes.map(node => node.data?.title || node.type).join(', '); + if (confirm(`确定要删除选中的节点吗?\n${nodeNames}`)) { + selectedNodes.forEach(node => { + deleteNode(node.id); + }); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [showShortcuts, hideNodeSelector, nodes, deleteNode]); + + // 自定义边样式 + const edgeOptions = useMemo(() => ({ + animated: true, + style: { strokeWidth: 2 } + }), []); + + return ( +
+ + + + + + + {/* 节点选择器 */} + {showNodeSelector && ( + + )} + + {/* 处理进度指示器 */} + {Array.from(processingNodes.entries()).map(([nodeId, info]) => ( + + ))} + + {/* 通知系统 */} + + + {/* 快捷键提示 */} + + + {/* 帮助提示 */} + +
+ ); +}; + +export const CanvasContainer: React.FC = () => { + return ; +}; diff --git a/apps/desktop/src/components/canvas/CanvasToolbar.tsx b/apps/desktop/src/components/canvas/CanvasToolbar.tsx new file mode 100644 index 0000000..5d1988a --- /dev/null +++ b/apps/desktop/src/components/canvas/CanvasToolbar.tsx @@ -0,0 +1,237 @@ +import React, { useState } from 'react'; +import { useCanvasStore } from '../../store/canvasStore'; +import { Save, FolderOpen, Download, Trash2, RotateCcw, Settings, ZoomIn } from 'lucide-react'; +import { AIConfigModal } from './AIConfigModal'; +import { AIServiceConfig, loadAIConfig } from '../../services/aiServices'; +import { useReactFlow } from 'reactflow'; + +interface CanvasToolbarProps { + onSave?: () => void; + onLoad?: () => void; + onExport?: () => void; +} + +export const CanvasToolbar: React.FC = ({ + onSave, + onLoad, + onExport +}) => { + const [showAIConfig, setShowAIConfig] = useState(false); + const [aiConfig, setAiConfig] = useState(loadAIConfig()); + const { fitView } = useReactFlow(); + + const { + nodes, + edges, + clearCanvas, + resetCanvas, + processingNodes + } = useCanvasStore(); + + const handleSave = () => { + if (onSave) { + onSave(); + } else { + // 默认保存逻辑 + const canvasData = { + nodes, + edges, + timestamp: new Date().toISOString() + }; + + const dataStr = JSON.stringify(canvasData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + const link = document.createElement('a'); + link.href = url; + link.download = `canvas-${Date.now()}.json`; + link.click(); + + URL.revokeObjectURL(url); + } + }; + + const handleLoad = () => { + if (onLoad) { + onLoad(); + } else { + // 默认加载逻辑 + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + try { + const canvasData = JSON.parse(event.target?.result as string); + // TODO: 实现加载逻辑 + console.log('加载画布数据:', canvasData); + } catch (error) { + console.error('加载失败:', error); + } + }; + reader.readAsText(file); + } + }; + input.click(); + } + }; + + const handleExport = () => { + if (onExport) { + onExport(); + } else { + // 默认导出逻辑 + console.log('导出画布'); + } + }; + + const handleClear = () => { + if (confirm('确定要清空画布吗?此操作不可撤销。')) { + clearCanvas(); + } + }; + + const handleReset = () => { + if (confirm('确定要重置画布吗?这将清空所有内容并重置视图。')) { + resetCanvas(); + } + }; + + const handleAIConfigSave = (newConfig: AIServiceConfig) => { + setAiConfig(newConfig); + // 保存到本地存储 + localStorage.setItem('aiConfig', JSON.stringify(newConfig)); + }; + + const handleFitView = () => { + fitView({ + padding: 0.1, + includeHiddenNodes: false, + minZoom: 0.1, + maxZoom: 2 + }); + }; + + const activeProcessingCount = processingNodes.size; + const hasNodes = nodes.length > 0; + + return ( +
+ {/* 左侧:标题和说明 */} +
+
+
+ AI +
+

AI画布工具

+
+ +
+ 双击空白处创建节点,拖拽连线建立数据流 +
+ + {/* 状态指示器 */} + {activeProcessingCount > 0 && ( +
+
+ {activeProcessingCount} 个节点处理中 +
+ )} +
+ + {/* 右侧:操作按钮 */} +
+ {/* 文件操作 */} +
+ + + + + +
+ +
+ + {/* AI 配置 */} + + +
+ + {/* 画布操作 */} +
+ + + + + +
+
+ + {/* AI 配置模态框 */} + setShowAIConfig(false)} + onSave={handleAIConfigSave} + currentConfig={aiConfig} + /> +
+ ); +}; diff --git a/apps/desktop/src/components/canvas/ConnectionIndicator.tsx b/apps/desktop/src/components/canvas/ConnectionIndicator.tsx new file mode 100644 index 0000000..96b0bf9 --- /dev/null +++ b/apps/desktop/src/components/canvas/ConnectionIndicator.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react'; +import { useCanvasStore } from '../../store/canvasStore'; + +interface ConnectionIndicatorProps { + sourceNodeId?: string; + targetNodeId?: string; + isConnecting: boolean; +} + +export const ConnectionIndicator: React.FC = ({ + sourceNodeId, + targetNodeId, + isConnecting +}) => { + const { nodes } = useCanvasStore(); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + if (isConnecting) { + const handleMouseMove = (e: MouseEvent) => { + setMousePosition({ x: e.clientX, y: e.clientY }); + }; + + document.addEventListener('mousemove', handleMouseMove); + return () => document.removeEventListener('mousemove', handleMouseMove); + } + }, [isConnecting]); + + if (!isConnecting || !sourceNodeId) return null; + + const sourceNode = nodes.find(n => n.id === sourceNodeId); + if (!sourceNode) return null; + + return ( +
+ {/* 连接线 */} + + + + + + + + + + {/* 连接提示 */} +
+ 拖拽到目标节点的输入端口 +
+
+ ); +}; + +// 端口高亮组件 +export const PortHighlight: React.FC<{ + nodeId: string; + portId: string; + type: 'source' | 'target'; + isCompatible: boolean; +}> = ({ nodeId, portId, type, isCompatible }) => { + return ( +
+ ); +}; + +// 连接验证提示 +export const ConnectionValidationTooltip: React.FC<{ + position: { x: number; y: number }; + message: string; + type: 'success' | 'error' | 'warning'; +}> = ({ position, message, type }) => { + const getColors = () => { + switch (type) { + case 'success': + return 'bg-green-600 text-white'; + case 'error': + return 'bg-red-600 text-white'; + case 'warning': + return 'bg-yellow-600 text-white'; + } + }; + + return ( +
+ {message} +
+
+ ); +}; + +// 节点操作提示 +export const NodeActionHint: React.FC<{ + nodeId: string; + action: string; + description: string; +}> = ({ nodeId, action, description }) => { + const { nodes } = useCanvasStore(); + const node = nodes.find(n => n.id === nodeId); + + if (!node) return null; + + return ( +
+
{action}
+
{description}
+
+ ); +}; + +// 处理进度指示器 +export const ProcessingIndicator: React.FC<{ + nodeId: string; + progress: number; + status: string; +}> = ({ nodeId, progress, status }) => { + const { nodes } = useCanvasStore(); + const node = nodes.find(n => n.id === nodeId); + + if (!node || status !== 'loading') return null; + + return ( +
+
+
+
+ 处理中... {Math.round(progress)}% +
+
+
+
+
+
+ ); +}; + +// 批量处理状态指示器 +export const BatchProcessingIndicator: React.FC<{ + nodeId: string; + completed: number; + total: number; + failed: number; +}> = ({ nodeId, completed, total, failed }) => { + const { nodes } = useCanvasStore(); + const node = nodes.find(n => n.id === nodeId); + + if (!node || total === 0) return null; + + const progress = (completed / total) * 100; + + return ( +
+
+ 批量处理进度 +
+ +
+ 已完成: {completed}/{total} + {failed > 0 && 失败: {failed}} +
+ +
+
+
+ +
+ {Math.round(progress)}% +
+
+ ); +}; + +// 键盘快捷键提示 +export const KeyboardShortcutsHint: React.FC<{ + visible: boolean; +}> = ({ visible }) => { + if (!visible) return null; + + const shortcuts = [ + { key: '双击空白', action: '创建节点' }, + { key: '单击空白', action: '关闭弹框' }, + { key: 'Delete/Backspace', action: '删除选中节点' }, + { key: '悬停节点', action: '显示删除按钮' }, + { key: 'F1', action: '显示/隐藏快捷键' }, + { key: 'Esc', action: '关闭弹框' }, + { key: 'Space+拖拽', action: '平移画布' }, + { key: '滚轮', action: '缩放画布' } + ]; + + return ( +
+

快捷键

+
+ {shortcuts.map((shortcut, index) => ( +
+ {shortcut.action} + + {shortcut.key} + +
+ ))} +
+
+ ); +}; diff --git a/apps/desktop/src/components/canvas/NodeSelector.tsx b/apps/desktop/src/components/canvas/NodeSelector.tsx new file mode 100644 index 0000000..8f84887 --- /dev/null +++ b/apps/desktop/src/components/canvas/NodeSelector.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { NodeType } from '../../types/canvas'; +import { useCanvasStore } from '../../store/canvasStore'; + +interface NodeSelectorProps { + position: { x: number; y: number }; + onSelect: (nodeType: NodeType, position: { x: number; y: number }) => void; + onClose: () => void; +} + +interface CalculatedPosition { + x: number; + y: number; + placement: 'top' | 'bottom' | 'left' | 'right'; +} + +export const NodeSelector: React.FC = ({ position, onSelect, onClose }) => { + const [calculatedPosition, setCalculatedPosition] = useState({ + x: position.x, + y: position.y, + placement: 'bottom' + }); + const [searchTerm, setSearchTerm] = useState(''); + const selectorRef = useRef(null); + // 计算最佳位置 + const calculateOptimalPosition = (): CalculatedPosition => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // 弹框的预估尺寸 + const modalWidth = 320; // max-w-md 约等于 320px + const modalHeight = 480; // 预估高度 + const padding = 20; // 边距 + + let x = position.x; + let y = position.y; + let placement: 'top' | 'bottom' | 'left' | 'right' = 'bottom'; + + // 检查右侧空间 + if (x + modalWidth + padding > viewportWidth) { + x = position.x - modalWidth; + placement = 'left'; + } + + // 检查下方空间 + if (y + modalHeight + padding > viewportHeight) { + y = position.y - modalHeight; + placement = placement === 'left' ? 'left' : 'top'; + } + + // 确保不超出左边界 + if (x < padding) { + x = padding; + } + + // 确保不超出上边界 + if (y < padding) { + y = padding; + } + + return { x, y, placement }; + }; + + // 组件挂载后计算位置 + useEffect(() => { + const newPosition = calculateOptimalPosition(); + setCalculatedPosition(newPosition); + }, [position]); + + // 点击外部关闭和键盘导航 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + const nodeCategories = { + '输入节点': [ + { type: 'textInput' as NodeType, label: '文本输入', icon: '📝', description: '输入文本内容' }, + { type: 'imageUpload' as NodeType, label: '图片上传', icon: '🖼️', description: '上传单张图片' }, + { type: 'folderUpload' as NodeType, label: '文件夹', icon: '📁', description: '批量上传图片' } + ], + '处理节点': [ + { type: 'promptOptimize' as NodeType, label: '提示词优化', icon: '✨', description: 'AI优化提示词' }, + { type: 'imageGenerate' as NodeType, label: '图片生成', icon: '🎨', description: '根据提示词生成图片' }, + { type: 'imageEdit' as NodeType, label: '图片编辑', icon: '✏️', description: '编辑和修改图片' }, + { type: 'videoGenerate' as NodeType, label: '视频生成', icon: '🎬', description: '图片转动态视频' } + ], + '输出节点': [ + { type: 'textDisplay' as NodeType, label: '文本显示', icon: '📄', description: '显示文本结果' }, + { type: 'imageDisplay' as NodeType, label: '图片显示', icon: '🖼️', description: '显示图片结果' }, + { type: 'videoPlayer' as NodeType, label: '视频播放', icon: '▶️', description: '播放视频结果' }, + { type: 'batchGrid' as NodeType, label: '批量结果', icon: '📊', description: '网格显示批量结果' } + ] + }; + + return ( +
+ {/* 位置指示器 */} + {calculatedPosition.placement === 'bottom' && ( +
+ )} + {calculatedPosition.placement === 'top' && ( +
+ )} + {calculatedPosition.placement === 'right' && ( +
+ )} + {calculatedPosition.placement === 'left' && ( +
+ )} + +
+

选择节点类型

+ +
+ + {/* 搜索框 */} +
+ setSearchTerm(e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + autoFocus + /> +
+ +
+ {Object.entries(nodeCategories).map(([category, nodes]) => { + // 过滤节点 + const filteredNodes = nodes.filter(node => + searchTerm === '' || + node.label.toLowerCase().includes(searchTerm.toLowerCase()) || + node.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (filteredNodes.length === 0) return null; + + return ( +
+

{category}

+
+ {filteredNodes.map(node => ( + + ))} +
+
+ ); + })} + + {/* 无搜索结果 */} + {searchTerm && Object.entries(nodeCategories).every(([_, nodes]) => + nodes.every(node => + !node.label.toLowerCase().includes(searchTerm.toLowerCase()) && + !node.description.toLowerCase().includes(searchTerm.toLowerCase()) + ) + ) && ( +
+
🔍
+
未找到匹配的节点
+
尝试其他关键词
+
+ )} +
+
+ ); +}; diff --git a/apps/desktop/src/components/canvas/NotificationSystem.tsx b/apps/desktop/src/components/canvas/NotificationSystem.tsx new file mode 100644 index 0000000..8cb7cd9 --- /dev/null +++ b/apps/desktop/src/components/canvas/NotificationSystem.tsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from 'react'; +import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'; + +export type NotificationType = 'success' | 'error' | 'warning' | 'info'; + +export interface Notification { + id: string; + type: NotificationType; + title: string; + message?: string; + duration?: number; + action?: { + label: string; + onClick: () => void; + }; +} + +interface NotificationSystemProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +const NotificationItem: React.FC<{ + notification: Notification; + onRemove: (id: string) => void; +}> = ({ notification, onRemove }) => { + const [isVisible, setIsVisible] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + + useEffect(() => { + // 入场动画 + setTimeout(() => setIsVisible(true), 50); + + // 自动移除 + if (notification.duration !== 0) { + const timer = setTimeout(() => { + handleRemove(); + }, notification.duration || 5000); + + return () => clearTimeout(timer); + } + }, [notification.duration]); + + const handleRemove = () => { + setIsLeaving(true); + setTimeout(() => { + onRemove(notification.id); + }, 300); + }; + + const getIcon = () => { + switch (notification.type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + } + }; + + const getBackgroundColor = () => { + switch (notification.type) { + case 'success': + return 'bg-green-50 border-green-200'; + case 'error': + return 'bg-red-50 border-red-200'; + case 'warning': + return 'bg-yellow-50 border-yellow-200'; + case 'info': + return 'bg-blue-50 border-blue-200'; + } + }; + + return ( +
+
+
+ {getIcon()} +
+ +
+

+ {notification.title} +

+ {notification.message && ( +

+ {notification.message} +

+ )} + + {notification.action && ( + + )} +
+ + +
+
+ ); +}; + +export const NotificationSystem: React.FC = ({ + notifications, + onRemove +}) => { + return ( +
+ {notifications.map(notification => ( + + ))} +
+ ); +}; + +// Hook for managing notifications +export const useNotifications = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = (notification: Omit) => { + const id = `notification-${Date.now()}-${Math.random()}`; + setNotifications(prev => [...prev, { ...notification, id }]); + }; + + const removeNotification = (id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }; + + const clearAll = () => { + setNotifications([]); + }; + + // 便捷方法 + const success = (title: string, message?: string, options?: Partial) => { + addNotification({ type: 'success', title, message, ...options }); + }; + + const error = (title: string, message?: string, options?: Partial) => { + addNotification({ type: 'error', title, message, duration: 0, ...options }); + }; + + const warning = (title: string, message?: string, options?: Partial) => { + addNotification({ type: 'warning', title, message, ...options }); + }; + + const info = (title: string, message?: string, options?: Partial) => { + addNotification({ type: 'info', title, message, ...options }); + }; + + return { + notifications, + addNotification, + removeNotification, + clearAll, + success, + error, + warning, + info + }; +}; + +// 全局通知管理器 +class GlobalNotificationManager { + private listeners: Array<(notifications: Notification[]) => void> = []; + private notifications: Notification[] = []; + + subscribe(listener: (notifications: Notification[]) => void) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter(l => l !== listener); + }; + } + + private notify() { + this.listeners.forEach(listener => listener([...this.notifications])); + } + + add(notification: Omit) { + const id = `global-notification-${Date.now()}-${Math.random()}`; + this.notifications.push({ ...notification, id }); + this.notify(); + } + + remove(id: string) { + this.notifications = this.notifications.filter(n => n.id !== id); + this.notify(); + } + + clear() { + this.notifications = []; + this.notify(); + } + + success(title: string, message?: string, options?: Partial) { + this.add({ type: 'success', title, message, ...options }); + } + + error(title: string, message?: string, options?: Partial) { + this.add({ type: 'error', title, message, duration: 0, ...options }); + } + + warning(title: string, message?: string, options?: Partial) { + this.add({ type: 'warning', title, message, ...options }); + } + + info(title: string, message?: string, options?: Partial) { + this.add({ type: 'info', title, message, ...options }); + } +} + +export const globalNotifications = new GlobalNotificationManager(); + +// Hook for global notifications +export const useGlobalNotifications = () => { + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + return globalNotifications.subscribe(setNotifications); + }, []); + + return { + notifications, + removeNotification: globalNotifications.remove.bind(globalNotifications) + }; +}; diff --git a/apps/desktop/src/components/canvas/QuickStartTemplates.tsx b/apps/desktop/src/components/canvas/QuickStartTemplates.tsx new file mode 100644 index 0000000..dcc7c36 --- /dev/null +++ b/apps/desktop/src/components/canvas/QuickStartTemplates.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useCanvasStore } from '../../store/canvasStore'; +import { Node } from 'reactflow'; + +interface Template { + id: string; + name: string; + description: string; + icon: string; + nodes: Partial[]; + edges: any[]; +} + +const templates: Template[] = [ + { + id: 'text-to-image', + name: '文本生成图片', + description: '输入文本 → 优化提示词 → 生成图片', + icon: '🎨', + nodes: [ + { + id: 'text-1', + type: 'textInput', + position: { x: 100, y: 100 }, + data: { text: '一只可爱的小猫' } + }, + { + id: 'prompt-1', + type: 'promptOptimize', + position: { x: 350, y: 100 }, + data: {} + }, + { + id: 'image-1', + type: 'imageGenerate', + position: { x: 600, y: 100 }, + data: {} + }, + { + id: 'display-1', + type: 'imageDisplay', + position: { x: 850, y: 100 }, + data: {} + } + ], + edges: [ + { id: 'e1', source: 'text-1', target: 'prompt-1', sourceHandle: 'text-output', targetHandle: 'text-input' }, + { id: 'e2', source: 'prompt-1', target: 'image-1', sourceHandle: 'text-output', targetHandle: 'prompt-input' }, + { id: 'e3', source: 'image-1', target: 'display-1', sourceHandle: 'image-output', targetHandle: 'image-input' } + ] + }, + { + id: 'batch-processing', + name: '批量图片处理', + description: '文件夹上传 → 批量处理 → 结果展示', + icon: '📁', + nodes: [ + { + id: 'folder-1', + type: 'folderUpload', + position: { x: 100, y: 150 }, + data: {} + }, + { + id: 'batch-1', + type: 'batchGrid', + position: { x: 400, y: 150 }, + data: {} + } + ], + edges: [ + { id: 'e1', source: 'folder-1', target: 'batch-1', sourceHandle: 'batch-output', targetHandle: 'batch-input' } + ] + }, + { + id: 'image-workflow', + name: '完整图片工作流', + description: '图片上传 → 多路径处理 → 结果对比', + icon: '🔄', + nodes: [ + { + id: 'upload-1', + type: 'imageUpload', + position: { x: 100, y: 200 }, + data: {} + }, + { + id: 'display-1', + type: 'imageDisplay', + position: { x: 400, y: 150 }, + data: {} + }, + { + id: 'display-2', + type: 'imageDisplay', + position: { x: 400, y: 250 }, + data: {} + } + ], + edges: [ + { id: 'e1', source: 'upload-1', target: 'display-1', sourceHandle: 'image-output', targetHandle: 'image-input' }, + { id: 'e2', source: 'upload-1', target: 'display-2', sourceHandle: 'image-output', targetHandle: 'image-input' } + ] + } +]; + +interface QuickStartTemplatesProps { + onClose: () => void; +} + +export const QuickStartTemplates: React.FC = ({ onClose }) => { + const { setNodes, setEdges, clearCanvas } = useCanvasStore(); + + const handleTemplateSelect = (template: Template) => { + // 清空当前画布 + clearCanvas(); + + // 应用模板 + setNodes(template.nodes as Node[]); + setEdges(template.edges); + + // 关闭模板选择器 + onClose(); + }; + + return ( +
+
+
+
+

快速开始

+ +
+

选择一个模板快速开始你的AI创作流程

+
+ +
+
+ {templates.map(template => ( +
handleTemplateSelect(template)} + className="border border-gray-200 rounded-lg p-4 hover:border-blue-300 hover:bg-blue-50 cursor-pointer transition-all duration-200" + > +
+
{template.icon}
+
+

{template.name}

+

{template.description}

+
+ {template.nodes.length} 个节点 • {template.edges.length} 个连接 +
+
+
+
+ ))} +
+ +
+

💡 使用提示

+
    +
  • • 双击空白处可以创建新节点
  • +
  • • 拖拽节点端口可以建立连线
  • +
  • • 连线完成后会自动触发处理
  • +
  • • 文件夹上传支持批量处理模式
  • +
+
+
+ +
+
+ +
+ 你也可以稍后通过工具栏加载模板 +
+
+
+
+
+ ); +}; diff --git a/apps/desktop/src/components/canvas/nodes/BatchResultGrid.tsx b/apps/desktop/src/components/canvas/nodes/BatchResultGrid.tsx new file mode 100644 index 0000000..1dae37f --- /dev/null +++ b/apps/desktop/src/components/canvas/nodes/BatchResultGrid.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { BaseNode } from '../BaseNode'; +import { NodePort, BatchResult } from '../../../types/canvas'; + +interface BatchResultGridProps { + id: string; + data: { + results?: BatchResult[]; + gridSize?: number; + totalCount?: number; + completedCount?: number; + }; +} + +export const BatchResultGrid: React.FC = ({ id, data }) => { + const inputs: NodePort[] = [ + { id: 'batch-input', type: 'batch', label: '批量输入', position: 'input' } + ]; + + const results = data.results || []; + const completedCount = results.filter(r => r.status === 'success').length; + const totalCount = results.length; + const gridSize = data.gridSize || 3; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'loading': return '⏳'; + case 'success': return '✅'; + case 'error': return '❌'; + default: return '⏸️'; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'loading': return 'text-blue-600'; + case 'success': return 'text-green-600'; + case 'error': return 'text-red-600'; + default: return 'text-gray-400'; + } + }; + + return ( + +
+ {/* 进度统计 */} + {totalCount > 0 && ( +
+
+ 批量处理进度 + {completedCount}/{totalCount} +
+
+
0 ? (completedCount / totalCount) * 100 : 0}%` }} + /> +
+
+ )} + + {/* 结果网格 */} + {results.length > 0 ? ( +
+ {results.map((item, index) => ( +
+ {/* 预览区域 */} +
+ {item.thumbnail ? ( + {item.filename} + ) : ( +
+ {item.status === 'loading' ? '处理中...' : '无预览'} +
+ )} + + {/* 状态指示器 */} +
+ {getStatusIcon(item.status)} +
+
+ + {/* 文件信息 */} +
+
+ {item.filename} +
+
+ {item.status === 'loading' && '处理中...'} + {item.status === 'success' && '完成'} + {item.status === 'error' && '失败'} + {item.status === 'idle' && '等待中'} +
+
+
+ ))} +
+ ) : ( +
+ 等待批量数据输入... +
+ )} + + {/* 操作按钮 */} + {results.length > 0 && ( +
+ + {results.length} 个项目 + +
+ + +
+
+ )} +
+ + ); +}; diff --git a/apps/desktop/src/components/canvas/nodes/FolderUploadNode.tsx b/apps/desktop/src/components/canvas/nodes/FolderUploadNode.tsx new file mode 100644 index 0000000..b2fc588 --- /dev/null +++ b/apps/desktop/src/components/canvas/nodes/FolderUploadNode.tsx @@ -0,0 +1,144 @@ +import React, { useState, useCallback } from 'react'; +import { BaseNode } from '../BaseNode'; +import { NodePort } from '../../../types/canvas'; +import { useCanvasStore } from '../../../store/canvasStore'; +import { batchProcessor, BatchItem } from '../../../utils/batchProcessor'; + +interface FolderUploadNodeProps { + id: string; + data: { + files?: File[]; + fileCount?: number; + totalSize?: number; + result?: File[]; + }; +} + +export const FolderUploadNode: React.FC = ({ id, data }) => { + const [files, setFiles] = useState(data.files || []); + const [isProcessing, setIsProcessing] = useState(false); + const [batchResults, setBatchResults] = useState([]); + const { updateNode } = useCanvasStore(); + + const outputs: NodePort[] = [ + { id: 'batch-output', type: 'batch', label: '批量输出', position: 'output' } + ]; + + const handleFolderChange = useCallback((e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files || []); + const imageFiles = selectedFiles.filter(file => file.type.startsWith('image/')); + + setFiles(imageFiles); + setBatchResults([]); // 清空之前的结果 + + const totalSize = imageFiles.reduce((sum, file) => sum + file.size, 0); + + // 准备批量数据 + const batchData = imageFiles.map(file => ({ + filename: file.name, + data: file, + size: file.size, + type: file.type + })); + + // 更新节点数据 + updateNode(id, { + data: { + ...data, + files: imageFiles, + fileCount: imageFiles.length, + totalSize, + result: batchData + } + }); + }, [id, data, updateNode]); + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( + +
+ + + {files.length > 0 ? ( +
+
+ 📁 {files.length} 个图片文件 +
+
+ 总大小: {formatFileSize(data.totalSize || 0)} +
+ + {/* 文件列表预览 */} +
+ {files.slice(0, 5).map((file, index) => ( +
+ • {file.name} ({formatFileSize(file.size)}) +
+ ))} + {files.length > 5 && ( +
+ ... 还有 {files.length - 5} 个文件 +
+ )} +
+ + {/* 批量处理状态 */} + {isProcessing && ( +
+
+
+ 批量处理中... +
+
+ )} + + {/* 批量结果预览 */} + {batchResults.length > 0 && ( +
+
+ 处理完成: {batchResults.filter(r => r.status === 'success').length}/{batchResults.length} +
+
+ {batchResults.slice(0, 6).map((result, index) => ( +
+ {result.status === 'success' ? '✓' : + result.status === 'error' ? '✗' : '⏳'} +
+ ))} +
+
+ )} +
+ ) : ( +
+ 选择包含图片的文件夹 +
+ )} +
+
+ ); +}; diff --git a/apps/desktop/src/components/canvas/nodes/ImageDisplayNode.tsx b/apps/desktop/src/components/canvas/nodes/ImageDisplayNode.tsx new file mode 100644 index 0000000..ed5db94 --- /dev/null +++ b/apps/desktop/src/components/canvas/nodes/ImageDisplayNode.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { BaseNode } from '../BaseNode'; +import { NodePort } from '../../../types/canvas'; + +interface ImageDisplayNodeProps { + id: string; + data: { + inputImage?: string; + result?: string; + }; +} + +export const ImageDisplayNode: React.FC = ({ id, data }) => { + const inputs: NodePort[] = [ + { id: 'image-input', type: 'image', label: '图片输入', position: 'input' } + ]; + + const handleDownload = () => { + if (data.result) { + const link = document.createElement('a'); + link.href = data.result; + link.download = `generated-image-${Date.now()}.png`; + link.click(); + } + }; + + return ( + +
+ {data.result ? ( +
+ 显示图片 +
+ 图片已加载 + +
+
+ ) : ( +
+ 等待图片输入... +
+ )} +
+
+ ); +}; diff --git a/apps/desktop/src/components/canvas/nodes/ImageEditNode.tsx b/apps/desktop/src/components/canvas/nodes/ImageEditNode.tsx new file mode 100644 index 0000000..0dee820 --- /dev/null +++ b/apps/desktop/src/components/canvas/nodes/ImageEditNode.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useState } from 'react'; +import { BaseNode } from '../BaseNode'; +import { NodePort, DataFlow } from '../../../types/canvas'; +import { useNodeProcessing, useNodeStatus } from '../../../hooks/useNodeProcessing'; + +interface ImageEditNodeProps { + id: string; + data: { + originalImage?: string; + editedImage?: string; + editInstruction?: string; + editType?: 'style-transfer' | 'background-remove' | 'enhance' | 'resize'; + result?: string; + }; +} + +export const ImageEditNode: React.FC = ({ id, data }) => { + const { processNode } = useNodeProcessing(); + const { status, progress } = useNodeStatus(id); + const [editType, setEditType] = useState(data.editType || 'enhance'); + const [instruction, setInstruction] = useState(data.editInstruction || ''); + + const inputs: NodePort[] = [ + { id: 'image-input', type: 'image', label: '原始图片', position: 'input' }, + { id: 'instruction-input', type: 'text', label: '编辑指令', position: 'input' } + ]; + + const outputs: NodePort[] = [ + { id: 'image-output', type: 'image', label: '编辑后图片', position: 'output' } + ]; + + // 监听输入数据变化并处理 + useEffect(() => { + if (data.originalImage && instruction && status !== 'loading') { + const inputData: DataFlow = { + type: 'single', + dataType: 'image', + content: { + imageUrl: data.originalImage, + instruction: instruction, + editType: editType + } + }; + + processNode(id, 'imageEdit', inputData).catch(error => { + console.error('Image editing failed:', error); + }); + } + }, [data.originalImage, instruction, editType, id, processNode, status]); + + const editTypeOptions = [ + { value: 'enhance', label: '图片增强', icon: '✨' }, + { value: 'style-transfer', label: '风格转换', icon: '🎨' }, + { value: 'background-remove', label: '背景移除', icon: '🖼️' }, + { value: 'resize', label: '尺寸调整', icon: '📐' } + ]; + + return ( + +
+ {/* 编辑类型选择 */} +
+ + +
+ + {/* 编辑指令输入 */} +
+ +