Ver código fonte

feat(routings): 重构工艺路线展示,优化并行工序节点布局和连接线逻辑

xieyong 1 mês atrás
pai
commit
50339c8acd

+ 310 - 13
src/views/produce/components/routings.vue

@@ -14,19 +14,64 @@
         工艺路线: <span>{{ routeObj.produceRoutingName }}</span></div
       >
       <div>
-        <el-steps :active="activeIndex" space="20px" align-center>
-          <el-step
-            v-for="(item, index) in routeList"
-            :key="index"
-            :title="item.taskTypeName"
-            @click.native="handIdx(index, item)"
+        <div
+          class="route-flow"
+          :style="{ height: stepsRowHeight + 'px' }"
+        >
+          <div
+            v-for="(col, colIndex) in stepColumns"
+            :key="`route-unit-${colIndex}`"
+            class="route-flow-unit"
           >
-            <template v-slot:description>
-              <!-- <div>投:{{  item.feedExistNum || 0  }} 报:{{item.reportExistNum  || 0}}</div> -->
-              <div>{{ desIndex == index ? '此处' : '' }}</div>
-            </template>
-          </el-step>
-        </el-steps>
+            <div v-if="colIndex > 0" class="route-connector">
+              <svg
+                class="route-connector-svg"
+                :viewBox="`0 0 ${connectorViewW} ${stepsRowHeight}`"
+                preserveAspectRatio="none"
+              >
+                <path
+                  v-for="(line, li) in connectorLines(
+                    stepColumns[colIndex - 1],
+                    col
+                  )"
+                  :key="li"
+                  :d="line.d"
+                  class="route-connector-line"
+                />
+              </svg>
+            </div>
+            <div class="route-col">
+              <div
+                v-for="row in col.items"
+                :key="row.flatIndex"
+                class="route-node-row"
+                :class="{ 'is-parallel': row.item.isParallel == 1 }"
+                :style="{ height: stepNodeH + 'px' }"
+              >
+                <div
+                  class="route-node"
+                  :class="{
+                    'is-current': row.flatIndex === desIndex,
+                    'is-error': row.flatIndex === errorIndex,
+                    'is-done': row.flatIndex < activeIndex,
+                    'is-compact': row.item.isParallel == 1
+                  }"
+                  :title="row.item.taskTypeName"
+                  @click="handIdx(row.flatIndex, row.item)"
+                >
+                  <div class="route-node-name">
+                    {{ row.item.taskTypeName
+                    }}<span
+                      v-if="row.flatIndex === desIndex"
+                      class="route-node-tag"
+                      >此处</span
+                    >
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
         <el-tabs type="border-card">
           <el-tab-pane label="领料记录">
             <!-- <feedDetails
@@ -99,12 +144,88 @@
         routeList: [],
         activeIndex: 0,
         desIndex: 0,
+        errorIndex: 10000,
         newId: '',
         curTaskObj: null,
-        sourceTaskId: ''
+        sourceTaskId: '',
+        stepNodeH: 38, // 每个工序节点的行高
+        stepConnectorW: 12, // 列间连接线 SVG 的最小宽度
+        connectorViewW: 56 // 连接线 SVG 的实际宽度(自适应测量)
       };
     },
