基于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/

相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax