ProcessViewer.vue 11 KB

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