Process visualization orchestration BUG fix, feature enhancement, and support fo…
…r export and import of orchestration processes.
Showing
2 changed files
with
1689 additions
and
97 deletions
| 1 | +let workflowEditorInitialized = false; | ||
| 2 | + | ||
| 1 | document.addEventListener('DOMContentLoaded', function() { | 3 | document.addEventListener('DOMContentLoaded', function() { |
| 4 | + // 检查是否已初始化,防止多次执行 | ||
| 5 | + if (workflowEditorInitialized) { | ||
| 6 | + console.log('工作流编辑器已初始化,跳过重复初始化'); | ||
| 7 | + return; | ||
| 8 | + } | ||
| 9 | + workflowEditorInitialized = true; | ||
| 10 | + | ||
| 2 | // 工作流编辑器的主要元素 | 11 | // 工作流编辑器的主要元素 |
| 3 | const workflowCanvas = document.getElementById('workflowCanvas'); | 12 | const workflowCanvas = document.getElementById('workflowCanvas'); |
| 4 | const connectionsSvg = document.getElementById('connectionsSvg'); | 13 | const connectionsSvg = document.getElementById('connectionsSvg'); |
| @@ -24,6 +33,22 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -24,6 +33,22 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 24 | let isConnecting = false; | 33 | let isConnecting = false; |
| 25 | let connectionStart = null; | 34 | let connectionStart = null; |
| 26 | let connectionPreviewPath = null; | 35 | let connectionPreviewPath = null; |
| 36 | + | ||
| 37 | + // 记录初始化状态,避免重复初始化导致的组件重复添加 | ||
| 38 | + let isInitialized = false; | ||
| 39 | + | ||
| 40 | + // 历史记录管理 | ||
| 41 | + const MAX_HISTORY = 50; // 最多保存50步历史 | ||
| 42 | + let history = []; | ||
| 43 | + let currentHistoryIndex = -1; | ||
| 44 | + | ||
| 45 | + // 自动保存相关变量 | ||
| 46 | + const AUTO_SAVE_INTERVAL = 3 * 60 * 1000; // 3分钟 | ||
| 47 | + let autoSaveTimer = null; | ||
| 48 | + | ||
| 49 | + // 视图缩放相关变量 | ||
| 50 | + let canvasScale = 1; | ||
| 51 | + let canvasTranslate = { x: 0, y: 0 }; | ||
| 27 | 52 | ||
| 28 | // 设置编辑器网格背景 | 53 | // 设置编辑器网格背景 |
| 29 | setEditorBackground(); | 54 | setEditorBackground(); |
| @@ -137,43 +162,88 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -137,43 +162,88 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 137 | } | 162 | } |
| 138 | 163 | ||
| 139 | function createNodeFromData(nodeData) { | 164 | function createNodeFromData(nodeData) { |
| 165 | + // 检查节点是否已存在 | ||
| 166 | + const existingNode = document.getElementById(nodeData.id); | ||
| 167 | + if (existingNode) { | ||
| 168 | + console.warn('节点已存在:', nodeData.id); | ||
| 169 | + return existingNode; | ||
| 170 | + } | ||
| 171 | + | ||
| 140 | // 从数据创建节点DOM元素 | 172 | // 从数据创建节点DOM元素 |
| 141 | const nodeElement = document.createElement('div'); | 173 | const nodeElement = document.createElement('div'); |
| 142 | nodeElement.className = 'workflow-node'; | 174 | nodeElement.className = 'workflow-node'; |
| 143 | nodeElement.id = nodeData.id; | 175 | nodeElement.id = nodeData.id; |
| 144 | - nodeElement.style.left = nodeData.x + 'px'; | ||
| 145 | - nodeElement.style.top = nodeData.y + 'px'; | 176 | + |
| 177 | + // 确保坐标有效 | ||
| 178 | + const x = typeof nodeData.x === 'number' ? nodeData.x : 100; | ||
| 179 | + const y = typeof nodeData.y === 'number' ? nodeData.y : 100; | ||
| 180 | + | ||
| 181 | + nodeElement.style.left = x + 'px'; | ||
| 182 | + nodeElement.style.top = y + 'px'; | ||
| 183 | + | ||
| 184 | + // 根据节点类型设置不同的样式 | ||
| 185 | + nodeElement.classList.add(`node-type-${nodeData.type}`); | ||
| 146 | 186 | ||
| 147 | // 构建节点内容 | 187 | // 构建节点内容 |
| 148 | nodeElement.innerHTML = ` | 188 | nodeElement.innerHTML = ` |
| 149 | <div class="node-header"> | 189 | <div class="node-header"> |
| 150 | <span class="node-title">${nodeData.title}</span> | 190 | <span class="node-title">${nodeData.title}</span> |
| 151 | - <span class="node-type">${getComponentTypeLabel(nodeData.type)}</span> | 191 | + <div class="node-type-badge">${getComponentTypeLabel(nodeData.type)}</div> |
| 152 | </div> | 192 | </div> |
| 153 | <div class="node-content"> | 193 | <div class="node-content"> |
| 194 | + <div class="node-subtype">${nodeData.subtype}</div> | ||
| 154 | <p class="node-description">${nodeData.config ? '已配置' : '点击配置参数'}</p> | 195 | <p class="node-description">${nodeData.config ? '已配置' : '点击配置参数'}</p> |
| 155 | </div> | 196 | </div> |
| 156 | <div class="node-ports"> | 197 | <div class="node-ports"> |
| 157 | - <div class="port port-in" data-port-type="input"></div> | ||
| 158 | - <div class="port port-out" data-port-type="output"></div> | 198 | + <div class="port port-in" data-port-type="input" title="输入连接点"></div> |
| 199 | + <div class="port port-out" data-port-type="output" title="输出连接点"></div> | ||
| 159 | </div> | 200 | </div> |
| 160 | - <div class="node-actions mt-2"> | ||
| 161 | - <button class="btn btn-sm btn-outline-danger delete-node-btn"> | 201 | + <div class="node-actions"> |
| 202 | + <button class="btn btn-sm btn-outline-danger delete-node-btn" title="删除节点"> | ||
| 162 | <i class="fas fa-trash-alt"></i> | 203 | <i class="fas fa-trash-alt"></i> |
| 163 | </button> | 204 | </button> |
| 205 | + <button class="btn btn-sm btn-outline-primary config-node-btn" title="配置节点"> | ||
| 206 | + <i class="fas fa-cog"></i> | ||
| 207 | + </button> | ||
| 164 | </div> | 208 | </div> |
| 165 | `; | 209 | `; |
| 166 | 210 | ||
| 211 | + // 添加入场动画 | ||
| 212 | + nodeElement.classList.add('node-entering'); | ||
| 213 | + setTimeout(() => { | ||
| 214 | + nodeElement.classList.remove('node-entering'); | ||
| 215 | + }, 300); | ||
| 216 | + | ||
| 167 | workflowCanvas.appendChild(nodeElement); | 217 | workflowCanvas.appendChild(nodeElement); |
| 168 | 218 | ||
| 219 | + // 绑定配置按钮事件 | ||
| 220 | + const configBtn = nodeElement.querySelector('.config-node-btn'); | ||
| 221 | + configBtn.addEventListener('click', function(e) { | ||
| 222 | + e.stopPropagation(); | ||
| 223 | + openNodeConfig(nodeData); | ||
| 224 | + }); | ||
| 225 | + | ||
| 169 | // 添加到节点数据 | 226 | // 添加到节点数据 |
| 170 | - workflowData.nodes.push(nodeData); | 227 | + const existingNodeIndex = workflowData.nodes.findIndex(node => node.id === nodeData.id); |
| 228 | + if (existingNodeIndex === -1) { | ||
| 229 | + workflowData.nodes.push(nodeData); | ||
| 230 | + } else { | ||
| 231 | + workflowData.nodes[existingNodeIndex] = nodeData; | ||
| 232 | + } | ||
| 171 | 233 | ||
| 172 | return nodeElement; | 234 | return nodeElement; |
| 173 | } | 235 | } |
| 174 | 236 | ||
| 175 | // ====== 运行工作流 ====== | 237 | // ====== 运行工作流 ====== |
| 176 | document.getElementById('runWorkflowBtn').addEventListener('click', function() { | 238 | document.getElementById('runWorkflowBtn').addEventListener('click', function() { |
| 239 | + // 先验证工作流是否有效 | ||
| 240 | + const validationResult = validateWorkflow(workflowData); | ||
| 241 | + | ||
| 242 | + if (!validationResult.valid) { | ||
| 243 | + showNotification('错误', `无法运行: ${validationResult.message}`, 'error'); | ||
| 244 | + return; | ||
| 245 | + } | ||
| 246 | + | ||
| 177 | $('#runWorkflowModal').modal('show'); | 247 | $('#runWorkflowModal').modal('show'); |
| 178 | }); | 248 | }); |
| 179 | 249 | ||
| @@ -631,28 +701,52 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -631,28 +701,52 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 631 | workflowCanvas.addEventListener('dragover', function(e) { | 701 | workflowCanvas.addEventListener('dragover', function(e) { |
| 632 | e.preventDefault(); | 702 | e.preventDefault(); |
| 633 | e.dataTransfer.dropEffect = 'copy'; | 703 | e.dataTransfer.dropEffect = 'copy'; |
| 704 | + | ||
| 705 | + // 显示可放置区域指示 | ||
| 706 | + this.classList.add('drag-over'); | ||
| 634 | }); | 707 | }); |
| 635 | 708 | ||
| 636 | - workflowCanvas.addEventListener('drop', function(e) { | ||
| 637 | - e.preventDefault(); | ||
| 638 | - const componentType = e.dataTransfer.getData('componentType'); | ||
| 639 | - const componentSubtype = e.dataTransfer.getData('componentSubtype'); | 709 | + workflowCanvas.addEventListener('dragleave', function() { |
| 710 | + // 移除可放置区域指示 | ||
| 711 | + this.classList.remove('drag-over'); | ||
| 712 | + }); | ||
| 713 | + | ||
| 714 | + // workflowCanvas.addEventListener('drop', function(e) { | ||
| 715 | + // e.preventDefault(); | ||
| 716 | + // this.classList.remove('drag-over'); | ||
| 640 | 717 | ||
| 641 | - if (componentType && componentSubtype) { | ||
| 642 | - const rect = workflowCanvas.getBoundingClientRect(); | ||
| 643 | - const x = e.clientX - rect.left; | ||
| 644 | - const y = e.clientY - rect.top; | 718 | + // const componentType = e.dataTransfer.getData('componentType'); |
| 719 | + // const componentSubtype = e.dataTransfer.getData('componentSubtype'); | ||
| 720 | + | ||
| 721 | + // if (componentType && componentSubtype) { | ||
| 722 | + // const rect = workflowCanvas.getBoundingClientRect(); | ||
| 645 | 723 | ||
| 646 | - addNode(componentType, componentSubtype, x, y); | ||
| 647 | - } | ||
| 648 | - }); | 724 | + // // 修正:修复坐标计算,确保准确的放置位置 |
| 725 | + // // 考虑滚动位置和缩放因素 | ||
| 726 | + // let x = (e.clientX - rect.left) / canvasScale - canvasTranslate.x; | ||
| 727 | + // let y = (e.clientY - rect.top) / canvasScale - canvasTranslate.y; | ||
| 728 | + | ||
| 729 | + // // 调整位置,使节点中心与鼠标位置对齐(假设节点宽度约为200px,高度为100px) | ||
| 730 | + // x = x - 100; // 使节点中心与鼠标对齐 | ||
| 731 | + // y = y - 50; | ||
| 732 | + | ||
| 733 | + // // 确保节点完全在可见区域内 | ||
| 734 | + // x = Math.max(0, Math.min(x, rect.width - 200)); | ||
| 735 | + // y = Math.max(0, Math.min(y, rect.height - 100)); | ||
| 736 | + | ||
| 737 | + // // 添加节点并记录历史 | ||
| 738 | + // addNode(componentType, componentSubtype, x, y); | ||
| 739 | + // addToHistory(); | ||
| 740 | + | ||
| 741 | + // // 用户反馈 | ||
| 742 | + // showNotification('成功', `已添加 ${getComponentTypeLabel(componentType)}-${componentSubtype} 节点`, 'success'); | ||
| 743 | + // } | ||
| 744 | + // }); | ||
| 649 | 745 | ||
| 650 | // 添加其他初始化代码 | 746 | // 添加其他初始化代码 |
| 651 | - document.addEventListener('DOMContentLoaded', function() { | ||
| 652 | - initializeWorkflowEditor(); | ||
| 653 | - setupEventListeners(); | ||
| 654 | - showSampleTemplates(); | ||
| 655 | - }); | 747 | + initializeWorkflowEditor(); |
| 748 | + setupEventListeners(); | ||
| 749 | + showSampleTemplates(); | ||
| 656 | 750 | ||
| 657 | function initializeWorkflowEditor() { | 751 | function initializeWorkflowEditor() { |
| 658 | // 初始化编辑器的基本设置 | 752 | // 初始化编辑器的基本设置 |
| @@ -691,6 +785,11 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -691,6 +785,11 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 691 | 785 | ||
| 692 | const nodeElement = createNodeFromData(nodeData); | 786 | const nodeElement = createNodeFromData(nodeData); |
| 693 | setupNodeEvents(nodeElement, nodeData); | 787 | setupNodeEvents(nodeElement, nodeData); |
| 788 | + | ||
| 789 | + // 更新工作流状态 | ||
| 790 | + onWorkflowChanged(); | ||
| 791 | + | ||
| 792 | + return nodeElement; | ||
| 694 | } | 793 | } |
| 695 | 794 | ||
| 696 | // 设置节点事件 | 795 | // 设置节点事件 |
| @@ -705,8 +804,8 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -705,8 +804,8 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 705 | dragTarget = nodeElement; | 804 | dragTarget = nodeElement; |
| 706 | const rect = nodeElement.getBoundingClientRect(); | 805 | const rect = nodeElement.getBoundingClientRect(); |
| 707 | dragOffset = { | 806 | dragOffset = { |
| 708 | - x: e.clientX - rect.left, | ||
| 709 | - y: e.clientY - rect.top | 807 | + x: e.clientX - rect.left/2, |
| 808 | + y: e.clientY - rect.top/2 | ||
| 710 | }; | 809 | }; |
| 711 | 810 | ||
| 712 | nodeElement.style.zIndex = '100'; | 811 | nodeElement.style.zIndex = '100'; |
| @@ -764,6 +863,11 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -764,6 +863,11 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 764 | 863 | ||
| 765 | // 从数据中删除节点 | 864 | // 从数据中删除节点 |
| 766 | workflowData.nodes = workflowData.nodes.filter(node => node.id !== nodeId); | 865 | workflowData.nodes = workflowData.nodes.filter(node => node.id !== nodeId); |
| 866 | + | ||
| 867 | + // 更新工作流状态 | ||
| 868 | + onWorkflowChanged(); | ||
| 869 | + | ||
| 870 | + showNotification('已删除', '节点已从工作流中移除', 'info'); | ||
| 767 | } | 871 | } |
| 768 | 872 | ||
| 769 | // 处理全局鼠标事件 | 873 | // 处理全局鼠标事件 |
| @@ -920,6 +1024,30 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -920,6 +1024,30 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 920 | y: clientY - canvasRect.top | 1024 | y: clientY - canvasRect.top |
| 921 | }; | 1025 | }; |
| 922 | 1026 | ||
| 1027 | + // 高亮可连接的目标端口 | ||
| 1028 | + document.querySelectorAll('.workflow-node').forEach(node => { | ||
| 1029 | + if (node.id !== connectionStart.id) { | ||
| 1030 | + const inputPort = node.querySelector('.port-in'); | ||
| 1031 | + const inputPortRect = inputPort.getBoundingClientRect(); | ||
| 1032 | + | ||
| 1033 | + // 计算鼠标与端口的距离 | ||
| 1034 | + const dx = clientX - (inputPortRect.left + inputPortRect.width/2); | ||
| 1035 | + const dy = clientY - (inputPortRect.top + inputPortRect.height/2); | ||
| 1036 | + const distance = Math.sqrt(dx*dx + dy*dy); | ||
| 1037 | + | ||
| 1038 | + // 如果距离小于20像素,高亮端口 | ||
| 1039 | + if (distance < 20) { | ||
| 1040 | + inputPort.classList.add('port-highlight'); | ||
| 1041 | + | ||
| 1042 | + // 更新预览连接终点到端口中心 | ||
| 1043 | + end.x = inputPortRect.left + inputPortRect.width/2 - canvasRect.left; | ||
| 1044 | + end.y = inputPortRect.top + inputPortRect.height/2 - canvasRect.top; | ||
| 1045 | + } else { | ||
| 1046 | + inputPort.classList.remove('port-highlight'); | ||
| 1047 | + } | ||
| 1048 | + } | ||
| 1049 | + }); | ||
| 1050 | + | ||
| 923 | // 绘制预览连接 | 1051 | // 绘制预览连接 |
| 924 | const dx = Math.abs(end.x - start.x) * 0.5; | 1052 | const dx = Math.abs(end.x - start.x) * 0.5; |
| 925 | const pathData = `M ${start.x},${start.y} C ${start.x + dx},${start.y} ${end.x - dx},${end.y} ${end.x},${end.y}`; | 1053 | const pathData = `M ${start.x},${start.y} C ${start.x + dx},${start.y} ${end.x - dx},${end.y} ${end.x},${end.y}`; |
| @@ -928,11 +1056,26 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -928,11 +1056,26 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 928 | 1056 | ||
| 929 | // 完成连接 | 1057 | // 完成连接 |
| 930 | function completeConnection(sourceId, targetId) { | 1058 | function completeConnection(sourceId, targetId) { |
| 1059 | + // 检查是否是自连接 | ||
| 1060 | + if (sourceId === targetId) { | ||
| 1061 | + showNotification('警告', '不能连接到自己', 'warning'); | ||
| 1062 | + cancelConnection(); | ||
| 1063 | + return; | ||
| 1064 | + } | ||
| 1065 | + | ||
| 931 | // 检查连接是否已存在 | 1066 | // 检查连接是否已存在 |
| 932 | const connectionExists = workflowData.connections.some(conn => | 1067 | const connectionExists = workflowData.connections.some(conn => |
| 933 | conn.sourceId === sourceId && conn.targetId === targetId); | 1068 | conn.sourceId === sourceId && conn.targetId === targetId); |
| 934 | 1069 | ||
| 935 | if (connectionExists) { | 1070 | if (connectionExists) { |
| 1071 | + showNotification('警告', '连接已存在', 'warning'); | ||
| 1072 | + cancelConnection(); | ||
| 1073 | + return; | ||
| 1074 | + } | ||
| 1075 | + | ||
| 1076 | + // 检查是否会形成循环 | ||
| 1077 | + if (wouldCreateCycle(sourceId, targetId)) { | ||
| 1078 | + showNotification('错误', '不能创建循环连接', 'error'); | ||
| 936 | cancelConnection(); | 1079 | cancelConnection(); |
| 937 | return; | 1080 | return; |
| 938 | } | 1081 | } |
| @@ -952,6 +1095,12 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -952,6 +1095,12 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 952 | 1095 | ||
| 953 | // 清理预览状态 | 1096 | // 清理预览状态 |
| 954 | cancelConnection(); | 1097 | cancelConnection(); |
| 1098 | + | ||
| 1099 | + // 更新工作流状态 | ||
| 1100 | + onWorkflowChanged(); | ||
| 1101 | + | ||
| 1102 | + // 显示成功通知 | ||
| 1103 | + showNotification('成功', '已创建连接', 'success'); | ||
| 955 | } | 1104 | } |
| 956 | 1105 | ||
| 957 | // 取消连接操作 | 1106 | // 取消连接操作 |
| @@ -960,6 +1109,11 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -960,6 +1109,11 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 960 | connectionPreviewPath.parentNode.removeChild(connectionPreviewPath); | 1109 | connectionPreviewPath.parentNode.removeChild(connectionPreviewPath); |
| 961 | } | 1110 | } |
| 962 | 1111 | ||
| 1112 | + // 移除所有高亮端口 | ||
| 1113 | + document.querySelectorAll('.port-highlight').forEach(port => { | ||
| 1114 | + port.classList.remove('port-highlight'); | ||
| 1115 | + }); | ||
| 1116 | + | ||
| 963 | isConnecting = false; | 1117 | isConnecting = false; |
| 964 | connectionStart = null; | 1118 | connectionStart = null; |
| 965 | connectionPreviewPath = null; | 1119 | connectionPreviewPath = null; |
| @@ -976,8 +1130,11 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -976,8 +1130,11 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 976 | // 生成配置表单 | 1130 | // 生成配置表单 |
| 977 | const configOptions = getComponentConfigs(nodeData.type, nodeData.subtype); | 1131 | const configOptions = getComponentConfigs(nodeData.type, nodeData.subtype); |
| 978 | let formHtml = ` | 1132 | let formHtml = ` |
| 979 | - <h6 class="mb-3">${nodeData.title} 配置</h6> | ||
| 980 | - <form id="nodeConfigForm"> | 1133 | + <div class="properties-header"> |
| 1134 | + <h6 class="mb-0">${nodeData.title} 配置</h6> | ||
| 1135 | + <span class="badge bg-secondary">${nodeData.id}</span> | ||
| 1136 | + </div> | ||
| 1137 | + <form id="nodeConfigForm" class="mt-3"> | ||
| 981 | `; | 1138 | `; |
| 982 | 1139 | ||
| 983 | configOptions.forEach(option => { | 1140 | configOptions.forEach(option => { |
| @@ -1004,15 +1161,32 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -1004,15 +1161,32 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 1004 | `; | 1161 | `; |
| 1005 | } else if (option.type === 'textarea') { | 1162 | } else if (option.type === 'textarea') { |
| 1006 | formHtml += `<textarea class="form-control" id="${option.id}" name="${option.id}" rows="3">${value}</textarea>`; | 1163 | formHtml += `<textarea class="form-control" id="${option.id}" name="${option.id}" rows="3">${value}</textarea>`; |
| 1164 | + if (option.placeholder) { | ||
| 1165 | + formHtml += `<div class="form-text">${option.placeholder}</div>`; | ||
| 1166 | + } | ||
| 1007 | } else { | 1167 | } else { |
| 1008 | - formHtml += `<input type="${option.type}" class="form-control" id="${option.id}" name="${option.id}" value="${value}">`; | 1168 | + formHtml += `<input type="${option.type}" class="form-control" id="${option.id}" name="${option.id}" value="${value}"`; |
| 1169 | + if (option.placeholder) { | ||
| 1170 | + formHtml += ` placeholder="${option.placeholder}"`; | ||
| 1171 | + } | ||
| 1172 | + if (option.min !== undefined) { | ||
| 1173 | + formHtml += ` min="${option.min}"`; | ||
| 1174 | + } | ||
| 1175 | + if (option.max !== undefined) { | ||
| 1176 | + formHtml += ` max="${option.max}"`; | ||
| 1177 | + } | ||
| 1178 | + formHtml += `>`; | ||
| 1179 | + if (option.helpText) { | ||
| 1180 | + formHtml += `<div class="form-text">${option.helpText}</div>`; | ||
| 1181 | + } | ||
| 1009 | } | 1182 | } |
| 1010 | 1183 | ||
| 1011 | formHtml += `</div>`; | 1184 | formHtml += `</div>`; |
| 1012 | }); | 1185 | }); |
| 1013 | 1186 | ||
| 1014 | formHtml += ` | 1187 | formHtml += ` |
| 1015 | - <div class="d-flex justify-content-end"> | 1188 | + <div class="d-flex justify-content-between mt-4"> |
| 1189 | + <button type="button" class="btn btn-secondary" id="cancelConfigBtn">取消</button> | ||
| 1016 | <button type="button" class="btn btn-primary" id="saveConfigBtn">应用</button> | 1190 | <button type="button" class="btn btn-primary" id="saveConfigBtn">应用</button> |
| 1017 | </div> | 1191 | </div> |
| 1018 | </form> | 1192 | </form> |
| @@ -1023,6 +1197,11 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -1023,6 +1197,11 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 1023 | // 保存配置事件 | 1197 | // 保存配置事件 |
| 1024 | document.getElementById('saveConfigBtn').addEventListener('click', function() { | 1198 | document.getElementById('saveConfigBtn').addEventListener('click', function() { |
| 1025 | const form = document.getElementById('nodeConfigForm'); | 1199 | const form = document.getElementById('nodeConfigForm'); |
| 1200 | + if (!form.checkValidity()) { | ||
| 1201 | + form.reportValidity(); | ||
| 1202 | + return; | ||
| 1203 | + } | ||
| 1204 | + | ||
| 1026 | const formData = new FormData(form); | 1205 | const formData = new FormData(form); |
| 1027 | const config = {}; | 1206 | const config = {}; |
| 1028 | 1207 | ||
| @@ -1046,13 +1225,23 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -1046,13 +1225,23 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 1046 | const descElement = nodeElement.querySelector('.node-description'); | 1225 | const descElement = nodeElement.querySelector('.node-description'); |
| 1047 | if (descElement) { | 1226 | if (descElement) { |
| 1048 | descElement.textContent = '已配置'; | 1227 | descElement.textContent = '已配置'; |
| 1228 | + descElement.classList.add('configured'); | ||
| 1049 | } | 1229 | } |
| 1050 | } | 1230 | } |
| 1231 | + | ||
| 1232 | + // 添加到历史记录 | ||
| 1233 | + addToHistory(); | ||
| 1234 | + | ||
| 1235 | + // 显示成功通知 | ||
| 1236 | + showNotification('成功', '节点配置已更新', 'success'); | ||
| 1051 | } | 1237 | } |
| 1052 | 1238 | ||
| 1053 | // 关闭面板 | 1239 | // 关闭面板 |
| 1054 | closePropertiesPanel(); | 1240 | closePropertiesPanel(); |
| 1055 | }); | 1241 | }); |
| 1242 | + | ||
| 1243 | + // 取消配置事件 | ||
| 1244 | + document.getElementById('cancelConfigBtn').addEventListener('click', closePropertiesPanel); | ||
| 1056 | } | 1245 | } |
| 1057 | 1246 | ||
| 1058 | // 关闭属性面板 | 1247 | // 关闭属性面板 |
| @@ -1062,4 +1251,1206 @@ document.addEventListener('DOMContentLoaded', function() { | @@ -1062,4 +1251,1206 @@ document.addEventListener('DOMContentLoaded', function() { | ||
| 1062 | 1251 | ||
| 1063 | // 绑定关闭属性面板的事件 | 1252 | // 绑定关闭属性面板的事件 |
| 1064 | document.getElementById('closePropertiesBtn').addEventListener('click', closePropertiesPanel); | 1253 | document.getElementById('closePropertiesBtn').addEventListener('click', closePropertiesPanel); |
| 1254 | + | ||
| 1255 | + function initializeAutoSave() { | ||
| 1256 | + // 开始自动保存计时器 | ||
| 1257 | + startAutoSaveTimer(); | ||
| 1258 | + | ||
| 1259 | + // 添加用户交互检测 | ||
| 1260 | + workflowCanvas.addEventListener('mousedown', resetAutoSaveTimer); | ||
| 1261 | + document.addEventListener('keydown', resetAutoSaveTimer); | ||
| 1262 | + } | ||
| 1263 | + | ||
| 1264 | + function startAutoSaveTimer() { | ||
| 1265 | + if (autoSaveTimer) { | ||
| 1266 | + clearTimeout(autoSaveTimer); | ||
| 1267 | + } | ||
| 1268 | + | ||
| 1269 | + autoSaveTimer = setTimeout(function() { | ||
| 1270 | + // 只在有节点时自动保存 | ||
| 1271 | + if (workflowData.nodes.length > 0) { | ||
| 1272 | + console.log('自动保存工作流...'); | ||
| 1273 | + // 设置自动保存标志 | ||
| 1274 | + workflowData.autoSaved = true; | ||
| 1275 | + saveWorkflow(workflowData); | ||
| 1276 | + } | ||
| 1277 | + // 重新开始计时器 | ||
| 1278 | + startAutoSaveTimer(); | ||
| 1279 | + }, AUTO_SAVE_INTERVAL); | ||
| 1280 | + } | ||
| 1281 | + | ||
| 1282 | + function resetAutoSaveTimer() { | ||
| 1283 | + startAutoSaveTimer(); | ||
| 1284 | + } | ||
| 1285 | + | ||
| 1286 | + // 初始化自动保存 | ||
| 1287 | + initializeAutoSave(); | ||
| 1288 | + | ||
| 1289 | + // 优化拖放功能 | ||
| 1290 | + workflowCanvas.addEventListener('dragover', function(e) { | ||
| 1291 | + e.preventDefault(); | ||
| 1292 | + e.dataTransfer.dropEffect = 'copy'; | ||
| 1293 | + | ||
| 1294 | + // 显示可放置区域指示 | ||
| 1295 | + this.classList.add('drag-over'); | ||
| 1296 | + }); | ||
| 1297 | + | ||
| 1298 | + workflowCanvas.addEventListener('dragleave', function() { | ||
| 1299 | + // 移除可放置区域指示 | ||
| 1300 | + this.classList.remove('drag-over'); | ||
| 1301 | + }); | ||
| 1302 | + | ||
| 1303 | + workflowCanvas.addEventListener('drop', function(e) { | ||
| 1304 | + e.preventDefault(); | ||
| 1305 | + this.classList.remove('drag-over'); | ||
| 1306 | + | ||
| 1307 | + const componentType = e.dataTransfer.getData('componentType'); | ||
| 1308 | + const componentSubtype = e.dataTransfer.getData('componentSubtype'); | ||
| 1309 | + | ||
| 1310 | + if (componentType && componentSubtype) { | ||
| 1311 | + const rect = workflowCanvas.getBoundingClientRect(); | ||
| 1312 | + // 计算相对于画布的坐标 | ||
| 1313 | + const x = e.clientX - rect.left; | ||
| 1314 | + const y = e.clientY - rect.top; | ||
| 1315 | + | ||
| 1316 | + // 添加节点并记录历史 | ||
| 1317 | + addNode(componentType, componentSubtype, x, y); | ||
| 1318 | + addToHistory(); | ||
| 1319 | + | ||
| 1320 | + // 用户反馈 | ||
| 1321 | + showNotification('成功', `已添加 ${getComponentTypeLabel(componentType)}-${componentSubtype} 节点`, 'success'); | ||
| 1322 | + } | ||
| 1323 | + }); | ||
| 1324 | + | ||
| 1325 | + // 优化创建节点函数 | ||
| 1326 | + function createNodeFromData(nodeData) { | ||
| 1327 | + // 检查节点是否已存在 | ||
| 1328 | + const existingNode = document.getElementById(nodeData.id); | ||
| 1329 | + if (existingNode) { | ||
| 1330 | + console.warn('节点已存在:', nodeData.id); | ||
| 1331 | + return existingNode; | ||
| 1332 | + } | ||
| 1333 | + | ||
| 1334 | + // 从数据创建节点DOM元素 | ||
| 1335 | + const nodeElement = document.createElement('div'); | ||
| 1336 | + nodeElement.className = 'workflow-node'; | ||
| 1337 | + nodeElement.id = nodeData.id; | ||
| 1338 | + | ||
| 1339 | + // 确保坐标有效 | ||
| 1340 | + const x = typeof nodeData.x === 'number' ? nodeData.x : 100; | ||
| 1341 | + const y = typeof nodeData.y === 'number' ? nodeData.y : 100; | ||
| 1342 | + | ||
| 1343 | + nodeElement.style.left = x + 'px'; | ||
| 1344 | + nodeElement.style.top = y + 'px'; | ||
| 1345 | + | ||
| 1346 | + // 根据节点类型设置不同的样式 | ||
| 1347 | + nodeElement.classList.add(`node-type-${nodeData.type}`); | ||
| 1348 | + | ||
| 1349 | + // 构建节点内容 | ||
| 1350 | + nodeElement.innerHTML = ` | ||
| 1351 | + <div class="node-header"> | ||
| 1352 | + <span class="node-title">${nodeData.title}</span> | ||
| 1353 | + <div class="node-type-badge">${getComponentTypeLabel(nodeData.type)}</div> | ||
| 1354 | + </div> | ||
| 1355 | + <div class="node-content"> | ||
| 1356 | + <div class="node-subtype">${nodeData.subtype}</div> | ||
| 1357 | + <p class="node-description">${nodeData.config ? '已配置' : '点击配置参数'}</p> | ||
| 1358 | + </div> | ||
| 1359 | + <div class="node-ports"> | ||
| 1360 | + <div class="port port-in" data-port-type="input" title="输入连接点"></div> | ||
| 1361 | + <div class="port port-out" data-port-type="output" title="输出连接点"></div> | ||
| 1362 | + </div> | ||
| 1363 | + <div class="node-actions"> | ||
| 1364 | + <button class="btn btn-sm btn-outline-danger delete-node-btn" title="删除节点"> | ||
| 1365 | + <i class="fas fa-trash-alt"></i> | ||
| 1366 | + </button> | ||
| 1367 | + <button class="btn btn-sm btn-outline-primary config-node-btn" title="配置节点"> | ||
| 1368 | + <i class="fas fa-cog"></i> | ||
| 1369 | + </button> | ||
| 1370 | + </div> | ||
| 1371 | + `; | ||
| 1372 | + | ||
| 1373 | + // 添加入场动画 | ||
| 1374 | + nodeElement.classList.add('node-entering'); | ||
| 1375 | + setTimeout(() => { | ||
| 1376 | + nodeElement.classList.remove('node-entering'); | ||
| 1377 | + }, 300); | ||
| 1378 | + | ||
| 1379 | + workflowCanvas.appendChild(nodeElement); | ||
| 1380 | + | ||
| 1381 | + // 绑定配置按钮事件 | ||
| 1382 | + const configBtn = nodeElement.querySelector('.config-node-btn'); | ||
| 1383 | + configBtn.addEventListener('click', function(e) { | ||
| 1384 | + e.stopPropagation(); | ||
| 1385 | + openNodeConfig(nodeData); | ||
| 1386 | + }); | ||
| 1387 | + | ||
| 1388 | + // 添加到节点数据 | ||
| 1389 | + const existingNodeIndex = workflowData.nodes.findIndex(node => node.id === nodeData.id); | ||
| 1390 | + if (existingNodeIndex === -1) { | ||
| 1391 | + workflowData.nodes.push(nodeData); | ||
| 1392 | + } else { | ||
| 1393 | + workflowData.nodes[existingNodeIndex] = nodeData; | ||
| 1394 | + } | ||
| 1395 | + | ||
| 1396 | + return nodeElement; | ||
| 1397 | + } | ||
| 1398 | + | ||
| 1399 | + // 优化节点配置面板 | ||
| 1400 | + function openNodeConfig(nodeData) { | ||
| 1401 | + const propertiesPanel = document.getElementById('propertiesPanel'); | ||
| 1402 | + const propertiesContent = document.getElementById('propertiesContent'); | ||
| 1403 | + | ||
| 1404 | + // 显示面板 | ||
| 1405 | + propertiesPanel.classList.add('open'); | ||
| 1406 | + | ||
| 1407 | + // 生成配置表单 | ||
| 1408 | + const configOptions = getComponentConfigs(nodeData.type, nodeData.subtype); | ||
| 1409 | + let formHtml = ` | ||
| 1410 | + <div class="properties-header"> | ||
| 1411 | + <h6 class="mb-0">${nodeData.title} 配置</h6> | ||
| 1412 | + <span class="badge bg-secondary">${nodeData.id}</span> | ||
| 1413 | + </div> | ||
| 1414 | + <form id="nodeConfigForm" class="mt-3"> | ||
| 1415 | + `; | ||
| 1416 | + | ||
| 1417 | + configOptions.forEach(option => { | ||
| 1418 | + const value = nodeData.config && nodeData.config[option.id] !== undefined ? | ||
| 1419 | + nodeData.config[option.id] : ''; | ||
| 1420 | + | ||
| 1421 | + formHtml += `<div class="mb-3"> | ||
| 1422 | + <label for="${option.id}" class="form-label">${option.label}</label>`; | ||
| 1423 | + | ||
| 1424 | + if (option.type === 'select') { | ||
| 1425 | + formHtml += `<select class="form-select" id="${option.id}" name="${option.id}">`; | ||
| 1426 | + option.options.forEach(opt => { | ||
| 1427 | + const selected = value === opt.value ? 'selected' : ''; | ||
| 1428 | + formHtml += `<option value="${opt.value}" ${selected}>${opt.label}</option>`; | ||
| 1429 | + }); | ||
| 1430 | + formHtml += `</select>`; | ||
| 1431 | + } else if (option.type === 'checkbox') { | ||
| 1432 | + const checked = value ? 'checked' : ''; | ||
| 1433 | + formHtml += ` | ||
| 1434 | + <div class="form-check"> | ||
| 1435 | + <input class="form-check-input" type="checkbox" id="${option.id}" name="${option.id}" ${checked}> | ||
| 1436 | + <label class="form-check-label" for="${option.id}">${option.label}</label> | ||
| 1437 | + </div> | ||
| 1438 | + `; | ||
| 1439 | + } else if (option.type === 'textarea') { | ||
| 1440 | + formHtml += `<textarea class="form-control" id="${option.id}" name="${option.id}" rows="3">${value}</textarea>`; | ||
| 1441 | + if (option.placeholder) { | ||
| 1442 | + formHtml += `<div class="form-text">${option.placeholder}</div>`; | ||
| 1443 | + } | ||
| 1444 | + } else { | ||
| 1445 | + formHtml += `<input type="${option.type}" class="form-control" id="${option.id}" name="${option.id}" value="${value}"`; | ||
| 1446 | + if (option.placeholder) { | ||
| 1447 | + formHtml += ` placeholder="${option.placeholder}"`; | ||
| 1448 | + } | ||
| 1449 | + if (option.min !== undefined) { | ||
| 1450 | + formHtml += ` min="${option.min}"`; | ||
| 1451 | + } | ||
| 1452 | + if (option.max !== undefined) { | ||
| 1453 | + formHtml += ` max="${option.max}"`; | ||
| 1454 | + } | ||
| 1455 | + formHtml += `>`; | ||
| 1456 | + if (option.helpText) { | ||
| 1457 | + formHtml += `<div class="form-text">${option.helpText}</div>`; | ||
| 1458 | + } | ||
| 1459 | + } | ||
| 1460 | + | ||
| 1461 | + formHtml += `</div>`; | ||
| 1462 | + }); | ||
| 1463 | + | ||
| 1464 | + formHtml += ` | ||
| 1465 | + <div class="d-flex justify-content-between mt-4"> | ||
| 1466 | + <button type="button" class="btn btn-secondary" id="cancelConfigBtn">取消</button> | ||
| 1467 | + <button type="button" class="btn btn-primary" id="saveConfigBtn">应用</button> | ||
| 1468 | + </div> | ||
| 1469 | + </form> | ||
| 1470 | + `; | ||
| 1471 | + | ||
| 1472 | + propertiesContent.innerHTML = formHtml; | ||
| 1473 | + | ||
| 1474 | + // 保存配置事件 | ||
| 1475 | + document.getElementById('saveConfigBtn').addEventListener('click', function() { | ||
| 1476 | + const form = document.getElementById('nodeConfigForm'); | ||
| 1477 | + if (!form.checkValidity()) { | ||
| 1478 | + form.reportValidity(); | ||
| 1479 | + return; | ||
| 1480 | + } | ||
| 1481 | + | ||
| 1482 | + const formData = new FormData(form); | ||
| 1483 | + const config = {}; | ||
| 1484 | + | ||
| 1485 | + // 构建配置对象 | ||
| 1486 | + configOptions.forEach(option => { | ||
| 1487 | + if (option.type === 'checkbox') { | ||
| 1488 | + config[option.id] = document.getElementById(option.id).checked; | ||
| 1489 | + } else { | ||
| 1490 | + config[option.id] = formData.get(option.id); | ||
| 1491 | + } | ||
| 1492 | + }); | ||
| 1493 | + | ||
| 1494 | + // 更新节点配置 | ||
| 1495 | + const node = workflowData.nodes.find(n => n.id === nodeData.id); | ||
| 1496 | + if (node) { | ||
| 1497 | + node.config = config; | ||
| 1498 | + | ||
| 1499 | + // 更新节点显示 | ||
| 1500 | + const nodeElement = document.getElementById(nodeData.id); | ||
| 1501 | + if (nodeElement) { | ||
| 1502 | + const descElement = nodeElement.querySelector('.node-description'); | ||
| 1503 | + if (descElement) { | ||
| 1504 | + descElement.textContent = '已配置'; | ||
| 1505 | + descElement.classList.add('configured'); | ||
| 1506 | + } | ||
| 1507 | + } | ||
| 1508 | + | ||
| 1509 | + // 添加到历史记录 | ||
| 1510 | + addToHistory(); | ||
| 1511 | + | ||
| 1512 | + // 显示成功通知 | ||
| 1513 | + showNotification('成功', '节点配置已更新', 'success'); | ||
| 1514 | + } | ||
| 1515 | + | ||
| 1516 | + // 关闭面板 | ||
| 1517 | + closePropertiesPanel(); | ||
| 1518 | + }); | ||
| 1519 | + | ||
| 1520 | + // 取消配置事件 | ||
| 1521 | + document.getElementById('cancelConfigBtn').addEventListener('click', closePropertiesPanel); | ||
| 1522 | + } | ||
| 1523 | + | ||
| 1524 | + // 改进连接预览 | ||
| 1525 | + function updateConnectionPreview(clientX, clientY) { | ||
| 1526 | + if (!connectionStart || !connectionPreviewPath) return; | ||
| 1527 | + | ||
| 1528 | + const sourceNode = document.getElementById(connectionStart.id); | ||
| 1529 | + if (!sourceNode) return; | ||
| 1530 | + | ||
| 1531 | + const sourcePort = sourceNode.querySelector('.port-out'); | ||
| 1532 | + const sourceRect = sourcePort.getBoundingClientRect(); | ||
| 1533 | + const canvasRect = workflowCanvas.getBoundingClientRect(); | ||
| 1534 | + | ||
| 1535 | + const start = { | ||
| 1536 | + x: sourceRect.left + sourceRect.width/2 - canvasRect.left, | ||
| 1537 | + y: sourceRect.top + sourceRect.height/2 - canvasRect.top | ||
| 1538 | + }; | ||
| 1539 | + | ||
| 1540 | + const end = { | ||
| 1541 | + x: clientX - canvasRect.left, | ||
| 1542 | + y: clientY - canvasRect.top | ||
| 1543 | + }; | ||
| 1544 | + | ||
| 1545 | + // 高亮可连接的目标端口 | ||
| 1546 | + document.querySelectorAll('.workflow-node').forEach(node => { | ||
| 1547 | + if (node.id !== connectionStart.id) { | ||
| 1548 | + const inputPort = node.querySelector('.port-in'); | ||
| 1549 | + const inputPortRect = inputPort.getBoundingClientRect(); | ||
| 1550 | + | ||
| 1551 | + // 计算鼠标与端口的距离 | ||
| 1552 | + const dx = clientX - (inputPortRect.left + inputPortRect.width/2); | ||
| 1553 | + const dy = clientY - (inputPortRect.top + inputPortRect.height/2); | ||
| 1554 | + const distance = Math.sqrt(dx*dx + dy*dy); | ||
| 1555 | + | ||
| 1556 | + // 如果距离小于20像素,高亮端口 | ||
| 1557 | + if (distance < 20) { | ||
| 1558 | + inputPort.classList.add('port-highlight'); | ||
| 1559 | + | ||
| 1560 | + // 更新预览连接终点到端口中心 | ||
| 1561 | + end.x = inputPortRect.left + inputPortRect.width/2 - canvasRect.left; | ||
| 1562 | + end.y = inputPortRect.top + inputPortRect.height/2 - canvasRect.top; | ||
| 1563 | + } else { | ||
| 1564 | + inputPort.classList.remove('port-highlight'); | ||
| 1565 | + } | ||
| 1566 | + } | ||
| 1567 | + }); | ||
| 1568 | + | ||
| 1569 | + // 绘制预览连接 | ||
| 1570 | + const dx = Math.abs(end.x - start.x) * 0.5; | ||
| 1571 | + const pathData = `M ${start.x},${start.y} C ${start.x + dx},${start.y} ${end.x - dx},${end.y} ${end.x},${end.y}`; | ||
| 1572 | + connectionPreviewPath.setAttribute('d', pathData); | ||
| 1573 | + } | ||
| 1574 | + | ||
| 1575 | + // 取消连接操作时清除高亮 | ||
| 1576 | + function cancelConnection() { | ||
| 1577 | + if (connectionPreviewPath && connectionPreviewPath.parentNode) { | ||
| 1578 | + connectionPreviewPath.parentNode.removeChild(connectionPreviewPath); | ||
| 1579 | + } | ||
| 1580 | + | ||
| 1581 | + // 移除所有高亮端口 | ||
| 1582 | + document.querySelectorAll('.port-highlight').forEach(port => { | ||
| 1583 | + port.classList.remove('port-highlight'); | ||
| 1584 | + }); | ||
| 1585 | + | ||
| 1586 | + isConnecting = false; | ||
| 1587 | + connectionStart = null; | ||
| 1588 | + connectionPreviewPath = null; | ||
| 1589 | + } | ||
| 1590 | + | ||
| 1591 | + // 优化连接完成处理 | ||
| 1592 | + function completeConnection(sourceId, targetId) { | ||
| 1593 | + // 检查是否是自连接 | ||
| 1594 | + if (sourceId === targetId) { | ||
| 1595 | + showNotification('警告', '不能连接到自己', 'warning'); | ||
| 1596 | + cancelConnection(); | ||
| 1597 | + return; | ||
| 1598 | + } | ||
| 1599 | + | ||
| 1600 | + // 检查连接是否已存在 | ||
| 1601 | + const connectionExists = workflowData.connections.some(conn => | ||
| 1602 | + conn.sourceId === sourceId && conn.targetId === targetId); | ||
| 1603 | + | ||
| 1604 | + if (connectionExists) { | ||
| 1605 | + showNotification('警告', '连接已存在', 'warning'); | ||
| 1606 | + cancelConnection(); | ||
| 1607 | + return; | ||
| 1608 | + } | ||
| 1609 | + | ||
| 1610 | + // 检查是否会形成循环 | ||
| 1611 | + if (wouldCreateCycle(sourceId, targetId)) { | ||
| 1612 | + showNotification('错误', '不能创建循环连接', 'error'); | ||
| 1613 | + cancelConnection(); | ||
| 1614 | + return; | ||
| 1615 | + } | ||
| 1616 | + | ||
| 1617 | + // 生成连接ID | ||
| 1618 | + const connectionId = `conn_${Date.now()}`; | ||
| 1619 | + | ||
| 1620 | + // 添加到数据中 | ||
| 1621 | + workflowData.connections.push({ | ||
| 1622 | + id: connectionId, | ||
| 1623 | + sourceId: sourceId, | ||
| 1624 | + targetId: targetId | ||
| 1625 | + }); | ||
| 1626 | + | ||
| 1627 | + // 绘制最终连接 | ||
| 1628 | + drawConnection(sourceId, targetId, connectionId); | ||
| 1629 | + | ||
| 1630 | + // 清理预览状态 | ||
| 1631 | + cancelConnection(); | ||
| 1632 | + | ||
| 1633 | + // 更新工作流状态 | ||
| 1634 | + onWorkflowChanged(); | ||
| 1635 | + | ||
| 1636 | + // 显示成功通知 | ||
| 1637 | + showNotification('成功', '已创建连接', 'success'); | ||
| 1638 | + } | ||
| 1639 | + | ||
| 1640 | + // 检查是否会形成循环 | ||
| 1641 | + function wouldCreateCycle(sourceId, targetId) { | ||
| 1642 | + // 如果目标节点可以到达源节点,那么添加这条边会导致循环 | ||
| 1643 | + return canReach(targetId, sourceId, new Set()); | ||
| 1644 | + } | ||
| 1645 | + | ||
| 1646 | + // 检查从startId是否可以到达endId | ||
| 1647 | + function canReach(startId, endId, visited) { | ||
| 1648 | + if (startId === endId) return true; | ||
| 1649 | + | ||
| 1650 | + // 标记当前节点为已访问 | ||
| 1651 | + visited.add(startId); | ||
| 1652 | + | ||
| 1653 | + // 获取startId的所有出边 | ||
| 1654 | + const outConnections = workflowData.connections.filter(conn => conn.sourceId === startId); | ||
| 1655 | + | ||
| 1656 | + // 检查每条出边 | ||
| 1657 | + for (const conn of outConnections) { | ||
| 1658 | + const nextId = conn.targetId; | ||
| 1659 | + | ||
| 1660 | + // 如果下一个节点未访问,继续搜索 | ||
| 1661 | + if (!visited.has(nextId)) { | ||
| 1662 | + if (canReach(nextId, endId, visited)) { | ||
| 1663 | + return true; | ||
| 1664 | + } | ||
| 1665 | + } | ||
| 1666 | + } | ||
| 1667 | + | ||
| 1668 | + return false; | ||
| 1669 | + } | ||
| 1670 | + | ||
| 1671 | + // 验证工作流 | ||
| 1672 | + function validateWorkflow(workflow) { | ||
| 1673 | + // 检查是否有节点 | ||
| 1674 | + if (!workflow.nodes || workflow.nodes.length === 0) { | ||
| 1675 | + return { valid: false, message: '工作流没有节点' }; | ||
| 1676 | + } | ||
| 1677 | + | ||
| 1678 | + // 检查是否有连接 | ||
| 1679 | + if (!workflow.connections || workflow.connections.length === 0) { | ||
| 1680 | + return { valid: false, message: '工作流没有连接' }; | ||
| 1681 | + } | ||
| 1682 | + | ||
| 1683 | + // 检查是否存在没有配置的节点 | ||
| 1684 | + const unconfiguredNodes = workflow.nodes.filter(node => !node.config); | ||
| 1685 | + if (unconfiguredNodes.length > 0) { | ||
| 1686 | + const nodeTitles = unconfiguredNodes.map(n => n.title).join(', '); | ||
| 1687 | + return { valid: false, message: `以下节点未配置: ${nodeTitles}` }; | ||
| 1688 | + } | ||
| 1689 | + | ||
| 1690 | + // 检查是否存在没有输入的节点(除了数据源类型) | ||
| 1691 | + const nonSourceNodes = workflow.nodes.filter(node => node.type !== 'data_source'); | ||
| 1692 | + for (const node of nonSourceNodes) { | ||
| 1693 | + const hasInput = workflow.connections.some(conn => conn.targetId === node.id); | ||
| 1694 | + if (!hasInput) { | ||
| 1695 | + return { valid: false, message: `节点 ${node.title} 没有输入连接` }; | ||
| 1696 | + } | ||
| 1697 | + } | ||
| 1698 | + | ||
| 1699 | + // 检查是否存在没有输出的节点(除了可视化类型) | ||
| 1700 | + const nonVisNodes = workflow.nodes.filter(node => node.type !== 'visualization'); | ||
| 1701 | + for (const node of nonVisNodes) { | ||
| 1702 | + const hasOutput = workflow.connections.some(conn => conn.sourceId === node.id); | ||
| 1703 | + if (!hasOutput) { | ||
| 1704 | + return { valid: false, message: `节点 ${node.title} 没有输出连接` }; | ||
| 1705 | + } | ||
| 1706 | + } | ||
| 1707 | + | ||
| 1708 | + return { valid: true }; | ||
| 1709 | + } | ||
| 1710 | + | ||
| 1711 | + // 初始化撤销/重做按钮 | ||
| 1712 | + function initializeToolbarButtons() { | ||
| 1713 | + const undoBtn = document.createElement('button'); | ||
| 1714 | + undoBtn.id = 'undoBtn'; | ||
| 1715 | + undoBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn'; | ||
| 1716 | + undoBtn.title = '撤销'; | ||
| 1717 | + undoBtn.innerHTML = '<i class="fas fa-undo"></i>'; | ||
| 1718 | + undoBtn.disabled = true; | ||
| 1719 | + undoBtn.addEventListener('click', undo); | ||
| 1720 | + | ||
| 1721 | + const redoBtn = document.createElement('button'); | ||
| 1722 | + redoBtn.id = 'redoBtn'; | ||
| 1723 | + redoBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn'; | ||
| 1724 | + redoBtn.title = '重做'; | ||
| 1725 | + redoBtn.innerHTML = '<i class="fas fa-redo"></i>'; | ||
| 1726 | + redoBtn.disabled = true; | ||
| 1727 | + redoBtn.addEventListener('click', redo); | ||
| 1728 | + | ||
| 1729 | + // 查找工具栏容器 | ||
| 1730 | + const toolbarContainer = document.getElementById('workflowToolbar'); | ||
| 1731 | + if (toolbarContainer) { | ||
| 1732 | + toolbarContainer.prepend(redoBtn); | ||
| 1733 | + toolbarContainer.prepend(undoBtn); | ||
| 1734 | + } else { | ||
| 1735 | + // 如果没有找到工具栏,创建一个浮动工具栏 | ||
| 1736 | + const floatingToolbar = document.createElement('div'); | ||
| 1737 | + floatingToolbar.id = 'workflowToolbar'; | ||
| 1738 | + floatingToolbar.className = 'workflow-floating-toolbar'; | ||
| 1739 | + floatingToolbar.appendChild(undoBtn); | ||
| 1740 | + floatingToolbar.appendChild(redoBtn); | ||
| 1741 | + | ||
| 1742 | + document.body.appendChild(floatingToolbar); | ||
| 1743 | + } | ||
| 1744 | + } | ||
| 1745 | + | ||
| 1746 | + // 改进显示示例模板逻辑 | ||
| 1747 | + function showSampleTemplates() { | ||
| 1748 | + // 使用示例模板数据 | ||
| 1749 | + const sampleTemplates = [ | ||
| 1750 | + { | ||
| 1751 | + id: 'template_1', | ||
| 1752 | + name: '微博热搜分析模板', | ||
| 1753 | + description: '爬取微博热搜榜数据,分析热点话题和情感倾向', | ||
| 1754 | + icon: 'fire' | ||
| 1755 | + }, | ||
| 1756 | + { | ||
| 1757 | + id: 'template_2', | ||
| 1758 | + name: '用户评论情感分析', | ||
| 1759 | + description: '分析用户评论的情感倾向,生成情感分布图表', | ||
| 1760 | + icon: 'heart' | ||
| 1761 | + }, | ||
| 1762 | + { | ||
| 1763 | + id: 'template_3', | ||
| 1764 | + name: '话题趋势监测', | ||
| 1765 | + description: '监测特定话题的讨论热度变化及关键词提取', | ||
| 1766 | + icon: 'chart-line' | ||
| 1767 | + }, | ||
| 1768 | + { | ||
| 1769 | + id: 'template_4', | ||
| 1770 | + name: '舆情预警分析', | ||
| 1771 | + description: '实时监测并预警负面舆情,生成应对建议', | ||
| 1772 | + icon: 'bell' | ||
| 1773 | + } | ||
| 1774 | + ]; | ||
| 1775 | + | ||
| 1776 | + try { | ||
| 1777 | + // 尝试寻找合适的容器 | ||
| 1778 | + const containers = [ | ||
| 1779 | + document.getElementById('analysisTemplatesList'), | ||
| 1780 | + document.getElementById('templateList'), | ||
| 1781 | + document.getElementById('crawlerTemplatesList'), | ||
| 1782 | + document.querySelector('.templates-container') | ||
| 1783 | + ]; | ||
| 1784 | + | ||
| 1785 | + const container = containers.find(el => el !== null); | ||
| 1786 | + | ||
| 1787 | + if (container) { | ||
| 1788 | + container.innerHTML = ''; | ||
| 1789 | + sampleTemplates.forEach(template => { | ||
| 1790 | + const templateDiv = createTemplateCard(template); | ||
| 1791 | + container.appendChild(templateDiv); | ||
| 1792 | + }); | ||
| 1793 | + } else { | ||
| 1794 | + console.warn('未找到合适的模板容器'); | ||
| 1795 | + | ||
| 1796 | + // 如果找不到容器,尝试创建一个模板区域 | ||
| 1797 | + const templatesPanel = document.getElementById('templatesPanel'); | ||
| 1798 | + if (templatesPanel) { | ||
| 1799 | + const newContainer = document.createElement('div'); | ||
| 1800 | + newContainer.className = 'templates-container'; | ||
| 1801 | + templatesPanel.appendChild(newContainer); | ||
| 1802 | + | ||
| 1803 | + sampleTemplates.forEach(template => { | ||
| 1804 | + const templateDiv = createTemplateCard(template); | ||
| 1805 | + newContainer.appendChild(templateDiv); | ||
| 1806 | + }); | ||
| 1807 | + } | ||
| 1808 | + } | ||
| 1809 | + } catch (error) { | ||
| 1810 | + console.error('加载模板出错:', error); | ||
| 1811 | + } | ||
| 1812 | + } | ||
| 1813 | + | ||
| 1814 | + // 添加键盘快捷键支持 | ||
| 1815 | + function setupKeyboardShortcuts() { | ||
| 1816 | + document.addEventListener('keydown', function(e) { | ||
| 1817 | + // Ctrl+Z: 撤销 | ||
| 1818 | + if (e.ctrlKey && e.key === 'z') { | ||
| 1819 | + e.preventDefault(); | ||
| 1820 | + undo(); | ||
| 1821 | + } | ||
| 1822 | + | ||
| 1823 | + // Ctrl+Y: 重做 | ||
| 1824 | + if (e.ctrlKey && e.key === 'y') { | ||
| 1825 | + e.preventDefault(); | ||
| 1826 | + redo(); | ||
| 1827 | + } | ||
| 1828 | + | ||
| 1829 | + // Ctrl+S: 保存 | ||
| 1830 | + if (e.ctrlKey && e.key === 's') { | ||
| 1831 | + e.preventDefault(); | ||
| 1832 | + saveWorkflow(workflowData); | ||
| 1833 | + } | ||
| 1834 | + | ||
| 1835 | + // Delete: 删除选中的节点 | ||
| 1836 | + if (e.key === 'Delete') { | ||
| 1837 | + const selectedNode = document.querySelector('.workflow-node.selected'); | ||
| 1838 | + if (selectedNode) { | ||
| 1839 | + deleteNode(selectedNode.id); | ||
| 1840 | + addToHistory(); | ||
| 1841 | + } | ||
| 1842 | + } | ||
| 1843 | + | ||
| 1844 | + // Escape: 取消连接或关闭配置面板 | ||
| 1845 | + if (e.key === 'Escape') { | ||
| 1846 | + if (isConnecting) { | ||
| 1847 | + cancelConnection(); | ||
| 1848 | + } else if (document.getElementById('propertiesPanel').classList.contains('open')) { | ||
| 1849 | + closePropertiesPanel(); | ||
| 1850 | + } | ||
| 1851 | + } | ||
| 1852 | + }); | ||
| 1853 | + } | ||
| 1854 | + | ||
| 1855 | + // 添加节点选择功能 | ||
| 1856 | + function setupNodeSelection() { | ||
| 1857 | + workflowCanvas.addEventListener('click', function(e) { | ||
| 1858 | + if (e.target === workflowCanvas || e.target === connectionsSvg) { | ||
| 1859 | + // 如果点击的是画布本身,清除所有选中 | ||
| 1860 | + clearNodeSelection(); | ||
| 1861 | + } | ||
| 1862 | + }); | ||
| 1863 | + } | ||
| 1864 | + | ||
| 1865 | + // 清除节点选择 | ||
| 1866 | + function clearNodeSelection() { | ||
| 1867 | + document.querySelectorAll('.workflow-node.selected').forEach(node => { | ||
| 1868 | + node.classList.remove('selected'); | ||
| 1869 | + }); | ||
| 1870 | + } | ||
| 1871 | + | ||
| 1872 | + // 为节点添加选择功能 | ||
| 1873 | + function setupNodeSelectEvents(nodeElement) { | ||
| 1874 | + nodeElement.addEventListener('click', function(e) { | ||
| 1875 | + // 如果没有按下Ctrl键,先清除其他节点的选择 | ||
| 1876 | + if (!e.ctrlKey) { | ||
| 1877 | + clearNodeSelection(); | ||
| 1878 | + } | ||
| 1879 | + | ||
| 1880 | + // 选择当前节点 | ||
| 1881 | + nodeElement.classList.add('selected'); | ||
| 1882 | + e.stopPropagation(); | ||
| 1883 | + }); | ||
| 1884 | + } | ||
| 1885 | + | ||
| 1886 | + // 启用键盘快捷键和节点选择功能 | ||
| 1887 | + setupKeyboardShortcuts(); | ||
| 1888 | + setupNodeSelection(); | ||
| 1889 | + | ||
| 1890 | + // 初始化 | ||
| 1891 | + initializeToolbarButtons(); | ||
| 1892 | + // 初始化:将当前状态添加到历史记录 | ||
| 1893 | + addToHistory(); | ||
| 1894 | + | ||
| 1895 | + // 添加导出/导入功能 | ||
| 1896 | + function setupExportImport() { | ||
| 1897 | + const exportBtn = document.createElement('button'); | ||
| 1898 | + exportBtn.id = 'exportWorkflowBtn'; | ||
| 1899 | + exportBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn'; | ||
| 1900 | + exportBtn.title = '导出工作流'; | ||
| 1901 | + exportBtn.innerHTML = '<i class="fas fa-file-export"></i>'; | ||
| 1902 | + exportBtn.addEventListener('click', exportWorkflow); | ||
| 1903 | + | ||
| 1904 | + const importBtn = document.createElement('button'); | ||
| 1905 | + importBtn.id = 'importWorkflowBtn'; | ||
| 1906 | + importBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn'; | ||
| 1907 | + importBtn.title = '导入工作流'; | ||
| 1908 | + importBtn.innerHTML = '<i class="fas fa-file-import"></i>'; | ||
| 1909 | + importBtn.addEventListener('click', importWorkflow); | ||
| 1910 | + | ||
| 1911 | + // 添加到工具栏 | ||
| 1912 | + const toolbarContainer = document.getElementById('workflowToolbar'); | ||
| 1913 | + if (toolbarContainer) { | ||
| 1914 | + toolbarContainer.appendChild(exportBtn); | ||
| 1915 | + toolbarContainer.appendChild(importBtn); | ||
| 1916 | + } | ||
| 1917 | + } | ||
| 1918 | + | ||
| 1919 | + function exportWorkflow() { | ||
| 1920 | + // 创建下载内容 | ||
| 1921 | + const workflowJson = JSON.stringify(workflowData, null, 2); | ||
| 1922 | + const blob = new Blob([workflowJson], {type: 'application/json'}); | ||
| 1923 | + const url = URL.createObjectURL(blob); | ||
| 1924 | + | ||
| 1925 | + // 创建下载链接 | ||
| 1926 | + const a = document.createElement('a'); | ||
| 1927 | + a.href = url; | ||
| 1928 | + a.download = `${workflowData.metadata.name || 'workflow'}_${new Date().toISOString().slice(0,10)}.json`; | ||
| 1929 | + document.body.appendChild(a); | ||
| 1930 | + a.click(); | ||
| 1931 | + | ||
| 1932 | + // 清理 | ||
| 1933 | + setTimeout(() => { | ||
| 1934 | + document.body.removeChild(a); | ||
| 1935 | + URL.revokeObjectURL(url); | ||
| 1936 | + }, 0); | ||
| 1937 | + | ||
| 1938 | + showNotification('成功', '工作流已导出为JSON文件', 'success'); | ||
| 1939 | + } | ||
| 1940 | + | ||
| 1941 | + function importWorkflow() { | ||
| 1942 | + // 创建文件输入元素 | ||
| 1943 | + const fileInput = document.createElement('input'); | ||
| 1944 | + fileInput.type = 'file'; | ||
| 1945 | + fileInput.accept = 'application/json'; | ||
| 1946 | + fileInput.style.display = 'none'; | ||
| 1947 | + | ||
| 1948 | + fileInput.addEventListener('change', function(e) { | ||
| 1949 | + if (!e.target.files.length) return; | ||
| 1950 | + | ||
| 1951 | + const file = e.target.files[0]; | ||
| 1952 | + const reader = new FileReader(); | ||
| 1953 | + | ||
| 1954 | + reader.onload = function(event) { | ||
| 1955 | + try { | ||
| 1956 | + const importedWorkflow = JSON.parse(event.target.result); | ||
| 1957 | + | ||
| 1958 | + // 验证导入的数据 | ||
| 1959 | + if (!importedWorkflow.nodes || !importedWorkflow.metadata) { | ||
| 1960 | + throw new Error('无效的工作流文件格式'); | ||
| 1961 | + } | ||
| 1962 | + | ||
| 1963 | + // 提示用户确认 | ||
| 1964 | + if (confirm('确定要导入此工作流?这将替换当前的工作流。')) { | ||
| 1965 | + clearWorkflow(); | ||
| 1966 | + renderWorkflow(importedWorkflow); | ||
| 1967 | + addToHistory(); | ||
| 1968 | + showNotification('成功', '工作流已导入', 'success'); | ||
| 1969 | + } | ||
| 1970 | + } catch (err) { | ||
| 1971 | + console.error('导入工作流出错:', err); | ||
| 1972 | + showNotification('错误', '导入失败:无效的工作流文件', 'error'); | ||
| 1973 | + } | ||
| 1974 | + }; | ||
| 1975 | + | ||
| 1976 | + reader.readAsText(file); | ||
| 1977 | + }); | ||
| 1978 | + | ||
| 1979 | + document.body.appendChild(fileInput); | ||
| 1980 | + fileInput.click(); | ||
| 1981 | + | ||
| 1982 | + // 清理 | ||
| 1983 | + setTimeout(() => { | ||
| 1984 | + document.body.removeChild(fileInput); | ||
| 1985 | + }, 0); | ||
| 1986 | + } | ||
| 1987 | + | ||
| 1988 | + // 设置导出/导入功能 | ||
| 1989 | + setupExportImport(); | ||
| 1990 | + | ||
| 1991 | + // ====== 历史记录管理 ====== | ||
| 1992 | + function addToHistory() { | ||
| 1993 | + // 如果当前不是历史的最后一步,截断历史 | ||
| 1994 | + if (currentHistoryIndex < history.length - 1) { | ||
| 1995 | + history = history.slice(0, currentHistoryIndex + 1); | ||
| 1996 | + } | ||
| 1997 | + | ||
| 1998 | + // 深拷贝当前状态 | ||
| 1999 | + const stateCopy = JSON.parse(JSON.stringify(workflowData)); | ||
| 2000 | + history.push(stateCopy); | ||
| 2001 | + | ||
| 2002 | + // 限制历史记录大小 | ||
| 2003 | + if (history.length > MAX_HISTORY) { | ||
| 2004 | + history.shift(); | ||
| 2005 | + } else { | ||
| 2006 | + currentHistoryIndex++; | ||
| 2007 | + } | ||
| 2008 | + | ||
| 2009 | + // 更新撤销/重做按钮状态 | ||
| 2010 | + updateHistoryButtonStates(); | ||
| 2011 | + } | ||
| 2012 | + | ||
| 2013 | + function undo() { | ||
| 2014 | + if (currentHistoryIndex > 0) { | ||
| 2015 | + currentHistoryIndex--; | ||
| 2016 | + restoreFromHistory(); | ||
| 2017 | + showNotification('撤销', '已撤销上一步操作', 'info'); | ||
| 2018 | + } | ||
| 2019 | + } | ||
| 2020 | + | ||
| 2021 | + function redo() { | ||
| 2022 | + if (currentHistoryIndex < history.length - 1) { | ||
| 2023 | + currentHistoryIndex++; | ||
| 2024 | + restoreFromHistory(); | ||
| 2025 | + showNotification('重做', '已重做操作', 'info'); | ||
| 2026 | + } | ||
| 2027 | + } | ||
| 2028 | + | ||
| 2029 | + function restoreFromHistory() { | ||
| 2030 | + // 从历史记录恢复工作流状态 | ||
| 2031 | + const historicalState = history[currentHistoryIndex]; | ||
| 2032 | + | ||
| 2033 | + // 清除画布 | ||
| 2034 | + clearWorkflow(); | ||
| 2035 | + | ||
| 2036 | + // 恢复状态 | ||
| 2037 | + workflowData = JSON.parse(JSON.stringify(historicalState)); | ||
| 2038 | + renderWorkflow(workflowData); | ||
| 2039 | + | ||
| 2040 | + // 更新按钮状态 | ||
| 2041 | + updateHistoryButtonStates(); | ||
| 2042 | + } | ||
| 2043 | + | ||
| 2044 | + function updateHistoryButtonStates() { | ||
| 2045 | + const undoBtn = document.getElementById('undoBtn'); | ||
| 2046 | + const redoBtn = document.getElementById('redoBtn'); | ||
| 2047 | + | ||
| 2048 | + if (undoBtn) { | ||
| 2049 | + undoBtn.disabled = currentHistoryIndex <= 0; | ||
| 2050 | + } | ||
| 2051 | + | ||
| 2052 | + if (redoBtn) { | ||
| 2053 | + redoBtn.disabled = currentHistoryIndex >= history.length - 1; | ||
| 2054 | + } | ||
| 2055 | + } | ||
| 2056 | + | ||
| 2057 | + // ====== 通知系统 ====== | ||
| 2058 | + function showNotification(title, message, type = 'info') { | ||
| 2059 | + // 创建通知元素 | ||
| 2060 | + const notification = document.createElement('div'); | ||
| 2061 | + notification.className = `workflow-notification notification-${type}`; | ||
| 2062 | + | ||
| 2063 | + notification.innerHTML = ` | ||
| 2064 | + <div class="d-flex align-items-center"> | ||
| 2065 | + <i class="fas ${getNotificationIcon(type)} me-2"></i> | ||
| 2066 | + <div> | ||
| 2067 | + <div class="fw-bold">${title}</div> | ||
| 2068 | + <div class="small">${message}</div> | ||
| 2069 | + </div> | ||
| 2070 | + <button type="button" class="btn-close ms-3" aria-label="关闭"></button> | ||
| 2071 | + </div> | ||
| 2072 | + `; | ||
| 2073 | + | ||
| 2074 | + // 添加到通知容器 | ||
| 2075 | + const container = document.getElementById('notificationContainer'); | ||
| 2076 | + if (!container) { | ||
| 2077 | + // 如果容器不存在,创建一个 | ||
| 2078 | + const notificationContainer = document.createElement('div'); | ||
| 2079 | + notificationContainer.id = 'notificationContainer'; | ||
| 2080 | + notificationContainer.style.cssText = ` | ||
| 2081 | + position: fixed; | ||
| 2082 | + top: 20px; | ||
| 2083 | + right: 20px; | ||
| 2084 | + z-index: 1050; | ||
| 2085 | + max-width: 350px; | ||
| 2086 | + `; | ||
| 2087 | + document.body.appendChild(notificationContainer); | ||
| 2088 | + notificationContainer.appendChild(notification); | ||
| 2089 | + } else { | ||
| 2090 | + container.appendChild(notification); | ||
| 2091 | + } | ||
| 2092 | + | ||
| 2093 | + // 显示通知 | ||
| 2094 | + setTimeout(() => { | ||
| 2095 | + notification.classList.add('show'); | ||
| 2096 | + }, 10); | ||
| 2097 | + | ||
| 2098 | + // 绑定关闭按钮事件 | ||
| 2099 | + const closeBtn = notification.querySelector('.btn-close'); | ||
| 2100 | + closeBtn.addEventListener('click', () => { | ||
| 2101 | + hideNotification(notification); | ||
| 2102 | + }); | ||
| 2103 | + | ||
| 2104 | + // 设置自动隐藏 | ||
| 2105 | + setTimeout(() => { | ||
| 2106 | + hideNotification(notification); | ||
| 2107 | + }, 5000); | ||
| 2108 | + } | ||
| 2109 | + | ||
| 2110 | + function hideNotification(notification) { | ||
| 2111 | + notification.classList.remove('show'); | ||
| 2112 | + setTimeout(() => { | ||
| 2113 | + if (notification.parentNode) { | ||
| 2114 | + notification.parentNode.removeChild(notification); | ||
| 2115 | + } | ||
| 2116 | + }, 300); | ||
| 2117 | + } | ||
| 2118 | + | ||
| 2119 | + function getNotificationIcon(type) { | ||
| 2120 | + switch (type) { | ||
| 2121 | + case 'success': return 'fa-check-circle'; | ||
| 2122 | + case 'warning': return 'fa-exclamation-triangle'; | ||
| 2123 | + case 'error': return 'fa-times-circle'; | ||
| 2124 | + case 'info': | ||
| 2125 | + default: return 'fa-info-circle'; | ||
| 2126 | + } | ||
| 2127 | + } | ||
| 2128 | + | ||
| 2129 | + // 优化工作流验证功能 | ||
| 2130 | + function validateWorkflow(workflow) { | ||
| 2131 | + // 检查是否有节点 | ||
| 2132 | + if (!workflow.nodes || workflow.nodes.length === 0) { | ||
| 2133 | + return { valid: false, message: '工作流没有节点' }; | ||
| 2134 | + } | ||
| 2135 | + | ||
| 2136 | + // 检查是否有连接 | ||
| 2137 | + if (!workflow.connections || workflow.connections.length === 0) { | ||
| 2138 | + return { valid: false, message: '工作流没有连接' }; | ||
| 2139 | + } | ||
| 2140 | + | ||
| 2141 | + // 检查是否存在没有配置的节点 | ||
| 2142 | + const unconfiguredNodes = workflow.nodes.filter(node => !node.config); | ||
| 2143 | + if (unconfiguredNodes.length > 0) { | ||
| 2144 | + const nodeTitles = unconfiguredNodes.map(n => n.title).join(', '); | ||
| 2145 | + return { valid: false, message: `以下节点未配置: ${nodeTitles}` }; | ||
| 2146 | + } | ||
| 2147 | + | ||
| 2148 | + // 检查是否存在没有输入的节点(除了数据源类型) | ||
| 2149 | + const nonSourceNodes = workflow.nodes.filter(node => node.type !== 'data_source'); | ||
| 2150 | + for (const node of nonSourceNodes) { | ||
| 2151 | + const hasInput = workflow.connections.some(conn => conn.targetId === node.id); | ||
| 2152 | + if (!hasInput) { | ||
| 2153 | + return { valid: false, message: `节点 "${node.title}" 没有输入连接` }; | ||
| 2154 | + } | ||
| 2155 | + } | ||
| 2156 | + | ||
| 2157 | + // 检查是否存在没有输出的节点(除了可视化类型和预测类型的某些子类型) | ||
| 2158 | + const nonOutputNodeTypes = ['visualization']; | ||
| 2159 | + const nonOutputNodeSubtypes = { | ||
| 2160 | + 'prediction': ['report', 'alert'] // 这些预测子类型不需要输出 | ||
| 2161 | + }; | ||
| 2162 | + | ||
| 2163 | + const shouldHaveOutput = node => { | ||
| 2164 | + if (nonOutputNodeTypes.includes(node.type)) return false; | ||
| 2165 | + return !(nonOutputNodeSubtypes[node.type] && | ||
| 2166 | + nonOutputNodeSubtypes[node.type].includes(node.subtype)); | ||
| 2167 | + }; | ||
| 2168 | + | ||
| 2169 | + const nonVisNodes = workflow.nodes.filter(shouldHaveOutput); | ||
| 2170 | + | ||
| 2171 | + for (const node of nonVisNodes) { | ||
| 2172 | + const hasOutput = workflow.connections.some(conn => conn.sourceId === node.id); | ||
| 2173 | + if (!hasOutput) { | ||
| 2174 | + return { valid: false, message: `节点 "${node.title}" 没有输出连接` }; | ||
| 2175 | + } | ||
| 2176 | + } | ||
| 2177 | + | ||
| 2178 | + // 检查是否有环 | ||
| 2179 | + const nodeIds = workflow.nodes.map(node => node.id); | ||
| 2180 | + for (const nodeId of nodeIds) { | ||
| 2181 | + if (hasCycle(nodeId, new Set(), workflow.connections)) { | ||
| 2182 | + return { valid: false, message: '工作流中存在循环连接' }; | ||
| 2183 | + } | ||
| 2184 | + } | ||
| 2185 | + | ||
| 2186 | + // 检查是否有悬空连接(连接指向不存在的节点) | ||
| 2187 | + for (const conn of workflow.connections) { | ||
| 2188 | + if (!workflow.nodes.some(node => node.id === conn.sourceId)) { | ||
| 2189 | + return { valid: false, message: `存在连接指向不存在的源节点ID: ${conn.sourceId}` }; | ||
| 2190 | + } | ||
| 2191 | + if (!workflow.nodes.some(node => node.id === conn.targetId)) { | ||
| 2192 | + return { valid: false, message: `存在连接指向不存在的目标节点ID: ${conn.targetId}` }; | ||
| 2193 | + } | ||
| 2194 | + } | ||
| 2195 | + | ||
| 2196 | + return { valid: true }; | ||
| 2197 | + } | ||
| 2198 | + | ||
| 2199 | + // 检查是否有环 | ||
| 2200 | + function hasCycle(currentId, visited, connections) { | ||
| 2201 | + if (visited.has(currentId)) { | ||
| 2202 | + return true; // 发现环 | ||
| 2203 | + } | ||
| 2204 | + | ||
| 2205 | + visited.add(currentId); | ||
| 2206 | + | ||
| 2207 | + // 获取从currentId出发的所有连接 | ||
| 2208 | + const outgoingConnections = connections.filter(conn => conn.sourceId === currentId); | ||
| 2209 | + | ||
| 2210 | + for (const conn of outgoingConnections) { | ||
| 2211 | + const nextId = conn.targetId; | ||
| 2212 | + | ||
| 2213 | + // 创建一个新的已访问集合副本 | ||
| 2214 | + const newVisited = new Set(visited); | ||
| 2215 | + if (hasCycle(nextId, newVisited, connections)) { | ||
| 2216 | + return true; | ||
| 2217 | + } | ||
| 2218 | + } | ||
| 2219 | + | ||
| 2220 | + return false; | ||
| 2221 | + } | ||
| 2222 | + | ||
| 2223 | + // 验证工作流按钮事件 | ||
| 2224 | + document.getElementById('validateWorkflowBtn')?.addEventListener('click', function() { | ||
| 2225 | + const result = validateWorkflow(workflowData); | ||
| 2226 | + if (result.valid) { | ||
| 2227 | + showNotification('验证通过', '工作流有效,可以运行', 'success'); | ||
| 2228 | + } else { | ||
| 2229 | + showNotification('验证失败', result.message, 'error'); | ||
| 2230 | + } | ||
| 2231 | + }); | ||
| 2232 | + | ||
| 2233 | + // 工作流工具栏事件绑定 | ||
| 2234 | + function bindToolbarEvents() { | ||
| 2235 | + // 撤销/重做 | ||
| 2236 | + document.getElementById('undoBtn')?.addEventListener('click', undo); | ||
| 2237 | + document.getElementById('redoBtn')?.addEventListener('click', redo); | ||
| 2238 | + | ||
| 2239 | + // 缩放控制 | ||
| 2240 | + document.getElementById('zoomInBtn')?.addEventListener('click', () => { | ||
| 2241 | + zoomCanvas(0.1); | ||
| 2242 | + }); | ||
| 2243 | + | ||
| 2244 | + document.getElementById('zoomOutBtn')?.addEventListener('click', () => { | ||
| 2245 | + zoomCanvas(-0.1); | ||
| 2246 | + }); | ||
| 2247 | + | ||
| 2248 | + document.getElementById('fitViewBtn')?.addEventListener('click', fitCanvasView); | ||
| 2249 | + } | ||
| 2250 | + | ||
| 2251 | + // 画布缩放功能 | ||
| 2252 | + function zoomCanvas(delta) { | ||
| 2253 | + let newScale = canvasScale + delta; | ||
| 2254 | + | ||
| 2255 | + // 限制缩放范围 | ||
| 2256 | + newScale = Math.max(0.5, Math.min(2, newScale)); | ||
| 2257 | + | ||
| 2258 | + if (newScale !== canvasScale) { | ||
| 2259 | + canvasScale = newScale; | ||
| 2260 | + applyCanvasTransform(); | ||
| 2261 | + | ||
| 2262 | + // 更新连接线 | ||
| 2263 | + workflowData.connections.forEach(conn => { | ||
| 2264 | + const path = document.getElementById('connection_' + conn.id); | ||
| 2265 | + if (path) { | ||
| 2266 | + path.parentNode.removeChild(path); | ||
| 2267 | + } | ||
| 2268 | + drawConnection(conn.sourceId, conn.targetId, conn.id); | ||
| 2269 | + }); | ||
| 2270 | + | ||
| 2271 | + // 显示当前缩放比例 | ||
| 2272 | + showNotification('视图', `缩放比例: ${Math.round(canvasScale * 100)}%`, 'info'); | ||
| 2273 | + } | ||
| 2274 | + } | ||
| 2275 | + | ||
| 2276 | + // 适应视图 | ||
| 2277 | + function fitCanvasView() { | ||
| 2278 | + if (workflowData.nodes.length === 0) { | ||
| 2279 | + return; // 没有节点,不需要调整 | ||
| 2280 | + } | ||
| 2281 | + | ||
| 2282 | + // 重置缩放和平移 | ||
| 2283 | + canvasScale = 1; | ||
| 2284 | + canvasTranslate = { x: 0, y: 0 }; | ||
| 2285 | + applyCanvasTransform(); | ||
| 2286 | + | ||
| 2287 | + // 重新绘制所有连接 | ||
| 2288 | + workflowData.connections.forEach(conn => { | ||
| 2289 | + const path = document.getElementById('connection_' + conn.id); | ||
| 2290 | + if (path) { | ||
| 2291 | + path.parentNode.removeChild(path); | ||
| 2292 | + } | ||
| 2293 | + drawConnection(conn.sourceId, conn.targetId, conn.id); | ||
| 2294 | + }); | ||
| 2295 | + | ||
| 2296 | + showNotification('视图', '已重置视图', 'info'); | ||
| 2297 | + } | ||
| 2298 | + | ||
| 2299 | + // 应用画布变换 | ||
| 2300 | + function applyCanvasTransform() { | ||
| 2301 | + const transform = `scale(${canvasScale}) translate(${canvasTranslate.x}px, ${canvasTranslate.y}px)`; | ||
| 2302 | + workflowCanvas.style.transform = transform; | ||
| 2303 | + } | ||
| 2304 | + | ||
| 2305 | + // 更新工作流状态信息 | ||
| 2306 | + function updateWorkflowStatus() { | ||
| 2307 | + const nodeCount = document.getElementById('nodeCount'); | ||
| 2308 | + const connectionCount = document.getElementById('connectionCount'); | ||
| 2309 | + const statusBar = document.getElementById('workflowStatusBar'); | ||
| 2310 | + | ||
| 2311 | + if (nodeCount) nodeCount.textContent = workflowData.nodes.length; | ||
| 2312 | + if (connectionCount) connectionCount.textContent = workflowData.connections.length; | ||
| 2313 | + | ||
| 2314 | + if (statusBar) { | ||
| 2315 | + // 根据工作流状态更新状态栏 | ||
| 2316 | + if (workflowData.nodes.length === 0) { | ||
| 2317 | + statusBar.style.display = 'flex'; | ||
| 2318 | + statusBar.querySelector('#workflowStatusMessage').textContent = | ||
| 2319 | + '工作流就绪。拖拽左侧组件到画布创建节点。'; | ||
| 2320 | + } else if (workflowData.connections.length === 0) { | ||
| 2321 | + statusBar.style.display = 'flex'; | ||
| 2322 | + statusBar.querySelector('#workflowStatusMessage').textContent = | ||
| 2323 | + '已添加节点。请连接节点以创建完整工作流。'; | ||
| 2324 | + } else { | ||
| 2325 | + const validationResult = validateWorkflow(workflowData); | ||
| 2326 | + if (!validationResult.valid) { | ||
| 2327 | + statusBar.style.display = 'flex'; | ||
| 2328 | + statusBar.querySelector('#workflowStatusMessage').textContent = | ||
| 2329 | + `工作流需要修正: ${validationResult.message}`; | ||
| 2330 | + statusBar.classList.add('bg-warning-subtle'); | ||
| 2331 | + statusBar.classList.remove('bg-light', 'bg-success-subtle'); | ||
| 2332 | + } else { | ||
| 2333 | + statusBar.style.display = 'flex'; | ||
| 2334 | + statusBar.querySelector('#workflowStatusMessage').textContent = | ||
| 2335 | + '工作流有效,可以运行。'; | ||
| 2336 | + statusBar.classList.add('bg-success-subtle'); | ||
| 2337 | + statusBar.classList.remove('bg-light', 'bg-warning-subtle'); | ||
| 2338 | + } | ||
| 2339 | + } | ||
| 2340 | + } | ||
| 2341 | + } | ||
| 2342 | + | ||
| 2343 | + // 每次工作流变化时更新状态 | ||
| 2344 | + function onWorkflowChanged() { | ||
| 2345 | + updateWorkflowStatus(); | ||
| 2346 | + addToHistory(); | ||
| 2347 | + } | ||
| 2348 | + | ||
| 2349 | + // 增强添加节点函数 | ||
| 2350 | + function addNode(componentType, componentSubtype, x, y) { | ||
| 2351 | + const nodeId = 'node_' + Date.now(); | ||
| 2352 | + const nodeData = { | ||
| 2353 | + id: nodeId, | ||
| 2354 | + type: componentType, | ||
| 2355 | + subtype: componentSubtype, | ||
| 2356 | + title: getComponentTypeLabel(componentType) + '-' + componentSubtype, | ||
| 2357 | + x: x, | ||
| 2358 | + y: y, | ||
| 2359 | + config: getDefaultConfig(componentType, componentSubtype) | ||
| 2360 | + }; | ||
| 2361 | + | ||
| 2362 | + const nodeElement = createNodeFromData(nodeData); | ||
| 2363 | + setupNodeEvents(nodeElement, nodeData); | ||
| 2364 | + | ||
| 2365 | + // 更新工作流状态 | ||
| 2366 | + onWorkflowChanged(); | ||
| 2367 | + | ||
| 2368 | + return nodeElement; | ||
| 2369 | + } | ||
| 2370 | + | ||
| 2371 | + // 增强删除节点函数 | ||
| 2372 | + function deleteNode(nodeId) { | ||
| 2373 | + const node = document.getElementById(nodeId); | ||
| 2374 | + if (node) { | ||
| 2375 | + node.parentNode.removeChild(node); | ||
| 2376 | + } | ||
| 2377 | + | ||
| 2378 | + // 删除相关连接 | ||
| 2379 | + workflowData.connections = workflowData.connections.filter(conn => { | ||
| 2380 | + if (conn.sourceId === nodeId || conn.targetId === nodeId) { | ||
| 2381 | + const path = document.getElementById('connection_' + conn.id); | ||
| 2382 | + if (path) { | ||
| 2383 | + path.parentNode.removeChild(path); | ||
| 2384 | + } | ||
| 2385 | + return false; | ||
| 2386 | + } | ||
| 2387 | + return true; | ||
| 2388 | + }); | ||
| 2389 | + | ||
| 2390 | + // 从数据中删除节点 | ||
| 2391 | + workflowData.nodes = workflowData.nodes.filter(node => node.id !== nodeId); | ||
| 2392 | + | ||
| 2393 | + // 更新工作流状态 | ||
| 2394 | + onWorkflowChanged(); | ||
| 2395 | + | ||
| 2396 | + showNotification('已删除', '节点已从工作流中移除', 'info'); | ||
| 2397 | + } | ||
| 2398 | + | ||
| 2399 | + // 增强完成连接函数 | ||
| 2400 | + function completeConnection(sourceId, targetId) { | ||
| 2401 | + // 检查是否是自连接 | ||
| 2402 | + if (sourceId === targetId) { | ||
| 2403 | + showNotification('警告', '不能连接到自己', 'warning'); | ||
| 2404 | + cancelConnection(); | ||
| 2405 | + return; | ||
| 2406 | + } | ||
| 2407 | + | ||
| 2408 | + // 检查连接是否已存在 | ||
| 2409 | + const connectionExists = workflowData.connections.some(conn => | ||
| 2410 | + conn.sourceId === sourceId && conn.targetId === targetId); | ||
| 2411 | + | ||
| 2412 | + if (connectionExists) { | ||
| 2413 | + showNotification('警告', '连接已存在', 'warning'); | ||
| 2414 | + cancelConnection(); | ||
| 2415 | + return; | ||
| 2416 | + } | ||
| 2417 | + | ||
| 2418 | + // 检查是否会形成循环 | ||
| 2419 | + if (wouldCreateCycle(sourceId, targetId)) { | ||
| 2420 | + showNotification('错误', '不能创建循环连接', 'error'); | ||
| 2421 | + cancelConnection(); | ||
| 2422 | + return; | ||
| 2423 | + } | ||
| 2424 | + | ||
| 2425 | + // 生成连接ID | ||
| 2426 | + const connectionId = `conn_${Date.now()}`; | ||
| 2427 | + | ||
| 2428 | + // 添加到数据中 | ||
| 2429 | + workflowData.connections.push({ | ||
| 2430 | + id: connectionId, | ||
| 2431 | + sourceId: sourceId, | ||
| 2432 | + targetId: targetId | ||
| 2433 | + }); | ||
| 2434 | + | ||
| 2435 | + // 绘制最终连接 | ||
| 2436 | + drawConnection(sourceId, targetId, connectionId); | ||
| 2437 | + | ||
| 2438 | + // 清理预览状态 | ||
| 2439 | + cancelConnection(); | ||
| 2440 | + | ||
| 2441 | + // 更新工作流状态 | ||
| 2442 | + onWorkflowChanged(); | ||
| 2443 | + | ||
| 2444 | + // 显示成功通知 | ||
| 2445 | + showNotification('成功', '已创建连接', 'success'); | ||
| 2446 | + } | ||
| 2447 | + | ||
| 2448 | + // 绑定工具栏事件 | ||
| 2449 | + bindToolbarEvents(); | ||
| 2450 | + | ||
| 2451 | + // 初始化:将当前状态添加到历史记录 | ||
| 2452 | + addToHistory(); | ||
| 2453 | + | ||
| 2454 | + // 初始更新工作流状态 | ||
| 2455 | + updateWorkflowStatus(); | ||
| 1065 | }); | 2456 | }); |
| @@ -27,6 +27,24 @@ | @@ -27,6 +27,24 @@ | ||
| 27 | font-weight: 600; | 27 | font-weight: 600; |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | + /* 修复顶部栏固定问题 */ | ||
| 31 | + .navbar { | ||
| 32 | + position: fixed; | ||
| 33 | + top: 0; | ||
| 34 | + left: 0; | ||
| 35 | + right: 0; | ||
| 36 | + z-index: 1030; | ||
| 37 | + /* 修复banner宽度问题 */ | ||
| 38 | + width: 100%; | ||
| 39 | + max-width: 100%; | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + /* 添加顶部导航栏高度的内边距,防止内容被遮挡 */ | ||
| 43 | + .container-fluid { | ||
| 44 | + /* padding-top: 10px; */ | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + /* 修复侧边栏样式 */ | ||
| 30 | .sidebar { | 48 | .sidebar { |
| 31 | position: fixed; | 49 | position: fixed; |
| 32 | top: 56px; | 50 | top: 56px; |
| @@ -36,19 +54,29 @@ | @@ -36,19 +54,29 @@ | ||
| 36 | padding: 20px 0; | 54 | padding: 20px 0; |
| 37 | width: 280px; | 55 | width: 280px; |
| 38 | overflow-x: hidden; | 56 | overflow-x: hidden; |
| 57 | + /* 确保只有一个滚动条 */ | ||
| 39 | overflow-y: auto; | 58 | overflow-y: auto; |
| 40 | background-color: white; | 59 | background-color: white; |
| 41 | border-right: 1px solid var(--border-color); | 60 | border-right: 1px solid var(--border-color); |
| 42 | } | 61 | } |
| 43 | 62 | ||
| 63 | + /* 完全移除子面板的独立滚动 */ | ||
| 64 | + #componentsPanel, #templatesPanel { | ||
| 65 | + height: auto; | ||
| 66 | + padding: 0 15px; | ||
| 67 | + /* 完全禁用独立滚动 */ | ||
| 68 | + overflow: visible; | ||
| 69 | + } | ||
| 70 | + | ||
| 44 | .main-content { | 71 | .main-content { |
| 72 | + margin-top: 50px; | ||
| 45 | margin-left: 280px; | 73 | margin-left: 280px; |
| 46 | padding: 20px; | 74 | padding: 20px; |
| 47 | } | 75 | } |
| 48 | 76 | ||
| 49 | .workflow-canvas { | 77 | .workflow-canvas { |
| 50 | background-color: white; | 78 | background-color: white; |
| 51 | - min-height: calc(100vh - 150px); | 79 | + min-height: calc(100vh - 200px); |
| 52 | border-radius: 8px; | 80 | border-radius: 8px; |
| 53 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); | 81 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); |
| 54 | position: relative; | 82 | position: relative; |
| @@ -89,6 +117,7 @@ | @@ -89,6 +117,7 @@ | ||
| 89 | padding: 12px; | 117 | padding: 12px; |
| 90 | cursor: move; | 118 | cursor: move; |
| 91 | z-index: 10; | 119 | z-index: 10; |
| 120 | + transition: transform 0.3s; | ||
| 92 | } | 121 | } |
| 93 | 122 | ||
| 94 | .workflow-node .node-header { | 123 | .workflow-node .node-header { |
| @@ -142,12 +171,19 @@ | @@ -142,12 +171,19 @@ | ||
| 142 | fill: none; | 171 | fill: none; |
| 143 | } | 172 | } |
| 144 | 173 | ||
| 174 | + /* 修复模板项布局样式 */ | ||
| 175 | + .templates-wrapper { | ||
| 176 | + padding: 0 15px; | ||
| 177 | + max-height: calc(100vh - 180px); | ||
| 178 | + overflow-y: auto; | ||
| 179 | + } | ||
| 180 | + | ||
| 145 | .template-item { | 181 | .template-item { |
| 146 | border: 1px solid var(--border-color); | 182 | border: 1px solid var(--border-color); |
| 147 | border-radius: 6px; | 183 | border-radius: 6px; |
| 148 | padding: 15px; | 184 | padding: 15px; |
| 149 | margin-bottom: 15px; | 185 | margin-bottom: 15px; |
| 150 | - cursor: pointer; | 186 | + background-color: white; |
| 151 | transition: all 0.3s; | 187 | transition: all 0.3s; |
| 152 | } | 188 | } |
| 153 | 189 | ||
| @@ -159,12 +195,26 @@ | @@ -159,12 +195,26 @@ | ||
| 159 | .template-item .template-title { | 195 | .template-item .template-title { |
| 160 | font-weight: 600; | 196 | font-weight: 600; |
| 161 | color: #333; | 197 | color: #333; |
| 198 | + font-size: 14px; | ||
| 199 | + margin-bottom: 5px; | ||
| 200 | + display: -webkit-box; | ||
| 201 | + -webkit-line-clamp: 1; | ||
| 202 | + -webkit-box-orient: vertical; | ||
| 203 | + overflow: hidden; | ||
| 204 | + text-overflow: ellipsis; | ||
| 162 | } | 205 | } |
| 163 | 206 | ||
| 164 | .template-item .template-desc { | 207 | .template-item .template-desc { |
| 165 | color: #666; | 208 | color: #666; |
| 166 | - font-size: 13px; | 209 | + font-size: 12px; |
| 167 | margin-top: 5px; | 210 | margin-top: 5px; |
| 211 | + margin-bottom: 10px; | ||
| 212 | + display: -webkit-box; | ||
| 213 | + -webkit-line-clamp: 2; | ||
| 214 | + -webkit-box-orient: vertical; | ||
| 215 | + overflow: hidden; | ||
| 216 | + text-overflow: ellipsis; | ||
| 217 | + height: 36px; | ||
| 168 | } | 218 | } |
| 169 | 219 | ||
| 170 | .active-tab { | 220 | .active-tab { |
| @@ -177,49 +227,75 @@ | @@ -177,49 +227,75 @@ | ||
| 177 | padding-top: 20px; | 227 | padding-top: 20px; |
| 178 | } | 228 | } |
| 179 | 229 | ||
| 180 | - .task-item { | ||
| 181 | - background-color: white; | ||
| 182 | - border-radius: 6px; | ||
| 183 | - padding: 15px; | ||
| 184 | - margin-bottom: 15px; | ||
| 185 | - border-left: 4px solid var(--primary-color); | 230 | + /* 优化组件和模板面板的滚动行为 */ |
| 231 | + #componentsPanel, #templatesPanel { | ||
| 232 | + height: calc(100vh - 120px); | ||
| 233 | + overflow-y: auto; | ||
| 234 | + padding: 0 15px; | ||
| 186 | } | 235 | } |
| 187 | 236 | ||
| 188 | - .task-item.running { | ||
| 189 | - border-left-color: var(--primary-color); | 237 | + .node-entering { |
| 238 | + animation: nodeEnter 0.3s ease; | ||
| 190 | } | 239 | } |
| 191 | 240 | ||
| 192 | - .task-item.completed { | ||
| 193 | - border-left-color: var(--success-color); | 241 | + @keyframes nodeEnter { |
| 242 | + from { | ||
| 243 | + opacity: 0; | ||
| 244 | + transform: scale(0.8); | ||
| 245 | + } | ||
| 246 | + to { | ||
| 247 | + opacity: 1; | ||
| 248 | + transform: scale(1); | ||
| 249 | + } | ||
| 194 | } | 250 | } |
| 195 | 251 | ||
| 196 | - .task-item.failed { | ||
| 197 | - border-left-color: var(--error-color); | 252 | + /* 添加拖放反馈样式 */ |
| 253 | + .workflow-canvas.drag-over { | ||
| 254 | + border: 2px dashed var(--primary-color); | ||
| 255 | + background-color: rgba(24, 144, 255, 0.05); | ||
| 198 | } | 256 | } |
| 199 | 257 | ||
| 200 | - .properties-panel { | ||
| 201 | - position: fixed; | ||
| 202 | - top: 76px; | ||
| 203 | - right: 20px; | ||
| 204 | - width: 320px; | 258 | + /* 添加通知样式 */ |
| 259 | + .workflow-notification { | ||
| 205 | background-color: white; | 260 | background-color: white; |
| 206 | - border-radius: 8px; | ||
| 207 | - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||
| 208 | - padding: 15px; | ||
| 209 | - max-height: calc(100vh - 120px); | ||
| 210 | - overflow-y: auto; | ||
| 211 | - z-index: 100; | ||
| 212 | - transform: translateX(360px); | 261 | + border-radius: 6px; |
| 262 | + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); | ||
| 263 | + padding: 12px; | ||
| 264 | + margin-bottom: 10px; | ||
| 265 | + transform: translateX(100%); | ||
| 213 | transition: transform 0.3s; | 266 | transition: transform 0.3s; |
| 267 | + max-width: 320px; | ||
| 214 | } | 268 | } |
| 215 | 269 | ||
| 216 | - .properties-panel.open { | 270 | + .workflow-notification.show { |
| 217 | transform: translateX(0); | 271 | transform: translateX(0); |
| 218 | } | 272 | } |
| 219 | 273 | ||
| 220 | - .form-label { | ||
| 221 | - font-weight: 500; | ||
| 222 | - font-size: 13px; | 274 | + .notification-success { |
| 275 | + border-left: 4px solid var(--success-color); | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + .notification-warning { | ||
| 279 | + border-left: 4px solid var(--warning-color); | ||
| 280 | + } | ||
| 281 | + | ||
| 282 | + .notification-error { | ||
| 283 | + border-left: 4px solid var(--error-color); | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + .notification-info { | ||
| 287 | + border-left: 4px solid var(--primary-color); | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + /* 优化节点选中样式 */ | ||
| 291 | + .workflow-node.selected { | ||
| 292 | + box-shadow: 0 0 0 2px var(--primary-color); | ||
| 293 | + z-index: 11; | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + .port-highlight { | ||
| 297 | + box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.5); | ||
| 298 | + transform: scale(1.2); | ||
| 223 | } | 299 | } |
| 224 | 300 | ||
| 225 | /* 媒体查询用于响应式设计 */ | 301 | /* 媒体查询用于响应式设计 */ |
| @@ -249,10 +325,37 @@ | @@ -249,10 +325,37 @@ | ||
| 249 | .properties-panel.open { | 325 | .properties-panel.open { |
| 250 | transform: translateY(0); | 326 | transform: translateY(0); |
| 251 | } | 327 | } |
| 328 | + | ||
| 329 | + #componentsPanel, #templatesPanel { | ||
| 330 | + height: auto; | ||
| 331 | + max-height: 400px; | ||
| 332 | + } | ||
| 333 | + } | ||
| 334 | + | ||
| 335 | + /* 修复属性面板样式 */ | ||
| 336 | + .properties-panel { | ||
| 337 | + position: fixed; | ||
| 338 | + top: 70px; | ||
| 339 | + right: 20px; | ||
| 340 | + width: 320px; | ||
| 341 | + background-color: white; | ||
| 342 | + border-radius: 8px; | ||
| 343 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | ||
| 344 | + padding: 15px; | ||
| 345 | + transform: translateX(calc(100% + 20px)); | ||
| 346 | + transition: transform 0.3s ease; | ||
| 347 | + z-index: 900; | ||
| 348 | + max-height: calc(100vh - 100px); | ||
| 349 | + overflow-y: auto; | ||
| 350 | + } | ||
| 351 | + | ||
| 352 | + .properties-panel.open { | ||
| 353 | + transform: translateX(0); | ||
| 252 | } | 354 | } |
| 253 | </style> | 355 | </style> |
| 254 | </head> | 356 | </head> |
| 255 | <body> | 357 | <body> |
| 358 | + <!-- 导航栏保持不变 --> | ||
| 256 | <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> | 359 | <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> |
| 257 | <div class="container-fluid"> | 360 | <div class="container-fluid"> |
| 258 | <a class="navbar-brand" href="#"> | 361 | <a class="navbar-brand" href="#"> |
| @@ -287,7 +390,7 @@ | @@ -287,7 +390,7 @@ | ||
| 287 | 390 | ||
| 288 | <div class="container-fluid"> | 391 | <div class="container-fluid"> |
| 289 | <div class="row"> | 392 | <div class="row"> |
| 290 | - <!-- 侧边栏 --> | 393 | + <!-- 侧边栏 - 修改模板面板的结构 --> |
| 291 | <div class="col-md-3 col-lg-2 d-md-block sidebar"> | 394 | <div class="col-md-3 col-lg-2 d-md-block sidebar"> |
| 292 | <div class="d-flex justify-content-center mb-4"> | 395 | <div class="d-flex justify-content-center mb-4"> |
| 293 | <div class="btn-group"> | 396 | <div class="btn-group"> |
| @@ -296,7 +399,7 @@ | @@ -296,7 +399,7 @@ | ||
| 296 | </div> | 399 | </div> |
| 297 | </div> | 400 | </div> |
| 298 | 401 | ||
| 299 | - <!-- 组件面板 --> | 402 | + <!-- 组件面板保持不变 --> |
| 300 | <div id="componentsPanel"> | 403 | <div id="componentsPanel"> |
| 301 | <div class="component-container"> | 404 | <div class="component-container"> |
| 302 | <h6><i class="fas fa-database me-2"></i>数据源</h6> | 405 | <h6><i class="fas fa-database me-2"></i>数据源</h6> |
| @@ -362,38 +465,89 @@ | @@ -362,38 +465,89 @@ | ||
| 362 | </div> | 465 | </div> |
| 363 | </div> | 466 | </div> |
| 364 | 467 | ||
| 365 | - <!-- 模板面板 --> | 468 | + <!-- 模板面板 - 修改结构和样式 --> |
| 366 | <div id="templatesPanel" style="display: none;"> | 469 | <div id="templatesPanel" style="display: none;"> |
| 367 | - <div class="d-flex justify-content-between align-items-center mb-3"> | ||
| 368 | - <h6 class="mb-0">爬虫模板</h6> | ||
| 369 | - <button class="btn btn-sm btn-outline-primary"> | ||
| 370 | - <i class="fas fa-plus"></i> 新建 | ||
| 371 | - </button> | ||
| 372 | - </div> | ||
| 373 | - <div id="crawlerTemplatesList"> | ||
| 374 | - <!-- 爬虫模板列表将动态加载 --> | 470 | + <div class="mb-4"> |
| 471 | + <div class="d-flex justify-content-between align-items-center mb-3"> | ||
| 472 | + <h6 class="mb-0">爬虫模板</h6> | ||
| 473 | + <button class="btn btn-sm btn-outline-primary"> | ||
| 474 | + <i class="fas fa-plus"></i> 新建 | ||
| 475 | + </button> | ||
| 476 | + </div> | ||
| 477 | + <div class="templates-wrapper"> | ||
| 478 | + <div id="crawlerTemplatesList"> | ||
| 479 | + <!-- 爬虫模板列表将动态加载 --> | ||
| 480 | + </div> | ||
| 481 | + </div> | ||
| 375 | </div> | 482 | </div> |
| 376 | 483 | ||
| 377 | - <div class="d-flex justify-content-between align-items-center mb-3 mt-4"> | ||
| 378 | - <h6 class="mb-0">分析流程模板</h6> | ||
| 379 | - <button class="btn btn-sm btn-outline-primary"> | ||
| 380 | - <i class="fas fa-plus"></i> 新建 | ||
| 381 | - </button> | ||
| 382 | - </div> | ||
| 383 | - <div id="analysisTemplatesList"> | ||
| 384 | - <!-- 分析流程模板列表将动态加载 --> | 484 | + <div class="mb-4"> |
| 485 | + <div class="d-flex justify-content-between align-items-center mb-3"> | ||
| 486 | + <h6 class="mb-0">分析流程模板</h6> | ||
| 487 | + <button class="btn btn-sm btn-outline-primary"> | ||
| 488 | + <i class="fas fa-plus"></i> 新建 | ||
| 489 | + </button> | ||
| 490 | + </div> | ||
| 491 | + <div class="templates-wrapper"> | ||
| 492 | + <div id="analysisTemplatesList"> | ||
| 493 | + <!-- 分析流程模板列表将动态加载 --> | ||
| 494 | + </div> | ||
| 495 | + </div> | ||
| 385 | </div> | 496 | </div> |
| 386 | </div> | 497 | </div> |
| 387 | </div> | 498 | </div> |
| 388 | 499 | ||
| 389 | <!-- 主要内容 --> | 500 | <!-- 主要内容 --> |
| 390 | <div class="col-md-9 col-lg-10 main-content"> | 501 | <div class="col-md-9 col-lg-10 main-content"> |
| 502 | + <!-- 添加工作流工具栏 --> | ||
| 503 | + <div class="d-flex justify-content-between align-items-center mb-3" id="workflowToolbar"> | ||
| 504 | + <div class="btn-group"> | ||
| 505 | + <button id="undoBtn" class="btn btn-sm btn-outline-secondary" title="撤销"> | ||
| 506 | + <i class="fas fa-undo"></i> | ||
| 507 | + </button> | ||
| 508 | + <button id="redoBtn" class="btn btn-sm btn-outline-secondary" title="重做"> | ||
| 509 | + <i class="fas fa-redo"></i> | ||
| 510 | + </button> | ||
| 511 | + <button id="zoomInBtn" class="btn btn-sm btn-outline-secondary" title="放大"> | ||
| 512 | + <i class="fas fa-search-plus"></i> | ||
| 513 | + </button> | ||
| 514 | + <button id="zoomOutBtn" class="btn btn-sm btn-outline-secondary" title="缩小"> | ||
| 515 | + <i class="fas fa-search-minus"></i> | ||
| 516 | + </button> | ||
| 517 | + <button id="fitViewBtn" class="btn btn-sm btn-outline-secondary" title="适应视图"> | ||
| 518 | + <i class="fas fa-expand"></i> | ||
| 519 | + </button> | ||
| 520 | + </div> | ||
| 521 | + <div> | ||
| 522 | + <button id="validateWorkflowBtn" class="btn btn-sm btn-outline-primary" title="验证工作流"> | ||
| 523 | + <i class="fas fa-check-circle"></i> 验证 | ||
| 524 | + </button> | ||
| 525 | + <button id="exportWorkflowBtn" class="btn btn-sm btn-outline-secondary" title="导出工作流"> | ||
| 526 | + <i class="fas fa-file-export"></i> | ||
| 527 | + </button> | ||
| 528 | + <button id="importWorkflowBtn" class="btn btn-sm btn-outline-secondary" title="导入工作流"> | ||
| 529 | + <i class="fas fa-file-import"></i> | ||
| 530 | + </button> | ||
| 531 | + </div> | ||
| 532 | + </div> | ||
| 533 | + | ||
| 391 | <div class="workflow-canvas" id="workflowCanvas"> | 534 | <div class="workflow-canvas" id="workflowCanvas"> |
| 392 | <!-- 工作流节点和连接将在这里动态创建 --> | 535 | <!-- 工作流节点和连接将在这里动态创建 --> |
| 393 | <svg id="connectionsSvg" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"> | 536 | <svg id="connectionsSvg" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"> |
| 394 | <!-- 连接线将在这里动态创建 --> | 537 | <!-- 连接线将在这里动态创建 --> |
| 395 | </svg> | 538 | </svg> |
| 396 | </div> | 539 | </div> |
| 540 | + | ||
| 541 | + <!-- 添加工作流状态栏 --> | ||
| 542 | + <div class="d-flex justify-content-between align-items-center p-2 bg-light rounded mt-3" id="workflowStatusBar" style="display: none !important;"> | ||
| 543 | + <div id="workflowStatusMessage" class="text-muted"> | ||
| 544 | + 工作流就绪。拖拽左侧组件到画布创建节点。 | ||
| 545 | + </div> | ||
| 546 | + <div class="d-flex"> | ||
| 547 | + <div class="me-3">节点: <span id="nodeCount">0</span></div> | ||
| 548 | + <div>连接: <span id="connectionCount">0</span></div> | ||
| 549 | + </div> | ||
| 550 | + </div> | ||
| 397 | </div> | 551 | </div> |
| 398 | </div> | 552 | </div> |
| 399 | </div> | 553 | </div> |
| @@ -481,39 +635,86 @@ | @@ -481,39 +635,86 @@ | ||
| 481 | </div> | 635 | </div> |
| 482 | <div class="modal-body"> | 636 | <div class="modal-body"> |
| 483 | <div class="mb-3"> | 637 | <div class="mb-3"> |
| 484 | - <h6>进度</h6> | ||
| 485 | - <div class="progress"> | ||
| 486 | - <div id="taskProgressBar" class="progress-bar" role="progressbar" style="width: 0%"></div> | 638 | + <h6 class="d-flex align-items-center"> |
| 639 | + <i class="fas fa-tasks me-2"></i>进度 | ||
| 640 | + <div class="ms-auto" id="taskProgressPercentage">0%</div> | ||
| 641 | + </h6> | ||
| 642 | + <div class="progress" style="height: 10px;"> | ||
| 643 | + <div id="taskProgressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div> | ||
| 487 | </div> | 644 | </div> |
| 488 | </div> | 645 | </div> |
| 489 | <div class="mb-3"> | 646 | <div class="mb-3"> |
| 490 | - <h6>状态信息</h6> | ||
| 491 | - <div id="taskStatusInfo" class="p-3 bg-light rounded"> | ||
| 492 | - <p class="mb-1">任务ID: <span id="taskIdDisplay">-</span></p> | ||
| 493 | - <p class="mb-1">状态: <span id="taskStatusDisplay">-</span></p> | ||
| 494 | - <p class="mb-1">开始时间: <span id="taskStartTimeDisplay">-</span></p> | ||
| 495 | - <p class="mb-0">完成时间: <span id="taskCompleteTimeDisplay">-</span></p> | 647 | + <h6 class="d-flex align-items-center"> |
| 648 | + <i class="fas fa-info-circle me-2"></i>状态信息 | ||
| 649 | + <span id="taskStatusBadge" class="ms-2 badge bg-info">等待中</span> | ||
| 650 | + </h6> | ||
| 651 | + <div id="taskStatusInfo" class="p-3 bg-light rounded border"> | ||
| 652 | + <div class="row g-2"> | ||
| 653 | + <div class="col-md-6"> | ||
| 654 | + <p class="mb-1"><strong>任务ID:</strong> <span id="taskIdDisplay">-</span></p> | ||
| 655 | + <p class="mb-1"><strong>状态:</strong> <span id="taskStatusDisplay">-</span></p> | ||
| 656 | + </div> | ||
| 657 | + <div class="col-md-6"> | ||
| 658 | + <p class="mb-1"><strong>开始时间:</strong> <span id="taskStartTimeDisplay">-</span></p> | ||
| 659 | + <p class="mb-1"><strong>完成时间:</strong> <span id="taskCompleteTimeDisplay">-</span></p> | ||
| 660 | + </div> | ||
| 661 | + </div> | ||
| 662 | + <div class="mt-2" id="taskDetailsContainer"> | ||
| 663 | + <p class="mb-1"><strong>当前步骤:</strong> <span id="taskCurrentStepDisplay">等待开始</span></p> | ||
| 664 | + <p class="mb-0"><strong>耗时:</strong> <span id="taskElapsedTimeDisplay">0秒</span></p> | ||
| 665 | + </div> | ||
| 496 | </div> | 666 | </div> |
| 497 | </div> | 667 | </div> |
| 498 | <div> | 668 | <div> |
| 499 | - <h6>结果预览</h6> | ||
| 500 | - <div id="taskResultPreview" class="p-3 bg-light rounded" style="max-height: 300px; overflow: auto;"> | ||
| 501 | - <p class="text-muted">任务完成后将显示结果预览...</p> | 669 | + <h6 class="d-flex align-items-center"> |
| 670 | + <i class="fas fa-chart-bar me-2"></i>结果预览 | ||
| 671 | + <button class="btn btn-sm btn-outline-secondary ms-auto" id="refreshPreviewBtn" title="刷新预览"> | ||
| 672 | + <i class="fas fa-sync-alt"></i> | ||
| 673 | + </button> | ||
| 674 | + </h6> | ||
| 675 | + <div id="taskResultPreview" class="p-3 bg-light rounded border" style="max-height: 300px; overflow: auto;"> | ||
| 676 | + <div class="text-center py-4" id="previewLoadingIndicator"> | ||
| 677 | + <div class="spinner-border text-primary" role="status"> | ||
| 678 | + <span class="visually-hidden">加载中...</span> | ||
| 679 | + </div> | ||
| 680 | + <p class="text-muted mt-2">任务运行中,正在准备预览数据...</p> | ||
| 681 | + </div> | ||
| 682 | + <div id="previewContent" style="display: none;"> | ||
| 683 | + <p class="text-muted">任务完成后将显示结果预览...</p> | ||
| 684 | + </div> | ||
| 685 | + <div id="previewError" class="alert alert-danger" style="display: none;"> | ||
| 686 | + <i class="fas fa-exclamation-triangle me-2"></i> | ||
| 687 | + <span id="errorMessage">加载预览时发生错误</span> | ||
| 688 | + </div> | ||
| 689 | + </div> | ||
| 690 | + <div class="mt-2 text-end"> | ||
| 691 | + <span class="text-muted small" id="previewUpdatedTime"></span> | ||
| 502 | </div> | 692 | </div> |
| 503 | </div> | 693 | </div> |
| 504 | </div> | 694 | </div> |
| 505 | - <div class="modal-footer"> | ||
| 506 | - <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button> | ||
| 507 | - <button type="button" class="btn btn-danger" id="cancelTaskBtn">取消任务</button> | ||
| 508 | - <button type="button" class="btn btn-primary" id="viewResultBtn">查看完整结果</button> | 695 | + <div class="modal-footer d-flex justify-content-between"> |
| 696 | + <div> | ||
| 697 | + <button type="button" class="btn btn-danger" id="cancelTaskBtn"> | ||
| 698 | + <i class="fas fa-stop-circle me-1"></i>取消任务 | ||
| 699 | + </button> | ||
| 700 | + </div> | ||
| 701 | + <div> | ||
| 702 | + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button> | ||
| 703 | + <button type="button" class="btn btn-primary" id="viewResultBtn"> | ||
| 704 | + <i class="fas fa-external-link-alt me-1"></i>查看完整结果 | ||
| 705 | + </button> | ||
| 706 | + </div> | ||
| 509 | </div> | 707 | </div> |
| 510 | </div> | 708 | </div> |
| 511 | </div> | 709 | </div> |
| 512 | </div> | 710 | </div> |
| 513 | 711 | ||
| 712 | + <!-- 添加通知容器 --> | ||
| 713 | + <div id="notificationContainer" style="position: fixed; top: 20px; right: 20px; z-index: 1050; max-width: 350px;"></div> | ||
| 714 | + | ||
| 514 | <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.1/jquery.min.js"></script> | 715 | <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.1/jquery.min.js"></script> |
| 515 | <script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.2.3/js/bootstrap.bundle.min.js"></script> | 716 | <script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.2.3/js/bootstrap.bundle.min.js"></script> |
| 516 | <script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.5.0/dist/jsoneditor.min.js"></script> | 717 | <script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.5.0/dist/jsoneditor.min.js"></script> |
| 517 | <script src="\static\js\workflow_editor.js"></script> | 718 | <script src="\static\js\workflow_editor.js"></script> |
| 518 | </body> | 719 | </body> |
| 519 | -</html> | ||
| 720 | +</html> |
-
Please register or login to post a comment