基于vueflow实现动态添加标记的装置图

当在实现网页端的一些甘特图方案的时候,我们往往也已借助一些比较常用的库能够来完成常用的图形展示,此方案针对的图形展示也属于甘特图下细分的一个模块,流行库也都有一些实现,比如echarts、antv、vueflow,综合不同技术方案来看,vueflow比较符合能够实现动态添加标记功能的库,不过其并没有针对此功能的模块,所以我们在此基础上进行开发,实现动态增加标记的功能。

本方案可以在web页面中,针对这种特定场景的可视化方案进行展示,通过数据和配置直接进行展示,可以应用在需要模块化展示、数据备注、数据框选等场景,还可以应用在其他如港口行业、物料展示、装置展示及批注等一些涉及到规律类型数据的展示。

现有技术

vueflow其中以下两点比较关键的技术点

  • 节点流程
    如下图,主要特点是可以配置输入节点、默认节点、输出节点,如图中 NODE1 为输入节点, 有一条连接线,默认节点 NODE3 有两条连接线,输出节点NODE5有一条连接线,连接线类型可以配置直线或者虚线,连接线上可以添加提示文字。同时节点是可以拖拽的,连接点可以拖拽出新的连接线链接到其他连接点上。可以对线段和节点的样式进行设置。
  • 节点调整器
    节点调整器的作用主要是可以通过拖拽边框锚点的方式,可以动态调整节点的大小。

方案说明

用户在网页中可以打开一个单独的页面或者弹窗显示此装置图,可以配置每一行显示的个数及间距,左上角为辅助图标,可以点击进行放大、缩小、还原、黑暗模式,右上角为操作区,默认按钮为编辑、导出图片,当点击编辑以后,按钮区变成新增矩形、新增文字、保存、取消编辑。

图中显示的框类型分为三种,第一种为进入页面以后初始化渲染的框类型,同时框内可以显示其他信息,如文字、图标等,第二种为矩形框,是可调整器,可以调整宽高,第三种为输入框,可以输入文本内容。

点击编辑以后图中的的默认框、矩形框、文本框才能够拖拽和编辑,且节点的点击事件不可被触发,非编辑状态下不可拖拽和编辑,点击事件可以被触发。

  • 流程细节
    一、初始页面状态(图1)。进入页面以后可以配置当前显示的数据组织形式及样式,可以配置间距,案例当中我们使用横排固定4组数据的形式纵向排列,可以设置当前默认显示框是否可拖动,或者是否紧在编辑状态下拖动。左上角为辅助操作区,右上角为按钮操作区。
    二、 辅助操作区(图2.1)。此区域还可以自定义设置图标及功能,图中四个图标对照功能分别是放大、缩小、还原、夜间模式。 其中,放大、缩小、还原为插件自带功能, 夜间模式图标及功能为自定义的。点击夜间模式(图2.1)以后,可以整体根据状态修改画布内所有样式,可以在夜间模式及日常模式下通过点击图标进行切换。
    三、 按钮操作区(图3.1)。按钮操作默认状态下显示 编辑、导出图片两个按钮,点击编辑以后,显示(图3.2)新增矩形、新增文字、保存、重置、取消编辑按钮。
    四、 新增矩形操作(图4.1)。点击新增矩形,新增一个矩形框,可以通过拖动边框调整大小(图4.2),通过拖动矩形其他位置调整矩形的位置,可以通过点击右上角X删除当前新增的矩形,新增的默认位置及大小可以自行设置,上限数量也可以设置。
    五、 新增文字(图5)。点击新增文字,新增一个输入框,可以通过拖动输入框边缘调整输入框位置。输入框输入的文字可自动换行,可以通过点击右上角X删除当前新增的输入框,新增的默认位置及大小可以自行设置,上限数量也可以设置。
    六、 保存、重置与取消编辑。点击修改按钮, 按钮组变成(图3.2),点击保存按钮,当前针对图形中的各个模块位置变动、大小修改、文字修改等都会记录并存储,下次再次打开以后则是变动之后的数据展示。点击重置按钮,当前画布所有修改都回复到点击修改按钮之前的画布状态。点击取消编辑,不记录当前修改,按钮回复到(图3.1)的状态。
    【图1】

    【图2.1】

    【图2.2】

    【图3.1】

    【图3.2】

    【图4.1】

    【图4.2】

    【图5】

    【图6】

