From ea5ca6601adaa0b8dd89f5f22eab9af47990ec71 Mon Sep 17 00:00:00 2001 From: dengqichen Date: Tue, 21 Oct 2025 10:38:17 +0800 Subject: [PATCH] 1 --- .../Workflow2/Design/hooks/useHistory.ts | 117 ++++++++++++ frontend/src/pages/Workflow2/Design/index.tsx | 167 ++++++++++++++++-- 2 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/Workflow2/Design/hooks/useHistory.ts diff --git a/frontend/src/pages/Workflow2/Design/hooks/useHistory.ts b/frontend/src/pages/Workflow2/Design/hooks/useHistory.ts new file mode 100644 index 00000000..0ab6ade4 --- /dev/null +++ b/frontend/src/pages/Workflow2/Design/hooks/useHistory.ts @@ -0,0 +1,117 @@ +import { useState, useCallback, useRef } from 'react'; +import type { FlowNode, FlowEdge } from '../types'; + +/** + * 历史记录项 + */ +interface HistoryState { + nodes: FlowNode[]; + edges: FlowEdge[]; +} + +/** + * 历史记录管理Hook + * 实现撤销/重做功能 + */ +export const useHistory = () => { + const [history, setHistory] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); + const isRecording = useRef(true); + + /** + * 记录当前状态到历史 + */ + const record = useCallback((nodes: FlowNode[], edges: FlowEdge[]) => { + if (!isRecording.current) return; + + setHistory(prev => { + // 移除当前索引之后的所有历史 + const newHistory = prev.slice(0, currentIndex + 1); + // 添加新状态 + newHistory.push({ + nodes: JSON.parse(JSON.stringify(nodes)), + edges: JSON.parse(JSON.stringify(edges)) + }); + // 限制历史记录数量(最多50条) + if (newHistory.length > 50) { + newHistory.shift(); + setCurrentIndex(prev => prev); + } else { + setCurrentIndex(newHistory.length - 1); + } + return newHistory; + }); + }, [currentIndex]); + + /** + * 撤销 + */ + const undo = useCallback((): HistoryState | null => { + if (currentIndex <= 0) { + return null; + } + const newIndex = currentIndex - 1; + setCurrentIndex(newIndex); + return history[newIndex]; + }, [currentIndex, history]); + + /** + * 重做 + */ + const redo = useCallback((): HistoryState | null => { + if (currentIndex >= history.length - 1) { + return null; + } + const newIndex = currentIndex + 1; + setCurrentIndex(newIndex); + return history[newIndex]; + }, [currentIndex, history]); + + /** + * 是否可以撤销 + */ + const canUndo = useCallback(() => { + return currentIndex > 0; + }, [currentIndex]); + + /** + * 是否可以重做 + */ + const canRedo = useCallback(() => { + return currentIndex < history.length - 1; + }, [currentIndex, history.length]); + + /** + * 暂停记录(用于批量操作时) + */ + const pauseRecording = useCallback(() => { + isRecording.current = false; + }, []); + + /** + * 恢复记录 + */ + const resumeRecording = useCallback(() => { + isRecording.current = true; + }, []); + + /** + * 清空历史 + */ + const clear = useCallback(() => { + setHistory([]); + setCurrentIndex(-1); + }, []); + + return { + record, + undo, + redo, + canUndo: canUndo(), + canRedo: canRedo(), + pauseRecording, + resumeRecording, + clear + }; +}; + diff --git a/frontend/src/pages/Workflow2/Design/index.tsx b/frontend/src/pages/Workflow2/Design/index.tsx index 73e952a6..5c1b37ee 100644 --- a/frontend/src/pages/Workflow2/Design/index.tsx +++ b/frontend/src/pages/Workflow2/Design/index.tsx @@ -13,6 +13,7 @@ import type { WorkflowNodeDefinition } from './nodes/types'; import { NodeType } from './types'; import { useWorkflowSave } from './hooks/useWorkflowSave'; import { useWorkflowLoad } from './hooks/useWorkflowLoad'; +import { useHistory } from './hooks/useHistory'; // 样式 import '@xyflow/react/dist/style.css'; @@ -51,6 +52,12 @@ const WorkflowDesignInner: React.FC = () => { // 保存和加载hooks const { hasUnsavedChanges, saveWorkflow, markUnsaved } = useWorkflowSave(); const { workflowDefinition, loadWorkflow } = useWorkflowLoad(); + + // 历史记录管理 + const history = useHistory(); + + // 剪贴板(用于复制粘贴) + const clipboard = useRef<{ nodes: FlowNode[]; edges: FlowEdge[] } | null>(null); // 加载工作流数据 useEffect(() => { @@ -184,25 +191,109 @@ const WorkflowDesignInner: React.FC = () => { navigate('/workflow2/definition'); }, [navigate]); + // 撤销操作 const handleUndo = useCallback(() => { - console.log('撤销操作'); - message.info('撤销功能开发中...'); - }, []); + const state = history.undo(); + if (state) { + history.pauseRecording(); + setNodes(state.nodes); + setEdges(state.edges); + setTimeout(() => history.resumeRecording(), 100); + message.success('已撤销'); + } else { + message.info('没有可撤销的操作'); + } + }, [history, setNodes, setEdges]); + // 重做操作 const handleRedo = useCallback(() => { - console.log('重做操作'); - message.info('重做功能开发中...'); - }, []); + const state = history.redo(); + if (state) { + history.pauseRecording(); + setNodes(state.nodes); + setEdges(state.edges); + setTimeout(() => history.resumeRecording(), 100); + message.success('已重做'); + } else { + message.info('没有可重做的操作'); + } + }, [history, setNodes, setEdges]); + // 复制选中的节点和边 const handleCopy = useCallback(() => { const selectedNodes = getNodes().filter(node => node.selected); + const selectedEdges = getEdges().filter(edge => { + // 只复制两端都被选中的边 + return selectedNodes.some(n => n.id === edge.source) && + selectedNodes.some(n => n.id === edge.target); + }); + if (selectedNodes.length > 0) { - console.log('复制节点:', selectedNodes); + clipboard.current = { + nodes: JSON.parse(JSON.stringify(selectedNodes)), + edges: JSON.parse(JSON.stringify(selectedEdges)) + }; message.success(`已复制 ${selectedNodes.length} 个节点`); } else { message.warning('请先选择要复制的节点'); } - }, [getNodes]); + }, [getNodes, getEdges]); + + // 粘贴节点 + const handlePaste = useCallback(() => { + if (!clipboard.current || clipboard.current.nodes.length === 0) { + message.info('剪贴板为空'); + return; + } + + const { nodes: copiedNodes, edges: copiedEdges } = clipboard.current; + const offset = 50; // 粘贴偏移量 + const idMap = new Map(); + + // 创建新节点(带偏移) + const newNodes = copiedNodes.map(node => { + const newId = `${node.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + idMap.set(node.id, newId); + + return { + ...node, + id: newId, + position: { + x: node.position.x + offset, + y: node.position.y + offset + }, + selected: true + }; + }); + + // 创建新边(更新source和target为新ID) + const newEdges = copiedEdges.map(edge => { + const newSource = idMap.get(edge.source); + const newTarget = idMap.get(edge.target); + + if (!newSource || !newTarget) return null; + + return { + ...edge, + id: `e${newSource}-${newTarget}`, + source: newSource, + target: newTarget, + selected: true + }; + }).filter(edge => edge !== null) as FlowEdge[]; + + // 取消其他元素的选中状态 + setNodes(nodes => [ + ...nodes.map(n => ({ ...n, selected: false })), + ...newNodes + ]); + setEdges(edges => [ + ...edges.map(e => ({ ...e, selected: false })), + ...newEdges + ]); + + message.success(`已粘贴 ${newNodes.length} 个节点`); + }, [setNodes, setEdges]); const handleDelete = useCallback(() => { const selectedNodes = getNodes().filter(node => node.selected); @@ -212,10 +303,11 @@ const WorkflowDesignInner: React.FC = () => { setNodes(nodes => nodes.filter(node => !node.selected)); setEdges(edges => edges.filter(edge => !edge.selected)); message.success(`已删除 ${selectedNodes.length} 个节点和 ${selectedEdges.length} 条连接`); + markUnsaved(); } else { message.warning('请先选择要删除的元素'); } - }, [getNodes, getEdges, setNodes, setEdges]); + }, [getNodes, getEdges, setNodes, setEdges, markUnsaved]); const handleSelectAll = useCallback(() => { setNodes(nodes => nodes.map(node => ({ ...node, selected: true }))); @@ -372,6 +464,59 @@ const WorkflowDesignInner: React.FC = () => { setCurrentZoom(zoom); }, [getZoom]); + // 监听节点和边的变化,记录到历史 + useEffect(() => { + const nodes = getNodes() as FlowNode[]; + const edges = getEdges() as FlowEdge[]; + + // 只在有节点或边时记录(避免空状态) + if (nodes.length > 0 || edges.length > 0) { + history.record(nodes, edges); + } + }, [getNodes, getEdges, history]); + + // 键盘快捷键支持 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const ctrlKey = isMac ? e.metaKey : e.ctrlKey; + + // Ctrl+Z / Cmd+Z - 撤销 + if (ctrlKey && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } + // Ctrl+Shift+Z / Cmd+Shift+Z - 重做 + else if (ctrlKey && e.key === 'z' && e.shiftKey) { + e.preventDefault(); + handleRedo(); + } + // Ctrl+C / Cmd+C - 复制 + else if (ctrlKey && e.key === 'c') { + e.preventDefault(); + handleCopy(); + } + // Ctrl+V / Cmd+V - 粘贴 + else if (ctrlKey && e.key === 'v') { + e.preventDefault(); + handlePaste(); + } + // Ctrl+A / Cmd+A - 全选 + else if (ctrlKey && e.key === 'a') { + e.preventDefault(); + handleSelectAll(); + } + // Delete / Backspace - 删除 + else if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + handleDelete(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete]); + return (
{ onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onFitView={handleFitView} - canUndo={false} - canRedo={false} + canUndo={history.canUndo} + canRedo={history.canRedo} zoom={currentZoom} />