当在实现网页端的一些甘特图方案的时候,我们往往也已借助一些比较常用的库能够来完成常用的图形展示,此方案针对的图形展示也属于甘特图下细分的一个模块,流行库也都有一些实现,比如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>