vue3实现拓扑图编辑功能(谨以此纪念我当前的最后一份前端工作)

前话:ai的发展已经让我这个前端程序媛,不再想深究技术了。这份工作结束后,就和前端及计算机行业say goodbye 了!

需求:

左侧是拓扑图上的一些内容,中间是拓扑图拖拽后展示编辑的画板canvas

效果图:

功能:

  • 可拖拽
  • 可调整线的长短方向
  • 可保存JSON数据
  • 可线对齐
  • 代码如下:不完整,额外配置代码我贴在后面
javascript 复制代码
<template>
  <div class="topo-editor">
    <div class="topo-toolbar">
      <el-button-group>
        <el-button icon="Back" @click="handleUndo" title="撤销"></el-button>
        <el-button icon="Edit" @click="handleRedo" title="重做"></el-button>
        <el-button icon="Delete" type="danger" @click="handleClear" title="清空"></el-button>
        <el-button icon="View" @click="handlePreview" title="预览"></el-button>
        <el-button :icon="graph?.value?.isSelectingEnabled() ? 'Lock' : 'Unlock'" @click="toggleLock">
          {{ graph?.value?.isSelectingEnabled() ? '锁定' : '解锁' }}
        </el-button>
      </el-button-group>
    </div>

    <div class="topo-body">
      <div class="topo-sidebar">
        <el-collapse v-model="activeCollapse">
          <el-collapse-item title="基础形状" name="basic">
            <div class="comp-grid">
              <div v-for="item in basicShapes" :key="item.type" class="comp-item" draggable="true"
                @dragstart="handleDragStart($event, item)">
                <svg viewBox="0 0 24 24" width="28" height="28">
                  <text v-if="item.type === 'text'" x="6" y="19" font-size="20" fill="#333" font-weight="bold">T</text>
                  <g v-else-if="item.type === 'indicator-data'">
                    <text x="7" y="17" font-size="18" fill="#409eff" font-weight="bold">T</text>
                  </g>
                  <rect v-else-if="item.type === 'rect'" x="3" y="4" width="18" height="16" stroke="#333"
                    stroke-width="2" fill="none" />
                  <line v-else-if="item.type === 'line-h'" x1="2" y1="12" x2="22" y2="12" stroke="#333"
                    stroke-width="2" />
                  <line v-else-if="item.type === 'line-v'" x1="12" y1="2" x2="12" y2="22" stroke="#333"
                    stroke-width="2" />
                  <line v-else-if="item.type === 'bus-ac'" x1="2" y1="12" x2="22" y2="12" stroke="#409eff"
                    stroke-width="3" />
                  <line v-else-if="item.type === 'bus-dc'" x1="2" y1="12" x2="22" y2="12" stroke="#f56c6c"
                    stroke-width="3" />
                  <circle v-else-if="item.type === 'indicator'" cx="12" cy="12" r="6" fill="#67c23a" />
                  <line v-else-if="item.type === 'flow-line'" x1="2" y1="12" x2="22" y2="12" stroke="#409eff"
                    stroke-width="2" stroke-dasharray="4,2" />
                </svg>
                <span class="comp-label">{{ item.label }}</span>
              </div>
            </div>
          </el-collapse-item>

          <el-collapse-item title="设备组件" name="device">
            <div class="comp-grid">
              <div v-for="item in deviceList" :key="item.type" class="comp-item" draggable="true"
                @dragstart="handleDragStart($event, item)">
                <img :src="item.icon" alt="" class="comp-img" />
                <span class="comp-label">{{ item.label }}</span>
              </div>
            </div>
          </el-collapse-item>
        </el-collapse>
      </div>

      <div class="topo-canvas" @drop="handleDrop" @dragover="handleDragOver">
        <div ref="graphContainer" class="graph-container"></div>
      </div>

      <div class="topo-right">
        <el-tabs v-model="activeTab">
          <el-tab-pane label="结构" name="structure">
            <div class="tree-panel">
              <h4>节点列表</h4>
              <el-tree :data="treeData" node-key="id" default-expand-all @node-click="handleTreeClick" />
            </div>
          </el-tab-pane>
        </el-tabs>
      </div>
    </div>

    <el-dialog v-model="previewVisible" title="拓扑预览" width="85%" top="5vh">
      <div ref="previewContainer" style="width:100%;height:600px;"></div>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, nextTick, onMounted, onUnmounted, computed } from 'vue'
