介绍
在企业管理系统中,审核流程是至关重要的环节。传统的静态审批流往往难以适应复杂的业务需求,而基于 Vue 实现的动态审核流程设计器可以提供更高的灵活性和可扩展性。本文将深入解析该设计器的实现方式,包括 流程节点管理、条件分支、缩放功能、数据交互 等核心功能。
实现钉钉审批流程图的效果,只需要使用css display:inline-flex和元素自身的布局就可以实现,无需使用js实现复杂的计算。
源码地址GitHub:github.com/SpanManX/vu…
源码地址Gitee:gitee.com/testcjw/vue…
主要功能
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 的审核流程设计器提供了 灵活、直观、可扩展 的流程管理方式,适用于 企业审批、财务审批、项目审批 等场景。