1
This commit is contained in:
parent
051709ed2a
commit
ea5ca6601a
117
frontend/src/pages/Workflow2/Design/hooks/useHistory.ts
Normal file
117
frontend/src/pages/Workflow2/Design/hooks/useHistory.ts
Normal file
@ -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<HistoryState[]>([]);
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@ -13,6 +13,7 @@ import type { WorkflowNodeDefinition } from './nodes/types';
|
|||||||
import { NodeType } from './types';
|
import { NodeType } from './types';
|
||||||
import { useWorkflowSave } from './hooks/useWorkflowSave';
|
import { useWorkflowSave } from './hooks/useWorkflowSave';
|
||||||
import { useWorkflowLoad } from './hooks/useWorkflowLoad';
|
import { useWorkflowLoad } from './hooks/useWorkflowLoad';
|
||||||
|
import { useHistory } from './hooks/useHistory';
|
||||||
|
|
||||||
// 样式
|
// 样式
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
@ -51,6 +52,12 @@ const WorkflowDesignInner: React.FC = () => {
|
|||||||
// 保存和加载hooks
|
// 保存和加载hooks
|
||||||
const { hasUnsavedChanges, saveWorkflow, markUnsaved } = useWorkflowSave();
|
const { hasUnsavedChanges, saveWorkflow, markUnsaved } = useWorkflowSave();
|
||||||
const { workflowDefinition, loadWorkflow } = useWorkflowLoad();
|
const { workflowDefinition, loadWorkflow } = useWorkflowLoad();
|
||||||
|
|
||||||
|
// 历史记录管理
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
// 剪贴板(用于复制粘贴)
|
||||||
|
const clipboard = useRef<{ nodes: FlowNode[]; edges: FlowEdge[] } | null>(null);
|
||||||
|
|
||||||
// 加载工作流数据
|
// 加载工作流数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -184,25 +191,109 @@ const WorkflowDesignInner: React.FC = () => {
|
|||||||
navigate('/workflow2/definition');
|
navigate('/workflow2/definition');
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
// 撤销操作
|
||||||
const handleUndo = useCallback(() => {
|
const handleUndo = useCallback(() => {
|
||||||
console.log('撤销操作');
|
const state = history.undo();
|
||||||
message.info('撤销功能开发中...');
|
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(() => {
|
const handleRedo = useCallback(() => {
|
||||||
console.log('重做操作');
|
const state = history.redo();
|
||||||
message.info('重做功能开发中...');
|
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 handleCopy = useCallback(() => {
|
||||||
const selectedNodes = getNodes().filter(node => node.selected);
|
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) {
|
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} 个节点`);
|
message.success(`已复制 ${selectedNodes.length} 个节点`);
|
||||||
} else {
|
} else {
|
||||||
message.warning('请先选择要复制的节点');
|
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<string, string>();
|
||||||
|
|
||||||
|
// 创建新节点(带偏移)
|
||||||
|
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 handleDelete = useCallback(() => {
|
||||||
const selectedNodes = getNodes().filter(node => node.selected);
|
const selectedNodes = getNodes().filter(node => node.selected);
|
||||||
@ -212,10 +303,11 @@ const WorkflowDesignInner: React.FC = () => {
|
|||||||
setNodes(nodes => nodes.filter(node => !node.selected));
|
setNodes(nodes => nodes.filter(node => !node.selected));
|
||||||
setEdges(edges => edges.filter(edge => !edge.selected));
|
setEdges(edges => edges.filter(edge => !edge.selected));
|
||||||
message.success(`已删除 ${selectedNodes.length} 个节点和 ${selectedEdges.length} 条连接`);
|
message.success(`已删除 ${selectedNodes.length} 个节点和 ${selectedEdges.length} 条连接`);
|
||||||
|
markUnsaved();
|
||||||
} else {
|
} else {
|
||||||
message.warning('请先选择要删除的元素');
|
message.warning('请先选择要删除的元素');
|
||||||
}
|
}
|
||||||
}, [getNodes, getEdges, setNodes, setEdges]);
|
}, [getNodes, getEdges, setNodes, setEdges, markUnsaved]);
|
||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
||||||
@ -372,6 +464,59 @@ const WorkflowDesignInner: React.FC = () => {
|
|||||||
setCurrentZoom(zoom);
|
setCurrentZoom(zoom);
|
||||||
}, [getZoom]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="workflow-design-container"
|
className="workflow-design-container"
|
||||||
@ -393,8 +538,8 @@ const WorkflowDesignInner: React.FC = () => {
|
|||||||
onZoomIn={handleZoomIn}
|
onZoomIn={handleZoomIn}
|
||||||
onZoomOut={handleZoomOut}
|
onZoomOut={handleZoomOut}
|
||||||
onFitView={handleFitView}
|
onFitView={handleFitView}
|
||||||
canUndo={false}
|
canUndo={history.canUndo}
|
||||||
canRedo={false}
|
canRedo={history.canRedo}
|
||||||
zoom={currentZoom}
|
zoom={currentZoom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user