import { Graph, Transform } from '@antv/x6'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Back, Edit, Delete, View, Lock, Unlock } from '@element-plus/icons-vue'

const graphContainer = ref(null)
const previewContainer = ref(null)
const graph = ref(null)
const previewGraph = ref(null)
const previewVisible = ref(false)
const activeCollapse = ref(['basic'])
const activeTab = ref('structure')
const originLineStyleMap = new Map()
const transformOptions = ref({
  enabled: true,
  minWidth: 1,
  maxWidth: 200,
  minHeight: 1,
  maxHeight: 150,
  orthogonal: false,
  restrict: false,
  preserveAspectRatio: false,
})

const basicShapes = [
  { type: 'text', label: '纯文本' },
  { type: 'indicator-data', label: '指标数据' },
  { type: 'rect', label: '矩形' },
  { type: 'line-h', label: '横线' },
  { type: 'line-v', label: '竖线' },
  { type: 'bus-ac', label: '交流母线' },
  { type: 'bus-dc', label: '直流母线' },
  { type: 'indicator', label: '指示灯' },
  { type: 'flow-line', label: '流转线' },
]

const deviceList = ref([
  { type: 'meter', label: '电表', icon: 'https://picsum.photos/40/40?3' },
  { type: 'battery', label: '电池', icon: 'https://picsum.photos/40/40?4' },
  { type: 'ac', label: '空调', icon: 'https://picsum.photos/40/40?5' },
])

const treeData = computed(() => {
  if (!graph.value) return []
  return graph.value.getNodes().map(n => ({
    id: n.id,
    label: n.getData()?.label || n.id,
  }))
})

// 初始化画布
const initGraph = () => {
  if (graph.value) graph.value.dispose()

  graph.value = new Graph({
    container: graphContainer.value,
    width: graphContainer.value.offsetWidth,
    height: graphContainer.value.offsetHeight,
    grid: { visible: true, size: 10 },
    panning: true,
    mousewheel: { enabled: true, modifiers: ['ctrl'] },
    snapline: true,
    clipboard: true,
    history: true,
    selecting: {
      enabled: true,
      multiple: true,
      rubberband: true,
      showNodeSelectionBox: false,
      click: true,
    },
    resizing: { enabled: true },
    rotating: true,
  })

  graph.value.use(
    new Transform({
      resizing: transformOptions.value,
    })
  )

  // 线条选中高亮
  graph.value.on('edge:selected', ({ edge }) => {
    const line = edge.attr('line')
    if (line && !originLineStyleMap.has(edge.id)) {
      originLineStyleMap.set(edge.id, {
        stroke: line.stroke,
        strokeWidth: line.strokeWidth,
        strokeDasharray: line.strokeDasharray
      })
    }
    edge.attr('line', { stroke: '#ff7d00', strokeWidth: 3 })
    edge.addTools([{ name: 'vertices', args: { size: 8, fill: '#1677ff', stroke: '#fff' } }])
  })
  graph.value.on('edge:unselected', ({ edge }) => {
    const origin = originLineStyleMap.get(edge.id)
    if (origin) {
      edge.attr('line', origin)
      originLineStyleMap.delete(edge.id)
    }
    edge.removeTools()
  })

  // ==============================================
  // 双击:开启编辑 + 蓝色边框 + 可调整大小
  // ==============================================
  graph.value.on('node:dblclick', ({ node }) => {
  })

  // ==============================================
  // 退出编辑:恢复原样
  // ==============================================
  graph.value.on('node:editor:hide', ({ node }) => {

  })

  // mouse效果
  graph.value.on('node:mouseenter', ({ node }) => {
    console.log('node:mouseenter', node)
    // 开启缩放
    // node.addTools({ name: 'resize' })
    // 蓝色高亮边框
    node.attr('body/stroke', '#1677ff')
    node.attr('body/strokeWidth', 2)
    // node.setResizable(true)  //鼠标移入开启缩放
  })
  graph.value.on('node:mouseleave', ({ node }) => {
    console.log('node:mouseleave', node.getData())
    const nodeData = node.getData()
    // node.removeTools(['node-editor', 'resize'])
    // node.setResizable(false) //鼠标移出关闭缩放
    if (nodeData.type == 'rect') {
      node.attr('body/stroke', '#333')
    } else {
      node.attr('body/stroke', '#fff')
      node.attr('body/strokeWidth', 0)
    }
  })

  // 空白取消
  graph.value.on('blank:click', () => {
    graph.value.cleanSelection()
  })
}

