<script lang="ts" setup>
|
import { computed, ref, watch } from 'vue';
|
|
import { useGlobSetting } from '@jnpf/hooks';
|
import { useModal } from '@jnpf/ui/modal';
|
import { formatToDateTime } from '@jnpf/utils';
|
|
import { Timeline, TimelineItem } from 'ant-design-vue';
|
|
import { flowNodeList } from '#/components/FlowProcess/src/helper/componentMap';
|
import { useFlowState } from '#/hooks/flow/useFlowStatus';
|
import { getSubFlowInfo } from '#/api/workFlow/task';
|
|
import LogErrorModal from './../components/modal/LogErrorModal.vue';
|
import CirculateUserModal from './modal/CirculateUserModal.vue';
|
import RecordModal from './modal/RecordModal.vue';
|
import TaskLogModal from './modal/TaskLogModal.vue';
|
|
const props: any = defineProps({
|
list: { type: Array, default: () => [] },
|
endTime: { type: Number, default: 0 },
|
opType: { default: '' },
|
taskId: { type: [String, Number], default: '' },
|
nodeList: { type: Array, default: () => [] },
|
flowInfo: { type: Object, default: () => ({}) },
|
});
|
const emit = defineEmits(['onRetry', 'viewSubFlow']);
|
const globSetting = useGlobSetting();
|
const apiUrl = ref(globSetting.apiURL);
|
const { getFlowStateContent, getFlowStateColor, getHexColor } = useFlowState();
|
const [registerRecord, { openModal: openRecordModal }] = useModal();
|
const [registerTaskLog, { openModal: openTaskLogModal }] = useModal();
|
const [registerCirculateUserModal, { openModal: openCirculateUserModal }] = useModal();
|
const [registerLogErrorModal, { openModal: openLogErrorModal }] = useModal();
|
const subFlowTimeMap = ref<Record<string, number>>({});
|
const subFlowTaskMap = ref<Record<string, boolean>>({});
|
const subFlowStatusMap: Record<string, { color: string; dotColor: string; text: string }> = {
|
'-1': { color: 'default', dotColor: '#d9d9d9', text: '未经过' },
|
0: { color: 'success', dotColor: '#08AF28', text: '已经过' },
|
1: { color: 'processing', dotColor: '#0177FF', text: '进行中' },
|
2: { color: 'default', dotColor: '#d9d9d9', text: '未经过' },
|
3: { color: 'error', dotColor: '#ed6f6f', text: '异常' },
|
};
|
const getNodeIndexMap = computed(() => {
|
const flowNodeIndexMap = getFlowNodeIndexMap();
|
if (Object.keys(flowNodeIndexMap).length) return flowNodeIndexMap;
|
return ((props.nodeList || []) as any[]).reduce((map: Record<string, number>, item, index) => {
|
if (item?.nodeCode) map[item.nodeCode] = index;
|
return map;
|
}, {});
|
});
|
const getSubFlowList = computed(() => {
|
return (props.nodeList || [])
|
.filter((o) => o?.nodeType === 'subFlow')
|
.filter((o) => ['0', '1', '3'].includes(String(o.type)) && subFlowTaskMap.value[o.nodeCode])
|
.map((o) => {
|
const type = String(o.type);
|
return {
|
...o,
|
itemType: 'subFlow',
|
startTime: subFlowTimeMap.value[o.nodeCode],
|
canViewSubFlow: true,
|
nodeStatusInfo: subFlowStatusMap[type] || subFlowStatusMap[2],
|
};
|
});
|
});
|
const getTimeList = computed(() => {
|
const recordList = (props.list || []).map((o, index) => ({ ...o, itemType: 'record', originIndex: index }));
|
const recordIndexList = recordList.map((o) => getNodeIndexMap.value[o.nodeCode]).filter((index) => typeof index === 'number');
|
const isDesc = recordIndexList.length > 1 ? recordIndexList[0] > recordIndexList[recordIndexList.length - 1] : true;
|
const list: any[] = [...recordList];
|
getSubFlowList.value.forEach((subFlow) => {
|
const anchor = getSubFlowAnchor(subFlow.nodeCode, isDesc);
|
const anchorIndex = list.findIndex((item) => item.itemType === 'record' && item.nodeCode === anchor?.nodeCode);
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + (anchor?.position === 'after' ? 1 : 0) : list.length;
|
const anchorRecord = anchorIndex >= 0 ? list[anchorIndex] : null;
|
list.splice(insertIndex, 0, { ...subFlow, startTime: subFlow.startTime || anchorRecord?.startTime });
|
});
|
return list;
|
});
|
function getFlowXmlData() {
|
const xml = props.flowInfo?.flowXml;
|
if (!xml || typeof DOMParser === 'undefined') return null;
|
try {
|
const xmlDoc = new DOMParser().parseFromString(decodeURIComponent(xml), 'text/xml');
|
const flows = Array.from(xmlDoc.getElementsByTagName('bpmn2:sequenceFlow')).map((item: any) => ({
|
sourceRef: item.getAttribute('sourceRef'),
|
targetRef: item.getAttribute('targetRef'),
|
}));
|
const nodeCodeSet = new Set((props.nodeList || []).map((o) => o?.nodeCode).filter(Boolean));
|
return { flows, nodeCodeSet };
|
} catch {
|
return null;
|
}
|
}
|
function getFlowNodeIndexMap() {
|
const xmlData = getFlowXmlData();
|
if (!xmlData) return {};
|
try {
|
const { flows, nodeCodeSet } = xmlData;
|
const targetSet = new Set(flows.map((o) => o.targetRef));
|
const startNode = Array.from(nodeCodeSet).find((code) => !targetSet.has(code)) || (props.nodeList || [])[0]?.nodeCode;
|
const order: string[] = [];
|
const visited = new Set<string>();
|
function walk(nodeCode) {
|
if (!nodeCode || visited.has(nodeCode)) return;
|
visited.add(nodeCode);
|
if (nodeCodeSet.has(nodeCode)) order.push(nodeCode);
|
flows
|
.filter((o) => o.sourceRef === nodeCode)
|
.forEach((o) => walk(o.targetRef));
|
}
|
walk(startNode);
|
(props.nodeList || []).forEach((o) => {
|
if (o?.nodeCode && !visited.has(o.nodeCode)) order.push(o.nodeCode);
|
});
|
return order.reduce((map: Record<string, number>, nodeCode, index) => {
|
map[nodeCode] = index;
|
return map;
|
}, {});
|
} catch {
|
return {};
|
}
|
}
|
function getSubFlowAnchor(nodeCode, isDesc) {
|
const xmlData = getFlowXmlData();
|
if (!xmlData) return null;
|
const recordNodeCodeSet = new Set((props.list || []).map((o) => o.nodeCode).filter(Boolean));
|
const { flows } = xmlData;
|
const getPrevRecordNode = (code, visited = new Set<string>()) => {
|
if (!code || visited.has(code)) return null;
|
visited.add(code);
|
const sources = flows.filter((o) => o.targetRef === code).map((o) => o.sourceRef);
|
for (const source of sources) {
|
if (recordNodeCodeSet.has(source)) return source;
|
const prev = getPrevRecordNode(source, visited);
|
if (prev) return prev;
|
}
|
return null;
|
};
|
const getNextRecordNode = (code, visited = new Set<string>()) => {
|
if (!code || visited.has(code)) return null;
|
visited.add(code);
|
const targets = flows.filter((o) => o.sourceRef === code).map((o) => o.targetRef);
|
for (const target of targets) {
|
if (recordNodeCodeSet.has(target)) return target;
|
const next = getNextRecordNode(target, visited);
|
if (next) return next;
|
}
|
return null;
|
};
|
const nextNodeCode = getNextRecordNode(nodeCode);
|
const prevNodeCode = getPrevRecordNode(nodeCode);
|
if (isDesc) {
|
if (nextNodeCode) return { nodeCode: nextNodeCode, position: 'after' };
|
if (prevNodeCode) return { nodeCode: prevNodeCode, position: 'before' };
|
return null;
|
}
|
if (prevNodeCode) return { nodeCode: prevNodeCode, position: 'after' };
|
if (nextNodeCode) return { nodeCode: nextNodeCode, position: 'before' };
|
return null;
|
}
|
function getNodeStatusColor(status) {
|
return status == 1 || status == 2 ? 'success' : status == 3 ? 'error' : 'blue';
|
}
|
function getTimeLineTagColor(status) {
|
return status == 1 || status == 2 ? '#08AF28' : status == 3 ? '#ed6f6f' : '#0177FF';
|
}
|
function getNodeStatusContent(status) {
|
const list = ['', '已提交', '已通过', '已拒绝', '审批中', '已退回', '已撤回', '等待中', '办理中'];
|
return list[status] || '';
|
}
|
function getCounterSignContent(counterSign, assigneeType) {
|
if (assigneeType == 10) return '逐级审批';
|
return counterSign == 0 ? '或签' : counterSign == 1 ? '会签' : '依次审批';
|
}
|
function getOutsideState(state: boolean) {
|
return state ? '成功' : '失败';
|
}
|
function getNodeIcon(nodeType) {
|
const list = flowNodeList.find((o) => o.option.wnType == nodeType);
|
return list?.icon || 'icon-ym icon-ym-flow-node-start';
|
}
|
function handleShowRecordModal(item) {
|
const title = `${item.nodeName}(${getCounterSignContent(item.counterSign, item.assigneeType)})`;
|
openRecordModal(true, { taskId: props.taskId, nodeId: item.nodeId, title });
|
}
|
function handleShowTaskLogModal(item) {
|
openTaskLogModal(true, { taskId: props.taskId, nodeId: item.nodeId });
|
}
|
function handleShowErrorModal(item) {
|
openLogErrorModal(true, { errorTip: item.errorTip, errorData: item.errorData });
|
}
|
function handleRetry(item) {
|
emit('onRetry', item.nodeId);
|
}
|
function handleShowCirculateUserModal(item) {
|
openCirculateUserModal(true, { taskId: props.taskId, nodeId: item.nodeId });
|
}
|
function handleShowSubFlow(item) {
|
emit('viewSubFlow', item.nodeCode);
|
}
|
function resetSubFlowState() {
|
subFlowTimeMap.value = {};
|
subFlowTaskMap.value = {};
|
}
|
function loadSubFlowTimes() {
|
if (!props.taskId) return;
|
(props.nodeList || [])
|
.filter((item) => item?.nodeType === 'subFlow' && ['0', '1', '3'].includes(String(item.type)) && subFlowTaskMap.value[item.nodeCode] !== false)
|
.forEach((item) => {
|
getSubFlowInfo(item.nodeCode, props.taskId)
|
.then((res) => {
|
const data = res.data || [];
|
subFlowTaskMap.value = { ...subFlowTaskMap.value, [item.nodeCode]: !!data.length };
|
const time = data
|
.map((o) => o?.taskInfo?.creatorTime)
|
.filter(Boolean)
|
.sort((a, b) => a - b)[0];
|
if (time) subFlowTimeMap.value = { ...subFlowTimeMap.value, [item.nodeCode]: time };
|
})
|
.catch(() => {});
|
});
|
}
|
|
watch(
|
() => [props.taskId, props.nodeList],
|
() => {
|
resetSubFlowState();
|
loadSubFlowTimes();
|
},
|
{ immediate: true, deep: true },
|
);
|
</script>
|
<template>
|
<Timeline class="record-time-list-container">
|
<TimelineItem v-for="item in getTimeList" :key="`${item.itemType}-${item.nodeCode || item.nodeId || item.originIndex}`">
|
<template #dot>
|
<span class="tag" :style="{ background: item.itemType === 'subFlow' ? item.nodeStatusInfo.dotColor : getTimeLineTagColor(item.nodeStatus) }"></span>
|
</template>
|
<div class="time-item-container">
|
<template v-if="item.itemType === 'subFlow'">
|
<span v-if="item.startTime">{{ formatToDateTime(item.startTime, 'YYYY-MM-DD HH:mm') }}</span>
|
<div class="time-node-name">
|
<i class="icon-ym icon-ym-flow-node-subFlow"></i>
|
<span class="node-name">{{ item.nodeName }}</span>
|
<a-tag :color="item.nodeStatusInfo.color" :bordered="false" class="node-status">{{ item.nodeStatusInfo.text }}</a-tag>
|
</div>
|
<div class="sub-flow-user" v-if="item.userName">{{ item.userName }}</div>
|
<div class="counter-sign" @click="handleShowSubFlow(item)" v-if="item.canViewSubFlow">
|
<span>子流程</span>
|
<i class="icon-ym icon-ym-right"></i>
|
</div>
|
</template>
|
<template v-else>
|
<span>{{ formatToDateTime(item.startTime, 'YYYY-MM-DD HH:mm') }}</span>
|
<div class="time-node-name">
|
<i :class="getNodeIcon(item.nodeType)"></i>
|
<span class="node-name">{{ item.nodeName }}</span>
|
<a-tag :color="getNodeStatusColor(item.nodeStatus)" :bordered="false" class="node-status">{{ getNodeStatusContent(item.nodeStatus) }}</a-tag>
|
</div>
|
<div class="time-node-approver" v-if="item.approver?.length">
|
<div class="approver-container">
|
<div class="approver-item" v-for="child in item.approver.slice(0, 4)" :key="child">
|
<a-avatar :size="24" :src="apiUrl + child.headIcon" />
|
<a-tag class="node-handle-type" :color="getHexColor(getFlowStateColor(child.handleType))">{{ getFlowStateContent(child.handleType) }}</a-tag>
|
<span class="user-name">{{ child.userName }}</span>
|
</div>
|
</div>
|
<div class="approver-count" v-if="item.approverCount">{{ item.approverCount }}</div>
|
</div>
|
<div class="counter-sign" @click="handleShowRecordModal(item)" v-if="['approver', 'processing'].includes(item.nodeType)">
|
<span>{{ getCounterSignContent(item.counterSign, item.assigneeType) }}</span>
|
<i class="icon-ym icon-ym-right"></i>
|
</div>
|
<div class="counter-sign" @click="handleShowCirculateUserModal(item)" v-if="item.isCirculate">
|
<span>抄送人员</span>
|
<i class="icon-ym icon-ym-right"></i>
|
</div>
|
<div class="outside-sign" v-if="item.nodeType == 'outside'">
|
<div>数据传递{{ getOutsideState(item.outSideStatus) }}</div>
|
<div v-if="!item.outSideStatus">
|
<a-button type="link" size="small" @click="handleShowErrorModal(item)">查看异常</a-button>
|
<a-button type="link" size="small" @click="handleRetry(item)" danger v-if="item.isRetry">重试</a-button>
|
</div>
|
</div>
|
<div class="counter-sign" @click="handleShowTaskLogModal(item)" v-if="item.showTaskFlow">
|
<span>任务流程</span>
|
<i class="icon-ym icon-ym-right"></i>
|
</div>
|
</template>
|
</div>
|
</TimelineItem>
|
</Timeline>
|
<RecordModal @register="registerRecord" />
|
<TaskLogModal @register="registerTaskLog" />
|
<LogErrorModal @register="registerLogErrorModal" />
|
<CirculateUserModal @register="registerCirculateUserModal" />
|
</template>
|
<style lang="scss">
|
.record-time-list-container {
|
height: 100%;
|
padding: 24px 12px;
|
overflow: auto;
|
|
.tag {
|
display: block;
|
width: 10px;
|
height: 10px;
|
border-radius: 50%;
|
}
|
|
.time-item-container {
|
margin-top: 8px;
|
background-color: var(--app-content-background);
|
border-radius: 4px;
|
|
.time-node-name {
|
display: flex;
|
align-items: center;
|
height: 40px;
|
margin-left: 10px;
|
|
i {
|
margin-right: 4px;
|
font-size: 12px;
|
}
|
|
.node-name {
|
flex: 1;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.node-status {
|
padding-inline: 10px;
|
border-radius: 10px;
|
}
|
}
|
|
.time-node-approver {
|
display: flex;
|
|
.approver-container {
|
display: flex;
|
flex: 1;
|
justify-content: flex-start;
|
min-width: 0;
|
margin: 0 10px 10px;
|
|
.approver-item {
|
position: relative;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
width: 25%;
|
|
.node-handle-type {
|
z-index: 999;
|
margin: -8px auto 0;
|
}
|
|
.user-name {
|
width: 100%;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
text-align: center;
|
white-space: nowrap;
|
}
|
}
|
}
|
|
.approver-count {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
width: 24px;
|
height: 24px;
|
margin-right: 10px;
|
background-color: var(--component-background);
|
border-radius: 12px;
|
}
|
}
|
|
.counter-sign {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
height: 40px;
|
margin: 0 16px;
|
cursor: pointer;
|
border-top: 1px solid var(--border-color-base);
|
|
span {
|
height: 20px;
|
padding: 0 12px;
|
line-height: 20px;
|
text-align: center;
|
background: #e2e2e2;
|
border-radius: 4px;
|
}
|
}
|
|
.sub-flow-user {
|
padding: 0 12px 10px;
|
overflow: hidden;
|
color: var(--text-color-secondary);
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.outside-sign {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
height: 40px;
|
margin: 0 16px;
|
cursor: pointer;
|
border-top: 1px solid var(--border-color-base);
|
|
span {
|
height: 20px;
|
line-height: 20px;
|
text-align: center;
|
border-radius: 4px;
|
}
|
}
|
}
|
}
|
</style>
|