vue+d3v6实现动态知识图谱可视化展示

一、前言

之前的博客:对Neo4j导出数据做知识图谱可视化 D3库实现

之前博客做的知识图谱可视化只是在html上直接写的页面,而且d3还使用的是v4版本的库。

但是当项目复杂的情况下(前端项目越来越复杂了),比如要在系统中加入其他图表进行综合大屏展示,显然不是一个很好的选择,于是决定用vue重构一下代码,把图可视化作为一个组件来维护,顺便再用一下新版本的d3。同时之前的项目存在一个致命的问题,就是动态更新,即当页面已经有图谱展示的情况下,查询新的数据时,无法正常展示(节点全跑到左上角去了)

下图是向基于nodejs搭建neo4j后端服务请求数据后,更新视图出现的问题:(Vue重构后,代码不变的情况下可以解决该问题)

在这里插入图片描述

二、d3@v6相关链接参考

下面附上供学习和参考的链接:

目前d3已经到v6版本了(更新是真滴快),关于新版d3的改动说明的链接我放在了上面供参考。虽然更新了很多东西,但总的来说原来的代码其实改动不大,比如新版的d3移除了d3.eventv4版本需要注意。

三、代码详细介绍

1. 页面结构(2021年5月更新)

首先页面的整体结构如下,分为2D和3D图谱展示两个页面,其中2D知识图谱除了展示还实现了各种图交互功能;而3D知识图谱采用了第三方模块,底层使用 D3+Three 实现视图渲染,直接截图展示:
在这里插入图片描述

2. 代码结构(2021年5月新增)

这次对代码结构作了大改,变化挺大的,文件夹对应关系如下:

  • 组件存放在 components 文件夹
    • d3graph.vue —— 2D图谱展示组件
    • threeGraph.vue —— 3D图谱展示组件
    • gSearch.vue —— 搜索组件,目前主要通过require代替后台请求
  • 页面存放在 views 文件夹
    • 2dView.vue —— 2D图谱展示页面
    • 3dView.vue —— 3D图谱展示页面
  • d3插件存放在 plugins 文件夹
    • d3-context-menu.js —— 右键菜单事件注册及回调函数
    • d3-context-menu.scss —— 右键菜单样式文件
  • 路由存放在 router 文件夹
    • index.js —— 路由较少,就2个页面
  • 静态图数据存放在 data 文件夹
  • storeassets 文件夹暂时不用

3. 功能及代码介绍

接下来介绍关于图可视化的基本功能与交互事件:

1)d3初始化(2021年5月更新)

d3初始化,包括数据解析、数据渲染及响应式数据初始化,在新版本代码将数据解析分离出来,放到 2dView.vue 页面中 。现在d3初始化分为数据渲染和状态初始化两个任务。

旧版本:

// d3初始化,包括数据解析、数据渲染
d3init () {
  // this.graph存放json数据
  this.d3jsonParser(this.graph)
  this.d3render()
  // 数据初始化(节点状态)
  this.nodeState = 0
  this.states = ['on', 'on', 'on', 'on']
}

新版本:

// d3初始化,包括数据解析、数据渲染
d3init () {
  this.links = this.data.links
  this.nodes = this.data.nodes
  this.svgDom = d3.select('#svg')  // 获取svg的DOM元素
  // this.d3jsonParser(this.graph)
  this.d3render()
  // 数据状态初始化
  this.stateInit()
},
  • d3jsonParser:对请求的json数据进行解析,分别格式化为节点和关系数据,解析后存放到 this.links(关系显示数据) / this.nodes(节点显示数据) / this.data(全部图数据)
d3jsonParser (json) {
  const nodes =[]
  const links = [] // 存放节点和关系
  const nodeSet = [] // 存放去重后nodes的id

  // 使用vue直接通过require获取本地json,不再需要使用d3.json获取数据
  // d3.json('./../data/records.json', function (error, data) {
  //   if (error) throw error
  //   graph = data
  //   console.log(graph[0].p)
  // })

  for (let item of json) {
    for (let segment of item.p.segments) {
      // 重新更改data格式
      if (nodeSet.indexOf(segment.start.identity) == -1) {
        nodeSet.push(segment.start.identity)
        nodes.push({
          id: segment.start.identity,
          label: segment.start.labels[0],
          properties: segment.start.properties
        })
      }
      if (nodeSet.indexOf(segment.end.identity) == -1) {
        nodeSet.push(segment.end.identity)
        nodes.push({
          id: segment.end.identity,
          label: segment.end.labels[0],
          properties: segment.end.properties
        })
      }
      links.push({
        source: segment.relationship.start,
        target: segment.relationship.end,
        type: segment.relationship.type,
        properties: segment.relationship.properties
      })
    }
  }
  console.log(nodes)
  console.log(links)
  this.links = links
  this.nodes = nodes
  this.data = { nodes, links }
  // return { nodes, links }
}
  • d3render代码部分较多,包括了DOM挂载、事件绑定、以及D3视图渲染,在下面一一进行介绍

