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 * dagre 自动布局返回带 position nodes
* @param {Array} nodes Vue Flow 节点id 必须为字符串 * @param {Array} nodes Vue Flow 节点id 必须为字符串
* @param {Array} edges Vue Flow * @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 = {}) { 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(); 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(() => ({})); g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((n) => g.setNode(n.id, { width: nodeW, height: nodeH })); nodes.forEach((n) => g.setNode(n.id, { width: nodeW, height: nodeH }));
edges.forEach((e) => g.setEdge(e.source, e.target)); edges.forEach((e) => g.setEdge(e.source, e.target));

View File

@ -5,7 +5,7 @@
<div class="flow-toolbar__left"> <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 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-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> <el-button icon="el-icon-circle-check" size="small" round @click="doValidate" :loading="validating">校验</el-button>
</div> </div>
<div class="flow-legend"> <div class="flow-legend">
@ -55,12 +55,14 @@
</div> </div>
<!-- 画布 --> <!-- 画布 -->
<div class="flow-canvas" @drop="onDrop" @dragover.prevent> <div ref="canvas" class="flow-canvas" @drop="onDrop" @dragover.prevent>
<VueFlow <VueFlow
:id="flowId" :id="flowId"
:default-viewport="{ zoom: 0.9 }" :default-viewport="{ zoom: 0.9 }"
:min-zoom="0.2" :min-zoom="0.2"
:max-zoom="2" :max-zoom="2"
:pan-on-scroll="true"
:zoom-on-scroll="false"
:connection-radius="8" :connection-radius="8"
:connect-on-click="false" :connect-on-click="false"
:nodes-draggable="true" :nodes-draggable="true"
@ -109,7 +111,7 @@ import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls"; import { Controls } from "@vue-flow/controls";
import routeForm from "@/views/mtm/route_form.vue"; import routeForm from "@/views/mtm/route_form.vue";
import MaterialNode from "./flow/MaterialNode.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/style.css";
import "@vue-flow/core/dist/theme-default.css"; import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css"; import "@vue-flow/controls/dist/style.css";
@ -132,9 +134,9 @@ export default {
emits: ["changed"], emits: ["changed"],
setup() { setup() {
const flowId = "route-flow-editor-" + ++flowSeq; const flowId = "route-flow-editor-" + ++flowSeq;
const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } = const { setViewport, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
useVueFlow(flowId); useVueFlow(flowId);
return { flowId, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes }; return { flowId, setViewport, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
}, },
data() { data() {
return { return {
@ -160,6 +162,8 @@ export default {
connectStartPos: null, connectStartPos: null,
// //
hasFitted: false, hasFitted: false,
resizeObserver: null,
resizeTimer: null,
}; };
}, },
computed: { computed: {
@ -180,6 +184,19 @@ export default {
mounted() { mounted() {
this.loadMaterials(); this.loadMaterials();
if (this.routepack) this.reload(); 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: { methods: {
/** 加载左侧物料面板列表 */ /** 加载左侧物料面板列表 */
@ -234,6 +251,7 @@ export default {
const matKind = {}; const matKind = {};
const targetIds = new Set(); const targetIds = new Set();
const edges = []; const edges = [];
const parallelEdgeCount = {};
this.routes.forEach((r) => { this.routes.forEach((r) => {
if (r.material_in == null || r.material_out == null) return; if (r.material_in == null || r.material_out == null) return;
@ -246,17 +264,21 @@ export default {
targetIds.add(tid); targetIds.add(tid);
const locked = !!r.from_route; const locked = !!r.from_route;
const edgeKey = sid + "->" + tid;
const edgeIndex = parallelEdgeCount[edgeKey] || 0;
parallelEdgeCount[edgeKey] = edgeIndex + 1;
edges.push({ edges.push({
id: String(r.id), id: String(r.id),
source: sid, source: sid,
target: tid, target: tid,
label: (r.process_name || "") + (locked ? " 🔒" : ""), label: (r.process_name || "") + (locked ? " 🔒" : ""),
data: { route: r, locked }, data: { route: r, locked },
type: "smoothstep", type: "default",
pathOptions: { curvature: 0.25 + edgeIndex * 0.12 },
markerEnd: "arrowclosed", markerEnd: "arrowclosed",
style: { style: {
stroke: locked ? "#c0c4cc" : "#409eff", stroke: locked ? "#c0c4cc" : "#409eff",
strokeWidth: locked ? 1.5 : 2, strokeWidth: locked ? 1.7 : 2.2,
strokeDasharray: locked ? "5 4" : undefined, strokeDasharray: locked ? "5 4" : undefined,
}, },
labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 }, labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 },
@ -291,10 +313,21 @@ export default {
const prevCount = this.nodes.length; const prevCount = this.nodes.length;
this.edges = edges; this.edges = edges;
this.nodes = layoutGraph(routeNodes.concat(this.seedNodes), edges); this.nodes = this.layoutNodes(routeNodes.concat(this.seedNodes), edges);
this.syncStore(); this.syncStore();
// dagre 线 // 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先节点后边避免边找不到端点 */ /** 把本地 nodes/edges 命令式同步到 Vue Flow store先节点后边避免边找不到端点 */
@ -306,15 +339,33 @@ export default {
/** 重新布局并适应画布 */ /** 重新布局并适应画布 */
relayout() { relayout() {
this.nodes = layoutGraph([...this.nodes], this.edges); this.nodes = this.layoutNodes([...this.nodes], this.edges);
this.setNodes(this.nodes); this.setNodes(this.nodes);
this.doFit(); this.fitToWidth();
}, },
doFit() { /** 按宽度保持节点可读,长流程通过滚轮或拖动画布纵向浏览 */
fitToWidth(animated = true) {
this.$nextTick(() => { this.$nextTick(() => {
try { 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) { } catch (e) {
/* noop */ /* noop */
} }
@ -325,8 +376,7 @@ export default {
onNodesInit() { onNodesInit() {
if (this.hasFitted) return; if (this.hasFitted) return;
if (this.nodes.length === 0) return; if (this.nodes.length === 0) return;
this.hasFitted = true; this.fitToWidth();
this.doFit();
}, },
/* ============ 左侧面板拖拽入画布 ============ */ /* ============ 左侧面板拖拽入画布 ============ */

View File

@ -3,7 +3,7 @@
<!-- 顶部工具栏 xtFlowEditor 视觉一致 --> <!-- 顶部工具栏 xtFlowEditor 视觉一致 -->
<div class="flow-toolbar"> <div class="flow-toolbar">
<div class="flow-toolbar__left"> <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>
<div class="flow-legend"> <div class="flow-legend">
<span class="lg lg--raw">原料</span> <span class="lg lg--raw">原料</span>
@ -11,11 +11,13 @@
<span class="lg lg--final">成品</span> <span class="lg lg--final">成品</span>
</div> </div>
</div> </div>
<div class="flow-canvas"> <div ref="canvas" class="flow-canvas">
<VueFlow <VueFlow
:id="flowId" :id="flowId"
:min-zoom="0.2" :min-zoom="0.2"
:max-zoom="2" :max-zoom="2"
:pan-on-scroll="true"
:zoom-on-scroll="false"
:nodes-draggable="false" :nodes-draggable="false"
:nodes-connectable="false" :nodes-connectable="false"
:elements-selectable="false" :elements-selectable="false"
@ -40,7 +42,7 @@ import { VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background"; import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls"; import { Controls } from "@vue-flow/controls";
import MaterialNode from "./flow/MaterialNode.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/style.css";
import "@vue-flow/core/dist/theme-default.css"; import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css"; import "@vue-flow/controls/dist/style.css";
@ -62,14 +64,17 @@ export default {
}, },
setup() { setup() {
const flowId = "xt-flow-viewer-" + ++viewerSeq; const flowId = "xt-flow-viewer-" + ++viewerSeq;
const { fitView, setNodes, setEdges } = useVueFlow(flowId); const { setViewport, setNodes, setEdges } = useVueFlow(flowId);
return { flowId, fitView, setNodes, setEdges }; return { flowId, setViewport, setNodes, setEdges };
}, },
data() { data() {
return { return {
// fit // fit
hasFitted: false, hasFitted: false,
rebuildPending: false, rebuildPending: false,
renderedNodes: [],
resizeObserver: null,
resizeTimer: null,
}; };
}, },
computed: { computed: {
@ -87,6 +92,19 @@ export default {
}, },
mounted() { mounted() {
this.rebuild(); 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: { methods: {
/** nodes/edges 通常被先后赋值,用微任务合并成一次 rebuild避免双次 dagre 布局 */ /** nodes/edges 通常被先后赋值,用微任务合并成一次 rebuild避免双次 dagre 布局 */
@ -102,11 +120,15 @@ export default {
rebuild() { rebuild() {
const sourceIds = new Set(); const sourceIds = new Set();
const targetIds = new Set(); const targetIds = new Set();
const parallelEdgeCount = {};
const rawEdges = (this.edges || []) const rawEdges = (this.edges || [])
.filter((e) => e.source != null && e.target != null) .filter((e) => e.source != null && e.target != null)
.map((e) => { .map((e) => {
const sid = String(e.source); const sid = String(e.source);
const tid = String(e.target); const tid = String(e.target);
const edgeKey = sid + "->" + tid;
const edgeIndex = parallelEdgeCount[edgeKey] || 0;
parallelEdgeCount[edgeKey] = edgeIndex + 1;
sourceIds.add(sid); sourceIds.add(sid);
targetIds.add(tid); targetIds.add(tid);
return { return {
@ -114,9 +136,11 @@ export default {
source: sid, source: sid,
target: tid, target: tid,
label: e.label || "", label: e.label || "",
type: "smoothstep", // 线
type: "default",
pathOptions: { curvature: 0.25 + edgeIndex * 0.12 },
markerEnd: "arrowclosed", markerEnd: "arrowclosed",
style: { stroke: "#409eff", strokeWidth: 2 }, style: { stroke: "#409eff", strokeWidth: 2.2 },
labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 }, labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 },
labelBgStyle: { fill: "#ffffff", stroke: "#e4e7ed", strokeWidth: 1 }, labelBgStyle: { fill: "#ffffff", stroke: "#e4e7ed", strokeWidth: 1 },
labelBgPadding: [7, 4], labelBgPadding: [7, 4],
@ -137,19 +161,47 @@ export default {
const nodeIds = new Set(vNodes.map((n) => n.id)); const nodeIds = new Set(vNodes.map((n) => n.id));
const vEdges = rawEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)); 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.setEdges([]);
this.setNodes(layoutGraph(vNodes, vEdges, { rankdir: this.rankdir })); this.setNodes(this.renderedNodes);
this.setEdges(vEdges); this.setEdges(vEdges);
// nodes-initialized fit fit // nodes-initialized
if (this.hasFitted) this.doFit(); if (this.hasFitted) this.fitToWidth();
}, },
onNodesInit() { onNodesInit() {
this.doFit(); this.fitToWidth();
}, },
doFit() { /**
* 只按宽度缩放长流程不再为了塞进高度而缩成缩略图
* 图从画布顶部开始展示用户可拖动画布继续向下查看
*/
fitToWidth(animated = true) {
this.$nextTick(() => { this.$nextTick(() => {
try { 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; this.hasFitted = true;
} catch (e) { } catch (e) {
/* noop */ /* noop */