refactor:工艺路线查看统一到Vue Flow只读视图scFlowViewer,与编辑器共用MaterialNode节点/dagre布局;route_show与route页弃用dagre-d3版scDegra

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-22 14:32:15 +08:00
parent 46ed8b0c54
commit 63dfe7e948
6 changed files with 303 additions and 122 deletions

View File

@ -0,0 +1,106 @@
<template>
<div class="mnode" :class="'mnode--' + (data.kind || 'mid')">
<Handle type="target" :position="Position.Top" class="mnode__handle" />
<span class="mnode__tag">{{ kindLabel }}</span>
<div class="mnode__name" :title="data.label">{{ data.label }}</div>
<Handle type="source" :position="Position.Bottom" class="mnode__handle" />
</div>
</template>
<script>
import { Handle, Position } from "@vue-flow/core";
// scFlowEditor scFlowViewer
export default {
name: "MaterialNode",
components: { Handle },
props: {
// { label, kind: raw|mid|final }
data: { type: Object, default: () => ({}) },
},
data() {
return { Position };
},
computed: {
kindLabel() {
return { raw: "原料", final: "成品", mid: "半成品" }[this.data.kind] || "半成品";
},
},
};
</script>
<style scoped>
.mnode {
position: relative;
width: 150px;
padding: 6px 10px 8px;
border-radius: 8px;
border: 1px solid #dcdfe6;
background: #fff;
box-shadow: 0 2px 8px rgba(31, 45, 61, 0.08);
text-align: center;
transition: box-shadow 0.15s, transform 0.15s;
}
.mnode:hover {
box-shadow: 0 4px 14px rgba(31, 45, 61, 0.16);
transform: translateY(-1px);
}
.mnode__tag {
display: inline-block;
font-size: 11px;
line-height: 1;
padding: 2px 6px;
border-radius: 8px;
margin-bottom: 5px;
color: #fff;
}
.mnode__name {
font-size: 12px;
line-height: 1.3;
color: #303133;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mnode--raw {
border-color: #f0c78a;
background: #fffaf2;
}
.mnode--raw .mnode__tag {
background: #e6a23c;
}
.mnode--mid {
border-color: #a3d0ff;
background: #f5faff;
}
.mnode--mid .mnode__tag {
background: #409eff;
}
.mnode--final {
border-color: #a4da89;
background: #f6fcf1;
}
.mnode--final .mnode__tag {
background: #67c23a;
}
</style>
<style>
/* Vue Flow 内部元素(非 scoped */
.vue-flow__node-material {
background: transparent;
border: none;
padding: 0;
}
.vue-flow__handle {
width: 9px;
height: 9px;
background: #fff;
border: 2px solid #409eff;
}
.vue-flow__handle:hover {
background: #409eff;
}
</style>

View File

@ -0,0 +1,24 @@
import dagre from "dagre";
export const NODE_W = 150;
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 }
*/
export function layoutGraph(nodes, edges, opts = {}) {
const { rankdir = "TB", nodeW = NODE_W, nodeH = NODE_H } = opts;
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir, nodesep: 30, ranksep: 56, marginx: 20, marginy: 20 });
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((n) => g.setNode(n.id, { width: nodeW, height: nodeH }));
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
return nodes.map((n) => {
const gn = g.node(n.id);
return { ...n, position: { x: gn.x - nodeW / 2, y: gn.y - nodeH / 2 } };
});
}

View File

