691 lines
19 KiB
Vue
691 lines
19 KiB
Vue
<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>
|
||
|
||
<!-- 复用现有单步表单:新增/编辑一条 Route;key 随每次打开递增,强制重建实例杜绝上次内容残留 -->
|
||
<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>
|