// 拖拽
const handleDragStart = (e, item) => {
  e.dataTransfer.setData('type', item.type)
  e.dataTransfer.setData('label', item.label)
}
const handleDragOver = (e) => e.preventDefault()
const handleDrop = (e) => {
  e.preventDefault()
  const type = e.dataTransfer.getData('type')
  const label = e.dataTransfer.getData('label')
  const rect = graphContainer.value.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  addElement(type, label, x, y)
}

// 添加元素
const addElement = (type, label, x, y) => {
  const edgeTypes = ['line-h', 'line-v', 'bus-ac', 'bus-dc', 'flow-line']
  if (edgeTypes.includes(type)) {
    const len = 70
    let sx = x - len, sy = y, tx = x + len, ty = y
    let stroke = '#333', strokeWidth = 2, dash = null
    if (type === 'bus-ac') { stroke = '#409eff'; strokeWidth = 3 }
    if (type === 'bus-dc') { stroke = '#f56c6c'; strokeWidth = 3 }
    if (type === 'flow-line') { stroke = '#409eff'; strokeWidth = 2; dash = '6 3' }
    graph.value.addEdge({
      source: { x: sx, y: sy },
      target: { x: tx, y: ty },
      attrs: { line: { stroke, strokeWidth, strokeDasharray: dash } },
    })
  } else if (type === 'text') {
    graph.value.addNode({
      x, y, width: 100, height: 40, label: '点击编辑',
      attrs: { body: { stroke: '#8f8f8f', strokeWidth: 0, fill: '#fff', rx: 6, ry: 6 } },
      tools: ['node-editor'],
      data: {
        type: 'text',        // 你自定义的类型:ems/pcs/battery...
      }

    })
  } else if (type === 'indicator-data') {
    graph.value.addNode({
      x, y, width: 100, height: 36, label: '指标数据',
      attrs: {
        body: { stroke: '#409eff', strokeWidth: 0, fill: '#fff', rx: 6, ry: 6 },
        text: { fill: '#409eff' }
      },
      tools: ['node-editor'],
      data: {
        type: 'indicator-data',        // 你自定义的类型:ems/pcs/battery...
      }
    })
  } else if (type === 'rect') {
    graph.value.addNode({
      x, y, width: 100, height: 36,
      attrs: { body: { stroke: '#333', fill: '#fff', strokeWidth: 2 } },
      data: {
        type: 'rect',        // 你自定义的类型:ems/pcs/battery...
      }
    })
  } else if (type === 'indicator') {
    graph.value.addNode({
      x, y, width: 24, height: 24, shape: 'circle',
      attrs: { body: { fill: '#67c23a' } }
    })
  } else {
    const dev = deviceList.value.find(d => d.type === type)
    graph.value.addNode({
      shape: 'image', x, y, width: 40, height: 40,
      attrs: { image: { 'xlink:href': dev.icon } }
    })
  }
  ElMessage.success(`添加:${label}`)
}

