2025-09-07 19:25:34 +08:00
|
|
|
|
<script lang="ts" setup>
|
|
|
|
|
|
import { defineProps, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
2025-09-05 15:38:09 +08:00
|
|
|
|
|
2025-09-15 10:23:17 +08:00
|
|
|
|
import { BpmProcessInstanceStatus, DICT_TYPE } from '@vben/constants';
|
2025-09-07 19:25:34 +08:00
|
|
|
|
import { UndoOutlined, ZoomInOutlined, ZoomOutOutlined } from '@vben/icons';
|
|
|
|
|
|
import { dateFormatter, formatPast2 } from '@vben/utils';
|
2025-09-05 15:38:09 +08:00
|
|
|
|
|
2025-09-07 19:25:34 +08:00
|
|
|
|
import { Button, ButtonGroup, Modal, Row, Table } from 'ant-design-vue';
|
2025-09-05 15:38:09 +08:00
|
|
|
|
import BpmnViewer from 'bpmn-js/lib/Viewer';
|
|
|
|
|
|
import MoveCanvasModule from 'diagram-js/lib/navigation/movecanvas';
|
2025-09-07 19:25:34 +08:00
|
|
|
|
|
|
|
|
|
|
import { DictTag } from '#/components/dict-tag';
|
|
|
|
|
|
|
|
|
|
|
|
import '../theme/index.scss';
|
2025-09-05 15:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
xml: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
view: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
require: true,
|
2025-09-07 19:25:34 +08:00
|
|
|
|
default: () => ({}),
|
2025-09-05 15:38:09 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const processCanvas = ref();
|
2025-09-07 19:25:34 +08:00
|
|
|
|
const bpmnViewer = ref<any | BpmnViewer>(null);
|
2025-09-05 15:38:09 +08:00
|
|
|
|
const customDefs = ref();
|
|
|
|
|
|
const defaultZoom = ref(1); // 默认缩放比例
|
|
|
|
|
|
const isLoading = ref(false); // 是否加载中
|
|
|
|
|
|
|
|
|
|
|
|
const processInstance = ref<any>({}); // 流程实例
|
|
|
|
|
|
const tasks = ref([]); // 流程任务
|
|
|
|
|
|
|
|
|
|
|
|
const dialogVisible = ref(false); // 弹窗可见性
|
|
|
|
|
|
const dialogTitle = ref<string | undefined>(undefined); // 弹窗标题
|
|
|
|
|
|
const selectActivityType = ref<string | undefined>(undefined); // 选中 Task 的活动编号
|
|
|
|
|
|
const selectTasks = ref<any[]>([]); // 选中的任务数组
|
|
|
|
|
|
|
|
|
|
|
|
/** Zoom:恢复 */
|
|
|
|
|
|
const processReZoom = () => {
|
|
|
|
|
|
defaultZoom.value = 1;
|
|
|
|
|
|
bpmnViewer.value?.get('canvas').zoom('fit-viewport', 'auto');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Zoom:放大 */
|
|
|
|
|
|
const processZoomIn = (zoomStep = 0.1) => {
|
2025-09-07 19:25:34 +08:00
|
|
|
|
const newZoom = Math.floor(defaultZoom.value * 100 + zoomStep * 100) / 100;
|
2025-09-05 15:38:09 +08:00
|
|
|
|
if (newZoom > 4) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
'[Process Designer Warn ]: The zoom ratio cannot be greater than 4',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
defaultZoom.value = newZoom;
|
|
|
|
|
|
bpmnViewer.value?.get('canvas').zoom(defaultZoom.value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Zoom:缩小 */
|
|
|
|
|
|
const processZoomOut = (zoomStep = 0.1) => {
|
2025-09-07 19:25:34 +08:00
|
|
|
|
const newZoom = Math.floor(defaultZoom.value * 100 - zoomStep * 100) / 100;
|
2025-09-05 15:38:09 +08:00
|
|
|
|
if (newZoom < 0.2) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
'[Process Designer Warn ]: The zoom ratio cannot be less than 0.2',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
defaultZoom.value = newZoom;
|
|
|
|
|
|
bpmnViewer.value?.get('canvas').zoom(defaultZoom.value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 流程图预览清空 */
|
|
|
|
|
|
const clearViewer = () => {
|
|
|
|
|
|
if (processCanvas.value) {
|
|
|
|
|
|
processCanvas.value.innerHTML = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bpmnViewer.value) {
|
|
|
|
|
|
bpmnViewer.value.destroy();
|
|
|
|
|
|
}
|
|
|
|
|
|
bpmnViewer.value = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 添加自定义箭头 */
|
|
|
|
|
|
// TODO 芋艿:自定义箭头不生效,有点奇怪!!!!相关的 marker-end、marker-start 暂时也注释了!!!
|
|
|
|
|
|
const addCustomDefs = () => {
|
|
|
|
|
|
if (!bpmnViewer.value) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const canvas = bpmnViewer.value?.get('canvas');
|
|
|
|
|
|
const svg = canvas?._svg;
|
2025-09-07 19:25:34 +08:00
|
|
|
|
svg.append(customDefs.value);
|
2025-09-05 15:38:09 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 节点选中 */
|
|
|
|
|
|
const onSelectElement = (element: any) => {
|
|
|
|
|
|
// 清空原选中
|
|
|
|
|
|
selectActivityType.value = undefined;
|
|
|
|
|
|
dialogTitle.value = undefined;
|
|
|
|
|
|
if (!element || !processInstance.value?.id) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UserTask 的情况
|
|
|
|
|
|
const activityType = element.type;
|
|
|
|
|
|
selectActivityType.value = activityType;
|
|
|
|
|
|
if (activityType === 'bpmn:UserTask') {
|
|
|
|
|
|
dialogTitle.value = element.businessObject
|
|
|
|
|
|
? element.businessObject.name
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
selectTasks.value = tasks.value.filter(
|
|
|
|
|
|
(item: any) => item?.taskDefinitionKey === element.id,
|
|
|
|
|
|
);
|
|
|
|
|
|
dialogVisible.value = true;
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
activityType === 'bpmn:EndEvent' ||
|
|
|
|
|
|
activityType === 'bpmn:StartEvent'
|
|
|
|
|
|
) {
|
|
|
|
|
|
dialogTitle.value = '审批信息';
|
|
|
|
|
|
selectTasks.value = [
|
|
|
|
|
|
{
|
|
|
|
|
|
assigneeUser: processInstance.value.startUser,
|
|
|
|
|
|
createTime: processInstance.value.startTime,
|
|
|
|
|
|
endTime: processInstance.value.endTime,
|
|
|
|
|
|
status: processInstance.value.status,
|
|
|
|
|
|
durationInMillis: processInstance.value.durationInMillis,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
dialogVisible.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 初始化 BPMN 视图 */
|
|
|
|
|
|
const importXML = async (xml: string) => {
|
|
|
|
|
|
// 清空流程图
|
|
|
|
|
|
clearViewer();
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化流程图
|
2025-09-07 19:25:34 +08:00
|
|
|
|
if (xml !== null && xml !== '') {
|
2025-09-05 15:38:09 +08:00
|
|
|
|
try {
|
|
|
|
|
|
bpmnViewer.value = new BpmnViewer({
|
|
|
|
|
|
additionalModules: [MoveCanvasModule],
|
|
|
|
|
|
container: processCanvas.value,
|
|
|
|
|
|
});
|
|
|
|
|
|
// 增加点击事件
|
2025-09-07 19:25:34 +08:00
|
|
|
|
bpmnViewer.value.on('element.click', ({ element }: { element: any }) => {
|
2025-09-05 15:38:09 +08:00
|
|
|
|
onSelectElement(element);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化 BPMN 视图
|
|
|
|
|
|
isLoading.value = true;
|
|
|
|
|
|
await bpmnViewer.value.importXML(xml);
|
|
|
|
|
|
// 自定义成功的箭头
|
|
|
|
|
|
addCustomDefs();
|
2025-09-07 19:25:34 +08:00
|
|
|
|
} catch {
|
2025-09-05 15:38:09 +08:00
|
|
|
|
clearViewer();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isLoading.value = false;
|
|
|
|
|
|
// 高亮流程
|
|
|
|
|
|
setProcessStatus(props.view);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 高亮流程 */
|
|
|
|
|
|
const setProcessStatus = (view: any) => {
|
|
|
|
|
|
// 设置相关变量
|
|
|
|
|
|
if (!view || !view.processInstance) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
processInstance.value = view.processInstance;
|
|
|
|
|
|
tasks.value = view.tasks;
|
|
|
|
|
|
if (isLoading.value || !bpmnViewer.value) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const {
|
|
|
|
|
|
unfinishedTaskActivityIds,
|
|
|
|
|
|
finishedTaskActivityIds,
|
|
|
|
|
|
finishedSequenceFlowActivityIds,
|
|
|
|
|
|
rejectedTaskActivityIds,
|
|
|
|
|
|
} = view;
|
2025-09-07 19:25:34 +08:00
|
|
|
|
const canvas: any = bpmnViewer.value.get('canvas');
|
|
|
|
|
|
const elementRegistry: any = bpmnViewer.value.get('elementRegistry');
|
2025-09-05 15:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 已完成节点
|
|
|
|
|
|
if (Array.isArray(finishedSequenceFlowActivityIds)) {
|
|
|
|
|
|
finishedSequenceFlowActivityIds.forEach((item: any) => {
|
2025-09-07 19:25:34 +08:00
|
|
|
|
if (item !== null) {
|
2025-09-05 15:38:09 +08:00
|
|
|
|
canvas.addMarker(item, 'success');
|
|
|
|
|
|
const element = elementRegistry.get(item);
|
|
|
|
|
|
const conditionExpression = element.businessObject.conditionExpression;
|
|
|
|
|
|
if (conditionExpression) {
|
|
|
|
|
|
canvas.addMarker(item, 'condition-expression');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Array.isArray(finishedTaskActivityIds)) {
|
|
|
|
|
|
finishedTaskActivityIds.forEach((item: any) =>
|
|
|
|
|
|
canvas.addMarker(item, 'success'),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 未完成节点
|
|
|
|
|
|
if (Array.isArray(unfinishedTaskActivityIds)) {
|
|
|
|
|
|
unfinishedTaskActivityIds.forEach((item: any) =>
|
|
|
|
|
|
canvas.addMarker(item, 'primary'),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 被拒绝节点
|
|
|
|
|
|
if (Array.isArray(rejectedTaskActivityIds)) {
|
|
|
|
|
|
rejectedTaskActivityIds.forEach((item: any) => {
|
2025-09-07 19:25:34 +08:00
|
|
|
|
if (item !== null) {
|
2025-09-05 15:38:09 +08:00
|
|
|
|
canvas.addMarker(item, 'danger');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 特殊:处理 end 节点的高亮。因为 end 在拒绝、取消时,被后端计算成了 finishedTaskActivityIds 里
|
|
|
|
|
|
if (
|
|
|
|
|
|
[BpmProcessInstanceStatus.CANCEL, BpmProcessInstanceStatus.REJECT].includes(
|
|
|
|
|
|
processInstance.value.status,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
const endNodes = elementRegistry.filter(
|
|
|
|
|
|
(element: any) => element.type === 'bpmn:EndEvent',
|
|
|
|
|
|
);
|
|
|
|
|
|
endNodes.forEach((item: any) => {
|
|
|
|
|
|
canvas.removeMarker(item.id, 'success');
|
|
|
|
|
|
if (processInstance.value.status === BpmProcessInstanceStatus.CANCEL) {
|
|
|
|
|
|
canvas.addMarker(item.id, 'cancel');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
canvas.addMarker(item.id, 'danger');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.xml,
|
|
|
|
|
|
(newXml) => {
|
|
|
|
|
|
importXML(newXml);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.view,
|
|
|
|
|
|
(newView) => {
|
|
|
|
|
|
setProcessStatus(newView);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/** mounted:初始化 */
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
importXML(props.xml);
|
|
|
|
|
|
setProcessStatus(props.view);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** unmount:销毁 */
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
clearViewer();
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
2025-09-07 19:25:34 +08:00
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="process-viewer">
|
|
|
|
|
|
<div style="height: 100%" ref="processCanvas" v-show="!isLoading"></div>
|
|
|
|
|
|
<!-- 自定义箭头样式,用于已完成状态下流程连线箭头 -->
|
|
|
|
|
|
<defs ref="customDefs">
|
|
|
|
|
|
<marker
|
|
|
|
|
|
id="sequenceflow-end-white-success"
|
|
|
|
|
|
viewBox="0 0 20 20"
|
|
|
|
|
|
refX="11"
|
|
|
|
|
|
refY="10"
|
|
|
|
|
|
markerWidth="10"
|
|
|
|
|
|
markerHeight="10"
|
|
|
|
|
|
orient="auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
class="success-arrow"
|
|
|
|
|
|
d="M 1 5 L 11 10 L 1 15 Z"
|
|
|
|
|
|
style="
|
|
|
|
|
|
stroke-width: 1px;
|
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
|
stroke-dasharray: 10000, 1;
|
|
|
|
|
|
"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</marker>
|
|
|
|
|
|
<marker
|
|
|
|
|
|
id="conditional-flow-marker-white-success"
|
|
|
|
|
|
viewBox="0 0 20 20"
|
|
|
|
|
|
refX="-1"
|
|
|
|
|
|
refY="10"
|
|
|
|
|
|
markerWidth="10"
|
|
|
|
|
|
markerHeight="10"
|
|
|
|
|
|
orient="auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
class="success-conditional"
|
|
|
|
|
|
d="M 0 10 L 8 6 L 16 10 L 8 14 Z"
|
|
|
|
|
|
style="
|
|
|
|
|
|
stroke-width: 1px;
|
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
|
stroke-dasharray: 10000, 1;
|
|
|
|
|
|
"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</marker>
|
|
|
|
|
|
</defs>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 审批记录 -->
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
:title="dialogTitle || '审批记录'"
|
|
|
|
|
|
v-model:open="dialogVisible"
|
|
|
|
|
|
:width="1000"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Row>
|
|
|
|
|
|
<Table :data-source="selectTasks" size="small" :bordered="true">
|
|
|
|
|
|
<Table.Column title="序号" align="center" width="50">
|
|
|
|
|
|
<template #default="{ index }">
|
|
|
|
|
|
{{ index + 1 }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Table.Column>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
title="审批人"
|
|
|
|
|
|
width="100"
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
v-if="selectActivityType === 'bpmn:UserTask'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default="{ record }">
|
|
|
|
|
|
{{ record.assigneeUser?.nickname || record.ownerUser?.nickname }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Table.Column>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
title="发起人"
|
|
|
|
|
|
data-index="assigneeUser.nickname"
|
|
|
|
|
|
width="100"
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
v-else
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Table.Column title="部门" width="100" align="center">
|
|
|
|
|
|
<template #default="{ record }">
|
|
|
|
|
|
{{ record.assigneeUser?.deptName || record.ownerUser?.deptName }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Table.Column>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
:custom-render="({ text }) => dateFormatter(text)"
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
title="开始时间"
|
|
|
|
|
|
data-index="createTime"
|
|
|
|
|
|
width="140"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
:custom-render="({ text }) => dateFormatter(text)"
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
title="结束时间"
|
|
|
|
|
|
data-index="endTime"
|
|
|
|
|
|
width="140"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
title="审批状态"
|
|
|
|
|
|
data-index="status"
|
|
|
|
|
|
width="90"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default="{ record }">
|
|
|
|
|
|
<DictTag
|
|
|
|
|
|
:type="DICT_TYPE.BPM_TASK_STATUS"
|
|
|
|
|
|
:value="record.status"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Table.Column>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
title="审批建议"
|
|
|
|
|
|
data-index="reason"
|
|
|
|
|
|
width="120"
|
|
|
|
|
|
v-if="selectActivityType === 'bpmn:UserTask'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Table.Column
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
title="耗时"
|
|
|
|
|
|
data-index="durationInMillis"
|
|
|
|
|
|
width="100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #default="{ record }">
|
|
|
|
|
|
{{ formatPast2(record.durationInMillis) }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Table.Column>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Zoom:放大、缩小 -->
|
|
|
|
|
|
<div style="position: absolute; top: 0; left: 0; width: 100%">
|
|
|
|
|
|
<Row justify="end">
|
|
|
|
|
|
<ButtonGroup key="scale-control">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
:disabled="defaultZoom <= 0.3"
|
|
|
|
|
|
:icon="ZoomOutOutlined"
|
|
|
|
|
|
@click="processZoomOut()"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button style="width: 90px">
|
|
|
|
|
|
{{ `${Math.floor(defaultZoom * 10 * 10)}%` }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
:disabled="defaultZoom >= 3.9"
|
|
|
|
|
|
:icon="ZoomInOutlined"
|
|
|
|
|
|
@click="processZoomIn()"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button :icon="UndoOutlined" @click="processReZoom()" />
|
|
|
|
|
|
</ButtonGroup>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|