最近在做系统中的流程管理功能,对比了各大流程设计器,很多都要结合脚本进行实现。作为一名追求完美用户体验的全栈设计师,这种方式一定要不得。其实对于我们系统来说也不必要那么复杂的操作体验。寻觅一番,发现钉钉的请假流程体验很好。

界面简单明确,适合固定流程类型的系统。于是开始对其进行解剖,看看他是如何去管控流程的,并联系我这边的系统进行移植和优化。

功能设计分析

钉钉这个流程设计器将审批流程的节点分为三类:

  • 审批节点
  • 条件分支
  • 抄送

其中“审批节点”的审批类型分为:指定成员、主管、角色、发起人自选、发起人自己、表单里的联系人、连续多级主管、……,和这些类型的更细分的属性操作。

然后“条件分支”是对“请假”(这个只有请假流程设计)内一些指定字段的大于、等于、小于、大于等于……的判断然后根据判断结果输出多个分支,后面再跟上审批节点。

抄送”这里其实可以融合到任何一个节点里面去,但钉钉团队选择分出来也是很好的选择,操作人员理解起来比较直观。我的系统不是基于IM基础的所以抄送功能就变得不太重要了,但确确实我在做做用户调研时拿出钉钉的抄送功能让用户对比,很多用户反馈这个功能很好(他们可能没用过邮件)。

钉钉的流程设计器还做了容错判断,比如发布流程时需要判断各节点内容是否完整、合法。问题就出在这里,我们后文会讲到。

UI实现

钉钉这个流程设计器采用的是DIV+CSS方式实现,可以说这位老兄的css功底很了得。听说阿里的技术选型是React,所以这里应该是一个组件递归,数据结构也就是类似树结构,像下面这样:

把这个树结构转成流程形状有很多种办法。

但是要编辑这个就和传统的树结构编辑有点区别了。比如当用户在两个节点之间增加了一个节点,那么下面的节点的父级就变成了这个新增的节点,上面的下级也变成了这个新增的节点。

也有一种办法,就是服务端返回的就是一个展开的树数据,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"1":{
"name":"XXXX"
"pid":"2",
"type":"approver"
……
},
"2":{
"name":"XXXX"
"pid":"1",
"type":"approver"
……
}
……
}

然后就可以写一个操作的构造函数(不考虑DOM生成),大致结构和设计思路可以看看下面的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class TreeOperate {
constructor(arguments){TODO}
/*
* id生成,根据时间戳做一些处理生成唯一id
*/
generateId(){}
/*
* 将原始数据组合成树结构,每次对节点操作都要执行一次重组,也就是说操作的是上面的展开的树数据,而不是组装的树数据。
*/
compose(){}
/*
* 校验流程的合法性
*/
werification(){}
/*
* 插入节点,有三种节点类型(分支、审批、抄送)
* 还需要判断插入节点是否存在子节点,如果存在则将子节点pid改为当前节点id
*/
addNodes(){}
/*
* 移除节点,同样判断移除节点是否有子节点,有的话先将自己移除,在使用addNodes()将自己的子节点放到自己的父节点内
*/
removeNodes(){}
/*
* 修改节点,钉钉的流程设计器不支持拖动改变父节点和节点内容修改,那么修改节点也仅仅是节点的内容进行修改
*/
editNodes(){}
}

缺点

像钉钉这样在编辑完成后再提交整体数据结构,那么各节点ID生成的重担就放到了前端开发人员的肩上,而且这样提交后的数据,后端必须清空现有流程节点再重新解析新数据填入表中,费事费力(如果不清空就要去重删除,那样更麻烦),这样也不太安全的。

我就在想钉钉这样做是为啥呢??实在没有道理,虽然钉钉细节上的东西很多,但不至于要这样做,难道仅仅是为了全盘校验?!如果是这样,那下面这样的流程通过合法性校验就有点匪夷所思了,请看下图:

这样一个明显不合法的流程被认定为合法。试想没有审批,条件分支以后直接结束流程,这? @钉钉前端工程师

本土化改进版

设计思路

发现了这些缺点后,我这边的系统采用的是vue进行前端编写,看下实现效果:

因为没有抄送,所以就只有两种节点类型:“审批节点”、“条件分支”。
看看审批节点的选项,理解一下我是怎样的思路:

因为在权限那里对发起人做了限制,所以就不需要钉钉那样的在流程内限制发起人,所以如图所示,选择审批人为上级部门主管,就只需要给节点取个名字就可以了。服务端会根据树去一级一级找(有一个流程进度表,专门记录流程进展),相关审批人就会收到审批请求,操作后服务端再设置相关状态值,前端根据状态值去判断显示。为了安全起见服务端还会做状态检验和URL拦截。

重点来了

这里点击保存按钮就会直接提交节点内容到服务端,服务端会自动重新组装返回数据结构给我,那我只需要再渲染一次就OK了!
删除也一样,我只把当前节点ID传给后端就可以了,然后后端返回删除后的流程节点数据给我,再重新渲染就是了。用一张图来对比一下这两种方法:
图层 4sss.png

问题

这种逐个操作法,总大的问题就是不能验证整体流程的合法性,很可能用户因此提交一个错误的流程,导致业务进程受阻,但通过对比正反向用例,发现担心是多余的,因为只有一种上面提到的“条件节点后不能没有审批”的这种合法性校验,这种逐个校验逐个提交的方式可以handle所有用例(这种合法性校验顶顶虽然使用了全局提交,但也没有做)。

终归问题还是要解决的,万一用户弄了这么一个流程怎么办?很简单,新流程提交的时候如果流程不合法就为用户返回“流程设计出现问题,请联系流程设计相关人员修改!”并阻断流程提交。

实现

看看文件结构

1
2
|-workFlow.vue
|-node.vue

利用组件递归方法,进行流程树渲染。下面重点看看node.vue的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    <template>
<div class="work-flow-item">
<!-- 判断流程是否是分支,是的话循环分支内部节点 -->
<div v-if="data.type == 'branch'" class="work-flow-conditionNodes c-flex c-flex-center">
……
<Item v-for="item in data.conditionNodes" :type="type" :config="config" :key="item.id" :data="item"/>
</div>
<!-- item 主体开始 -->
<div class="c-flex c-flex-center card-warp" v-if="data.type != 'end' && data.type != 'branch'">
<el-card :class="data.type">
<span slot="header">{{data.name}}</span>
<!-- 判断流程是否为条件,是的话按照条件渲染 -->
<div v-if="data.type == 'condition'" class="sys-flow-content">
<font v-if="data.params && config[type]">{{config[type].long}}:</font>
<font v-if="!data.params">其他条件进入此流程</font>
<span v-for="(val,key) in data.condition" :key="key">
{{equation[key]}}{{val}}
</span>
</div>
<div v-if="data.type == 'approver'" class="sys-flow-content">
<font v-if="approver[data.approver.type]">审批人:</font>
{{approver[data.approver.type]}}
<span v-if="data.approver.name">{{data.approver.name}}</span>
</div>
</el-card>
……
</div>
<!-- item 主体结束 -->
<!-- 判断流程是否存在nextNode,如果有则去递归,没有就结束 -->
<Item v-if="data.nextNode" :data="data.nextNode" :type="type" :config="config"/>
</div>
</template>

重点的样式

盒模型如下所示:
图层 4sss.png

连线采用beforeafter伪类,优点是控制灵活。

看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
.work-flow-item{
.el-card{
overflow: visible;
position: relative;
&::after{
content: "";
position: absolute;
width: 2px;
height: @height;
background-color: @color;
bottom: -@height;
left: 99px;
}
&::before{
content: "";
position: absolute;
width: 2px;
height: @height;
background-color: @color;
top: -@height;
left: 99px;
}
}
.work-flow-conditionNodes{
background-color: #f5f6f8;
&::after{
content: "";
position: absolute;
width: calc(~"100% - 200px");
height: 2px;
background-color: @color;
bottom: 0;
left:99px;
}
&>.work-flow-item{
position: relative;
&::before{
content: "";
position: absolute;
width: 2px;
height: 100%;
background-color: @color;
top: 0;
left:calc(~"50% - 1px");
}
}
&::before{
content: "";
position: absolute;
width: calc(~'100% - 200px');
height: 2px;
background-color: @color;
top: 0;
left: 99px;
}
}
}

OK 本文结束~