前言
在审批流项目中,大多是用了bpmnjs 来作为前端流程配置工具的。这种交互模式可以高度贴合BPMN的流程,但是对于用户就不太友好,特别是非技术岗的用户。后来钉钉推出之后,钉钉的审批流程交互,就让人觉得很合适,用起来也很简单。
有鉴于此,我们也着手实现一个类似的效果。
实现方案
交互实现
从效果图可以看到,整个的流程是一个自上而下的单向流程。这里并不是用类似x6的流程图方案实现的,而是将流程分为多个块,每个模块一个div 按照顺序排列。
数据结构
这里最重要的,就是规划好整个流程的数据结构。从效果反推,这里的结构,应该是一个类似链表结构的方式,一个节点会包含上个节点的信息和下个节点的信息。如果是条件分支节点,则还需要包含分支节点信息。如下:
{
type: 'START',
componentName: 'StartActivity',
title: '发起人',
nextNode: {
type: 'APPROVAL',
componentName: 'ApprovalActivity',
title: '审批',
nextNode: {
type: 'ROUTE',
componentName: 'RouteActivity',
title: '路由',
nextNode: {
type: 'CC',
componentName: 'CcActivity',
title: '抄送人',
},
children: [
{
type: 'CONDITION',
componentName: 'ConditionActivity',
title: '条件1',
nextNode: {
type: 'APPROVAL',
componentName: 'ApprovalActivity',
title: '审批人',
}
},
{
type: 'CONDITION',
componentName: 'ConditionActivity',
props: {
defaultCondition: true,
}
}
]
}
}
}
业务抽象
根据业务梳理,可以将节点基本分为,开始,审批,路由,条件,抄送,结束。这里与BPMN不一样,BPMN,是开始,结束,网关,任务。我们需要在服务端自己做好转化,区别就是BPMN的条件一般是在连接线上的,这里我们是独立的一个节点类型,而抄送也要做对于的任务转换。
组件封装
到这里,应该就可以做到效果实现了,但是如果我们要抽象成一个组件,就需要多考虑一点,如何与业务进行解耦。
技术选型
与之前的表单设计器一样,选择@formily/reactive 作为状态管理库,因为要考虑不对UI组件库产生依赖,所以组件还是主要以手写为主。
对象定义
我们定义出一个 ProcessNode对象,来表示节点信息,并且包含节点操作
export type ProcessNodeType = 'START' | 'ROUTE' | 'CONDITION' | 'APPROVAL' | 'CC' | 'END'
export interface IProcessNode {
engine?: ApprovalProcessEngine
isSourceNode?: boolean
id?: string
type: ProcessNodeType
componentName?: string
nextNode?: IProcessNode
children?: IProcessNode[]
title?: string
description?: string
props?: any
}
const ProcessNodes = new Map<string, ProcessNode>()
export class ProcessNode {
engine: ApprovalProcessEngine
isSourceNode: boolean
id: string
type: ProcessNodeType
componentName: string
prevNodeId: string
nextNode: ProcessNode
children: ProcessNode[]
title: string
description: string
props: any
constructor(node: IProcessNode, parentNode?: ProcessNode) {
this.engine = node.engine
this.isSourceNode = node.isSourceNode
this.id = node.id || `Activity_${randomstring.generate({
length: 10,
charset: 'alphabetic'
})}`
this.type = node.type
this.componentName = node.componentName || node.type
this.prevNodeId = parentNode?.id
this.nextNode = null
this.children = []
this.title = node.title
this.description = node.description
this.props = node.props
this.engine = parentNode?.engine
ProcessNodes.set(this.id, this)
if (node) {
this.from(node)
}
this.makeObservable()
}
makeObservable() {
define(this, {
prevNodeId: observable.ref,
title: observable.ref,
description: observable.ref,
nextNode: observable.ref,
children: observable.shallow,
props: observable
})
reaction(() => {
return this.prevNodeId + this.title + this.description + this.nextNode?.id + this.children.length
}, () => {
if (!this.isSourceNode) {
this.engine.handleChange(`${this.id} something changed`)
}
})
observe(this.props, (change) => {
if (!this.isSourceNode) {
this.engine.handleChange(`${this.id} props changed`)
}
})
}
setNextNode(node: ProcessNode) {
if (!node) {
this.nextNode = null
return
}
node.nextNode = this.nextNode
this.nextNode = node
node.prevNodeId = this.id
}
setChildren(nodes: ProcessNode[]) {
if (_.isEmpty(nodes)) {
return
}
_.forEach(nodes, (node) => {
node.prevNodeId = this.id
})
this.children = nodes
}
from(node?: IProcessNode) {
if (!node) return
if (node.id && node.id !== this.id) {
ProcessNodes.delete(this.id)
ProcessNodes.set(node.id, this)
this.id = node.id
}
this.type = node.type
this.componentName = node.componentName || node.type
this.title = node.title
this.description = node.description
this.props = node.props ?? {}
if (node.engine) {
this.engine = node.engine
}
if (node.nextNode) {
this.nextNode = new ProcessNode(node.nextNode, this)
}
if (node.children && node.children.length > 0) {
this.children = node.children?.map((node) => {
return new ProcessNode(node, this)
}) || []
}
}
clone(parentNode?: ProcessNode) {
const node = new ProcessNode({
type: this.type,
componentName: this.componentName,
title: this.title,
description: this.description,
props: _.cloneDeep(this.props),
}, parentNode)
if (this.type == 'ROUTE') {
const conditionResource = GlobalStore.getConditionActivityResource()
const condition1 = conditionResource.node.clone(node)
const conditionDefault = conditionResource.node.clone(node)
conditionDefault.props = _.assign(conditionDefault.props, {defaultCondition: true})
node.setChildren([condition1, conditionDefault])
}
return node
}
remove() {
const parentNode = ProcessNodes.get(this.prevNodeId)
if (this.type == "CONDITION") { //当前节点是条件节点
const linkedIds = this.collectLinkIds()
if (parentNode.children.length > 2) { //当分支超过2个时,只需要删除当前节点,否则,清除整个路由节点
parentNode.children = _.filter(parentNode.children, (conditionNode: any) => {
return conditionNode.id !== this.id
})
} else {
const parentParentNode = ProcessNodes.get(parentNode.prevNodeId) //条件节点的父节点是路由节点,如果清除整个路由,需要找到父节点的父节点
linkedIds.push(parentNode.id);
parentParentNode?.setNextNode(parentNode.nextNode)
}
_.forEach(linkedIds, (id: string) => {
ProcessNodes.delete(id)
})
} else {
parentNode?.setNextNode(this.nextNode)
ProcessNodes.delete(this.id)
}
}
/**
* 添加条件分支
*/
addConditionBranch() {
if (this.type !== "ROUTE") {
return
}
const conditionActivity = GlobalStore.getConditionActivityResource()?.node.clone(this)
if (conditionActivity) {
const newChildren = _.concat(this.children.slice(0, this.children.length - 1), conditionActivity, this.children.slice(this.children.length - 1))
this.setChildren(newChildren)
}
}
/**
* 获取下面链路上的所有节点id
*/
collectLinkIds() {
let ids = []
if (this.nextNode) {
ids.push(this.nextNode.id)
ids = ids.concat(this.nextNode.collectLinkIds())
}
if (this.children && this.children.length > 0) {
this.children.forEach(child => {
ids.push(child.id)
ids = ids.concat(child.collectLinkIds())
})
}
return ids
}
get index() {
if (this.type === 'CONDITION') {
const parentNode = ProcessNodes.get(this.prevNodeId)
if (parentNode) {
return parentNode.children?.indexOf(this) || 0
}
}
return null
}
isFirst() {
if (this.type !== 'CONDITION') {
return false
}
return this.index === 0
}
isLast() {
if (this.type !== 'CONDITION') {
return false
}
const parentNode = ProcessNodes.get(this.prevNodeId)
return this.index === ((parentNode?.children?.length || 0) - 1)
}
}
节点组件封装
对每种类型的节点进行封装,预留出点击扩展,可以进行点击弹窗等操作。我们定义一个组件接口IActivity,所有的节点组件都实现此interface,
export interface IActivity{
nextActivity: React.ReactNode
processNode: ProcessNode
onClick?: (processNode: ProcessNode) => void
}
节点组件,以审批节点为例,首先,写一个基础节点组件
export type ActivityProps = {
children?: React.ReactNode
processNode?: ProcessNode
titleStyle?: React.CSSProperties
titleEditable?: boolean
onChange?: (v: string) => void
closeable?: boolean
onClick?: (processNode: ProcessNode) => void
}
export const Activity: FC<ActivityProps> = observer(({
children,
processNode,
titleStyle,
titleEditable,
onChange,
closeable,
onClick,
}) => {
const inputRef = createRef<any>()
const [editing, setEditing] = useState(false)
const handleClick = () => {
onClick?.(processNode)
}
const handleSave = (value: any) => {
processNode.title = value
setEditing(false)
}
const handleInputBlur = (e: any) => {
if (onChange) {
onChange(e.target.value)
}
handleSave(e.target.value)
}
const handleKeyDown = (e: any) => {
if (e.keyCode == 13) {
handleSave(e.target.value)
}
}
const handleRemove = () => {
processNode?.remove()
}
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [inputRef])
return <ActivityStyled className={`activity`}>
<div className={`activity-box`} onClick={handleClick}>
<div>
<div style={titleStyle} className={classNames('header', {'editable-title': titleEditable})}>
<div>
{editing ? <input ref={inputRef} defaultValue={processNode.title} onBlur={handleInputBlur}
onKeyDown={handleKeyDown}/> :
<span className={classNames({'editable-title': titleEditable})}
onClick={(e) => {
e.stopPropagation();
setEditing(true)
}}>{processNode?.title}</span>}
</div>
{closeable &&
<IconWidget className={`close`} onClick={handleRemove} icon={React.cloneElement(CloseIcon)}/>}
</div>
<div className={classNames('body')}>
<div className={`text`}>{processNode.description || processNode.title}</div>
<span>{React.cloneElement(RightIcon)}</span>
</div>
</div>
</div>
<AddActivityBox processNode={processNode}/>
</ActivityStyled>
})
ApprovalActivity
type ApprovalActivityProps = IActivity
export const ApprovalActivity: FC<ApprovalActivityProps> = ({
processNode,
nextActivity,
onClick
}) => {
return <>
<Activity processNode={processNode} closeable={true} onClick={onClick}/>
{nextActivity}
</>
}
组件使用
为了满足组件的可扩展性,我们对在使用组件的时候,需要对组件进行封装。同样以审批节点为例
export const ApprovalActivity: ActivityFC<IActivity> = ({...props}) => {
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const handleClick = () => {
setOpen(true)
}
const handleSave = () => {
form.validateFields().then((values: any) => {
props.processNode.description = values.description
})
}
return <>
<TdApprovalActivity {...props} onClick={handleClick}/>
<Drawer open={open} onClose={() => {
setOpen(false)
}}
footer={<div>
<Button onClick={handleSave}>确定</Button>
</div>}
>
<Form form={form}>
<Form.Item label={`描述`} name={`description`}>
<Input/>
</Form.Item>
</Form>
</Drawer>
</>
}
ApprovalActivity.Resource = createResource({
icon: 'ApprovalActivityIcon',
type: 'APPROVAL',
componentName: 'ApprovalActivity',
title: '审批人',
addable: true
})
将封装好的组件,传入设计器
<ApprovalProcessDesigner value={processNode} onChange={handleOnChange}>
<StudioPanel>
<ProcessWidget activities={{
StartActivity,
ApprovalActivity,
RouteActivity,
ConditionActivity,
CcActivity
}}/>
</StudioPanel>
</ApprovalProcessDesigner>
效果
注:
当前是react版本的,后续会继续开发vue版本
github地址:https://github.com/trionesdev/triones-approval-process-designer
演示地址:https://trionesdev.github.io/triones-approval-process-designer/