背景

最近做了一个在线考试系统,其中有个题型是流程图类型的,考试端大概效果就是把答案选项拖拽到流程图框中,要求能拖动左侧选项,但是不能编辑右侧流程框和连接线。

需要满足简单流程图编辑,最主要是要满足在后台中流程图题型动态配置性,在考试端流程图的展示和答案选项的拖拽。

最开始的想法是把流程图当作背景,然后通过 CSS 相对定位一个个调整元素位置,使用拖拽工具给元素绑定拖拽事件,效果也还能凑合,但是如果流程图类型太多,难以维护,并且不支持后台动态配置。

如果是流程图的拖拽、编辑,基本大多数流程图工具都支持,但是这个拖拽选项到流程图框上填充内容的小小需求没有找到解决方案,那就只能自己解决了,选择的是vue-super-flow这个工具,因为只需要简单的流程图编辑能力,并且这个工具可以在引用时灵活可配置。

觉得挺有意思,记录一下考试端实现过程
效果图

参考

vue-super-flow
vue-super-flow的使用

实现过程

主要讲一下这个 flowChart 组件,上代码

// flowChart.vue
<template>
  <div class="f-super-flow">
    <div class="f-node-container">
      <div
        class="f-node-item"
        :class="!item.label ? 'f-node-item-empty' : ''"
        v-for="(item, index) in nodeItemList"
        :key="index"
        @mousedown="evt => nodeItemMouseDown(evt, item.value, index)"
      >
        {{ item.label }}
      </div>
    </div>
    <div class="f-flow-container" ref="flowContainer">
      <super-flow
        ref="superFlow"
        :draggable="false"
        :linkAddable="false"
        :linkEditable="false"
        :hasMarkLine="false"
        :link-desc="linkDesc"
        :node-list="nodeList"
        :link-list="linkList"
      >
        <template v-slot:node="{ meta }">
          <div 
            v-if="meta.name" 
            class="f-node-del" 
            :style="meta.type == 'judge' ? 'top: 10%;' : ''" 
            @mouseup="evt => nodeMouseUp(evt, meta)"
          >x</div>
          <div 
            class="f-flow-node"
            :class="meta.type? `f-flow-node-${meta.type}`: ''"
          >
            <div class="f-node-content" :title="meta.name">{{ meta.name }}</div>
          </div>
        </template>
      </super-flow>
    </div>
  </div>
</template>

<script>
/**
 * 流程图题
 */
import SuperFlow from "vue-super-flow";
import "vue-super-flow/lib/index.css";