代码实现

本项目基于 vue3 + vite + elementPlus + naive-ui 实现

代码中 useModal 方式引入的 hook 可以忽略, 这个是点击的交互,也就是点击当前节点, 打开下一个弹窗

  • 集成后的组件代码
xml 复制代码
<script setup>
import { ref } from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { ControlButton, Controls } from '@vue-flow/controls'
import RectNode from './components/RectNode.vue'
import DeaultNode from './components/DefaultNode.vue'
import InputNode from './components/InputNode.vue'
import Operation from './components/Operation.vue'
import Icon from './Icon.vue'
import useDeviceFlowStore from './pinia.ts'
const {
  onInit,
  onNodeDragStart,
  onNodeDragStop,
  onNodeDrag,
  onConnect,
  setViewport,
  setState,
  toObject,
  fromObject,
  addNodes,
  getNodes
} = useVueFlow({ multiSelectionActive: false })

const ControlsEef = ref()
// our dark mode toggle flag
const dark = ref(false)
const flowKey = 'vue-flow--save-restore'
const flowStore = useDeviceFlowStore()
const isEdit = computed(() => flowStore.isEdit)
onInit((vueFlowInstance) => {
  // instance is the same as the return of `useVueFlow`
  vueFlowInstance.fitView()
})

// 拖拽事件 需要修改 x,y 位置信息
onNodeDragStart((res) => {
  const { nodes } = res
  // if (nodes.length === 2) {
  //   console.log('onNodeDragStart', nodes)
  //   nodes[0].draggable = false
  //   event.preventDefault() // 阻止默认拖动行为
  //   event.stopPropagation()
  // } else {
  //   nodes[0].draggable = true
  // }
})
onNodeDrag((res) => {
  const { nodes, event } = res
  // if (nodes.length === 2) {
  //   console.log('onNodeDrag', nodes)
  //   nodes[0].draggable = false
  //   event.preventDefault() // 阻止默认拖动行为
  //   event.stopPropagation()
  // } else {
  //   nodes[0].draggable = true
  // }
})
onNodeDragStop((res) => {})
/**
 * 重置视窗大小
 */
function resetTransform() {
  setViewport({ x: 0, y: 0, zoom: 1 })
}

function toggleDarkMode() {
  dark.value = !dark.value
}
const clickhander = (value) => {
  console.log('value', value)
}

// 删除节点
const deleteNode = (res) => {
  const { nodes: newNode } = toObject() // 获取当前最新nodes 列表
  const current = newNode.filter((node) => node.id !== res.id)
  setState({ nodes: current })
}
const addNodeHandler = () => {
  // nodes.value.push({
  //   id: Math.random(),
  //   type: 'resizable',
  //   position: { x: 0, y: 0 },
  //   data: { label: 'New Node' }
  // })
  addNodes({
    id: Math.random(),
    type: 'resizable',
    position: { x: 0, y: 0 },
    data: { label: 'New Node' }
  })
}

onMounted(() => {
  // onRestore()
  flowStore.initStore()
  nextTick(() => {})
})
</script>

<template>
  <VueFlow
    :fit-view-on-init="true"
    :nodes="flowStore.nodes"
    :class="{ dark }"
    class="basic-flow"
    :nodes-draggable="isEdit"
    @on-node-click="clickhander"
    :min-zoom="0.1"
    :max-zoom="2"
  >
    <Operation />
    <Controls ref="ControlsEef" :showInteractive="false" position="top-left">
      <!-- <ControlButton title="Toggle Dark Mode" @click="toggleDarkMode">
        <Icon v-if="dark" name="sun" />
        <Icon v-else name="moon" />
      </ControlButton> -->
    </Controls>
    <template #node-resizable="resizableNodeProps">
      <RectNode :node="resizableNodeProps" :deleteNode="deleteNode" />
    </template>
    <template #node-defaultshow="defaultNodeProps"> 
      <DeaultNode :node="defaultNodeProps" :dark="dark" />
    </template>
    <template #node-inputnode="defaultNodeProps">
      <InputNode :deleteNode="deleteNode" :node="defaultNodeProps" />
    </template>
  </VueFlow>
</template>
  • 核心逻辑 pinia 管理
typescript 复制代码
/*
 * @Description: 流程图
 * @Autor: codeBo
 * @Date: 2025-04-18 13:13:48
 * @LastEditTime: 2025-04-21 15:39:11
 */
import { defineStore } from 'pinia'
import to from 'await-to-js'
import { useVueFlow } from '@vue-flow/core'
import { getDiviceProcessApi, editDiviceProcessApi } from '@/api/outputData'
import { VxeUI } from 'vxe-table'

// 接口设计: { data: [] 后端数据列表, nodes: JSON || null, 前端存储,用户编辑以后存储到这里, 已包含data}
/**
 * 流程图逻辑
 * 1、初始化、通过传入的参数请求数据,先确认是否之前是否有过修改, 如果有过修改,应该使用之前后端存储的数据处理好以后直接渲染, 如果没有过修改,证明后端接口没有保存过这个数据,使用后端默认数据处理好以后初始化数据。
 * 2、pinia 存储 节点列表、编辑状态、loading状态、modelRef (用于获取画布实例)
 * 3、initNodes 存储初始化的节点列表,取消编辑的时候,使用这个进行恢复,当保存成功以后,使用更新以后的值进行覆盖
 * 4、用户第一次保存以后,第二次再打开页面,就不会使用后端存储的数据恢复,而是使用前端存储在后端的数据进行恢复,如果想同步后面用户对原始数据的修改,对接产品确认是否新增按钮主动同步,此时还需删除前端存储在后端的数据
 * 
 */
const useDeviceFlowStore = defineStore('flow', {
  state: (): { nodes: any[], flowKey: string, isEdit: boolean, loading: boolean, modelRef: any, initNodes: any, vueFlowInstance: any, caseId: string } => ({
    nodes: [], // 节点列表 绑定更新
    flowKey: 'vue-flow--save-restore', // 本地存储 模拟接口数据存取,
    isEdit: false,
    loading: false,
    modelRef: null,
    initNodes: '', // 初始化节点数据 用于重置 字符串格式
    vueFlowInstance: null, // 添加这个状态,用于获取实例
    caseId: '',
  }),
  getters: {
  },
  actions: {
    initRef(ref, caseId){
      this.modelRef = ref
      this.caseId = caseId
    },
    // 清空或重置数据
    reset(){},
    async initStore(){
      this.loading = true
      this.isEdit = false // 重置编辑状态
      const [err, res] = await to(getDiviceProcessApi({ caseId: this.caseId }))
      this.loading = false
      if(err){
        console.log('err',err);
        return
      }
      const { data, detail} = res
      // 初始化,应该请求接口, 接口中有之前保存过的, 回复保存的, 没有保存过的, 用后端给的列表数据初始化,前端如果保存过数据, 使用JSON字符串的形式直接存储到后端,拿到以后转换成对象,使用其中的 nodes 赋值。 nodes 即为数据数组
      console.log('caseId',this.caseId, JSON.parse(detail));

      if(detail){
        let flow = null
        flow = JSON.parse(detail)
        if(flow){
        // 有保存过数据,没有使用后端存储的数据
          this.nodes = flow
          this.initNodes = detail
        }
      }else
      if(data && data.length){
        const newData = this.transform(data)
        this.nodes = newData
        this.initNodes = JSON.stringify(newData)
      }
      this.vueFlowInstance = useVueFlow()
    },
    // 改造后端数据 增加位置信息 defaultshow
    transform(data){  
      const xspacing = 250;
      const yspacing = 100;
      const itemsPerRow = 4;
      return  data.map((item,index) =>{
        const { deviceCode, deviceName} = item
        const row = Math.floor(index / itemsPerRow);
        const col = index % itemsPerRow;
        return {
          id: deviceCode,
          type: 'defaultshow',
          draggable: false,  // 禁用拖拽,避免相互影响,暂无其他方案
          data: {
            label: deviceName,
            ...item
          },
          zIndex:2,
          position: {
            x: col * xspacing,
            y: row * yspacing
          }
        }
      })
    },
    // 打开编辑状态
    openEdit(){
      this.isEdit = true
    },
    // 关闭编辑状态
    closeEdit(){
      this.isEdit = false
    },
    // 保存事件, 请求接口保存成功 刷新store, 从新初始化 nodes 或直接覆盖 nodes
    async saveNodeHandler(params){
      this.loading = true
      const [err,res] = await to(editDiviceProcessApi({
        caseId: this.caseId,
        groupDetail: JSON.stringify(params)
      }))  
      this.loading = false
      if(err || !res){
        console.log('err',err);
        return
      }
      // 保存成功 
      VxeUI.modal.message({ status: 'success', content: '保存成功!' })
      this.nodes = params
      this.initNodes = JSON.stringify(params)
    },
    // 取消事件
    cancelHandler(){
      console.log('this.nodes',this.nodes);
    },
  }
})

