From 64d606c3a54972d730718eaa268abe00b1749ba7 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 27 Jul 2026 15:14:45 +0800 Subject: [PATCH] Refine route flow routing and step details --- src/components/flow/DagreEdge.vue | 92 +++++++++++++++++++++++++++ src/components/flow/MaterialNode.vue | 22 +++++++ src/components/flow/dagreLayout.js | 93 +++++++++++++++++++++++++++- src/components/xtFlowEditor.vue | 42 +++++++------ src/components/xtFlowViewer.vue | 36 +++++------ src/views/mtm/route_form.vue | 17 +++-- src/views/mtm/route_show.vue | 31 +++++++++- 7 files changed, 285 insertions(+), 48 deletions(-) create mode 100644 src/components/flow/DagreEdge.vue diff --git a/src/components/flow/DagreEdge.vue b/src/components/flow/DagreEdge.vue new file mode 100644 index 00000000..1bf9152f --- /dev/null +++ b/src/components/flow/DagreEdge.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/components/flow/MaterialNode.vue b/src/components/flow/MaterialNode.vue index 1edf847d..71f93be3 100644 --- a/src/components/flow/MaterialNode.vue +++ b/src/components/flow/MaterialNode.vue @@ -1,9 +1,27 @@ @@ -64,6 +82,10 @@ export default { -webkit-box-orient: vertical; overflow: hidden; } +.mnode__route-handle { + opacity: 0; + pointer-events: none; +} .mnode--raw { border-color: #f0c78a; background: #fffaf2; diff --git a/src/components/flow/dagreLayout.js b/src/components/flow/dagreLayout.js index ffd5fb1e..ffbb1ac6 100644 --- a/src/components/flow/dagreLayout.js +++ b/src/components/flow/dagreLayout.js @@ -10,6 +10,14 @@ export const NODE_H = 54; * @param {Object} opts { rankdir: TB|BT|LR|RL, nodeW, nodeH, nodesep, edgesep, ranksep, marginx, marginy } */ export function layoutGraph(nodes, edges, opts = {}) { + return layoutGraphWithEdges(nodes, edges, opts).nodes; +} + +/** + * dagre 同时计算节点位置、边的绕行点和标签位置。 + * Vue Flow 默认边只使用节点坐标,会让跨层边穿过节点;因此查看/编辑视图使用此完整结果。 + */ +export function layoutGraphWithEdges(nodes, edges, opts = {}) { const { rankdir = "TB", nodeW = NODE_W, @@ -20,14 +28,93 @@ export function layoutGraph(nodes, edges, opts = {}) { marginx = 20, marginy = 20, } = opts; - const g = new dagre.graphlib.Graph(); + const g = new dagre.graphlib.Graph({ multigraph: true }); g.setGraph({ rankdir, nodesep, edgesep, ranksep, marginx, marginy }); g.setDefaultEdgeLabel(() => ({})); nodes.forEach((n) => g.setNode(n.id, { width: nodeW, height: nodeH })); - edges.forEach((e) => g.setEdge(e.source, e.target)); + const parallelCount = new Map(); + const edgeRefs = []; + edges.forEach((e, index) => { + const key = `${e.source}->${e.target}`; + const parallelIndex = parallelCount.get(key) || 0; + parallelCount.set(key, parallelIndex + 1); + // 参考原 dagre-d3:基础边跨 2 个 rank,同组边逐条增加长度。 + const minlen = e.layoutMinlen || 2 + parallelIndex; + const name = `route-${index}`; + const labelWidth = e.label ? Math.min(190, Math.max(56, String(e.label).length * 13 + 20)) : 0; + g.setEdge( + e.source, + e.target, + { + minlen, + width: labelWidth, + height: e.label ? 30 : 0, + // 标签位于工序线本身;平行边已由独立锚点和 dagre edgesep 分开。 + labelpos: "c", + labeloffset: 0, + }, + name + ); + edgeRefs.push({ v: e.source, w: e.target, name }); + }); dagre.layout(g); - return nodes.map((n) => { + const laidOutNodes = nodes.map((n) => { const gn = g.node(n.id); return { ...n, position: { x: gn.x - nodeW / 2, y: gn.y - nodeH / 2 } }; }); + const laidOutEdges = edges.map((edge, index) => { + const ge = g.edge(edgeRefs[index]) || {}; + return { + ...edge, + data: { + ...edge.data, + dagrePoints: ge.points || [], + dagreLabel: Number.isFinite(ge.x) && Number.isFinite(ge.y) ? { x: ge.x, y: ge.y } : null, + }, + }; + }); + return { nodes: laidOutNodes, edges: laidOutEdges }; +} + +/** + * 为每条边分配独立的节点锚点,避免多条工序共用中心 Handle 产生重叠。 + * 同一节点上的锚点按相邻节点横坐标排序,可同时减少分支交叉。 + */ +export function assignEdgePorts(nodes, edges) { + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + const outgoing = new Map(); + const incoming = new Map(); + + const routedEdges = edges.map((edge, index) => { + const sourceHandle = `route-out-${index}`; + const targetHandle = `route-in-${index}`; + if (!outgoing.has(edge.source)) outgoing.set(edge.source, []); + if (!incoming.has(edge.target)) incoming.set(edge.target, []); + outgoing.get(edge.source).push({ edgeIndex: index, handleId: sourceHandle, neighborId: edge.target }); + incoming.get(edge.target).push({ edgeIndex: index, handleId: targetHandle, neighborId: edge.source }); + return { ...edge, sourceHandle, targetHandle }; + }); + + const getCenterX = (id) => { + const node = nodeMap.get(id); + return node ? node.position.x + NODE_W / 2 : 0; + }; + const makePorts = (items = []) => + [...items] + .sort((a, b) => getCenterX(a.neighborId) - getCenterX(b.neighborId) || a.edgeIndex - b.edgeIndex) + .map((item, index, list) => ({ + id: item.handleId, + left: ((index + 1) * 100) / (list.length + 1), + })); + + const routedNodes = nodes.map((node) => ({ + ...node, + data: { + ...node.data, + sourcePorts: makePorts(outgoing.get(node.id)), + targetPorts: makePorts(incoming.get(node.id)), + }, + })); + + return { nodes: routedNodes, edges: routedEdges }; } diff --git a/src/components/xtFlowEditor.vue b/src/components/xtFlowEditor.vue index 0a02bef9..5802317c 100644 --- a/src/components/xtFlowEditor.vue +++ b/src/components/xtFlowEditor.vue @@ -80,6 +80,9 @@ + @@ -110,8 +113,9 @@ import { VueFlow, useVueFlow } from "@vue-flow/core"; import { Background } from "@vue-flow/background"; import { Controls } from "@vue-flow/controls"; import routeForm from "@/views/mtm/route_form.vue"; +import DagreEdge from "./flow/DagreEdge.vue"; import MaterialNode from "./flow/MaterialNode.vue"; -import { layoutGraph, NODE_W } from "./flow/dagreLayout"; +import { assignEdgePorts, layoutGraphWithEdges, NODE_W } from "./flow/dagreLayout"; import "@vue-flow/core/dist/style.css"; import "@vue-flow/core/dist/theme-default.css"; import "@vue-flow/controls/dist/style.css"; @@ -121,7 +125,7 @@ let flowSeq = 0; export default { name: "xtFlowEditor", - components: { VueFlow, Background, Controls, routeForm, MaterialNode }, + components: { VueFlow, Background, Controls, routeForm, DagreEdge, MaterialNode }, props: { // 工艺包 id routepack: { type: String, default: "" }, @@ -251,7 +255,6 @@ export default { const matKind = {}; const targetIds = new Set(); const edges = []; - const parallelEdgeCount = {}; this.routes.forEach((r) => { if (r.material_in == null || r.material_out == null) return; @@ -264,17 +267,13 @@ export default { targetIds.add(tid); const locked = !!r.from_route; - const edgeKey = sid + "->" + tid; - const edgeIndex = parallelEdgeCount[edgeKey] || 0; - parallelEdgeCount[edgeKey] = edgeIndex + 1; edges.push({ id: String(r.id), source: sid, target: tid, label: (r.process_name || "") + (locked ? " 🔒" : ""), data: { route: r, locked }, - type: "default", - pathOptions: { curvature: 0.25 + edgeIndex * 0.12 }, + type: "dagre", markerEnd: "arrowclosed", style: { stroke: locked ? "#c0c4cc" : "#409eff", @@ -312,22 +311,24 @@ export default { this.seedNodes = this.seedNodes.filter((s) => !routeNodeIds.has(s.id)); const prevCount = this.nodes.length; - this.edges = edges; - this.nodes = this.layoutNodes(routeNodes.concat(this.seedNodes), edges); + const routedGraph = this.layoutAndRoute(routeNodes.concat(this.seedNodes), edges); + this.nodes = routedGraph.nodes; + this.edges = routedGraph.edges; this.syncStore(); // 节点增减后 dagre 会整体重排,重新适配视野,避免“看着是空白其实有节点”导致拉线误吸附 if (this.hasFitted && this.nodes.length !== prevCount) this.fitToWidth(); }, - /** 编辑与查看使用一致的疏朗布局参数,给工序标签和分支留出空间 */ - layoutNodes(nodes, edges) { - return layoutGraph(nodes, edges, { - nodesep: 58, - edgesep: 32, - ranksep: 96, - marginx: 32, - marginy: 32, + /** 编辑与查看使用一致的 dagre-d3 布局参数,并保留边绕行点 */ + layoutAndRoute(nodes, edges) { + const laidOutGraph = layoutGraphWithEdges(nodes, edges, { + nodesep: 40, + edgesep: 25, + ranksep: 20, + marginx: 80, + marginy: 10, }); + return assignEdgePorts(laidOutGraph.nodes, laidOutGraph.edges); }, /** 把本地 nodes/edges 命令式同步到 Vue Flow store(先节点后边,避免边找不到端点) */ @@ -339,8 +340,11 @@ export default { /** 重新布局并适应画布 */ relayout() { - this.nodes = this.layoutNodes([...this.nodes], this.edges); + const routedGraph = this.layoutAndRoute([...this.nodes], this.edges); + this.nodes = routedGraph.nodes; + this.edges = routedGraph.edges; this.setNodes(this.nodes); + this.setEdges(this.edges); this.fitToWidth(); }, diff --git a/src/components/xtFlowViewer.vue b/src/components/xtFlowViewer.vue index 014b98e1..938f70f6 100644 --- a/src/components/xtFlowViewer.vue +++ b/src/components/xtFlowViewer.vue @@ -26,6 +26,9 @@ + @@ -41,8 +44,9 @@ import { VueFlow, useVueFlow } from "@vue-flow/core"; import { Background } from "@vue-flow/background"; import { Controls } from "@vue-flow/controls"; +import DagreEdge from "./flow/DagreEdge.vue"; import MaterialNode from "./flow/MaterialNode.vue"; -import { layoutGraph, NODE_W } from "./flow/dagreLayout"; +import { assignEdgePorts, layoutGraphWithEdges, NODE_W } from "./flow/dagreLayout"; import "@vue-flow/core/dist/style.css"; import "@vue-flow/core/dist/theme-default.css"; import "@vue-flow/controls/dist/style.css"; @@ -56,7 +60,7 @@ let viewerSeq = 0; */ export default { name: "xtFlowViewer", - components: { VueFlow, Background, Controls, MaterialNode }, + components: { VueFlow, Background, Controls, DagreEdge, MaterialNode }, props: { nodes: { type: Array, default: () => [] }, edges: { type: Array, default: () => [] }, @@ -120,25 +124,19 @@ export default { rebuild() { const sourceIds = new Set(); const targetIds = new Set(); - const parallelEdgeCount = {}; const rawEdges = (this.edges || []) .filter((e) => e.source != null && e.target != null) - .map((e) => { + .map((e, index) => { const sid = String(e.source); const tid = String(e.target); - const edgeKey = sid + "->" + tid; - const edgeIndex = parallelEdgeCount[edgeKey] || 0; - parallelEdgeCount[edgeKey] = edgeIndex + 1; sourceIds.add(sid); targetIds.add(tid); return { - id: e.id != null ? String(e.id) : sid + "-" + tid, + id: e.id != null ? String(e.id) : `${sid}-${tid}-${edgeIndex}`, source: sid, target: tid, label: e.label || "", - // 贝塞尔线会在分叉后立即分离;同一对节点有多道工序时用不同曲率错开。 - type: "default", - pathOptions: { curvature: 0.25 + edgeIndex * 0.12 }, + type: "dagre", markerEnd: "arrowclosed", style: { stroke: "#409eff", strokeWidth: 2.2 }, labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 }, @@ -161,17 +159,19 @@ export default { const nodeIds = new Set(vNodes.map((n) => n.id)); const vEdges = rawEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)); - this.renderedNodes = layoutGraph(vNodes, vEdges, { + const laidOutGraph = layoutGraphWithEdges(vNodes, vEdges, { rankdir: this.rankdir, - nodesep: 58, - edgesep: 32, - ranksep: 96, - marginx: 32, - marginy: 32, + nodesep: 40, + edgesep: 25, + ranksep: 20, + marginx: 80, + marginy: 10, }); + const routedGraph = assignEdgePorts(laidOutGraph.nodes, laidOutGraph.edges); + this.renderedNodes = routedGraph.nodes; this.setEdges([]); this.setNodes(this.renderedNodes); - this.setEdges(vEdges); + this.setEdges(routedGraph.edges); // 首次挂载交给 nodes-initialized(尺寸测量完成)再定位;数据更新时直接重新适配宽度 if (this.hasFitted) this.fitToWidth(); }, diff --git a/src/views/mtm/route_form.vue b/src/views/mtm/route_form.vue index aefbcd5d..5b04df8c 100644 --- a/src/views/mtm/route_form.vue +++ b/src/views/mtm/route_form.vue @@ -13,6 +13,7 @@ ref="dialogForm" :model="form" :rules="rules" + :disabled="mode === 'show'" label-position="right" label-width="100px" > @@ -197,7 +198,7 @@ - + @@ -226,9 +227,10 @@ - + - + + + + @@ -66,14 +83,16 @@ + @@ -283,6 +307,9 @@ export default { flex: 1; min-height: 0; } +.rs-steps__clickable :deep(.el-table__row) { + cursor: pointer; +} /* 右侧工单/审批面板:与主体同底色,间距交给 ticketd 自身的内边距 */ .rs-aside { background: #f5f7fa;