vue3中使用Antv G6渲染树形结构并支持节点增删改

写在前面

在一些管理系统中,会对组织架构、级联数据等做一些管理,你会怎么实现呢?在经过调研很多插件之后决定使用 Antv G6 实现,文档也比较清晰,看看怎么实现吧,先来看看效果图。点击在线体验

实现的功能有:

  1. 增加节点
  2. 删除节点
  3. 编辑节点
  4. 展开收起

思路:增加/删除/编辑都可以改变对应的数据,更新元素数据,展开收起 G6 自带有 collapsed 状态。新增/编辑时可通过 G6 的坐标转换动态更改输入框位置达到编辑指定节点的效果。

具体实现

  1. 先在项目中安装 antv g6
shell 复制代码
npm install --save @antv/g6
  1. vue 文件创建容器渲染
  • 渲染的容器
vue 复制代码
<div id="container" class="one-tree"></div>
  • 渲染方法和初始化树图
js 复制代码
import G6 from '@antv/g6'

const state = reactive({
    treeData: {
        id: 'root',
        sname: 'root',
        name: uniqueId(),
        children: [],
    },
    graph: null,
})

function renderMap(data: any[], graph: Graph): void {
  G6.registerNode(
    'icon-node',
    {
      options: {
        size: [60, 20],
        stroke: '#73D13D',
        fill: '#fff'
      },
      draw(cfg: any, group: any) {
        const styles = (this as any).getShapeStyle(cfg)
        const { labelCfg = {} } = cfg

        const w = cfg.size[0]
        const h = cfg.size[1]
        const keyShape = group.addShape('rect', {
          attrs: {
            ...styles,
            cursor: 'pointer',
            x: 0,
            y: 0,
            width: w, // 200,
            height: h, // 60
            fill: cfg.style.fill || '#fff'
          },
          name: 'node-rect',
          draggable: true
        })

        // 动态增加和删除元素
        group.addShape('text', {
          attrs: {
            x: 131,
            y: 20,
            r: 6,
            stroke: '#707070',
            cursor: 'pointer',
            opacity: 1,
            fontFamily: 'iconfont',
            textAlign: 'center',
            textBaseline: 'middle',
            text: '\ue658',
            fontSize: 16
          },
          name: 'add-item'
        })
        // 删除icon,根元素不能删除
        if (cfg.id !== 'root') {
          group.addShape('text', {
            attrs: {
              x: 110,
              y: 20,
              r: 6,
              fontFamily: 'iconfont',
              textAlign: 'center',
              textBaseline: 'middle',
              text: '\ue74b',
              fontSize: 14,
              stroke: '#909399',
              cursor: 'pointer',
              opacity: 0
            },
            name: 'remove-item'
          })
        }

        if (cfg.sname) {
          group.addShape('text', {
            attrs: {
              ...labelCfg.style,
              text: fittingString(cfg.sname, 110, 12),
              textAlign: 'left',
              x: 10,
              y: 25
            }
          })
        }
        // 展开收起
        if (cfg.children && cfg.children.length > 0) {
          group.addShape('circle', {
            attrs: {
              width: 24,
              height: 24,
              x: 154,
              y: 20,
              r: 12,
              cursor: 'pointer',
              lineWidth: 1,
              fill: !cfg.collapsed ? '#9e9e9e' : '#2196f3',
              opacity: 1,
              text: 1
            },
            name: 'collapse-icon'
          })
          group.addShape('text', {
            attrs: {
              ...labelCfg.style,
              text: cfg.children.length,
              textAlign: 'left',
              x: 150,
              y: 25,
              fill: '#ffffff',
              fontWeight: 500,
              cursor: 'pointer'
            },
            name: 'collapse-icon'
          })
        }
        return keyShape
      },
      setState(name, value, item) {
        const group = item?.getContainer()
        if (name === 'collapsed') {
          const marker = item?.get('group').find((ele: any) => ele.get('name') === 'collapse-icon')
          const icon = value ? G6.Marker.expand : G6.Marker.collapse
          marker.attr('symbol', icon)
        }
        if (name === 'selected') {
          const nodeRect = group?.find(function (e) {
            return e.get('name') === 'node-rect'
          })
          if (value) {
            nodeRect?.attr({
              stroke: '#2196f3',
              lineWidth: 2
            })
          }
        }
        if (name === 'hover') {
          const addMarker = group?.find(function (e) {
            return e.get('name') === 'add-item'
          })
          const reduceMarker = group?.find(function (e) {
            return e.get('name') === 'remove-item'
          })
          if (value) {
            addMarker?.attr({
              opacity: 1
            })
            reduceMarker?.attr({
              opacity: 1
            })
          }
        }
      },
      update: undefined
    },
    'rect'
  )
  graph.data(data)
  graph.render()
  mouseenterNode(graph)
  mouseLeaveNode(graph)
  collapseNode(graph)
}