+    computed: {
+      // 按 isParallel 连续段把工序拆分成"列",并行工序同列垂直堆叠展示
+      stepColumns() {
+        const list = Array.isArray(this.routeList) ? this.routeList : [];
+        const cols = [];
+        list.forEach((item, flatIndex) => {
+          const isParallel =
+            !!item &&
+            (item.isParallel === 1 ||
+              item.isParallel === '1' ||
+              item.isParallel === true);
+          const cur = cols[cols.length - 1];
+          if (isParallel && cur && cur.parallel) {
+            cur.items.push({ item, flatIndex });
+          } else {
+            cols.push({
+              parallel: isParallel,
+              items: [{ item, flatIndex }]
+            });
+          }
+        });
+        return cols;
+      },
+      // 流程图整体高度 = 最大并行列的节点数 * 节点行高
+      stepsRowHeight() {
+        const maxRows = this.stepColumns.reduce(
+          (m, col) => Math.max(m, col.items.length),
+          1
+        );
+        return maxRows * this.stepNodeH;
+      }
+    },
     methods: {
+      // 测量连接线区域的实际宽度(自适应布局下需实测)
+      measureConnectorWidth() {
+        this.$nextTick(() => {
+          const el = this.$el && this.$el.querySelector('.route-connector');
+          if (!el) {
+            return;
+          }
+          const w = Math.max(
+            Math.round(el.getBoundingClientRect().width),
+            this.stepConnectorW
+          );
+          if (w !== this.connectorViewW) {
+            this.connectorViewW = w;
+          }
+        });
+      },
+      // 计算两列之间的连线:左列每个节点与右列每个节点全连接(分叉/合并)
+      connectorLines(leftCol, rightCol) {
+        const total = this.stepsRowHeight;
+        const h = this.stepNodeH;
+        const w = this.connectorViewW;
+        const midX = w / 2;
+        const centers = (n) => {
+          const offset = (total - n * h) / 2;
+          return Array.from({ length: n }, (_, i) => offset + (i + 0.5) * h);
+        };
+        const leftYs = centers(leftCol.items.length);
+        const rightYs = centers(rightCol.items.length);
+        const lines = [];
+        leftYs.forEach((y1) => {
+          rightYs.forEach((y2) => {
+            // 直角折线:水平 → 垂直 → 水平;同行时垂直段退化为直线
+            lines.push({
+              d: `M 0 ${y1} L ${midX} ${y1} L ${midX} ${y2} L ${w} ${y2}`
+            });
+          });
+        });
+        return lines;
+      },
+
       getTaskFn() {
         getTaskInstanceList(this.routeObj.id).then((res) => {
           this.routeList = res;
@@ -131,6 +252,7 @@
 
           this.activeIndex = index;
           this.$refs.pickListRef.getList([this.routeObj.id]);
+          this.measureConnectorWidth();
         });
       },
 
@@ -154,6 +276,17 @@
 
     created() {
       this.getTaskFn();
+    },
+
+    mounted() {
+      this._routeResizeHandler = () => this.measureConnectorWidth();
+      window.addEventListener('resize', this._routeResizeHandler);
+    },
+
+    destroyed() {
+      if (this._routeResizeHandler) {
+        window.removeEventListener('resize', this._routeResizeHandler);
+      }
     }
   };
 </script>
@@ -173,4 +306,168 @@
       font-weight: bold;
     }
   }