2)图查询更新视图(2021年5月更新)

该按钮模拟了向后台请求数据的功能,获取新的数据后,更新图展示部分,重新渲染视图,解决了原博客在动态更新这一块的问题。

在这里插入图片描述
旧版(暂时保留图片):
在这里插入图片描述

目前查询暂用require本地数据代替后台请求数据,gSearch.vue 将数据传给页面,并执行数据解析的任务;而 d3graph.vue 组件通过 watch 监听当前的数据变化,更新后通过 this.d3init() 重新渲染页面。

gSearch.vue 模拟后台查询:

query () {
  // console.log(typeof this.mode)
  if (this.data.length <= 20) {
    this.data = require('../data/top5.json')
  } else {
    this.data = require('../data/records.json')
  }
  this.$emit('getData', this.data)
}

d3graph.vue 监听数据变化:

watch: {
  // 当请求到新的数据时,重新渲染
  data (newData, oldData) {
    console.log(newData, oldData)
    // 移除svg和元素注册事件,防止内存泄漏
    this.svgDom.on('.', null)
    this.svgDom.selectAll('*').on('.', null)
    this.d3init()
  }
}

3)平移与缩放(2021年5月更新)

  • 按住鼠标在空白位置移动可以移动视图的相对位置
  • 鼠标滚轮完成视图的放大和缩小

下图展示将视图通过缩放、拖拽和平移来调整图谱布局,使展示结果更加清晰。
在这里插入图片描述

事件直接在svg视图部分注册,通过标签的translate和scale属性实现平移和缩放:

var svg = d3.select("#svg1")
  // 给画布绑定zoom事件(缩放、平移)
  .call(d3.zoom().on('zoom', function(event) {
    // console.log(event)
    var scale = event.transform.k,
        translate = [event.transform.x, event.transform.y]

	// 视图矫正,暂不使用
    // if (this.svgTranslate) {
    //     translate[0] += this.svgTranslate[0]
    //     translate[1] += this.svgTranslate[1]
    // }

    // if (this.svgScale) {
    //     scale *= this.svgScale
    // }

    svg.attr('transform', 'translate(' + translate[0] + ', ' + translate[1] + ') scale(' + scale + ')');
  }))
  .append('g')
  .attr('width', '100%')
  .attr('height', '100%')

4)文字显示

文字基于节点居中显示,并实现自动换行排版,根据文本字数分1-3行进行展示:

  • 单行(3个字内)

在这里插入图片描述

  • 两行(4-7个字)

在这里插入图片描述

  • 三行(8-10,10字以上隐藏)

在这里插入图片描述

函数实现(通过attr属性进行批量操作)

/**
 * 文本分隔(根据字数在当前选择器中分隔三行,超过10字省略)
 * @method textBreaking
 * @param {d3text} 文本对应的DOM对象
 * @param {text} 节点名称的文本值
 * @return {void}
 */
function textBreaking(d3text, text) {
  const len = text.length
  if (len <= 3) {
    d3text.append('tspan')
      .attr('x', 0)
      .attr('y', 2)
      .text(text)
  } else {
    const topText = text.substring(0, 3)
    const midText = text.substring(3, 7)
    let botText = text.substring(7, len)
    let topY = -16
    let midY = 0
    let botY = 16
    if (len <= 7) {
      topY += 10
      midY += 10
    } else if (len > 10){
      botText = text.substring(7, 9) + '...'
    }

    d3text.text('')
    d3text.append('tspan')
      .attr('x', 0)
      .attr('y', topY)
      .text(function () {
        return topText
      })
    d3text.append('tspan')
      .attr('x', 0)
      .attr('y', midY)
      .text(function () {
        return midText
      })
    d3text.append('tspan')
      .attr('x', 0)
      .attr('y', botY)
      .text(function () {
        return botText
      })
  }
}

