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:
parent
46ed8b0c54
commit
63dfe7e948
|
|
@ -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>
|
||||
|
|
@ -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 } };
|
||||
});
|
||||
}
|
||||
|
|
@ -72,14 +72,9 @@
|
|||
@edge-context-menu="onEdgeContextMenu"
|
||||
@node-context-menu="onNodeContextMenu"
|
||||
>
|
||||
<!-- 自定义物料节点 -->
|
||||
<!-- 自定义物料节点(与 scFlowViewer 共用) -->
|
||||
<template #node-material="{ data }">
|
||||
<div class="mnode" :class="'mnode--' + (data.kind || 'mid')">
|
||||
<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>
|
||||
<material-node :data="data" />
|
||||
</template>
|
||||
|
||||
<Background :gap="18" pattern-color="#e6e9ef" />
|
||||
|
|
@ -105,24 +100,22 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { VueFlow, useVueFlow, Handle, Position } from "@vue-flow/core";
|
||||
import { VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import { Background } from "@vue-flow/background";
|
||||
import { Controls } from "@vue-flow/controls";
|
||||
import dagre from "dagre";
|
||||
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/theme-default.css";
|
||||
import "@vue-flow/controls/dist/style.css";
|
||||
|
||||
const NODE_W = 150;
|
||||
const NODE_H = 54;
|
||||
|
||||
// 每个编辑器实例分配唯一 flowId,避免 Vue Flow 全局 store 按固定 id 缓存导致跨次残留
|
||||
let flowSeq = 0;
|
||||
|
||||
export default {
|
||||
name: "scFlowEditor",
|
||||
components: { VueFlow, Background, Controls, routeForm, Handle },
|
||||
components: { VueFlow, Background, Controls, routeForm, MaterialNode },
|
||||
props: {
|
||||
// 工艺包 id
|
||||
routepack: { type: String, default: "" },
|
||||
|
|
@ -134,7 +127,7 @@ export default {
|
|||
const flowId = "route-flow-editor-" + ++flowSeq;
|
||||
const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
|
||||
useVueFlow(flowId);
|
||||
return { flowId, Position, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
|
||||
return { flowId, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -176,10 +169,6 @@ export default {
|
|||
if (this.routepack) this.reload();
|
||||
},
|
||||
methods: {
|
||||
kindLabel(kind) {
|
||||
return { raw: "原料", final: "成品", mid: "半成品" }[kind] || "半成品";
|
||||
},
|
||||
|
||||
/** 加载左侧物料面板列表 */
|
||||
loadMaterials() {
|
||||
this.matLoading = true;
|
||||
|
|
@ -265,7 +254,7 @@ export default {
|
|||
this.seedNodes = this.seedNodes.filter((s) => !routeNodeIds.has(s.id));
|
||||
|
||||
this.edges = edges;
|
||||
this.nodes = this.doLayout(routeNodes.concat(this.seedNodes), edges);
|
||||
this.nodes = layoutGraph(routeNodes.concat(this.seedNodes), edges);
|
||||
this.syncStore();
|
||||
},
|
||||
|
||||
|
|
@ -276,23 +265,9 @@ export default {
|
|||
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() {
|
||||
this.nodes = this.doLayout([...this.nodes], this.edges);
|
||||
this.nodes = layoutGraph([...this.nodes], this.edges);
|
||||
this.setNodes(this.nodes);
|
||||
this.doFit();
|
||||
},
|
||||
|
|
@ -668,79 +643,9 @@ export default {
|
|||
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>
|
||||
/* Vue Flow 内部元素(非 scoped) */
|
||||
.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 内部元素(非 scoped);物料节点样式在 flow/MaterialNode.vue */
|
||||
.vue-flow__controls {
|
||||
box-shadow: 0 2px 10px rgba(31, 45, 61, 0.12);
|
||||
border-radius: 6px;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -17,14 +17,7 @@
|
|||
</div>
|
||||
</el-header>
|
||||
<el-main class="nopadding">
|
||||
<scDegra
|
||||
style="margin-top: 50px;"
|
||||
ref="degraDialogs"
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
:rankdir="'DL'"
|
||||
>
|
||||
</scDegra>
|
||||
<scFlowViewer :nodes="nodes" :edges="edges"></scFlowViewer>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-aside>
|
||||
|
|
@ -130,6 +123,9 @@
|
|||
</el-dialog>
|
||||
</template>
|
||||
<script>
|
||||
import { defineAsyncComponent } from "vue";
|
||||
// 异步加载:Vue Flow 单独分包
|
||||
const scFlowViewer = defineAsyncComponent(() => import("@/components/scFlowViewer.vue"));
|
||||
const defaultForm = {
|
||||
process: null,
|
||||
sort: 1,
|
||||
|
|
@ -139,7 +135,7 @@ const defaultForm = {
|
|||
};
|
||||
export default {
|
||||
name: "route",
|
||||
components: {},
|
||||
components: { scFlowViewer },
|
||||
data() {
|
||||
return {
|
||||
apiObj: null,
|
||||
|
|
|
|||
|
|
@ -17,17 +17,17 @@
|
|||
</el-header>
|
||||
<el-main>
|
||||
<el-container>
|
||||
<el-aside style="width: 50%;overflow: scroll;">
|
||||
<el-aside style="width: 50%;overflow: hidden;display: flex;flex-direction: column;">
|
||||
<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>
|
||||
<el-tabs v-if="tabsTitle.length > 1" v-model="activeName" :tab-position="'left'"
|
||||
style="flex-direction: row;" @tab-click="tabshandleClick">
|
||||
<el-tabs v-if="tabsTitle.length > 1" v-model="activeName" :tab-position="'top'"
|
||||
@tab-click="tabshandleClick">
|
||||
<el-tab-pane :label="item" v-for="item in tabsTitle" :key="item"></el-tab-pane>
|
||||
</el-tabs>
|
||||
<scDegra style="margin-top: 50px;" v-if="limitedWatch" ref="degraDialogs" :nodes="nodes"
|
||||
:edges="edges" :rankdir="'DL'" @closeDialog="limitedWatch = false">
|
||||
</scDegra>
|
||||
<scFlowViewer v-if="limitedWatch" :nodes="nodes" :edges="edges"
|
||||
style="flex: 1;min-height: 0;">
|
||||
</scFlowViewer>
|
||||
</el-aside>
|
||||
<el-main class="padding: 1px">
|
||||
<scTable ref="tables" :data="tableData" row-key="id" hidePagination hideDo stripe border>
|
||||
|
|
@ -70,10 +70,13 @@
|
|||
</el-container>
|
||||
</template>
|
||||
<script>
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import ticketd from '@/views/wf/ticketd.vue'
|
||||
// 异步加载:Vue Flow 单独分包,仅打开详情时才下载
|
||||
const scFlowViewer = defineAsyncComponent(() => import("@/components/scFlowViewer.vue"));
|
||||
export default {
|
||||
emits: ["success", "closed"],
|
||||
components: { ticketd },
|
||||
components: { ticketd, scFlowViewer },
|
||||
props: {
|
||||
t_id: { type: String, default: null },
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue