From a30903dd70765bbc270611cc01f9a974cd121464 Mon Sep 17 00:00:00 2001 From: lixiaolong <702156524@qq.com> Date: Wed, 29 Jul 2026 16:17:39 +0800 Subject: [PATCH] feat: optimize Vue Flow workflow editor --- src/utils/flow.js | 6 +- src/views/flow/components/Aside.vue | 302 ++++++++--- .../flow/components/FormItemRecursive.vue | 6 +- src/views/flow/components/NodeState.vue | 301 +++++------ src/views/flow/components/NodeTitle.vue | 486 +++++++++--------- src/views/flow/components/ParamsDrawer.vue | 48 +- src/views/flow/components/TestRun.vue | 15 +- .../components/params/RecognizeNodeParams.vue | 1 - .../components/params/SdAgentNodeParams.vue | 3 +- src/views/flow/composables/useFlowEvents.js | 18 + src/views/flow/index.vue | 349 ++++++++++--- src/views/flow/nodes/common/endNode.vue | 242 +-------- .../flow/nodes/common/selectAreaNode.vue | 91 +++- src/views/flow/nodes/common/serviceNode.vue | 20 +- src/views/flow/nodes/common/startNode.vue | 87 +--- src/views/flow/nodes/function/codeNode.vue | 18 +- src/views/flow/nodes/function/currentLoop.vue | 19 +- src/views/flow/nodes/function/httpNode.vue | 20 +- src/views/flow/nodes/function/loop.vue | 30 +- src/views/flow/nodes/function/recognize.vue | 19 +- src/views/flow/nodes/function/sdAgent.vue | 19 +- src/views/flow/nodes/function/sleep.vue | 21 +- src/views/flow/nodes/function/subEnd.vue | 146 +----- src/views/flow/nodes/function/subStart.vue | 107 +--- src/views/flow/nodes/function/switch.vue | 204 +++++--- src/views/flow/vue-flow/FlowEdge.vue | 8 +- src/views/flow/vue-flow/FlowNode.vue | 166 +++++- src/views/flow/vue-flow/adapter.js | 197 ++++++- 28 files changed, 1622 insertions(+), 1327 deletions(-) create mode 100644 src/views/flow/composables/useFlowEvents.js diff --git a/src/utils/flow.js b/src/utils/flow.js index 103e613..1f56eff 100644 --- a/src/utils/flow.js +++ b/src/utils/flow.js @@ -214,13 +214,11 @@ export const getInputNumber = (nodeId) => { export const removeSwitchEdge = (nodeId, sourceAnchorId) => { const { edges } = lf.getGraphData(); - const edge = edges.find(item => { + const matchedEdges = edges.filter(item => { return item.sourceNodeId === nodeId && item.sourceAnchorId === sourceAnchorId }) - if (edge) { - lf.deleteEdge(edge.id); - } + matchedEdges.forEach(edge => lf.deleteEdge(edge.id)) } export const convertToTree = (data) => { diff --git a/src/views/flow/components/Aside.vue b/src/views/flow/components/Aside.vue index 3d90731..0172159 100644 --- a/src/views/flow/components/Aside.vue +++ b/src/views/flow/components/Aside.vue @@ -1,76 +1,242 @@ - + \ No newline at end of file + +.library-header { + padding: 16px 14px 14px; + border-bottom: 1px solid #e4e8ef; + background: #fff; +} + +.library-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.library-title { + font-size: 15px; + font-weight: 650; +} + +.library-subtitle { + margin-top: 2px; + color: #8a94a6; + font-size: 12px; +} + +.library-content { + min-height: 0; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; +} + +:deep(.el-collapse) { + border: 0; +} + +:deep(.collapse-item) { + .el-collapse-item__header { + height: 46px; + padding: 0 14px; + border-bottom-color: #e8ebf0; + background: #f8fafc; + color: #344054; + font-size: 13px; + font-weight: 600; + } + + .el-collapse-item__wrap { + border-bottom-color: #e8ebf0; + background: #f8fafc; + } + + .el-collapse-item__content { + padding: 8px 10px 10px; + } +} + +.category-title { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: space-between; + padding-right: 10px; +} + +.category-count { + display: grid; + min-width: 22px; + height: 20px; + padding: 0 6px; + place-items: center; + border: 1px solid #dfe4ec; + border-radius: 4px; + background: #fff; + color: #7a8597; + font-size: 11px; + font-weight: 500; + line-height: 18px; +} + +.node-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.node-item { + display: grid; + min-height: 62px; + grid-template-columns: 34px minmax(0, 1fr) 18px; + gap: 9px; + align-items: center; + padding: 9px 8px 9px 10px; + border: 1px solid #e1e6ed; + border-radius: 7px; + background: #fff; + cursor: grab; + transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; + + &:hover { + border-color: #f2a071; + box-shadow: 0 5px 14px rgba(31, 41, 55, 0.08); + transform: translateX(2px); + } + + &:active { cursor: grabbing; } +} + +.node-icon { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 6px; + background: #f1f5f9; + + img { + width: 21px; + height: 21px; + object-fit: contain; + } +} + +.node-content { min-width: 0; } + +.node-name { + overflow: hidden; + color: #202938; + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-desc { + display: -webkit-box; + overflow: hidden; + margin-top: 4px; + color: #7a8597; + font-size: 11px; + line-height: 16px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.drag-handle { + color: #a8b0bf; + font-size: 16px; +} + diff --git a/src/views/flow/components/FormItemRecursive.vue b/src/views/flow/components/FormItemRecursive.vue index d466bb3..c24d7d8 100644 --- a/src/views/flow/components/FormItemRecursive.vue +++ b/src/views/flow/components/FormItemRecursive.vue @@ -137,8 +137,8 @@ const { formType, currentList, propPath, depth, isFirstLevel, endDepth, parentPa default: 2 }, parentPath: { - type: String, - default: '' + type: Array, + default: () => [] }, }); @@ -218,4 +218,4 @@ const handleChildDelete = (childFullPath) => { } } - \ No newline at end of file + diff --git a/src/views/flow/components/NodeState.vue b/src/views/flow/components/NodeState.vue index 5d3b5b4..d71527e 100644 --- a/src/views/flow/components/NodeState.vue +++ b/src/views/flow/components/NodeState.vue @@ -1,176 +1,185 @@ --> -
-
-
- - Start -
+
+
+
+
流程开始
+
接收流程输入并启动工作流
-
工作流的起始节点,用于设定启动工作流需要的信息
- - - - diff --git a/src/views/flow/nodes/function/codeNode.vue b/src/views/flow/nodes/function/codeNode.vue index 0209702..1618bef 100644 --- a/src/views/flow/nodes/function/codeNode.vue +++ b/src/views/flow/nodes/function/codeNode.vue @@ -46,12 +46,14 @@ diff --git a/src/views/flow/nodes/function/currentLoop.vue b/src/views/flow/nodes/function/currentLoop.vue index d6c26bf..78685a2 100644 --- a/src/views/flow/nodes/function/currentLoop.vue +++ b/src/views/flow/nodes/function/currentLoop.vue @@ -25,20 +25,21 @@ :nodeProperties="props.properties" :nodeName="props.properties.name" :nodeDesc="props.properties.desc" - :zoom-state="nodeZoom" @setNodeName="setNodeName" />
diff --git a/src/views/flow/nodes/function/httpNode.vue b/src/views/flow/nodes/function/httpNode.vue index ff065ee..e3794af 100644 --- a/src/views/flow/nodes/function/httpNode.vue +++ b/src/views/flow/nodes/function/httpNode.vue @@ -40,21 +40,21 @@ :nodeType="props.properties.nodeType || 'NONE'" :nodeName="props.properties.name" :nodeDesc="props.properties.desc" - :zoom-state="nodeZoom" - @zoom="zoom" @setNodeName="setNodeName" /> diff --git a/src/views/flow/nodes/function/loop.vue b/src/views/flow/nodes/function/loop.vue index 2543c94..63f2fce 100644 --- a/src/views/flow/nodes/function/loop.vue +++ b/src/views/flow/nodes/function/loop.vue @@ -16,7 +16,7 @@ />
循环体 - {{ childCount }} 个节点 + {{ actualChildCount }} 个节点
import NodeTitle from "../../components/NodeTitle.vue"; import loop from "../../icon/loop.svg"; -import { addNewEdge } from "@/utils/flow"; import { emitter } from "@/utils/eventBus"; -import { computed, onUnmounted, ref } from "vue"; +import { computed, ref } from "vue"; const props = defineProps({ model: Object, graphModel: Object, properties: Object, nowTime: Number, + childCount: Number, }); const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]); const isDragOver = ref(false); -const childCount = computed(() => props.model?.children?.size || 0); +const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0); const handleDragover = (e) => { e.preventDefault(); @@ -82,9 +82,6 @@ const handleDrop = (e) => { } lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition); - setTimeout(() => { - addNewEdge(props.model.id); - }, 50); }; const setNodeName = (name) => { @@ -96,10 +93,6 @@ const setNodeName = (name) => { emits("contentChange"); } -onUnmounted(() => { - emitter.off("openParamsDrawer"); -}) - - - \ No newline at end of file diff --git a/src/views/flow/nodes/function/subStart.vue b/src/views/flow/nodes/function/subStart.vue index 4389046..6181f79 100644 --- a/src/views/flow/nodes/function/subStart.vue +++ b/src/views/flow/nodes/function/subStart.vue @@ -1,106 +1,15 @@ - - - - \ No newline at end of file diff --git a/src/views/flow/nodes/function/switch.vue b/src/views/flow/nodes/function/switch.vue index b58bc46..63a9e10 100644 --- a/src/views/flow/nodes/function/switch.vue +++ b/src/views/flow/nodes/function/switch.vue @@ -47,7 +47,9 @@
@@ -82,7 +84,7 @@
- +
-
+
Else
diff --git a/src/views/flow/vue-flow/adapter.js b/src/views/flow/vue-flow/adapter.js index ee9af97..eeb879d 100644 --- a/src/views/flow/vue-flow/adapter.js +++ b/src/views/flow/vue-flow/adapter.js @@ -8,6 +8,7 @@ const GROUP_TYPES = new Set(["loop", "selectArea"]); const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]); const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]); const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 }; +const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 }; const LOOP_MIN_SIZE = { width: 520, height: 360 }; const loopChildExtent = () => ({ @@ -20,6 +21,16 @@ const loopChildExtent = () => ({ ], }); +const groupChildExtent = () => ({ + range: "parent", + padding: [ + GROUP_BODY_INSET.top, + GROUP_BODY_INSET.right, + GROUP_BODY_INSET.bottom, + GROUP_BODY_INSET.left, + ], +}); + const nodeSize = (node) => { const width = Number(node.properties?.width || node.width); const height = Number(node.properties?.height || node.height); @@ -37,19 +48,28 @@ const nodeSize = (node) => { const edgeId = () => `edge_${uuid().replace(/-/g, "")}`; const nodeId = () => uuid().replace(/-/g, ""); -const toVueNode = (node) => { +const toVueNode = (node, sizeOverride, positionOverride) => { const normalized = clone(node); normalized.id ||= nodeId(); normalized.properties ||= {}; - const size = nodeSize(normalized); + const overrideWidth = Number(sizeOverride?.width); + const overrideHeight = Number(sizeOverride?.height); + const size = Number.isFinite(overrideWidth) && overrideWidth > 0 + && Number.isFinite(overrideHeight) && overrideHeight > 0 + ? { width: overrideWidth, height: overrideHeight } + : nodeSize(normalized); const center = { x: Number(normalized.x) || 0, y: Number(normalized.y) || 0, }; - const position = { - x: center.x - size.width / 2, - y: center.y - size.height / 2, - }; + const overrideX = Number(positionOverride?.x); + const overrideY = Number(positionOverride?.y); + const position = Number.isFinite(overrideX) && Number.isFinite(overrideY) + ? { x: overrideX, y: overrideY } + : { + x: center.x - size.width / 2, + y: center.y - size.height / 2, + }; const dimensions = GROUP_TYPES.has(normalized.type) ? { width: size.width, height: size.height } @@ -106,9 +126,16 @@ const toVueEdge = (edge) => { const attachChildrenToGroups = (nodes) => { const nodeLookup = new Map(nodes.map((node) => [node.id, node])); + const declaredParents = new Map(); + + nodes.filter((node) => GROUP_TYPES.has(node.data.flowType)).forEach((group) => { + (group.data.children || []).forEach((childId) => { + if (!declaredParents.has(childId)) declaredParents.set(childId, group.id); + }); + }); nodes.forEach((child) => { - const parentId = child.data.properties?.parentId; + const parentId = child.data.properties?.parentId || declaredParents.get(child.id); const parent = nodeLookup.get(parentId); if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return; @@ -122,10 +149,12 @@ const attachChildrenToGroups = (nodes) => { : child.position.y - parent.position.y, }; child.parentNode = parentId; - child.extent = isLoop ? loopChildExtent() : "parent"; + child.extent = isLoop ? loopChildExtent() : groupChildExtent(); child.expandParent = false; child.position = relativePosition; - child.zIndex = 2; + child.zIndex = 5; + child.data.properties = { ...child.data.properties, parentId }; + parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id])); }); nodes.filter((node) => node.data.flowType === "loop").forEach((group) => { @@ -148,10 +177,20 @@ const attachChildrenToGroups = (nodes) => { return nodes.sort((left, right) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode))); }; -export const toVueFlowData = (graph = {}) => ({ - nodes: attachChildrenToGroups((graph.nodes || []).map(toVueNode)), - edges: (graph.edges || []).map(toVueEdge), -}); +export const toVueFlowData = (graph = {}, options = {}) => { + const nodes = attachChildrenToGroups((graph.nodes || []).map((node) => ( + toVueNode(node, options.nodeSizes?.get(node.id), options.nodePositions?.get(node.id)) + ))); + const nodeLookup = new Map(nodes.map((node) => [node.id, node])); + const edges = (graph.edges || []).map((edge) => { + const vueEdge = toVueEdge(edge); + const source = nodeLookup.get(vueEdge.source); + const target = nodeLookup.get(vueEdge.target); + if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4; + return vueEdge; + }); + return { nodes, edges }; +}; export const createFlowFacade = (store) => { const handlers = new Map(); @@ -177,9 +216,20 @@ export const createFlowFacade = (store) => { : (node.position || node.computedPosition); result.id = node.id; result.type = node.data.flowType; - result.x = absolutePosition.x + size.width / 2; - result.y = absolutePosition.y + size.height / 2; + if (GROUP_TYPES.has(result.type)) { + result.x = absolutePosition.x + size.width / 2; + result.y = absolutePosition.y + size.height / 2; + } else { + const originalCenter = node.data.originalCenter || { + x: absolutePosition.x + size.width / 2, + y: absolutePosition.y + size.height / 2, + }; + const originalPosition = node.data.originalPosition || absolutePosition; + result.x = originalCenter.x + absolutePosition.x - originalPosition.x; + result.y = originalCenter.y + absolutePosition.y - originalPosition.y; + } result.properties = clone(node.data.properties || {}); + if (parent?.data?.flowType === "selectArea") delete result.properties.parentId; if (GROUP_TYPES.has(result.type)) { result.properties.width = size.width; result.properties.height = size.height; @@ -342,6 +392,9 @@ export const createFlowFacade = (store) => { get y() { return backendNode(node).y; }, get width() { return node.dimensions?.width || node.data.size?.width || 0; }, get height() { return node.dimensions?.height || node.data.size?.height || 0; }, + get left() { return getNodeRect(node).x; }, + get top() { return getNodeRect(node).y; }, + get parentId() { return node.parentNode || null; }, get anchors() { return getAnchors(node); }, get children() { return new Set(node.data.children || []); }, set children(value) { @@ -390,18 +443,44 @@ export const createFlowFacade = (store) => { render(graph, options = {}) { historySuspended = true; loopLayoutStates.clear(); - const data = toVueFlowData(graph); - store.setNodes(data.nodes); - store.setEdges(data.edges); - nextTick(() => { - store.updateNodeInternals(); + const nodeSizes = options.preserveNodeSizes === true + ? new Map(getNodes().map((node) => [node.id, { + width: Number(node.dimensions?.width || node.width || node.data.size?.width || 0), + height: Number(node.dimensions?.height || node.height || node.data.size?.height || 0), + }])) + : options.nodeSizes; + const nodePositions = options.preserveNodePositions === true + ? new Map(getNodes().map((node) => { + const rect = getNodeRect(node); + return [node.id, { x: rect.x, y: rect.y }]; + })) + : options.nodePositions; + const data = toVueFlowData(graph, { nodeSizes, nodePositions }); + const finishRender = () => { historySuspended = false; if (options.resetHistory !== false) { history = []; historyIndex = -1; recordHistory(); } - }); + nextTick(() => store.updateNodeInternals()); + }; + const applyData = () => { + store.setNodes(data.nodes); + store.setEdges(data.edges); + finishRender(); + }; + + if (options.replaceNodes === true) { + // Changing parentNode is structural in Vue Flow. Unmount in one tick + // and remount in the next so node wrappers and edges cannot retain + // different graph-node instances for the same id. + store.setEdges([]); + store.setNodes([]); + nextTick(applyData); + } else { + applyData(); + } }, addNode(node) { const vueNode = toVueNode(node); @@ -411,6 +490,10 @@ export const createFlowFacade = (store) => { }, deleteNode(id) { const target = store.findNode(id); + if (target?.data?.flowType === "selectArea") { + facade.ungroup(id); + return; + } const parentId = target?.parentNode; const childIds = target?.data?.children || []; const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0; @@ -432,6 +515,9 @@ export const createFlowFacade = (store) => { }, addEdge(edge) { const vueEdge = toVueEdge(edge); + const source = store.findNode(vueEdge.source); + const target = store.findNode(vueEdge.target); + if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4; store.addEdges(vueEdge); changed(); return backendEdge(vueEdge); @@ -508,12 +594,75 @@ export const createFlowFacade = (store) => { childData.x = dropCenter.x; childData.y = dropCenter.y; } - facade.render(graph, { resetHistory: false }); + facade.render(graph, { + resetHistory: false, + replaceNodes: true, + preserveNodeSizes: true, + preserveNodePositions: true, + }); nextTick(() => { store.updateNodeInternals([groupId, childId]); changed(); }); }, + groupNodes(groupNode, childIds = []) { + const graph = facade.getGraphData(); + const selectedIds = new Set(childIds); + const children = graph.nodes.filter((node) => ( + selectedIds.has(node.id) + && !GROUP_TYPES.has(node.type) + && !store.findNode(node.id)?.parentNode + )); + if (!children.length) return null; + + const group = clone(groupNode); + group.id ||= nodeId(); + group.type = "selectArea"; + group.properties = { ...(group.properties || {}), action: "NONE" }; + group.children = children.map((node) => node.id); + children.forEach((node) => { + node.properties = { ...(node.properties || {}), parentId: group.id }; + }); + graph.nodes.push(group); + facade.render(graph, { + resetHistory: false, + replaceNodes: true, + preserveNodeSizes: true, + preserveNodePositions: true, + }); + nextTick(() => { + store.updateNodeInternals([group.id, ...group.children]); + changed(); + }); + return clone(group); + }, + ungroup(groupId) { + const graph = facade.getGraphData(); + const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea"); + if (!group) return false; + const childIds = new Set([ + ...(group.children || []), + ...graph.nodes.filter((node) => node.properties?.parentId === groupId).map((node) => node.id), + ]); + graph.nodes = graph.nodes.filter((node) => node.id !== groupId); + graph.nodes.forEach((node) => { + if (!childIds.has(node.id)) return; + const properties = { ...(node.properties || {}) }; + delete properties.parentId; + node.properties = properties; + }); + facade.render(graph, { + resetHistory: false, + replaceNodes: true, + preserveNodeSizes: true, + preserveNodePositions: true, + }); + nextTick(() => { + store.updateNodeInternals([...childIds]); + changed(); + }); + return true; + }, prepareLoopChildDrag(childId) { const child = store.findNode(childId); const group = child?.parentNode ? store.findNode(child.parentNode) : null; @@ -551,7 +700,7 @@ export const createFlowFacade = (store) => { if (historyIndex <= 0) return false; historyIndex -= 1; const snapshot = clone(history[historyIndex].graph); - facade.render(snapshot, { resetHistory: false }); + facade.render(snapshot, { resetHistory: false, replaceNodes: true }); nextTick(() => emit("history:change", { data: facade.getGraphData() })); return true; }, @@ -559,7 +708,7 @@ export const createFlowFacade = (store) => { if (historyIndex >= history.length - 1) return false; historyIndex += 1; const snapshot = clone(history[historyIndex].graph); - facade.render(snapshot, { resetHistory: false }); + facade.render(snapshot, { resetHistory: false, replaceNodes: true }); nextTick(() => emit("history:change", { data: facade.getGraphData() })); return true; },