ProcessViewer.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <template>
  2. <div class="process-viewer">
  3. <div style="height: 100%" ref="processCanvas" v-show="!isLoading"> </div>
  4. <!-- 自定义箭头样式,用于已完成状态下流程连线箭头 -->
  5. <defs ref="customDefs">
  6. <marker
  7. id="sequenceflow-end-white-success"
  8. viewBox="0 0 20 20"
  9. refX="11"
  10. refY="10"
  11. markerWidth="10"
  12. markerHeight="10"
  13. orient="auto"
  14. >
  15. <path
  16. class="success-arrow"
  17. d="M 1 5 L 11 10 L 1 15 Z"
  18. style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1"
  19. />
  20. </marker>
  21. <marker
  22. id="conditional-flow-marker-white-success"
  23. viewBox="0 0 20 20"
  24. refX="-1"
  25. refY="10"
  26. markerWidth="10"
  27. markerHeight="10"
  28. orient="auto"
  29. >
  30. <path
  31. class="success-conditional"
  32. d="M 0 10 L 8 6 L 16 10 L 8 14 Z"
  33. style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1"
  34. />
  35. </marker>
  36. </defs>
  37. <!-- 审批记录 -->
  38. <el-dialog :title="dialogTitle || '审批记录'" v-model="dialogVisible" width="1000px">
  39. <el-row>
  40. <el-table
  41. :data="selectTasks"
  42. size="small"
  43. border
  44. header-cell-class-name="table-header-gray"
  45. >
  46. <el-table-column
  47. label="序号"
  48. header-align="center"
  49. align="center"
  50. type="index"
  51. width="50"
  52. />
  53. <el-table-column
  54. label="审批人"
  55. min-width="100"
  56. align="center"
  57. v-if="selectActivityType === 'bpmn:UserTask'"
  58. >
  59. <template #default="scope">
  60. {{ scope.row.assigneeUser?.nickname || scope.row.ownerUser?.nickname }}
  61. </template>
  62. </el-table-column>
  63. <el-table-column
  64. label="发起人"
  65. prop="assigneeUser.nickname"
  66. min-width="100"
  67. align="center"
  68. v-else
  69. />
  70. <el-table-column label="部门" min-width="100" align="center">
  71. <template #default="scope">
  72. {{ scope.row.assigneeUser?.deptName || scope.row.ownerUser?.deptName }}
  73. </template>
  74. </el-table-column>
  75. <el-table-column
  76. :formatter="dateFormatter"
  77. align="center"
  78. label="开始时间"
  79. prop="createTime"
  80. min-width="140"
  81. />
  82. <el-table-column
  83. :formatter="dateFormatter"
  84. align="center"
  85. label="结束时间"
  86. prop="endTime"
  87. min-width="140"
  88. />
  89. <el-table-column align="center" label="审批状态" prop="status" min-width="90">
  90. <template #default="scope">
  91. <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" />
  92. </template>
  93. </el-table-column>
  94. <el-table-column
  95. align="center"
  96. label="审批建议"
  97. prop="reason"
  98. min-width="120"
  99. v-if="selectActivityType === 'bpmn:UserTask'"
  100. />
  101. <el-table-column align="center" label="耗时" prop="durationInMillis" width="100">
  102. <template #default="scope">
  103. {{ formatPast2(scope.row.durationInMillis) }}
  104. </template>
  105. </el-table-column>
  106. </el-table>
  107. </el-row>
  108. </el-dialog>
  109. <!-- Zoom:放大、缩小 -->
  110. <div style="position: absolute; top: 0; left: 0; width: 100%">
  111. <el-row type="flex" justify="end">
  112. <el-button-group key="scale-control" size="default">
  113. <el-button
  114. size="default"
  115. :plain="true"
  116. :disabled="defaultZoom <= 0.3"
  117. :icon="ZoomOut"
  118. @click="processZoomOut()"
  119. />
  120. <el-button size="default" style="width: 90px">
  121. {{ Math.floor(defaultZoom * 10 * 10) + '%' }}
  122. </el-button>
  123. <el-button
  124. size="default"
  125. :plain="true"
  126. :disabled="defaultZoom >= 3.9"
  127. :icon="ZoomIn"
  128. @click="processZoomIn()"
  129. />
  130. <el-button size="default" :icon="ScaleToOriginal" @click="processReZoom()" />
  131. </el-button-group>
  132. </el-row>
  133. </div>
  134. </div>
  135. </template>
  136. <script lang="ts" setup>
  137. import '../theme/index.scss'
  138. import BpmnViewer from 'bpmn-js/lib/Viewer'
  139. import MoveCanvasModule from 'diagram-js/lib/navigation/movecanvas'
  140. import { ZoomOut, ZoomIn, ScaleToOriginal } from '@element-plus/icons-vue'
  141. import { DICT_TYPE } from '@/utils/dict'
  142. import { dateFormatter, formatPast2 } from '@/utils/formatTime'
  143. import { BpmProcessInstanceStatus } from '@/utils/constants'
  144. const props = defineProps({
  145. xml: {
  146. type: String,
  147. required: true
  148. },
  149. view: {
  150. type: Object,
  151. require: true
  152. }
  153. })
  154. const processCanvas = ref()
  155. const bpmnViewer = ref<BpmnViewer | null>(null)
  156. const customDefs = ref()
  157. const defaultZoom = ref(1) // 默认缩放比例
  158. const isLoading = ref(false) // 是否加载中
  159. const processInstance = ref<any>({}) // 流程实例
  160. const tasks = ref([]) // 流程任务
  161. const dialogVisible = ref(false) // 弹窗可见性
  162. const dialogTitle = ref<string | undefined>(undefined) // 弹窗标题
  163. const selectActivityType = ref<string | undefined>(undefined) // 选中 Task 的活动编号
  164. const selectTasks = ref<any[]>([]) // 选中的任务数组
  165. /** Zoom:恢复 */
  166. const processReZoom = () => {
  167. defaultZoom.value = 1
  168. bpmnViewer.value?.get('canvas').zoom('fit-viewport', 'auto')
  169. }
  170. /** Zoom:放大 */
  171. const processZoomIn = (zoomStep = 0.1) => {
  172. let newZoom = Math.floor(defaultZoom.value * 100 + zoomStep * 100) / 100
  173. if (newZoom > 4) {
  174. throw new Error('[Process Designer Warn ]: The zoom ratio cannot be greater than 4')
  175. }
  176. defaultZoom.value = newZoom
  177. bpmnViewer.value?.get('canvas').zoom(defaultZoom.value)
  178. }
  179. /** Zoom:缩小 */
  180. const processZoomOut = (zoomStep = 0.1) => {
  181. let newZoom = Math.floor(defaultZoom.value * 100 - zoomStep * 100) / 100
  182. if (newZoom < 0.2) {
  183. throw new Error('[Process Designer Warn ]: The zoom ratio cannot be less than 0.2')
  184. }
  185. defaultZoom.value = newZoom
  186. bpmnViewer.value?.get('canvas').zoom(defaultZoom.value)
  187. }
  188. /** 流程图预览清空 */
  189. const clearViewer = () => {
  190. if (processCanvas.value) {
  191. processCanvas.value.innerHTML = ''
  192. }
  193. if (bpmnViewer.value) {
  194. bpmnViewer.value.destroy()
  195. }
  196. bpmnViewer.value = null
  197. }
  198. /** 添加自定义箭头 */
  199. // TODO 芋艿:自定义箭头不生效,有点奇怪!!!!相关的 marker-end、marker-start 暂时也注释了!!!
  200. const addCustomDefs = () => {
  201. if (!bpmnViewer.value) {
  202. return
  203. }
  204. const canvas = bpmnViewer.value?.get('canvas')
  205. const svg = canvas?._svg
  206. svg.appendChild(customDefs.value)
  207. }
  208. /** 节点选中 */
  209. const onSelectElement = (element: any) => {
  210. // 清空原选中
  211. selectActivityType.value = undefined
  212. dialogTitle.value = undefined
  213. if (!element || !processInstance.value?.id) {
  214. return
  215. }
  216. // UserTask 的情况
  217. const activityType = element.type
  218. selectActivityType.value = activityType
  219. if (activityType === 'bpmn:UserTask') {
  220. dialogTitle.value = element.businessObject ? element.businessObject.name : undefined
  221. selectTasks.value = tasks.value.filter((item: any) => item?.taskDefinitionKey === element.id)
  222. dialogVisible.value = true
  223. } else if (activityType === 'bpmn:EndEvent' || activityType === 'bpmn:StartEvent') {
  224. dialogTitle.value = '审批信息'
  225. selectTasks.value = [
  226. {
  227. assigneeUser: processInstance.value.startUser,
  228. createTime: processInstance.value.startTime,
  229. endTime: processInstance.value.endTime,
  230. status: processInstance.value.status,
  231. durationInMillis: processInstance.value.durationInMillis
  232. }
  233. ]
  234. dialogVisible.value = true
  235. }
  236. }
  237. /** 初始化 BPMN 视图 */
  238. const importXML = async (xml: string) => {
  239. // 清空流程图
  240. clearViewer()
  241. // 初始化流程图
  242. if (xml != null && xml !== '') {
  243. try {
  244. bpmnViewer.value = new BpmnViewer({
  245. additionalModules: [MoveCanvasModule],
  246. container: processCanvas.value
  247. })
  248. // 增加点击事件
  249. bpmnViewer.value.on('element.click', ({ element }) => {
  250. onSelectElement(element)
  251. })
  252. // 初始化 BPMN 视图
  253. isLoading.value = true
  254. await bpmnViewer.value.importXML(xml)
  255. // 自定义成功的箭头
  256. addCustomDefs()
  257. } catch (e) {
  258. clearViewer()
  259. } finally {
  260. isLoading.value = false
  261. // 高亮流程
  262. setProcessStatus(props.view)
  263. }
  264. }
  265. }
  266. /** 高亮流程 */
  267. const setProcessStatus = (view: any) => {
  268. // 设置相关变量
  269. if (!view || !view.processInstance) {
  270. return
  271. }
  272. processInstance.value = view.processInstance
  273. tasks.value = view.tasks
  274. if (isLoading.value || !bpmnViewer.value) {
  275. return
  276. }
  277. const {
  278. unfinishedTaskActivityIds,
  279. finishedTaskActivityIds,
  280. finishedSequenceFlowActivityIds,
  281. rejectedTaskActivityIds
  282. } = view
  283. const canvas = bpmnViewer.value.get('canvas')
  284. const elementRegistry = bpmnViewer.value.get('elementRegistry')
  285. // 已完成节点
  286. if (Array.isArray(finishedSequenceFlowActivityIds)) {
  287. finishedSequenceFlowActivityIds.forEach((item: any) => {
  288. if (item != null) {
  289. canvas.addMarker(item, 'success')
  290. const element = elementRegistry.get(item)
  291. const conditionExpression = element.businessObject.conditionExpression
  292. if (conditionExpression) {
  293. canvas.addMarker(item, 'condition-expression')
  294. }
  295. }
  296. })
  297. }
  298. if (Array.isArray(finishedTaskActivityIds)) {
  299. finishedTaskActivityIds.forEach((item: any) => canvas.addMarker(item, 'success'))
  300. }
  301. // 未完成节点
  302. if (Array.isArray(unfinishedTaskActivityIds)) {
  303. unfinishedTaskActivityIds.forEach((item: any) => canvas.addMarker(item, 'primary'))
  304. }
  305. // 被拒绝节点
  306. if (Array.isArray(rejectedTaskActivityIds)) {
  307. rejectedTaskActivityIds.forEach((item: any) => {
  308. if (item != null) {
  309. canvas.addMarker(item, 'danger')
  310. }
  311. })
  312. }
  313. // 特殊:处理 end 节点的高亮。因为 end 在拒绝、取消时,被后端计算成了 finishedTaskActivityIds 里
  314. if (
  315. [BpmProcessInstanceStatus.CANCEL, BpmProcessInstanceStatus.REJECT].includes(
  316. processInstance.value.status
  317. )
  318. ) {
  319. const endNodes = elementRegistry.filter((element: any) => element.type === 'bpmn:EndEvent')
  320. endNodes.forEach((item: any) => {
  321. canvas.removeMarker(item.id, 'success')
  322. if (processInstance.value.status === BpmProcessInstanceStatus.CANCEL) {
  323. canvas.addMarker(item.id, 'cancel')
  324. } else {
  325. canvas.addMarker(item.id, 'danger')
  326. }
  327. })
  328. }
  329. }
  330. watch(
  331. () => props.xml,
  332. (newXml) => {
  333. importXML(newXml)
  334. },
  335. { immediate: true }
  336. )
  337. watch(
  338. () => props.view,
  339. (newView) => {
  340. setProcessStatus(newView)
  341. },
  342. { immediate: true }
  343. )
  344. /** mounted:初始化 */
  345. onMounted(() => {
  346. importXML(props.xml)
  347. setProcessStatus(props.view)
  348. })
  349. /** unmount:销毁 */
  350. onBeforeUnmount(() => {
  351. clearViewer()
  352. })
  353. </script>