From 54412b7f2a291de7d89e10758afaaeb9821c0ac2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=88=92=E9=85=92=E7=9A=84=E6=9D=8E=E7=99=BD?=
<670939375@qq.com>
Date: Mon, 17 Mar 2025 18:50:56 +0800
Subject: [PATCH] Process visualization orchestration BUG fix, feature
enhancement, and support for export and import of orchestration processes.
---
static/js/workflow_editor.js | 1453 +++++++++++++++++++++++++++++++-
templates/workflow_editor.html | 349 ++++++--
2 files changed, 1697 insertions(+), 105 deletions(-)
diff --git a/static/js/workflow_editor.js b/static/js/workflow_editor.js
index a5ec74b..5f44a4d 100644
--- a/static/js/workflow_editor.js
+++ b/static/js/workflow_editor.js
@@ -1,4 +1,13 @@
+let workflowEditorInitialized = false;
+
document.addEventListener('DOMContentLoaded', function() {
+ // 检查是否已初始化,防止多次执行
+ if (workflowEditorInitialized) {
+ console.log('工作流编辑器已初始化,跳过重复初始化');
+ return;
+ }
+ workflowEditorInitialized = true;
+
// 工作流编辑器的主要元素
const workflowCanvas = document.getElementById('workflowCanvas');
const connectionsSvg = document.getElementById('connectionsSvg');
@@ -24,6 +33,22 @@ document.addEventListener('DOMContentLoaded', function() {
let isConnecting = false;
let connectionStart = null;
let connectionPreviewPath = null;
+
+ // 记录初始化状态,避免重复初始化导致的组件重复添加
+ let isInitialized = false;
+
+ // 历史记录管理
+ const MAX_HISTORY = 50; // 最多保存50步历史
+ let history = [];
+ let currentHistoryIndex = -1;
+
+ // 自动保存相关变量
+ const AUTO_SAVE_INTERVAL = 3 * 60 * 1000; // 3分钟
+ let autoSaveTimer = null;
+
+ // 视图缩放相关变量
+ let canvasScale = 1;
+ let canvasTranslate = { x: 0, y: 0 };
// 设置编辑器网格背景
setEditorBackground();
@@ -137,43 +162,88 @@ document.addEventListener('DOMContentLoaded', function() {
}
function createNodeFromData(nodeData) {
+ // 检查节点是否已存在
+ const existingNode = document.getElementById(nodeData.id);
+ if (existingNode) {
+ console.warn('节点已存在:', nodeData.id);
+ return existingNode;
+ }
+
// 从数据创建节点DOM元素
const nodeElement = document.createElement('div');
nodeElement.className = 'workflow-node';
nodeElement.id = nodeData.id;
- nodeElement.style.left = nodeData.x + 'px';
- nodeElement.style.top = nodeData.y + 'px';
+
+ // 确保坐标有效
+ const x = typeof nodeData.x === 'number' ? nodeData.x : 100;
+ const y = typeof nodeData.y === 'number' ? nodeData.y : 100;
+
+ nodeElement.style.left = x + 'px';
+ nodeElement.style.top = y + 'px';
+
+ // 根据节点类型设置不同的样式
+ nodeElement.classList.add(`node-type-${nodeData.type}`);
// 构建节点内容
nodeElement.innerHTML = `
+
+
@@ -1023,6 +1197,11 @@ document.addEventListener('DOMContentLoaded', function() {
// 保存配置事件
document.getElementById('saveConfigBtn').addEventListener('click', function() {
const form = document.getElementById('nodeConfigForm');
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return;
+ }
+
const formData = new FormData(form);
const config = {};
@@ -1046,13 +1225,23 @@ document.addEventListener('DOMContentLoaded', function() {
const descElement = nodeElement.querySelector('.node-description');
if (descElement) {
descElement.textContent = '已配置';
+ descElement.classList.add('configured');
}
}
+
+ // 添加到历史记录
+ addToHistory();
+
+ // 显示成功通知
+ showNotification('成功', '节点配置已更新', 'success');
}
// 关闭面板
closePropertiesPanel();
});
+
+ // 取消配置事件
+ document.getElementById('cancelConfigBtn').addEventListener('click', closePropertiesPanel);
}
// 关闭属性面板
@@ -1062,4 +1251,1206 @@ document.addEventListener('DOMContentLoaded', function() {
// 绑定关闭属性面板的事件
document.getElementById('closePropertiesBtn').addEventListener('click', closePropertiesPanel);
+
+ function initializeAutoSave() {
+ // 开始自动保存计时器
+ startAutoSaveTimer();
+
+ // 添加用户交互检测
+ workflowCanvas.addEventListener('mousedown', resetAutoSaveTimer);
+ document.addEventListener('keydown', resetAutoSaveTimer);
+ }
+
+ function startAutoSaveTimer() {
+ if (autoSaveTimer) {
+ clearTimeout(autoSaveTimer);
+ }
+
+ autoSaveTimer = setTimeout(function() {
+ // 只在有节点时自动保存
+ if (workflowData.nodes.length > 0) {
+ console.log('自动保存工作流...');
+ // 设置自动保存标志
+ workflowData.autoSaved = true;
+ saveWorkflow(workflowData);
+ }
+ // 重新开始计时器
+ startAutoSaveTimer();
+ }, AUTO_SAVE_INTERVAL);
+ }
+
+ function resetAutoSaveTimer() {
+ startAutoSaveTimer();
+ }
+
+ // 初始化自动保存
+ initializeAutoSave();
+
+ // 优化拖放功能
+ workflowCanvas.addEventListener('dragover', function(e) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+
+ // 显示可放置区域指示
+ this.classList.add('drag-over');
+ });
+
+ workflowCanvas.addEventListener('dragleave', function() {
+ // 移除可放置区域指示
+ this.classList.remove('drag-over');
+ });
+
+ workflowCanvas.addEventListener('drop', function(e) {
+ e.preventDefault();
+ this.classList.remove('drag-over');
+
+ const componentType = e.dataTransfer.getData('componentType');
+ const componentSubtype = e.dataTransfer.getData('componentSubtype');
+
+ if (componentType && componentSubtype) {
+ const rect = workflowCanvas.getBoundingClientRect();
+ // 计算相对于画布的坐标
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ // 添加节点并记录历史
+ addNode(componentType, componentSubtype, x, y);
+ addToHistory();
+
+ // 用户反馈
+ showNotification('成功', `已添加 ${getComponentTypeLabel(componentType)}-${componentSubtype} 节点`, 'success');
+ }
+ });
+
+ // 优化创建节点函数
+ function createNodeFromData(nodeData) {
+ // 检查节点是否已存在
+ const existingNode = document.getElementById(nodeData.id);
+ if (existingNode) {
+ console.warn('节点已存在:', nodeData.id);
+ return existingNode;
+ }
+
+ // 从数据创建节点DOM元素
+ const nodeElement = document.createElement('div');
+ nodeElement.className = 'workflow-node';
+ nodeElement.id = nodeData.id;
+
+ // 确保坐标有效
+ const x = typeof nodeData.x === 'number' ? nodeData.x : 100;
+ const y = typeof nodeData.y === 'number' ? nodeData.y : 100;
+
+ nodeElement.style.left = x + 'px';
+ nodeElement.style.top = y + 'px';
+
+ // 根据节点类型设置不同的样式
+ nodeElement.classList.add(`node-type-${nodeData.type}`);
+
+ // 构建节点内容
+ nodeElement.innerHTML = `
+
+
+
${nodeData.subtype}
+
${nodeData.config ? '已配置' : '点击配置参数'}
+
+
+
+
+
+
+ `;
+
+ // 添加入场动画
+ nodeElement.classList.add('node-entering');
+ setTimeout(() => {
+ nodeElement.classList.remove('node-entering');
+ }, 300);
+
+ workflowCanvas.appendChild(nodeElement);
+
+ // 绑定配置按钮事件
+ const configBtn = nodeElement.querySelector('.config-node-btn');
+ configBtn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ openNodeConfig(nodeData);
+ });
+
+ // 添加到节点数据
+ const existingNodeIndex = workflowData.nodes.findIndex(node => node.id === nodeData.id);
+ if (existingNodeIndex === -1) {
+ workflowData.nodes.push(nodeData);
+ } else {
+ workflowData.nodes[existingNodeIndex] = nodeData;
+ }
+
+ return nodeElement;
+ }
+
+ // 优化节点配置面板
+ function openNodeConfig(nodeData) {
+ const propertiesPanel = document.getElementById('propertiesPanel');
+ const propertiesContent = document.getElementById('propertiesContent');
+
+ // 显示面板
+ propertiesPanel.classList.add('open');
+
+ // 生成配置表单
+ const configOptions = getComponentConfigs(nodeData.type, nodeData.subtype);
+ let formHtml = `
+
+
+ `;
+
+ propertiesContent.innerHTML = formHtml;
+
+ // 保存配置事件
+ document.getElementById('saveConfigBtn').addEventListener('click', function() {
+ const form = document.getElementById('nodeConfigForm');
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return;
+ }
+
+ const formData = new FormData(form);
+ const config = {};
+
+ // 构建配置对象
+ configOptions.forEach(option => {
+ if (option.type === 'checkbox') {
+ config[option.id] = document.getElementById(option.id).checked;
+ } else {
+ config[option.id] = formData.get(option.id);
+ }
+ });
+
+ // 更新节点配置
+ const node = workflowData.nodes.find(n => n.id === nodeData.id);
+ if (node) {
+ node.config = config;
+
+ // 更新节点显示
+ const nodeElement = document.getElementById(nodeData.id);
+ if (nodeElement) {
+ const descElement = nodeElement.querySelector('.node-description');
+ if (descElement) {
+ descElement.textContent = '已配置';
+ descElement.classList.add('configured');
+ }
+ }
+
+ // 添加到历史记录
+ addToHistory();
+
+ // 显示成功通知
+ showNotification('成功', '节点配置已更新', 'success');
+ }
+
+ // 关闭面板
+ closePropertiesPanel();
+ });
+
+ // 取消配置事件
+ document.getElementById('cancelConfigBtn').addEventListener('click', closePropertiesPanel);
+ }
+
+ // 改进连接预览
+ function updateConnectionPreview(clientX, clientY) {
+ if (!connectionStart || !connectionPreviewPath) return;
+
+ const sourceNode = document.getElementById(connectionStart.id);
+ if (!sourceNode) return;
+
+ const sourcePort = sourceNode.querySelector('.port-out');
+ const sourceRect = sourcePort.getBoundingClientRect();
+ const canvasRect = workflowCanvas.getBoundingClientRect();
+
+ const start = {
+ x: sourceRect.left + sourceRect.width/2 - canvasRect.left,
+ y: sourceRect.top + sourceRect.height/2 - canvasRect.top
+ };
+
+ const end = {
+ x: clientX - canvasRect.left,
+ y: clientY - canvasRect.top
+ };
+
+ // 高亮可连接的目标端口
+ document.querySelectorAll('.workflow-node').forEach(node => {
+ if (node.id !== connectionStart.id) {
+ const inputPort = node.querySelector('.port-in');
+ const inputPortRect = inputPort.getBoundingClientRect();
+
+ // 计算鼠标与端口的距离
+ const dx = clientX - (inputPortRect.left + inputPortRect.width/2);
+ const dy = clientY - (inputPortRect.top + inputPortRect.height/2);
+ const distance = Math.sqrt(dx*dx + dy*dy);
+
+ // 如果距离小于20像素,高亮端口
+ if (distance < 20) {
+ inputPort.classList.add('port-highlight');
+
+ // 更新预览连接终点到端口中心
+ end.x = inputPortRect.left + inputPortRect.width/2 - canvasRect.left;
+ end.y = inputPortRect.top + inputPortRect.height/2 - canvasRect.top;
+ } else {
+ inputPort.classList.remove('port-highlight');
+ }
+ }
+ });
+
+ // 绘制预览连接
+ const dx = Math.abs(end.x - start.x) * 0.5;
+ const pathData = `M ${start.x},${start.y} C ${start.x + dx},${start.y} ${end.x - dx},${end.y} ${end.x},${end.y}`;
+ connectionPreviewPath.setAttribute('d', pathData);
+ }
+
+ // 取消连接操作时清除高亮
+ function cancelConnection() {
+ if (connectionPreviewPath && connectionPreviewPath.parentNode) {
+ connectionPreviewPath.parentNode.removeChild(connectionPreviewPath);
+ }
+
+ // 移除所有高亮端口
+ document.querySelectorAll('.port-highlight').forEach(port => {
+ port.classList.remove('port-highlight');
+ });
+
+ isConnecting = false;
+ connectionStart = null;
+ connectionPreviewPath = null;
+ }
+
+ // 优化连接完成处理
+ function completeConnection(sourceId, targetId) {
+ // 检查是否是自连接
+ if (sourceId === targetId) {
+ showNotification('警告', '不能连接到自己', 'warning');
+ cancelConnection();
+ return;
+ }
+
+ // 检查连接是否已存在
+ const connectionExists = workflowData.connections.some(conn =>
+ conn.sourceId === sourceId && conn.targetId === targetId);
+
+ if (connectionExists) {
+ showNotification('警告', '连接已存在', 'warning');
+ cancelConnection();
+ return;
+ }
+
+ // 检查是否会形成循环
+ if (wouldCreateCycle(sourceId, targetId)) {
+ showNotification('错误', '不能创建循环连接', 'error');
+ cancelConnection();
+ return;
+ }
+
+ // 生成连接ID
+ const connectionId = `conn_${Date.now()}`;
+
+ // 添加到数据中
+ workflowData.connections.push({
+ id: connectionId,
+ sourceId: sourceId,
+ targetId: targetId
+ });
+
+ // 绘制最终连接
+ drawConnection(sourceId, targetId, connectionId);
+
+ // 清理预览状态
+ cancelConnection();
+
+ // 更新工作流状态
+ onWorkflowChanged();
+
+ // 显示成功通知
+ showNotification('成功', '已创建连接', 'success');
+ }
+
+ // 检查是否会形成循环
+ function wouldCreateCycle(sourceId, targetId) {
+ // 如果目标节点可以到达源节点,那么添加这条边会导致循环
+ return canReach(targetId, sourceId, new Set());
+ }
+
+ // 检查从startId是否可以到达endId
+ function canReach(startId, endId, visited) {
+ if (startId === endId) return true;
+
+ // 标记当前节点为已访问
+ visited.add(startId);
+
+ // 获取startId的所有出边
+ const outConnections = workflowData.connections.filter(conn => conn.sourceId === startId);
+
+ // 检查每条出边
+ for (const conn of outConnections) {
+ const nextId = conn.targetId;
+
+ // 如果下一个节点未访问,继续搜索
+ if (!visited.has(nextId)) {
+ if (canReach(nextId, endId, visited)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ // 验证工作流
+ function validateWorkflow(workflow) {
+ // 检查是否有节点
+ if (!workflow.nodes || workflow.nodes.length === 0) {
+ return { valid: false, message: '工作流没有节点' };
+ }
+
+ // 检查是否有连接
+ if (!workflow.connections || workflow.connections.length === 0) {
+ return { valid: false, message: '工作流没有连接' };
+ }
+
+ // 检查是否存在没有配置的节点
+ const unconfiguredNodes = workflow.nodes.filter(node => !node.config);
+ if (unconfiguredNodes.length > 0) {
+ const nodeTitles = unconfiguredNodes.map(n => n.title).join(', ');
+ return { valid: false, message: `以下节点未配置: ${nodeTitles}` };
+ }
+
+ // 检查是否存在没有输入的节点(除了数据源类型)
+ const nonSourceNodes = workflow.nodes.filter(node => node.type !== 'data_source');
+ for (const node of nonSourceNodes) {
+ const hasInput = workflow.connections.some(conn => conn.targetId === node.id);
+ if (!hasInput) {
+ return { valid: false, message: `节点 ${node.title} 没有输入连接` };
+ }
+ }
+
+ // 检查是否存在没有输出的节点(除了可视化类型)
+ const nonVisNodes = workflow.nodes.filter(node => node.type !== 'visualization');
+ for (const node of nonVisNodes) {
+ const hasOutput = workflow.connections.some(conn => conn.sourceId === node.id);
+ if (!hasOutput) {
+ return { valid: false, message: `节点 ${node.title} 没有输出连接` };
+ }
+ }
+
+ return { valid: true };
+ }
+
+ // 初始化撤销/重做按钮
+ function initializeToolbarButtons() {
+ const undoBtn = document.createElement('button');
+ undoBtn.id = 'undoBtn';
+ undoBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn';
+ undoBtn.title = '撤销';
+ undoBtn.innerHTML = '
';
+ undoBtn.disabled = true;
+ undoBtn.addEventListener('click', undo);
+
+ const redoBtn = document.createElement('button');
+ redoBtn.id = 'redoBtn';
+ redoBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn';
+ redoBtn.title = '重做';
+ redoBtn.innerHTML = '
';
+ redoBtn.disabled = true;
+ redoBtn.addEventListener('click', redo);
+
+ // 查找工具栏容器
+ const toolbarContainer = document.getElementById('workflowToolbar');
+ if (toolbarContainer) {
+ toolbarContainer.prepend(redoBtn);
+ toolbarContainer.prepend(undoBtn);
+ } else {
+ // 如果没有找到工具栏,创建一个浮动工具栏
+ const floatingToolbar = document.createElement('div');
+ floatingToolbar.id = 'workflowToolbar';
+ floatingToolbar.className = 'workflow-floating-toolbar';
+ floatingToolbar.appendChild(undoBtn);
+ floatingToolbar.appendChild(redoBtn);
+
+ document.body.appendChild(floatingToolbar);
+ }
+ }
+
+ // 改进显示示例模板逻辑
+ function showSampleTemplates() {
+ // 使用示例模板数据
+ const sampleTemplates = [
+ {
+ id: 'template_1',
+ name: '微博热搜分析模板',
+ description: '爬取微博热搜榜数据,分析热点话题和情感倾向',
+ icon: 'fire'
+ },
+ {
+ id: 'template_2',
+ name: '用户评论情感分析',
+ description: '分析用户评论的情感倾向,生成情感分布图表',
+ icon: 'heart'
+ },
+ {
+ id: 'template_3',
+ name: '话题趋势监测',
+ description: '监测特定话题的讨论热度变化及关键词提取',
+ icon: 'chart-line'
+ },
+ {
+ id: 'template_4',
+ name: '舆情预警分析',
+ description: '实时监测并预警负面舆情,生成应对建议',
+ icon: 'bell'
+ }
+ ];
+
+ try {
+ // 尝试寻找合适的容器
+ const containers = [
+ document.getElementById('analysisTemplatesList'),
+ document.getElementById('templateList'),
+ document.getElementById('crawlerTemplatesList'),
+ document.querySelector('.templates-container')
+ ];
+
+ const container = containers.find(el => el !== null);
+
+ if (container) {
+ container.innerHTML = '';
+ sampleTemplates.forEach(template => {
+ const templateDiv = createTemplateCard(template);
+ container.appendChild(templateDiv);
+ });
+ } else {
+ console.warn('未找到合适的模板容器');
+
+ // 如果找不到容器,尝试创建一个模板区域
+ const templatesPanel = document.getElementById('templatesPanel');
+ if (templatesPanel) {
+ const newContainer = document.createElement('div');
+ newContainer.className = 'templates-container';
+ templatesPanel.appendChild(newContainer);
+
+ sampleTemplates.forEach(template => {
+ const templateDiv = createTemplateCard(template);
+ newContainer.appendChild(templateDiv);
+ });
+ }
+ }
+ } catch (error) {
+ console.error('加载模板出错:', error);
+ }
+ }
+
+ // 添加键盘快捷键支持
+ function setupKeyboardShortcuts() {
+ document.addEventListener('keydown', function(e) {
+ // Ctrl+Z: 撤销
+ if (e.ctrlKey && e.key === 'z') {
+ e.preventDefault();
+ undo();
+ }
+
+ // Ctrl+Y: 重做
+ if (e.ctrlKey && e.key === 'y') {
+ e.preventDefault();
+ redo();
+ }
+
+ // Ctrl+S: 保存
+ if (e.ctrlKey && e.key === 's') {
+ e.preventDefault();
+ saveWorkflow(workflowData);
+ }
+
+ // Delete: 删除选中的节点
+ if (e.key === 'Delete') {
+ const selectedNode = document.querySelector('.workflow-node.selected');
+ if (selectedNode) {
+ deleteNode(selectedNode.id);
+ addToHistory();
+ }
+ }
+
+ // Escape: 取消连接或关闭配置面板
+ if (e.key === 'Escape') {
+ if (isConnecting) {
+ cancelConnection();
+ } else if (document.getElementById('propertiesPanel').classList.contains('open')) {
+ closePropertiesPanel();
+ }
+ }
+ });
+ }
+
+ // 添加节点选择功能
+ function setupNodeSelection() {
+ workflowCanvas.addEventListener('click', function(e) {
+ if (e.target === workflowCanvas || e.target === connectionsSvg) {
+ // 如果点击的是画布本身,清除所有选中
+ clearNodeSelection();
+ }
+ });
+ }
+
+ // 清除节点选择
+ function clearNodeSelection() {
+ document.querySelectorAll('.workflow-node.selected').forEach(node => {
+ node.classList.remove('selected');
+ });
+ }
+
+ // 为节点添加选择功能
+ function setupNodeSelectEvents(nodeElement) {
+ nodeElement.addEventListener('click', function(e) {
+ // 如果没有按下Ctrl键,先清除其他节点的选择
+ if (!e.ctrlKey) {
+ clearNodeSelection();
+ }
+
+ // 选择当前节点
+ nodeElement.classList.add('selected');
+ e.stopPropagation();
+ });
+ }
+
+ // 启用键盘快捷键和节点选择功能
+ setupKeyboardShortcuts();
+ setupNodeSelection();
+
+ // 初始化
+ initializeToolbarButtons();
+ // 初始化:将当前状态添加到历史记录
+ addToHistory();
+
+ // 添加导出/导入功能
+ function setupExportImport() {
+ const exportBtn = document.createElement('button');
+ exportBtn.id = 'exportWorkflowBtn';
+ exportBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn';
+ exportBtn.title = '导出工作流';
+ exportBtn.innerHTML = '
';
+ exportBtn.addEventListener('click', exportWorkflow);
+
+ const importBtn = document.createElement('button');
+ importBtn.id = 'importWorkflowBtn';
+ importBtn.className = 'btn btn-sm btn-outline-secondary toolbar-btn';
+ importBtn.title = '导入工作流';
+ importBtn.innerHTML = '
';
+ importBtn.addEventListener('click', importWorkflow);
+
+ // 添加到工具栏
+ const toolbarContainer = document.getElementById('workflowToolbar');
+ if (toolbarContainer) {
+ toolbarContainer.appendChild(exportBtn);
+ toolbarContainer.appendChild(importBtn);
+ }
+ }
+
+ function exportWorkflow() {
+ // 创建下载内容
+ const workflowJson = JSON.stringify(workflowData, null, 2);
+ const blob = new Blob([workflowJson], {type: 'application/json'});
+ const url = URL.createObjectURL(blob);
+
+ // 创建下载链接
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${workflowData.metadata.name || 'workflow'}_${new Date().toISOString().slice(0,10)}.json`;
+ document.body.appendChild(a);
+ a.click();
+
+ // 清理
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, 0);
+
+ showNotification('成功', '工作流已导出为JSON文件', 'success');
+ }
+
+ function importWorkflow() {
+ // 创建文件输入元素
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = 'application/json';
+ fileInput.style.display = 'none';
+
+ fileInput.addEventListener('change', function(e) {
+ if (!e.target.files.length) return;
+
+ const file = e.target.files[0];
+ const reader = new FileReader();
+
+ reader.onload = function(event) {
+ try {
+ const importedWorkflow = JSON.parse(event.target.result);
+
+ // 验证导入的数据
+ if (!importedWorkflow.nodes || !importedWorkflow.metadata) {
+ throw new Error('无效的工作流文件格式');
+ }
+
+ // 提示用户确认
+ if (confirm('确定要导入此工作流?这将替换当前的工作流。')) {
+ clearWorkflow();
+ renderWorkflow(importedWorkflow);
+ addToHistory();
+ showNotification('成功', '工作流已导入', 'success');
+ }
+ } catch (err) {
+ console.error('导入工作流出错:', err);
+ showNotification('错误', '导入失败:无效的工作流文件', 'error');
+ }
+ };
+
+ reader.readAsText(file);
+ });
+
+ document.body.appendChild(fileInput);
+ fileInput.click();
+
+ // 清理
+ setTimeout(() => {
+ document.body.removeChild(fileInput);
+ }, 0);
+ }
+
+ // 设置导出/导入功能
+ setupExportImport();
+
+ // ====== 历史记录管理 ======
+ function addToHistory() {
+ // 如果当前不是历史的最后一步,截断历史
+ if (currentHistoryIndex < history.length - 1) {
+ history = history.slice(0, currentHistoryIndex + 1);
+ }
+
+ // 深拷贝当前状态
+ const stateCopy = JSON.parse(JSON.stringify(workflowData));
+ history.push(stateCopy);
+
+ // 限制历史记录大小
+ if (history.length > MAX_HISTORY) {
+ history.shift();
+ } else {
+ currentHistoryIndex++;
+ }
+
+ // 更新撤销/重做按钮状态
+ updateHistoryButtonStates();
+ }
+
+ function undo() {
+ if (currentHistoryIndex > 0) {
+ currentHistoryIndex--;
+ restoreFromHistory();
+ showNotification('撤销', '已撤销上一步操作', 'info');
+ }
+ }
+
+ function redo() {
+ if (currentHistoryIndex < history.length - 1) {
+ currentHistoryIndex++;
+ restoreFromHistory();
+ showNotification('重做', '已重做操作', 'info');
+ }
+ }
+
+ function restoreFromHistory() {
+ // 从历史记录恢复工作流状态
+ const historicalState = history[currentHistoryIndex];
+
+ // 清除画布
+ clearWorkflow();
+
+ // 恢复状态
+ workflowData = JSON.parse(JSON.stringify(historicalState));
+ renderWorkflow(workflowData);
+
+ // 更新按钮状态
+ updateHistoryButtonStates();
+ }
+
+ function updateHistoryButtonStates() {
+ const undoBtn = document.getElementById('undoBtn');
+ const redoBtn = document.getElementById('redoBtn');
+
+ if (undoBtn) {
+ undoBtn.disabled = currentHistoryIndex <= 0;
+ }
+
+ if (redoBtn) {
+ redoBtn.disabled = currentHistoryIndex >= history.length - 1;
+ }
+ }
+
+ // ====== 通知系统 ======
+ function showNotification(title, message, type = 'info') {
+ // 创建通知元素
+ const notification = document.createElement('div');
+ notification.className = `workflow-notification notification-${type}`;
+
+ notification.innerHTML = `
+
+
+
+
${title}
+
${message}
+
+
+
+ `;
+
+ // 添加到通知容器
+ const container = document.getElementById('notificationContainer');
+ if (!container) {
+ // 如果容器不存在,创建一个
+ const notificationContainer = document.createElement('div');
+ notificationContainer.id = 'notificationContainer';
+ notificationContainer.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 1050;
+ max-width: 350px;
+ `;
+ document.body.appendChild(notificationContainer);
+ notificationContainer.appendChild(notification);
+ } else {
+ container.appendChild(notification);
+ }
+
+ // 显示通知
+ setTimeout(() => {
+ notification.classList.add('show');
+ }, 10);
+
+ // 绑定关闭按钮事件
+ const closeBtn = notification.querySelector('.btn-close');
+ closeBtn.addEventListener('click', () => {
+ hideNotification(notification);
+ });
+
+ // 设置自动隐藏
+ setTimeout(() => {
+ hideNotification(notification);
+ }, 5000);
+ }
+
+ function hideNotification(notification) {
+ notification.classList.remove('show');
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.parentNode.removeChild(notification);
+ }
+ }, 300);
+ }
+
+ function getNotificationIcon(type) {
+ switch (type) {
+ case 'success': return 'fa-check-circle';
+ case 'warning': return 'fa-exclamation-triangle';
+ case 'error': return 'fa-times-circle';
+ case 'info':
+ default: return 'fa-info-circle';
+ }
+ }
+
+ // 优化工作流验证功能
+ function validateWorkflow(workflow) {
+ // 检查是否有节点
+ if (!workflow.nodes || workflow.nodes.length === 0) {
+ return { valid: false, message: '工作流没有节点' };
+ }
+
+ // 检查是否有连接
+ if (!workflow.connections || workflow.connections.length === 0) {
+ return { valid: false, message: '工作流没有连接' };
+ }
+
+ // 检查是否存在没有配置的节点
+ const unconfiguredNodes = workflow.nodes.filter(node => !node.config);
+ if (unconfiguredNodes.length > 0) {
+ const nodeTitles = unconfiguredNodes.map(n => n.title).join(', ');
+ return { valid: false, message: `以下节点未配置: ${nodeTitles}` };
+ }
+
+ // 检查是否存在没有输入的节点(除了数据源类型)
+ const nonSourceNodes = workflow.nodes.filter(node => node.type !== 'data_source');
+ for (const node of nonSourceNodes) {
+ const hasInput = workflow.connections.some(conn => conn.targetId === node.id);
+ if (!hasInput) {
+ return { valid: false, message: `节点 "${node.title}" 没有输入连接` };
+ }
+ }
+
+ // 检查是否存在没有输出的节点(除了可视化类型和预测类型的某些子类型)
+ const nonOutputNodeTypes = ['visualization'];
+ const nonOutputNodeSubtypes = {
+ 'prediction': ['report', 'alert'] // 这些预测子类型不需要输出
+ };
+
+ const shouldHaveOutput = node => {
+ if (nonOutputNodeTypes.includes(node.type)) return false;
+ return !(nonOutputNodeSubtypes[node.type] &&
+ nonOutputNodeSubtypes[node.type].includes(node.subtype));
+ };
+
+ const nonVisNodes = workflow.nodes.filter(shouldHaveOutput);
+
+ for (const node of nonVisNodes) {
+ const hasOutput = workflow.connections.some(conn => conn.sourceId === node.id);
+ if (!hasOutput) {
+ return { valid: false, message: `节点 "${node.title}" 没有输出连接` };
+ }
+ }
+
+ // 检查是否有环
+ const nodeIds = workflow.nodes.map(node => node.id);
+ for (const nodeId of nodeIds) {
+ if (hasCycle(nodeId, new Set(), workflow.connections)) {
+ return { valid: false, message: '工作流中存在循环连接' };
+ }
+ }
+
+ // 检查是否有悬空连接(连接指向不存在的节点)
+ for (const conn of workflow.connections) {
+ if (!workflow.nodes.some(node => node.id === conn.sourceId)) {
+ return { valid: false, message: `存在连接指向不存在的源节点ID: ${conn.sourceId}` };
+ }
+ if (!workflow.nodes.some(node => node.id === conn.targetId)) {
+ return { valid: false, message: `存在连接指向不存在的目标节点ID: ${conn.targetId}` };
+ }
+ }
+
+ return { valid: true };
+ }
+
+ // 检查是否有环
+ function hasCycle(currentId, visited, connections) {
+ if (visited.has(currentId)) {
+ return true; // 发现环
+ }
+
+ visited.add(currentId);
+
+ // 获取从currentId出发的所有连接
+ const outgoingConnections = connections.filter(conn => conn.sourceId === currentId);
+
+ for (const conn of outgoingConnections) {
+ const nextId = conn.targetId;
+
+ // 创建一个新的已访问集合副本
+ const newVisited = new Set(visited);
+ if (hasCycle(nextId, newVisited, connections)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // 验证工作流按钮事件
+ document.getElementById('validateWorkflowBtn')?.addEventListener('click', function() {
+ const result = validateWorkflow(workflowData);
+ if (result.valid) {
+ showNotification('验证通过', '工作流有效,可以运行', 'success');
+ } else {
+ showNotification('验证失败', result.message, 'error');
+ }
+ });
+
+ // 工作流工具栏事件绑定
+ function bindToolbarEvents() {
+ // 撤销/重做
+ document.getElementById('undoBtn')?.addEventListener('click', undo);
+ document.getElementById('redoBtn')?.addEventListener('click', redo);
+
+ // 缩放控制
+ document.getElementById('zoomInBtn')?.addEventListener('click', () => {
+ zoomCanvas(0.1);
+ });
+
+ document.getElementById('zoomOutBtn')?.addEventListener('click', () => {
+ zoomCanvas(-0.1);
+ });
+
+ document.getElementById('fitViewBtn')?.addEventListener('click', fitCanvasView);
+ }
+
+ // 画布缩放功能
+ function zoomCanvas(delta) {
+ let newScale = canvasScale + delta;
+
+ // 限制缩放范围
+ newScale = Math.max(0.5, Math.min(2, newScale));
+
+ if (newScale !== canvasScale) {
+ canvasScale = newScale;
+ applyCanvasTransform();
+
+ // 更新连接线
+ workflowData.connections.forEach(conn => {
+ const path = document.getElementById('connection_' + conn.id);
+ if (path) {
+ path.parentNode.removeChild(path);
+ }
+ drawConnection(conn.sourceId, conn.targetId, conn.id);
+ });
+
+ // 显示当前缩放比例
+ showNotification('视图', `缩放比例: ${Math.round(canvasScale * 100)}%`, 'info');
+ }
+ }
+
+ // 适应视图
+ function fitCanvasView() {
+ if (workflowData.nodes.length === 0) {
+ return; // 没有节点,不需要调整
+ }
+
+ // 重置缩放和平移
+ canvasScale = 1;
+ canvasTranslate = { x: 0, y: 0 };
+ applyCanvasTransform();
+
+ // 重新绘制所有连接
+ workflowData.connections.forEach(conn => {
+ const path = document.getElementById('connection_' + conn.id);
+ if (path) {
+ path.parentNode.removeChild(path);
+ }
+ drawConnection(conn.sourceId, conn.targetId, conn.id);
+ });
+
+ showNotification('视图', '已重置视图', 'info');
+ }
+
+ // 应用画布变换
+ function applyCanvasTransform() {
+ const transform = `scale(${canvasScale}) translate(${canvasTranslate.x}px, ${canvasTranslate.y}px)`;
+ workflowCanvas.style.transform = transform;
+ }
+
+ // 更新工作流状态信息
+ function updateWorkflowStatus() {
+ const nodeCount = document.getElementById('nodeCount');
+ const connectionCount = document.getElementById('connectionCount');
+ const statusBar = document.getElementById('workflowStatusBar');
+
+ if (nodeCount) nodeCount.textContent = workflowData.nodes.length;
+ if (connectionCount) connectionCount.textContent = workflowData.connections.length;
+
+ if (statusBar) {
+ // 根据工作流状态更新状态栏
+ if (workflowData.nodes.length === 0) {
+ statusBar.style.display = 'flex';
+ statusBar.querySelector('#workflowStatusMessage').textContent =
+ '工作流就绪。拖拽左侧组件到画布创建节点。';
+ } else if (workflowData.connections.length === 0) {
+ statusBar.style.display = 'flex';
+ statusBar.querySelector('#workflowStatusMessage').textContent =
+ '已添加节点。请连接节点以创建完整工作流。';
+ } else {
+ const validationResult = validateWorkflow(workflowData);
+ if (!validationResult.valid) {
+ statusBar.style.display = 'flex';
+ statusBar.querySelector('#workflowStatusMessage').textContent =
+ `工作流需要修正: ${validationResult.message}`;
+ statusBar.classList.add('bg-warning-subtle');
+ statusBar.classList.remove('bg-light', 'bg-success-subtle');
+ } else {
+ statusBar.style.display = 'flex';
+ statusBar.querySelector('#workflowStatusMessage').textContent =
+ '工作流有效,可以运行。';
+ statusBar.classList.add('bg-success-subtle');
+ statusBar.classList.remove('bg-light', 'bg-warning-subtle');
+ }
+ }
+ }
+ }
+
+ // 每次工作流变化时更新状态
+ function onWorkflowChanged() {
+ updateWorkflowStatus();
+ addToHistory();
+ }
+
+ // 增强添加节点函数
+ function addNode(componentType, componentSubtype, x, y) {
+ const nodeId = 'node_' + Date.now();
+ const nodeData = {
+ id: nodeId,
+ type: componentType,
+ subtype: componentSubtype,
+ title: getComponentTypeLabel(componentType) + '-' + componentSubtype,
+ x: x,
+ y: y,
+ config: getDefaultConfig(componentType, componentSubtype)
+ };
+
+ const nodeElement = createNodeFromData(nodeData);
+ setupNodeEvents(nodeElement, nodeData);
+
+ // 更新工作流状态
+ onWorkflowChanged();
+
+ return nodeElement;
+ }
+
+ // 增强删除节点函数
+ function deleteNode(nodeId) {
+ const node = document.getElementById(nodeId);
+ if (node) {
+ node.parentNode.removeChild(node);
+ }
+
+ // 删除相关连接
+ workflowData.connections = workflowData.connections.filter(conn => {
+ if (conn.sourceId === nodeId || conn.targetId === nodeId) {
+ const path = document.getElementById('connection_' + conn.id);
+ if (path) {
+ path.parentNode.removeChild(path);
+ }
+ return false;
+ }
+ return true;
+ });
+
+ // 从数据中删除节点
+ workflowData.nodes = workflowData.nodes.filter(node => node.id !== nodeId);
+
+ // 更新工作流状态
+ onWorkflowChanged();
+
+ showNotification('已删除', '节点已从工作流中移除', 'info');
+ }
+
+ // 增强完成连接函数
+ function completeConnection(sourceId, targetId) {
+ // 检查是否是自连接
+ if (sourceId === targetId) {
+ showNotification('警告', '不能连接到自己', 'warning');
+ cancelConnection();
+ return;
+ }
+
+ // 检查连接是否已存在
+ const connectionExists = workflowData.connections.some(conn =>
+ conn.sourceId === sourceId && conn.targetId === targetId);
+
+ if (connectionExists) {
+ showNotification('警告', '连接已存在', 'warning');
+ cancelConnection();
+ return;
+ }
+
+ // 检查是否会形成循环
+ if (wouldCreateCycle(sourceId, targetId)) {
+ showNotification('错误', '不能创建循环连接', 'error');
+ cancelConnection();
+ return;
+ }
+
+ // 生成连接ID
+ const connectionId = `conn_${Date.now()}`;
+
+ // 添加到数据中
+ workflowData.connections.push({
+ id: connectionId,
+ sourceId: sourceId,
+ targetId: targetId
+ });
+
+ // 绘制最终连接
+ drawConnection(sourceId, targetId, connectionId);
+
+ // 清理预览状态
+ cancelConnection();
+
+ // 更新工作流状态
+ onWorkflowChanged();
+
+ // 显示成功通知
+ showNotification('成功', '已创建连接', 'success');
+ }
+
+ // 绑定工具栏事件
+ bindToolbarEvents();
+
+ // 初始化:将当前状态添加到历史记录
+ addToHistory();
+
+ // 初始更新工作流状态
+ updateWorkflowStatus();
});
diff --git a/templates/workflow_editor.html b/templates/workflow_editor.html
index 91d9c65..e0f4069 100644
--- a/templates/workflow_editor.html
+++ b/templates/workflow_editor.html
@@ -27,6 +27,24 @@
font-weight: 600;
}
+ /* 修复顶部栏固定问题 */
+ .navbar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1030;
+ /* 修复banner宽度问题 */
+ width: 100%;
+ max-width: 100%;
+ }
+
+ /* 添加顶部导航栏高度的内边距,防止内容被遮挡 */
+ .container-fluid {
+ /* padding-top: 10px; */
+ }
+
+ /* 修复侧边栏样式 */
.sidebar {
position: fixed;
top: 56px;
@@ -36,19 +54,29 @@
padding: 20px 0;
width: 280px;
overflow-x: hidden;
+ /* 确保只有一个滚动条 */
overflow-y: auto;
background-color: white;
border-right: 1px solid var(--border-color);
}
+ /* 完全移除子面板的独立滚动 */
+ #componentsPanel, #templatesPanel {
+ height: auto;
+ padding: 0 15px;
+ /* 完全禁用独立滚动 */
+ overflow: visible;
+ }
+
.main-content {
+ margin-top: 50px;
margin-left: 280px;
padding: 20px;
}
.workflow-canvas {
background-color: white;
- min-height: calc(100vh - 150px);
+ min-height: calc(100vh - 200px);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
position: relative;
@@ -89,6 +117,7 @@
padding: 12px;
cursor: move;
z-index: 10;
+ transition: transform 0.3s;
}
.workflow-node .node-header {
@@ -142,12 +171,19 @@
fill: none;
}
+ /* 修复模板项布局样式 */
+ .templates-wrapper {
+ padding: 0 15px;
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+ }
+
.template-item {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
- cursor: pointer;
+ background-color: white;
transition: all 0.3s;
}
@@ -159,12 +195,26 @@
.template-item .template-title {
font-weight: 600;
color: #333;
+ font-size: 14px;
+ margin-bottom: 5px;
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.template-item .template-desc {
color: #666;
- font-size: 13px;
+ font-size: 12px;
margin-top: 5px;
+ margin-bottom: 10px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ height: 36px;
}
.active-tab {
@@ -177,49 +227,75 @@
padding-top: 20px;
}
- .task-item {
+ /* 优化组件和模板面板的滚动行为 */
+ #componentsPanel, #templatesPanel {
+ height: calc(100vh - 120px);
+ overflow-y: auto;
+ padding: 0 15px;
+ }
+
+ .node-entering {
+ animation: nodeEnter 0.3s ease;
+ }
+
+ @keyframes nodeEnter {
+ from {
+ opacity: 0;
+ transform: scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ /* 添加拖放反馈样式 */
+ .workflow-canvas.drag-over {
+ border: 2px dashed var(--primary-color);
+ background-color: rgba(24, 144, 255, 0.05);
+ }
+
+ /* 添加通知样式 */
+ .workflow-notification {
background-color: white;
border-radius: 6px;
- padding: 15px;
- margin-bottom: 15px;
- border-left: 4px solid var(--primary-color);
- }
-
- .task-item.running {
- border-left-color: var(--primary-color);
- }
-
- .task-item.completed {
- border-left-color: var(--success-color);
- }
-
- .task-item.failed {
- border-left-color: var(--error-color);
- }
-
- .properties-panel {
- position: fixed;
- top: 76px;
- right: 20px;
- width: 320px;
- background-color: white;
- border-radius: 8px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- padding: 15px;
- max-height: calc(100vh - 120px);
- overflow-y: auto;
- z-index: 100;
- transform: translateX(360px);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+ padding: 12px;
+ margin-bottom: 10px;
+ transform: translateX(100%);
transition: transform 0.3s;
+ max-width: 320px;
}
- .properties-panel.open {
+ .workflow-notification.show {
transform: translateX(0);
}
- .form-label {
- font-weight: 500;
- font-size: 13px;
+ .notification-success {
+ border-left: 4px solid var(--success-color);
+ }
+
+ .notification-warning {
+ border-left: 4px solid var(--warning-color);
+ }
+
+ .notification-error {
+ border-left: 4px solid var(--error-color);
+ }
+
+ .notification-info {
+ border-left: 4px solid var(--primary-color);
+ }
+
+ /* 优化节点选中样式 */
+ .workflow-node.selected {
+ box-shadow: 0 0 0 2px var(--primary-color);
+ z-index: 11;
+ }
+
+ .port-highlight {
+ box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.5);
+ transform: scale(1.2);
}
/* 媒体查询用于响应式设计 */
@@ -249,10 +325,37 @@
.properties-panel.open {
transform: translateY(0);
}
+
+ #componentsPanel, #templatesPanel {
+ height: auto;
+ max-height: 400px;
+ }
+ }
+
+ /* 修复属性面板样式 */
+ .properties-panel {
+ position: fixed;
+ top: 70px;
+ right: 20px;
+ width: 320px;
+ background-color: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ padding: 15px;
+ transform: translateX(calc(100% + 20px));
+ transition: transform 0.3s ease;
+ z-index: 900;
+ max-height: calc(100vh - 100px);
+ overflow-y: auto;
+ }
+
+ .properties-panel.open {
+ transform: translateX(0);
}
+