yichael hace 4 meses
padre
commit
0d6008516b

+ 29 - 1
main-js/workflow.js

@@ -224,6 +224,29 @@ export async function saveBlueprintJson(folderName, blueprintData) {
   }
   }
 }
 }
 
 
+/**
+ * 保存 processing.json 文件到现有工作流文件夹
+ * @param {string} folderName - 工作流文件夹名称
+ * @param {Object} workflowData - 工作流数据
+ * @returns {Promise<{success: boolean, error?: string}>}
+ */
+export async function saveProcessingJson(folderName, workflowData) {
+  try {
+    if (!folderName || typeof folderName !== 'string') {
+      return { success: false, error: '文件夹名称无效' };
+    }
+
+    const workflowPath = join(__dirname, '..', 'static', 'processing', folderName);
+    const jsonPath = join(workflowPath, 'processing.json');
+    const jsonContent = JSON.stringify(workflowData, null, '\t');
+    
+    await writeFile(jsonPath, jsonContent, 'utf-8');
+    return { success: true };
+  } catch (error) {
+    return { success: false, error: error.message };
+  }
+}
+
 /**
 /**
  * 注册工作流管理相关的 IPC handlers
  * 注册工作流管理相关的 IPC handlers
  */
  */
@@ -238,7 +261,12 @@ export function registerIpcHandlers() {
     return await readProcessingJson(folderName);
     return await readProcessingJson(folderName);
   });
   });
 
 
-  // 保存工作流
+  // 保存 processing.json 文件到现有工作流文件夹
+  ipcMain.handle('save-processing-json', async (event, folderName, workflowData) => {
+    return await saveProcessingJson(folderName, workflowData);
+  });
+
+  // 保存工作流(创建新文件夹)
   ipcMain.handle('save-workflow', async (event, workflowJson, imagesData) => {
   ipcMain.handle('save-workflow', async (event, workflowJson, imagesData) => {
     return await saveWorkflow(workflowJson, imagesData);
     return await saveWorkflow(workflowJson, imagesData);
   });
   });

+ 2 - 0
preload.cjs

@@ -35,6 +35,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
   cropAndSaveImage: (imagePath, x, y, width, height, savePath) => ipcRenderer.invoke('crop-and-save-image', imagePath, x, y, width, height, savePath),
   cropAndSaveImage: (imagePath, x, y, width, height, savePath) => ipcRenderer.invoke('crop-and-save-image', imagePath, x, y, width, height, savePath),
   // 读取 processing.json 文件
   // 读取 processing.json 文件
   readProcessingJson: (folderName) => ipcRenderer.invoke('read-processing-json', folderName),
   readProcessingJson: (folderName) => ipcRenderer.invoke('read-processing-json', folderName),
+  // 保存 processing.json 文件到现有工作流文件夹
+  saveProcessingJson: (folderName, workflowData) => ipcRenderer.invoke('save-processing-json', folderName, workflowData),
   // 文字识别并获取坐标
   // 文字识别并获取坐标
   findTextAndGetCoordinate: (ipPort, targetText) => ipcRenderer.invoke('find-text-and-get-coordinate', ipPort, targetText),
   findTextAndGetCoordinate: (ipPort, targetText) => ipcRenderer.invoke('find-text-and-get-coordinate', ipPort, targetText),
   // OCR识别最后一条消息(兼容旧API)
   // OCR识别最后一条消息(兼容旧API)

+ 252 - 76
src/pages/blueprint/blueprint-core.js

