|
@@ -0,0 +1,839 @@
|
|
|
|
|
+import { ref, onMounted, onUnmounted, nextTick, reactive, computed, type Ref } from 'vue'
|
|
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
+import LogicFlow, {
|
|
|
|
|
+ RectNode,
|
|
|
|
|
+ RectNodeModel,
|
|
|
|
|
+ CircleNode,
|
|
|
|
|
+ CircleNodeModel,
|
|
|
|
|
+ PolygonNode,
|
|
|
|
|
+ PolygonNodeModel,
|
|
|
|
|
+} from '@logicflow/core'
|
|
|
|
|
+import { Menu, Snapshot } from '@logicflow/extension'
|
|
|
|
|
+import '@logicflow/core/dist/style/index.css'
|
|
|
|
|
+import '@logicflow/extension/lib/style/index.css'
|
|
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
|
|
+import type { FormRules } from 'element-plus'
|
|
|
|
|
+import { listRole } from '@/api/system/role'
|
|
|
|
|
+import { addDefinition, updateDefinition, getDefinition } from '@/api/flow/definition'
|
|
|
|
|
+import type { Role } from '@/types/system'
|
|
|
|
|
+import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
|
|
|
|
|
+
|
|
|
|
|
+/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
|
+export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
|
|
|
|
|
+ const route = useRoute()
|
|
|
|
|
+ const router = useRouter()
|
|
|
|
|
+
|
|
|
|
|
+ let lf: LogicFlow | null = null
|
|
|
|
|
+
|
|
|
|
|
+ const mode = computed(() => (route.query.mode as string) || '')
|
|
|
|
|
+ const flowId = computed(() => (route.query.id as string) || '')
|
|
|
|
|
+ const flowCode = computed(() => (route.query.code as string) || '')
|
|
|
|
|
+ const flowName = computed(() => (route.query.name as string) || '')
|
|
|
|
|
+ const flowCategory = computed(() => (route.query.category as string) || '')
|
|
|
|
|
+ const flowDescription = computed(() => (route.query.description as string) || '')
|
|
|
|
|
+ const flowInfoVisible = computed(() => mode.value === 'create' || mode.value === 'edit')
|
|
|
|
|
+
|
|
|
|
|
+ const selectedNode = ref<any>(null)
|
|
|
|
|
+ const roleList = ref<Role[]>([])
|
|
|
|
|
+ const nodeProps = reactive<Record<string, any>>({
|
|
|
|
|
+ assigneeType: 'ROLE',
|
|
|
|
|
+ assigneeValue: '',
|
|
|
|
|
+ approveMode: 'or',
|
|
|
|
|
+ timeoutHours: undefined as number | undefined,
|
|
|
|
|
+ timeoutAction: 'remind',
|
|
|
|
|
+ ccUsers: '',
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 条件节点出连线配置
|
|
|
|
|
+ const outgoingEdges = ref<any[]>([])
|
|
|
|
|
+
|
|
|
|
|
+ function loadOutgoingEdges(nodeId?: string) {
|
|
|
|
|
+ if (!lf) {
|
|
|
|
|
+ outgoingEdges.value = []
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const id = nodeId || selectedNode.value?.id
|
|
|
|
|
+ if (!id) {
|
|
|
|
|
+ outgoingEdges.value = []
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const graphData = lf.getGraphData()
|
|
|
|
|
+ const edges = (graphData.edges || []).filter((e: any) => e.sourceNodeId === id)
|
|
|
|
|
+ outgoingEdges.value = edges.map((e: any) => ({
|
|
|
|
|
+ ...e,
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ condition: '',
|
|
|
|
|
+ isDefault: false,
|
|
|
|
|
+ branchName: '',
|
|
|
|
|
+ ...(e.properties || {}),
|
|
|
|
|
+ },
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getEdgeTargetName(edge: any): string {
|
|
|
|
|
+ if (!lf || !edge?.targetNodeId) return '—'
|
|
|
|
|
+ try {
|
|
|
|
|
+ const node = lf.getNodeData(edge.targetNodeId)
|
|
|
|
|
+ return node?.text?.value || node?.text || '未命名节点'
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return '—'
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getEdgeDisplayText(edge: any): string {
|
|
|
|
|
+ const props = edge.properties || {}
|
|
|
|
|
+ if (props.branchName) return props.branchName
|
|
|
|
|
+ if (props.isDefault) return '默认'
|
|
|
|
|
+ if (props.condition) return props.condition
|
|
|
|
|
+ return ''
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function syncEdgeToLf(edge: any) {
|
|
|
|
|
+ if (!lf || !edge?.id) return
|
|
|
|
|
+ const props = edge.properties || {}
|
|
|
|
|
+ const displayText = getEdgeDisplayText(edge)
|
|
|
|
|
+ lf.setEdgeData(edge.id, {
|
|
|
|
|
+ properties: { ...props },
|
|
|
|
|
+ text: displayText ? { value: displayText } : undefined,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function onDefaultChange(edge: any) {
|
|
|
|
|
+ if (edge.properties.isDefault) {
|
|
|
|
|
+ edge.properties.condition = ''
|
|
|
|
|
+ // 同一条件节点下只能有一个默认分支
|
|
|
|
|
+ const nodeId = edge.sourceNodeId
|
|
|
|
|
+ outgoingEdges.value.forEach((e: any) => {
|
|
|
|
|
+ if (e.id !== edge.id && e.sourceNodeId === nodeId) {
|
|
|
|
|
+ e.properties.isDefault = false
|
|
|
|
|
+ syncEdgeToLf(e)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ syncEdgeToLf(edge)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function onBranchBlur(edge: any) {
|
|
|
|
|
+ syncEdgeToLf(edge)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getDefaultProps(type: string) {
|
|
|
|
|
+ if (type === 'approval-node')
|
|
|
|
|
+ return {
|
|
|
|
|
+ assigneeType: 'ROLE',
|
|
|
|
|
+ assigneeValue: '',
|
|
|
|
|
+ approveMode: 'or',
|
|
|
|
|
+ timeoutHours: undefined,
|
|
|
|
|
+ timeoutAction: 'remind',
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === 'cc-node') return { ccUsers: '' }
|
|
|
|
|
+ return {}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function saveCurrentNodeProps() {
|
|
|
|
|
+ if (selectedNode.value && lf) {
|
|
|
|
|
+ const type = selectedNode.value.type
|
|
|
|
|
+ const defaultProps = getDefaultProps(type)
|
|
|
|
|
+ // 只保存当前节点类型相关的属性,避免不同类型节点属性互相污染
|
|
|
|
|
+ const props: Record<string, any> = JSON.parse(JSON.stringify(defaultProps))
|
|
|
|
|
+ if (type === 'approval-node') {
|
|
|
|
|
+ props.assigneeType = 'ROLE'
|
|
|
|
|
+ props.assigneeValue = nodeProps.assigneeValue ?? ''
|
|
|
|
|
+ props.approveMode = nodeProps.approveMode ?? 'or'
|
|
|
|
|
+ props.timeoutHours = nodeProps.timeoutHours
|
|
|
|
|
+ props.timeoutAction = nodeProps.timeoutAction ?? 'remind'
|
|
|
|
|
+ } else if (type === 'cc-node') {
|
|
|
|
|
+ props.ccUsers = nodeProps.ccUsers ?? ''
|
|
|
|
|
+ }
|
|
|
|
|
+ lf.setProperties(selectedNode.value.id, props)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function loadNodeProps(data: any) {
|
|
|
|
|
+ const props = data.properties || getDefaultProps(data.type)
|
|
|
|
|
+ const cloned = JSON.parse(JSON.stringify(props))
|
|
|
|
|
+ // 先重置为默认值,避免上一个节点的属性残留
|
|
|
|
|
+ Object.assign(nodeProps, getDefaultProps(data.type))
|
|
|
|
|
+ // 兼容旧格式:approver + approveType -> 新格式
|
|
|
|
|
+ if (data.type === 'approval-node') {
|
|
|
|
|
+ const assigneeType = 'ROLE'
|
|
|
|
|
+ let assigneeValue = cloned.assigneeValue ?? ''
|
|
|
|
|
+ const approveMode = cloned.approveMode ?? cloned.approveType ?? 'or'
|
|
|
|
|
+ if (!cloned.assigneeValue && cloned.approver) {
|
|
|
|
|
+ assigneeValue = cloned.approver
|
|
|
|
|
+ }
|
|
|
|
|
+ // 旧数据中的 USER/SELF/LEADER 不再支持,统一清空让用户重新选择员工角色
|
|
|
|
|
+ if (cloned.assigneeType && cloned.assigneeType !== 'ROLE') {
|
|
|
|
|
+ assigneeValue = ''
|
|
|
|
|
+ }
|
|
|
|
|
+ nodeProps.assigneeType = assigneeType
|
|
|
|
|
+ nodeProps.assigneeValue = assigneeValue
|
|
|
|
|
+ nodeProps.approveMode = approveMode
|
|
|
|
|
+ nodeProps.timeoutHours = cloned.timeoutHours ?? undefined
|
|
|
|
|
+ nodeProps.timeoutAction = cloned.timeoutAction ?? 'remind'
|
|
|
|
|
+ } else if (data.type === 'cc-node') {
|
|
|
|
|
+ nodeProps.ccUsers = cloned.ccUsers ?? ''
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function registerNodes() {
|
|
|
|
|
+ if (!lf) return
|
|
|
|
|
+
|
|
|
|
|
+ class StartNodeModel extends CircleNodeModel {
|
|
|
|
|
+ setAttributes() {
|
|
|
|
|
+ this.r = 30
|
|
|
|
|
+ if (!this.text.value) this.text.value = '开始'
|
|
|
|
|
+ }
|
|
|
|
|
+ getNodeStyle() {
|
|
|
|
|
+ const style = super.getNodeStyle()
|
|
|
|
|
+ style.fill = '#67C23A'
|
|
|
|
|
+ style.stroke = '#67C23A'
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ getTextStyle() {
|
|
|
|
|
+ const style = super.getTextStyle()
|
|
|
|
|
+ style.color = '#fff'
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lf.register({ type: 'start-node', view: CircleNode, model: StartNodeModel })
|
|
|
|
|
+
|
|
|
|
|
+ class ApprovalNodeModel extends RectNodeModel {
|
|
|
|
|
+ setAttributes() {
|
|
|
|
|
+ this.width = 160
|
|
|
|
|
+ this.height = 60
|
|
|
|
|
+ this.radius = 8
|
|
|
|
|
+ if (!this.text.value) this.text.value = '审批节点'
|
|
|
|
|
+ }
|
|
|
|
|
+ getNodeStyle() {
|
|
|
|
|
+ const style = super.getNodeStyle()
|
|
|
|
|
+ style.fill = '#fff'
|
|
|
|
|
+ style.stroke = '#409EFF'
|
|
|
|
|
+ style.strokeWidth = 1
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lf.register({ type: 'approval-node', view: RectNode, model: ApprovalNodeModel })
|
|
|
|
|
+
|
|
|
|
|
+ class CcNodeModel extends RectNodeModel {
|
|
|
|
|
+ setAttributes() {
|
|
|
|
|
+ this.width = 160
|
|
|
|
|
+ this.height = 60
|
|
|
|
|
+ this.radius = 8
|
|
|
|
|
+ if (!this.text.value) this.text.value = '抄送节点'
|
|
|
|
|
+ }
|
|
|
|
|
+ getNodeStyle() {
|
|
|
|
|
+ const style = super.getNodeStyle()
|
|
|
|
|
+ style.fill = '#fff'
|
|
|
|
|
+ style.stroke = '#E6A23C'
|
|
|
|
|
+ style.strokeWidth = 1
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lf.register({ type: 'cc-node', view: RectNode, model: CcNodeModel })
|
|
|
|
|
+
|
|
|
|
|
+ class ConditionNodeModel extends PolygonNodeModel {
|
|
|
|
|
+ setAttributes() {
|
|
|
|
|
+ this.points = [
|
|
|
|
|
+ [60, 0],
|
|
|
|
|
+ [120, 40],
|
|
|
|
|
+ [60, 80],
|
|
|
|
|
+ [0, 40],
|
|
|
|
|
+ ]
|
|
|
|
|
+ if (!this.text.value) this.text.value = '条件'
|
|
|
|
|
+ }
|
|
|
|
|
+ getNodeStyle() {
|
|
|
|
|
+ const style = super.getNodeStyle()
|
|
|
|
|
+ style.fill = '#fff'
|
|
|
|
|
+ style.stroke = '#909399'
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lf.register({ type: 'condition-node', view: PolygonNode, model: ConditionNodeModel })
|
|
|
|
|
+
|
|
|
|
|
+ class EndNodeModel extends CircleNodeModel {
|
|
|
|
|
+ setAttributes() {
|
|
|
|
|
+ this.r = 30
|
|
|
|
|
+ if (!this.text.value) this.text.value = '结束'
|
|
|
|
|
+ }
|
|
|
|
|
+ getNodeStyle() {
|
|
|
|
|
+ const style = super.getNodeStyle()
|
|
|
|
|
+ style.fill = '#F56C6C'
|
|
|
|
|
+ style.stroke = '#F56C6C'
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ getTextStyle() {
|
|
|
|
|
+ const style = super.getTextStyle()
|
|
|
|
|
+ style.color = '#fff'
|
|
|
|
|
+ return style
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lf.register({ type: 'end-node', view: CircleNode, model: EndNodeModel })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function defaultNodes() {
|
|
|
|
|
+ return [
|
|
|
|
|
+ { id: 'start-1', type: 'start-node', x: 200, y: 100, text: '开始' },
|
|
|
|
|
+ { id: 'end-1', type: 'end-node', x: 200, y: 500, text: '结束' },
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function initLogicFlow() {
|
|
|
|
|
+ if (!lfContainer.value) return
|
|
|
|
|
+
|
|
|
|
|
+ lf = new LogicFlow({
|
|
|
|
|
+ container: lfContainer.value,
|
|
|
|
|
+ grid: true,
|
|
|
|
|
+ plugins: [Menu, Snapshot],
|
|
|
|
|
+ pluginsOptions: {
|
|
|
|
|
+ menu: {
|
|
|
|
|
+ nodeMenu: [
|
|
|
|
|
+ {
|
|
|
|
|
+ text: '删除',
|
|
|
|
|
+ callback(node: any) {
|
|
|
|
|
+ lf?.deleteNode(node.id)
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ } as any,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ registerNodes()
|
|
|
|
|
+
|
|
|
|
|
+ lf.on('node:click', ({ data }: any) => {
|
|
|
|
|
+ saveCurrentNodeProps()
|
|
|
|
|
+ selectedNode.value = data
|
|
|
|
|
+ loadNodeProps(data)
|
|
|
|
|
+ loadOutgoingEdges(data.id)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ lf.on('blank:click', () => {
|
|
|
|
|
+ saveCurrentNodeProps()
|
|
|
|
|
+ selectedNode.value = null
|
|
|
|
|
+ outgoingEdges.value = []
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 当条件节点的出连线发生变化时,刷新右侧分支配置面板
|
|
|
|
|
+ lf.on('edge:add', ({ data }: any) => {
|
|
|
|
|
+ if (selectedNode.value?.id && data?.sourceNodeId === selectedNode.value.id) {
|
|
|
|
|
+ loadOutgoingEdges(selectedNode.value.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ lf.on('edge:delete', ({ data }: any) => {
|
|
|
|
|
+ if (selectedNode.value?.id && data?.sourceNodeId === selectedNode.value.id) {
|
|
|
|
|
+ loadOutgoingEdges(selectedNode.value.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 编辑模式:加载已有流程图
|
|
|
|
|
+ if (mode.value === 'edit' && flowId.value) {
|
|
|
|
|
+ getDefinition(Number(flowId.value))
|
|
|
|
|
+ .then((def: FlowDefinition) => {
|
|
|
|
|
+ parseFormSchema(def.formSchema)
|
|
|
|
|
+ if (def.flowJson) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(def.flowJson)
|
|
|
|
|
+ const graphData = convertToFrontendFormat(parsed)
|
|
|
|
|
+ lf?.render(graphData)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ lf?.render({ nodes: defaultNodes(), edges: [] })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lf?.render({ nodes: defaultNodes(), edges: [] })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {
|
|
|
|
|
+ lf?.render({ nodes: defaultNodes(), edges: [] })
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lf.render({ nodes: defaultNodes(), edges: [] })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 节点类型映射:LogicFlow <-> 后端
|
|
|
|
|
+ const typeToBackend: Record<string, string> = {
|
|
|
|
|
+ 'start-node': 'start',
|
|
|
|
|
+ 'approval-node': 'approval',
|
|
|
|
|
+ 'cc-node': 'cc',
|
|
|
|
|
+ 'condition-node': 'condition',
|
|
|
|
|
+ 'end-node': 'end',
|
|
|
|
|
+ }
|
|
|
|
|
+ const typeToFrontend: Record<string, string> = {
|
|
|
|
|
+ start: 'start-node',
|
|
|
|
|
+ approval: 'approval-node',
|
|
|
|
|
+ cc: 'cc-node',
|
|
|
|
|
+ condition: 'condition-node',
|
|
|
|
|
+ end: 'end-node',
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 将 LogicFlow 的 graphData 转换为后端格式
|
|
|
|
|
+ function convertToBackendFormat(graphData: any) {
|
|
|
|
|
+ const data = JSON.parse(JSON.stringify(graphData))
|
|
|
|
|
+ if (data.nodes) {
|
|
|
|
|
+ data.nodes = data.nodes.map((node: any) => {
|
|
|
|
|
+ const n = { ...node }
|
|
|
|
|
+ if (n.type && typeToBackend[n.type]) {
|
|
|
|
|
+ n.type = typeToBackend[n.type]
|
|
|
|
|
+ }
|
|
|
|
|
+ // text -> name
|
|
|
|
|
+ if (n.text !== undefined) {
|
|
|
|
|
+ n.name = typeof n.text === 'object' ? n.text.value : n.text
|
|
|
|
|
+ delete n.text
|
|
|
|
|
+ }
|
|
|
|
|
+ return n
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ // 将 LogicFlow edge.properties 转换为后端的 edge.condition 结构
|
|
|
|
|
+ if (data.edges) {
|
|
|
|
|
+ data.edges = data.edges.map((edge: any) => {
|
|
|
|
|
+ const e = { ...edge }
|
|
|
|
|
+ const props = e.properties || {}
|
|
|
|
|
+ e.condition = {
|
|
|
|
|
+ condition: props.condition || '',
|
|
|
|
|
+ isDefault: props.isDefault ?? !props.condition,
|
|
|
|
|
+ branchName: props.branchName || '',
|
|
|
|
|
+ }
|
|
|
|
|
+ // 清理 LogicFlow 运行时不需要的属性,避免后端序列化冗余
|
|
|
|
|
+ if (e.text !== undefined && (!e.text || !e.text.value)) {
|
|
|
|
|
+ delete e.text
|
|
|
|
|
+ }
|
|
|
|
|
+ delete e.properties
|
|
|
|
|
+ return e
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return data
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 将后端的 graphData 转换为 LogicFlow 格式
|
|
|
|
|
+ function convertToFrontendFormat(graphData: any) {
|
|
|
|
|
+ const data = JSON.parse(JSON.stringify(graphData))
|
|
|
|
|
+
|
|
|
|
|
+ // 兼容旧格式:条件表达式写在节点 properties.condition 上,需要迁移到连线
|
|
|
|
|
+ if (data.nodes && data.edges) {
|
|
|
|
|
+ data.nodes.forEach((node: any) => {
|
|
|
|
|
+ if ((node.type === 'condition' || node.type === 'condition-node') && node.properties?.condition) {
|
|
|
|
|
+ const nodeCondition = String(node.properties.condition)
|
|
|
|
|
+ const outEdges = data.edges.filter((e: any) => e.sourceNodeId === node.id)
|
|
|
|
|
+ if (outEdges.length > 0) {
|
|
|
|
|
+ // 第一条连线作为条件分支,其余作为默认分支
|
|
|
|
|
+ outEdges.forEach((e: any, idx: number) => {
|
|
|
|
|
+ if (!e.condition) {
|
|
|
|
|
+ e.condition = {}
|
|
|
|
|
+ }
|
|
|
|
|
+ if (idx === 0) {
|
|
|
|
|
+ e.condition.condition = nodeCondition
|
|
|
|
|
+ e.condition.isDefault = false
|
|
|
|
|
+ } else {
|
|
|
|
|
+ e.condition.condition = ''
|
|
|
|
|
+ e.condition.isDefault = true
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ delete node.properties.condition
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (data.edges) {
|
|
|
|
|
+ data.edges = data.edges.map((edge: any) => {
|
|
|
|
|
+ const e = { ...edge }
|
|
|
|
|
+ // 将后端的 edge.condition 转换为 LogicFlow 的 edge.properties
|
|
|
|
|
+ const conditionMap = e.condition || {}
|
|
|
|
|
+ e.properties = {
|
|
|
|
|
+ condition: conditionMap.condition || '',
|
|
|
|
|
+ isDefault: conditionMap.isDefault ?? !conditionMap.condition,
|
|
|
|
|
+ branchName: conditionMap.branchName || '',
|
|
|
|
|
+ }
|
|
|
|
|
+ // 设置/清理连线文本
|
|
|
|
|
+ const displayText =
|
|
|
|
|
+ e.properties.branchName ||
|
|
|
|
|
+ (!e.properties.isDefault && e.properties.condition) ||
|
|
|
|
|
+ (e.properties.isDefault ? '默认' : '')
|
|
|
|
|
+ if (displayText) {
|
|
|
|
|
+ e.text = { value: displayText }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ delete e.text
|
|
|
|
|
+ }
|
|
|
|
|
+ delete e.condition
|
|
|
|
|
+ return e
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (data.nodes) {
|
|
|
|
|
+ data.nodes = data.nodes.map((node: any) => {
|
|
|
|
|
+ const n = { ...node }
|
|
|
|
|
+ if (n.type && typeToFrontend[n.type]) {
|
|
|
|
|
+ n.type = typeToFrontend[n.type]
|
|
|
|
|
+ }
|
|
|
|
|
+ // name -> text
|
|
|
|
|
+ if (n.name !== undefined) {
|
|
|
|
|
+ n.text = n.name
|
|
|
|
|
+ delete n.name
|
|
|
|
|
+ }
|
|
|
|
|
+ return n
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return data
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleDrop(e: DragEvent) {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ const dataStr = e.dataTransfer?.getData('application/json')
|
|
|
|
|
+ if (!dataStr || !lf || !lfContainer.value) return
|
|
|
|
|
+ const nodeData = JSON.parse(dataStr)
|
|
|
|
|
+ const rect = lfContainer.value.getBoundingClientRect()
|
|
|
|
|
+ lf.addNode({
|
|
|
|
|
+ type: nodeData.type,
|
|
|
|
|
+ x: e.clientX - rect.left,
|
|
|
|
|
+ y: e.clientY - rect.top,
|
|
|
|
|
+ text: nodeData.label,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateNodeName() {
|
|
|
|
|
+ if (!lf || !selectedNode.value) return
|
|
|
|
|
+ lf.setNodeText(selectedNode.value.id, selectedNode.value.text?.value || '')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保存流程
|
|
|
|
|
+ const saveDialogVisible = ref(false)
|
|
|
|
|
+ const saveForm = reactive({
|
|
|
|
|
+ code: '',
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ category: '',
|
|
|
|
|
+ description: '',
|
|
|
|
|
+ })
|
|
|
|
|
+ const saveFormRules: FormRules = {
|
|
|
|
|
+ code: [{ required: true, message: '请输入流程编码', trigger: 'blur' }],
|
|
|
|
|
+ name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const formFields = ref<FormField[]>([])
|
|
|
|
|
+ const fieldTypeOptions = [
|
|
|
|
|
+ { label: '文本', value: 'text' },
|
|
|
|
|
+ { label: '多行文本', value: 'textarea' },
|
|
|
|
|
+ { label: '数字', value: 'number' },
|
|
|
|
|
+ { label: '日期', value: 'date' },
|
|
|
|
|
+ { label: '下拉选择', value: 'select' },
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ function addFormField() {
|
|
|
|
|
+ formFields.value.push({ name: '', label: '', type: 'text', required: false })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function removeFormField(index: number) {
|
|
|
|
|
+ formFields.value.splice(index, 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getFormSchema(): string {
|
|
|
|
|
+ const validFields = formFields.value.filter((f) => f.name.trim() && f.label.trim())
|
|
|
|
|
+ return validFields.length > 0 ? JSON.stringify(validFields) : ''
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function parseFormSchema(schema?: string) {
|
|
|
|
|
+ if (!schema) return
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(schema)
|
|
|
|
|
+ if (Array.isArray(parsed)) {
|
|
|
|
|
+ formFields.value = parsed
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ formFields.value = []
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 下拉选项编辑
|
|
|
|
|
+ const optionDialogVisible = ref(false)
|
|
|
|
|
+ const optionEditingIndex = ref<number | null>(null)
|
|
|
|
|
+ const optionEditingField = ref<FormField | null>(null)
|
|
|
|
|
+ const optionList = ref<FormFieldOption[]>([])
|
|
|
|
|
+
|
|
|
|
|
+ function openOptionDialog(index: number) {
|
|
|
|
|
+ optionEditingIndex.value = index
|
|
|
|
|
+ optionEditingField.value = formFields.value[index]
|
|
|
|
|
+ const rawOptions = optionEditingField.value.options || []
|
|
|
|
|
+ optionList.value = rawOptions.map((opt: any) => {
|
|
|
|
|
+ if (typeof opt === 'string') {
|
|
|
|
|
+ return { label: opt, value: opt }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { label: String(opt.label ?? opt.value), value: opt.value }
|
|
|
|
|
+ })
|
|
|
|
|
+ optionDialogVisible.value = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function addOption() {
|
|
|
|
|
+ optionList.value.push({ label: '', value: '' })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function removeOption(index: number) {
|
|
|
|
|
+ optionList.value.splice(index, 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function saveOptions() {
|
|
|
|
|
+ if (optionEditingIndex.value === null || !optionEditingField.value) return
|
|
|
|
|
+ const validOptions = optionList.value.filter(
|
|
|
|
|
+ (opt) => String(opt.label).trim() !== '' && String(opt.value).trim() !== ''
|
|
|
|
|
+ )
|
|
|
|
|
+ formFields.value[optionEditingIndex.value].options = validOptions
|
|
|
|
|
+ formFields.value[optionEditingIndex.value].multiple = optionEditingField.value.multiple
|
|
|
|
|
+ optionDialogVisible.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function validateFlow(graphData: any): string | null {
|
|
|
|
|
+ const nodes = graphData.nodes || []
|
|
|
|
|
+ const edges = graphData.edges || []
|
|
|
|
|
+ if (nodes.length === 0) return '流程图不能为空'
|
|
|
|
|
+ const startNodes = nodes.filter((n: any) => n.type === 'start-node' || n.type === 'start')
|
|
|
|
|
+ const endNodes = nodes.filter((n: any) => n.type === 'end-node' || n.type === 'end')
|
|
|
|
|
+ if (startNodes.length === 0) return '流程图缺少开始节点'
|
|
|
|
|
+ if (endNodes.length === 0) return '流程图缺少结束节点'
|
|
|
|
|
+ if (startNodes.length > 1) return '流程图只能有一个开始节点'
|
|
|
|
|
+ // 检查审批节点是否选择了审批角色
|
|
|
|
|
+ for (const node of nodes) {
|
|
|
|
|
+ if (node.type === 'approval-node' || node.type === 'approval') {
|
|
|
|
|
+ const props = node.properties || {}
|
|
|
|
|
+ const assigneeValue = props.assigneeValue || props.approver
|
|
|
|
|
+ if (!assigneeValue) {
|
|
|
|
|
+ return `审批节点 "${node.text || node.name}" 未选择审批角色`
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 检查孤立节点
|
|
|
|
|
+ const connectedNodeIds = new Set<string>()
|
|
|
|
|
+ for (const edge of edges) {
|
|
|
|
|
+ connectedNodeIds.add(edge.sourceNodeId)
|
|
|
|
|
+ connectedNodeIds.add(edge.targetNodeId)
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const node of nodes) {
|
|
|
|
|
+ if (
|
|
|
|
|
+ !connectedNodeIds.has(node.id) &&
|
|
|
|
|
+ node.type !== 'start-node' &&
|
|
|
|
|
+ node.type !== 'start' &&
|
|
|
|
|
+ node.type !== 'end-node' &&
|
|
|
|
|
+ node.type !== 'end'
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return `节点 "${node.text || node.name}" 是孤立节点,请删除或连接`
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 检查条件分支:有且只能有一个默认分支,非默认分支必须填写条件
|
|
|
|
|
+ const sourceMap: Record<string, any[]> = {}
|
|
|
|
|
+ for (const edge of edges) {
|
|
|
|
|
+ if (!sourceMap[edge.sourceNodeId]) sourceMap[edge.sourceNodeId] = []
|
|
|
|
|
+ sourceMap[edge.sourceNodeId].push(edge)
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const node of nodes) {
|
|
|
|
|
+ if (node.type === 'condition-node' || node.type === 'condition') {
|
|
|
|
|
+ const outEdges = sourceMap[node.id] || []
|
|
|
|
|
+ if (outEdges.length > 1) {
|
|
|
|
|
+ const defaultEdges = outEdges.filter((e: any) => {
|
|
|
|
|
+ const props = e.properties || {}
|
|
|
|
|
+ return props.isDefault || (!props.condition && !e.condition)
|
|
|
|
|
+ })
|
|
|
|
|
+ if (defaultEdges.length === 0) {
|
|
|
|
|
+ return `条件节点 "${node.text || node.name}" 需要至少一条默认分支`
|
|
|
|
|
+ }
|
|
|
|
|
+ if (defaultEdges.length > 1) {
|
|
|
|
|
+ return `条件节点 "${node.text || node.name}" 只能有一条默认分支`
|
|
|
|
|
+ }
|
|
|
|
|
+ const invalid = outEdges.some((e: any) => {
|
|
|
|
|
+ const props = e.properties || {}
|
|
|
|
|
+ return !props.isDefault && !props.condition && !e.condition
|
|
|
|
|
+ })
|
|
|
|
|
+ if (invalid) {
|
|
|
|
|
+ return `条件节点 "${node.text || node.name}" 的非默认分支必须填写条件表达式`
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleSave() {
|
|
|
|
|
+ if (!lf) return
|
|
|
|
|
+ saveCurrentNodeProps()
|
|
|
|
|
+ const rawGraphData = lf.getGraphData()
|
|
|
|
|
+ const error = validateFlow(rawGraphData)
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ ElMessage.warning(error)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 统一弹出保存弹窗,方便配置流程表单字段
|
|
|
|
|
+ if (mode.value === 'create') {
|
|
|
|
|
+ saveForm.code = flowCode.value
|
|
|
|
|
+ saveForm.name = flowName.value
|
|
|
|
|
+ saveForm.category = flowCategory.value
|
|
|
|
|
+ saveForm.description = flowDescription.value
|
|
|
|
|
+ formFields.value = []
|
|
|
|
|
+ } else if (mode.value === 'edit' && flowId.value) {
|
|
|
|
|
+ saveForm.code = flowCode.value
|
|
|
|
|
+ saveForm.name = flowName.value
|
|
|
|
|
+ saveForm.category = flowCategory.value
|
|
|
|
|
+ saveForm.description = flowDescription.value
|
|
|
|
|
+ // formFields 在编辑模式加载流程时已通过 parseFormSchema 填充
|
|
|
|
|
+ } else {
|
|
|
|
|
+ saveForm.code = ''
|
|
|
|
|
+ saveForm.name = ''
|
|
|
|
|
+ saveForm.category = ''
|
|
|
|
|
+ saveForm.description = ''
|
|
|
|
|
+ formFields.value = []
|
|
|
|
|
+ }
|
|
|
|
|
+ saveDialogVisible.value = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function submitSave() {
|
|
|
|
|
+ if (!lf) return
|
|
|
|
|
+ saveCurrentNodeProps()
|
|
|
|
|
+ const rawGraphData = lf.getGraphData()
|
|
|
|
|
+ const error = validateFlow(rawGraphData)
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ ElMessage.warning(error)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const graphData = convertToBackendFormat(rawGraphData)
|
|
|
|
|
+
|
|
|
|
|
+ if (mode.value === 'create') {
|
|
|
|
|
+ const data: Partial<FlowDefinition> = {
|
|
|
|
|
+ code: saveForm.code,
|
|
|
|
|
+ name: saveForm.name,
|
|
|
|
|
+ category: saveForm.category,
|
|
|
|
|
+ description: saveForm.description,
|
|
|
|
|
+ formSchema: getFormSchema(),
|
|
|
|
|
+ flowJson: JSON.stringify(graphData),
|
|
|
|
|
+ status: 0,
|
|
|
|
|
+ }
|
|
|
|
|
+ await addDefinition(data)
|
|
|
|
|
+ ElMessage.success('流程新增成功')
|
|
|
|
|
+ saveDialogVisible.value = false
|
|
|
|
|
+ router.push('/flow/definition')
|
|
|
|
|
+ } else if (mode.value === 'edit' && flowId.value) {
|
|
|
|
|
+ const data: Partial<FlowDefinition> = {
|
|
|
|
|
+ id: Number(flowId.value),
|
|
|
|
|
+ code: saveForm.code,
|
|
|
|
|
+ name: saveForm.name,
|
|
|
|
|
+ category: saveForm.category,
|
|
|
|
|
+ description: saveForm.description,
|
|
|
|
|
+ formSchema: getFormSchema(),
|
|
|
|
|
+ flowJson: JSON.stringify(graphData),
|
|
|
|
|
+ }
|
|
|
|
|
+ const newId = await updateDefinition(data)
|
|
|
|
|
+ if (newId !== Number(flowId.value)) {
|
|
|
|
|
+ ElMessage.success('流程已创建为新版本,请重新发布')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ElMessage.success('流程修改成功')
|
|
|
|
|
+ }
|
|
|
|
|
+ saveDialogVisible.value = false
|
|
|
|
|
+ router.push('/flow/definition')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const data: Partial<FlowDefinition> = {
|
|
|
|
|
+ code: saveForm.code,
|
|
|
|
|
+ name: saveForm.name,
|
|
|
|
|
+ category: saveForm.category,
|
|
|
|
|
+ description: saveForm.description,
|
|
|
|
|
+ formSchema: getFormSchema(),
|
|
|
|
|
+ flowJson: JSON.stringify(graphData),
|
|
|
|
|
+ status: 0,
|
|
|
|
|
+ }
|
|
|
|
|
+ await addDefinition(data)
|
|
|
|
|
+ ElMessage.success('流程保存成功')
|
|
|
|
|
+ saveDialogVisible.value = false
|
|
|
|
|
+ router.push('/flow/definition')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleClear() {
|
|
|
|
|
+ if (!lf) return
|
|
|
|
|
+ lf.clearData()
|
|
|
|
|
+ selectedNode.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function goBack() {
|
|
|
|
|
+ router.push('/flow/definition')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadRoles() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await listRole({ pageNum: 1, pageSize: 100 })
|
|
|
|
|
+ roleList.value = res.list
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ /* ignore */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onMounted(() => {
|
|
|
|
|
+ // 防止快速进入/离开设计器时重复创建 LogicFlow 实例(v1.x 无 destroy 方法)
|
|
|
|
|
+ if (lf) {
|
|
|
|
|
+ if (typeof lf.destroy === 'function') {
|
|
|
|
|
+ lf.destroy()
|
|
|
|
|
+ }
|
|
|
|
|
+ lf = null
|
|
|
|
|
+ }
|
|
|
|
|
+ nextTick(initLogicFlow)
|
|
|
|
|
+ loadRoles()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ onUnmounted(() => {
|
|
|
|
|
+ if (lf) {
|
|
|
|
|
+ if (typeof lf.destroy === 'function') {
|
|
|
|
|
+ lf.destroy()
|
|
|
|
|
+ }
|
|
|
|
|
+ lf = null
|
|
|
|
|
+ }
|
|
|
|
|
+ selectedNode.value = null
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ // flow info
|
|
|
|
|
+ mode,
|
|
|
|
|
+ flowId,
|
|
|
|
|
+ flowCode,
|
|
|
|
|
+ flowName,
|
|
|
|
|
+ flowCategory,
|
|
|
|
|
+ flowDescription,
|
|
|
|
|
+ flowInfoVisible,
|
|
|
|
|
+ // logic flow / selection
|
|
|
|
|
+ selectedNode,
|
|
|
|
|
+ outgoingEdges,
|
|
|
|
|
+ roleList,
|
|
|
|
|
+ nodeProps,
|
|
|
|
|
+ // property panel helpers
|
|
|
|
|
+ loadOutgoingEdges,
|
|
|
|
|
+ getEdgeTargetName,
|
|
|
|
|
+ onDefaultChange,
|
|
|
|
|
+ onBranchBlur,
|
|
|
|
|
+ saveCurrentNodeProps,
|
|
|
|
|
+ loadNodeProps,
|
|
|
|
|
+ updateNodeName,
|
|
|
|
|
+ // save dialog
|
|
|
|
|
+ saveDialogVisible,
|
|
|
|
|
+ saveForm,
|
|
|
|
|
+ saveFormRules,
|
|
|
|
|
+ formFields,
|
|
|
|
|
+ fieldTypeOptions,
|
|
|
|
|
+ addFormField,
|
|
|
|
|
+ removeFormField,
|
|
|
|
|
+ getFormSchema,
|
|
|
|
|
+ parseFormSchema,
|
|
|
|
|
+ // option dialog
|
|
|
|
|
+ optionDialogVisible,
|
|
|
|
|
+ optionEditingIndex,
|
|
|
|
|
+ optionEditingField,
|
|
|
|
|
+ optionList,
|
|
|
|
|
+ openOptionDialog,
|
|
|
|
|
+ addOption,
|
|
|
|
|
+ removeOption,
|
|
|
|
|
+ saveOptions,
|
|
|
|
|
+ // toolbar actions
|
|
|
|
|
+ handleSave,
|
|
|
|
|
+ submitSave,
|
|
|
|
|
+ handleClear,
|
|
|
|
|
+ goBack,
|
|
|
|
|
+ // drag/drop
|
|
|
|
|
+ handleDrop,
|
|
|
|
|
+ // conversion
|
|
|
|
|
+ convertToBackendFormat,
|
|
|
|
|
+ convertToFrontendFormat,
|
|
|
|
|
+ validateFlow,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|