// 工具栏
const handleUndo = () => graph.value.history.undo()
const handleRedo = () => graph.value.history.redo()
const handleClear = async () => {
  await ElMessageBox.confirm('确认清空全部画布内容?')
  graph.value.clearCells()
}
const toggleLock = () => {
  const locked = !graph.value.isSelectingEnabled()
  graph.value.setSelecting(!locked)
  graph.value.setPanning(!locked)
  ElMessage.success(locked ? '画布已锁定' : '画布已解锁')
}
const handleTreeClick = (treeNode) => graph.value.centerCell(treeNode.id)
const handlePreview = () => {
  previewVisible.value = true
  nextTick(() => {
    if (previewGraph.value) previewGraph.value.dispose()
    previewGraph.value = new Graph({
      container: previewContainer.value,
      width: previewContainer.value.offsetWidth,
      height: 580,
      panning: true, mousewheel: true
    })
    previewGraph.value.fromJSON(graph.value.toJSON())
  })
}

onMounted(initGraph)
onUnmounted(() => {
  graph.value?.dispose()
  previewGraph.value?.dispose()
})
</script>

<style scoped lang="scss">
.topo-editor {
  width: 100%;
  height: calc(100vh - 84px);
  display: flex;
  flex-direction: column;
  background: #f5f5f5;
}

.topo-toolbar {
  height: 50px;
  background: #fff;
  padding: 0 16px;
  border-bottom: 1px solid #e5e7eb;
  display: flex;
  align-items: center;
}

.topo-body {
  flex: 1;
  display: flex;
  overflow: hidden;
}

.topo-sidebar {
  width: 260px;
  background: #fff;
  border-right: 1px solid #e5e7eb;
}

.comp-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  padding: 12px;
}

.comp-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 10px;
  border: 1px solid #e5e7eb;
  border-radius: 6px;
  cursor: grab;
  background: #fafafa;
}

.comp-item:hover {
  border-color: #409eff;
  background: #ecf5ff;
}

.comp-img {
  width: 36px;
  height: 36px;
  object-fit: contain;
}

.comp-label {
  font-size: 12px;
  margin-top: 4px;
}

.topo-canvas {
  flex: 1;
  background: #fff;
  position: relative;
}

.graph-container {
  width: 100%;
  height: 100%;
}

.topo-right {
  width: 280px;
  background: #fff;
  border-left: 1px solid #e5e7eb;
  padding: 16px;
}
</style>

hover高亮效果:

在createGraph代码中添加

javascript 复制代码
  // 线条hover端点
  graph.value.on('edge:mouseenter', ({ edge }) => {
    edge.setTools([
      { name: 'source-arrowhead', args: { attrs: { fill: '#409eff', stroke: '#fff', r: 5 } } },
      { name: 'target-arrowhead', args: { attrs: { fill: '#409eff', stroke: '#fff', r: 5 } } },
    ])
  })
  graph.value.on('edge:mouseleave', ({ edge }) => edge.setTools([]))

  // 节点hover边框
  graph.value.on('node:mouseenter', ({ node }) => {
    node.attr('body/stroke', '#409eff')
    node.attr('body/strokeWidth', 1)
  })
  graph.value.on('node:mouseleave', ({ node }) => {
    const d = node.getData()
    node.attr('body/stroke', d?.type === 'rect' ? '#e5e7eb' : 'none')
  })

拖拽显示对齐线:

在createGraph方法中

javascript 复制代码
import { Graph, Transform, Snapline } from '@antv/x6'
  graph.value.use(new Snapline({ enabled: true }))

完善功能版本:

右键可以显示菜单,可放大缩小选中内容,画布内容回显

