实现结果
实现功能:
- 拖拽创建节点
- 自定义节点/边
- 自定义快捷键
- 人员选择弹窗
- 右侧动态配置组件
- 配置项获取/回显
- 必填项验证
- 历史记录(撤销/恢复)
自定义节点与拖拽创建节点
拖拽节点面板node-panel.vue
<template>
<div class="node-panel">
<div
v-for="(item, key) in state.nodePanel"
:key="key"
class="approve-node"
@mousedown="dragNode(item)"
>
<div class="node-shape" :class="'node-' + item.type"></div>
<div class="node-label">{{ item.text }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ILogicFlowNodePanelItem } from "@/types/logic-flow";
import LogicFlow from "@logicflow/core";
import { reactive } from "vue";
const props = defineProps<{ lf?: LogicFlow }>();
const state = reactive({
nodePanel: [
{
type: "approver",
text: "用户活动",
},
{
type: "link",
text: "连接点",
},
{
type: "review",
text: "传阅",
},
],
});
const dragNode = (item: ILogicFlowNodePanelItem) => {
props.lf?.dnd.startDrag({
type: item.type,
text: item.text,
});
};
</script>
自定义节点/边index.ts
/**
* @description 注册节点
* @export
* @param {LogicFlow} lf
* @return {*}
*/
export function registeNode(lf: ShallowRef<LogicFlow | undefined>) {
/**
* @description 自定义开始节点
*/
class StartNode extends CircleNode {
getShape() {
const { x, y } = this.props.model;
const style = this.props.model.getNodeStyle();
return h("g", {}, [
h("circle", {
...style,
cx: x,
cy: y,
r: 30,
stroke: "#000",
fill: "#000",
}),
]);
}
getText() {
const { x, y, text } = this.props.model;
return h(
"text",
{
x: x,
y: y,
fill: "#fff",
textAnchor: "middle",
alignmentBaseline: "middle",
style: { fontSize: 12 },
},
text.value
);
}
}
class StartNodeModel extends CircleNodeModel {
setAttributes() {
this.r = 30;
this.isSelected = false;
}
getConnectedTargetRules() {
const rules = super.getConnectedTargetRules();
const geteWayOnlyAsTarget = {
message: "开始节点只能连出,不能连入!",
validate: (source?: BaseNodeModel, target?: BaseNodeModel) => {
let isValid = true;
if (target) {
isValid = false;
}
return isValid;
},
};
rules.push(geteWayOnlyAsTarget);
return rules;
}
getConnectedSourceRules() {
const rules = super.getConnectedSourceRules();
const onlyOneOutEdge = {
message: "开始节点只能连出一条线!",
validate: (source?: BaseNodeModel, target?: BaseNodeModel) => {
let isValid = true;
if (source?.outgoing.edges.length) {
isValid = false;
}
return isValid;
},
};
rules.push(onlyOneOutEdge);
return rules;
}
createId() {
return uuidv4();
}
}
lf.value?.register({
type: "start",
view: StartNode,
model: StartNodeModel,
});
/**
* @description 自定义发起节点
*/
class LaunchNode extends RectNode {
getShape() {
const { x, y, width, height, radius } = this.props.model;
const style = this.props.model.getNodeStyle();
return h("g", {}, [
h("rect", {
...style,
x: x - width / 2,
y: y - height / 2,
rx: radius,
ry: radius,
width: 120,
height: 50,
stroke: "#000",
fill: "#000",
}),
]);
}
getText() {
const { x, y, text, width, height } = this.props.model;
return h(
"foreignObject",
{
x: x - width / 2,
y: y - height / 2,
className: "foreign-object",
style: {
width: width,
height: height,
},
},
[
h(
"p",
{
style: {
fontSize: 12,
width: width,
height: height,
lineHeight: height + "px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
padding: "0 8px",
boxSizing: "border-box",
margin: "0",
color: "#fff",
},
},
text.value
),
]
);
}
}
class LaunchModel extends RectNodeModel {
setAttributes() {
this.width = 120;
this.height = 50;
this.radius = 4;
this.isSelected = false;
}
getConnectedSourceRules() {
const rules = super.getConnectedSourceRules();
const notAsTarget = {
message: "不能连接自己",
validate: (source?: BaseNodeModel, target?: BaseNodeModel) => {
let isValid = true;
if (source?.id === target?.id) {
isValid = false;
}
return isValid;
},
};
rules.push(notAsTarget);
return rules;
}
createId() {
return uuidv4();
}
}
lf.value?.register({
type: "launch",
view: LaunchNode,
model: LaunchModel,
});
/**
* @description 自定义审批节点
*/
class ApproverNode extends RectNode {
getShape() {
const { x, y, width, height, radius } = this.props.model;
const style = this.props.model.getNodeStyle();
return h("g", {}, [
h("rect", {
...style,
x: x - width / 2,
y: y - height / 2,
rx: radius,
ry: radius,
width: 120,
height: 50,
stroke: "#facd91",
fill: "#facd91",
}),
]);
}
getText() {
const { x, y, text, width, height } = this.props.model;
return h(
"foreignObject",
{
x: x - width / 2,
y: y - height / 2,
className: "foreign-object",
style: {
width: width,
height: height,
},
},
[
h(
"p",
{
style: {
fontSize: 12,
width: width,
height: height,
lineHeight: height + "px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
padding: "0 8px",
boxSizing: "border-box",
margin: "0",
},
},
text.value
),
]
);
}
}
class ApproverModel extends RectNodeModel {
setAttributes() {
this.width = 120;
this.height = 50;
this.radius = 4;
this.isSelected = false;
}
getConnectedSourceRules() {
const rules = super.getConnectedSourceRules();
const notAsTarget = {
message: "不能连接自己",
validate: (source?: BaseNodeModel, target?: BaseNodeModel) => {
let isValid = true;
if (source?.id === target?.id) {
isValid = false;
}
return isValid;
},
};
rules.push(notAsTarget);
return rules;
}
createId() {
return uuidv4();
}
}
lf.value?.register({
type: "approver",
view: ApproverNode,
model: ApproverModel,
});
/**
* @description 自定义连接点节点
*/
class LinkNode extends RectNode {
getShape() {
const { x, y, width, height, radius } = this.props.model;
const style = this.props.model.getNodeStyle();
return h("g", {}, [
h("rect", {
...style,
x: x - width / 2,
y: y - height / 2,
rx: radius,
ry: radius,
width: 120,
height: 50,
stroke: "#caf982",
fill: "#caf982",
}),
]);
}
getText() {
const { x, y, text, width, height } = this.props.model;
return h(
"foreignObject",
{
x: x - width / 2,
y: y - height / 2,
className: "foreign-object",
style: {
width: width,
height: height,
},
},
[
h(
"p",
{
style: {
fontSize: 12,
width: width,
height: height,
lineHeight: height + "px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
padding: "0 8px",
boxSizing: "border-box",
margin: "0",
},
},
text.value
),
]
);
}
}
class LinkModel extends RectNodeModel {
setAttributes() {
this.width = 120;
this.height = 50;
this.radius = 4;
this.isSelected = false;
}
getConnectedSourceRules() {
const rules = super.getConnectedSourceRules();
const notAsTarget = {
message: "不能连接自己",
validate: (source?: BaseNodeModel, target?: BaseNodeModel) => {
let isValid = true;
if (source?.id === target?.id) {
isValid = false;
}
return isValid;
},
};
rules.push(notAsTarget);
return rules;
}
createId() {
return uuidv4();
}
}
lf.value?.register({
type: "link",
view: LinkNode,
model: LinkModel,
});
/**
* @description 自定义传阅节点
*/
class ReviewNode extends RectNode {
getShape() {
const { x, y, width, height, radius } = this.props.model;
const style = this.props.model.getNodeStyle();
return h("g", {}, [
h("rect", {
...style,
x: x - width / 2,
y: y - height / 2,
rx: radius,
ry: radius,
width: 120,
height: 50,
stroke: "#81d3f8",
fill: "#81d3f8",
}),
]);
}
getText() {
const { x, y, text, width, height } = this.props.model;
return h(
"foreignObject",
{
x: x - width / 2,
y: y - height / 2,
className: "foreign-object",
style: {
width: width,
height: height,
},
},
[
h(
"p",
{
style: {
fontSize: 12,
width: width,
height: height,
lineHeight: height + "px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
padding: "0 8px",
boxSizing: "border-box",
margin: "0",
},
},
text.value
),
]
);
}
}
class ReviewModel extends RectNodeModel {
setAttributes() {
this.width = 120;
this.height = 50;
this.radius = 4;
this.isSelected = false;
}
getConnectedSourceRules() {
const rules = super.getConnectedSourceRules();
const notAsTarget = {
message: "不能连接自己",
validate: (source?: BaseNodeModel, target?: BaseNodeModel) => {
let isValid = true;
if (source?.id === target?.id) {
isValid = false;
}
return isValid;
},
};
rules.push(notAsTarget);
return rules;
}
createId() {
return uuidv4();
}
}
lf.value?.register({
type: "review",
view: ReviewNode,
model: ReviewModel,
});
/**
* @description 结束节点
*/
class FinishNode extends CircleNode {
getShape() {
const { x, y } = this.props.model;
const style = this.props.model.getNodeStyle();
return h("g", {}, [
h("circle", {
...style,
cx: x,
cy: y,
r: 30,
stroke: "#000",
fill: "#000",
}),
]);
}
getText() {
const { x, y, text } = this.props.model;
return h(
"text",
{
x: x,
y: y,
fill: "#fff",
textAnchor: "middle",
alignmentBaseline: "middle",
style: { fontSize: 12 },
},
text.value
);
}
}
class FinishModel extends CircleNodeModel {
setAttributes() {
this.r = 30;
this.isSelected = false;
}
getConnectedSourceRules() {
const rules = super.getConnectedSourceRules();
const notAsTarget = {
message: "终止节点不能作为连线的起点",
validate: () => false,
};
rules.push(notAsTarget);
return rules;
}
createId() {
return uuidv4();
}
}
lf.value?.register({
type: "end",
view: FinishNode,
model: FinishModel,
});
/**
* @description 虚线
*/
class DashedLineModel extends PolylineEdgeModel {
getEdgeStyle() {
const style = super.getEdgeStyle();
style.stroke = "#000";
style.strokeDasharray = "3 3";
return style;
}
}
lf.value?.register({
type: "dashedLine",
view: PolylineEdge,
model: DashedLineModel,
});
/**
* @description 开始的连线
*/
class StartPolylineModel extends PolylineEdgeModel {
setAttributes() {
this.isSelected = false;
this.isHitable = false;
}
}
lf.value?.register({
type: "startPolyline",
view: PolylineEdge,
model: StartPolylineModel,
});
}
注册logicflow并使用自定义节点
<template>
<div class="logic-flow-container">
<div class="logic-flow-header">
<el-button type="primary" @click="getData">获取数据</el-button>
<el-button type="primary" @click="submit">提交</el-button>
</div>
<div class="logic-flow-main">
<div class="logic-flow" ref="logicFlowRef"></div>
<Setting
class="logic-flow-setting"
:data="nodeData!"
:lf="lf"
:type="state.settingType"
></Setting>
<NodePanel :lf="lf"></NodePanel>
</div>
<!-- 当lf有值 才能注册事件 -->
<Control v-if="lf" :lf="lf"></Control>
</div>
</template>
<script lang="ts">
export default { name: "LogicFlow" };
</script>
<script lang="ts" setup>
import LogicFlow from "@logicflow/core";
import "@logicflow/core/lib/style/index.css";
import "@logicflow/extension/lib/style/index.css";
import { onMounted, reactive, ref, ShallowRef, shallowRef } from "vue";
import NodePanel from "./components/node-panel.vue";
import { registeNode, registerKeyboard, requiredConfig } from "./index";
import { ElMessage } from "element-plus";
import Control from "./components/control.vue";
import Setting from "./components/setting.vue";
import { SettingType } from "@/types/logic-flow";
const logicFlowRef = ref<HTMLDivElement>();
const nodeData = ref<LogicFlow.NodeData | LogicFlow.EdgeData>(); // 节点数据
const state = reactive({
settingType: "all" as SettingType,
});
const lf = shallowRef<LogicFlow>();
const getSettingInfo = (data: LogicFlow.NodeData | LogicFlow.EdgeData) => {
switch (data.type) {
case "launch":
nodeData.value = data;
state.settingType = data.type;
break;
case "approver":
nodeData.value = data;
state.settingType = data.type;
break;
case "link":
nodeData.value = data;
state.settingType = data.type;
break;
case "review":
nodeData.value = data;
state.settingType = data.type;
break;
case "polyline":
case "dashedLine":
nodeData.value = data;
state.settingType = data.type;
break;
}
};
/**
* @description 注册事件
*/
const initEvent = (lf: ShallowRef<LogicFlow | undefined>) => {
lf.value?.on("blank:click", (e) => {
state.settingType = "all";
});
lf.value?.on("node:mousedown", ({ data }) => {
lf.value?.selectElementById(data.id, false);
getSettingInfo(data);
});
lf.value?.on("edge:click", ({ data }) => {
lf.value?.selectElementById(data.id, false);
getSettingInfo(data);
});
lf.value?.on("connection:not-allowed", (data) => {
ElMessage.error(data.msg);
return false;
});
lf.value?.on("node:dnd-add", ({ data }) => {
// 选中节点 更改信息
lf.value?.selectElementById(data.id, false);
getSettingInfo(data);
lf.value?.container.focus(); // 聚焦 能够使用键盘操作
});
};
/**
* @description 获取数据
*/
const getData = () => {
console.log(lf.value?.getGraphData());
};
/**
* @description 提交 验证数据
*/
const submit = () => {
const { nodes } = lf.value?.getGraphData() as LogicFlow.GraphData;
for (let index = 0; index < nodes.length; index++) {
const data = nodes[index];
const { properties } = data;
// 循环配置项
for (const key in properties) {
// 数组配置项 判断是否为空
if (Array.isArray(properties[key])) {
if (requiredConfig[key] && properties[key].length === 0) {
return ElMessage.error(
`${data.text?.value}节点 ${requiredConfig[key]}`
);
}
} else {
// 非数组配置项 判断是否为空
if (requiredConfig[key] && !properties[key]) {
return ElMessage.error(
`${data.text?.value}节点 ${requiredConfig[key]}`
);
}
}
}
}
console.log(lf.value?.getGraphData());
};
onMounted(() => {
lf.value = new LogicFlow({
container: logicFlowRef.value!,
grid: true,
keyboard: {
enabled: true,
shortcuts: registerKeyboard(lf, nodeData),
},
textEdit: false,
});
registeNode(lf);
initEvent(lf);
lf.value.render({
nodes: [
{
id: "node_1",
type: "start",
x: 100,
y: 300,
properties: {
width: 60,
height: 60,
},
text: {
x: 100,
y: 300,
value: "开始",
},
},
{
id: "node_2",
type: "launch",
x: 100,
y: 400,
properties: {
width: 120,
height: 50,
},
text: {
x: 100,
y: 400,
value: "发起流程",
},
},
{
id: "node_3",
type: "end",
x: 100,
y: 600,
properties: {
width: 60,
height: 60,
},
text: {
x: 100,
y: 600,
value: "结束",
},
},
],
edges: [
{
id: "edge_1",
type: "startPolyline",
sourceNodeId: "node_1",
targetNodeId: "node_2",
},
{
id: "edge_2",
type: "polyline",
sourceNodeId: "node_2",
targetNodeId: "node_3",
},
],
});
lf.value.translateCenter(); // 将图形移动到画布中央
});
</script>
右侧的配置设置
- 通过
componentIs
实现不同的配置组件 - 通过logicflow的
setProperties()
函数,将配置项注入节点/边的properties
对象中,目的是传参和回显的时候方便
人员选择组件
正选、反选、回显,可作为一个单独组件使用,目前使用的是el-tree
,数据量大时可考虑虚拟树
<template>
<MyDialog
v-model="state.visible"
title="选择人员"
width="800px"
@close="close"
@cancel="close"
@submit="submit"
>
<div class="type">
<label>
<span>发起人:</span>
<el-radio-group v-model="state.type">
<el-radio value="1">指定人员</el-radio>
<el-radio value="2">角色</el-radio>
</el-radio-group>
</label>
</div>
<div class="panel">
<div class="left-panel">
<div class="panel-title">人员选择</div>
<div class="search">
<el-input
v-model="state.filterText"
style="width: 100%"
placeholder="请输入筛选内容"
/>
</div>
<div class="content">
<el-tree
ref="treeRef"
:data="state.data"
show-checkbox
node-key="key"
:check-on-click-node="true"
:filter-node-method="filterNode"
@check-change="checkChange"
/>
</div>
</div>
<div class="right-panel">
<div class="panel-title">已选择</div>
<div class="content checked-content">
<el-tag
v-for="tag in state.checkedList"
:key="tag.key"
closable
type="primary"
@close="handleClose(tag.key)"
>
{{ tag.label }}
</el-tag>
</div>
</div>
</div>
</MyDialog>
</template>
<script lang="ts">
export default { name: "ChoosePerson" };
</script>
<script lang="ts" setup>
import { ElTree } from "element-plus";
import { nextTick, reactive, ref, watch } from "vue";
interface Tree {
[key: string]: any;
}
const state = reactive({
visible: false,
type: "1",
filterText: "",
value: [],
data: [
{
label: "张三",
key: "1",
},
{
label: "李四",
key: "2",
},
{
label: "王五",
key: "3",
children: [
{
label: "王五1",
key: "31",
},
{
label: "王五2",
key: "32",
},
],
},
],
checked: [] as string[],
checkedList: [] as { label: string; key: string }[],
});
const treeRef = ref<InstanceType<typeof ElTree>>();
const emits = defineEmits(["submit"]);
/**
* @description 筛选节点
*/
watch(
() => state.filterText,
(val) => {
treeRef.value!.filter(val);
}
);
const open = (checked: string[]) => {
state.visible = true;
nextTick(() => {
state.checked = checked;
treeRef.value?.setCheckedKeys([...checked], false);
});
};
const close = () => {
state.visible = false;
state.filterText = "";
};
const submit = () => {
emits("submit", state.checked, state.checkedList);
close();
};
/**
* @description 筛选节点
*/
const filterNode = (value: string, data: Tree) => {
if (!value) return true;
return data.label.includes(value);
};
/**
* @description 选中节点
*/
const checkChange = () => {
// 已选的id string[] 用来提交
state.checked = treeRef.value
?.getCheckedNodes(true, false)
.map((item) => item.key) as string[];
// 已选的对象 {label: string; key: string}[] 用来展示tag
state.checkedList = treeRef.value
?.getCheckedNodes(true, false)
.map((item) => {
return {
label: item.label,
key: item.key,
};
})!;
};
/**
* @description 删除已选人员
*/
const handleClose = (key: string) => {
state.checkedList = state.checkedList.filter((item) => item.key !== key);
treeRef.value?.setCheckedKeys(
state.checkedList.map((item) => item.key),
false
);
};
/**
* @description 清空已选人员
*/
const clear = () => {
state.checkedList = [];
state.checked = [];
treeRef.value?.setCheckedKeys([], false);
};
defineExpose({
open,
clear,
});
</script>
<style lang="scss" scoped>
.type {
display: flex;
align-items: center;
margin-bottom: 20px;
span {
margin-right: 10px;
}
label {
display: flex;
align-items: center;
}
}
.panel {
width: 100%;
display: flex;
.left-panel {
flex: 1;
border: 1px solid #ccc;
border-radius: 4px;
.search {
padding: 6px 10px;
}
}
.right-panel {
flex: 1;
margin-left: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
.panel-title {
padding: 10px 0;
font-size: 14px;
font-weight: bold;
background-color: #f5f5f5;
text-align: center;
}
.content {
max-height: 400px;
min-height: 200px;
overflow: auto;
}
.checked-content {
padding: 6px 10px;
.el-tag + .el-tag {
margin-left: 10px;
}
}
}
</style>
自定义快捷键,根据源码改编
/**
* @description 注册键盘事件
* @export
* @param {(ShallowRef<LogicFlow | undefined>)} lf
* @param {(Ref<LogicFlow.NodeData | LogicFlow.EdgeData | undefined>)} nodeData
* @return {*}
*/
export function registerKeyboard(
lf: ShallowRef<LogicFlow | undefined>,
nodeData: Ref<LogicFlow.NodeData | LogicFlow.EdgeData | undefined>
) {
let copyNodes = undefined as LogicFlow.NodeData[] | undefined;
let TRANSLATION_DISTANCE = 40;
let CHILDREN_TRANSLATION_DISTANCE = 40;
const cv = [
{
keys: ["ctrl + c", "cmd + c"],
callback: () => {
copyNodes = lf.value?.getSelectElements().nodes;
},
},
{
keys: ["ctrl + v", "cmd + v"],
callback: () => {
const startOrEndNode = copyNodes?.find(
(node) =>
node.type === "start" ||
node.type === "end" ||
node.type === "launch"
);
if (startOrEndNode) {
return true;
}
if (copyNodes) {
lf.value?.clearSelectElements();
copyNodes.forEach(function (node) {
node.x += TRANSLATION_DISTANCE;
node.y += TRANSLATION_DISTANCE;
node.text!.x += TRANSLATION_DISTANCE;
node.text!.y += TRANSLATION_DISTANCE;
return node;
});
let addElements = lf.value?.addElements(
{ nodes: copyNodes, edges: [] },
CHILDREN_TRANSLATION_DISTANCE
);
if (!addElements) return true;
addElements.nodes.forEach(function (node) {
nodeData.value = node.getData();
return lf.value?.selectElementById(node.id, true);
});
CHILDREN_TRANSLATION_DISTANCE =
CHILDREN_TRANSLATION_DISTANCE + TRANSLATION_DISTANCE;
}
return false;
},
},
{
keys: ["backspace"],
callback: () => {
const elements = lf.value?.getSelectElements(true);
if (elements) {
lf.value?.clearSelectElements();
elements.edges.forEach(function (edge) {
return edge.id && lf.value?.deleteEdge(edge.id);
});
elements.nodes.forEach(function (node) {
if (
node.type === "start" ||
node.type === "end" ||
node.type === "launch"
) {
return true;
}
return node.id && lf.value?.deleteNode(node.id);
});
return false;
}
},
},
];
return cv;
}
仓库地址
在线预览