function initGraph(graphWrapId: string): Graph {
  const width = (document.getElementById(graphWrapId) as HTMLElement).clientWidth || 1000
  const height = (document.getElementById(graphWrapId) as HTMLElement).clientHeight || 1000
  const graph = new G6.TreeGraph({
    container: graphWrapId,
    width,
    height,
    linkCenter: true,
    animate: false,
    fitView: false, // 自动调整节点位置和缩放,使得节点适应画布大小
    modes: {
      default: ['scroll-canvas'],
      edit: ['click-select']
    },
    defaultNode: {
      type: 'icon-node',
      size: [120, 40],
      style: defaultNodeStyle,
      labelCfg: defaultLabelCfg
    },
    defaultEdge: {
      type: 'cubic-vertical'
    },
    comboStateStyles,
    layout: defaultLayout
  })
  return graph
}
  • 事件处理
js 复制代码
/**
 * @description:树型图的事件绑定
 */

// 展开收起子节点
function collapseNode(graph: Graph): void {
    // 展开和收起子节点
    graph.on('node:click', (e: any) => {
        if (e.target.get('name') === 'collapse-icon') {
            e.item.getModel().collapsed = !e.item.getModel().collapsed
            graph.setItemState(e.item, 'collapsed', e.item.getModel().collapsed)
            graph.layout()
        }
    })
}

// 鼠标滑入
function mouseenterNode(graph: Graph): void {
    graph.on('node:mouseover', (evt: any) => {
        const { item, target } = evt
        if (item._cfg.id === 'root') return
        const canHoverName = ['node-rect', 'remove-item']
        if (!canHoverName.includes(target.get('name'))) return

        // 显示icon
        const deleteItem = item.get('group').find(function (el: any) {
            return el.cfg.name === 'remove-item'
        })
        deleteItem.attr('opacity', 1)
        if (item._cfg && item._cfg.keyShape) {
            item._cfg.keyShape.attr('stroke', '#2196f3')
        }
        graph.setItemState(item, 'active', true)
    })
}

// 鼠标离开
function mouseLeaveNode(graph: Graph): void {
    graph.on('node:mouseout', (evt: any) => {
        const { item, target } = evt
        const canHoverName = ['node-rect', 'remove-item']
        if (item._cfg.id === 'root') return
        if (!canHoverName.includes(target.get('name'))) return
        // 隐藏icon
        const deleteItem = item.get('group').find(function (el: any) {
            return el.cfg.name === 'remove-item'
        })
        deleteItem.attr('opacity', 0)
        if (item._cfg && item._cfg.keyShape) {
            item._cfg.keyShape.attr('stroke', '#fff')
        }
        graph.setItemState(item, 'active', false)
    })
}

/**
 * @description 文本超长显示
 */
const fittingString = (str: string, maxWidth: number, fontSize: number): string => {
    const ellipsis = '...'
    const ellipsisLength = Util.getTextSize(ellipsis, fontSize)[0]
    let currentWidth = 0
    let res = str
    const pattern = new RegExp('[\u4E00-\u9FA5]+')
    str.split('').forEach((letter, i) => {
        if (currentWidth > maxWidth - ellipsisLength) return
        if (pattern.test(letter)) {
            currentWidth += fontSize
        } else {
            currentWidth += Util.getLetterWidth(letter, fontSize)
        }
        if (currentWidth > maxWidth - ellipsisLength) {
            res = `${str.substr(0, i)}${ellipsis}`
        }
    })
    return res
}
  • 节点的增加、删除、编辑时间