+
+  .route-flow {
+    display: flex;
+    align-items: center;
+    min-height: 88px;
+    padding: 12px 0 16px;
+    box-sizing: border-box;
+    overflow-x: auto;
+  }
+
+  .route-flow-unit {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    flex: 1 1 0;
+    min-width: 140px;
+  }
+  .route-flow-unit:nth-of-type(1) {
+    flex: 0 0 auto !important;
+    min-width: 0 !important;
+  }
+  .route-connector {
+    flex: 1 1 0;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    height: 100%;
+  }
+
+  .route-connector-svg {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+
+  .route-connector-line {
+    stroke: #909399;
+    stroke-width: 1.5;
+    fill: none;
+    vector-effect: non-scaling-stroke;
+  }
+
+  .route-col {
+    flex: 0 0 auto;
+    margin: 0 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    height: 100%;
+  }
+
+  .route-node-row {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100px;
+    box-sizing: border-box;
+  }
+
+  .route-node-row.is-parallel {
+    margin-bottom: 2px;
+  }
+
+  .route-node-row.is-parallel:last-child {
+    margin-bottom: 0;
+  }
+
+  .route-node {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    min-width: 0;
+    max-width: none;
+    min-height: 0;
+    padding: 2px 8px;
+    background: #ffffff;
+    border: 2px solid #c0c4cc;
+    border-radius: 2px;
+    cursor: pointer;
+    box-sizing: border-box;
+    overflow: hidden;
+    transition: border-color 0.2s, box-shadow 0.2s;
+
+    &:hover {
+      border-color: #157a2c;
+    }
+  }
+
+  .route-node.is-done {
+    border-color: #1890ff;
+
+    .route-node-name {
+      color: #1890ff;
+    }
+  }
+
+  .route-node.is-current {
+    border-color: #1890ff;
+
+    .route-node-name {
+      color: #1890ff;
+      font-weight: 700;
+    }
+  }
+
+  .route-node.is-error {
+    border-color: #f56c6c;
+
+    .route-node-name {
+      color: #f56c6c;
+    }
+  }
+
+  // 并行工序节点(isParallel == 1):更紧凑
+  .route-node.is-compact {
+    width: 100%;
+    height: 100%;
+    min-width: 0;
+    max-width: none;
+    min-height: 0;
+    padding: 1px 6px;
+    overflow: hidden;
+
+    .route-node-name {
+      font-size: 12px;
+    }
+
+    .route-node-team {
+      font-size: 10px;
+    }
+  }
+
+  .route-node-name {
+    font-size: 12px;
+    font-weight: 500;
+    line-height: 1.3;
+    color: #303133;
+    text-align: center;
+    word-break: break-all;
+  }
+
+  .route-node-team {
+    margin-top: 2px;
+    color: #606266;
+    font-size: 10px;
+    line-height: 1.2;
+    text-align: center;
+    word-break: break-all;
+  }
+
+  .route-node-tag {
+    display: inline-block;
+    margin-left: 4px;
+    padding: 0 6px;
+    font-size: 11px;
+    line-height: 16px;
+    color: #ffffff;
+    background: #1890ff;
+    border-radius: 8px;
+    vertical-align: middle;
+  }
 </style>

+ 312 - 15
src/views/produceOrder/components/details/index.vue

@@ -31,21 +31,64 @@
         <!-- //详细信息 -->
         <Info :workOrderInfo="workOrderInfo"></Info>
 
-        <el-steps
-          :active="activeIndex"
-          space="20px"
-          align-center
-          style="margin-top: 18px"
+        <div
+          class="route-flow"
+          :style="{ height: stepsRowHeight + 'px' }"
         >
-          <el-step
-            v-for="(item, index) in routeList"
-            :key="index"
-            :title="item.taskTypeName"
-            @click.native="handIdx(index, item)"
-            :description="desIndex == index ? '此处' : ''"
-            :class="desIndex == index ? 'active' : ''"
-          ></el-step>
-        </el-steps>
+          <div
+            v-for="(col, colIndex) in stepColumns"
+            :key="`route-unit-${colIndex}`"
+            class="route-flow-unit"
+          >
+            <div v-if="colIndex > 0" class="route-connector">
+              <svg
+                class="route-connector-svg"
+                :viewBox="`0 0 ${connectorViewW} ${stepsRowHeight}`"
+                preserveAspectRatio="none"
+              >
+                <path
+                  v-for="(line, li) in connectorLines(
+                    stepColumns[colIndex - 1],
+                    col
+                  )"
+                  :key="li"
+                  :d="line.d"
+                  class="route-connector-line"
+                />
+              </svg>
+            </div>
+            <div class="route-col">
+              <div
+                v-for="row in col.items"
+                :key="row.flatIndex"
+                class="route-node-row"
+                :class="{ 'is-parallel': row.item.isParallel == 1 }"
+                :style="{ height: stepNodeH + 'px' }"
+              >
+                <div
+                  class="route-node"
+                  :class="{
+                    'is-current': row.flatIndex === desIndex,
+                    'is-error': row.flatIndex === errorIndex,
+                    'is-done': row.flatIndex < activeIndex,
+                    'is-compact': row.item.isParallel == 1
+                  }"
+                  :title="row.item.taskTypeName"
+                  @click="handIdx(row.flatIndex, row.item)"
+                >
+                  <div class="route-node-name">
+                    {{ row.item.taskTypeName
+                    }}<span
+                      v-if="row.flatIndex === desIndex"
+                      class="route-node-tag"
+                      >此处</span
+                    >
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
 
         <el-tabs
           v-model="detailActiveTab"
