基于 Vue3 的审核流程设计器实现解析

2,351 阅读6分钟

介绍

在企业管理系统中,审核流程是至关重要的环节。传统的静态审批流往往难以适应复杂的业务需求,而基于 Vue 实现的动态审核流程设计器可以提供更高的灵活性和可扩展性。本文将深入解析该设计器的实现方式,包括 流程节点管理、条件分支、缩放功能、数据交互 等核心功能。

实现钉钉审批流程图的效果,只需要使用css display:inline-flex和元素自身的布局就可以实现,无需使用js实现复杂的计算。

源码地址GitHub:github.com/SpanManX/vu…

源码地址Gitee:gitee.com/testcjw/vue…


screenshot-20250214-145155.png

screenshot-20250218-203309.png

主要功能

1. 流程节点管理

审核流程的核心在于 节点的增删改查。在 Vue 组件 workflowNodes.vue 中,每个节点代表一个审批步骤,可以是 审批人、抄送人、条件分支 等。

新增审批人

function addApprover(val, i) {
  val.splice(i + 1, 0, {
    title: '审批人',
    content: '',
    placeholder: '请选择审批人',
    type: 'approver',
    id: generateRandomId(),
  })
}

这个方法会在当前节点后面插入一个审批人节点,保证流程的动态扩展性。

新增抄送人

function addCcTo(val, i) {
  val.splice(i + 1, 0, {
    title: '抄送人',
    content: '',
    placeholder: '请选择抄送人',
    type: 'ccTo',
    id: generateRandomId(),
  })
}

审批过程中,除了审批人,还可能需要通知相关人员,这时抄送人节点可以确保审批进度透明化。


2. 条件分支控制

条件分支 是动态审核流程中的重要一环,允许不同情况触发不同的审批流。

新增条件分支

function addCondition(val) {
  setPlaceholder(val)
  val.push([
    {
      title: '条件',
      content: '其他条件进入此流程',
      type: 'condition',
    }
  ])
}

新增子条件

function add(val, i) {
  let arr = [[
    {
      title: '条件',
      content: '',
      placeholder: '请设置条件',
      type: 'condition',
      id: generateRandomId(),
    }
  ],
    [
      {
        title: '条件',
        content: '其他条件进入此流程',
        // placeholder: '其他条件进入此流程',
        type: 'condition',
        id: generateRandomId(),
        last:true
      }
    ]
  ]

  if (val[i + 1]) {
    let data = val.splice(i + 1, val.length - 1)
    arr[0].push(...data)
    val.splice(i + 1, 0, {children: arr})
  } else {
    val.push({children: arr})
  }
}

该方法会向审批流中添加条件,使审批流可以根据业务需求分支执行。

调整优先级

<span class="priority" v-if="val.type === 'condition'">(优先级{{ i + 1 }})</span>

界面上会显示优先级编号,确保分支按照设定顺序执行。


3. 流程缩放功能

实现缩放

function zoomIn() {
  wheelZoomFunc({ scaleFactor: num.value / 100 + 0.1, isExternalCall: true })
}

function zoomOut() {
  wheelZoomFunc({ scaleFactor: num.value / 100 - 0.1, isExternalCall: true })
}

通过 wheelZoomFunc 方法修改 scaleFactor 实现流程的缩放。

重置缩放

function zoomReset() {
  num.value = 100
  resetImage()
}

当流程图被缩小或放大后,可以一键恢复到默认状态。


4. 交互

点击节点触发事件

const emit = defineEmits(['clickNode'])

function clickNode(val, i) {
  emit('clickNode', val, i)
}

当用户点击某个流程节点时,触发 clickNode 事件,方便外部组件监听用户操作。


组件化架构

该设计器采用 Vue 组件化开发,提高了代码的复用性和维护性。

父组件 workflow.vue

负责整体流程图的容器,管理缩放、数据交互等功能。