javascript 复制代码
<template>
  <div class="topo-editor">
    <TopoToolbar :locked="locked" @undo="handleUndo" @redo="handleRedo" @save="handleSave" @clear="handleClear"
      @preview="handlePreview" @lock="toggleLock" />
    <div class="topo-body">

      <TopoLeft style="height:50vh;overflow: auto;" :basic-shapes="basicShapes" :device-list="deviceList" />
      <div class="topo-canvas" @drop="handleDrop" @dragover="handleDragOver">
        <div ref="graphContainer" class="graph-container"></div>

        <div v-if="contextMenu.show" class="context-menu"
          :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
          <div class="menu-item" @click="toTop">置顶</div>
          <div class="menu-item" @click="toBottom">置底</div>
          <div class="menu-item" @click="moveUp">上移一层</div>
          <div class="menu-item" @click="moveDown">下移一层</div>
          <div class="menu-item del" @click="delCell">删除</div>
        </div>
      </div>

      <!-- 右侧属性面板 -->
      <TopoRight ref="topoRightRef" :tree-data="treeData" :selected-cell="selectedCell" :data-field-list="dataFieldList"
        :template-form="templateForm" @toggle-visible="toggleCellVisible" @tree-hover="handleTreeHover"
        @tree-leave="handleTreeLeave" @save-device-point-id="handleSaveDevicePointId" />
    </div>

    <el-dialog v-model="previewVisible" title="拓扑预览" width="85%" top="5vh">
      <div ref="previewContainer" style="width:100%;height:600px;text-align: center;"></div>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, nextTick, onMounted, onUnmounted, computed, watch } from 'vue'
import { Graph, Transform, Snapline } from '@antv/x6'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listComponent } from '@/api/template/index.js'
import { getDeviceTypeList } from '@/api/station/device.js'

import TopoToolbar from './TopoToolbar.vue'
import TopoLeft from './TopoLeft.vue'
import TopoRight from './TopoRight.vue'

const emit = defineEmits(['select-cell'])
// 接收父组件传递的模板表单数据
const props = defineProps({
  templateForm: {
    type: Object,
    default: () => ({
      templateName: '',
      remark: ''
    })
  }
})

const graphContainer = ref(null)
const previewContainer = ref(null)
const graph = ref(null)
const previewGraph = ref(null)
const previewVisible = ref(false)
const locked = ref(false)
const selectCell = ref(null)
const contextMenu = ref({ show: false, x: 0, y: 0 })
const isInitialized = ref(false)

// 右侧面板相关
const topoRightRef = ref(null)
const selectedCell = ref(null)
const treeData = ref([])
const dataFieldList = ref([])

// 使用 props 或本地状态
const templateForm = computed(() => {
  return props.templateForm || {
    templateName: '',
    remark: ''
  }
})

const transformOptions = ref({
  enabled: true,
  minWidth: 1, maxWidth: 200, minHeight: 1, maxHeight: 150,
})

const basicShapes = [
  { type: 'text', compLabel: '纯文本' },
  { type: 'indicator-data', compLabel: '数据采集器' },
  { type: 'rect', compLabel: '矩形框' },
  { type: 'line-h', compLabel: '普通横线' },
  { type: 'line-v', compLabel: '普通竖线' },
  { type: 'bus-ac', compLabel: '主母线(交流)' },
  { type: 'bus-dc', compLabel: '主母线(直流)' },
  { type: 'indicator', compLabel: '指示灯' },
  { type: 'flow-line', compLabel: '数据流连线' },
]

const deviceList = ref([])

/** 获取设备类型列表 */
const loadDataFields = async () => {
  try {
    const res = await getDeviceTypeList()
    dataFieldList.value = res.data || []
  } catch (error) {
    console.error('获取设备类型列表失败:', error)
  }
}

const refreshTree = () => {
  if (!graph.value) return
  console.log('treeData:', graph.value.getNodes())
  const nodes = graph.value.getNodes().map(n => ({
    id: n.id,
    label: n.getData().label + n.id.slice(4, 8),
    type: 'node',
    visible: n.isVisible()
  }))
  const edges = graph.value.getEdges().map(e => ({
    id: e.id,
    label: '连线-' + e.id.slice(4, 8),
    type: 'edge',
    visible: e.isVisible()
  }))
  treeData.value = [...nodes, ...edges]
}

const initGraph = () => {
  if (!graphContainer.value) {
    console.warn('Graph container not ready')
    return
  }

  const checkSize = () => {
    const width = graphContainer.value.offsetWidth
    const height = graphContainer.value.offsetHeight
    return !(width === 0 || height === 0)
  }

  if (!checkSize()) {
    setTimeout(() => {
      if (checkSize()) createGraph()
      else setTimeout(() => checkSize() && createGraph(), 200)
    }, 100)
    return
  }

  createGraph()
}

