实习做这个功能的时候没头脑思路,现在学会了就记录一下完成的过程。下面是效果图
整体采用链表的数据结构搭建,每一个节点就是如图所示的内容,每一个节点包含自身的描述信息,包含子项child的信息,整体渲染方式采用递归子组件完成。
默认情况下就存在两个视图节点,即开始和节数节点,对应的数据结构如下。后期查找父子级关系的时候采用id和pid去维护。
const process = ref<FlowNode>({
id: "root",
pid: undefined,
type: "start",
name: "发起人",
executionListeners: [],
formProperties: [],
child: {
id: "end",
pid: "root",
type: "end",
name: "流程结束",
executionListeners: [],
child: undefined,
} as EndNode,
} as StartNode);
递归子组件
如下是外部调用子组件的地方,会将初始节点和新增节点和删除节点的操作传入。
<TreeNode :node="props.process" @add-node="addNode" @del-node="delNode" />
在TreeNode组件内部,会根据component
动态渲染组件。即:is="nodes[node.type]
属性,同时将当前节点的属性传入:node="node" v-bind="$attrs"
。然后在该组件的结束位置处调用自身。当没有node.child
的时候,递归渲染结束
<slot />
<!-- $attrs是收集没有被props接受的属性 -->
<!-- $attrs中包含了所有未被主动接受的属性和自定义事件 -->
<component :is="nodes[node.type]" :node="node" v-bind="$attrs">
<!-- 使用具名插槽 -->
<template v-for="(value, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope || {}" />
</template>
</component>
<!-- 递归子组件生成一个一个节点 -->
<TreeNode v-if="node.child" :node="node.child" v-bind="$attrs" />
以审批人节点为例,内部会将动态组件传递的数据接收,然后封装统一的根节点组件渲染即Node
。每一个类似审批人节点的这种样式都是固定的,因此传入相关的图标,颜色等
<Node
v-bind="$attrs"
:node="node"
icon="el:Stamp"
color="linear-gradient(89.96deg, #FA6F32 .05%, #FB9337 79.83%)"
>
<el-text>{{ content }}</el-text>
</Node>
主要的渲染和样式布局都在Node
节点中完成,布局如下
<div class="node-box">
<el-card
@click="activeNode"
:class="['node', { 'error-node': errorInfo?.length && !_readOnly }]"
>
<template #header>
<!-- 头部 -->
<div class="head">
<div @click.stop v-if="showInput">
<el-input
ref="inputRef"
v-click-outside="onClickOutsize"
@blur="onClickOutsize"
maxlength="30"
v-model="node.name"
></el-input>
</div>
<el-text v-else @click.stop="onShowInput" tag="b" truncated>
{{ node.name }}
<el-icon><EditPen /></el-icon>
</el-text>
<slot name="icon">
<svg-icon :size="30" color="node-icon" v-if="icon" :name="icon" />
</slot>
</div>
<!--删除按钮-->
<span @click.stop>
<el-popconfirm
title="您确定要删除该节点吗?"
width="200"
:hide-after="0"
placement="right-start"
@confirm="delNode"
>
<template #reference>
<el-button
class="node-close"
v-show="close && !_readOnly"
plain
circle
icon="CircleClose"
size="small"
type="danger"
/>
</template>
</el-popconfirm>
</span>
<!--错误提示-->
<el-tooltip placement="top-start">
<template #content>
<div v-for="err in errorInfo" :key="err.id">
{{ err.message }}
</div>
</template>
<el-icon
class="warn-icon"
:size="20"
v-show="errorInfo?.length && !_readOnly"
>
<WarnTriangleFilled @click.stop />
</el-icon>
</el-tooltip>
</template>
<!-- 插槽 -->
<slot />
</el-card>
<!-- 添加下一个节点 -->
<Add @add-node="addNode" />
</div>
const _inject = inject<{
readOnly?: Ref<boolean>;
nodesError: Ref<Recordable<ErrorInfo[]>>;
}>("flowDesign", {
readOnly: ref(false),
nodesError: ref({}),
});
//透传
const $emits = defineEmits<{
(e: "addNode", type: NodeType, node: FlowNode): void;
(e: "delNode", node: FlowNode): void;
(e: "activeNode", node: FlowNode): void;
}>();
const $props = withDefaults(
defineProps<{
icon?: string;
node: FlowNode;
color?: string;
readOnly?: boolean;
close?: boolean;
}>(),
{
readOnly: false,
close: true,
}
);
const errorInfo = computed<ErrorInfo[] | undefined>(() => {
return _inject.nodesError.value[$props.node.id];
});
const _readOnly = computed(() => _inject.readOnly?.value || $props.readOnly);
const showInput = ref(false);
const inputRef = ref<InputInstance>();
const onShowInput = () => {
//只读模式的时候不处理
if (_readOnly.value) return;
showInput.value = true;
nextTick(() => {
inputRef.value?.focus();
});
};
const onClickOutsize = () => {
if (showInput.value) showInput.value = false;
};
//点击的时候激活当前节点
const activeNode = () => {
if (_readOnly.value) return;
$emits("activeNode", $props.node);
};
//添加节点
const addNode = (type: NodeType) => {
//$props.node是当前节点
$emits("addNode", type, $props.node);
};
//删除节点
const delNode = () => {
$emits("delNode", $props.node);
};
每一个Node节点包含自身节点的数据和添加节点的操作,两者组成一个整体。每一个卡片的头部区域通过props传递实现节点类型展示。主体内容部分则采用插槽。
Node组件中主要传递两个如下方法给Add组件使用
//添加节点
const addNode = (type: NodeType) => {
//$props.node是当前节点
$emits("addNode", type, $props.node);
};
//删除节点
const delNode = () => {
$emits("delNode", $props.node);
};
Add组件代码如下
<div class="add-but">
<el-popover
placement="bottom-start"
ref="popoverRef"
trigger="click"
title="添加节点"
:width="340"
>
<el-space wrap>
<div class="node-select" @click="addApprovalNode">
<svg-icon name="el:Stamp" />
<el-text>审批人</el-text>
</div>
<div class="node-select" @click="addCcNode">
<svg-icon name="el:Promotion" />
<el-text>抄送人</el-text>
</div>
<div class="node-select" @click="addExclusiveNode">
<svg-icon name="el:Share" />
<el-text>互斥分支</el-text>
</div>
<div class="node-select" @click="addTimerNode">
<svg-icon name="el:Timer" />
<el-text>计时等待</el-text>
</div>
<div class="node-select" @click="addNotifyNode">
<svg-icon name="el:BellFilled" />
<el-text>消息通知</el-text>
</div>
</el-space>
<template #reference>
<el-button
v-show="!readOnly"
icon="Plus"
type="primary"
style="z-index: 1"
circle
></el-button>
</template>
</el-popover>
</div>
当点击添加的时候会打开如下面板,每一个图标点击的时候都会记录当前要创建新节点的类型信息。
const addApprovalNode = () => {
$emits("addNode", "approval");
popoverRef.value?.hide();
};
此时会进入父级Node的 $emits("addNode", type, $props.node);
执行语句中,将该步骤层层往上传递给TreeNode的addNode方法处理。
插入节点
插入一个新节点的时候,用户会触发新增按钮,新增按钮会记录当前用户插入的节点类型以及当前节点(会被作为父节点使用)。
如下代码是将传入点击的node节点收集,创建一个用户指定的节点结构,然后以链表的方式进行链接维护关系。
const addApproval = (node: FlowNode) => {
const child = node.child;
const id = nextId();
node.child = {
id: id,
pid: node.id,
type: "approval",
name: "审批人",
executionListeners: [],
child: child,
// 属性
assigneeType: "user",
formUser: "",
formRole: "",
users: [],
roles: [],
leader: 1,
orgLeader: 1,
choice: false,
self: false,
multi: "sequential",
multiPercent: 100,
nobody: "pass",
nobodyUsers: [],
formProperties: [],
operations: {
complete: true,
refuse: true,
back: true,
transfer: true,
delegate: true,
addMulti: false,
minusMulti: false,
},
} as ApprovalNode;
if (child) {
child.pid = id;
}
};
被emit
触发后会执行这里的addNode操作,根据不同的type类型执行不同的创建函数,同时传入父节点node。
const addNode = (type: NodeType, node: FlowNode) => {
//node当前插入父节点
//映射函数添加
const addMap: Recordable<(node: FlowNode) => void> = {
exclusive: addExclusive,
condition: addCondition,
cc: addCc,
timer: addTimer,
notify: addNotify,
approval: addApproval,
};
const fun = addMap[type];
fun && fun(node);
};
删除节点
删除节点再每一个Node
节点中去触发delNode
函数将当前删除的节点信息传递给父组件完成。删除一个节点,需要找到当前删除节点的父节点,父节点和删除节点的子项关系即可。
删除的时候需要将整个链表和删除节点的数据传入。next.id === del.pid
判断删除节点的父节点id和当前节点id是否一致,一直则代表找到删除节点的父节点信息。否则继续递归去处理。
const delNode = (del: FlowNode) => {
//删除当前节点需要维护当前节点的父节点和该节点的子节点关系
delete nodesError.value[del.id];
delNodeNext(props.process, del);
};
//删除节点的错误信息
const delError = (node: FlowNode) => {
...
};
const delNodeNext = (next: FlowNode, del: FlowNode) => {
delete nodesError.value[del.id];
//常规链表删除链接的操作,找到删除节点的前一个节点
if (next.id === del.pid) {
//条件分支是整个大节点,里面显示的是分支节点,用户只能点击分支节点,需要通过点击分支节点去删除条件分支大节点
if (Reflect.has(next, "children") && next.child?.id !== del.id) {
const branchNode = next as BranchNode;
const index = branchNode.children.findIndex((item) => item.id == del.id);
if (index !== -1) {
if (branchNode.children.length <= 2) {
// 两个分支节点的情况下,是将整个条件分支删除
delError(branchNode);
delNode(branchNode); //重新判断删除整个分支节点
} else {
//添加条件 到 多个分支节点的情况下,是点击谁删谁
delError(del);
branchNode.children.splice(index, 1);
}
}
} else {
if (del.child && del.child.id) {
del.child.pid = next.id;
}
next.child = del.child;
}
} else {
//普通节点的删除情况
if (next.child) {
delNodeNext(next.child, del);
}
//条件分支的删除情况,会把子节点和自身全部删除
if (Reflect.has(next, "children")) {
const nextBranch = next as BranchNode;
if (nextBranch.children && nextBranch.children.length) {
nextBranch.children.forEach((item) => {
delNodeNext(item, del);
});
}
}
}
};
这里展示不看处理分支节点的情况。以普通节点为例,当找到的时候,需要进行关系的重新设置,重新设置删除节点子节点的父id。同时维护删除节点的父节点连接到删除节点的子节点上
if (del.child && del.child.id) {
del.child.pid = next.id;
}
next.child = del.child;
分支节点
分支节点的话处理情况比普通节点复杂一点。如图所示区域,整体作为分支总节点。其中该总分支节点的child是直接连接下一个节点的,而内部节点数据维护在自身的children属性中。
如下是插入分支节点的情况,分支节点插入的时候默认插入一个总节点,但是总结点不展示数据信息,同时创建两个分支节点给总节点的chidlrens中保存。
const addCondition = (node: FlowNode) => {
const exclusive = node as ExclusiveNode;
exclusive.children.splice(exclusive.children.length - 1, 0, {
id: nextId(),
pid: exclusive.id,
type: "condition",
def: false,
name: `条件${exclusive.children.length + 1}`,
conditions: {
operator: "and",
conditions: [],
groups: [],
} as FilterRules,
child: undefined,
});
};
const addExclusive = (node: FlowNode) => {
//node是父节点
const child = node.child; //进行链接操作 ,如果是初始化情况,node.child链接的是结束节点
const id = nextId();
const exclusiveNode = {
//创建条件分支节点主体数据
id: id,
pid: node.id,
type: "exclusive",
name: "独占网关",
child: child,
children: [],
} as ExclusiveNode;
if (child) child.pid = id; //修改原子节点中的保存的父节点的id
//执行两次是创建条件分支自己的两个内部节点
addCondition(exclusiveNode);
addCondition(exclusiveNode);
node.child = exclusiveNode; //重新连接父节点的字节点
if (exclusiveNode.children.length > 0) {
//修改默认创建的两个条件子节点中最后一个的展示数据
const condition = exclusiveNode.children[
exclusiveNode.children.length - 1
] as ConditionNode;
condition.def = true;
condition.name = "默认条件";
}
};
exclusive节点如下,GatewayNode
充当整个最大的分支节点,
<GatewayNode v-bind="$attrs" :node="node">
<!-- 使用作用域插槽 -->
<template #default="{ addNode, readOnly }">
<el-button
type="primary"
:disabled="readOnly"
@click="addNode('condition', node)"
plain
round
>添加条件
</el-button>
</template>
</GatewayNode>
GatewayNode 组件代码如下
<div class="gateway-node">
<!-- 替换默认的添加节点 -->
<div class="add-branch">
<slot :addNode="addNode" :readOnly="readOnly" />
</div>
<div v-for="(item, index) in node.children" :key="item.id" class="col-box">
<template v-if="index === 0">
<div class="top-left-border"></div>
<div class="bottom-left-border" />
</template>
<template v-else-if="node.children.length === index + 1">
<div class="top-right-border"></div>
<div class="bottom-right-border" />
</template>
<TreeNode
:node="item"
v-bind="$attrs"
@addNode="addNode"
class="col-node"
>
<template #append>
<div
class="move-left"
@click.stop="moveLeft(index)"
v-show="
index !== 0 && node.children.length !== index + 1 && !readOnly
"
>
<svg-icon name="el:ArrowLeft" />
</div>
<div
class="move-right"
@click.stop="moveRight(index)"
v-show="
![index + 1, index + 2].includes(node.children.length) &&
!readOnly
"
>
<svg-icon name="el:ArrowRight" />
</div>
</template>
</TreeNode>
</div>
</div>
<!-- 条件分支节点中的子节点还能添加节点 -->
<Add @addNode="addNode" class="branch-but" />
最终显示的一个个小的条件分支节点ConditionNode组件如下
<div class="branch-node">
<Node v-bind="$attrs" icon="el:Share" :node="node" :readOnly="node.def">
<el-text>{{ content }}</el-text>
<slot name="append" />
</Node>
</div>
在删除节点分支时候主要如下代码
if ("children" in next && next.child?.id !== del.id) {
const branchNode = next as BranchNode;
const index = branchNode.children.findIndex((item) => item.id == del.id);
if (index !== -1) {
if (branchNode.children.length <= 2) {
// 两个分支节点的情况下,是将整个条件分支删除
delError(branchNode);
delNode(branchNode); //重新判断删除整个分支节点
} else {
//添加条件 到 多个分支节点的情况下,是点击谁删谁
delError(del);
branchNode.children.splice(index, 1);
}
}
//条件分支的删除情况,会把子节点和自身全部删除
if ("children" in next) {
const nextBranch = next as BranchNode;
if (nextBranch.children && nextBranch.children.length > 0) {
nextBranch.children.forEach((item) => {
delNodeNext(item, del);
});
}
}
删除的时候需要处理总的分支节点的情况,因此这里将总分支中每一个子节点进行删除,若删除的子分支节点存在子数据也会一起删除
if (nextBranch.children && nextBranch.children.length > 0) {
nextBranch.children.forEach((item) => {
delNodeNext(item, del);
});
}