5)拖拽

通过按住鼠标调整选中节点的位置及相对布局,注意v6版本事件移除了全局事件对象d3.event,通过返回的函数参数来处理。

node.call(this.drag(simulation))
text.call(this.drag(simulation))
// 定义在vue的methods中
drag(simulation) {
  function dragsubject(event) {
    return simulation.find(event.x, event.y);
  }

  function dragstarted(event) {
    if (!event.active) simulation.alphaTarget(0.3).restart();
    event.subject.fx = event.subject.x;
    event.subject.fy = event.subject.y;
  }
  
  function dragged(event) {
    event.subject.fx = event.x;
    event.subject.fy = event.y;
  }
  
  function dragended(event) {
    if (!event.active) simulation.alphaTarget(0);
    // 注释以下代码,使拖动结束后固定节点
    // event.subject.fx = null;
    // event.subject.fy = null;
  }
  
  return d3.drag()
    .subject(dragsubject)
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended)
}

6)鼠标浮动事件(2021年5月更新)

鼠标悬浮在节点上触发,并通过粗边框标记选中节点,细边框标记关联的节点,同时在右侧展示该节点的详细信息。

在这里插入图片描述
旧版效果:
在这里插入图片描述
注册悬浮事件时,注意_this指向Vue实例(全局定义),this指向当前的d3元素:

node.on('mouseenter', function (event) {
  const node = d3.select(this)
  
  //获取被选中元素的名字
  let name = node.attr("name")
  let id = node.attr("id")
  let color = node.attr('fill')
  console.log(name, id, color)
  //设置#info h4样式的颜色为该节点的颜色,文本为该节点name
  _this.$set(_this.selectNodeData, 'id', id)
  _this.$set(_this.selectNodeData, 'name', name)
  _this.$set(_this.selectNodeData, 'color', color)
  
  //遍历查找id对应的属性
  for (let item of _this.nodes) {
    if (item.id == id) {
      // for(var key in item.properties)
      _this.$set(_this.selectNodeData, 'properties', item.properties)
    }
  }
  // 选择#svg1 .nodes中所有的circle,再增加个class
  d3.select('#svg1 .nodes').selectAll('circle').attr('class', function(d) {
    // 节点属性name是否等于name,返回fixed(激活选中样式)
    if(d.properties.name == name) {
      return 'fixed'
    }
    // 当前节点返回空,否则其他节点循环判断是否被隐藏起来(CSS设置隐藏)
    else {
      // links链接的起始节点进行判断,如果其id等于name则显示这类节点
      // 注意: graph=data
      for (var i = 0; i < _this.links.length; i++) {
        //如果links的起点等于name,并且终点等于正在处理的则显示
        if (_this.links[i]['source'].properties.name == name && _this.links[i]['target'].id == d.id) {
            return ''
        }
        if (_this.links[i]['target'].properties.name == name && _this.links[i]['source'].id == d.id) {
            return ''
        }
      }
      return "inactive" // 前面CSS定义 .nodes circle.inactive
    }
  })
  //处理相邻的边line是否隐藏 注意 || 
  d3.select("#svg1 .links").selectAll('line').attr('class', function(d) {
      if (d.source.properties.name == name || d.target.properties.name == name) {
          return ''
      } else {
          return 'inactive'
      }
  })
})

7)鼠标单击事件(2021年5月更新)

  • 单击当前节点,进入聚焦模式,此时会隐藏无关节点并标记当前选中节点及邻居节点和关系;
  • 点击画布即可取消聚焦模式。

在这里插入图片描述

8)鼠标右键菜单(写一个d3插件)

鼠标右击目标节点触发,目前实现了三个菜单功能

  • 隐藏节点(隐藏选中节点及其关系)
  • 显示节点关联图(显示与选中节点关联的其他节点和关系)
  • 显示所有查询节点(显示查询的所有节点)

在这里插入图片描述
由于d3没有相关的菜单功能,需要自己手动写插件挂载到d3上,我们把菜单的功能和样式放到plugins文件夹中,并在vue组件中引入。
在这里插入图片描述
全局注册d3插件:

import * as d3 from 'd3'
import install from '@/plugins/d3-context-menu'
install(d3) // 为d3注册右键菜单插件

style中引入菜单的样式:

@import '@/plugins/d3-context-menu';

给节点注册菜单事件:

// 注册菜单事件
// d3.contextMenu是全局自定义的插件功能
node.on('contextmenu', d3.contextMenu(this.menu))

9)图例交互事件

通过点击图例,隐藏对应类型的节点和关系,如图点击后隐藏了贸易类型和国家两种类型的节点及对应关系。
在这里插入图片描述

// 隐藏该类型的所有节点
hideNodeOfType (event) {
  // console.log(event.target.dataset)
  const index = event.target.dataset.index
  const state = event.target.dataset.state
  const nodeTypes = ['Enterprise', 'Type', 'Region', 'Country']
  const linkTypes = ['', 'type', 'locate', 'export']
  // 图例的状态切换(对应类型的节点隐藏)
  if (state === 'on') {
    // 隐藏该类型的所有节点及关联关系
    // this.states[index] = 'off'
    this.$set(this.states, index, 'off')
  } else {
    // this.states[index] = 'on'
    this.$set(this.states, index, 'on')
  }
  /**************************************
   * 状态更新后,同时对数据更新
   */
  const indexs = this.states.map(s => {
    if (s === 'on') {
      return '1'
    } else {
      return '0'
    }
  })
  // 遍历删除节点
  this.nodes = this.data.nodes.filter(node => {
    for (let i = 0; i < indexs.length; i++) {
      if (node.label === nodeTypes[i] && indexs[i] === '0') return false
    }
    return true
  })
  // 遍历删除关系
  this.links = this.data.links.filter(link => {
    for (let i = 0; i < indexs.length; i++) {
      if (i === 0 && indexs[i] === '0') return false
      else if (link.type === linkTypes[i] && indexs[i] === '0') return false
    }
    return true
  })
  
  // 重新渲染
  this.d3render()
}

10)文字搜索(2021年5月更新)

图数据量过大时,可以通过左侧的关键字搜索,找到符合条件的节点及关联节点
在这里插入图片描述

// 搜索包含关键字的节点
searchKeyWords (value) {
  console.log('keyup event! => ' + value)
  // 如果Input值是空的显示所有的圆和线(没有进行筛选)
  if (this.keywords === '') {
    // d3.select('#svg1 .texts').selectAll('text').attr('class', '')
    d3.select('#svg1 .nodes').selectAll('circle').attr('class', '')
    d3.select('#svg1 .links').selectAll('line').attr('class', '')
  }
  // 否则判断判断三个元素是否等于name值,等于则显示该值
  else {
    var name = this.keywords
    // 搜索所有的节点
    d3.select('#svg1 .nodes').selectAll('circle').attr('class', (d) => {
      // 输入节点id的小写等于name则显示,否则隐藏
      if (d.properties.name.indexOf(name) >= 0) {
          return ''
      } else {
        // 优化:与该搜索节点相关联的节点均显示
        // links链接的起始节点进行判断,如果其id等于name则显示这类节点
        // 注意: graph=data
        for (var i = 0; i < this.links.length; i++) {
          // 如果links的起点等于name,并且终点等于正在处理的则显示
          if ((this.links[i]['source'].properties.name.indexOf(name) >= 0) && 
            (this.links[i]['target'].id == d.id)) {
              return ''
          }
          // 如果links的终点等于name,并且起点等于正在处理的则显示
          if ((this.links[i]['target'].properties.name.indexOf(name) >= 0) && 
            (this.links[i]['source'].id == d.id)) {
              return ''
          }
        }
        return 'inactive' // 隐藏
      }
    })
    
    // 搜索links
    // 显示相的邻边 注意 || 
    d3.select("#svg1 .links").selectAll('line').attr('class', (d) => {
      if ((d.source.properties.name.indexOf(name) >= 0) || 
        (d.target.properties.name.indexOf(name) >= 0) 
        ) {
          return ''
        } else {
          return 'inactive' //隐藏
        }
    })
  }
}

11)模式选择(2021年5月更新)

显示或隐藏关系的文字信息,这里就不截图了。通过节点状态(显示/隐藏)判断是否展示文字部分:

// 隐藏文字
changeTextState (state) {
  // state发生变化时才进行更新、处理
  if (this.textState !== state) {
    this.textState = state
    // const text = d3.selectAll('.texts text')
    const text = d3.selectAll('.linkTexts text')
    console.log(text)
    // 根据新的节点状态,在节点上展示不同的文本信息
    if (this.textState === 2) {
      text.style('display', 'none')
      // 暂不作校准
      // // transform属性数值化
      // // 原:translate(40, 8) scale(1)
      // // 现:[40, 8, 1]
      // let transform = d3.select('#svg1 g').attr('transform')
      // transform = transform
      //   ? transform.match(/\d.?/g).map(item => parseInt(item))
      //   : [0, 0, 1]
      // // 校准
      // transform[0] = transform[0] + this.svgTranslate[0]
      // transform[1] = transform[1] + this.svgTranslate[1]
      // transform[2] = transform[2] * this.svgScale

      // console.log(transform)
      // // 隐藏节点后,svg自动缩放
      // d3.select('#svg1 g').attr('transform', 'translate(' + transform[0] + ', ' + transform[1] + ') scale(' + transform[2] + ')')
    } else {
      text.style('display', 'block')
      // 暂不作校准
      // 显示节点后,svg自动还原
      // d3.select('#svg1 g').attr('transform', '')
    }
  }
}

12)关系文字和箭头的绘制与调整(2021年5月新增)

根据关系直线的坐标位置对文字进行调整:

  • 第一象限
    在这里插入图片描述
  • 第二象限
    在这里插入图片描述
  • 第三象限
    在这里插入图片描述
  • 第四象限
    在这里插入图片描述

添加关系文字和箭头(代码在d3render ()函数中)

var link = svg.append("g")
  .attr("class", "links")
  .selectAll("line")
  .data(this.links).enter()
  .append("line")
  .attr("stroke-width", function(d) {
    // 每次访问links的一项数据
    return 2 //所有线宽度均为2
  })
  .join("path")
  .attr("marker-end", "url(#posMarker)")

var linksName = svg.append("g")
  .attr("class", "linkTexts")
  .selectAll("text")
  .data(this.links)
  .join("text")
  .style('text-anchor','middle')
  .style('fill', '#fff')
  .style('font-size', '12px')
  // .style('font-weight', 'bold')
  .text(d => d.properties.name)

关系文字动态调整位置(代码在d3render ()的内部函数 ticked()中):

// ticked()函数确定link线的起始点x、y坐标 node确定中心点 文本通过translate平移变化
function ticked() {
  link
    .attr("x1", d => d.source.x)
    .attr("y1", d => d.source.y)
    .attr("x2", d => d.target.x)
    .attr("y2", d => d.target.y)

  linksName
    .attr('transform', d => {
      let x = Math.min(d.source.x, d.target.x) + Math.abs(d.source.x - d.target.x) / 2
      let y = Math.min(d.source.y, d.target.y) + Math.abs(d.source.y - d.target.y) / 2 - 1
      // tanA = a / b
      // A = arctan(tanA)
      let tanA = Math.abs(d.source.y - d.target.y) / Math.abs(d.source.x - d.target.x)
      let angle = Math.atan(tanA) / Math.PI * 180
      // let angle = Math.atan2(1,1)/Math.PI*180
      // console.log(angle)
      // 第一、二象限额外处理
      if (d.source.x > d.target.x) {
        // 第二象限
        if (d.source.y <= d.target.y) {
          angle = -angle
        }
        // else {  // 第三象限
        //   angle = angle
        // }
      } else if (d.source.y > d.target.y) {
        // 第一象限
        angle = -angle
      }
      return 'translate(' + x + ',' + y + ')' + 'rotate(' + angle + ')'
    })

  node
    .attr("cx", d => d.x)
    .attr("cy", d => d.y)

  text.attr('transform', function(d) {
    let size = 15
    switch(d.label){
      case _this.labels[0]: break;
      case _this.labels[1]: size = 14;break;
      case _this.labels[2]: size = 13;break;
      default: size = 12;break;
    }
    size -= 5
    return 'translate(' + (d.x - size / 2 + 3) + ',' + (d.y + size / 2) + ')'
  })
}

绘制关系箭头:目前只绘制了正向关系箭头(激活和非激活样式),逆向关系箭头未生效,需要的请自行修改下面的注释代码。