export default {
  name: "flow-chart",
  components: {
    SuperFlow,
  },
  props: {
    optionList: {
      type: Array,
      default: () => [],
    },
    flowChartNode: {
      type: Array,
      default: () => [],
    },
    flowChartLink: {
      type: Array,
      default: () => [],
    }
  },
  data() {
    return {
      // flowChart 节点
      nodeList: [],

      // 线条
      linkList: [],

      // 左侧列表
      nodeItemList: [],

      dragConf: {
        isDown: false,
        isMove: false,
        offsetTop: 0,
        offsetLeft: 0,
        clientX: 0,
        clientY: 0,
        ele: null,
        info: null,
      },

      resetNodeItem: {
        label: '',
        value: {
          meta: {
            label: '',
            name: '',
            type: ''
          }
        }
      }
    }
  },
  computed: {
    
  },
  mounted() {
	// 测试数据
	this.nodeItemList = [
      {
        label: 1,
        value: {
          meta: {
            label: 1,
            name: 1
          }
        }
      },
      {
        label: 3,
        value: {
          meta: {
            label: 3,
            name: 3
          }
        }
      },
      {
        label: 2,
        value: {
          meta: {
            label: 2,
            name: 2
          }
        }
      },
      {
        label: 5,
        value: {
          meta: {
            label: 5,
            name: 5
          }
        }
      },
      {
        label: 4,
        value: {
          meta: {
            label: 4,
            name: 4
          }
        }
      }
    ]
    this.nodeList = [
      {
        id: "N1",
        width: 180,
        height: 50,
        coordinate: [100, 32],
        meta: {
          label: "",
          name: "",
          type: "startAndEnd",
        },
      },
      {
        id: "N2",
        width: 180,
        height: 50,
        coordinate: [100, 139],
        meta: {
          label: "",
          name: "",
          type: "process",
        },
      },
      {
        id: "N3",
        width: 180,
        height: 180,
        coordinate: [100, 236],
        meta: {
          label: "",
          name: "",
          type: "judge",
        },
      },
      {
        id: "N4",
        width: 180,
        height: 50,
        coordinate: [100, 500],
        meta: {
          label: "",
          name: "",
          type: "process",
        },
      },
      {
        id: "N5",
        width: 180,
        height: 50,
        coordinate: [360, 300],
        meta: {
          label: "",
          name: "",
          type: "process",
        },
      },
      {
        id: "N6",
        width: 180,
        height: 50,
        coordinate: [100, 596],
        meta: {
          label: "",
          name: "",
          type: "startAndEnd",
        },
      },
    ]
    this.linkList = [
      {
        id: "linkUkwVhocoTp08AbLQ",
        startId: "N1",
        endId: "N2",
        startAt: [60, 40],
        endAt: [100, 0],
        meta: null,
      },
      {
        id: "linkS27wPzJ1Z7plttsR",
        startId: "N3",
        endId: "N5",
        startAt: [168, 84],
        endAt: [0, 20],
        meta: { desc: "NO" },
      },
      {
        id: "linka8cGGQAPQTtXuYID",
        startId: "N5",
        endId: "N2",
        startAt: [100, 0],
        endAt: [200, 20],
        meta: null,
      },
      {
        id: "linkdNLEL6EcIVijSQx4",
        startId: "N2",
        endId: "N3",
        startAt: [100, 40],
        endAt: [84, 0],
        meta: null,
      },
      {
        id: "link3heD5DMOJbmxcLHu",
        startId: "N3",
        endId: "N4",
        startAt: [84, 168],
        endAt: [100, 0],
        meta: { desc: "YES" },
      },
      {
        id: "linkVpTa6NUNNcG2NKeY",
        startId: "N4",
        endId: "N6",
        startAt: [100, 40],
        endAt: [60, 0],
        meta: null,
      },
    ]

    document.addEventListener("mousemove", this.docMousemove);
    document.addEventListener("mouseup", this.docMouseup);
    this.$once("hook:beforeDestroy", () => {
      document.removeEventListener("mousemove", this.docMousemove);
      document.removeEventListener("mouseup", this.docMouseup);
    });
  },
  methods: {
    linkDesc(link) {
      return link.meta ? link.meta.desc : "";
    },
    docMousemove({ clientX, clientY }) {
      const conf = this.dragConf;
      if (conf.isMove) {
        conf.ele.style.top = clientY - conf.offsetTop + "px";
        conf.ele.style.left = clientX - conf.offsetLeft + "px";
      } else if (conf.isDown) {
        // 鼠标移动量大于 5 时 移动状态生效
        conf.isMove =
          Math.abs(clientX - conf.clientX) > 5 ||
          Math.abs(clientY - conf.clientY) > 5;
      }
    },
    docMouseup({ clientX, clientY }) {
      const conf = this.dragConf;
      conf.isDown = false;

      if (conf.isMove) {
        const { top, right, bottom, left } = this.$refs.flowContainer.getBoundingClientRect();

        // 判断鼠标是否进入 flow container
        if (
          clientX > left &&
          clientX < right &&
          clientY > top &&
          clientY < bottom
        ) {
          // 获取拖动元素左上角相对 super flow 区域原点坐标
          const coordinate = this.$refs.superFlow.getMouseCoordinate( clientX, clientY );
          for(let i = 0;i<this.nodeList.length;i++) {
            let nodeData = this.nodeList[i]
            let xStartPostion = nodeData.coordinate[0]
            let yStartPostion = nodeData.coordinate[1] * 0.5
            let xEndPostion = +nodeData.width + nodeData.coordinate[0]
            let yEndPostion = +nodeData.height + nodeData.coordinate[1]
            
            if((coordinate[0] >= xStartPostion  && coordinate[0] <= xEndPostion) && (coordinate[1] >= yStartPostion  && coordinate[1] <= yEndPostion)) {
              nodeData.meta.label = conf.info.meta.label
              nodeData.meta.name = conf.info.meta.name
              nodeData.meta.index = conf.info.meta.index
              this.$set(this.nodeList,i,nodeData)
              this.nodeItemList[conf.info.meta.index] = this.resetNodeItem
              break; 
            }
          }
        }
        conf.isMove = false;
      }
      if (conf.ele) {
        conf.ele.remove();
        conf.ele = null;
      }
    },
    nodeItemMouseDown(evt, infoFun, index) {
      const { clientX, clientY, currentTarget } = evt;

      const { top, left } = evt.currentTarget.getBoundingClientRect();

      const conf = this.dragConf;
      const ele = currentTarget.cloneNode(true);

      infoFun.meta.index = index
      Object.assign(this.dragConf, {
        offsetLeft: clientX - left,
        offsetTop: clientY - top,
        clientX: clientX,
        clientY: clientY,
        info: infoFun,
        ele,
        isDown: true,
      });

      ele.style.position = "fixed";
      ele.style.margin = "0";
      ele.style.top = clientY - conf.offsetTop + "px";
      ele.style.left = clientX - conf.offsetLeft + "px";

      this.$el.appendChild(this.dragConf.ele);
    },
    
    nodeMouseUp(evt, meta) {
      evt.preventDefault()
      const metaData = { ...meta }
      let selectIndex = this.nodeList.findIndex(item => item.meta.name === meta.name)
      let nodeItem = this.nodeList[selectIndex]
      nodeItem.meta.label = ""
      nodeItem.meta.name = ""
      
      this.$set(this.nodeList, selectIndex, nodeItem)
      this.$set(this.nodeItemList, metaData.index, {
        label: metaData.label,
        value: {
          meta: {
            label: metaData.label,
            name: metaData.name,
          }
        }
      })
    },

    getNodeList() {
      let list = this.$refs.superFlow.toJSON().nodeList
      let arr = []
      list.map(item => {
        arr.push({
          id: item.id,
          name: item.meta.name
        })
      })
      // console.log(arr);
      return arr
    },
  },
};

