vue3+jsplumb

image.png
"jsplumb": "^2.15.6"
<template>
  <div>
    <HighForm>
      <template #high-form-body>
        <div class="flow-container" ref="containerRef">
          <!-- 顶部工具栏 -->
          <div class="toolbar">
            <el-icon>
              <el-tooltip content="放大">
                <ZoomOut @click="zoomIn" />
              </el-tooltip>
            </el-icon>
            <el-icon>
              <el-tooltip content="缩小">
                <ZoomIn @click="zoomOut" />
              </el-tooltip>
            </el-icon>
            <el-icon>
              <el-tooltip content="居中">
                <Operation @click="fitToCenter" />
              </el-tooltip>
            </el-icon>
            <el-icon>
              <el-tooltip content="导入">
                <Upload @click="openImportDialog" />
              </el-tooltip>
            </el-icon>
            <el-icon>
              <el-tooltip content="清空">
                <Delete @click="clearCanvas" />
              </el-tooltip>
            </el-icon>
          </div>

          <!-- 左侧选择面板 -->
          <div class="sidebar">
            <div class="section">
              <h4>节点类型</h4>
              <div class="node-item" draggable="true" @dragstart="onDragStart($event, 'start')">开始</div>
              <div class="node-item" draggable="true" @dragstart="onDragStart($event, 'task')">任务</div>
              <div class="node-item" draggable="true" @dragstart="onDragStart($event, 'end')">结束</div>
            </div>
          </div>
          <!-- 节点容器(视口) -->
          <div class="nodes-area" ref="nodesAreaRef" @dragover.prevent @drop="onDrop">
            <!-- 👇 可平移的画布层(jsPlumb 操作此层内的节点) -->
            <div
              ref="canvasLayerRef"
              class="canvas-layer"
              :style="{
                transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoomLevel})`,
                transformOrigin: '0 0'
              }">
              <div
                v-for="node in nodes"
                :key="node.id"
                :id="`${node.id}`"
                :class="['flow-node', node.type]"
                @contextmenu.prevent="(e) => showContextMenu(e, node)"
                @click="selectNode(node)">
                <!-- 内容不变 -->
                <div class="side-bar" :style="{ backgroundColor: getSideBarColor(node.type) }"></div>
                <div class="label">{{ node.name || '未命名' }}</div>
                <div class="endpoint" :data-node-id="node.id"></div>
              </div>
            </div>
          </div>
          <!-- 右键菜单 -->
          <div
            v-if="contextMenu.visible"
            class="context-menu"
            :style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }">
            <!--      <button @click="editNode(contextMenu.node)">编辑</button>-->
            <button @click="deleteNode(contextMenu.node)">删除</button>
            <button @click="hideContextMenu">取消</button>
          </div>

          <!-- 属性面板 -->

          <div class="properties-panel" v-show="nodeDialog">
            <h4>节点信息</h4>
            <el-form :model="selectedNode">
              <el-form-item label="节点名称">
                <el-input v-model="selectedNode.name" placeholder="请输入节点名称" />
              </el-form-item>
              <el-form-item label="节点描述">
                <el-input v-model="selectedNode.description" placeholder="请输入节点描述" />
              </el-form-item>
              <el-form-item label="程序类型">
                <el-select v-model="selectedNode.programType" placeholder="请选择程序类型">
                  <el-option label="审核信息" value="审核信息" />
                  <el-option label="审批流程" value="审批流程" />
                  <el-option label="通知提醒" value="通知提醒" />
                </el-select>
              </el-form-item>
              <el-form-item label="程序编码">
                <el-input v-model="selectedNode.programCode" placeholder="请输入程序编码" />
              </el-form-item>
              <el-form-item label="扩展属性1">
                <el-input v-model="selectedNode.attribute1" placeholder="请输入扩展属性1" />
              </el-form-item>
              <el-form-item label="扩展属性2">
                <el-input v-model="selectedNode.attribute2" placeholder="请输入扩展属性2" />
              </el-form-item>
              <el-form-item label="扩展属性3">
                <el-input v-model="selectedNode.attribute3" placeholder="请输入扩展属性3" />
              </el-form-item>
              <el-form-item label="扩展属性4">
                <el-input v-model="selectedNode.attribute4" placeholder="请输入扩展属性4" />
              </el-form-item>
              <el-form-item label="扩展属性5">
                <el-input v-model="selectedNode.attribute5" placeholder="请输入扩展属性5" />
              </el-form-item>
            </el-form>
            <div class="actions">
              <el-button @click="nodeDialog = false" style="margin: 0 auto">关闭</el-button>
            </div>
          </div>
        </div>
      </template>
      <template #high-form-footer>
        <el-button @click="handleCancel">取消</el-button>
        <el-button type="primary" @click="handleSave">保存</el-button>
      </template>
    </HighForm>
    <!-- 导入数据 Dialog -->
    <el-dialog v-model="importDialogVisible" title="导入流程数据" width="600px" @close="importJsonText = ''">
      <p style="margin-bottom: 10px; font-size: 12px; color: #666">请粘贴有效的 JSON 数据(格式参考导出内容):</p>
      <el-input
        v-model="importJsonText"
        type="textarea"
        :rows="12"
        placeholder='{ "nodes": [...], "connections": [...] }'
        style="font-family: monospace; font-size: 12px" />
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="importDialogVisible = false">取消</el-button>
          <el-button type="primary" @click="confirmImport" :disabled="!importJsonText.trim()"> 确定导入 </el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ElMessageBox } from 'element-plus';
import { jsPlumb } from 'jsplumb';
import { getInfoGraph, saveGraph } from '@/api/system/jsplumb.js';

const { proxy } = getCurrentInstance();
const importDialogVisible = ref(false);
const importJsonText = ref('');
const canvasLayerRef = ref(null);
// 数据
const nodes = ref([]);
const connections = ref([]);
let nodeIdCounter = 6;
const MIN_ZOOM = 0.3;
const MAX_ZOOM = 3;
const nodeDialog = ref(false);
const nodesAreaRef = ref(null);
// 右键菜单状态
const contextMenu = ref({
  visible: false,
  x: 0,
  y: 0,
  node: null
});

// 当前选中节点
const selectedNode = ref({
  id: null,
  type: '',
  name: '',
  description: '',
  programType: '',
  programCode: '',
  attribute1: '',
  attribute2: '',
  attribute3: '',
  attribute4: '',
  attribute5: '',
  x: 0,
  y: 0
});
const zoomLevel = ref(1); // 初始缩放 100%
const offset = ref({ x: 0, y: 0 }); // 定义画布偏移量
let isDragging = false; // 跟踪鼠标是否按下
let startX = 0;
let startY = 0;
// 存储当前正在拖拽的节点类型
let draggedNodeType = null;
// jsPlumb 实例
let jsPlumbInstance = null;
const route = useRoute();

// 拖拽开始
function onDragStart(event, type) {
  draggedNodeType = type;
  // 可选:设置拖拽图标(某些浏览器支持)
  event.dataTransfer.setData('text/plain', type);
  // 允许复制效果(非必须)
  event.dataTransfer.effectAllowed = 'copy';
}

// 拖拽结束
function onDrop(event) {
  event.preventDefault();
  if (!draggedNodeType) return;

  // 获取画布区域的边界(用于计算相对坐标)
  const rect = nodesAreaRef.value.getBoundingClientRect();

  // 鼠标在视口中的位置 → 转换为 canvasLayer 内的坐标(考虑缩放和平移)
  const clientX = event.clientX;
  const clientY = event.clientY;

  // 1. 先减去画布偏移(nodesArea 的位置)
  const offsetX = clientX - rect.left;
  const offsetY = clientY - rect.top;

  // 2. 考虑当前 zoom 和 offset:实际 canvas 坐标 = (offsetX - offset.x * zoom) / zoom
  //    因为 transform: translate(offset.x, offset.y) scale(zoom)
  //    所以反向计算真实坐标:
  const canvasX = (offsetX - offset.value.x) / zoomLevel.value;
  const canvasY = (offsetY - offset.value.y) / zoomLevel.value;

  // 创建新节点(使用计算出的位置)
  const id = nodeIdCounter++;
  const newNode = {
    id,
    type: draggedNodeType,
    name: draggedNodeType === 'start' ? '开始' : draggedNodeType === 'end' ? '结束' : '任务',
    description: '',
    programCode: '',
    programType: '',
    attribute1: '',
    attribute2: '',
    attribute3: '',
    attribute4: '',
    attribute5: '',
    x: Math.round(canvasX),
    y: Math.round(canvasY)
  };

  nodes.value.push(newNode);

  // 清除拖拽状态
  draggedNodeType = null;

  // 等 DOM 更新后初始化 jsPlumb
  nextTick(() => {
    const el = document.getElementById(`${id}`);
    if (el && jsPlumbInstance) {
      el.style.left = newNode.x + 'px';
      el.style.top = newNode.y + 'px';

      jsPlumbInstance.draggable(el, {
        containment: canvasLayerRef.value,
        stop: () => {
          const node = nodes.value.find((n) => n.id === id);
          if (node) {
            node.x = parseInt(el.style.left) || 0;
            node.y = parseInt(el.style.top) || 0;
          }
        }
      });

      jsPlumbInstance.makeSource(el, {
        filter: '.endpoint',
        anchor: 'Continuous',
        allowLoopback: false,
        endpoint: ['Dot', { radius: 6 }],
        connector: ['Flowchart', { cornerRadius: 5 }],
        paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
        connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
      });

      jsPlumbInstance.makeTarget(el, {
        anchor: 'Continuous',
        allowLoopback: false,
        endpoint: ['Dot', { radius: 6 }],
        connector: ['Flowchart', { cornerRadius: 5 }],
        paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
        connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
      });
    }
  });
}

// 初始化 jsPlumb
function initJsPlumb() {
  jsPlumbInstance = jsPlumb.getInstance({
    Container: canvasLayerRef.value, // ✅ 关键:Container 是可移动的 layer
    Connector: ['Flowchart', { cornerRadius: 5 }],
    Endpoint: ['Dot', { radius: 6 }],
    Anchor: 'Continuous',
    PaintStyle: { stroke: '#1e90ff', strokeWidth: 1 },
    EndpointStyle: { fill: '#1e90ff' },
    ConnectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]],
    DragOptions: { cursor: 'pointer' }
  });
}

// 获取节点颜色
function getSideBarColor(type) {
  switch (type) {
    case 'start':
      return '#00cc00';
    case 'end':
      return '#cc0000';
    case 'task':
      return '#1e90ff';
    case 'gateway':
      return '#ff9900';
    default:
      return '#ccc';
  }
}

// 导入数据按钮
function openImportDialog() {
  importJsonText.value = ''; // 清空上次内容
  importDialogVisible.value = true;
}

// 导入弹框的确定按钮
function confirmImport() {
  try {
    const parsed = JSON.parse(importJsonText.value.trim());

    // 验证结构
    if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.connections)) {
      proxy.$modal.msgError('导入数据格式错误:缺少 nodes 或 connections 字段');
      return;
    }

    // 清空当前画布
    jsPlumbInstance.deleteEveryEndpoint(); // 删除所有连接和端点
    nodes.value = []; // 清空节点列表

    // 重置节点 ID 计数器(可选:避免 ID 冲突)
    // 这里简单取最大 ID + 1,更严谨的做法是记录最大值
    let maxId = 0;

    // 添加新节点
    parsed.nodes.forEach((node) => {
      if (typeof node.id !== 'number' && typeof node.id !== 'string') {
        throw new Error('节点缺少有效 id');
      }
      const idNum = Number(node.id);
      if (isNaN(idNum)) {
        throw new Error(`无效的节点 ID: ${node.id}`);
      }
      maxId = Math.max(maxId, idNum);

      nodes.value.push({
        id: idNum,
        type: node.type || 'task',
        name: node.name || '未命名',
        description: node.description || '',
        programType: node.programType || '',
        programCode: node.programCode || '',
        attribute1: node.attribute1 || '',
        attribute2: node.attribute2 || '',
        attribute3: node.attribute3 || '',
        attribute4: node.attribute4 || '',
        attribute5: node.attribute5 || '',
        x: node.x ?? 100,
        y: node.y ?? 100
      });
    });

    nodeIdCounter = maxId + 1; // 更新 ID 计数器

    // 等待 DOM 更新后初始化节点和连线
    nextTick(() => {
      // 1. 初始化每个节点(拖拽 + 端点)
      nodes.value.forEach((node) => {
        const el = document.getElementById(`${node.id}`);
        if (!el || !jsPlumbInstance) return;

        el.style.left = node.x + 'px';
        el.style.top = node.y + 'px';

        jsPlumbInstance.draggable(el, {
          containment: canvasLayerRef.value,
          stop: (event) => {
            const updatedNode = nodes.value.find((n) => n.id === node.id);
            if (updatedNode) {
              updatedNode.x = parseInt(el.style.left, 10) || 0;
              updatedNode.y = parseInt(el.style.top, 10) || 0;
            }
          }
        });

        jsPlumbInstance.makeSource(el, {
          filter: '.endpoint',
          anchor: 'Continuous',
          allowLoopback: false,
          endpoint: ['Dot', { radius: 6 }],
          connector: ['Flowchart', { cornerRadius: 5 }],
          paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
          connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
        });

        jsPlumbInstance.makeTarget(el, {
          anchor: 'Continuous',
          allowLoopback: false,
          endpoint: ['Dot', { radius: 6 }],
          connector: ['Flowchart', { cornerRadius: 5 }],
          paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
          connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
        });
      });

      // 2. 恢复连接线
      parsed.connections.forEach((conn) => {
        const sourceEl = document.getElementById(`${conn.sourceId}`);
        const targetEl = document.getElementById(`${conn.targetId}`);
        if (sourceEl && targetEl) {
          jsPlumbInstance.connect({
            source: sourceEl,
            target: targetEl,
            connector: ['Flowchart', { cornerRadius: 5 }],
            paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
            connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]],
            anchor: 'Continuous'
          });
        } else {
          console.warn(`跳过无效连接: ${conn.sourceId} -> ${conn.targetId}`);
        }
      });
      proxy.$modal.msgSuccess('流程数据导入成功!');
      importDialogVisible.value = false;
      fitToCenter();
    });
  } catch (error) {
    proxy.$modal.msgError(`导入失败:${error.message || '无效的 JSON 格式'}`);
  }
}

//保存节点
// function saveNode() {}

// 删除节点
function deleteNode(node) {
  const index = nodes.value.findIndex((n) => n.id === node.id);
  if (index !== -1) {
    jsPlumbInstance.remove(document.getElementById(`${node.id}`));
    nodes.value.splice(index, 1);
  }
  hideContextMenu();
}

// 选择节点
function selectNode(node) {
  selectedNode.value = node;
  nodeDialog.value = true;
}

// 右键菜单
function showContextMenu(e, node) {
  contextMenu.value = {
    visible: true,
    x: e.clientX,
    y: e.clientY,
    node
  };
}

function hideContextMenu() {
  contextMenu.value.visible = false;
}

//取消按钮
function handleCancel() {
  const obj = {
    path: '/bxcmd/jsplumb',
    query: {}
  };
  proxy.$tab.closeOpenPage(obj);
}

// 保存按钮
function handleSave() {
  console.log(jsPlumbInstance.getAllConnections(), '879');
  const connections = jsPlumbInstance.getAllConnections().map((conn) => ({
    sourceId: Number(conn.sourceId),
    targetId: Number(conn.targetId)
  }));
  const params = {
    graphId: route.query.graphId,
    graphName: route.query.graphName,
    description: route.query.description,
    nodes: nodes.value,
    connections
  };
  console.log('保存数据:', params);
  saveGraph(params).then((res) => {
    proxy.$modal.msgSuccess('保存成功!');
    // handleCancel();
  });
}

// 点击空白关闭右键菜单
document.addEventListener('click', () => {
  hideContextMenu();
});

//鼠标移动
function onMouseMove(event) {
  if (isDragging) {
    // offset.value.x = event.clientX - startX;
    // offset.value.y = event.clientY - startY;
    // 考虑缩放:鼠标移动 1px,画布应移动 1/zoom px(视觉一致)
    offset.value.x = (event.clientX - startX) / zoomLevel.value;
    offset.value.y = (event.clientY - startY) / zoomLevel.value;
  }
}

//鼠标按下
function onMouseDown(event) {
  const target = event.target;
  // 如果点击的是节点本身、端点、或 jsPlumb 连接线,则不触发画布拖拽
  if (
    target.closest('.flow-node') || // 节点内部
    target.classList.contains('endpoint') || // 端点
    target.classList.contains('_jsPlumb_connector') || // jsPlumb 连接线
    target.closest('.toolbar') ||
    target.closest('.sidebar') ||
    target.closest('.properties-panel') ||
    target.closest('.context-menu')
  ) {
    return; // 让 jsPlumb 或其他组件处理
  }

  // 否则,开始拖动画布
  isDragging = true;
  startX = event.clientX - offset.value.x;
  startY = event.clientY - offset.value.y;
  if (nodesAreaRef.value) {
    nodesAreaRef.value.classList.add('dragging');
  }
}

//鼠标抬起
function onMouseUp() {
  isDragging = false;
  if (nodesAreaRef.value) {
    nodesAreaRef.value.classList.remove('dragging');
  }
}

//清空画布
function clearCanvas() {
  ElMessageBox.confirm('确定要清空整个画布吗?此操作不可恢复。', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => {
      // 1. 清除所有 jsPlumb 连接和端点
      if (jsPlumbInstance) {
        jsPlumbInstance.deleteEveryEndpoint(); // 删除连接线和端点
      }

      // 2. 👇 手动从 DOM 中移除所有节点元素
      const canvas = canvasLayerRef.value;
      if (canvas) {
        // 获取所有 flow-node 元素并逐个移除
        const nodeElements = canvas.querySelectorAll('.flow-node');
        nodeElements.forEach((el) => el.remove());
      }

      // 3. 清空 Vue 数据
      nodes.value = [];

      // 4. 取消选中
      selectedNode.value = {
        id: null,
        type: '',
        name: '',
        description: '',
        programType: '',
        programCode: '',
        attribute1: '',
        attribute2: '',
        attribute3: '',
        attribute4: '',
        attribute5: '',
        x: 0,
        y: 0
      };

      // 5. 重置视图
      zoomLevel.value = 1;
      offset.value = { x: 0, y: 0 };
      proxy.$modal.msgSuccess('流程数据已保存!');
      nodeDialog.value = false;
    })
    .catch(() => {
      // 用户取消
    });
}

//放大
function zoomIn() {
  if (zoomLevel.value < MAX_ZOOM) {
    zoomLevel.value = Math.round((zoomLevel.value + 0.2) * 10) / 10;
  }
}

//缩小
function zoomOut() {
  if (zoomLevel.value > MIN_ZOOM) {
    zoomLevel.value = Math.round((zoomLevel.value - 0.2) * 10) / 10;
  }
}

//居中
function fitToCenter() {
  if (nodes.value.length === 0) return;
  // 1. 计算所有节点的边界框
  let minX = Infinity,
    minY = Infinity;
  let maxX = -Infinity,
    maxY = -Infinity;
  nodes.value.forEach((node) => {
    minX = Math.min(minX, node.x);
    minY = Math.min(minY, node.y);
    maxX = Math.max(maxX, node.x + 160); // 节点宽约 160px
    maxY = Math.max(maxY, node.y + 40); // 节点高约 40px
  });
  const width = maxX - minX;
  const height = maxY - minY;
  // 2. 获取视口尺寸(nodesArea)
  const viewRect = nodesAreaRef.value?.getBoundingClientRect();
  if (!viewRect) return;
  const viewWidth = viewRect.width;
  const viewHeight = viewRect.height;
  // 3. 计算缩放比例(留 20% 边距)
  const scaleX = viewWidth / (width * 1.2);
  const scaleY = viewHeight / (height * 1.2);
  const newZoom = Math.min(scaleX, scaleY, 1); // 不超过 100%
  // 4. 计算居中偏移
  const contentCenterX = minX + width / 2;
  const contentCenterY = minY + height / 2;
  const offsetAfterZoom = {
    x: viewWidth / 2 - contentCenterX * newZoom,
    y: viewHeight / 2 - contentCenterY * newZoom
  };
  // 5. 应用
  zoomLevel.value = parseFloat(newZoom.toFixed(2));
  offset.value = offsetAfterZoom;
}

function loadData() {
  getInfoGraph({ graphId: route.query.graphId }).then((res) => {
    console.log('加载数据:', res);
    nodes.value = res.data.nodes;
    connections.value = res.data.connections;
    nextTick(() => {
      nodes.value.forEach((node) => {
        const el = document.getElementById(`${node.id}`);
        if (el && jsPlumbInstance) {
          el.style.left = node.x + 'px';
          el.style.top = node.y + 'px';
          jsPlumbInstance.draggable(el, {
            containment: canvasLayerRef.value,
            stop: (event) => {
              const node = nodes.value.find((n) => n.id === id);
              if (node) {
                const style = window.getComputedStyle(el);
                node.x = parseInt(style.left, 10) || 0;
                node.y = parseInt(style.top, 10) || 0;
              }
            }
          });

          jsPlumbInstance.makeSource(el, {
            filter: '.endpoint',
            anchor: 'Continuous',
            allowLoopback: false,
            endpoint: ['Dot', { radius: 6 }],
            connector: ['Flowchart', { cornerRadius: 5 }],
            paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
            connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
          });

          jsPlumbInstance.makeTarget(el, {
            anchor: 'Continuous',
            allowLoopback: false,
            endpoint: ['Dot', { radius: 6 }],
            connector: ['Flowchart', { cornerRadius: 5 }],
            paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
            connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]]
          });
        }
      });

      connections.value.forEach((conn) => {
        const sourceId = `${conn.sourceId}`;
        const targetId = `${conn.targetId}`;
        jsPlumbInstance.connect({
          source: sourceId,
          target: targetId,
          connector: ['Flowchart', { cornerRadius: 5 }],
          paintStyle: { stroke: '#1e90ff', strokeWidth: 2 },
          connectorOverlays: [['Arrow', { location: 1, length: 10, foldback: 0.8 }]],
          anchor: 'Continuous'
        });
      });
      fitToCenter();
    });
  });
}

onActivated(() => {
  if (route.query.graphId) {
    loadData();
  }
});

onMounted(() => {
  initJsPlumb();
  const nodesAreaEl = nodesAreaRef.value;
  if (!nodesAreaEl) return; // 防御性编程
  nodesAreaEl.addEventListener('mousedown', onMouseDown);
  window.addEventListener('mousemove', onMouseMove);
  window.addEventListener('mouseup', onMouseUp);
});

onBeforeUnmount(() => {
  const nodesAreaEl = nodesAreaRef.value;
  if (nodesAreaEl) {
    nodesAreaEl.removeEventListener('mousedown', onMouseDown);
  }
  window.removeEventListener('mousemove', onMouseMove);
  window.removeEventListener('mouseup', onMouseUp);
});
</script>

<style lang="scss" scoped>
.nodes-area {
  flex: 1;
  position: relative;
  background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><circle cx="10" cy="10" r="1" fill="%23ccc"/></svg>')
    repeat;
  background-size: 10px 10px;
  overflow: hidden;
  cursor: grab;
  user-select: none;
  transition: transform 0.2s; /* 平滑过渡(可选) */
}

/* 👇 拖拽中状态 */
.nodes-area.dragging {
  cursor: grabbing;
  /* 可选:降低非关键元素的干扰 */
  /* 例如:临时隐藏端点或降低透明度(不推荐隐藏节点) */
}

/* 可选:拖拽时禁止文本选择(更彻底) */
.nodes-area.dragging * {
  user-select: none !important;
}

.canvas-layer {
  //position: absolute;
  //left: 0;
  //top: 0;
  //min-width: 2000px;
  //min-height: 2000px;
  min-height: calc(100vh - 220px);
}

.flow-container {
  position: relative;
  width: 100%;
  height: calc(100vh - 140px);
  background: #f9f9f9;
  display: flex;
  flex-direction: column;
}

.toolbar {
  padding: 16px;
  //background: white;
  //border-bottom: 1px solid #ddd;
  ////display: flex;
  ////gap: 8px;
  text-align: right;

  .el-icon {
    margin: 0 5px;
    cursor: pointer;
    font-size: 20px;

    :focus {
      outline: none; /* 移除默认边框 */
    }
  }
}

.sidebar {
  width: 200px;
  background: white;
  border-right: 1px solid #eee;
  padding: 10px;
  box-shadow: 1px 0 5px rgba(0, 0, 0, 0.05);
  overflow-y: auto;
  position: absolute;
  left: 0px;
  top: 0px;
  z-index: 1;
}

.sidebar .section {
  margin-bottom: 15px;
}

.sidebar .section h4 {
  font-size: 14px;
  color: #666;
  margin-top: 0;
  margin-bottom: 8px;
}

.sidebar .node-item {
  padding: 6px;
  border: 1px dashed #ccc;
  margin-bottom: 6px;
  cursor: pointer;
  border-radius: 4px;
  transition: background-color 0.2s;
  text-align: center;
  font-size: 14px;
}

.sidebar .node-item:hover {
  background-color: #f0f8ff;
}

.flow-node {
  position: absolute;
  width: 160px;
  height: 40px;
  background: white;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 10px;
  cursor: move;
  user-select: none;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
  transition: all 0.2s;
}

.flow-node.start {
  border-color: #00cc00;
  background: #e8fff7;
}

.flow-node.end {
  border-color: #cc0000;
  background: #ffe8e8;
}

.flow-node.task {
  border-color: #1e90ff;
  background: #f0f8ff;
}

.flow-node.gateway {
  border-color: #ff9900;
  background: #fff9e8;
}

.side-bar {
  width: 4px;
  height: 30px;
  margin-right: 8px;
  border-radius: 2px;
}

.icon {
  font-size: 14px;
  margin-right: 8px;
}

.label {
  font-size: 12px;
  font-weight: 500;
  flex: 1;
  text-align: left;
}

.star {
  font-size: 10px;
  color: #1e90ff;
  margin-left: 4px;
}

.endpoint {
  position: absolute;
  bottom: -6px;
  left: 50%;
  transform: translateX(-50%);
  width: 10px;
  height: 10px;
  background: #1e90ff;
  border-radius: 50%;
  cursor: pointer;
}

.context-menu {
  position: fixed;
  background: white;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
  z-index: 1000;
  padding: 5px;
}

.context-menu button {
  display: block;
  width: 100%;
  padding: 6px 10px;
  text-align: left;
  border: none;
  background: none;
  cursor: pointer;
}

.context-menu button:hover {
  background: #f0f0f0;
}

.properties-panel {
  position: absolute;
  right: 10px;
  top: 60px;
  width: 300px;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  z-index: 100;
}

.properties-panel h4 {
  margin-top: 0;
  font-size: 14px;
  color: #333;
}

.property-group {
  margin: 8px 0;
}

.property-group label {
  display: block;
  font-size: 12px;
  color: #666;
  margin-bottom: 4px;
}

.property-group input,
.property-group select,
.property-group textarea {
  width: 100%;
  padding: 6px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 12px;
}

.actions {
  margin-top: 12px;
  display: flex;
  gap: 8px;
}
</style>

{
  "graphName": "系统https访问地址",
  "description": "用于https访问或地址拼接",
  "graphId": 0,
  "nodes": [
    {
      "id": 101,
      "type": "start",
      "name": "开始",
      "description": "这是流程的起点",
      "programType": "REQ",
      "programCode": "BXINIT",
      "attribute1": "111",
      "attribute2": "112",
      "attribute3": "113",
      "attribute4": "114",
      "attribute5": "115",
      "x": 120,
      "y": 200
    },
    {
      "id": 102,
      "type": "task",
      "name": "提交",
      "description": "用户填写并提交表单",
      "x": 300,
      "y": 200
    },
    {
      "id": 103,
      "type": "task",
      "name": "审核",
      "description": "由直属领导审批",
      "x": 500,
      "y": 150
    },
    {
      "id": 104,
      "type": "task",
      "name": "复核",
      "description": "财务确认预算合规",
      "x": 500,
      "y": 250
    },
    {
      "id": 105,
      "type": "end",
      "name": "结束",
      "description": "流程完成",
      "x": 700,
      "y": 200
    }
  ],
  "connections": [
    {"sourceId": 101, "targetId": 102},
    {"sourceId": 102, "targetId": 103},
    {"sourceId": 102, "targetId": 104},
    {"sourceId": 103, "targetId": 105},
    {"sourceId": 104, "targetId": 105}
  ]
}
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容