@@ -146,6 +189,7 @@
         routeList: [],
         activeIndex: 0,
         desIndex: 0,
+        errorIndex: 10000,
         curTaskObj: null,
         newId: '',
         routeObj: {
@@ -154,10 +198,46 @@
         sourceTaskId: '',
         /** 任务数据就绪后递增,强制各 Tab 子组件重挂载以重新拉数 */
         tabsRenderKey: 0,
-        detailActiveTab: 'pick'
+        detailActiveTab: 'pick',
+        stepNodeH: 38, // 每个工序节点的行高
+        stepConnectorW: 12, // 列间连接线 SVG 的最小宽度
+        connectorViewW: 56 // 连接线 SVG 的实际宽度(自适应测量)
       };
     },
 
+    computed: {
+      // 按 isParallel 连续段把工序拆分成"列",并行工序同列垂直堆叠展示
+      stepColumns() {
+        const list = Array.isArray(this.routeList) ? this.routeList : [];
+        const cols = [];
+        list.forEach((item, flatIndex) => {
+          const isParallel =
+            !!item &&
+            (item.isParallel === 1 ||
+              item.isParallel === '1' ||
+              item.isParallel === true);
+          const cur = cols[cols.length - 1];
+          if (isParallel && cur && cur.parallel) {
+            cur.items.push({ item, flatIndex });
+          } else {
+            cols.push({
+              parallel: isParallel,
+              items: [{ item, flatIndex }]
+            });
+          }
+        });
+        return cols;
+      },
+      // 流程图整体高度 = 最大并行列的节点数 * 节点行高
+      stepsRowHeight() {
+        const maxRows = this.stepColumns.reduce(
+          (m, col) => Math.max(m, col.items.length),
+          1
+        );
+        return maxRows * this.stepNodeH;
+      }
+    },
+
     methods: {
       handleClose() {
         this.activeIndex = 0;
@@ -167,6 +247,47 @@
       handleFull() {
         this.isFullscreen = !this.isFullscreen;
         this.$forceUpdate();
+        this.measureConnectorWidth();
+      },
+
+      // 测量连接线区域的实际宽度(自适应布局下需实测)
+      measureConnectorWidth() {
+        this.$nextTick(() => {
+          const el = this.$el && this.$el.querySelector('.route-connector');
+          if (!el) {
+            return;
+          }
+          const w = Math.max(
+            Math.round(el.getBoundingClientRect().width),
+            this.stepConnectorW
+          );
+          if (w !== this.connectorViewW) {
+            this.connectorViewW = w;
+          }
+        });
+      },
+      // 计算两列之间的连线:左列每个节点与右列每个节点全连接(分叉/合并)
+      connectorLines(leftCol, rightCol) {
+        const total = this.stepsRowHeight;
+        const h = this.stepNodeH;
+        const w = this.connectorViewW;
+        const midX = w / 2;
+        const centers = (n) => {
+          const offset = (total - n * h) / 2;
+          return Array.from({ length: n }, (_, i) => offset + (i + 0.5) * h);
+        };
+        const leftYs = centers(leftCol.items.length);
+        const rightYs = centers(rightCol.items.length);
+        const lines = [];
+        leftYs.forEach((y1) => {
+          rightYs.forEach((y2) => {
+            // 直角折线:水平 → 垂直 → 水平;同行时垂直段退化为直线
+            lines.push({
+              d: `M 0 ${y1} L ${midX} ${y1} L ${midX} ${y2} L ${w} ${y2}`
+            });
+          });
+        });
+        return lines;
       },
 
       open(row) {
@@ -214,6 +335,7 @@
           })
           .finally(() => {
             this.tabsRenderKey += 1;
+            this.measureConnectorWidth();
           });
       },
 
