diff --git a/src/components/flow/dagreLayout.js b/src/components/flow/dagreLayout.js index a7275036..ffd5fb1e 100644 --- a/src/components/flow/dagreLayout.js +++ b/src/components/flow/dagreLayout.js @@ -7,12 +7,21 @@ export const NODE_H = 54; * dagre 自动布局,返回带 position 的 nodes * @param {Array} nodes Vue Flow 节点(id 必须为字符串) * @param {Array} edges Vue Flow 边 - * @param {Object} opts { rankdir: TB|BT|LR|RL, nodeW, nodeH } + * @param {Object} opts { rankdir: TB|BT|LR|RL, nodeW, nodeH, nodesep, edgesep, ranksep, marginx, marginy } */ export function layoutGraph(nodes, edges, opts = {}) { - const { rankdir = "TB", nodeW = NODE_W, nodeH = NODE_H } = opts; + const { + rankdir = "TB", + nodeW = NODE_W, + nodeH = NODE_H, + nodesep = 30, + edgesep = 20, + ranksep = 56, + marginx = 20, + marginy = 20, + } = opts; const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir, nodesep: 30, ranksep: 56, marginx: 20, marginy: 20 }); + 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)); diff --git a/src/components/xtFlowEditor.vue b/src/components/xtFlowEditor.vue index 90c93998..0a02bef9 100644 --- a/src/components/xtFlowEditor.vue +++ b/src/components/xtFlowEditor.vue @@ -5,7 +5,7 @@
新增工序 自动布局 - 适应画布 + 适应宽度 校验
@@ -55,12 +55,14 @@
-
+
{ + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + if (this.hasFitted) this.fitToWidth(false); + }, 80); + }); + this.resizeObserver.observe(this.$refs.canvas); + } + }, + beforeUnmount() { + if (this.resizeObserver) this.resizeObserver.disconnect(); + clearTimeout(this.resizeTimer); }, methods: { /** 加载左侧物料面板列表 */ @@ -234,6 +251,7 @@ 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; @@ -246,17 +264,21 @@ 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: "smoothstep", + type: "default", + pathOptions: { curvature: 0.25 + edgeIndex * 0.12 }, markerEnd: "arrowclosed", style: { stroke: locked ? "#c0c4cc" : "#409eff", - strokeWidth: locked ? 1.5 : 2, + strokeWidth: locked ? 1.7 : 2.2, strokeDasharray: locked ? "5 4" : undefined, }, labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 }, @@ -291,10 +313,21 @@ export default { const prevCount = this.nodes.length; this.edges = edges; - this.nodes = layoutGraph(routeNodes.concat(this.seedNodes), edges); + this.nodes = this.layoutNodes(routeNodes.concat(this.seedNodes), edges); this.syncStore(); // 节点增减后 dagre 会整体重排,重新适配视野,避免“看着是空白其实有节点”导致拉线误吸附 - if (this.hasFitted && this.nodes.length !== prevCount) this.doFit(); + 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, + }); }, /** 把本地 nodes/edges 命令式同步到 Vue Flow store(先节点后边,避免边找不到端点) */ @@ -306,15 +339,33 @@ export default { /** 重新布局并适应画布 */ relayout() { - this.nodes = layoutGraph([...this.nodes], this.edges); + this.nodes = this.layoutNodes([...this.nodes], this.edges); this.setNodes(this.nodes); - this.doFit(); + this.fitToWidth(); }, - doFit() { + /** 按宽度保持节点可读,长流程通过滚轮或拖动画布纵向浏览 */ + fitToWidth(animated = true) { this.$nextTick(() => { try { - this.fitView({ padding: 0.25, maxZoom: 1, duration: 300 }); + const canvas = this.$refs.canvas; + if (!canvas || !this.nodes.length) return; + const xs = this.nodes.map((n) => n.position.x); + const ys = this.nodes.map((n) => n.position.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs) + NODE_W; + const minY = Math.min(...ys); + const paddingX = 48; + const paddingTop = 36; + const graphWidth = Math.max(maxX - minX, NODE_W); + const zoom = Math.min(1, Math.max(0.2, (canvas.clientWidth - paddingX * 2) / graphWidth)); + const x = (canvas.clientWidth - graphWidth * zoom) / 2 - minX * zoom; + const y = paddingTop - minY * zoom; + this.setViewport( + { x, y, zoom }, + { duration: animated && this.hasFitted ? 200 : 0 } + ); + this.hasFitted = true; } catch (e) { /* noop */ } @@ -325,8 +376,7 @@ export default { onNodesInit() { if (this.hasFitted) return; if (this.nodes.length === 0) return; - this.hasFitted = true; - this.doFit(); + this.fitToWidth(); }, /* ============ 左侧面板拖拽入画布 ============ */ diff --git a/src/components/xtFlowViewer.vue b/src/components/xtFlowViewer.vue index 15119b9a..014b98e1 100644 --- a/src/components/xtFlowViewer.vue +++ b/src/components/xtFlowViewer.vue @@ -3,7 +3,7 @@
- 适应画布 + 适应宽度
原料 @@ -11,11 +11,13 @@ 成品
-
+
{ + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + if (this.hasFitted) this.fitToWidth(false); + }, 80); + }); + this.resizeObserver.observe(this.$refs.canvas); + } + }, + beforeUnmount() { + if (this.resizeObserver) this.resizeObserver.disconnect(); + clearTimeout(this.resizeTimer); }, methods: { /** nodes/edges 通常被先后赋值,用微任务合并成一次 rebuild,避免双次 dagre 布局 */ @@ -102,11 +120,15 @@ 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) => { 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 { @@ -114,9 +136,11 @@ export default { source: sid, target: tid, label: e.label || "", - type: "smoothstep", + // 贝塞尔线会在分叉后立即分离;同一对节点有多道工序时用不同曲率错开。 + type: "default", + pathOptions: { curvature: 0.25 + edgeIndex * 0.12 }, markerEnd: "arrowclosed", - style: { stroke: "#409eff", strokeWidth: 2 }, + style: { stroke: "#409eff", strokeWidth: 2.2 }, labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 }, labelBgStyle: { fill: "#ffffff", stroke: "#e4e7ed", strokeWidth: 1 }, labelBgPadding: [7, 4], @@ -137,19 +161,47 @@ 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, { + rankdir: this.rankdir, + nodesep: 58, + edgesep: 32, + ranksep: 96, + marginx: 32, + marginy: 32, + }); this.setEdges([]); - this.setNodes(layoutGraph(vNodes, vEdges, { rankdir: this.rankdir })); + this.setNodes(this.renderedNodes); this.setEdges(vEdges); - // 首次挂载交给 nodes-initialized(尺寸测量完成)再 fit;数据更新时尺寸已就绪,直接 fit - if (this.hasFitted) this.doFit(); + // 首次挂载交给 nodes-initialized(尺寸测量完成)再定位;数据更新时直接重新适配宽度 + if (this.hasFitted) this.fitToWidth(); }, onNodesInit() { - this.doFit(); + this.fitToWidth(); }, - doFit() { + /** + * 只按宽度缩放,长流程不再为了塞进高度而缩成缩略图。 + * 图从画布顶部开始展示,用户可拖动画布继续向下查看。 + */ + fitToWidth(animated = true) { this.$nextTick(() => { try { - this.fitView({ padding: 0.2, maxZoom: 1, duration: this.hasFitted ? 200 : 0 }); + const canvas = this.$refs.canvas; + if (!canvas || !this.renderedNodes.length) return; + const xs = this.renderedNodes.map((n) => n.position.x); + const ys = this.renderedNodes.map((n) => n.position.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs) + NODE_W; + const minY = Math.min(...ys); + const paddingX = 48; + const paddingTop = 36; + const graphWidth = Math.max(maxX - minX, NODE_W); + const zoom = Math.min(1, Math.max(0.2, (canvas.clientWidth - paddingX * 2) / graphWidth)); + const x = (canvas.clientWidth - graphWidth * zoom) / 2 - minX * zoom; + const y = paddingTop - minY * zoom; + this.setViewport( + { x, y, zoom }, + { duration: animated && this.hasFitted ? 200 : 0 } + ); this.hasFitted = true; } catch (e) { /* noop */