export default useDeviceFlowStore
  • icon 代码 图标属于辅助展示,可以替换成自己需要的 svg, 图标插入形式同 vue-flow
ini 复制代码
<script setup>
defineProps({
  name: {
    type: String,
    required: true
  }
})
</script>

<template>
  <svg v-if="name === 'reset'" width="16" height="16" viewBox="0 0 32 32">
    <path
      d="M18 28A12 12 0 1 0 6 16v6.2l-3.6-3.6L1 20l6 6l6-6l-1.4-1.4L8 22.2V16a10 10 0 1 1 10 10Z"
    />
  </svg>

  <svg v-if="name === 'update'" width="16" height="16" viewBox="0 0 24 24">
    <path
      d="M14 20v-2h2.6l-3.2-3.2l1.425-1.425L18 16.55V14h2v6Zm-8.6 0L4 18.6L16.6 6H14V4h6v6h-2V7.4Zm3.775-9.425L4 5.4L5.4 4l5.175 5.175Z"
    />
  </svg>

  <svg v-if="name === 'sun'" width="16" height="16" viewBox="0 0 24 24">
    <path
      d="M12 17q-2.075 0-3.537-1.463Q7 14.075 7 12t1.463-3.538Q9.925 7 12 7t3.538 1.462Q17 9.925 17 12q0 2.075-1.462 3.537Q14.075 17 12 17ZM2 13q-.425 0-.712-.288Q1 12.425 1 12t.288-.713Q1.575 11 2 11h2q.425 0 .713.287Q5 11.575 5 12t-.287.712Q4.425 13 4 13Zm18 0q-.425 0-.712-.288Q19 12.425 19 12t.288-.713Q19.575 11 20 11h2q.425 0 .712.287q.288.288.288.713t-.288.712Q22.425 13 22 13Zm-8-8q-.425 0-.712-.288Q11 4.425 11 4V2q0-.425.288-.713Q11.575 1 12 1t.713.287Q13 1.575 13 2v2q0 .425-.287.712Q12.425 5 12 5Zm0 18q-.425 0-.712-.288Q11 22.425 11 22v-2q0-.425.288-.712Q11.575 19 12 19t.713.288Q13 19.575 13 20v2q0 .425-.287.712Q12.425 23 12 23ZM5.65 7.05L4.575 6q-.3-.275-.288-.7q.013-.425.288-.725q.3-.3.725-.3t.7.3L7.05 5.65q.275.3.275.7q0 .4-.275.7q-.275.3-.687.287q-.413-.012-.713-.287ZM18 19.425l-1.05-1.075q-.275-.3-.275-.712q0-.413.275-.688q.275-.3.688-.287q.412.012.712.287L19.425 18q.3.275.288.7q-.013.425-.288.725q-.3.3-.725.3t-.7-.3ZM16.95 7.05q-.3-.275-.287-.688q.012-.412.287-.712L18 4.575q.275-.3.7-.288q.425.013.725.288q.3.3.3.725t-.3.7L18.35 7.05q-.3.275-.7.275q-.4 0-.7-.275ZM4.575 19.425q-.3-.3-.3-.725t.3-.7l1.075-1.05q.3-.275.713-.275q.412 0 .687.275q.3.275.288.688q-.013.412-.288.712L6 19.425q-.275.3-.7.287q-.425-.012-.725-.287Z"
    />
  </svg>

  <svg v-if="name === 'moon'" width="16" height="16" viewBox="0 0 24 24">
    <path
      d="M12 21q-3.75 0-6.375-2.625T3 12q0-3.75 2.625-6.375T12 3q.35 0 .688.025q.337.025.662.075q-1.025.725-1.637 1.887Q11.1 6.15 11.1 7.5q0 2.25 1.575 3.825Q14.25 12.9 16.5 12.9q1.375 0 2.525-.613q1.15-.612 1.875-1.637q.05.325.075.662Q21 11.65 21 12q0 3.75-2.625 6.375T12 21Z"
    />
  </svg>

  <svg v-if="name === 'log'" width="16" height="16" viewBox="0 0 24 24">
    <path
      d="M20 19V7H4v12h16m0-16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16m-7 14v-2h5v2h-5m-3.42-4L5.57 9H8.4l3.3 3.3c.39.39.39 1.03 0 1.42L8.42 17H5.59l3.99-4Z"
    />
  </svg>
  <svg
    v-if="name === 'add'"
    width="16"
    height="16"
    viewBox="0 0 14 14"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M14 7C14 7.48325 13.6082 7.875 13.125 7.875L8.80844 7.87452C8.29293 7.87446 7.875 8.29235 7.875 8.80785L7.875 13.125C7.875 13.6082 7.48325 14 7 14C6.51675 14 6.125 13.6082 6.125 13.125L6.125 8.80785C6.125 8.29235 5.70707 7.87446 5.19156 7.87452L0.875 7.875C0.391751 7.875 0 7.48325 0 7C0 6.51675 0.391751 6.125 0.875 6.125L5.19177 6.12452C5.7072 6.12446 6.125 5.70661 6.125 5.19119L6.125 0.875C6.125 0.391751 6.51675 0 7 0C7.48325 0 7.875 0.391751 7.875 0.875L7.875 5.19119C7.875 5.70661 8.2928 6.12446 8.80823 6.12452L13.125 6.125C13.6082 6.125 14 6.51675 14 7Z"
    />
  </svg>
