hejianhao
2025-04-30 9e68dd38fd021acb579c9d280546dd442455fc74
谱系图
2个文件已修改
1个文件已添加
706 ■■■■■ 已修改文件
culture/package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
culture/src/router/index.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
culture/src/views/strain-library/strain-flow-chart/index.vue 694 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
culture/package.json
@@ -10,6 +10,7 @@
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "@antv/g6": "^4.8.24",
    "@tinymce/tinymce-vue": "^3.2.8",
    "aieditor": "^1.3.6",
    "axios": "^0.24.0",
culture/src/router/index.js
@@ -198,7 +198,16 @@
                            hide: true
                        },
                        component: () => import("../views/strain-library/production-cell-library/add.vue"),
                    }
                    },
                    {
                        path: "strain-flow-chart",
                        name: "StrainFlowChart",
                        meta: {
                            title: "菌种传代产生流程图",
                            keepAlive: true,
                        },
                        component: () => import("../views/strain-library/strain-flow-chart"),
                    },
                ]
            },
            {
culture/src/views/strain-library/strain-flow-chart/index.vue
New file
@@ -0,0 +1,694 @@
<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>