|
|
'use client'; |
|
|
|
|
|
import { useState, useRef } from 'react'; |
|
|
|
|
|
interface WorkflowCanvasProps { |
|
|
nodes: any[]; |
|
|
connections: any[]; |
|
|
onNodeSelect: (node: any) => void; |
|
|
onUpdateWorkflow: (data: any) => void; |
|
|
} |
|
|
|
|
|
export default function WorkflowCanvas({ nodes, connections, onNodeSelect, onUpdateWorkflow }: WorkflowCanvasProps) { |
|
|
const canvasRef = useRef<HTMLDivElement>(null); |
|
|
const [draggedNode, setDraggedNode] = useState(null); |
|
|
const [selectedNode, setSelectedNode] = useState(null); |
|
|
const [canvasNodes, setCanvasNodes] = useState([ |
|
|
{ |
|
|
id: 'start', |
|
|
type: 'trigger', |
|
|
name: '开始', |
|
|
icon: '🚀', |
|
|
x: 100, |
|
|
y: 100, |
|
|
isFixed: true |
|
|
} |
|
|
]); |
|
|
|
|
|
const handleDragOver = (e: React.DragEvent) => { |
|
|
e.preventDefault(); |
|
|
}; |
|
|
|
|
|
const handleDrop = (e: React.DragEvent) => { |
|
|
e.preventDefault(); |
|
|
|
|
|
const nodeData = JSON.parse(e.dataTransfer.getData('application/json')); |
|
|
const rect = canvasRef.current?.getBoundingClientRect(); |
|
|
|
|
|
if (rect) { |
|
|
const newNode = { |
|
|
id: `node_${Date.now()}`, |
|
|
type: nodeData.type, |
|
|
name: nodeData.name, |
|
|
icon: nodeData.icon, |
|
|
nodeType: nodeData.nodeType, |
|
|
x: e.clientX - rect.left - 50, |
|
|
y: e.clientY - rect.top - 25, |
|
|
config: {} |
|
|
}; |
|
|
|
|
|
setCanvasNodes(prev => [...prev, newNode]); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleNodeClick = (node: any) => { |
|
|
setSelectedNode(node); |
|
|
onNodeSelect(node); |
|
|
}; |
|
|
|
|
|
const handleNodeDrag = (nodeId: string, newX: number, newY: number) => { |
|
|
setCanvasNodes(prev => |
|
|
prev.map(node => |
|
|
node.id === nodeId ? { ...node, x: newX, y: newY } : node |
|
|
) |
|
|
); |
|
|
}; |
|
|
|
|
|
const deleteNode = (nodeId: string) => { |
|
|
setCanvasNodes(prev => prev.filter(node => node.id !== nodeId && !node.isFixed)); |
|
|
if (selectedNode && selectedNode.id === nodeId) { |
|
|
setSelectedNode(null); |
|
|
onNodeSelect(null); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div |
|
|
ref={canvasRef} |
|
|
className="relative w-full h-full bg-gray-50 overflow-auto" |
|
|
onDragOver={handleDragOver} |
|
|
onDrop={handleDrop} |
|
|
style={{ |
|
|
backgroundImage: 'radial-gradient(circle, #d1d5db 1px, transparent 1px)', |
|
|
backgroundSize: '20px 20px' |
|
|
}} |
|
|
> |
|
|
{/* 画布提示 */} |
|
|
{canvasNodes.length <= 1 && ( |
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> |
|
|
<div className="text-center text-gray-500"> |
|
|
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4"> |
|
|
<div className="w-8 h-8 flex items-center justify-center"> |
|
|
<i className="ri-drag-drop-line text-2xl"></i> |
|
|
</div> |
|
|
</div> |
|
|
<p className="text-lg font-medium mb-2">开始构建您的工作流</p> |
|
|
<p className="text-sm">从左侧拖拽节点到画布上</p> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* 渲染节点 */} |
|
|
{canvasNodes.map((node) => ( |
|
|
<WorkflowNode |
|
|
key={node.id} |
|
|
node={node} |
|
|
isSelected={selectedNode?.id === node.id} |
|
|
onClick={() => handleNodeClick(node)} |
|
|
onDrag={handleNodeDrag} |
|
|
onDelete={deleteNode} |
|
|
/> |
|
|
))} |
|
|
|
|
|
{/* 连接线 */} |
|
|
<svg className="absolute inset-0 pointer-events-none"> |
|
|
{connections.map((connection, index) => { |
|
|
const startNode = canvasNodes.find(n => n.id === connection.from); |
|
|
const endNode = canvasNodes.find(n => n.id === connection.to); |
|
|
|
|
|
if (startNode && endNode) { |
|
|
return ( |
|
|
<line |
|
|
key={index} |
|
|
x1={startNode.x + 50} |
|
|
y1={startNode.y + 25} |
|
|
x2={endNode.x} |
|
|
y2={endNode.y + 25} |
|
|
stroke="#6b7280" |
|
|
strokeWidth="2" |
|
|
markerEnd="url(#arrowhead)" |
|
|
/> |
|
|
); |
|
|
} |
|
|
return null; |
|
|
})} |
|
|
|
|
|
{/* 箭头定义 */} |
|
|
<defs> |
|
|
<marker |
|
|
id="arrowhead" |
|
|
markerWidth="10" |
|
|
markerHeight="7" |
|
|
refX="9" |
|
|
refY="3.5" |
|
|
orient="auto" |
|
|
> |
|
|
<polygon |
|
|
points="0 0, 10 3.5, 0 7" |
|
|
fill="#6b7280" |
|
|
/> |
|
|
</marker> |
|
|
</defs> |
|
|
</svg> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
function WorkflowNode({ node, isSelected, onClick, onDrag, onDelete }: any) { |
|
|
const [isDragging, setIsDragging] = useState(false); |
|
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); |
|
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => { |
|
|
if (node.isFixed) return; |
|
|
|
|
|
setIsDragging(true); |
|
|
setDragStart({ |
|
|
x: e.clientX - node.x, |
|
|
y: e.clientY - node.y |
|
|
}); |
|
|
}; |
|
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => { |
|
|
if (!isDragging || node.isFixed) return; |
|
|
|
|
|
onDrag(node.id, e.clientX - dragStart.x, e.clientY - dragStart.y); |
|
|
}; |
|
|
|
|
|
const handleMouseUp = () => { |
|
|
setIsDragging(false); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div |
|
|
className={`absolute bg-white border-2 rounded-lg shadow-md cursor-pointer transition-all ${ |
|
|
isSelected ? 'border-blue-500 shadow-lg' : 'border-gray-200 hover:shadow-lg' |
|
|
} ${isDragging ? 'cursor-move' : ''}`} |
|
|
style={{ |
|
|
left: node.x, |
|
|
top: node.y, |
|
|
width: 100, |
|
|
height: 50, |
|
|
zIndex: isSelected ? 10 : 1 |
|
|
}} |
|
|
onClick={onClick} |
|
|
onMouseDown={handleMouseDown} |
|
|
onMouseMove={handleMouseMove} |
|
|
onMouseUp={handleMouseUp} |
|
|
> |
|
|
<div className="p-2 h-full flex flex-col items-center justify-center"> |
|
|
<div className="text-lg mb-1">{node.icon}</div> |
|
|
<div className="text-xs font-medium text-gray-700 text-center line-clamp-1"> |
|
|
{node.name} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* 连接点 */} |
|
|
{!node.isFixed && ( |
|
|
<> |
|
|
<div className="absolute -left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 bg-blue-500 rounded-full border-2 border-white cursor-pointer"></div> |
|
|
<div className="absolute -right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 bg-blue-500 rounded-full border-2 border-white cursor-pointer"></div> |
|
|
</> |
|
|
)} |
|
|
|
|
|
{node.isFixed && ( |
|
|
<div className="absolute -right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 bg-blue-500 rounded-full border-2 border-white cursor-pointer"></div> |
|
|
)} |
|
|
|
|
|
{} |
|
|
{!node.isFixed && isSelected && ( |
|
|
<button |
|
|
onClick={(e) => { |
|
|
e.stopPropagation(); |
|
|
onDelete(node.id); |
|
|
}} |
|
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors cursor-pointer" |
|
|
> |
|
|
<i className="ri-close-line text-xs"></i> |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
} |