|
|
@@ -0,0 +1,517 @@
|
|
|
+<template>
|
|
|
+ <div class="designer">
|
|
|
+ <div class="designer-aside">
|
|
|
+ <el-space direction="vertical">
|
|
|
+ <span>节点类型</span>
|
|
|
+ <el-button type="success" @click="onAddApprove">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><Avatar /></el-icon>
|
|
|
+ </template>
|
|
|
+ <span>审批</span>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="success" @click="onAddBranch">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><Share /></el-icon>
|
|
|
+ </template>
|
|
|
+ <span>分支</span>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="success" @click="onAddVirtual">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><Finished /></el-icon>
|
|
|
+ </template>
|
|
|
+ <span>虚拟</span>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="success" @click="onAddCopy">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><CopyDocument /></el-icon>
|
|
|
+ </template>
|
|
|
+ <span>抄送</span>
|
|
|
+ </el-button>
|
|
|
+ </el-space>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="designer-main">
|
|
|
+ <div class="designer-main-toolbar">
|
|
|
+ <el-button type="primary" @click="onZoomIn">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><ZoomIn /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" @click="onZoomOut">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><ZoomOut /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" @click="onCenter">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><FullScreen /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" @click="onZoomFit">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><Rank /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" @click="onView">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><View /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" @click="onSave">
|
|
|
+ <template #icon>
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="designer-main-canvas" ref="canvasRef">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="designer-property">
|
|
|
+ <design-property :graph="graphInstance" :mode="state.mode" :current="state.current" @on-save-process="onSaveProcess" @on-save-node="onSaveNode" @on-save-flow="onSaveFlow" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <design-json :visible="state.visible" :mode="state.mode" :json="state.json" @on-save="onSaveJson" @on-close="onCloseJson"></design-json>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { nextTick, onMounted, reactive, ref } from 'vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import { useRoute } from 'vue-router'
|
|
|
+import { Graph, Node, Edge, Cell, Model } from '@antv/x6'
|
|
|
+import { cloneDeep } from 'lodash-es'
|
|
|
+
|
|
|
+import { type OperMode, type FlowDescriptor, type NodeDescriptor, type ProcessDescriptor, SuccessResultCode, useLoading } from '@cacp/ui'
|
|
|
+
|
|
|
+import * as apis from '@/apis/workflow/definition'
|
|
|
+import { useDesignerStore } from '@/stores'
|
|
|
+import { initProcessDescriptor } from '@/types/process'
|
|
|
+import * as utils from './utils'
|
|
|
+import DesignProperty from './ProcessDesignProperty.vue'
|
|
|
+import DesignJson from './ProcessDesignJson.vue'
|
|
|
+
|
|
|
+const $route = useRoute()
|
|
|
+const graphInstance = ref<Graph>()
|
|
|
+const canvasRef = ref<HTMLDivElement>()
|
|
|
+
|
|
|
+const { loading, setLoading } = useLoading()
|
|
|
+const designerStore = useDesignerStore()
|
|
|
+
|
|
|
+const state = reactive<{
|
|
|
+ visible: boolean,
|
|
|
+ mode: OperMode,
|
|
|
+ descriptor: ProcessDescriptor,
|
|
|
+ json: string
|
|
|
+ current: {
|
|
|
+ type: 'PROCESS' | 'NODE' | 'FLOW',
|
|
|
+ data: ProcessDescriptor | NodeDescriptor | FlowDescriptor
|
|
|
+ }
|
|
|
+}>({
|
|
|
+ visible: false,
|
|
|
+ mode: 'show',
|
|
|
+ descriptor: initProcessDescriptor,
|
|
|
+ json: '',
|
|
|
+ current: {
|
|
|
+ type: 'PROCESS',
|
|
|
+ data: initProcessDescriptor
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ setLoading(true)
|
|
|
+
|
|
|
+ await designerStore.init()
|
|
|
+ await initProcessData()
|
|
|
+ initRenderGraph()
|
|
|
+
|
|
|
+ setLoading(false)
|
|
|
+})
|
|
|
+
|
|
|
+async function initProcessData() {
|
|
|
+ const processCode: string = $route.query.processCode as string
|
|
|
+ const mode = $route.query.mode as string
|
|
|
+
|
|
|
+ if (mode === 'create') {
|
|
|
+ state.mode = mode
|
|
|
+ state.descriptor = cloneDeep(initProcessDescriptor)
|
|
|
+ } else if (mode === 'oper') {
|
|
|
+ const res = await apis.getDefinition(processCode, 0)
|
|
|
+ if (res.code === SuccessResultCode && res.data) {
|
|
|
+ state.descriptor = res.data!
|
|
|
+ }
|
|
|
+ state.mode = 'oper'
|
|
|
+ } else {
|
|
|
+ const res = await apis.getDefinition(processCode, 0)
|
|
|
+ if (res.code === SuccessResultCode && res.data) {
|
|
|
+ state.descriptor = res.data!
|
|
|
+ }
|
|
|
+ state.mode = 'show'
|
|
|
+ }
|
|
|
+
|
|
|
+ state.current.data = state.descriptor
|
|
|
+ state.current.type = 'PROCESS'
|
|
|
+}
|
|
|
+
|
|
|
+function initRenderGraph() {
|
|
|
+ graphInstance.value = utils.initGraph(canvasRef.value!, (graph) => {
|
|
|
+ graph.on("selection:changed", handleSelectionChange)
|
|
|
+ .on("node:mouseenter", handleMouseEnter)
|
|
|
+ .on("node:mouseleave", handleMouseLeave)
|
|
|
+ .on("edge:connected", handleConnected)
|
|
|
+ .on("node:removed", handleDelete)
|
|
|
+ .on("edge:removed", handleDelete)
|
|
|
+
|
|
|
+ const json = toGraphData(state.descriptor)
|
|
|
+ graph.fromJSON(json)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function toGraphData(descriptor: ProcessDescriptor) : Model.FromJSONData {
|
|
|
+ const data: Model.FromJSONData = {
|
|
|
+ nodes: [],
|
|
|
+ edges: []
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const item of descriptor.nodes) {
|
|
|
+ const node: Node.Metadata = {
|
|
|
+ id: item.code,
|
|
|
+ shape: item.type.toUpperCase(),
|
|
|
+ position: { x: (item.attrs?.x ?? 50) as number, y: (item.attrs?.y ?? 5) as number },
|
|
|
+ label: item.name,
|
|
|
+ data: {...item}
|
|
|
+ }
|
|
|
+
|
|
|
+ data.nodes.push(node)
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const item of descriptor.flows) {
|
|
|
+ const edge: Edge.Metadata = {
|
|
|
+ id: item.code,
|
|
|
+ shape: 'FLOW',
|
|
|
+ source: item.source,
|
|
|
+ target: item.target,
|
|
|
+ label: (item.name ?? '') + (item.type === 'CONDITION' ? '<条件>': ''),
|
|
|
+ data: {...item}
|
|
|
+ }
|
|
|
+
|
|
|
+ data.edges.push(edge)
|
|
|
+ }
|
|
|
+
|
|
|
+ return data
|
|
|
+}
|
|
|
+function toDescriptor(data: Model.ToJSONData): ProcessDescriptor {
|
|
|
+ const cells = data.cells
|
|
|
+
|
|
|
+ const nodes = cells.filter(c => c.shape !== 'FLOW')
|
|
|
+ .map(c => {
|
|
|
+ const node: NodeDescriptor = cloneDeep(c.data)
|
|
|
+ node.attrs = Object.assign({}, node.attrs, {
|
|
|
+ x: c.position.x, y: c.position.y
|
|
|
+ })
|
|
|
+ return node
|
|
|
+ })
|
|
|
+ const flows = cells.filter(c => c.shape === 'FLOW')
|
|
|
+ .map(c => {
|
|
|
+ const flow: FlowDescriptor = cloneDeep(c.data)
|
|
|
+ Object.assign(flow, {
|
|
|
+ source: c.source.cell,
|
|
|
+ target: c.target.cell
|
|
|
+ })
|
|
|
+ return flow
|
|
|
+ })
|
|
|
+
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.backCode && !nodes.some(n => n.code === node.backCode)) {
|
|
|
+ node.backCode = ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return Object.assign({}, state.descriptor, { nodes: nodes, flows: flows })
|
|
|
+}
|
|
|
+
|
|
|
+function onAddApprove() {
|
|
|
+ if (!graphInstance.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const id = `n_${utils.nanoId()}`
|
|
|
+ const nodeData: NodeDescriptor = {
|
|
|
+ type: 'APPROVE',
|
|
|
+ code: id,
|
|
|
+ name: '审批节点',
|
|
|
+ backCode: '',
|
|
|
+ seceneCode: '',
|
|
|
+ routeOptional: false,
|
|
|
+ completeStrategy: {
|
|
|
+ strategy: 'ONE'
|
|
|
+ },
|
|
|
+ expireDays: 0,
|
|
|
+ assignment: {
|
|
|
+ type: 'NONE'
|
|
|
+ },
|
|
|
+ keepTrace: false,
|
|
|
+ listeners: [],
|
|
|
+ options: {},
|
|
|
+ attrs: { x: 300, y: 30 }
|
|
|
+ }
|
|
|
+
|
|
|
+ graphInstance.value.addNode({
|
|
|
+ id: id,
|
|
|
+ data: nodeData,
|
|
|
+ shape: nodeData.type.toUpperCase(),
|
|
|
+ position: { x: (nodeData.attrs?.x ?? 50) as number, y: (nodeData.attrs?.y ?? 5) as number },
|
|
|
+ label: nodeData.name
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function onAddBranch() {
|
|
|
+ if (!graphInstance.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const id = `n_${utils.nanoId()}`
|
|
|
+ const nodeData: NodeDescriptor = {
|
|
|
+ type: 'BRANCH',
|
|
|
+ code: id,
|
|
|
+ name: '分支节点',
|
|
|
+ branchCode: '',
|
|
|
+ completeStrategy: {
|
|
|
+ strategy: 'BRANCH'
|
|
|
+ },
|
|
|
+ assignment: {
|
|
|
+ type: 'BRANCH_DEPT',
|
|
|
+ branchDepts: ''
|
|
|
+ },
|
|
|
+ listeners: [],
|
|
|
+ options: {},
|
|
|
+ attrs: { x: 300, y: 30 }
|
|
|
+ }
|
|
|
+
|
|
|
+ graphInstance.value.addNode({
|
|
|
+ id: id,
|
|
|
+ data: nodeData,
|
|
|
+ shape: nodeData.type.toUpperCase(),
|
|
|
+ position: { x: (nodeData.attrs?.x ?? 50) as number, y: (nodeData.attrs?.y ?? 5) as number },
|
|
|
+ label: nodeData.name
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function onAddVirtual() {
|
|
|
+ if (!graphInstance.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const id = `n_${utils.nanoId()}`
|
|
|
+ const nodeData: NodeDescriptor = {
|
|
|
+ type: 'VIRTUAL',
|
|
|
+ code: id,
|
|
|
+ name: '虚拟节点',
|
|
|
+ returnable: false,
|
|
|
+ listeners: [],
|
|
|
+ options: {},
|
|
|
+ attrs: { x: 300, y: 30 }
|
|
|
+ }
|
|
|
+
|
|
|
+ graphInstance.value.addNode({
|
|
|
+ id: id,
|
|
|
+ data: nodeData,
|
|
|
+ shape: nodeData.type.toUpperCase(),
|
|
|
+ position: { x: (nodeData.attrs?.x ?? 50) as number, y: (nodeData.attrs?.y ?? 5) as number },
|
|
|
+ label: nodeData.name
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function onAddCopy() {
|
|
|
+ if (!graphInstance.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const id = `n_${utils.nanoId()}`
|
|
|
+ const nodeData: NodeDescriptor = {
|
|
|
+ type: 'COPY',
|
|
|
+ code: id,
|
|
|
+ name: '抄送节点',
|
|
|
+ routeOptional: false,
|
|
|
+ assignment: {
|
|
|
+ type: 'NONE'
|
|
|
+ },
|
|
|
+ listeners: [],
|
|
|
+ options: {},
|
|
|
+ attrs: { x: 300, y: 30 }
|
|
|
+ }
|
|
|
+
|
|
|
+ graphInstance.value.addNode({
|
|
|
+ id: id,
|
|
|
+ data: nodeData,
|
|
|
+ shape: nodeData.type.toUpperCase(),
|
|
|
+ position: { x: (nodeData.attrs?.x ?? 50) as number, y: (nodeData.attrs?.y ?? 5) as number },
|
|
|
+ label: nodeData.name
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function onZoomIn() {
|
|
|
+ graphInstance.value?.zoom(0.2)
|
|
|
+}
|
|
|
+function onZoomOut() {
|
|
|
+ graphInstance.value?.zoom(-0.2)
|
|
|
+}
|
|
|
+function onZoomFit() {
|
|
|
+ graphInstance.value?.zoomToFit()
|
|
|
+}
|
|
|
+function onCenter() {
|
|
|
+ graphInstance.value?.centerContent()
|
|
|
+}
|
|
|
+function onView() {
|
|
|
+ state.visible = true
|
|
|
+
|
|
|
+ debugger
|
|
|
+ const data = graphInstance.value!.toJSON()
|
|
|
+ const descriptor = toDescriptor(data)
|
|
|
+ state.json = JSON.stringify(descriptor, null, ' ')
|
|
|
+}
|
|
|
+function onSaveJson(json: string) {
|
|
|
+ state.visible = false
|
|
|
+
|
|
|
+ const descriptor: ProcessDescriptor = JSON.parse(json)
|
|
|
+ const data = toGraphData(descriptor)
|
|
|
+ graphInstance.value!.fromJSON(data)
|
|
|
+}
|
|
|
+function onCloseJson() {
|
|
|
+ state.visible = false
|
|
|
+}
|
|
|
+async function onSave() {
|
|
|
+ const data = graphInstance.value!.toJSON()
|
|
|
+ state.descriptor = toDescriptor(data)
|
|
|
+
|
|
|
+ if (state.mode === 'create' || state.mode === 'oper') {
|
|
|
+ const res = await apis.saveDefinition(state.descriptor)
|
|
|
+ if (res.code === SuccessResultCode && res.data > 0) {
|
|
|
+ ElMessage.success('保存成功')
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+async function handleSelectionChange(args: { selected: Cell[] }) {
|
|
|
+ const selected: Cell[] = args.selected
|
|
|
+
|
|
|
+ if (!selected || selected.length === 0) {
|
|
|
+ state.current.type = 'PROCESS'
|
|
|
+ state.current.data = state.descriptor
|
|
|
+ } else {
|
|
|
+ state.current.type = 'PROCESS'
|
|
|
+ state.current.data = state.descriptor
|
|
|
+
|
|
|
+ await nextTick()
|
|
|
+
|
|
|
+ const item = selected[0]
|
|
|
+ if (item.shape === 'FLOW') {
|
|
|
+ state.current.type = 'FLOW'
|
|
|
+ state.current.data = item.data as FlowDescriptor
|
|
|
+ } else {
|
|
|
+ state.current.type = 'NODE'
|
|
|
+ state.current.data = item.data as NodeDescriptor
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+function handleMouseEnter(args: { node: Node }) {
|
|
|
+ const node: Node = args.node
|
|
|
+ for (const port of node.getPorts()) {
|
|
|
+ node.setPortProp(port.id!, 'attrs/circle/style/visibility', 'visible')
|
|
|
+ }
|
|
|
+}
|
|
|
+function handleMouseLeave(args: { node: Node }) {
|
|
|
+ const node: Node = args.node
|
|
|
+ for (const port of node.getPorts()) {
|
|
|
+ node.setPortProp(port.id!, 'attrs/circle/style/visibility', 'hidden')
|
|
|
+ }
|
|
|
+}
|
|
|
+function handleConnected(args: { edge: Edge, currentCell?: Cell | null }) {
|
|
|
+ const edge: Edge = args.edge
|
|
|
+ const source = edge.getSourceCell()
|
|
|
+ const target = args.currentCell
|
|
|
+
|
|
|
+ Object.assign(edge.data, {
|
|
|
+ source: source!.data.code,
|
|
|
+ target: target!.data.code
|
|
|
+ })
|
|
|
+}
|
|
|
+function handleDelete() {
|
|
|
+ state.current.type = 'PROCESS'
|
|
|
+ state.current.data = state.descriptor
|
|
|
+}
|
|
|
+
|
|
|
+function onSaveProcess(info: ProcessDescriptor) {
|
|
|
+ state.descriptor = Object.assign({}, info, { nodes: state.descriptor.nodes, flows: state.descriptor.nodes })
|
|
|
+}
|
|
|
+function onSaveNode(info: NodeDescriptor) {
|
|
|
+ const item = graphInstance.value!.getCellById(info.code) as Node
|
|
|
+ if (!item) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ item.setData(info, {
|
|
|
+ deep: false,
|
|
|
+ silent: true
|
|
|
+ })
|
|
|
+ item.setAttrs({
|
|
|
+ text: { text: info.name }
|
|
|
+ })
|
|
|
+}
|
|
|
+function onSaveFlow(info: FlowDescriptor) {
|
|
|
+ const item = graphInstance.value!.getCellById(info.code) as Edge
|
|
|
+ if (!item) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ item.setData(info, {
|
|
|
+ deep: false,
|
|
|
+ silent: true
|
|
|
+ })
|
|
|
+ item.setLabels({
|
|
|
+ attrs: {
|
|
|
+ label: {
|
|
|
+ fill: utils.colors.flowColor,
|
|
|
+ fontSize: 12
|
|
|
+ },
|
|
|
+ text: {
|
|
|
+ text: (info.name ?? '') + (info.type === 'CONDITION' ? '<条件>' : '')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+</script>
|
|
|
+<style lang="less" scoped>
|
|
|
+ .designer {
|
|
|
+ margin: 5px;
|
|
|
+ display: flex;
|
|
|
+ width: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ &-aside {
|
|
|
+ padding: 0 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &-main {
|
|
|
+ padding: 0 5px;
|
|
|
+ overflow: hidden;
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ &-canvas {
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &-property {
|
|
|
+ padding: 0 5px;
|
|
|
+ width: 400px;
|
|
|
+ height: 100%;
|
|
|
+ overflow: auto;
|
|
|
+ scrollbar-width: none;
|
|
|
+ &::-webkit-scrollbar {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|