|
|
@@ -21,6 +21,7 @@ import ReactFlow, {
|
|
|
getBezierPath,
|
|
|
getSmoothStepPath,
|
|
|
EdgeTypes,
|
|
|
+ type NodeChange,
|
|
|
} from 'reactflow';
|
|
|
import 'reactflow/dist/style.css';
|
|
|
import {
|
|
|
@@ -904,13 +905,15 @@ export default function ProcessDesigner() {
|
|
|
sessionStorage.setItem('lastActiveMenu', JSON.stringify(menuInfo));
|
|
|
navigate('/dashboard');
|
|
|
};
|
|
|
- const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
|
+ const [nodes, setNodes, onNodesChangeBase] = useNodesState([]);
|
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
|
|
|
|
// 保存初始状态,用于检测是否有未保存的更改
|
|
|
const initialNodesRef = useRef<Node[]>([]);
|
|
|
const initialEdgesRef = useRef<Edge[]>([]);
|
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
|
+ // 是否正处于「从服务器/缓存加载或恢复」阶段,此期间不把 onNodesChange/onEdgesChange 视为用户修改
|
|
|
+ const isLoadingFromServerOrRestoreRef = useRef(false);
|
|
|
|
|
|
// 从URL参数获取流程ID
|
|
|
const workflowId = React.useMemo(() => {
|
|
|
@@ -1202,24 +1205,12 @@ export default function ProcessDesigner() {
|
|
|
console.log('Edges 状态更新:', edges.length, edges);
|
|
|
}, [edges]);
|
|
|
|
|
|
- // 监听 nodes 和 edges 变化,实时保存到缓存,并检测是否有未保存的更改
|
|
|
+ // 仅在用户真正修改过(hasUnsavedChanges)时写入缓存,供异常退出后恢复使用
|
|
|
useEffect(() => {
|
|
|
- // 只有当有节点或边时才保存缓存(避免初始化时保存空数据)
|
|
|
- if (workflowId && (nodes.length > 0 || edges.length > 0)) {
|
|
|
+ if (workflowId && (nodes.length > 0 || edges.length > 0) && hasUnsavedChanges) {
|
|
|
saveWorkflowCache(workflowId, nodes, edges);
|
|
|
}
|
|
|
-
|
|
|
- // 检测是否有未保存的更改(只有在初始状态已设置后才检测)
|
|
|
- // 如果初始状态和当前状态都为空,则没有未保存的更改
|
|
|
- if ((initialNodesRef.current.length > 0 || initialEdgesRef.current.length > 0) ||
|
|
|
- (nodes.length > 0 || edges.length > 0)) {
|
|
|
- const hasChanges = checkUnsavedChanges();
|
|
|
- setHasUnsavedChanges(hasChanges);
|
|
|
- } else {
|
|
|
- // 如果初始状态和当前状态都为空,则没有未保存的更改
|
|
|
- setHasUnsavedChanges(false);
|
|
|
- }
|
|
|
- }, [nodes, edges, workflowId, saveWorkflowCache, checkUnsavedChanges]);
|
|
|
+ }, [nodes, edges, workflowId, hasUnsavedChanges, saveWorkflowCache]);
|
|
|
|
|
|
// 获取隔离方式字典数据
|
|
|
const getIsolationMethodDictList = async () => {
|
|
|
@@ -1381,6 +1372,9 @@ export default function ProcessDesigner() {
|
|
|
|
|
|
// 加载 JSON 数据到画布(可复用的函数)
|
|
|
const loadJsonToCanvas = useCallback((jsonString: string, updateHistory: boolean = false) => {
|
|
|
+ if (updateHistory) {
|
|
|
+ isLoadingFromServerOrRestoreRef.current = true;
|
|
|
+ }
|
|
|
try {
|
|
|
const data = JSON.parse(jsonString);
|
|
|
|
|
|
@@ -1505,10 +1499,17 @@ export default function ProcessDesigner() {
|
|
|
initialNodesRef.current = JSON.parse(JSON.stringify(importedNodes));
|
|
|
initialEdgesRef.current = JSON.parse(JSON.stringify(importedEdges));
|
|
|
setHasUnsavedChanges(false);
|
|
|
+ // 短时内不把后续的 onNodesChange/onEdgesChange 视为用户修改,避免保存后再次进入仍弹「未保存」框
|
|
|
+ setTimeout(() => {
|
|
|
+ isLoadingFromServerOrRestoreRef.current = false;
|
|
|
+ }, 200);
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
} catch (error: any) {
|
|
|
+ if (updateHistory) {
|
|
|
+ isLoadingFromServerOrRestoreRef.current = false;
|
|
|
+ }
|
|
|
console.error('加载 JSON 失败:', error);
|
|
|
message.error('JSON 格式错误:' + (error?.message || '解析失败'));
|
|
|
return false;
|
|
|
@@ -1544,15 +1545,17 @@ export default function ProcessDesigner() {
|
|
|
// 内容不同,说明用户有修改,弹出确认框
|
|
|
Modal.confirm({
|
|
|
title: '检测到未保存的设计',
|
|
|
- content: '检测到您有未保存的设计稿,需要恢复继续设计吗?',
|
|
|
+ content: '检测到您有未保存的设计稿(可能为异常退出),需要恢复继续设计吗?',
|
|
|
okText: '恢复',
|
|
|
- cancelText: '不用',
|
|
|
+ cancelText: '不恢复',
|
|
|
onOk: () => {
|
|
|
- // 使用缓存内容
|
|
|
+ // 使用缓存内容恢复;恢复后视为「未保存」以便点击返回时提示保存或放弃
|
|
|
loadJsonToCanvas(cachedContent, true);
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
},
|
|
|
onCancel: () => {
|
|
|
- // 使用接口内容
|
|
|
+ // 使用接口内容,并清除缓存避免下次再提示
|
|
|
+ clearWorkflowCache(workflowId);
|
|
|
loadJsonToCanvas(detail.content, true);
|
|
|
},
|
|
|
});
|
|
|
@@ -1573,7 +1576,7 @@ export default function ProcessDesigner() {
|
|
|
};
|
|
|
|
|
|
fetchWorkflowDetail();
|
|
|
- }, [workflowId, loadJsonToCanvas, loadWorkflowCache, compareJsonContent]);
|
|
|
+ }, [workflowId, loadJsonToCanvas, loadWorkflowCache, compareJsonContent, clearWorkflowCache]);
|
|
|
|
|
|
// 拖拽处理
|
|
|
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
|
|
@@ -1701,6 +1704,7 @@ export default function ProcessDesigner() {
|
|
|
setHistoryIndex(newHistory.length - 1);
|
|
|
return newEdges;
|
|
|
});
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
},
|
|
|
[setEdges, history, historyIndex, nodes]
|
|
|
);
|
|
|
@@ -1766,6 +1770,7 @@ export default function ProcessDesigner() {
|
|
|
|
|
|
return updatedEdges;
|
|
|
});
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
},
|
|
|
[history, historyIndex, nodes]
|
|
|
);
|
|
|
@@ -1897,6 +1902,7 @@ export default function ProcessDesigner() {
|
|
|
return newEdges;
|
|
|
});
|
|
|
setSelectedEdge(null);
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
}, [history, historyIndex, nodes]);
|
|
|
|
|
|
// 更新全局删除函数和 edges
|
|
|
@@ -1983,6 +1989,7 @@ export default function ProcessDesigner() {
|
|
|
if (selectedNode?.id === targetNodeId) {
|
|
|
setSelectedNode(null);
|
|
|
}
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
toast.success('节点已删除');
|
|
|
},
|
|
|
});
|
|
|
@@ -2033,6 +2040,7 @@ export default function ProcessDesigner() {
|
|
|
setNodes(prevState.nodes);
|
|
|
setEdges(prevState.edges);
|
|
|
setHistoryIndex(historyIndex - 1);
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
}
|
|
|
}, [history, historyIndex, setNodes, setEdges]);
|
|
|
|
|
|
@@ -2043,10 +2051,16 @@ export default function ProcessDesigner() {
|
|
|
setNodes(nextState.nodes);
|
|
|
setEdges(nextState.edges);
|
|
|
setHistoryIndex(historyIndex + 1);
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
}
|
|
|
}, [history, historyIndex, setNodes, setEdges]);
|
|
|
|
|
|
- // 拖放处理
|
|
|
+ // 网格步长,与画布 snapGrid 一致
|
|
|
+ const GRID_SIZE = 16;
|
|
|
+ // 水平线磁吸阈值:放置时与已有节点 Y 相差在此范围内则对齐到该节点水平线
|
|
|
+ const HORIZONTAL_SNAP_THRESHOLD = 40;
|
|
|
+
|
|
|
+ // 拖放处理:支持网格对齐 + 与前一个节点同一水平线磁吸
|
|
|
const onDrop = useCallback(
|
|
|
(event: React.DragEvent) => {
|
|
|
event.preventDefault();
|
|
|
@@ -2054,7 +2068,7 @@ export default function ProcessDesigner() {
|
|
|
const type = event.dataTransfer.getData('application/reactflow');
|
|
|
if (!type || !reactFlowInstance) return;
|
|
|
|
|
|
- const position = reactFlowInstance.screenToFlowPosition({
|
|
|
+ let position = reactFlowInstance.screenToFlowPosition({
|
|
|
x: event.clientX,
|
|
|
y: event.clientY,
|
|
|
});
|
|
|
@@ -2062,23 +2076,38 @@ export default function ProcessDesigner() {
|
|
|
const config = nodeConfigs.find(c => c.type === type);
|
|
|
const timestamp = Date.now();
|
|
|
const nodeId = `${type}-${timestamp}`;
|
|
|
- const nodeNumber = nodes.length + 1;
|
|
|
- const newNode: Node = {
|
|
|
- id: nodeId,
|
|
|
- type,
|
|
|
- position,
|
|
|
- data: {
|
|
|
- label: config?.label || type,
|
|
|
- type,
|
|
|
- nodeId: String(nodeNumber).padStart(3, '0'),
|
|
|
- smsTemplateCode: 'false',
|
|
|
- messageTemplateCode: 'false',
|
|
|
- emailTemplateCode: 'false',
|
|
|
- appTemplateCode: 'false',
|
|
|
- },
|
|
|
- };
|
|
|
|
|
|
setNodes((nds) => {
|
|
|
+ // 网格对齐
|
|
|
+ position = {
|
|
|
+ x: Math.round(position.x / GRID_SIZE) * GRID_SIZE,
|
|
|
+ y: Math.round(position.y / GRID_SIZE) * GRID_SIZE,
|
|
|
+ };
|
|
|
+ // 水平线磁吸:若有已有节点,且当前 Y 与最后一个节点 Y 接近,则对齐到同一水平线
|
|
|
+ if (nds.length > 0) {
|
|
|
+ const lastNode = nds[nds.length - 1];
|
|
|
+ const refY = lastNode.position.y;
|
|
|
+ if (Math.abs(position.y - refY) <= HORIZONTAL_SNAP_THRESHOLD) {
|
|
|
+ position.y = refY;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const nodeNumber = nds.length + 1;
|
|
|
+ const newNode: Node = {
|
|
|
+ id: nodeId,
|
|
|
+ type,
|
|
|
+ position: { ...position },
|
|
|
+ data: {
|
|
|
+ label: config?.label || type,
|
|
|
+ type,
|
|
|
+ nodeId: String(nodeNumber).padStart(3, '0'),
|
|
|
+ smsTemplateCode: 'false',
|
|
|
+ messageTemplateCode: 'false',
|
|
|
+ emailTemplateCode: 'false',
|
|
|
+ appTemplateCode: 'false',
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
const newNodes = nds.concat(newNode);
|
|
|
// 保存历史
|
|
|
const newHistory = history.slice(0, historyIndex + 1);
|
|
|
@@ -2087,8 +2116,9 @@ export default function ProcessDesigner() {
|
|
|
setHistoryIndex(newHistory.length - 1);
|
|
|
return newNodes;
|
|
|
});
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
},
|
|
|
- [reactFlowInstance, setNodes, history, historyIndex, edges, nodes.length]
|
|
|
+ [reactFlowInstance, setNodes, history, historyIndex, edges]
|
|
|
);
|
|
|
|
|
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
|
|
@@ -2096,6 +2126,42 @@ export default function ProcessDesigner() {
|
|
|
event.dataTransfer.dropEffect = 'move';
|
|
|
}, []);
|
|
|
|
|
|
+ // 拖拽节点时的水平线磁吸:与画布上其他节点 Y 接近时对齐到同一水平线;节点变更时标记为已修改
|
|
|
+ const onNodesChange = useCallback(
|
|
|
+ (changes: NodeChange[]) => {
|
|
|
+ const modified = changes.map((ch) => {
|
|
|
+ if (ch.type === 'position' && ch.position != null && ch.dragging) {
|
|
|
+ const otherNodes = nodes.filter((n) => n.id !== ch.id);
|
|
|
+ let newY = ch.position!.y;
|
|
|
+ for (const n of otherNodes) {
|
|
|
+ if (Math.abs(ch.position!.y - n.position.y) <= HORIZONTAL_SNAP_THRESHOLD) {
|
|
|
+ newY = n.position.y;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { ...ch, position: { ...ch.position!, y: newY } };
|
|
|
+ }
|
|
|
+ return ch;
|
|
|
+ });
|
|
|
+ if (!isLoadingFromServerOrRestoreRef.current) {
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
+ }
|
|
|
+ onNodesChangeBase(modified);
|
|
|
+ },
|
|
|
+ [nodes, onNodesChangeBase]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 边变更时标记为已修改(仅用户操作会触发 onEdgesChange);加载/恢复阶段不标记
|
|
|
+ const onEdgesChangeWithDirty = useCallback(
|
|
|
+ (changes: Parameters<typeof onEdgesChange>[0]) => {
|
|
|
+ if (!isLoadingFromServerOrRestoreRef.current) {
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
+ }
|
|
|
+ onEdgesChange(changes);
|
|
|
+ },
|
|
|
+ [onEdgesChange]
|
|
|
+ );
|
|
|
+
|
|
|
// 更新节点配置
|
|
|
const updateNodeConfig = useCallback(() => {
|
|
|
if (!selectedNode) return;
|
|
|
@@ -2144,6 +2210,7 @@ export default function ProcessDesigner() {
|
|
|
setHistoryIndex(newHistory.length - 1);
|
|
|
return updatedNodes;
|
|
|
});
|
|
|
+ setHasUnsavedChanges(true);
|
|
|
toast.success('节点配置已保存');
|
|
|
}, [selectedNode, nodeConfig, setNodes, setSelectedNode, history, historyIndex, edges]);
|
|
|
|
|
|
@@ -2388,14 +2455,14 @@ export default function ProcessDesigner() {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- // 返回
|
|
|
+ // 返回(仅在有未保存修改时弹框:保存 或 放弃并返回)
|
|
|
const handleBack = () => {
|
|
|
- // 检查是否有未保存的更改
|
|
|
if (hasUnsavedChanges) {
|
|
|
- Modal.warning({
|
|
|
- title: '提示',
|
|
|
- content: '检测到您有未保存的更改,请先保存后再返回。',
|
|
|
+ Modal.confirm({
|
|
|
+ title: '未保存的更改',
|
|
|
+ content: '检测到您有未保存的更改,请先保存后再返回,或放弃更改并返回。',
|
|
|
okText: '保存',
|
|
|
+ cancelText: '放弃并返回',
|
|
|
maskClosable: false,
|
|
|
closable: false,
|
|
|
onOk: async () => {
|
|
|
@@ -2509,6 +2576,11 @@ export default function ProcessDesigner() {
|
|
|
message.error(error?.message || '流程保存失败');
|
|
|
}
|
|
|
},
|
|
|
+ onCancel: () => {
|
|
|
+ // 放弃并返回:清除缓存后跳转到流程模板列表
|
|
|
+ clearWorkflowCache(workflowId);
|
|
|
+ backToProcessTemplateMenu();
|
|
|
+ },
|
|
|
});
|
|
|
} else {
|
|
|
// 没有未保存的更改,直接返回
|
|
|
@@ -2980,7 +3052,7 @@ export default function ProcessDesigner() {
|
|
|
nodes={nodes}
|
|
|
edges={edges}
|
|
|
onNodesChange={onNodesChange}
|
|
|
- onEdgesChange={onEdgesChange}
|
|
|
+ onEdgesChange={onEdgesChangeWithDirty}
|
|
|
onConnect={onConnect}
|
|
|
onEdgeUpdate={onEdgeUpdate}
|
|
|
isValidConnection={isValidConnection}
|
|
|
@@ -2997,6 +3069,9 @@ export default function ProcessDesigner() {
|
|
|
onInit={setReactFlowInstance}
|
|
|
onMove={(_, viewport) => setZoom(viewport.zoom)}
|
|
|
connectionMode={ConnectionMode.Loose}
|
|
|
+ connectionRadius={40}
|
|
|
+ snapToGrid={true}
|
|
|
+ snapGrid={[16, 16]}
|
|
|
defaultEdgeOptions={{
|
|
|
style: { strokeWidth: 2, stroke: '#000000' },
|
|
|
markerStart: {
|