factory_web/src/components/xtFlowEditor.vue

691 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="flow-editor" v-loading="loading">
<!-- 顶部工具栏 -->
<div class="flow-toolbar">
<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-circle-check" size="small" round @click="doValidate" :loading="validating">校验</el-button>
</div>
<div class="flow-legend">
<span class="lg lg--raw">原料</span>
<span class="lg lg--mid">半成品</span>
<span class="lg lg--final">成品</span>
</div>
</div>
<div class="flow-tip" v-if="!readonly">
从左侧拖入原料作为起点 节点间拉线新建工序拉到空白处自动生成半成品连到产品节点收尾单击线条编辑右键线条删工序 / 右键节点移除物料
</div>
<div class="flow-tip flow-tip--ro" v-else>当前状态不可编辑仅创建中的工艺可编辑</div>
<div class="flow-body">
<!-- 左侧物料面板拖入画布作为节点 -->
<div class="flow-palette" v-if="!readonly">
<div class="palette-head">
<div class="palette-title">物料库</div>
<el-select v-model="matType" size="small" @change="loadMaterials" style="width: 100%">
<el-option label="主要原料" value="30"></el-option>
<el-option label="成品" value="10"></el-option>
<el-option label="半成品" value="20"></el-option>
</el-select>
<el-input
v-model="matKeyword"
size="small"
clearable
prefix-icon="el-icon-search"
placeholder="搜索物料"
style="margin-top: 6px"
></el-input>
</div>
<div class="palette-list" v-loading="matLoading">
<div
v-for="m in filteredMaterials"
:key="m.id"
class="palette-item"
:draggable="true"
@dragstart="onDragStart($event, m)"
:title="m.full_name"
>
<i class="el-icon-rank palette-item__grip"></i>
<span class="palette-item__txt">{{ m.full_name }}</span>
</div>
<div v-if="!matLoading && filteredMaterials.length === 0" class="palette-empty">无匹配物料</div>
</div>
</div>
<!-- 画布 -->
<div class="flow-canvas" @drop="onDrop" @dragover.prevent>
<VueFlow
:id="flowId"
:default-viewport="{ zoom: 0.9 }"
:min-zoom="0.2"
:max-zoom="2"
:nodes-draggable="true"
:nodes-connectable="!readonly"
:elements-selectable="true"
@nodes-initialized="onNodesInit"
@connect-start="onConnectStart"
@connect="onConnect"
@connect-end="onConnectEnd"
@edge-click="onEdgeClick"
@edge-context-menu="onEdgeContextMenu"
@node-context-menu="onNodeContextMenu"
>
<!-- 自定义物料节点(与 xtFlowViewer 共用) -->
<template #node-material="{ data }">
<material-node :data="data" />
</template>
<Background :gap="18" pattern-color="#e6e9ef" />
<Controls :show-interactive="false" />
</VueFlow>
<div v-if="!loading && nodes.length === 0" class="flow-empty">
<i class="el-icon-position flow-empty__icon"></i>
<div>从左侧把原料拖到这里开始搭建工艺路线</div>
</div>
</div>
</div>
<!-- 复用现有单步表单新增/编辑一条 Routekey 随每次打开递增强制重建实例杜绝上次内容残留 -->
<route-form
v-if="dialogVisible"
:key="dialogSeq"
ref="routeForm"
:count="count"
:routepack="routepack"
@success="onFormSuccess"
@closed="dialogVisible = false"
></route-form>
</div>
</template>
<script>
import { VueFlow, useVueFlow } from "@vue-flow/core";
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 "@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 flowSeq = 0;
export default {
name: "xtFlowEditor",
components: { VueFlow, Background, Controls, routeForm, MaterialNode },
props: {
// 工艺包 id
routepack: { type: String, default: "" },
// 工艺包产品:常驻画布作为终点节点,可从其他节点拉线连入
productId: { type: [String, Number], default: "" },
productName: { type: String, default: "" },
// 是否只读(非“创建中”状态)
readonly: { type: Boolean, default: false },
},
emits: ["changed"],
setup() {
const flowId = "route-flow-editor-" + ++flowSeq;
const { fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes } =
useVueFlow(flowId);
return { flowId, fitView, screenToFlowCoordinate, project, setNodes, setEdges, addNodes, removeNodes };
},
data() {
return {
loading: false,
validating: false,
routes: [],
nodes: [],
edges: [],
count: 0,
dialogVisible: false,
dialogSeq: 0,
// 左侧物料面板
matType: "30",
matKeyword: "",
materials: [],
matLoading: false,
// 客户端临时种子节点(尚未挂到任何 Route 上)
seedNodes: [],
// 连线手势内部状态
connectSource: null,
connectMade: false,
// 仅初次加载自动适配一次,避免编辑时视图跳动
hasFitted: false,
};
},
computed: {
filteredMaterials() {
const kw = (this.matKeyword || "").trim().toLowerCase();
if (!kw) return this.materials;
return this.materials.filter((m) => (m.full_name || "").toLowerCase().includes(kw));
},
},
watch: {
routepack() {
this.reload();
},
productId() {
if (this.routepack) this.reload();
},
},
mounted() {
this.loadMaterials();
if (this.routepack) this.reload();
},
methods: {
/** 加载左侧物料面板列表 */
loadMaterials() {
this.matLoading = true;
this.$API.mtm.material.list
.req({ page: 0, type__in: this.matType, is_hidden: false, query: "{full_name,id}" })
.then((res) => {
this.materials = Array.isArray(res) ? res : res.results || [];
})
.finally(() => {
this.matLoading = false;
});
},
/** 重新拉取路线并重建图 */
reload() {
if (!this.routepack) return;
this.loading = true;
this.$API.mtm.route.list
.req({ page: 0, routepack: this.routepack })
.then((res) => {
this.routes = Array.isArray(res) ? res : res.results || [];
this.count = this.routes.length;
this.buildGraph();
})
.finally(() => {
this.loading = false;
});
},
/** 物料真实类型 → 节点配色10成品 30原料 其余半成品 */
kindOfType(t) {
const s = String(t);
if (s === "10") return "final";
if (s === "30") return "raw";
return "mid";
},
/** 拓扑推断兜底(后端未返回物料类型时用):起点原料 / 终点成品 / 中间半成品 */
kindOf(id, targetIds) {
const isSrc = this.routes.some((r) => String(r.material_in) === id);
const isTgt = targetIds.has(id);
if (isTgt && !isSrc) return "final";
if (!isTgt && isSrc) return "raw";
return "mid";
},
/** 由 routes 构建 物料节点 + 工序边,并用 dagre 自动布局;合并未落库的种子节点 */
buildGraph() {
const matMap = {};
const matKind = {};
const targetIds = new Set();
const edges = [];
this.routes.forEach((r) => {
if (r.material_in == null || r.material_out == null) return;
const sid = String(r.material_in);
const tid = String(r.material_out);
if (!matMap[sid]) matMap[sid] = r.material_in_name || sid;
if (!matMap[tid]) matMap[tid] = r.material_out_name || tid;
if (r.material_in_type != null) matKind[sid] = this.kindOfType(r.material_in_type);
if (r.material_out_type != null) matKind[tid] = this.kindOfType(r.material_out_type);
targetIds.add(tid);
const locked = !!r.from_route;
edges.push({
id: String(r.id),
source: sid,
target: tid,
label: (r.process_name || "") + (locked ? " 🔒" : ""),
data: { route: r, locked },
type: "smoothstep",
markerEnd: "arrowclosed",
style: {
stroke: locked ? "#c0c4cc" : "#409eff",
strokeWidth: locked ? 1.5 : 2,
strokeDasharray: locked ? "5 4" : undefined,
},
labelStyle: { fill: "#5b6370", fontSize: "12px", fontWeight: 500 },
labelBgStyle: { fill: "#ffffff", stroke: "#e4e7ed", strokeWidth: 1 },
labelBgPadding: [7, 4],
labelBgBorderRadius: 6,
});
});
const routeNodes = Object.keys(matMap).map((id) => ({
id,
type: "material",
data: { label: matMap[id], kind: matKind[id] || this.kindOf(id, targetIds) },
position: { x: 0, y: 0 },
}));
// 工艺包产品常驻画布作为终点节点(尚未被任何工序引用时也显示)
const pid = this.productId ? String(this.productId) : "";
if (pid && !matMap[pid]) {
routeNodes.push({
id: pid,
type: "material",
data: { label: this.productName || "产品", kind: "final" },
position: { x: 0, y: 0 },
});
}
// 已落库的物料从种子集合中剔除,其余种子保留在画布上
const routeNodeIds = new Set(Object.keys(matMap));
if (pid) routeNodeIds.add(pid);
this.seedNodes = this.seedNodes.filter((s) => !routeNodeIds.has(s.id));
this.edges = edges;
this.nodes = layoutGraph(routeNodes.concat(this.seedNodes), edges);
this.syncStore();
},
/** 把本地 nodes/edges 命令式同步到 Vue Flow store先节点后边避免边找不到端点 */
syncStore() {
this.setEdges([]);
this.setNodes(this.nodes);
this.setEdges(this.edges);
},
/** 重新布局并适应画布 */
relayout() {
this.nodes = layoutGraph([...this.nodes], this.edges);
this.setNodes(this.nodes);
this.doFit();
},
doFit() {
this.$nextTick(() => {
try {
this.fitView({ padding: 0.25, maxZoom: 1, duration: 300 });
} catch (e) {
/* noop */
}
});
},
/** VueFlow 节点尺寸测量完成:仅首次自动适配一次 */
onNodesInit() {
if (this.hasFitted) return;
if (this.nodes.length === 0) return;
this.hasFitted = true;
this.doFit();
},
/* ============ 左侧面板拖拽入画布 ============ */
onDragStart(evt, mat) {
evt.dataTransfer.setData(
"application/xtflow",
JSON.stringify({ id: mat.id, name: mat.full_name, type: this.matType })
);
evt.dataTransfer.effectAllowed = "move";
},
onDrop(evt) {
if (this.readonly) return;
const raw = evt.dataTransfer.getData("application/xtflow");
if (!raw) return;
let mat;
try {
mat = JSON.parse(raw);
} catch (e) {
return;
}
const pos = this.toFlowPos(evt.clientX, evt.clientY);
this.addSeed(mat, pos);
},
toFlowPos(clientX, clientY) {
if (typeof this.screenToFlowCoordinate === "function") {
return this.screenToFlowCoordinate({ x: clientX, y: clientY });
}
if (typeof this.project === "function") {
return this.project({ x: clientX, y: clientY });
}
return { x: clientX, y: clientY };
},
addSeed(mat, position) {
const id = String(mat.id);
if (this.nodes.some((n) => n.id === id)) {
const inUse = this.routes.some(
(r) => String(r.material_in) === id || String(r.material_out) === id
);
this.$message.info(inUse ? "该物料已在工序中使用" : "该物料已在画布中(右键节点可移除)");
return;
}
const node = {
id,
type: "material",
data: { label: mat.name, kind: this.kindOfType(mat.type) },
position,
};
this.seedNodes.push(node);
this.nodes = [...this.nodes, node];
// 命令式加入 store先注册再渲染 Handle避免 dimensions 竞态
this.addNodes([node]);
},
/* ============ 连线手势 ============ */
onConnectStart(params) {
this.connectSource = (params && (params.nodeId || params.source)) || null;
this.connectMade = false;
},
/** 连到已有节点:新建一条 Route预填输入/输出物料 */
onConnect(conn) {
this.connectMade = true;
if (this.readonly) return;
const src = this.nodes.find((n) => n.id === conn.source);
const tgt = this.nodes.find((n) => n.id === conn.target);
// 注意id 为雪花大整数,必须保持字符串,切勿 Number() 否则精度丢失
this.openForm("add", {
material_in: conn.source,
material_in_name: src ? src.data.label : "",
material_out: conn.target,
material_out_name: tgt ? tgt.data.label : "",
});
},
/** 拉线到空白处:新建工序,输出物料留空由后端自动生成下一节点 */
onConnectEnd() {
const src = this.connectSource;
const made = this.connectMade;
this.connectSource = null;
this.connectMade = false;
if (this.readonly || made || !src) return;
const node = this.nodes.find((n) => n.id === src);
// id 为雪花大整数,保持字符串
this.openForm("add", {
material_in: src,
material_in_name: node ? node.data.label : "",
});
},
/** 单击边 => 编辑对应 Route复用 route_form */
onEdgeClick(evt) {
const edge = evt.edge || evt;
const route = edge.data && edge.data.route;
if (!route) return;
if (this.readonly) {
this.$message.info("当前状态不可编辑");
return;
}
if (edge.data.locked) {
this.$message.warning("该工序引用了其他步骤,无法编辑");
return;
}
this.openForm("edit", route);
},
/** 右键边 => 删除对应 Route */
onEdgeContextMenu(evt) {
const oe = evt.event || evt;
if (oe && oe.preventDefault) oe.preventDefault();
if (this.readonly) return;
const edge = evt.edge || evt;
const route = edge.data && edge.data.route;
if (!route) return;
if (edge.data.locked) {
this.$message.warning("该工序被其他步骤引用,无法删除");
return;
}
this.$confirm(`确定删除工序「${route.process_name || ""}」吗?`, "提示", {
type: "warning",
})
.then(() => {
this.$API.mtm.route.delete.req(route.id).then(() => {
this.$message.success("删除成功");
this.reload();
this.$emit("changed");
});
})
.catch(() => {});
},
/** 右键节点 => 移除未落库的种子节点(已用于工序的物料需删对应工序) */
onNodeContextMenu(evt) {
const oe = evt.event || evt;
if (oe && oe.preventDefault) oe.preventDefault();
if (this.readonly) return;
const node = evt.node || evt;
const nid = node.id;
if (this.productId && nid === String(this.productId)) {
this.$message.warning("产品节点为工艺终点,不可移除");
return;
}
const inUse = this.routes.some(
(r) => String(r.material_in) === nid || String(r.material_out) === nid
);
if (inUse) {
this.$message.warning("该物料已用于工序,请删除对应工序");
return;
}
const label = (node.data && node.data.label) || "";
this.$confirm(`从画布移除「${label}」?`, "提示", { type: "warning" })
.then(() => {
this.seedNodes = this.seedNodes.filter((s) => s.id !== nid);
this.nodes = this.nodes.filter((n) => n.id !== nid);
this.removeNodes([nid]);
})
.catch(() => {});
},
addStep() {
this.openForm("add", null);
},
/** 打开单步表单prefill 为 null 表示纯新增 */
openForm(mode, prefill) {
this.dialogSeq += 1;
this.dialogVisible = true;
this.$nextTick(() => {
const inst = this.$refs.routeForm.open(mode);
if (prefill) inst.setData(prefill);
});
},
onFormSuccess() {
this.dialogVisible = false;
this.reload();
this.$emit("changed");
},
/** 调后端 validate 接口,实时反馈 DAG 是否合法 */
doValidate() {
if (!this.routepack) return;
this.validating = true;
this.$API.mtm.routepack.validate
.req(this.routepack)
.then((res) => {
if (res && res.valid) {
this.$message.success("工艺图校验通过");
} else {
this.$message.error("校验未通过:" + (res ? res.error : "未知错误"));
}
})
.finally(() => {
this.validating = false;
});
},
},
};
</script>
<style scoped>
.flow-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 480px;
}
.flow-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
background: #f7f9fc;
border: 1px solid #eef1f6;
border-radius: 8px;
flex-wrap: wrap;
}
.flow-toolbar__left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-legend {
display: flex;
align-items: center;
gap: 12px;
}
.lg {
position: relative;
padding-left: 16px;
font-size: 12px;
color: #606266;
}
.lg::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 3px;
border: 1px solid;
}
.lg--raw::before {
background: #fdf6ec;
border-color: #e6a23c;
}
.lg--mid::before {
background: #ecf5ff;
border-color: #409eff;
}
.lg--final::before {
background: #f0f9eb;
border-color: #67c23a;
}
.flow-tip {
color: #909399;
font-size: 12px;
padding: 3px 2px 4px;
}
.flow-tip--ro {
color: #e6a23c;
}
.flow-body {
display: flex;
flex: 1;
min-height: 420px;
gap: 6px;
}
.flow-palette {
width: 190px;
flex: 0 0 190px;
display: flex;
flex-direction: column;
border: 1px solid #eef1f6;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.palette-head {
padding: 8px;
border-bottom: 1px solid #f0f2f5;
background: #fafbfd;
}
.palette-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.palette-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.palette-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 9px;
margin-bottom: 6px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fff;
cursor: grab;
font-size: 12px;
color: #303133;
transition: all 0.15s;
}
.palette-item:hover {
border-color: #409eff;
background: #ecf5ff;
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.15);
transform: translateY(-1px);
}
.palette-item:active {
cursor: grabbing;
}
.palette-item__grip {
color: #c0c4cc;
flex-shrink: 0;
}
.palette-item__txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-empty {
color: #909399;
font-size: 12px;
text-align: center;
padding: 16px 0;
}
.flow-canvas {
position: relative;
flex: 1;
min-height: 420px;
border: 1px solid #eef1f6;
border-radius: 8px;
overflow: hidden;
background: #fbfcfe;
}
.flow-empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #a8abb2;
font-size: 13px;
text-align: center;
pointer-events: none;
}
.flow-empty__icon {
font-size: 34px;
color: #c8ccd4;
display: block;
margin-bottom: 10px;
}
</style>
<style>
/* Vue Flow 内部元素(非 scoped物料节点样式在 flow/MaterialNode.vue */
.vue-flow__controls {
box-shadow: 0 2px 10px rgba(31, 45, 61, 0.12);
border-radius: 6px;
overflow: hidden;
}
</style>