const createGraph = () => {
  if (graph.value) graph.value.dispose()
  graph.value = new Graph({
    container: graphContainer.value,
    width: graphContainer.value.offsetWidth,
    height: graphContainer.value.offsetHeight,
    grid: { visible: false },
    panning: false,
    mousewheel: { enabled: true, modifiers: ['ctrl'] },
    snapline: { enabled: true },
    clipboard: true,
    history: true,
    selecting: { enabled: true, multiple: true, rubberband: true },
    resizing: { enabled: true },
    rotating: true,
    interacting: { edgeMovable: true, vertexMovable: true },
  })

  graph.value.use(new Transform({ resizing: transformOptions.value }))
  graph.value.use(new Snapline({ enabled: true }))

  // 线条hover端点
  graph.value.on('edge:mouseenter', ({ edge }) => {
    edge.setTools([
      { name: 'source-arrowhead', args: { attrs: { fill: '#409eff', stroke: '#fff', r: 5 } } },
      { name: 'target-arrowhead', args: { attrs: { fill: '#409eff', stroke: '#fff', r: 5 } } },
    ])
  })
  graph.value.on('edge:mouseleave', ({ edge }) => edge.setTools([]))

  // 节点hover边框
  graph.value.on('node:mouseenter', ({ node }) => {
    node.attr('body/stroke', '#409eff')
    node.attr('body/strokeWidth', 1)
  })
  graph.value.on('node:mouseleave', ({ node }) => {
    const d = node.getData()
    node.attr('body/stroke', d?.type === 'rect' ? '#e5e7eb' : 'none')
  })

  // 增删刷新树
  graph.value.on('node:added', refreshTree)
  graph.value.on('node:removed', refreshTree)
  graph.value.on('edge:added', refreshTree)
  graph.value.on('edge:removed', refreshTree)

  // 空白点击清空选中
  graph.value.on('blank:click', () => {
    contextMenu.value.show = false
    selectedCell.value = null
    emit('select-cell', null)
  })

  // 节点选中
  graph.value.on('node:click', ({ node }) => {
    selectedCell.value = node
    emit('select-cell', node)
  })
  graph.value.on('edge:click', ({ edge }) => {
    selectedCell.value = edge
    emit('select-cell', edge)
  })

  // 右键菜单
  graph.value.on('cell:contextmenu', ({ cell, e }) => {
    e.preventDefault()
    selectCell.value = cell
    contextMenu.value = { show: true, x: e.clientX, y: e.clientY }
  })

  isInitialized.value = true
}

const handleDragOver = (e) => {
  e.preventDefault()
  e.dataTransfer.dropEffect = (isInitialized.value && graph.value) ? 'copy' : 'none'
}

const handleDrop = (e) => {
  e.preventDefault()
  if (!isInitialized.value || !graph.value) return

  try {
    const item = JSON.parse(e.dataTransfer.getData('text/plain'))
    const rect = graphContainer.value.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    const elementType = item.type || item.id
    const elementLabel = item.compLabel || item.label
    addElement(elementType, elementLabel, x, y)
  } catch (err) {
    console.error('拖拽失败', err)
  }
}

