Improve route flow responsive layout

This commit is contained in:
caoqianming 2026-07-27 14:54:58 +08:00
parent be1d702334
commit a1fda76330
3 changed files with 142 additions and 31 deletions

View File

@ -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));

View File

@ -5,7 +5,7 @@
<div class="flow-toolbar__left">
<el-button v-if="!readonly" type="primary" icon="el-icon-plus" size="small" round @click="addStep">新增工序</el-button>
<el-button icon="el-icon-magic-stick" size="small" round @click="relayout">自动布局</el-button>
<el-button icon="el-icon-full-screen" size="small" round @click="doFit">适应画布</el-button>
<el-button icon="el-icon-full-screen" size="small" round @click="fitToWidth">适应宽度</el-button>
<el-button icon="el-icon-circle-check" size="small" round @click="doValidate" :loading="validating">校验</el-button>
</div>
<div class="flow-legend">
@ -55,12 +55,14 @@
</div>
<!-- 画布 -->
<div class="flow-canvas" @drop="onDrop" @dragover.prevent>
<div ref="canvas" class="flow-canvas" @drop="onDrop" @dragover.prevent>
<VueFlow
:id="flowId"
:default-viewport="{ zoom: 0.9 }"
:min-zoom="0.2"
:max-zoom="2"
:pan-on-scroll="true"
:zoom-on-scroll="false"
:connection-radius="8"
:connect-on-click="false"
:nodes-draggable="true"
@ -109,7 +111,7 @@ import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import routeForm from "@/views/mtm/route_form.vue";
import MaterialNode from "./flow/MaterialNode.vue";
import { layoutGraph } from "./flow/dagreLayout";
import { layoutGraph, 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";
@ -132,9 +134,9 @@ export default {
emits: ["changed"],
setup() {
const flowId = "route-flow-editor-" + ++flowSeq;
const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
const { setViewport, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
useVueFlow(flowId);
return { flowId, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
return { flowId, setViewport, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
},
data() {
return {
@ -160,6 +162,8 @@ export default {
connectStartPos: null,
//
hasFitted: false,
resizeObserver: null,
resizeTimer: null,
};
},
computed: {
@ -180,6 +184,19 @@ export default {
mounted() {
this.loadMaterials();
if (this.routepack) this.reload();
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => {
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();
},
/* ============ 左侧面板拖拽入画布 ============ */

View File

@ -3,7 +3,7 @@
<!-- 顶部工具栏 xtFlowEditor 视觉一致 -->
<div class="flow-toolbar">
<div class="flow-toolbar__left">
<el-button icon="el-icon-full-screen" size="small" round @click="doFit">适应画布</el-button>
<el-button icon="el-icon-full-screen" size="small" round @click="fitToWidth">适应宽度</el-button>
</div>
<div class="flow-legend">
<span class="lg lg--raw">原料</span>
@ -11,11 +11,13 @@
<span class="lg lg--final">成品</span>
</div>
</div>
<div class="flow-canvas">
<div ref="canvas" class="flow-canvas">
<VueFlow
:id="flowId"
:min-zoom="0.2"
:max-zoom="2"
:pan-on-scroll="true"
:zoom-on-scroll="false"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
@ -40,7 +42,7 @@ import { VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import MaterialNode from "./flow/MaterialNode.vue";
import { layoutGraph } from "./flow/dagreLayout";
import { layoutGraph, 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";
@ -62,14 +64,17 @@ export default {
},
setup() {
const flowId = "xt-flow-viewer-" + ++viewerSeq;
const { fitView, setNodes, setEdges } = useVueFlow(flowId);
return { flowId, fitView, setNodes, setEdges };
const { setViewport, setNodes, setEdges } = useVueFlow(flowId);
return { flowId, setViewport, setNodes, setEdges };
},
data() {
return {
// fit
hasFitted: false,
rebuildPending: false,
renderedNodes: [],
resizeObserver: null,
resizeTimer: null,
};
},
computed: {
@ -87,6 +92,19 @@ export default {
},
mounted() {
this.rebuild();
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => {
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 */