<template>
  <div class="workflow-box" ref="boxRef">
    <div class="zoom-wrapper">
      <span class="zoom-out" @click="zoomOut"></span>
      <span class="num">{{ num }}%</span>
      <span class="zoom-in" @click="zoomIn"></span>
      <span class="zoom-reset" @click="zoomReset">重置</span>
      <span class="zoom-reset" @click="getData">获取数据</span>
    </div>
    <div class="workflow-content-wrapper" ref="contentRef">
      <workflowNodes :list="[list]"
                     :parentData="list" @click-node="clickNode"></workflowNodes>
      <div class="end">
        <br/>
        流程结束
      </div>
    </div>
  </div>
</template>
<script setup>
import {onMounted, ref} from "vue";
import workflowNodes from "./components/workflowNodes.vue";
import {resetImage, wheelZoomFunc, zoomInit} from "./utils/zoom.js";

const props = defineProps({
  data: Array,
})

const emit = defineEmits(['clickNode'])

let list = ref(
    [
      {
        title: '发起人',
        placeholder: '请选择发起人',
        content: '所有人',
        type: 'start',
        id: 0
      }
    ]
)

let boxRef = ref(null)
let contentRef = ref(null)
let num = ref(100)

onMounted(() => {
  if (props.data && props.data.length) {
    list.value.push(props.data)
  }
  zoomInit(boxRef, contentRef, (val) => {
    num.value = val
  })
})

function zoomReset() {
  num.value = 100
  resetImage()
}

function zoomIn() {
  wheelZoomFunc({scaleFactor: num.value / 100 + 0.1, isExternalCall: true})
}

function zoomOut() {
  wheelZoomFunc({scaleFactor: num.value / 100 - 0.1, isExternalCall: true})
}

function clickNode(val, i, list) {
  emit('clickNode', val, i, list)
}

function getData() {
  console.log(list.value)

  let num = list.value.findIndex(item => item.type === 'approver')
  let str = ''
  if (num !== -1) {
    str = '流程开始或流程结束阶段,须配置至少一个审批人节点'
  }

  // return num !== -1
}
</script>
<style lang="scss">
html, body, #vue-workflow-diagram {
  margin: 0;
  height: 100%;
}

.workflow-box {
  height: 100%;
  //overflow: auto;
  overflow: hidden;
  text-align: center;
  position: relative;
  padding: 20px;
  box-sizing: border-box;

  .zoom-wrapper {
    position: absolute;
    right: 20px;
    z-index: 100;
    user-select: none;

    .zoom-reset {
      color: #8c939d;
      cursor: pointer;
      margin-right: 15px;
    }

    .zoom-out, .zoom-in {
      display: inline-block;
      cursor: pointer;
      color: #8c939d;
      font-weight: bolder;
      padding: 0 15px;
    }
  }
}

.workflow-content-wrapper {
  height: 100%;
  width: 100%; // 如果要使用滚动条,则去掉此行
  display: inline-flex;
  align-items: center;
  flex-direction: column;
  white-space: nowrap;
}

.end {
  text-align: center;
  color: #8c939d;

  &:before {
    content: '';
    display: inline-block;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: #cccccc;
  }
}

.num {
  color: #8c939d;
}
</style>

子组件 workflowNodes.vue

用于渲染流程节点,包括 审批人、抄送人、条件分支,并提供动态增删改能力。