@@ -87,6 +87,7 @@ export function useBlueprint(workflowName = null) {
       const varReferences = analyzeVariableReferences(blueprint.nodes);
       const varReferences = analyzeVariableReferences(blueprint.nodes);
       
       
       // 创建变量节点(根据引用类型创建 Get 或 Set 节点)
       // 创建变量节点(根据引用类型创建 Get 或 Set 节点)
+      // 使用固定的 ID 格式,确保每次加载时 ID 一致
       const variableNodes = [];
       const variableNodes = [];
       let yOffset = 200;
       let yOffset = 200;
       varNames.forEach((varName) => {
       varNames.forEach((varName) => {
@@ -95,21 +96,26 @@ export function useBlueprint(workflowName = null) {
         
         
         // 如果变量被用作输入参数,创建 Get 节点
         // 如果变量被用作输入参数,创建 Get 节点
         if (refs.asInput) {
         if (refs.asInput) {
-          const getNode = createVariableNode(varName, varValue, 50, yOffset, 'get');
+          // 使用固定的 ID 格式:var_get_变量名
+          const fixedId = `var_get_${varName}`;
+          const getNode = createVariableNode(varName, varValue, 50, yOffset, 'get', fixedId);
           variableNodes.push(getNode);
           variableNodes.push(getNode);
           yOffset += 80;
           yOffset += 80;
         }
         }
         
         
         // 如果变量被用作输出参数,创建 Set 节点
         // 如果变量被用作输出参数,创建 Set 节点
         if (refs.asOutput) {
         if (refs.asOutput) {
-          const setNode = createVariableNode(varName, varValue, 50, yOffset, 'set');
+          // 使用固定的 ID 格式:var_set_变量名
+          const fixedId = `var_set_${varName}`;
+          const setNode = createVariableNode(varName, varValue, 50, yOffset, 'set', fixedId);
           variableNodes.push(setNode);
           variableNodes.push(setNode);
           yOffset += 80;
           yOffset += 80;
         }
         }
         
         
         // 如果变量没有被引用,默认创建一个 Get 节点
         // 如果变量没有被引用,默认创建一个 Get 节点
         if (!refs.asInput && !refs.asOutput) {
         if (!refs.asInput && !refs.asOutput) {
-          const getNode = createVariableNode(varName, varValue, 50, yOffset, 'get');
+          const fixedId = `var_get_${varName}`;
+          const getNode = createVariableNode(varName, varValue, 50, yOffset, 'get', fixedId);
           variableNodes.push(getNode);
           variableNodes.push(getNode);
           yOffset += 80;
           yOffset += 80;
         }
         }
@@ -126,8 +132,9 @@ export function useBlueprint(workflowName = null) {
       console.log('blueprint.nodes 是否存在:', !!blueprint.nodes);
       console.log('blueprint.nodes 是否存在:', !!blueprint.nodes);
       console.log('blueprint.nodes 长度:', blueprint.nodes?.length);
       console.log('blueprint.nodes 长度:', blueprint.nodes?.length);
       
       
-      // 加载位置信息
+      // 加载位置信息和连线信息
       let nodePositions = null;
       let nodePositions = null;
+      let savedConnections = null;
       if (window.electronAPI && window.electronAPI.readBlueprintJson) {
       if (window.electronAPI && window.electronAPI.readBlueprintJson) {
         try {
         try {
           const bpData = await window.electronAPI.readBlueprintJson(folderName);
           const bpData = await window.electronAPI.readBlueprintJson(folderName);
@@ -136,6 +143,10 @@ export function useBlueprint(workflowName = null) {
             nodePositions = bpData.nodePositions;
             nodePositions = bpData.nodePositions;
             console.log('nodePositions:', nodePositions);
             console.log('nodePositions:', nodePositions);
           }
           }
+          if (bpData && bpData.connections) {
+            savedConnections = bpData.connections;
+            console.log('savedConnections:', savedConnections);
+          }
         } catch (error) {
         } catch (error) {
           console.log('读取位置信息失败:', error);
           console.log('读取位置信息失败:', error);
           // 读取位置信息失败,忽略错误
           // 读取位置信息失败,忽略错误
@@ -182,79 +193,24 @@ export function useBlueprint(workflowName = null) {
           needsLayout = true;
           needsLayout = true;
         }
         }
         
         
-        // 如果需要自动布局
+        // 不再自动布局,用户可以手动点击"整理布局"按钮
+        // 如果没有位置信息,给节点一个默认位置(简单排列)
         if (needsLayout) {
         if (needsLayout) {
-          setIsLoadingLayout(true);
-          setLayoutProgress(0);
-          setLayoutMessage('准备开始...');
-          
-          // 使用 setTimeout 确保 UI 更新后再执行布局(让加载提示显示出来)
-          await new Promise(resolve => setTimeout(resolve, 50));
-          
-          try {
-            // 只对流程节点进行自动布局,变量节点保持原位置
-            const processNodes = finalNodes.filter(n => n.type !== 'variable');
-            const varNodes = finalNodes.filter(n => n.type === 'variable');
-            console.log('开始自动布局,流程节点数量:', processNodes.length, '变量节点数量:', varNodes.length);
-            console.log('流程节点ID列表:', processNodes.map(n => n.id));
-            // 使用进度回调(同步版本,更快)
-            const layoutedProcessNodes = autoLayoutBlueprint(
-              processNodes, 
-              blueprint.connections || [],
-              (progress, message) => {
-                console.log(`布局进度: ${progress}% - ${message}`);
-                setLayoutProgress(progress);
-                setLayoutMessage(message);
-              }
-            );
-            // 合并布局后的流程节点和变量节点
-            finalNodes = [...layoutedProcessNodes, ...varNodes];
-            console.log('自动布局完成,finalNodes 数量:', finalNodes.length);
-            if (finalNodes.length > 0) {
-              console.log('布局后第一个节点:', finalNodes[0]);
-              const processNodes = finalNodes.filter(n => n.type !== 'variable');
-              if (processNodes.length > 0) {
-                console.log('布局后流程节点坐标范围:', {
-                  minX: Math.min(...processNodes.map(n => n.x || 0)),
-                  maxX: Math.max(...processNodes.map(n => n.x || 0)),
-                  minY: Math.min(...processNodes.map(n => n.y || 0)),
-                  maxY: Math.max(...processNodes.map(n => n.y || 0))
-                });
-              }
-            }
-            
-            setLayoutProgress(90);
-            setLayoutMessage('正在保存位置信息...');
-            
-            // 布局后保存位置信息
-            if (window.electronAPI && window.electronAPI.saveBlueprintJson) {
-              try {
-                const nodePositions = {};
-                finalNodes.forEach(node => {
-                  nodePositions[node.id] = {
-                    x: node.x,
-                    y: node.y
-                  };
-                });
-                await window.electronAPI.saveBlueprintJson(folderName, { nodePositions });
-                setLayoutProgress(100);
-                setLayoutMessage('完成');
-              } catch (error) {
-                setLayoutMessage('保存位置信息失败');
+          // 简单地给没有位置的节点分配默认位置
+          let defaultX = 150;
+          let defaultY = 100;
+          finalNodes = finalNodes.map(node => {
+            if (typeof node.x !== 'number' || typeof node.y !== 'number') {
+              const newNode = { ...node, x: defaultX, y: defaultY };
+              defaultX += 250;
+              if (defaultX > 1000) {
+                defaultX = 150;
+                defaultY += 200;
               }
               }
+              return newNode;
             }
             }
-          } catch (error) {
-            console.error('自动布局出错:', error);
-            console.error('错误堆栈:', error.stack);
-            // 如果布局失败,使用原始节点
-            finalNodes = blueprint.nodes;
-          } finally {
-            // 延迟一点再隐藏,让用户看到100%完成
-            await new Promise(resolve => setTimeout(resolve, 300));
-            setIsLoadingLayout(false);
-            setLayoutProgress(0);
-            setLayoutMessage('');
-          }
+            return node;
+          });
         }
         }
         
         
         // 调试:检查节点数据
         // 调试:检查节点数据
@@ -270,7 +226,15 @@ export function useBlueprint(workflowName = null) {
         }
         }
         
         
         setNodes(finalNodes);
         setNodes(finalNodes);
-        setConnections(blueprint.connections || []);
+        
+        // 优先使用保存的连线信息,否则使用从工作流解析的连线
+        if (savedConnections && savedConnections.length > 0) {
+          console.log('使用保存的连线信息,数量:', savedConnections.length);
+          setConnections(savedConnections);
+        } else {
+          console.log('使用解析的连线信息,数量:', blueprint.connections?.length || 0);
+          setConnections(blueprint.connections || []);
+        }
         setIsDirty(false); // 加载完成后,标记为未修改
         setIsDirty(false); // 加载完成后,标记为未修改
       } else {
       } else {
         console.log('blueprint.nodes 为空或长度为 0,设置空数组');
         console.log('blueprint.nodes 为空或长度为 0,设置空数组');
@@ -300,30 +264,80 @@ export function useBlueprint(workflowName = null) {
    * 保存工作流
    * 保存工作流
    */
    */
   const saveWorkflow = useCallback(async (folderName) => {
   const saveWorkflow = useCallback(async (folderName) => {
+    console.log('saveWorkflow 被调用,folderName:', folderName);
     if (!folderName) {
     if (!folderName) {
+      console.error('saveWorkflow: folderName 为空');
       return { success: false, error: '工作流名称为空' };
       return { success: false, error: '工作流名称为空' };
     }
     }
     
     
     try {
     try {
+      console.log('saveWorkflow: 开始转换蓝图到工作流,nodes:', nodes.length, 'connections:', connections.length);
       const workflow = blueprintToWorkflow({ nodes, connections });
       const workflow = blueprintToWorkflow({ nodes, connections });
+      console.log('saveWorkflow: 转换完成,workflow:', workflow);
       const fullWorkflow = {
       const fullWorkflow = {
         ...workflow,
         ...workflow,
         variables: variables || {}
         variables: variables || {}
       };
       };
+      console.log('saveWorkflow: fullWorkflow:', fullWorkflow);
       
       
       if (!window.electronAPI || !window.electronAPI.saveProcessingJson) {
       if (!window.electronAPI || !window.electronAPI.saveProcessingJson) {
+        console.error('saveWorkflow: electronAPI 或 saveProcessingJson 不可用');
         return { success: false, error: '保存 API 不可用' };
         return { success: false, error: '保存 API 不可用' };
       }
       }
       
       
+      console.log('saveWorkflow: 调用 electronAPI.saveProcessingJson');
       const result = await window.electronAPI.saveProcessingJson(folderName, fullWorkflow);
       const result = await window.electronAPI.saveProcessingJson(folderName, fullWorkflow);
+      console.log('saveWorkflow: 保存结果:', result);
+      
+      // 同时保存节点位置和连线信息到 bp.json
+      // 注意:连线坐标需要从 DOM 获取,这里只保存连线的基本信息
+      // 实际的坐标保存由 canvas 组件通过 saveConnectionCoordinates 完成
+      if (result && result.success !== false && window.electronAPI.saveBlueprintJson) {
+        try {
+          // 保存节点位置
+          const nodePositions = {};
+          nodes.forEach(node => {
+            nodePositions[node.id] = {
+              x: node.x,
+              y: node.y
+            };
+          });
+          
+          // 保存连线信息(基本信息,坐标由 canvas 组件补充)
+          const connectionData = connections.map(conn => ({
+            id: conn.id,
+            source: conn.source,
+            target: conn.target,
+            sourcePort: conn.sourcePort,
+            targetPort: conn.targetPort,
+            // 如果连线已有保存的坐标,保留它们
+            sourceX: conn.sourceX,
+            sourceY: conn.sourceY,
+            targetX: conn.targetX,
+            targetY: conn.targetY
+          }));
+          
+          console.log('saveWorkflow: 保存节点位置和连线到 bp.json,节点数量:', nodes.length, '连线数量:', connections.length);
+          await window.electronAPI.saveBlueprintJson(folderName, { 
+            nodePositions,
+            connections: connectionData
+          });
+          console.log('saveWorkflow: 节点位置和连线保存成功');
+        } catch (bpError) {
+          console.error('saveWorkflow: 保存节点位置失败:', bpError);
+          // 节点位置保存失败不影响主流程
+        }
+      }
       
       
       // 保存成功后,标记为未修改
       // 保存成功后,标记为未修改
       if (result && result.success !== false) {
       if (result && result.success !== false) {
         setIsDirty(false);
         setIsDirty(false);
+        console.log('saveWorkflow: 保存成功,isDirty 设为 false');
       }
       }
       
       
       return result;
       return result;
     } catch (error) {
     } catch (error) {
+      console.error('saveWorkflow: 出错:', error);
       return { success: false, error: error.message };
       return { success: false, error: error.message };
     }
     }
   }, [nodes, connections, variables]);
   }, [nodes, connections, variables]);
@@ -430,6 +444,7 @@ export function useBlueprint(workflowName = null) {
       
       
       return updatedNodes;
       return updatedNodes;
     });
     });
+    setIsDirty(true); // 标记为已修改
   };
   };
   
   
   /**
   /**
@@ -573,6 +588,51 @@ export function useBlueprint(workflowName = null) {
     setIsDirty(true); // 标记为已修改
     setIsDirty(true); // 标记为已修改
   };
   };
   
   
+  /**
+   * 更新连线坐标(从 DOM 获取的实际坐标)
+   * @param {string} connectionId - 连线ID
+   * @param {Object} coords - 坐标 { sourceX, sourceY, targetX, targetY }
+   */
+  const updateConnectionCoords = useCallback((connectionId, coords) => {
+    setConnections(prev => prev.map(conn => {
+      if (conn.id === connectionId) {
+        return {
+          ...conn,
+          sourceX: coords.sourceX,
+          sourceY: coords.sourceY,
+          targetX: coords.targetX,
+          targetY: coords.targetY
+        };
+      }
+      return conn;
+    }));
+  }, []);
+  
+  /**
+   * 批量更新所有连线坐标
+   * @param {Array} coordsArray - [{ connectionId, sourceX, sourceY, targetX, targetY }, ...]
+   */
+  const updateAllConnectionCoords = useCallback((coordsArray) => {
+    if (!coordsArray || coordsArray.length === 0) return;
+    
+    setConnections(prev => {
+      const coordsMap = new Map(coordsArray.map(c => [c.connectionId, c]));
+      return prev.map(conn => {
+        const coords = coordsMap.get(conn.id);
+        if (coords) {
+          return {
+            ...conn,
+            sourceX: coords.sourceX,
+            sourceY: coords.sourceY,
+            targetX: coords.targetX,
+            targetY: coords.targetY
+          };
+        }
+        return conn;
+      });
+    });
+  }, []);
+  
   /**
   /**
    * 复制节点
    * 复制节点
    */
    */
@@ -662,6 +722,119 @@ export function useBlueprint(workflowName = null) {
     setIsDirty(true); // 标记为已修改
     setIsDirty(true); // 标记为已修改
   };
   };
   
   
+  /**
+   * 手动整理布局
+   */
+  const arrangeLayout = useCallback(async () => {
+    if (nodes.length === 0) return;
+    
+    setIsLoadingLayout(true);
+    setLayoutProgress(0);
+    setLayoutMessage('准备开始...');
+    
+    // 使用 setTimeout 确保 UI 更新后再执行布局
+    await new Promise(resolve => setTimeout(resolve, 50));
+    
+    try {
+      // 只对流程节点进行自动布局,变量节点单独处理
+      const processNodes = nodes.filter(n => n.type !== 'variable');
+      const varNodes = nodes.filter(n => n.type === 'variable');
+      
+      console.log('开始手动整理布局,流程节点数量:', processNodes.length, '变量节点数量:', varNodes.length);
+      
+      // 使用进度回调
+      const layoutedProcessNodes = autoLayoutBlueprint(
+        processNodes, 
+        connections,
+        (progress, message) => {
+          console.log(`布局进度: ${progress}% - ${message}`);
+          setLayoutProgress(progress * 0.8); // 0-80% 用于布局
+          setLayoutMessage(message);
+        }
+      );
+      
+      // 变量节点放在流程节点下方,按类型分组
+      const getVarNodes = varNodes.filter(n => n.varMode === 'get' || !n.varMode);
+      const setVarNodes = varNodes.filter(n => n.varMode === 'set');
+      
+      // 计算流程节点的边界
+      let maxY = 100;
+      let minX = 150;
+      layoutedProcessNodes.forEach(node => {
+        if (node.y > maxY) maxY = node.y;
+        if (node.x < minX) minX = node.x;
+      });
+      
+      // Get 变量节点放在左下方
+      let varX = minX;
+      let varY = maxY + 200;
+      const layoutedGetVarNodes = getVarNodes.map((node, index) => {
+        const newNode = { ...node, x: varX, y: varY };
+        varX += 200;
+        if (varX > 800) {
+          varX = minX;
+          varY += 80;
+        }
+        return newNode;
+      });
+      
+      // Set 变量节点放在 Get 节点右侧
+      varX = Math.max(varX + 100, 600);
+      varY = maxY + 200;
+      const layoutedSetVarNodes = setVarNodes.map((node, index) => {
+        const newNode = { ...node, x: varX, y: varY };
+        varX += 200;
+        if (varX > 1200) {
+          varX = 600;
+          varY += 80;
+        }
+        return newNode;
+      });
+      
+      const finalNodes = [...layoutedProcessNodes, ...layoutedGetVarNodes, ...layoutedSetVarNodes];
+      
+      console.log('整理布局完成,节点数量:', finalNodes.length);
+      
+      setLayoutProgress(90);
+      setLayoutMessage('正在保存位置信息...');
+      
+      // 更新节点状态
+      setNodes(finalNodes);
+      setIsDirty(true);
+      
+      // 保存位置信息
+      if (workflowName && window.electronAPI && window.electronAPI.saveBlueprintJson) {
+        try {
+          const nodePositions = {};
+          finalNodes.forEach(node => {
+            nodePositions[node.id] = {
+              x: node.x,
+              y: node.y
+            };
+          });
+          await window.electronAPI.saveBlueprintJson(workflowName, { nodePositions });
+          setLayoutProgress(100);
+          setLayoutMessage('完成');
+        } catch (error) {
+          console.error('保存位置信息失败:', error);
+          setLayoutMessage('保存位置信息失败');
+        }
+      } else {
+        setLayoutProgress(100);
+        setLayoutMessage('完成');
+      }
+    } catch (error) {
+      console.error('整理布局出错:', error);
+      setLayoutMessage('整理布局失败');
+    } finally {
+      // 延迟一点再隐藏,让用户看到100%完成
+      await new Promise(resolve => setTimeout(resolve, 300));
+      setIsLoadingLayout(false);
+      setLayoutProgress(0);
+      setLayoutMessage('');
+    }
+  }, [nodes, connections, workflowName]);
+  
   return {
   return {
     nodes,
     nodes,
     connections,
     connections,
@@ -681,11 +854,14 @@ export function useBlueprint(workflowName = null) {
     createConnection,
     createConnection,
     deleteConnection,
     deleteConnection,
     updatePortValue,
     updatePortValue,
+    updateConnectionCoords,
+    updateAllConnectionCoords,
     copySelectedNodes,
     copySelectedNodes,
     cutSelectedNodes,
     cutSelectedNodes,
     pasteNodes,
     pasteNodes,
     updateVariables,
     updateVariables,
     saveWorkflow,
     saveWorkflow,
-    loadWorkflow
+    loadWorkflow,
+    arrangeLayout
   };
   };
 }
 }

+ 29 - 4
src/pages/blueprint/blueprint.js

@@ -37,7 +37,7 @@ export function useBlueprintLogic(propWorkflowName) {
   
   
   // 从 useBlueprint hook 获取状态和方法
   // 从 useBlueprint hook 获取状态和方法
   const blueprintState = useBlueprintHook(workflowName);
   const blueprintState = useBlueprintHook(workflowName);
-  const { nodes, selectedNodeIds, variables, isLoadingLayout, layoutProgress, layoutMessage, isDirty, saveWorkflow } = blueprintState;
+  const { nodes, selectedNodeIds, variables, isLoadingLayout, layoutProgress, layoutMessage, isDirty, saveWorkflow, arrangeLayout } = blueprintState;
   
   
   /**
   /**
    * 处理返回
    * 处理返回
@@ -109,7 +109,12 @@ export function useBlueprintLogic(propWorkflowName) {
         if ((e.ctrlKey || e.metaKey) && e.key === 's') {
         if ((e.ctrlKey || e.metaKey) && e.key === 's') {
           e.preventDefault();
           e.preventDefault();
           if (workflowName) {
           if (workflowName) {
-            saveWorkflow(workflowName);
+            console.log('Ctrl+S 触发保存(输入框内),workflowName:', workflowName);
+            saveWorkflow(workflowName).then(result => {
+              console.log('保存结果:', result);
+            }).catch(err => {
+              console.error('保存出错:', err);
+            });
           }
           }
         }
         }
         return;
         return;
@@ -119,7 +124,19 @@ export function useBlueprintLogic(propWorkflowName) {
       if ((e.ctrlKey || e.metaKey) && e.key === 's') {
       if ((e.ctrlKey || e.metaKey) && e.key === 's') {
         e.preventDefault();
         e.preventDefault();
         if (workflowName) {
         if (workflowName) {
-          saveWorkflow(workflowName);
+          console.log('Ctrl+S 触发保存,workflowName:', workflowName);
+          saveWorkflow(workflowName).then(result => {
+            console.log('保存结果:', result);
+            if (result && result.success !== false) {
+              console.log('保存成功');
+            } else {
+              console.error('保存失败:', result?.error);
+            }
+          }).catch(err => {
+            console.error('保存出错:', err);
+          });
+        } else {
+          console.warn('workflowName 为空,无法保存');
         }
         }
       }
       }
       
       
@@ -202,6 +219,13 @@ export function useBlueprintLogic(propWorkflowName) {
     blueprintState.addNode(nodeType, canvasX, canvasY);
     blueprintState.addNode(nodeType, canvasX, canvasY);
   };
   };
 
 
+  /**
+   * 处理整理布局
+   */
+  const handleArrangeLayout = () => {
+    arrangeLayout();
+  };
+
   return {
   return {
     ...blueprintState,
     ...blueprintState,
     workflowName,
     workflowName,
@@ -225,6 +249,7 @@ export function useBlueprintLogic(propWorkflowName) {
     getProgressStyle,
     getProgressStyle,
     formatProgress,
     formatProgress,
     handleCloseContextMenu,
     handleCloseContextMenu,
-    addVariableNode
+    addVariableNode,
+    handleArrangeLayout
   };
   };
 }
 }

+ 5 - 1
src/pages/blueprint/blueprint.jsx

@@ -38,11 +38,13 @@ function Blueprint({ workflowName: propWorkflowName = null }) {
     createConnection,
     createConnection,
     deleteConnection,
     deleteConnection,
     updatePortValue,
     updatePortValue,
+    updateAllConnectionCoords,
     handleNodeSelect,
     handleNodeSelect,
     getProgressStyle,
     getProgressStyle,
     formatProgress,
     formatProgress,
     handleCloseContextMenu,
     handleCloseContextMenu,
-    addVariableNode
+    addVariableNode,
+    handleArrangeLayout
   } = useBlueprintLogic(propWorkflowName);
   } = useBlueprintLogic(propWorkflowName);
   
   
   // 变量节点类型选择对话框状态
   // 变量节点类型选择对话框状态
@@ -85,6 +87,7 @@ function Blueprint({ workflowName: propWorkflowName = null }) {
     >
     >
       <BlueprintToolbar
       <BlueprintToolbar
         onBack={handleBack}
         onBack={handleBack}
+        onArrangeLayout={handleArrangeLayout}
         workflowName={workflowName}
         workflowName={workflowName}
         isDirty={isDirty}
         isDirty={isDirty}
       />
       />
@@ -113,6 +116,7 @@ function Blueprint({ workflowName: propWorkflowName = null }) {
         onCanvasContextMenu={handleCanvasContextMenu}
         onCanvasContextMenu={handleCanvasContextMenu}
         onPortValueChange={updatePortValue}
         onPortValueChange={updatePortValue}
         onVariableDrop={handleVariableDrop}
         onVariableDrop={handleVariableDrop}
+        onUpdateConnectionCoords={updateAllConnectionCoords}
         canvasControllerRef={canvasControllerRef}
         canvasControllerRef={canvasControllerRef}
       />
       />
       
       

+ 22 - 4
src/pages/blueprint/canvas/blueprint-canvas.jsx

@@ -2,7 +2,7 @@
  * 画布组件(主编辑区)
  * 画布组件(主编辑区)
  */
  */
 
 
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
 import './canvas.css';
 import './canvas.css';
 import { NodeRenderer } from '../node-renderer/node-renderer.jsx';
 import { NodeRenderer } from '../node-renderer/node-renderer.jsx';
 import { useCanvasLogic, useCanvasEventHandlers, createEventHandlers, getConnectionClass, calculateGridStyle, handleVariableDrop, handleVariableDragOver } from './canvas.js';
 import { useCanvasLogic, useCanvasEventHandlers, createEventHandlers, getConnectionClass, calculateGridStyle, handleVariableDrop, handleVariableDragOver } from './canvas.js';
@@ -21,6 +21,7 @@ export function BlueprintCanvas({
   onCanvasContextMenu,
   onCanvasContextMenu,
   onPortValueChange,
   onPortValueChange,
   onVariableDrop,
   onVariableDrop,
+  onUpdateConnectionCoords,
   canvasControllerRef: externalControllerRef
   canvasControllerRef: externalControllerRef
 }) {
 }) {
   const {
   const {
@@ -36,17 +37,34 @@ export function BlueprintCanvas({
     handleContextMenu: handleContextMenuLogic,
     handleContextMenu: handleContextMenuLogic,
     handleConnectionClick: handleConnectionClickLogic,
     handleConnectionClick: handleConnectionClickLogic,
     getConnectionPath,
     getConnectionPath,
-    refreshPortPositionCache
+    refreshPortPositionCache,
+    getAllConnectionCoords
   } = useCanvasLogic(connections, externalControllerRef, nodes);
   } = useCanvasLogic(connections, externalControllerRef, nodes);
   
   
-  // 首次渲染后刷新端口坐标缓存(等待 DOM 渲染完成)
+  // 用于跟踪连线ID列表,避免坐标更新导致的无限循环
+  const lastConnectionIdsRef = useRef('');
+  
+  // 首次渲染后刷新端口坐标缓存并更新连线坐标(等待 DOM 渲染完成)
   useEffect(() => {
   useEffect(() => {
+    // 生成当前连线ID列表的字符串(用于比较)
+    const currentConnectionIds = connections.map(c => c.id).sort().join(',');
+    
     // 延迟执行,确保 DOM 已渲染
     // 延迟执行,确保 DOM 已渲染
     const timer = setTimeout(() => {
     const timer = setTimeout(() => {
       refreshPortPositionCache();
       refreshPortPositionCache();
+      
+      // 只有当连线列表发生变化时才更新坐标(避免坐标更新导致的循环)
+      if (onUpdateConnectionCoords && connections.length > 0 && currentConnectionIds !== lastConnectionIdsRef.current) {
+        lastConnectionIdsRef.current = currentConnectionIds;
+        const coords = getAllConnectionCoords(connections);
+        if (coords.length > 0) {
+          console.log('更新连线坐标,数量:', coords.length);
+          onUpdateConnectionCoords(coords);
+        }
+      }
     }, 100);
     }, 100);
     return () => clearTimeout(timer);
     return () => clearTimeout(timer);
-  }, [nodes.length, connections.length]);
+  }, [nodes.length, connections, refreshPortPositionCache, getAllConnectionCoords, onUpdateConnectionCoords]);
   
   
   // 绑定事件监听
   // 绑定事件监听
   useCanvasEventHandlers(canvasRef, transform, connectingStart, connectingEnd, handleMouseMoveLogic, handleMouseUpLogic, handleContextMenuLogic, onNodeMove, onCanvasContextMenu);
   useCanvasEventHandlers(canvasRef, transform, connectingStart, connectingEnd, handleMouseMoveLogic, handleMouseUpLogic, handleContextMenuLogic, onNodeMove, onCanvasContextMenu);

+ 121 - 10
src/pages/blueprint/canvas/canvas.js

@@ -462,12 +462,26 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
       console.log(`  目标节点:`, connection.target, `端口:`, connection.targetPort, `索引:`, targetPortIndex, `(共${targetNode.inputs?.length || 0}个输入端口)`);
       console.log(`  目标节点:`, connection.target, `端口:`, connection.targetPort, `索引:`, targetPortIndex, `(共${targetNode.inputs?.length || 0}个输入端口)`);
     }
     }
     
     
-    // 使用缓存方案获取端口坐标
+    // 使用缓存方案获取端口坐标(优先从 DOM 获取,带有节点偏移量计算)
+    let sourceX, sourceY, targetX, targetY;
+    
     const sourcePortPos = getPortPosition(sourceNode, sourcePort);
     const sourcePortPos = getPortPosition(sourceNode, sourcePort);
     const targetPortPos = getPortPosition(targetNode, targetPort);
     const targetPortPos = getPortPosition(targetNode, targetPort);
     
     
-    
-    if (!sourcePortPos || !targetPortPos) {
+    if (sourcePortPos && targetPortPos) {
+      // 使用从 DOM/缓存获取的坐标
+      sourceX = sourcePortPos.x;
+      sourceY = sourcePortPos.y;
+      targetX = targetPortPos.x;
+      targetY = targetPortPos.y;
+    } else if (connection.sourceX !== undefined && connection.sourceY !== undefined &&
+               connection.targetX !== undefined && connection.targetY !== undefined) {
+      // 如果无法从 DOM 获取,使用保存的坐标作为后备
+      sourceX = connection.sourceX;
+      sourceY = connection.sourceY;
+      targetX = connection.targetX;
+      targetY = connection.targetY;
+    } else {
       console.warn('无法计算端口位置:', connection.id, {
       console.warn('无法计算端口位置:', connection.id, {
         sourceNodeId: connection.source,
         sourceNodeId: connection.source,
         sourcePortId: connection.sourcePort,
         sourcePortId: connection.sourcePort,
@@ -477,12 +491,6 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
       return null;
       return null;
     }
     }
     
     
-    // SVG 现在与画布坐标系一致,不需要偏移
-    const sourceX = sourcePortPos.x;
-    const sourceY = sourcePortPos.y;
-    const targetX = targetPortPos.x;
-    const targetY = targetPortPos.y;
-    
     // 调试:检查坐标是否有效
     // 调试:检查坐标是否有效
     if (isNaN(sourceX) || isNaN(sourceY) || isNaN(targetX) || isNaN(targetY)) {
     if (isNaN(sourceX) || isNaN(sourceY) || isNaN(targetX) || isNaN(targetY)) {
       console.warn('连线坐标无效:', connection.id, {
       console.warn('连线坐标无效:', connection.id, {
@@ -550,6 +558,107 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
     }
     }
   };
   };
   
   
+  /**
+   * 获取所有连线的实际坐标(从 DOM 获取)
+   * 用于保存到 bp.json
+   * @param {Array} connections - 连线数组
+   * @returns {Array} 包含坐标的连线数组
+   */
+  const getAllConnectionCoords = (connections) => {
+    if (!canvasRef.current) return [];
+    
+    const result = [];
+    
+    connections.forEach(connection => {
+      const sourceNode = nodes.find(n => n.id === connection.source);
+      const targetNode = nodes.find(n => n.id === connection.target);
+      
+      if (!sourceNode || !targetNode) return;
+      
+      const sourcePort = (sourceNode.outputs || []).find(p => p.id === connection.sourcePort);
+      const targetPort = (targetNode.inputs || []).find(p => p.id === connection.targetPort);
+      
+      if (!sourcePort || !targetPort) return;
+      
+      // 从 DOM 获取实际坐标
+      const sourcePos = getPortPositionFromDOM(sourceNode.id, connection.sourcePort, false);
+      const targetPos = getPortPositionFromDOM(targetNode.id, connection.targetPort, true);
+      
+      if (sourcePos && targetPos) {
+        result.push({
+          connectionId: connection.id,
+          sourceX: sourcePos.x,
+          sourceY: sourcePos.y,
+          targetX: targetPos.x,
+          targetY: targetPos.y
+        });
+      }
+    });
+    
+    return result;
+  };
+  
+  /**
+   * 从 DOM 获取端口的精确坐标
+   * @param {string} nodeId - 节点ID
+   * @param {string} portId - 端口ID
+   * @param {boolean} isInput - 是否是输入端口
+   * @returns {Object|null} { x, y }
+   */
+  const getPortPositionFromDOM = (nodeId, portId, isInput) => {
+    if (!canvasRef.current) return null;
+    
+    const nodeElement = canvasRef.current.querySelector(`[data-node-id="${nodeId}"]`);
+    if (!nodeElement) return null;
+    
+    const node = nodes.find(n => n.id === nodeId);
+    if (!node) return null;
+    
+    // 查找端口
+    const ports = isInput ? (node.inputs || []) : (node.outputs || []);
+    const port = ports.find(p => p.id === portId);
+    if (!port) return null;
+    
+    const isExecutionPort = port.type === 'execution';
+    
+    // 查找匹配的端口元素
+    const portElements = nodeElement.querySelectorAll('.Blueprint-node-port');
+    const matchingPorts = Array.from(portElements).filter(portEl => {
+      const isInputPort = portEl.classList.contains('input');
+      const isOutputPort = portEl.classList.contains('output');
+      const isExecPort = portEl.classList.contains('port-execution');
+      
+      const directionMatch = (isInput && isInputPort) || (!isInput && isOutputPort);
+      const typeMatch = isExecutionPort ? isExecPort : !isExecPort;
+      
+      return directionMatch && typeMatch;
+    });
+    
+    // 获取端口在同类型中的索引
+    const filteredPorts = ports.filter(p => isExecutionPort ? p.type === 'execution' : p.type !== 'execution');
+    const portIndex = filteredPorts.findIndex(p => p.id === portId);
+    
+    if (portIndex >= 0 && portIndex < matchingPorts.length) {
+      const portEl = matchingPorts[portIndex];
+      const dotElement = portEl.querySelector('.Blueprint-node-port-dot');
+      const arrowElement = portEl.querySelector('.Blueprint-node-port-arrow');
+      const targetElement = dotElement || arrowElement;
+      
+      if (targetElement) {
+        const targetRect = targetElement.getBoundingClientRect();
+        const canvasRect = canvasRef.current.getBoundingClientRect();
+        
+        // 计算相对于画布的坐标(考虑当前 transform)
+        const x = (targetRect.left + targetRect.width / 2 - canvasRect.left - transform.translateX) / transform.scale;
+        const y = (targetRect.top + targetRect.height / 2 - canvasRect.top - transform.translateY) / transform.scale;
+        
+        return { x, y };
+      }
+    }
+    
+    return null;
+  };
+  
   return {
   return {
     canvasRef,
     canvasRef,
     transform,
     transform,
@@ -564,7 +673,9 @@ export function useCanvasLogic(connections, externalControllerRef, nodes = []) {
     handleConnectionClick,
     handleConnectionClick,
     getConnectionPath,
     getConnectionPath,
     refreshPortPositionCache,
     refreshPortPositionCache,
-    refreshNodePortCache
+    refreshNodePortCache,
+    getAllConnectionCoords,
+    getPortPositionFromDOM
   };
   };
 }
 }
 
 

+ 1 - 0
src/pages/blueprint/node-renderer/node-renderer.jsx

@@ -14,6 +14,7 @@ import {
   formatOutputLabel,
   formatOutputLabel,
   separatePorts,
   separatePorts,
   handleNodeMouseDown,
   handleNodeMouseDown,
+  handleNodeDoubleClick,
   handlePortMouseDown,
   handlePortMouseDown,
   handlePortMouseUp,
   handlePortMouseUp,
   handleInputChange,
   handleInputChange,

+ 5 - 2
src/pages/blueprint/toolbar/blueprint-toolbar.jsx

@@ -1,16 +1,19 @@
 /**
 /**
- * 工具栏组件(返回)
+ * 工具栏组件(返回、整理布局
  */
  */
 
 
 import './toolbar.css';
 import './toolbar.css';
 import { formatWorkflowName, getStatusTooltip } from './toolbar.js';
 import { formatWorkflowName, getStatusTooltip } from './toolbar.js';
 
 
-export function BlueprintToolbar({ onBack, workflowName, isDirty = false }) {
+export function BlueprintToolbar({ onBack, onArrangeLayout, workflowName, isDirty = false }) {
   return (
   return (
     <div className="Blueprint-toolbar">
     <div className="Blueprint-toolbar">
       <button className="Blueprint-toolbar-button" onClick={onBack} title="返回">
       <button className="Blueprint-toolbar-button" onClick={onBack} title="返回">
         返回
         返回
       </button>
       </button>
+      <button className="Blueprint-toolbar-button" onClick={onArrangeLayout} title="自动整理节点布局">
+        整理布局
+      </button>
       <div className="Blueprint-toolbar-spacer"></div>
       <div className="Blueprint-toolbar-spacer"></div>
       <div className="Blueprint-toolbar-title">
       <div className="Blueprint-toolbar-title">
         <span className="Blueprint-toolbar-status" title={getStatusTooltip(isDirty)}>
         <span className="Blueprint-toolbar-status" title={getStatusTooltip(isDirty)}>

+ 4 - 2
src/pages/blueprint/utils/node-operations.js

@@ -282,10 +282,12 @@ function getNodeLabelFromType(nodeType, data = {}) {
  * @param {number} x - X 坐标
  * @param {number} x - X 坐标
  * @param {number} y - Y 坐标
  * @param {number} y - Y 坐标
  * @param {string} mode - 模式:'get'(只有输出端口)或 'set'(只有输入端口)
  * @param {string} mode - 模式:'get'(只有输出端口)或 'set'(只有输入端口)
+ * @param {string} customId - 自定义节点 ID(可选,用于确保 ID 一致性)
  * @returns {Object} 变量节点对象
  * @returns {Object} 变量节点对象
  */
  */
-export function createVariableNode(varName, varValue, x, y, mode = 'get') {
-  const nodeId = `var_${mode}_${varName}_${Date.now()}`;
+export function createVariableNode(varName, varValue, x, y, mode = 'get', customId = null) {
+  // 如果没有提供自定义 ID,则生成一个(用于用户手动创建的节点)
+  const nodeId = customId || `var_${mode}_${varName}_${Date.now()}`;
   const varType = typeof varValue === 'number' ? 'int' : 'string';
   const varType = typeof varValue === 'number' ? 'int' : 'string';
   
   
   let inputs = [];
   let inputs = [];

+ 3 - 2
src/pages/blueprint/utils/workflow-converter.js

@@ -154,7 +154,8 @@ export function workflowToBlueprint(workflow) {
   // 检查是否有 Begin 节点,如果没有则创建一个并连接到第一个节点
   // 检查是否有 Begin 节点,如果没有则创建一个并连接到第一个节点
   const hasBeginNode = nodes.some(node => node.type === 'begin');
   const hasBeginNode = nodes.some(node => node.type === 'begin');
   if (!hasBeginNode && nodes.length > 0) {
   if (!hasBeginNode && nodes.length > 0) {
-    const beginNodeId = `node_begin_${Date.now()}`;
+    // 使用固定的 ID,确保每次加载时 ID 一致
+    const beginNodeId = 'node_begin';
     const beginNode = {
     const beginNode = {
       id: beginNodeId,
       id: beginNodeId,
       type: 'begin',
       type: 'begin',
@@ -171,7 +172,7 @@ export function workflowToBlueprint(workflow) {
     if (nodes.length > 1) {
     if (nodes.length > 1) {
       const firstNode = nodes[1];
       const firstNode = nodes[1];
       connections.unshift({
       connections.unshift({
-        id: `conn_begin_${Date.now()}`,
+        id: 'conn_begin',
         source: beginNodeId,
         source: beginNodeId,
         target: firstNode.id,
         target: firstNode.id,
         sourcePort: 'output_0',
         sourcePort: 'output_0',

+ 35 - 12
static/processing/测试/bp.json

@@ -1,20 +1,43 @@
 {
 {
 	"nodePositions": {
 	"nodePositions": {
-		"node_begin_1768723414909": {
-			"x": 80,
-			"y": 180
+		"node_begin": {
+			"x": 150,
+			"y": 100
 		},
 		},
 		"node_0": {
 		"node_0": {
-			"x": 340,
-			"y": 280
+			"x": 150,
+			"y": 300
 		},
 		},
-		"var_get_mini_1768723414909": {
-			"x": 60,
-			"y": 380
+		"var_get_mini": {
+			"x": 150,
+			"y": 500
 		},
 		},
-		"var_set_swipeDirection_1768723414909": {
-			"x": 160,
-			"y": 520
+		"var_set_swipeDirection": {
+			"x": 600,
+			"y": 500
 		}
 		}
-	}
+	},
+	"connections": [
+		{
+			"id": "conn_begin",
+			"source": "node_begin",
+			"target": "node_0",
+			"sourcePort": "output_0",
+			"targetPort": "input_0"
+		},
+		{
+			"id": "conn_var_in_node_0_input_param_min_1768726400654_j8er0pxs0",
+			"source": "var_get_mini",
+			"target": "node_0",
+			"sourcePort": "output_0",
+			"targetPort": "input_param_min"
+		},
+		{
+			"id": "conn_var_out_node_0_output_1_1768726400654_uzge68fg8",
+			"source": "node_0",
+			"target": "var_set_swipeDirection",
+			"sourcePort": "output_1",
+			"targetPort": "input_0"
+		}
+	]
 }
 }

+ 9 - 4
static/processing/测试/processing.json

@@ -6,8 +6,13 @@
 	"execute": [
 	"execute": [
 		{
 		{
 			"type": "random",
 			"type": "random",
-			"inVars": ["{mini}", "4"],
-			"outVars": ["{swipeDirection}"]
-		}		
+			"inVars": [
+				"{mini}",
+				"4"
+			],
+			"outVars": [
+				"{swipeDirection}"
+			]
+		}
 	]
 	]
-}
+}

BIN
temp_screenshot.png