@@ -246,6 +368,17 @@
           this.sourceTaskId = this.routeList[this.desIndex]?.sourceTaskId || '';
         }
       }
+    },
+
+    mounted() {
+      this._routeResizeHandler = () => this.measureConnectorWidth();
+      window.addEventListener('resize', this._routeResizeHandler);
+    },
+
+    destroyed() {
+      if (this._routeResizeHandler) {
+        window.removeEventListener('resize', this._routeResizeHandler);
+      }
     }
   };
 </script>
@@ -286,4 +419,168 @@
     border-color: #ffa929;
     color: #ffffff; /* 图标文字颜色 */
   }
+
+  .route-flow {
+    display: flex;
+    align-items: center;
+    min-height: 88px;
+    padding: 12px 0 16px;
+    box-sizing: border-box;
+    overflow-x: auto;
+  }
+
+  .route-flow-unit {
+    display: flex;
+    align-items: center;
+    height: 100%;
+    flex: 1 1 0;
+    min-width: 140px;
+  }
+  .route-flow-unit:nth-of-type(1) {
+    flex: 0 0 auto !important;
+    min-width: 0 !important;
+  }
+  .route-connector {
+    flex: 1 1 0;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    height: 100%;
+  }
+
+  .route-connector-svg {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+
+  .route-connector-line {
+    stroke: #909399;
+    stroke-width: 1.5;
+    fill: none;
+    vector-effect: non-scaling-stroke;
+  }
+
+  .route-col {
+    flex: 0 0 auto;
+    margin: 0 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    height: 100%;
+  }
+
+  .route-node-row {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100px;
+    box-sizing: border-box;
+  }
+
+  .route-node-row.is-parallel {
+    margin-bottom: 2px;
+  }
+
+  .route-node-row.is-parallel:last-child {
+    margin-bottom: 0;
+  }
+
+  .route-node {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    min-width: 0;
+    max-width: none;
+    min-height: 0;
+    padding: 2px 8px;
+    background: #ffffff;
+    border: 2px solid #c0c4cc;
+    border-radius: 2px;
+    cursor: pointer;
+    box-sizing: border-box;
+    overflow: hidden;
+    transition: border-color 0.2s, box-shadow 0.2s;
+
+    &:hover {
+      border-color: #157a2c;
+    }
+  }
+
+  .route-node.is-done {
+    border-color: #1890ff;
+
+    .route-node-name {
+      color: #1890ff;
+    }
+  }
+
+  .route-node.is-current {
+    border-color: #1890ff;
+
+    .route-node-name {
+      color: #1890ff;
+      font-weight: 700;
+    }
+  }
+
+  .route-node.is-error {
+    border-color: #f56c6c;
+
+    .route-node-name {
+      color: #f56c6c;
+    }
+  }
+
+  // 并行工序节点(isParallel == 1):更紧凑
+  .route-node.is-compact {
+    width: 100%;
+    height: 100%;
+    min-width: 0;
+    max-width: none;
+    min-height: 0;
+    padding: 1px 6px;
+    overflow: hidden;
+
+    .route-node-name {
+      font-size: 12px;
+    }
+
+    .route-node-team {
+      font-size: 10px;
+    }
+  }
+
+  .route-node-name {
+    font-size: 12px;
+    font-weight: 500;
+    line-height: 1.3;
+    color: #303133;
+    text-align: center;
+    word-break: break-all;
+  }
+
+  .route-node-team {
+    margin-top: 2px;
+    color: #606266;
+    font-size: 10px;
+    line-height: 1.2;
+    text-align: center;
+    word-break: break-all;
+  }
+
+  .route-node-tag {
+    display: inline-block;
+    margin-left: 4px;
+    padding: 0 6px;
+    font-size: 11px;
+    line-height: 16px;
+    color: #ffffff;
+    background: #1890ff;
+    border-radius: 8px;
+    vertical-align: middle;
+  }
 </style>