const addElement = (type, label, x, y) => {
  if (type === 'line-h' || type === 'bus-ac' || type === 'bus-dc') {
    const l = 160
    const stroke = type === 'bus-ac' ? '#1677ff' : type === 'bus-dc' ? '#f56c6c' : '#333'
    const sw = type.includes('bus') ? 5 : 2
    graph.value.addEdge({
      source: { x: x - l / 2, y },
      target: { x: x + l / 2, y },
      attrs: { line: { stroke, strokeWidth: sw, strokeLinecap: 'round', sourceMarker: null, targetMarker: null } },
      zIndex: 1,
    })
  } else if (type === 'line-v') {
    const l = 160
    graph.value.addEdge({
      source: { x, y: y - l / 2 },
      target: { x, y: y + l / 2 },
      attrs: { line: { stroke: '#333', strokeWidth: 2, strokeLinecap: 'round', sourceMarker: null, targetMarker: null } },
      zIndex: 1,
    })
  } else if (type === 'flow-line') {
    const l = 120
    graph.value.addEdge({
      source: { x: x - l / 2, y },
      target: { x: x + l / 2, y },
      attrs: { line: { stroke: '#409eff', strokeWidth: 2, strokeDasharray: '6 4', sourceMarker: null, targetMarker: null } },
      zIndex: 2,
    })
  } else if (type === 'text') {
    graph.value.addNode({
      x, y, width: 80, height: 30, label,
      attrs: { body: { fill: 'none' }, text: { fill: '#333' } },
      data: { type, label },
      tools: ['node-editor']
    })
  } else if (type === 'rect') {
    graph.value.addNode({
      x, y, width: 100, height: 60,
      attrs: { body: { stroke: '#e5e7eb', fill: '#f9fafb' } },
      data: { type, label },
    })
  } else if (type === 'indicator') {
    graph.value.addNode({
      x, y, width: 18, height: 18, shape: 'circle',
      attrs: { body: { fill: '#67c23a' } },
      data: { type, label },
    })
  } else if (type === 'indicator-data') {
    graph.value.addNode({
      x, y, width: 60, height: 40,
      label: label,
      attrs: { body: { stroke: '#e5e7eb', fill: '#fff', rx: 4 } },
      data: { type, label },
      tools: ['node-editor']
    })
  } else {
    console.log('添加元素:', type, label, deviceList.value)
    const dev = deviceList.value.find(d => d.id === type)
    const iconUrl = dev?.iconUrl
    graph.value.addNode({
      shape: 'image', x, y, width: 48, height: 48,
      attrs: { image: { 'xlink:href': iconUrl, width: 36, height: 36 } },
      data: {
        type,
        label: dev.compLabel,
        devicePointId: dev?.id || null,
        compGroup: '1'
      },
    })
  }
  ElMessage.success('已添加:' + label)
}

// 右键层级操作
const toTop = () => { graph.value.bringToFront(selectCell.value); contextMenu.value.show = false }
const toBottom = () => { graph.value.sendToBack(selectCell.value); contextMenu.value.show = false }
const moveUp = () => { graph.value.bringForward(selectCell.value); contextMenu.value.show = false }
const moveDown = () => { graph.value.sendBackward(selectCell.value); contextMenu.value.show = false }
const delCell = () => { graph.value.removeCell(selectCell.value); contextMenu.value.show = false }

const handleUndo = () => graph.value?.history.undo()
const handleRedo = () => graph.value?.history.redo()
const handleClear = async () => {
  await ElMessageBox.confirm('确定清空?')
  graph.value.clearCells()
}
const toggleLock = () => {
  locked.value = !locked.value
  graph.value.setSelecting(!locked.value)
  graph.value.setNodesDraggable(!locked.value)
  graph.value.setEdgesDraggable(!locked.value)
  ElMessage.success(locked.value ? '已锁定' : '已解锁')
}
const handlePreview = () => {
  previewVisible.value = true
  nextTick(() => {
    if (previewGraph.value) previewGraph.value.dispose()
    previewGraph.value = new Graph({
      container: previewContainer.value,
      width: previewContainer.value.offsetWidth,
      height: 580,
      interacting: { nodeMovable: false, edgeMovable: false },
    })
    previewGraph.value.fromJSON(graph.value.toJSON())
  })
}

// 显隐切换
const toggleCellVisible = (id) => {
  const cell = getCellById(id)
  if (cell) {
    cell.toggleVisible()
    refreshTree()
  }
}
const handleTreeHover = () => { }
const handleTreeLeave = (id) => {
  const cell = getCellById(id)
  if (!cell) return
  if (cell.isNode()) {
    const d = cell.getData()
    cell.attr('body/stroke', d?.type === 'rect' ? '#e5e7eb' : 'none')
    cell.attr('body/strokeWidth', 1)
  } else {
    cell.attr('line/strokeWidth', 2)
  }
}
const handleSaveDevicePointId = ({ cell, data }) => {
  if (!cell) return
  cell.setData(data)
}