</template>
  • 默认节点DeaultNode代码
xml 复制代码
<script setup>
import { useModal } from 'vue-modal-provider'
import DeviceG6Chart from '@/commn/deviceG6Chart/index.vue'
import useDeviceFlowStore from '../pinia.ts'

const { show: myDeviceG6Chart } = useModal(DeviceG6Chart)
const flowStore = useDeviceFlowStore()
const isEdit = computed(() => flowStore.isEdit)
const props = defineProps(['node', 'dark'])
const clickNode = () => {
  if (isEdit.value) {
    return
  }
  // 打开下一个弹窗,关闭当前弹窗
  flowStore.modelRef?.close()
  myDeviceG6Chart({
    caseId: flowStore.caseId,
    deviceCode: props.node.data.deviceCode
  })
}
</script>

<template>
  <div
    style="padding: 10px"
    :class="{ 'not-edit': !isEdit, 'dark-theme': dark }"
    class="default-node"
  >
    <div @click="clickNode">
      <p>装置代码:{{ node.data.label }}</p>
      <p>装置名称:{{ node.data.deviceName }}</p>
    </div>
  </div>
</template>

<style scoped lang="scss">
.default-node {
  border: 1px #1d4f91 solid;
  background-color: #fff;
  color: #082050;
}
.dark-theme {
  color: #333;
}
.not-edit {
  cursor: pointer;
}
</style>
  • 输入框InputNode节点代码