@ -72,14 +72,9 @@
@edge-context-menu="onEdgeContextMenu" @edge-context-menu="onEdgeContextMenu"
@node-context-menu="onNodeContextMenu" @node-context-menu="onNodeContextMenu"
> >
<!-- 自定义物料节点 --> <!-- 自定义物料节点 scFlowViewer 共用 -->
<template #node-material="{ data }"> <template #node-material="{ data }">
<div class="mnode" :class="'mnode--' + (data.kind || 'mid')"> <material-node :data="data" />
<Handle type="target" :position="Position.Top" class="mnode__handle" />
<span class="mnode__tag">{{ kindLabel(data.kind) }}</span>
<div class="mnode__name" :title="data.label">{{ data.label }}</div>
<Handle type="source" :position="Position.Bottom" class="mnode__handle" />
</div>
</template> </template>
<Background :gap="18" pattern-color="#e6e9ef" /> <Background :gap="18" pattern-color="#e6e9ef" />
@ -105,24 +100,22 @@
</template> </template>
<script> <script>
import { VueFlow, useVueFlow, Handle, Position } from "@vue-flow/core"; 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 dagre from "dagre";
import routeForm from "@/views/mtm/route_form.vue"; import routeForm from "@/views/mtm/route_form.vue";
import MaterialNode from "./flow/MaterialNode.vue";
import { layoutGraph } 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";
const NODE_W = 150;
const NODE_H = 54;
// flowId Vue Flow store id // flowId Vue Flow store id
let flowSeq = 0; let flowSeq = 0;
export default { export default {
name: "scFlowEditor", name: "scFlowEditor",
components: { VueFlow, Background, Controls, routeForm, Handle }, components: { VueFlow, Background, Controls, routeForm, MaterialNode },
props: { props: {
// id // id
routepack: { type: String, default: "" }, routepack: { type: String, default: "" },
@ -134,7 +127,7 @@ export default {
const flowId = "route-flow-editor-" + ++flowSeq; const flowId = "route-flow-editor-" + ++flowSeq;
const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } = const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
useVueFlow(flowId); useVueFlow(flowId);
return { flowId, Position, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes }; return { flowId, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
}, },
data() { data() {
return { return {
@ -176,10 +169,6 @@ export default {
if (this.routepack) this.reload(); if (this.routepack) this.reload();
}, },
methods: { methods: {
kindLabel(kind) {
return { raw: "原料", final: "成品", mid: "半成品" }[kind] || "半成品";
},
/** 加载左侧物料面板列表 */ /** 加载左侧物料面板列表 */
loadMaterials() { loadMaterials() {
this.matLoading = true; this.matLoading = true;
@ -265,7 +254,7 @@ export default {
this.seedNodes = this.seedNodes.filter((s) => !routeNodeIds.has(s.id)); this.seedNodes = this.seedNodes.filter((s) => !routeNodeIds.has(s.id));
this.edges = edges; this.edges = edges;
this.nodes = this.doLayout(routeNodes.concat(this.seedNodes), edges); this.nodes = layoutGraph(routeNodes.concat(this.seedNodes), edges);
this.syncStore(); this.syncStore();
}, },
@ -276,23 +265,9 @@ export default {
this.setEdges(this.edges); this.setEdges(this.edges);
}, },
/** dagre 布局,返回带 position 的 nodes自上而下 TB */
doLayout(nodes, edges, rankdir = "TB") {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir, nodesep: 30, ranksep: 56, marginx: 20, marginy: 20 });
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((n) => g.setNode(n.id, { width: NODE_W, height: NODE_H }));
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
return nodes.map((n) => {
const gn = g.node(n.id);
return { ...n, position: { x: gn.x - NODE_W / 2, y: gn.y - NODE_H / 2 } };
});
},
/** 重新布局并适应画布 */ /** 重新布局并适应画布 */
relayout() { relayout() {
this.nodes = this.doLayout([...this.nodes], this.edges); this.nodes = layoutGraph([...this.nodes], this.edges);
this.setNodes(this.nodes); this.setNodes(this.nodes);
this.doFit(); this.doFit();
}, },
@ -668,79 +643,9 @@ export default {
margin-bottom: 10px; margin-bottom: 10px;
} }
/* ===== 自定义物料节点 ===== */
.mnode {
position: relative;
width: 150px;
padding: 6px 10px 8px;
border-radius: 8px;
border: 1px solid #dcdfe6;
background: #fff;
box-shadow: 0 2px 8px rgba(31, 45, 61, 0.08);
text-align: center;
transition: box-shadow 0.15s, transform 0.15s;
}
.mnode:hover {
box-shadow: 0 4px 14px rgba(31, 45, 61, 0.16);
transform: translateY(-1px);
}
.mnode__tag {
display: inline-block;
font-size: 11px;
line-height: 1;
padding: 2px 6px;
border-radius: 8px;
margin-bottom: 5px;
color: #fff;
}
.mnode__name {
font-size: 12px;
line-height: 1.3;
color: #303133;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mnode--raw {
border-color: #f0c78a;
background: #fffaf2;
}
.mnode--raw .mnode__tag {
background: #e6a23c;
}
.mnode--mid {
border-color: #a3d0ff;
background: #f5faff;
}
.mnode--mid .mnode__tag {
background: #409eff;
}
.mnode--final {
border-color: #a4da89;
background: #f6fcf1;
}
.mnode--final .mnode__tag {
background: #67c23a;
}
</style> </style>
<style> <style>
/* Vue Flow 内部元素(非 scoped */ /* Vue Flow 内部元素(非 scoped物料节点样式在 flow/MaterialNode.vue */
.vue-flow__handle {
width: 9px;
height: 9px;
background: #fff;
border: 2px solid #409eff;
}
.vue-flow__handle:hover {
background: #409eff;
}
.vue-flow__node-material {
background: transparent;
border: none;
padding: 0;
}
.vue-flow__controls { .vue-flow__controls {
box-shadow: 0 2px 10px rgba(31, 45, 61, 0.12); box-shadow: 0 2px 10px rgba(31, 45, 61, 0.12);
border-radius: 6px; border-radius: 6px;

View File

@ -0,0 +1,147 @@
<template>
<div class="sc-flow-viewer">
<VueFlow
:id="flowId"
:min-zoom="0.2"
:max-zoom="2"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
@nodes-initialized="onNodesInit"
>
<template #node-material="{ data }">
<material-node :data="data" />
</template>
<Background :gap="18" pattern-color="#e6e9ef" />
<Controls :show-interactive="false" />
</VueFlow>
</div>
</template>
<script>
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 "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
// flowId Vue Flow store id
let viewerSeq = 0;
/**
* 工艺路线只读流程图Vue Flow scFlowEditor 共用节点样式与布局
* props scDegra 对齐nodes [{id,label}]edges [{id,source,target,label}]
*/
export default {
name: "scFlowViewer",
components: { VueFlow, Background, Controls, MaterialNode },
props: {
nodes: { type: Array, default: () => [] },
edges: { type: Array, default: () => [] },
rankdir: { type: String, default: "TB" },
},
setup() {
const flowId = "sc-flow-viewer-" + ++viewerSeq;
const { fitView, setNodes, setEdges } = useVueFlow(flowId);
return { flowId, fitView, setNodes, setEdges };
},
watch: {
nodes() {
this.rebuild();
},
edges() {
this.rebuild();
},
},
mounted() {
this.rebuild();
},
methods: {
/** nodes/edges -> Vue Flow 图;节点类型按出入度推断(无入边=原料,无出边=成品) */
rebuild() {
const sourceIds = new Set();
const targetIds = new Set();
const rawEdges = (this.edges || [])
.filter((e) => e.source != null && e.target != null)
.map((e) => {
const sid = String(e.source);
const tid = String(e.target);
sourceIds.add(sid);
targetIds.add(tid);
return {
id: e.id != null ? String(e.id) : sid + "-" + tid,
source: sid,
target: tid,
label: e.label || "",
type: "smoothstep",
markerEnd: "arrowclosed",
style: { stroke: "#409eff", strokeWidth: 2 },
labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 },
labelBgStyle: { fill: "#ffffff", stroke: "#e4e7ed", strokeWidth: 1 },
labelBgPadding: [7, 4],
labelBgBorderRadius: 6,
};
});
const vNodes = (this.nodes || [])
.filter((n) => n.id != null && n.label)
.map((n) => {
const id = String(n.id);
const isSrc = sourceIds.has(id);
const isTgt = targetIds.has(id);
let kind = "mid";
if (isTgt && !isSrc) kind = "final";
else if (isSrc && !isTgt) kind = "raw";
return { id, type: "material", data: { label: n.label, kind }, position: { x: 0, y: 0 } };
});
const nodeIds = new Set(vNodes.map((n) => n.id));
const vEdges = rawEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
this.setEdges([]);
this.setNodes(layoutGraph(vNodes, vEdges, { rankdir: this.rankdir }));
this.setEdges(vEdges);
this.doFit();
},
onNodesInit() {
this.doFit();
},
doFit() {
this.$nextTick(() => {
try {
this.fitView({ padding: 0.2, maxZoom: 1, duration: 200 });
} catch (e) {
/* noop */
}
});
},
},
};
</script>
<style scoped>
.sc-flow-viewer {
position: relative;
width: 100%;
height: 100%;
min-height: 320px;
border: 1px solid #eef1f6;
border-radius: 8px;
overflow: hidden;
background: #fbfcfe;
}
/* 只读视图:隐藏连接点 */
.sc-flow-viewer :deep(.vue-flow__handle) {
opacity: 0;
pointer-events: none;
}
</style>
<style>
.sc-flow-viewer .vue-flow__controls {
box-shadow: 0 2px 10px rgba(31, 45, 61, 0.12);
border-radius: 6px;
overflow: hidden;
}
</style>

View File

@ -17,14 +17,7 @@
</div> </div>
</el-header> </el-header>
<el-main class="nopadding"> <el-main class="nopadding">
<scDegra <scFlowViewer :nodes="nodes" :edges="edges"></scFlowViewer>
style="margin-top: 50px;"
ref="degraDialogs"
:nodes="nodes"
:edges="edges"
:rankdir="'DL'"
>
</scDegra>
</el-main> </el-main>
</el-container> </el-container>
</el-aside> </el-aside>
@ -130,6 +123,9 @@
</el-dialog> </el-dialog>
</template> </template>
<script> <script>
import { defineAsyncComponent } from "vue";
// Vue Flow
const scFlowViewer = defineAsyncComponent(() => import("@/components/scFlowViewer.vue"));
const defaultForm = { const defaultForm = {
process: null, process: null,
sort: 1, sort: 1,
@ -139,7 +135,7 @@ const defaultForm = {
}; };
export default { export default {
name: "route", name: "route",
components: {}, components: { scFlowViewer },
data() { data() {
return { return {
apiObj: null, apiObj: null,

View File

@ -17,17 +17,17 @@
</el-header> </el-header>
<el-main> <el-main>
<el-container> <el-container>
<el-aside style="width: 50%;overflow: scroll;"> <el-aside style="width: 50%;overflow: hidden;display: flex;flex-direction: column;">
<div <div
style="font-weight: 600;color: #303133;font-size: 16px;padding: 10px 0;position: fixed;"> style="font-weight: 600;color: #303133;font-size: 16px;padding: 10px 0;">
工艺路线流程图</div> 工艺路线流程图</div>
<el-tabs v-if="tabsTitle.length > 1" v-model="activeName" :tab-position="'left'" <el-tabs v-if="tabsTitle.length > 1" v-model="activeName" :tab-position="'top'"
style="flex-direction: row;" @tab-click="tabshandleClick"> @tab-click="tabshandleClick">
<el-tab-pane :label="item" v-for="item in tabsTitle" :key="item"></el-tab-pane> <el-tab-pane :label="item" v-for="item in tabsTitle" :key="item"></el-tab-pane>
</el-tabs> </el-tabs>
<scDegra style="margin-top: 50px;" v-if="limitedWatch" ref="degraDialogs" :nodes="nodes" <scFlowViewer v-if="limitedWatch" :nodes="nodes" :edges="edges"
:edges="edges" :rankdir="'DL'" @closeDialog="limitedWatch = false"> style="flex: 1;min-height: 0;">
</scDegra> </scFlowViewer>
</el-aside> </el-aside>
<el-main class="padding: 1px"> <el-main class="padding: 1px">
<scTable ref="tables" :data="tableData" row-key="id" hidePagination hideDo stripe border> <scTable ref="tables" :data="tableData" row-key="id" hidePagination hideDo stripe border>
@ -70,10 +70,13 @@
</el-container> </el-container>
</template> </template>
<script> <script>
import { defineAsyncComponent } from "vue";
import ticketd from '@/views/wf/ticketd.vue' import ticketd from '@/views/wf/ticketd.vue'
// Vue Flow
const scFlowViewer = defineAsyncComponent(() => import("@/components/scFlowViewer.vue"));
export default { export default {
emits: ["success", "closed"], emits: ["success", "closed"],
components: { ticketd }, components: { ticketd, scFlowViewer },
props: { props: {
t_id: { type: String, default: null }, t_id: { type: String, default: null },
}, },