// 下载JSON文件
const handleSave = () => {
  const data = graph.value.toJSON()
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = `拓扑图_${new Date().getTime()}.json`
  a.click()
  URL.revokeObjectURL(url)
  ElMessage.success('保存成功!')
}

// 加载设备组件列表
const getTopoImg = async () => {
  try {
    const res = await listComponent()
    const deviceData = res.data.filter(item => item.compGroup === '1')
    deviceList.value = deviceData
  } catch (error) {
    console.error('设备列表加载失败', error)
  }
}

onMounted(() => {
  initGraph()
  getTopoImg()
  loadDataFields()
})
onUnmounted(() => {
  graph.value?.dispose()
  previewGraph.value?.dispose()
})

/**
 * 核心:初始化canvasConfig画布配置
 * @param {Object|Array} data 完整canvasConfig对象 / 仅cells数组
 */
const loadData = (data) => {
  if (!graph.value || !data) return
  try {
    graph.value.clearCells()
    if (Array.isArray(data)) {
      // 只传节点数组
      graph.value.fromJSON({ cells: data })
    } else if (data.cells && typeof data === 'object') {
      // 完整canvasConfig,还原全部画布状态
      graph.value.fromJSON(data)
    }
    nextTick(() => {
      if (graph.value && graphContainer.value) {
        const w = graphContainer.value.offsetWidth
        const h = graphContainer.value.offsetHeight
        if (w > 0 && h > 0) graph.value.resize(w, h)
        refreshTree()
      }
    })
  } catch (err) {
    console.error('加载canvasConfig失败', err)
  }
}

// 获取画布完整数据(包含canvasConfig)
const getData = () => {
  const canvasConfig = graph.value?.toJSON() || { cells: [] }
  return { canvasConfig }
}

// 清空画布
const clearCanvas = () => graph.value?.clearCells()

const getCellById = (id) => graph.value?.getCellById(id) || null

defineExpose({
  getData,
  loadData,
  clearCanvas,
  getCellById,
  toggleCellVisible,
  handleTreeHover,
  handleTreeLeave,
  handleSaveDevicePointId
})
</script>

<style scoped lang="scss">
.topo-editor {
  display: flex;
  flex-direction: column;
  background: #fff;
}

.topo-body {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.topo-canvas {
  flex: 1;
  background: #fff;
  position: relative;
}

.graph-container {
  width: 100%;
  height: 100% !important;
}

.context-menu {
  position: fixed;
  z-index: 9999;
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
  padding: 4px 0;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.menu-item {
  padding: 6px 14px;
  font-size: 13px;
  cursor: pointer;
}

.menu-item:hover {
  background: #ecf5ff;
  color: #409eff;
}

.menu-item.del:hover {
  background: #fef0f0;
  color: #f56c6c;
}
</style>
相关推荐
大家的林语冰1 小时前
超越 TypeScript,Flow 强势回归,语法高仿 TS,功能更丰富,类型更安全!
前端·javascript·typescript
এ慕ོ冬℘゜1 小时前
jQuery 高可用多图上传组件(企业级封装 + 踩坑全解 + 可直接上线)
前端·javascript·jquery
爱勇宝2 小时前
AI 时代,前端工程师的话语权正在下降?
前端·后端
kymjs张涛2 小时前
一个月,纯VibeCoding,全平台云笔记APP
前端·javascript·后端
巴勒个啦2 小时前
esbuild 插件实战:5个真实场景带你自定义构建流水线
前端·angular.js
狗头大军之江苏分军2 小时前
前端路由是怎么来的
前端·javascript·后端
Patrick_Wilson2 小时前
Cookie 作用域避坑:父域泄漏、同名优先级与多环境隔离
前端·http·浏览器
api工厂2 小时前
ZCode 3.0 版本搭配GLM-5.2能力测试
前端·人工智能·ai