js 复制代码
const addEvent = (graph: any) => {
    graph.on('node:click', (evt: any) => {
        const { item, target } = evt
        const name = target.get('name')

        // 增加元素
        const model = item.getModel()
        if (name === 'add-item') {
            state.editType = 'add'
            // 如果收起需要展开
            if (model.collapsed) model.collapsed = false
            // 没有子级的时候设置空数组
            if (!model.children) model.children = []
            const id = uniqueId()
            model.children.push({
                id,
                name: 1,
                sname: '',
                parentId: model.id,
            })
            graph.updateChild(model, model.id)
            const curTarget = graph.findDataById(id)
            const canvasXY = graph.getCanvasByPoint(curTarget.x, curTarget.y)
            state.editOne = curTarget
            state.input = curTarget.sname
            setTimeout(() => {
                state.showInput = true
                nextTick(() => {
                    inputRref.value.focus()
                })
            }, 200)
            // 更改输入框的位置
            state.inputStyle = {
                left: `${canvasXY.x}px`,
                top: `${canvasXY.y}px`,
            }
        }
        // 删除节点
        if (name === 'remove-item') {
            graph.removeChild(model.id)
            // 查找当前的父id,更新其子元素的长度
            graph.updateItem(model.parentId, {})
        }

        // 编辑
        if (name === 'node-rect') {
            const curTarget = graph.findDataById(item._cfg.id)
            const canvasXY = graph.getCanvasByPoint(curTarget.x, curTarget.y)
            state.editOne = evt.item
            state.input = curTarget.sname
            state.showInput = true
            state.editType = 'edit'
            nextTick(() => {
                inputRref.value.focus()
            })
            state.inputStyle = {
                left: `${canvasXY.x}px`,
                top: `${canvasXY.y}px`,
            }
        }
    })
    // 画布滚动、拖动时,不能编辑节点名称
    graph.on('dragstart', () => {
        state.showInput = false
    })
    graph.on('wheel', () => {
        state.showInput = false
    })
}
  • dom 节点渲染后渲染树图
js 复制代码
onMounted(() => {
    nextTick(() => {
        state.graph = initGraph('container')
        state.graph.clear()
        addEvent(state.graph)
        renderMap(state.treeData, state.graph)
    })
})

存在问题

配置了滚动画布scroll-canvas,文档上说可以配置 scalableRange 参数,设置拖动 canvas 可扩展的范围,默认为 0,值为 -1 ~ 1 代表可超出视口的范围的比例值。但好像设置了不生效,画布会无限滚动。欢迎各位大佬指点解决方式。

官网文档链接

相关链接

  1. 源码链接
  2. Antv G6 官网
  3. 参考文章
相关推荐
rocky1917 分钟前
谷歌浏览器插件 录制元素拖动事件
前端·javascript
emoji11111132 分钟前
vue3、原生html交互传值
vue.js·html·交互
nothingbutluck46432 分钟前
2025.4.10 html有序、无序、定义列表、音视频标签
前端·html·音视频
爱上python的猴子1 小时前
chrome中的copy xpath 与copy full xpath的区别
前端·chrome
Lysun0012 小时前
dispaly: inline-flex 和 display: flex 的区别
前端·javascript·css
山禾女鬼0012 小时前
Vue 3 自定义指令
前端·javascript·vue.js
麦麦大数据2 小时前
知识图谱中医知识问答系统|养生医案综合可视化系|推荐算法|vue+flask+neo4j+mysql
vue.js·知识图谱·推荐算法
啊卡无敌2 小时前
Vue 3 reactive 和 ref 区别及 失去响应性问题
前端·javascript·vue.js
北桥苏2 小时前
Spine动画教程:皮肤制作
前端
涵信2 小时前
第九节:React HooksReact 18+新特性-React 19的use钩子如何简化异步操作?
前端·javascript·react.js