<template>
  <span class="but" @click="addCondition(list)" v-if="typeof props.index !== 'undefined'">添加条件分支</span>
  <div class="workflow-content-nodes">
    <div class="workflow-node" v-for="(item,i) in list"
         :class="{'node-border':list.length > 1}">
      <template v-for="(val,l) in item">
        <div class="workflow-item" :class="val.type" v-if="val.title">
          <div>
            <div class="title"
                 :class="{'default':val.type === 'start','indigo':val.type === 'ccTo',yellow:val.type === 'approver',purple:val.type === 'condition'}">
              <!--              <span v-if="val.type === 'approver'">📝</span> -->
              {{ val.title }}
              <span class="priority" v-if="val.type === 'condition'">(优先级{{ i + 1 }})</span>
              <span class="close" @click="removeNode(list,i,l,parentData,val)" v-if="val.type !== 'start'">×</span>
            </div>
            <div class="content" @click="clickNode(val,i,`优先级${i + 1}`)">
              <span class="left-arrow" v-if="i && val.type === 'condition'" @click.stop="moveToLeft(list,i,l)"></span>
              <span v-if="val.content && val.content !== ''">{{ val.content }}</span>
              <span class="placeholder" v-else>{{ val.placeholder }}</span>
              <span class="right-arrow" v-if="val.type === 'condition' && i !== list.length - 1"
                    @click.stop="moveToRight(list,i,l)"></span>
            </div>
          </div>
        </div>
        <div class="add-box"
             :class="{'last-add-box':l === item.length - 1 && (!val.children || !val.children.length), 'short-add-box':item[l + 1] && item[l + 1].children}"
             v-if="val.title">
          <div class="popover">
            <span class="add-item">+</span>
            <div class="tools-list">
              <div>
                <div>
                  <span class="tools-item" @click="addApprover(item,l)">审批人</span>
                </div>
                <div>
                  <span class="tools-item" @click="addCcTo(item,l)">抄送人</span>
                </div>
                <div>
                  <span class="tools-item" @click="add(item,l)">添加条件分支</span>
                </div>
              </div>
            </div>
          </div>
        </div>
        <workflowNodes v-if="val.children" :list="val.children" :index="l" :parent-data="item"
                       @click-node="clickNode"></workflowNodes>
      </template>
    </div>
  </div>
  <div class="workflow-bottom-nodes" v-if="typeof props.index !== 'undefined'">
    <div class="add-box">
      <div class="popover">
        <span class="add-item">+</span>

        <div class="tools-list">
          <div>
            <div>
              <span class="tools-item" @click="addApprover(parentData,index)">审批人</span>
            </div>
            <div>
              <span class="tools-item" @click="addCcTo(parentData,index)">抄送人</span>
            </div>
            <div>
              <span class="tools-item" @click="add(parentData,index)">添加条件分支</span>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import workflowNodes from "./workflowNodes.vue";
import {watch} from "vue";

const props = defineProps({
  list: Array,
  index: Number,
  depth: Number,
  parentData: [Object, Array],
  testData: Object
})

const emit = defineEmits(['clickNode'])

function generateRandomId() {
  const timestamp = new Date().getTime(); // 获取当前时间戳
  const randomNum = Math.floor(Math.random() * 1000); // 生成一个0-999之间的随机数
  return `${timestamp}${randomNum}`; // 返回拼接后的ID字符串
}

/**
 * 插入审批人
 */
function addApprover(val, i) {
  val.splice(i + 1, 0, {
    title: '审批人',
    content: '',
    placeholder: '请选择审批人',
    type: 'approver',
    id: generateRandomId(),
  })
}

/**
 * 插入抄送人
 */
function addCcTo(val, i) {
  val.splice(i + 1, 0, {
    title: '抄送人',
    content: '',
    placeholder: '请选择抄送人',
    type: 'ccTo',
    id: generateRandomId(),
  })
}

function setPlaceholder(val) {
  for (let item of val) {
    if (item[0].last) {
      delete item[0].last
    }
    if (item[0].content === '其他条件进入此流程') {
      item[0].placeholder = '请设置条件'
      item[0].content = ''
    }
  }
}

function setContent(nodeList) {
  if (nodeList[nodeList.length - 1][0].placeholder === '请设置条件' && nodeList[nodeList.length - 1][0].content === '') {
    nodeList[nodeList.length - 1][0].content = '其他条件进入此流程'
    nodeList[nodeList.length - 1][0].placeholder = ''
  }
  nodeList[nodeList.length - 1][0].last = true
}

/**
 * 添加条件分支
 *
 * @param val 需要操作的数组
 * @param i 插入子项的索引位置
 */
function add(val, i) {
  let arr = [[
    {
      title: '条件',
      content: '',
      placeholder: '请设置条件',
      type: 'condition',
      id: generateRandomId(),
    }
  ],
    [
      {
        title: '条件',
        content: '其他条件进入此流程',
        // placeholder: '其他条件进入此流程',
        type: 'condition',
        id: generateRandomId(),
        last:true
      }
    ]
  ]

  if (val[i + 1]) {
    let data = val.splice(i + 1, val.length - 1)
    arr[0].push(...data)
    val.splice(i + 1, 0, {children: arr})
  } else {
    val.push({children: arr})
  }
}

/**
 * 添加条件分支
 *
 * @param val 要添加条件的数组
 */
function addCondition(val) {
  setPlaceholder(val)
  val.push([
    {
      title: `条件`,
      content: '其他条件进入此流程',
      // placeholder: '',
      type: 'condition',
    }
  ])
}

function moveToLeft(nodeList, i, l) {
  let temp = nodeList[i];
  nodeList[i] = nodeList[i - 1];
  nodeList[i - 1] = temp;

  setPlaceholder(nodeList)
  setContent(nodeList)
}

function moveToRight(nodeList, i, l) {
  let temp = nodeList[i];
  nodeList[i] = nodeList[i + 1];
  nodeList[i + 1] = temp;

  setPlaceholder(nodeList)
  setContent(nodeList)
}

/**
 * 从树形中删除指定节点
 *
 * @param nodeList 需要删除节点的列表
 * @param nodeIndex 节点索引
 * @param childIndex 子节点索引
 * @param parentList 父级数据
 * @param currentNode 当前节点
 */
function removeNode(nodeList, nodeIndex, childIndex, parentList, currentNode) {
  console.log('currentNode:', currentNode);
  console.log('parentList:', parentList, nodeList, nodeIndex, childIndex);

  // 如果是“条件”节点
  if (currentNode.type === 'condition') {
    if (nodeList.length === 2) {

      let parentIndex = -1

      // 查找父级索引
      parentList.map((item, i) => {
        if (item.children) {
          item.children.map(val => {
            if (val[0].id === currentNode.id) {
              parentIndex = i
            }
          })
        }
      })

      if (parentIndex === -1) return;

      let parentChildren = parentList[parentIndex];

      // 删除当前节点
      nodeList.splice(nodeIndex, 1);

      // 删除“条件”节点
      parentChildren.children[0].splice(0, 1);

      // 获取 children 剩下的所有数据
      let childrenData = parentChildren.children[0];

      if (childrenData.length) {
        // 删除父级原 children 数据,并展开 childrenData 插入
        parentList.splice(parentIndex, 1, ...childrenData);
      } else {
        // 直接删除整个父级 children
        parentList.splice(parentIndex, 1);
      }
    } else {
      // 如果 `nodeList` 中仍有多个元素,则正常删除
      nodeList.splice(nodeIndex, 1);
    }
  } else {  // 普通节点删除
    nodeList[nodeIndex].splice(childIndex, 1);
  }

  // 设置条件文字提示,如果最后一个条件节点是“其他条件进入此流程”则不显示 placeholder
  if (nodeList[nodeList.length - 1][0]) {
    setPlaceholder(nodeList)
    setContent(nodeList)
  }
}

function clickNode(val, i) {
  emit('clickNode', val, i, props.list)
}
</script>
<style scoped lang="scss">
$line-color: #cccccc;

.default {
  background: #6e8dd5;
}

.purple {
  background: #8e70c7;
}

.indigo {
  background: #5c6bc0;
}

.yellow {
  background: #f5a623;
}

.blue {
  background: #2385c8;
}

.workflow-bottom-nodes {
  text-align: center;
  flex: 1;

  .add-box {
    height: 100%;
  }
}

.priority {
  color: #cccccc;
}

.tools-item {
  cursor: pointer;
  color: #409eff;
}

.but {
  font-size: 12px;
  padding: 5px 10px;
  border-radius: 15px;
  color: #3296fa;
  background: #fff;
  cursor: pointer;
  position: relative;
  top: 14px;
  display: inline-flex;
  justify-content: center;
  z-index: 500;
  box-shadow: 0 0 10px rgba(0, 0, 0, .2);

  &:before {
    content: '';
    position: absolute;
    top: -15px;
    width: 2px;
    height: 14px;
    background: $line-color;
  }
}

.workflow-content-nodes {
  position: relative;

  .workflow-item {
    box-sizing: border-box;
    position: relative;
    padding: 0 50px 0;
  }

  .condition {
    padding-top: 50px;

    &:before {
      content: '';
      position: absolute;
      top: 0;
      left: calc(50% - 1px);
      width: 2px;
      height: 100%;
      background: $line-color;
      z-index: -1;
    }
  }

  .workflow-node {
    display: inline-flex;
    flex-direction: column;
    align-items: center;
    position: relative;
    height: 100%;
    box-sizing: border-box;
  }

  .node-border {
    &:before, &:after {
      content: '';
      position: absolute;
      width: 100%;
      height: 2px;
      background: #cccccc;
    }

    &:before {
      top: 0;
    }

    &:last-child:before, &:last-child:after {
      width: calc(100% / 2);
      left: 0;
    }

    &:first-child:before, &:first-child:after {
      width: calc(100% / 2);
      right: 0;
    }

    &:after {
      bottom: 0;
    }
  }
}


//全局
.workflow-item {
  display: inline-block;

  & > div {
    width: 220px;
    border-radius: 5px;
    box-shadow: 0 0 10px rgba(0, 0, 0, .2);
    position: relative;

    &:after {
      content: '';
      display: none;
      pointer-events: none;
      box-sizing: border-box;
      border-radius: 5px;
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: transparent;
    }
  }

  & > div:hover {
    &:after {
      display: block;
      border: 1px solid #409eff;
    }
  }

  .title {
    position: relative;
    padding-left: 15px;
    padding-right: 15px;
    height: 24px;
    line-height: 24px;
    font-size: 12px;
    text-align: left;
    border-radius: 4px 4px 0 0;
    color: #fff;

    .close {
      cursor: pointer;
      float: right;
      font-size: 16px;
    }
  }

  .content {
    position: relative;
    text-align: left;
    border-bottom-right-radius: 5px;
    border-bottom-left-radius: 5px;
    background: #fff;
    cursor: pointer;
    color: #000000;
    font-size: 14px;
    padding: 15px 15px 15px 20px;

    &:hover {
      .left-arrow, .right-arrow {
        display: inline-block;
      }
    }

    .placeholder {
      color: $line-color;
    }

    .left-arrow, .right-arrow {
      position: absolute;
      color: #5c6bc0;
      display: none;
    }

    .left-arrow {
      left: 2px;
    }

    .right-arrow {
      right: 2px;
    }
  }
}

.last-add-box {
  flex: 1;
}

.add-box:not(.short-add-box) {
  padding: 50px 0;
}

.short-add-box {
  padding-top: 50px;
  padding-bottom: 36px;
}

.add-box {
  position: relative;
  display: flex;
  justify-content: center;
  box-sizing: border-box;

  &:before {
    content: '';
    position: absolute;
    top: 0;
    left: calc(50% - 1px);
    width: 2px;
    height: 100%;
    background: $line-color;
    z-index: -1;
  }

  .add-item {
    display: inline-block;
    cursor: pointer;
    width: 30px;
    height: 30px;
    text-align: center;
    font-size: 21px;
    font-weight: bold;
    background: #2385c8;
    color: #fff;
    border-radius: 50%;
  }
}

.popover {
  position: relative;
  z-index: 1000;

  .tools-list {
    padding-left: 10px;
    display: none;
    position: absolute;
    top: -23px;
    left: 30px;

    &:before {
      content: '';
      position: absolute;
      top: calc(50% - 6px);
      left: 4px;
      width: 10px;
      height: 10px;
      background: #ffffff;
      transform: rotate(45deg);
      border-left: 1px solid $line-color;
      border-bottom: 1px solid $line-color;
    }

    & > div {
      text-align: center;
      background: #ffffff;
      padding: 5px 15px;
      border: 1px solid $line-color;
      border-radius: 4px;
      box-shadow: 0 0 10px rgba(0, 0, 0, .2);
    }
  }

  &:hover .tools-list {
    display: block;
  }
}
</style>

递归渲染 确保了嵌套审批流的可视化支持。


结论

这款基于 Vue 的审核流程设计器提供了 灵活、直观、可扩展 的流程管理方式,适用于 企业审批、财务审批、项目审批 等场景。