</script>

<style lang="scss" scoped>
.f-super-flow {
  position: relative;
  display: flex;
  background: #f5f5f5;
  height: 100%;

  .f-node-container {
    width: 20%;
    text-align: center;
    background-color: #ffffff;
    overflow: auto;

    .f-node-item {
      margin: 20px 20%;
      padding: 6px 0;
      border: 2px solid #333;
      border-radius: 6px;
      min-height: 40px;
      font-size: 18px;
      color: #333;
      box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.3);
      cursor: pointer;
      user-select: none; // 防止鼠标左键拖动选中页面的文字
      &:hover {
        box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.4);
      }

      &.f-node-item-empty {
        border: 1px solid rgba(118, 118, 118, 0.3);
        pointer-events: none;
        background: rgba(239, 239, 239, 0.3);
        cursor: no-drop;
      }
    }
  }
  .f-flow-container {
    flex: 1;
    // padding: 10px;

    .super-flow {
      overflow: auto;
    }
  }
  
  /deep/.super-flow__node {
    display: table;
    border: none;
    background: none;
    box-shadow: none;

    &:hover {
      .f-node-del {
        display: block;
      }
    }

    .f-node-del {
      display: none;
      position: absolute;
      right: -10px;
      top: -10px;
      width: 26px;
      height: 26px;
      line-height: 26px;
      font-size: 20px;
      text-align: center;
      color: #666;
      border-radius: 50%;
      background: rgba(150,150,150,.5);
      z-index: 1;
    }

    .f-flow-node {
      position: relative;
      display: table-cell;
      width: 100%;
      height: 100%;
      vertical-align: middle;
      font-size: 16px;
      color: #333;
      font-weight: bold;
      box-sizing: border-box;
      background: #fff;

      .f-node-content {
        text-align: center;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: pre-wrap;
      }
    }

    .f-flow-node-startAndEnd {
      border: 2px solid rgba(39, 107, 232, .6);
      border-radius: 22px;
    }

    .f-flow-node-process {
      border: 2px solid rgba(39, 107, 232, .6);
    }

    .f-flow-node-judge {
      padding: 0 40px;
      border: 2px solid rgba(39, 107, 232, .6);
      transform: rotate(45deg) scale(.7);
      .f-node-content {
        transform: rotate(-45deg) scale(1.4);
      }
    }
    
    .f-flow-node-quote {
      padding: 0 40px;
      border: 2px solid rgba(39, 107, 232, .6);
      border-radius: 50%;
    }
  }
}
</style>

