<template>
|
<div class="strain-flow-chart">
|
<div class="toolbar">
|
<el-button type="primary" size="small" @click="addNode" :disabled="!canAddNode">新增</el-button>
|
<el-button size="small" @click="setGenerationPlan" :disabled="!selectedNode">设置传代计划数</el-button>
|
<el-button size="small" @click="showDetail" :disabled="!selectedNode">详情</el-button>
|
</div>
|
<div id="mountNode"></div>
|
|
<!-- 节点详情弹窗 -->
|
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @close="handleDialogClose"
|
:close-on-click-modal="false">
|
<el-form :model="form" :rules="rules" ref="form" label-width="120px">
|
<el-form-item :label="formLabel" prop="value">
|
<el-input v-model="form.value" :type="inputType"></el-input>
|
</el-form-item>
|
<el-form-item label="废弃状态" prop="isDiscarded" v-if="showDiscarded">
|
<el-switch v-model="form.isDiscarded"></el-switch>
|
</el-form-item>
|
</el-form>
|
<div slot="footer" class="dialog-footer">
|
<el-button @click="dialogVisible = false">取 消</el-button>
|
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
</div>
|
</el-dialog>
|
|
<!-- 新增母代弹窗 -->
|
<el-dialog title="新增母代" :visible.sync="addParentDialogVisible" width="500px" :close-on-click-modal="false">
|
<el-form :model="parentForm" :rules="parentRules" ref="parentForm" label-width="120px">
|
<el-form-item label="代传菌种编号" prop="number">
|
<el-input v-model="parentForm.number"></el-input>
|
</el-form-item>
|
</el-form>
|
<div slot="footer" class="dialog-footer">
|
<el-button @click="addParentDialogVisible = false">取 消</el-button>
|
<el-button type="primary" @click="handleAddParent">确 定</el-button>
|
</div>
|
</el-dialog>
|
|
<!-- 设置传代计划数弹窗 -->
|
<el-dialog title="设置传代计划数" :visible.sync="planDialogVisible" width="500px" :close-on-click-modal="false">
|
<el-form :model="planForm" :rules="planRules" ref="planForm" label-width="120px">
|
<el-form-item label="计划数" prop="count">
|
<el-input v-model="planForm.count" type="number" :min="1"></el-input>
|
</el-form-item>
|
</el-form>
|
<div slot="footer" class="dialog-footer">
|
<el-button @click="planDialogVisible = false">取 消</el-button>
|
<el-button type="primary" @click="handleSetPlan">确 定</el-button>
|
</div>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script>
|
import G6 from '@antv/g6';
|
import dagre from 'dagre';
|
|
export default {
|
name: 'StrainFlowChart',
|
data() {
|
return {
|
graph: null,
|
nodeCount: 0,
|
selectedNode: null,
|
graphData: {
|
nodes: [],
|
edges: []
|
},
|
// 弹窗相关数据
|
dialogVisible: false,
|
dialogTitle: '',
|
formLabel: '',
|
inputType: 'text',
|
showDiscarded: false,
|
isAddingNode: false,
|
form: {
|
value: '',
|
isDiscarded: false
|
},
|
rules: {
|
value: [
|
{ required: true, message: '不能为空', trigger: 'blur' }
|
]
|
},
|
// 新增母代弹窗数据
|
addParentDialogVisible: false,
|
parentForm: {
|
number: ''
|
},
|
parentRules: {
|
number: [
|
{ required: true, message: '菌种编号不能为空', trigger: 'blur' }
|
]
|
},
|
// 设置传代计划数弹窗数据
|
planDialogVisible: false,
|
planForm: {
|
count: 1
|
},
|
planRules: {
|
count: [
|
{ required: true, message: '计划数不能为空', trigger: 'blur' }
|
]
|
}
|
};
|
},
|
computed: {
|
canAddNode() {
|
// 如果没有节点,可以新增母代
|
if (this.graphData.nodes.length === 0) {
|
return true;
|
}
|
// 如果选中了传代计划数节点,可以新增下一代
|
if (this.selectedNode && this.selectedNode.label === '传代计划数') {
|
return true;
|
}
|
return false;
|
}
|
},
|
mounted() {
|
this.initGraph();
|
this.initEvents();
|
},
|
beforeDestroy() {
|
this.graph?.destroy();
|
window.removeEventListener('resize', this.handleResize);
|
},
|
methods: {
|
initGraph() {
|
const container = document.getElementById('mountNode');
|
const width = container.scrollWidth;
|
const height = container.scrollHeight || 600;
|
|
// 自定义节点
|
G6.registerNode('custom-node', {
|
draw(cfg, group) {
|
const width = 120;
|
const titleHeight = 30;
|
const contentHeight = 40;
|
const gap = 4;
|
const totalHeight = titleHeight + gap + contentHeight;
|
|
// 根据节点状态设置颜色
|
const isDiscarded = cfg.isDiscarded;
|
const titleFill = isDiscarded ? 'rgba(245, 248, 250, 1)' : (cfg.selected ? 'l(0) 0:#0ACBCA 1:#049C9A' : 'l(0) 0:#0ACBCA 1:#049C9A');
|
const contentFill = isDiscarded ? 'rgba(245, 248, 250, 1)' : (cfg.selected ? 'rgba(4,156,154,0.2)' : 'rgba(4,156,154,0.1)');
|
const textFill = isDiscarded ? 'rgba(144, 147, 153, 1)' : '#049C9A';
|
const stroke = isDiscarded ? '#DCDFE6' : (cfg.selected ? '#049C9A' : 'transparent');
|
|
// 创建渐变
|
const gradient = group.addShape('rect', {
|
attrs: {
|
x: -width / 2,
|
y: -totalHeight / 2,
|
width: width,
|
height: titleHeight,
|
radius: 20,
|
fill: titleFill,
|
cursor: 'move',
|
stroke: stroke,
|
lineWidth: isDiscarded ? 1 : (cfg.selected ? 2 : 0),
|
},
|
name: 'title-box',
|
});
|
|
// 下部分 - 内容背景
|
const contentBox = group.addShape('rect', {
|
attrs: {
|
x: -width / 2,
|
y: -totalHeight / 2 + titleHeight + gap,
|
width: width,
|
height: contentHeight,
|
fill: contentFill,
|
radius: 15,
|
cursor: 'move',
|
stroke: stroke,
|
lineWidth: isDiscarded ? 1 : (cfg.selected ? 2 : 0),
|
},
|
name: 'content-box',
|
});
|
|
// 标题文本
|
if (cfg.label) {
|
group.addShape('text', {
|
attrs: {
|
text: cfg.label,
|
x: 0,
|
y: -totalHeight / 2 + titleHeight / 2,
|
fill: isDiscarded ? 'rgba(144, 147, 153, 1)' : '#fff',
|
fontSize: 12,
|
textAlign: 'center',
|
textBaseline: 'middle',
|
fontWeight: 'bold',
|
cursor: 'move',
|
},
|
name: 'title-text',
|
});
|
}
|
|
// 内容文本
|
let content = '';
|
if (cfg.label === '传代计划数') {
|
content = `${cfg.planCount || 0}`;
|
} else if (cfg.number) {
|
content = cfg.label === '母代' ? `代传菌种编号:${cfg.number}` : `接种菌种编号:${cfg.number}`;
|
}
|
|
if (content) {
|
group.addShape('text', {
|
attrs: {
|
text: content,
|
x: 0,
|
y: -totalHeight / 2 + titleHeight + gap + contentHeight / 2,
|
fill: textFill,
|
fontSize: 10,
|
textAlign: 'center',
|
textBaseline: 'middle',
|
cursor: 'move',
|
},
|
name: 'content-text',
|
});
|
}
|
|
return gradient;
|
},
|
getAnchorPoints() {
|
return [
|
[0.5, 0], // 上
|
[1, 0.5], // 右
|
[0.5, 1], // 下
|
[0, 0.5], // 左
|
];
|
},
|
setState(name, value, item) {
|
// 移除悬浮效果,保持节点样式始终一致
|
},
|
});
|
|
this.graph = new G6.Graph({
|
container: 'mountNode',
|
width,
|
height,
|
fitView: true,
|
fitViewPadding: 30,
|
animate: false,
|
enabledStack: false,
|
renderer: 'canvas',
|
minZoom: 0.3,
|
maxZoom: 2,
|
defaultZoom: 1,
|
layout: {
|
type: 'dagre',
|
rankdir: 'LR',
|
align: 'UL',
|
nodesep: 30, // 减小节点间距
|
ranksep: 50, // 减小层级间距
|
controlPoints: true,
|
},
|
modes: {
|
default: [
|
{
|
type: 'drag-canvas',
|
enableOptimize: true,
|
direction: 'both',
|
scalableRange: 0.1,
|
dragTimesOfScale: 0.1,
|
onlyChangeComputeZoom: true,
|
},
|
{
|
type: 'zoom-canvas',
|
sensitivity: 1.5,
|
enableOptimize: true,
|
},
|
{
|
type: 'drag-node',
|
enableDelegate: true,
|
delegateStyle: {
|
fill: '#f3f3f3',
|
stroke: '#ccc',
|
opacity: 0.5,
|
},
|
updateEdge: false,
|
enableOptimize: true,
|
optimizeZoom: 0.7,
|
damping: 0.1,
|
}
|
]
|
},
|
defaultNode: {
|
type: 'custom-node',
|
style: {
|
fill: 'l(0) 0:#0ACBCA 1:#049C9A',
|
},
|
},
|
defaultEdge: {
|
type: 'cubic-horizontal',
|
style: {
|
stroke: 'rgba(4, 156, 154, 1)',
|
lineWidth: 1,
|
opacity: 0.5,
|
endArrow: {
|
path: G6.Arrow.triangle(6, 6),
|
fill: 'rgba(4, 156, 154, 1)',
|
stroke: 'rgba(4, 156, 154, 1)',
|
},
|
},
|
},
|
optimizeEdge: true,
|
optimizeLayoutAnimation: true,
|
});
|
|
const canvas = this.graph.get('canvas');
|
canvas.set('localRefresh', false);
|
canvas.set('autoDraw', true);
|
canvas.set('animating', false);
|
|
let throttleTimer = null;
|
const throttleInterval = 16;
|
|
this.graph.on('node:dragstart', () => {
|
canvas.set('localRefresh', false);
|
this.graph.get('canvas').draw();
|
});
|
|
this.graph.on('node:drag', (e) => {
|
if (throttleTimer) return;
|
throttleTimer = setTimeout(() => {
|
const model = e.item.get('model');
|
const edges = this.graph.getEdges().filter(edge => {
|
const source = edge.getSource();
|
const target = edge.getTarget();
|
return source.get('id') === model.id || target.get('id') === model.id;
|
});
|
edges.forEach(edge => {
|
this.graph.refreshItem(edge);
|
});
|
throttleTimer = null;
|
}, throttleInterval);
|
});
|
|
this.graph.on('node:dragend', (e) => {
|
if (throttleTimer) {
|
clearTimeout(throttleTimer);
|
throttleTimer = null;
|
}
|
const model = e.item.get('model');
|
const edges = this.graph.getEdges().filter(edge => {
|
const source = edge.getSource();
|
const target = edge.getTarget();
|
return source.get('id') === model.id || target.get('id') === model.id;
|
});
|
edges.forEach(edge => {
|
this.graph.refreshItem(edge);
|
});
|
canvas.set('localRefresh', true);
|
this.graph.get('canvas').draw();
|
});
|
|
this.graph.data(this.graphData);
|
this.graph.render();
|
|
let debounceTimer = null;
|
this.graph.on('afterchange', () => {
|
if (debounceTimer) clearTimeout(debounceTimer);
|
debounceTimer = setTimeout(() => {
|
if (!canvas.get('destroyed')) {
|
canvas.draw();
|
}
|
}, 16);
|
});
|
},
|
initEvents() {
|
// 监听窗口大小变化
|
window.addEventListener('resize', this.handleResize);
|
|
// 节点点击事件
|
this.graph.on('node:click', (evt) => {
|
const node = evt.item;
|
const nodeModel = node.getModel();
|
|
// 如果节点已废弃,不允许任何操作
|
if (nodeModel.isDiscarded) {
|
this.$message.warning('该节点已废弃,不能进行操作');
|
return;
|
}
|
|
// 更新选中节点
|
this.selectedNode = nodeModel;
|
|
// 更新节点选中状态
|
this.graphData.nodes.forEach(n => {
|
n.selected = n.id === nodeModel.id;
|
});
|
this.graph.changeData(this.graphData);
|
});
|
|
// 画布点击事件,取消选中节点
|
this.graph.on('canvas:click', () => {
|
this.selectedNode = null;
|
this.graphData.nodes.forEach(n => {
|
n.selected = false;
|
});
|
this.graph.changeData(this.graphData);
|
});
|
},
|
handleResize() {
|
if (this.graph) {
|
const container = document.getElementById('mountNode');
|
const width = container.scrollWidth;
|
const height = container.scrollHeight || 600;
|
this.graph.changeSize(width, height);
|
}
|
},
|
addNode() {
|
// 如果没有节点,新增母代
|
if (this.graphData.nodes.length === 0) {
|
this.addParentDialogVisible = true;
|
this.parentForm.number = '';
|
return;
|
}
|
|
// 如果选中了传代计划数节点,新增下一代
|
if (this.selectedNode && this.selectedNode.label === '传代计划数') {
|
const nodeModel = this.selectedNode;
|
|
// 检查是否已达到计划数
|
if (nodeModel.currentCount >= nodeModel.planCount) {
|
this.$message.warning('已达到计划数,不能再添加');
|
return;
|
}
|
|
// 获取父节点
|
const parentEdge = this.graphData.edges.find(e => e.target === nodeModel.id);
|
const parentNode = this.graphData.nodes.find(n => n.id === parentEdge.source);
|
|
// 如果父节点是孙代,不允许添加
|
if (parentNode.label === '孙代') {
|
this.$message.warning('孙代节点不能再生成下一代');
|
return;
|
}
|
|
const isParent = parentNode.label === '母代';
|
const nextLevel = isParent ? '子代' : '孙代';
|
|
// 使用对话框输入节点信息
|
this.dialogVisible = true;
|
this.dialogTitle = `新增${nextLevel}`;
|
this.formLabel = '接种菌种编号';
|
this.form.value = '';
|
this.form.isDiscarded = false;
|
this.showDiscarded = true;
|
this.inputType = 'text';
|
this.isAddingNode = true;
|
}
|
},
|
handleAddParent() {
|
this.$refs.parentForm.validate((valid) => {
|
if (valid) {
|
const parentId = `parent-${++this.nodeCount}`;
|
const container = document.getElementById('mountNode');
|
const height = container.scrollHeight || 600;
|
|
this.graphData.nodes.push({
|
id: parentId,
|
label: '母代',
|
number: this.parentForm.number.trim(),
|
x: 200,
|
y: 200,
|
style: {
|
fill: '#00B5AA',
|
},
|
});
|
|
this.graph.changeData(this.graphData);
|
this.$message.success('母代节点添加成功');
|
this.addParentDialogVisible = false;
|
}
|
});
|
},
|
handleDialogClose() {
|
this.$refs.form.resetFields();
|
},
|
handleSubmit() {
|
this.$refs.form.validate((valid) => {
|
if (valid) {
|
if (this.isAddingNode) {
|
// 新增节点的处理逻辑
|
const nodeModel = this.selectedNode;
|
const parentEdge = this.graphData.edges.find(e => e.target === nodeModel.id);
|
const parentNode = this.graphData.nodes.find(n => n.id === parentEdge.source);
|
const isParent = parentNode.label === '母代';
|
const nextLevel = isParent ? '子代' : '孙代';
|
|
const childId = `child-${++this.nodeCount}`;
|
this.graphData.nodes.push({
|
id: childId,
|
label: nextLevel,
|
number: this.form.value.trim(),
|
isDiscarded: this.form.isDiscarded,
|
style: {
|
fill: this.form.isDiscarded ? '#999' : '#00B5AA',
|
opacity: this.form.isDiscarded ? 0.3 : (isParent ? 0.6 : 0.4),
|
},
|
});
|
|
this.graphData.edges.push({
|
source: nodeModel.id,
|
target: childId,
|
style: {
|
stroke: 'rgba(4, 156, 154, 1)',
|
lineWidth: 1,
|
},
|
});
|
|
const nodeIndex = this.graphData.nodes.findIndex(n => n.id === nodeModel.id);
|
this.graphData.nodes[nodeIndex].currentCount++;
|
|
this.graph.changeData(this.graphData);
|
this.$message.success(`${nextLevel}添加成功`);
|
|
this.isAddingNode = false;
|
this.dialogVisible = false;
|
} else {
|
// 编辑节点的处理逻辑
|
const nodeModel = this.selectedNode;
|
const nodeIndex = this.graphData.nodes.findIndex(n => n.id === nodeModel.id);
|
|
if (nodeIndex > -1) {
|
if (nodeModel.label === '传代计划数') {
|
this.graphData.nodes[nodeIndex].planCount = parseInt(this.form.value);
|
} else {
|
this.graphData.nodes[nodeIndex].number = this.form.value.trim();
|
if (this.showDiscarded) {
|
this.graphData.nodes[nodeIndex].isDiscarded = this.form.isDiscarded;
|
// 如果设置为废弃状态,同时废弃所有子节点
|
if (this.form.isDiscarded) {
|
const discardChildren = (parentId) => {
|
const childEdges = this.graphData.edges.filter(e => e.source === parentId);
|
childEdges.forEach(edge => {
|
const childNode = this.graphData.nodes.find(n => n.id === edge.target);
|
if (childNode) {
|
const childIndex = this.graphData.nodes.findIndex(n => n.id === childNode.id);
|
if (childIndex > -1) {
|
this.graphData.nodes[childIndex].isDiscarded = true;
|
this.graphData.nodes[childIndex].style.fill = '#999';
|
this.graphData.nodes[childIndex].style.opacity = 0.3;
|
// 递归处理子节点的子节点
|
discardChildren(childNode.id);
|
}
|
}
|
});
|
};
|
discardChildren(nodeModel.id);
|
}
|
}
|
}
|
this.graph.changeData(this.graphData);
|
this.$message.success('修改成功');
|
this.dialogVisible = false;
|
}
|
}
|
}
|
});
|
},
|
setGenerationPlan() {
|
if (!this.selectedNode) {
|
this.$message.warning('请先选择节点');
|
return;
|
}
|
|
const nodeModel = this.selectedNode;
|
|
if (nodeModel.label === '孙代') {
|
this.$message.warning('孙代节点不能再生成传代计划数');
|
return;
|
}
|
|
if (nodeModel.label === '传代计划数') {
|
this.$message.warning('传代计划数节点不能再设置计划数');
|
return;
|
}
|
|
const hasGenerationNode = this.graphData.edges.some(e =>
|
e.source === nodeModel.id &&
|
this.graphData.nodes.some(n => n.id === e.target && n.label === '传代计划数')
|
);
|
|
if (hasGenerationNode) {
|
this.$message.warning('该节点已经存在传代计划数节点');
|
return;
|
}
|
|
this.planDialogVisible = true;
|
this.planForm.count = 1;
|
},
|
handleSetPlan() {
|
this.$refs.planForm.validate((valid) => {
|
if (valid) {
|
const nodeModel = this.selectedNode;
|
const planCount = this.planForm.count;
|
const generationId = `generation-${++this.nodeCount}`;
|
|
this.graphData.nodes.push({
|
id: generationId,
|
label: '传代计划数',
|
planCount: planCount,
|
currentCount: 0,
|
style: {
|
fill: '#00B5AA',
|
},
|
});
|
|
this.graphData.edges.push({
|
source: nodeModel.id,
|
target: generationId,
|
style: {
|
stroke: 'rgba(4, 156, 154, 1)',
|
lineWidth: 1,
|
},
|
});
|
|
this.graph.changeData(this.graphData);
|
this.$message.success('传代计划数设置成功');
|
this.planDialogVisible = false;
|
}
|
});
|
},
|
showDetail() {
|
if (!this.selectedNode) {
|
this.$message.warning('请先选择节点');
|
return;
|
}
|
|
const nodeModel = this.selectedNode;
|
|
if (nodeModel.label === '母代') {
|
this.dialogTitle = '母代详情';
|
this.formLabel = '代传菌种编号';
|
this.form.value = nodeModel.number || '';
|
this.showDiscarded = false;
|
this.inputType = 'text';
|
} else if (nodeModel.label === '子代' || nodeModel.label === '孙代') {
|
this.dialogTitle = `${nodeModel.label}详情`;
|
this.formLabel = '接种菌种编号';
|
this.form.value = nodeModel.number || '';
|
this.form.isDiscarded = nodeModel.isDiscarded || false;
|
this.showDiscarded = true;
|
this.inputType = 'text';
|
} else if (nodeModel.label === '传代计划数') {
|
this.dialogTitle = '传代计划数详情';
|
this.formLabel = '计划数';
|
this.form.value = nodeModel.planCount || '';
|
this.showDiscarded = false;
|
this.inputType = 'number';
|
}
|
|
this.dialogVisible = true;
|
}
|
},
|
};
|
</script>
|
|
<style lang="less" scoped>
|
.strain-flow-chart {
|
width: 100%;
|
height: 100%;
|
min-height: 600px;
|
background-color: #fff;
|
padding: 24px;
|
border-radius: 4px;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
display: flex;
|
flex-direction: column;
|
|
.toolbar {
|
margin-bottom: 16px;
|
display: flex;
|
gap: 12px;
|
|
.el-button {
|
margin-right: 0;
|
}
|
}
|
|
#mountNode {
|
flex: 1;
|
width: 100%;
|
min-height: 500px;
|
border: 1px solid #eee;
|
border-radius: 4px;
|
}
|
}
|
</style>
|