戒酒的李白

Process visualization orchestration BUG fix, feature enhancement, and support fo…

…r export and import of orchestration processes.
  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');
@@ -25,6 +34,22 @@ document.addEventListener('DOMContentLoaded', function() { @@ -25,6 +34,22 @@ document.addEventListener('DOMContentLoaded', function() {
25 let connectionStart = null; 34 let connectionStart = null;
26 let connectionPreviewPath = null; 35 let connectionPreviewPath = null;
27 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 };
  52 +
28 // 设置编辑器网格背景 53 // 设置编辑器网格背景
29 setEditorBackground(); 54 setEditorBackground();
30 55
@@ -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 // 添加到节点数据
  227 + const existingNodeIndex = workflowData.nodes.findIndex(node => node.id === nodeData.id);
  228 + if (existingNodeIndex === -1) {
170 workflowData.nodes.push(nodeData); 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 + });
640 713
641 - if (componentType && componentSubtype) {  
642 - const rect = workflowCanvas.getBoundingClientRect();  
643 - const x = e.clientX - rect.left;  
644 - const y = e.clientY - rect.top; 714 + // workflowCanvas.addEventListener('drop', function(e) {
  715 + // e.preventDefault();
  716 + // this.classList.remove('drag-over');
645 717
646 - addNode(componentType, componentSubtype, x, y);  
647 - }  
648 - }); 718 + // const componentType = e.dataTransfer.getData('componentType');
  719 + // const componentSubtype = e.dataTransfer.getData('componentSubtype');
  720 +
  721 + // if (componentType && componentSubtype) {
  722 + // const rect = workflowCanvas.getBoundingClientRect();
  723 +
  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(); 747 initializeWorkflowEditor();
653 setupEventListeners(); 748 setupEventListeners();
654 showSampleTemplates(); 749 showSampleTemplates();
655 - });  
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;">
  470 + <div class="mb-4">
367 <div class="d-flex justify-content-between align-items-center mb-3"> 471 <div class="d-flex justify-content-between align-items-center mb-3">
368 <h6 class="mb-0">爬虫模板</h6> 472 <h6 class="mb-0">爬虫模板</h6>
369 <button class="btn btn-sm btn-outline-primary"> 473 <button class="btn btn-sm btn-outline-primary">
370 <i class="fas fa-plus"></i> 新建 474 <i class="fas fa-plus"></i> 新建
371 </button> 475 </button>
372 </div> 476 </div>
  477 + <div class="templates-wrapper">
373 <div id="crawlerTemplatesList"> 478 <div id="crawlerTemplatesList">
374 <!-- 爬虫模板列表将动态加载 --> 479 <!-- 爬虫模板列表将动态加载 -->
375 </div> 480 </div>
  481 + </div>
  482 + </div>
376 483
377 - <div class="d-flex justify-content-between align-items-center mb-3 mt-4"> 484 + <div class="mb-4">
  485 + <div class="d-flex justify-content-between align-items-center mb-3">
378 <h6 class="mb-0">分析流程模板</h6> 486 <h6 class="mb-0">分析流程模板</h6>
379 <button class="btn btn-sm btn-outline-primary"> 487 <button class="btn btn-sm btn-outline-primary">
380 <i class="fas fa-plus"></i> 新建 488 <i class="fas fa-plus"></i> 新建
381 </button> 489 </button>
382 </div> 490 </div>
  491 + <div class="templates-wrapper">
383 <div id="analysisTemplatesList"> 492 <div id="analysisTemplatesList">
384 <!-- 分析流程模板列表将动态加载 --> 493 <!-- 分析流程模板列表将动态加载 -->
385 </div> 494 </div>
386 </div> 495 </div>
387 </div> 496 </div>
  497 + </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,36 +635,83 @@ @@ -481,36 +635,83 @@
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;"> 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;">
501 <p class="text-muted">任务完成后将显示结果预览...</p> 683 <p class="text-muted">任务完成后将显示结果预览...</p>
502 </div> 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>
503 </div> 688 </div>
504 </div> 689 </div>
505 - <div class="modal-footer"> 690 + <div class="mt-2 text-end">
  691 + <span class="text-muted small" id="previewUpdatedTime"></span>
  692 + </div>
  693 + </div>
  694 + </div>
  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>
506 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button> 702 <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> 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>