代码解析

因为不需要对流程图进行操作,先流程图的编辑功能相关配置设置为falsevue-super-flow Demo 里有绑定鼠标事件,不过不支持拖拽到流程图框上面替换,利用已有的鼠标事件做修改。

自定义流程图框型样式

<template v-slot:node="{ meta }">
  <div 
    v-if="meta.name" 
    class="f-node-del" 
    :style="meta.type == 'judge' ? 'top: 10%;' : ''" 
    @mouseup="evt => nodeMouseUp(evt, meta)"
  >x</div>
  <div 
    class="f-flow-node"
    :class="meta.type? `f-flow-node-${meta.type}`: ''"
  >
    <div class="f-node-content" :title="meta.name">{{ meta.name }}</div>
  </div>
</template>

左侧选项拖拽到流程图上填充

鼠标是否进入 flow container 之后,循环当前节点列表,判断当前鼠标位置是否在流程图框的范围内,在范围内时设置nodeList节点内容,并重置nodeItemList选项禁止选中拖拽

// 获取当前鼠标在 graph 坐标系的坐标
const coordinate = this.$refs.superFlow.getMouseCoordinate( clientX, clientY );
for(let i = 0;i<this.nodeList.length;i++) {
  let nodeData = this.nodeList[i]
  let xStartPostion = nodeData.coordinate[0]
  let yStartPostion = nodeData.coordinate[1] * 0.5
  let xEndPostion = +nodeData.width + nodeData.coordinate[0]
  let yEndPostion = +nodeData.height + nodeData.coordinate[1]
  
  if((coordinate[0] >= xStartPostion  && coordinate[0] <= xEndPostion) && (coordinate[1] >= yStartPostion  && coordinate[1] <= yEndPostion)) {
    nodeData.meta.label = conf.info.meta.label
    nodeData.meta.name = conf.info.meta.name
    nodeData.meta.index = conf.info.meta.index
    this.$set(this.nodeList,i,nodeData)
    this.nodeItemList[conf.info.meta.index] = this.resetNodeItem
    break; 
  }
}

删除流程图框中内容

拖拽到流程图框中的内容支持删除,删除操作绑定mouseup事件上,尝试过绑定click事件无效。

@mouseup="evt => nodeMouseUp(evt, meta)"
// 删除事件
nodeMouseUp(evt, meta) {
  evt.preventDefault()
  const metaData = { ...meta }
  let selectIndex = this.nodeList.findIndex(item => item.meta.name === meta.name)
  let nodeItem = this.nodeList[selectIndex]
  nodeItem.meta.label = ""
  nodeItem.meta.name = ""
  
  this.$set(this.nodeList, selectIndex, nodeItem)
  this.$set(this.nodeItemList, metaData.index, {
    label: metaData.label,
    value: {
      meta: {
        label: metaData.label,
        name: metaData.name,
      }
    }
  })
},

记录

Logo

前往低代码交流专区

更多推荐