文章
问答
冒泡
这是不是你们喜欢的钉钉审批流设计器

前言

在审批流项目中,大多是用了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/

react
审批流设计器

关于作者

落雁沙
非典型码农
获得点赞
文章被阅读