// 绘制关系箭头
addMarkers() {
  // 定义箭头的标识
  var defs = this.svgDom.append("defs")
  const posMarker = defs.append("marker")
    .attr("id", "posMarker")
    .attr("orient", "auto")
    .attr("stroke-width", 2)
    .attr("markerUnits", "strokeWidth")
    .attr("markerUnits", "userSpaceOnUse")
    .attr("viewBox", "0 -5 10 10")
    .attr("refX", 31)
    .attr("refY", 0)
    .attr("markerWidth", 12)
    .attr("markerHeight", 12)
    .append("path")
    .attr("d", "M 0 -5 L 10 0 L 0 5")
    .attr('fill', '#e0cac1')
    .attr("stroke-opacity", 0.6);
  const posActMarker = defs.append("marker")
    .attr("id", "posActMarker")
    .attr("orient", "auto")
    .attr("stroke-width", 2)
    .attr("markerUnits", "strokeWidth")
    .attr("markerUnits", "userSpaceOnUse")
    .attr("viewBox", "0 -5 10 10")
    .attr("refX", 31)
    .attr("refY", 0)
    .attr("markerWidth", 12)
    .attr("markerHeight", 12)
    .append("path")
    .attr("d", "M 0 -5 L 10 0 L 0 5")
    .attr('fill', '#1E90FF')
    .attr("stroke-opacity", 0.6);
  // const negMarker = defs.append("marker")
  //   .attr("id","negMarker")
  //   .attr("orient","auto")
  //   .attr("stroke-width",2)
  //   .attr("markerUnits", "strokeWidth")
  //   .attr("markerUnits", "userSpaceOnUse")
  //   .attr("viewBox", "0 -5 10 10")
  //   .attr("refX", -25)
  //   .attr("refY", 0)
  //   .attr("markerWidth", 12)
  //   .attr("markerHeight", 12)
  //   .append("path")
  //   .attr("d", "M 10 -5 L 0 0 L 10 5")
  //   .attr('fill', '#999')
  //   .attr("stroke-opacity", 0.6);
}

13)节点编辑功能(2021年5月新增)

目前完成了纯前端的节点编辑功能,如果想集成后端需要自行添加代码。通过点击编辑按钮可以修改属性对应的文本内容,再点击确定使其生效。

在这里插入图片描述

// 编辑当前选中节点
btnEdit () {
  this.temp = Object.assign({}, this.selectNodeData.properties) // copy obj
  this.dialogFormVisible = true
  console.log(this.selectNodeData)
},
doEdit () {
  // console.log(this.data)
  let i = 0
  // 更新props的data 和 selectNodeData
  this.selectNodeData.name = this.temp.name
  this.selectNodeData.properties = this.temp
  for (let node of this.data.nodes) {
    // console.log(node.id === this.selectNodeData.id)
    // console.log(node.id)
    // console.log(this.selectNodeData.id)
    if (node.id == this.selectNodeData.id) {
      // this.$set(this.data.nodes, i, this.selectNodeData)
      // this.$set(this.nodes, i, this.selectNodeData)
      this.data.nodes[i].properties = this.temp
      this.nodes[i].properties = this.temp
      break
    }
    i++
  }
  this.dialogFormVisible = false
  this.d3init()
  this.$message({
    message: '更新成功',
    type: 'success'
  })
},
cancelEdit () {
  this.dialogFormVisible = false
}

14)3D图谱展示(2021年5月新增)

在这里插入图片描述

四、个人代码参考

目前已经实现了图谱展示的基本功能,可以根据代码自行拓展,以下是代码地址,水平有限,仅提供参考:

五、总结

目前代码存在的问题:

  • 菜单功能有BUG,隐藏节点后再显示当前节点关联图会出现节点缺失问题
  • 没有展示关系文本
  • 没有箭头,表示关系的指向
  • 右键菜单功能和样式待优化
  • 不支持多关系

到这里其实博客差不多就算完结了,这篇博客和代码的工作量其实比我想象的要大,真的花了很多精力去做这个,前前后后改了好几个版本,现在其实基本功能已经差不多都实现了,再去拓展比自己写要容易很多。


代码改动相比老版本较大,可能会存在一些问题,但是写博客好累,特别是这种一个字一个字打,打完又改了好几遍的。目前短时间内不太想往下做了,如果后面有时间的话,会回来再完善一下代码的功能。


由于最近发现点击Github地址会跳转到第三方盗用代码的下载链接,目前该下载还需要VIP,并且无法举报,所以我把代码上传到CSDN资源上了。如果无法打开Github可以选择从上面的CSDN地址进入下载。