xml 复制代码
<script setup>
import { NConfigProvider, NInput } from 'naive-ui'
import useDeviceFlowStore from '../pinia.ts'
const flowStore = useDeviceFlowStore()
const isEdit = computed(() => flowStore.isEdit)
const props = defineProps(['node', 'deleteNode'])
const clickNode = () => {
  console.log('props', props.node)
}
const deleteEvent = () => {
  props.deleteNode(props.node)
}
// 主题配置, 修改默认样式,使用 naive ui 右下角插件可以配置
const themeOverrides = {
  Input: {
    borderFocus: '1px solid #409eff',
    borderHover: '1px solid #409eff',
    border: '1px solid #C8100E'
  }
}
</script>

<template>
  <div style="padding: 20px" class="input-node">
    <el-button v-if="isEdit" link @click="deleteEvent" class="shanshu-btn-hover"
      ><Icon icon="svg-icon:icon-hua-cancel" size="10" color="#8F919E"
    /></el-button>
    <n-config-provider v-if="isEdit" :theme-overrides="themeOverrides">
      <n-input
        v-model:value="node.data.label"
        style="width: 200px"
        size="small"
        type="textarea"
        placeholder="请输入"
        :autosize="true"
        class="input-no-edit nodrag"
      />
    </n-config-provider>
    <div v-else style="width: 200px; color: #082050">
      {{ node.data.label }}
    </div>
  </div>
</template>

<style scoped lang="scss">
.input-node {
}
.shanshu-btn-hover {
  position: absolute;
  right: 20px;
  z-index: 9999;
}
</style>
  • 矩形框 RectNode(可拉伸拖拽)代码
xml 复制代码
<!--
 * @Description: 
 * @Autor: codeBo
 * @Date: 2025-04-23 11:22:22
 * @LastEditors: lihaibo@shanshu.ai
 * @LastEditTime: 2025-05-06 15:33:47
-->
<script setup>
import { Handle, Position } from '@vue-flow/core'
import { NodeResizer } from '@vue-flow/node-resizer'
import useDeviceFlowStore from '../pinia.ts'
import { useModal } from 'vue-modal-provider'
import DeviceG6Chart from '@/commn/deviceG6Chart/index.vue'

const { show: myDeviceG6Chart } = useModal(DeviceG6Chart)

const flowStore = useDeviceFlowStore()
const isEdit = computed(() => flowStore.isEdit)
const props = defineProps(['node', 'deleteNode'])

const deleteEvent = () => {
  props.deleteNode(props.node)
}
const clickNode = () => {
  if (isEdit.value) {
    return
  }
  // 打开下一个弹窗,关闭当前弹窗
  flowStore.modelRef?.close()
  myDeviceG6Chart({
    caseId: flowStore.caseId,
    deviceCode: props.node.data.deviceCode
  })
}
</script>

<template>
  <div class="rect-node" v-if="node.type === 'resizable'">
    <NodeResizer color="gray" :isVisible="isEdit" :min-width="200" :min-height="100" />
    <el-button v-if="isEdit" link @click="deleteEvent" class="shanshu-btn-hover"
      ><Icon icon="svg-icon:icon-hua-cancel" size="10" color="#8F919E"
    /></el-button>
    <div style="padding: 10px">
      <!-- <el-input v-model="node.data.label" /> -->
    </div>
  </div>
  <div
    v-else
    style="padding: 10px"
    :class="{ 'not-edit': !isEdit, 'dark-theme': dark }"
    class="default-node"
  >
    <div @click="clickNode">
      <p>装置代码:{{ node.data.label }}</p>
      <p>装置名称:{{ node.data.deviceName }}</p>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.shanshu-btn-hover {
  position: absolute;
  right: 0px;
  top: 0px;
}
.rect-node {
  height: 100%;
  // background-color: rgba(0, 0, 0, 0.1);
  border: 1px solid #c9d2e5;
}
// ::v-deep(.vue-flow__resize-control.line.top) {
//   border-top-width: 2px;
//   border-color: red !important;
// }
.default-node {
  border: 1px #1d4f91 solid;
  background-color: #fff;
  color: #082050;
}
.dark-theme {
  color: #333;
}
.not-edit {
  cursor: pointer;
}
</style>
  • 交互区 Operation(按钮)代码
xml 复制代码
<script setup>
import { Panel, useVueFlow } from '@vue-flow/core'
import useDeviceFlowStore from '../pinia.ts'
const flowStore = useDeviceFlowStore()
const isEdit = computed(() => flowStore.isEdit)
import { useScreenshot } from '../hook/useScreenshot'

const props = defineProps(['addNodeHandler', 'addTextHandler'])
const { setNodes, setState, nodes, addNodes, dimensions, toObject, fromObject, vueFlowRef } =
  useVueFlow()
const { capture } = useScreenshot()
function onSave() {}

function saveHandler() {
  flowStore.saveNodeHandler(toObject().nodes)
}

// 新增事件 text 文本  rect 矩形

const addNodesHandler = (type) => {
  const nodeConfig = {
    text: {
      type: 'inputnode',
      data: { label: 'text' },
      zIndex: 3,
      selectable: false
    },
    rect: {
      type: 'resizable',
      data: { label: 'rect' },
      selectable: false,
      style: { width: '180px', height: '90px' },
      zIndex: 1
    }
  }
  const config = nodeConfig[type]
  if (config) {
    addNodes({
      id: Date.now(), // 随机生成一个id, 这个id没有数据意义,只用来显示框及文字。 和后端存储的原始数据不影响,所以不需要后端指定id
      position: { x: 0, y: 0 },
      ...config
    })
  } else {
    console.warn(`Unknown node type: ${type}`)
  }
}
const cancelHandler = () => {
  setState({ nodes: JSON.parse(flowStore.initNodes) })
  flowStore.closeEdit()
}
const resetHandler = () => {
  setState({ nodes: flowStore.nodes })
}
const doScreenshot = () => {
  if (!vueFlowRef.value) {
    console.warn('VueFlow element not found')
    return
  }
  capture(vueFlowRef.value, { shouldDownload: true })
}
</script>

<template>
  <Panel position="top-right">
    <div class="buttons">
      <el-button v-if="!isEdit" @click="flowStore.openEdit">修改</el-button>
      <el-button v-if="isEdit" @click="addNodesHandler('rect')">新增矩形</el-button>
      <el-button v-if="isEdit" @click="addNodesHandler('text')">新增文字</el-button>
      <el-button v-if="isEdit" @click="saveHandler">保存</el-button>
      <!-- <el-button v-if="isEdit" @click="resetHandler">重置</el-button> -->
      <el-button v-if="isEdit" @click="cancelHandler">取消编辑</el-button>
      <!-- <el-button v-if="!isEdit" @click="doScreenshot">导出图片</el-button> -->
    </div>
  </Panel>
</template>
<style lang="scss" scoped>
.buttons {
  > button {
    color: rgba(#082050, 0.7);
    border-color: #c9d2e4;
  }
}
</style>

参考链接

vueflow.dev/examples/

相关推荐
FogLetter2 分钟前
Vite vs Webpack:前端构建工具的双雄对决
前端·面试·vite
tianchang4 分钟前
JS 排序神器 sort 的正确打开方式
前端·javascript·算法
怪可爱的地球人7 分钟前
ts的类型兼容性
前端
方圆fy14 分钟前
探秘Object.prototype.toString(): 揭开 JavaScript 深层数据类型的神秘面纱
前端
FliPPeDround17 分钟前
🚀 定义即路由:definePage宏如何让uni-app路由配置原地起飞?
前端·vue.js·uni-app
怪可爱的地球人18 分钟前
ts的类型推论
前端
林太白25 分钟前
动态角色权限和动态权限到底API是怎么做的你懂了吗
前端·后端·node.js
每一天,每一步29 分钟前
React页面使用ant design Spin加载遮罩指示符自定义成进度条的形式
前端·react.js·前端框架
moyu8440 分钟前
Pinia 状态管理:现代 Vue 应用的优雅解决方案
前端
Deepsleep.42 分钟前
吉比特(雷霆游戏)前端二面问题总结
前端·游戏