Explorar o código

perf: 优化物品明细表大批量回显性能(销售订单)

xieyong hai 5 días
pai
achega
d41d438a56

+ 373 - 121
src/views/purchasingManage/purchaseOrder/components/inventoryTableNew.vue

@@ -1,16 +1,20 @@
 <template>
   <el-form ref="form" :model="form" :rules="rules">
-    <ele-pro-table
-      ref="table"
-      :needPage="false"
-      :columns="columns"
-      @columns-change="handleColumnChange"
-      :cache-key="cacheKeyUrl"
-      max-height="500px"
-      :datasource="form.datasource"
-      class="time-form"
-      :selection.sync="selection"
-    >
+    <div class="inventory-table-wrap">
+      <ele-pro-table
+        ref="table"
+        :needPage="false"
+        :columns="columns"
+        @columns-change="handleColumnChange"
+        :cache-key="cacheKeyUrl"
+        @header-dragend="handleHeaderDragend"
+        row-key="key"
+        max-height="500px"
+        :datasource="form.datasource"
+        class="time-form"
+        :class="{ 'time-form-busy': tableBusy }"
+        :selection.sync="selection"
+      >
       <!-- 表头工具栏 -->
       <template v-slot:toolbar>
         <div class="headbox">
@@ -21,6 +25,7 @@
               icon="el-icon-plus"
               class="ele-btn-icon"
               @click="handParent('', -1)"
+              :disabled="tableBusy"
               v-if="!isContractId"
             >
               新增
@@ -553,7 +558,7 @@
           @confirm="remove(row, $index)"
         >
           <template v-slot:reference>
-            <el-link type="danger" :underline="false" icon="el-icon-delete">
+            <el-link type="danger" :underline="false" icon="el-icon-delete" :disabled="tableBusy">
               删除
             </el-link>
           </template>
@@ -618,6 +623,13 @@
         </el-form-item>
       </template> -->
     </ele-pro-table>
+    <div v-if="tableLoading" class="table-loading-mask">
+      <i class="el-icon-loading"></i>
+      <span class="table-loading-text">
+        {{ batchLoading ? `正在加载 ${loadedCount}/${totalCount}` : '正在加载,请稍候' }}
+      </span>
+    </div>
+    </div>
 
     <product-list
       ref="productListRef"
@@ -682,6 +694,10 @@
       showSaleCount: {
         type: Boolean,
         default: false
+      },
+      appendBatchSize: {
+        type: Number,
+        default: 20
       }
     },
     components: {
@@ -693,53 +709,33 @@
       setAllValue
     },
     computed: {
+      tableBusy() {
+        return this.batchLoading;
+      },
       canHandl() {
         return this.form.datasource.length;
       },
       contractId() {
         return this.$store.state.order.contractId;
-      }
-    },
-
-    data() {
-      const defaultForm = {
-        key: null,
-        endTime: '',
-        isFirst: 0,
-        name: '',
-        startTime: '',
-        workHour: '',
-        technicalDrawings: [],
-        discountRatio: 100,
-        quoteWay: 1
-      };
-      return {
-        selection: [],
-        quoteTypeOp,
-        levelList,
-        pricingWayList,
-        discountTotalPrice: 0.0,
-        allPrice: 0.0,
-        numberReg,
-        positiveIntegerReg,
-        defaultForm,
-        contractStartDate: new Date(),
-        form: {
-          discountTotalPrice: 0,
-          datasource: []
-        },
-        rules: {},
-        dictList: {},
-        billDetailDialogFlag: false,
-        taskinstanceDialogFlag: false,
-        columns: [
+      },
+      columns() {
+        this.widthsVersion;
+        const cacheWidths = this.getStorage(this.cacheKeyUrl + 'Widths') || {};
+        const applyWidth = (col) => {
+          if (col.prop && cacheWidths[col.prop]) {
+            return { ...col, width: cacheWidths[col.prop], minWidth: undefined };
+          }
+          return col;
+        };
+        return [
           {
             label: '选择',
             width: 45,
             type: 'selection',
             columnKey: 'selection',
             align: 'center',
-            fixed: 'left'
+            fixed: 'left',
+            reserveSelection: true
           },
           {
             width: 45,
@@ -1165,7 +1161,46 @@
             fixed: 'right',
             showOverflowTooltip: true
           }
-        ],
+        ].map(applyWidth);
+      }
+    },
+
+    data() {
+      const defaultForm = {
+        key: null,
+        endTime: '',
+        isFirst: 0,
+        name: '',
+        startTime: '',
+        workHour: '',
+        technicalDrawings: [],
+        discountRatio: 100,
+        quoteWay: 1
+      };
+      return {
+        selection: [],
+        quoteTypeOp,
+        levelList,
+        pricingWayList,
+        discountTotalPrice: 0.0,
+        allPrice: 0.0,
+        numberReg,
+        positiveIntegerReg,
+        tableLoading: false,
+        batchLoading: false,
+        loadedCount: 0,
+        totalCount: 0,
+        widthsVersion: 0,
+        defaultForm,
+        contractStartDate: new Date(),
+        form: {
+          discountTotalPrice: 0,
+          datasource: []
+        },
+        rules: {},
+        dictList: {},
+        billDetailDialogFlag: false,
+        taskinstanceDialogFlag: false,
         purchaseSinglePriceData: []
       };
     },
@@ -1185,6 +1220,7 @@
     methods: {
       //批量设置税率
       setAllTaxRateChange({ key, value }) {
+        if (this.tableBusy) return;
         let indexS = this.selection.map((item) => item.key - 1);
         indexS.forEach((i) => {
           this.$set(this.form.datasource[i], key, value);
@@ -1212,6 +1248,11 @@
         if (comitDatasource.length === 0) return [];
         comitDatasource.forEach((v, i) => {
           v.detailType = this.detailType;
+          const purchaseCount = v.purchaseCount;
+          if ((v.totalCount === undefined || v.totalCount === null || v.totalCount === '') &&
+              purchaseCount !== undefined && purchaseCount !== null && purchaseCount !== '') {
+            v.totalCount = purchaseCount;
+          }
           if (v.modelKey) {
             v.modelKey = v.modelKey;
           }
@@ -1250,6 +1291,128 @@
         }
       },
 
+      /**
+       * 重算表格。dirtyIndexes 只指定本次真正变动的行下标,
+       * 其余行直接复用原对象引用,省掉深拷贝与重复观测的成本。
+       * 不传 dirtyIndexes 时退化为全量重算(数量/税率等手工输入场景)。
+       * 采购端没把 countObj 绑成 prop,所以这里用内联字面量。
+       */
+      recalculateTableOnce(dirtyIndexes) {
+        const countObj = {
+          countKey: 'purchaseCount',
+          unitKey: 'purchaseUnit',
+          unitIdKey: 'purchaseUnitId',
+          type: 'purchase'
+        };
+        const dirty = Array.isArray(dirtyIndexes)
+          ? new Set(dirtyIndexes)
+          : null;
+        this.form.datasource = this.form.datasource.map((item, index) => {
+          if (dirty && !dirty.has(index)) {
+            return item;
+          }
+          const row = changeCount(item, countObj, false);
+          if (row.pricingWay == 3) {
+            row.totalPrice = formatPrice(
+              (row.increaseTotalWeight || 0) * row.singlePrice
+            );
+          }
+          row.notaxSinglePrice =
+            row.singlePrice && row.taxRate
+              ? formatPrice(row.singlePrice / (1 + row.taxRate / 100))
+              : '';
+          return row;
+        });
+        this.changeAll();
+      },
+      /**
+       * 大批量回显时先让 loading 遮罩绘制出来,再执行会阻塞主线程的计算。
+       * pro-table 内部在同一个 tick 里把 tableLoading 置 true 又置 false,
+       * 遮罩来不及显示,所以这里必须跨帧控制。
+       */
+      runAfterPaint(task) {
+        this._loadingStart = Date.now();
+        this.tableLoading = true;
+        this.$nextTick(() => {
+          requestAnimationFrame(task);
+        });
+      },
+      // 保证遮罩最短展示时间,避免一闪而过
+      endTableLoading(minDuration = 200) {
+        const start = this._loadingStart || Date.now();
+        const rest = minDuration - (Date.now() - start);
+        if (rest > 0) {
+          setTimeout(() => {
+            this.tableLoading = false;
+          }, rest);
+        } else {
+          this.tableLoading = false;
+        }
+      },
+      /**
+       * 分批追加:一次选中上百个物品时,一次性塞进表格会让主线程长时间阻塞,
+       * 这里按 appendBatchSize 分批写入并重算,每批之间让出一帧,
+       * 浏览器可以正常绘制 loading 遮罩和已经渲染出来的行。
+       */
+      appendRowsInBatches(
+        rows,
+        startIndex = this.form.datasource.length,
+        onDone
+      ) {
+        if (!rows.length) {
+          if (onDone) onDone();
+          this.endTableLoading();
+          return;
+        }
+        const batchSize = Math.max(1, this.appendBatchSize || 20);
+        const total = rows.length;
+        this.batchLoading = true;
+        this.loadedCount = 0;
+        this.totalCount = total;
+        let done = 0;
+
+        const step = () => {
+          const batch = rows.slice(done, done + batchSize);
+          if (batch.length) {
+            const from = startIndex + done;
+            this.form.datasource = this.form.datasource.concat(batch);
+            // 只重算本批新增的行,其余行复用原对象
+            this.recalculateTableOnce(batch.map((row, i) => from + i));
+            done += batch.length;
+            this.loadedCount = done;
+          }
+          if (done < total) {
+            requestAnimationFrame(step);
+            return;
+          }
+          // 全部批次都写完并渲染完,才收尾关闭 loading
+          this.batchLoading = false;
+          if (onDone) onDone();
+          this.endTableLoading();
+        };
+
+        requestAnimationFrame(step);
+      },
+      afterReplaceTable() {
+        // 计量数量为后端非空字段:只补齐留空的行,避免覆盖已按计量单位换算好的数据
+        this.form.datasource.forEach((item) => {
+          const count = item.purchaseCount;
+          if (
+            (item.totalCount === undefined ||
+              item.totalCount === null ||
+              item.totalCount === '') &&
+            count !== undefined &&
+            count !== null &&
+            count !== ''
+          ) {
+            this.$set(item, 'totalCount', count);
+          }
+        });
+        if (this.showSaleCount) {
+          this.changeCount();
+        }
+      },
+
       //改变数量
       changeCount(row, index, weightType) {
         // if (this.detailType) {
@@ -1505,6 +1668,7 @@
       },
       //选择产品
       handParent(row, index) {
+        if (this.tableBusy) return;
         let item = {
           id: row.productCode
         };
@@ -1538,90 +1702,121 @@
         });
       },
       //选择产品回调
-      async changeParent(obj = [], idx) {
-        console.log('选择产品回调', obj);
-        const promises = obj.map(async (item, index) => {
-          let i = idx == -1 ? index : idx;
-          let row = JSON.parse(JSON.stringify(this.defaultForm));
-          row.key = this.form.datasource.length + 1;
-          let parasm = idx == -1 ? row : this.form.datasource[i];
-          this.$set(parasm, 'productId', item.id);
-          this.$set(parasm, 'categoryName', item.name);
-          this.$set(parasm, 'productCategoryId', item.categoryLevelId);
-          this.$set(parasm, 'productBrand', item.brandNum);
-          this.$set(parasm, 'productCategoryName', item.categoryLevelPath);
-          this.$set(parasm, 'productCode', item.code);
-          this.$set(parasm, 'productName', item.name);
-          this.$set(parasm, 'modelType', item.modelType);
-          this.$set(parasm, 'availableCountBase', item.availableCountBase);
-          this.$set(parasm, 'measuringUnit', item.measuringUnit);
-          this.$set(parasm, 'specification', item.specification);
-          this.$set(parasm, 'weightUnit', item.weightUnit);
-          this.$set(parasm, 'imgCode', item.imgCode);
-          this.$set(parasm, 'produceType', item.componentAttribute);
-          this.$set(parasm, 'approvalNumber', item.extField?.approvalNumber);
-          this.$set(parasm, 'singleWeight', item.netWeight);
-          this.$set(parasm, 'pricingWay', item.pricingWay || 1);
-          this.$set(parasm, 'goodsLevel', item.level);
-          this.$set(parasm, 'taxRate', 13);
-          this.$set(parasm, 'totalCount', 0);
-          if(this.showSaleCount) {
-            this.$set(parasm, 'purchaseCount', item.saleCount);
-          }
-          if (item.modelKey) {
-            this.$set(parasm, 'modelKey', item.modelKey?.split(',')?.[0] || '');
-          }
-          if (item.colorKey) {
-            this.$set(parasm, 'colorKey', item.colorKey?.split(',')?.[0] || '');
-          }
+      /**
+       * 构造选择产品回显的一行。
+       * 不传 oldRow 时返回普通对象(尚未进入响应式数组,赋值零成本)。
+       */
+      buildProductRow(item, oldRow, key) {
+        let parasm = oldRow || JSON.parse(JSON.stringify(this.defaultForm));
+        parasm.key = key;
+        this.$set(parasm, 'productId', item.id);
+        this.$set(parasm, 'categoryName', item.name);
+        this.$set(parasm, 'productCategoryId', item.categoryLevelId);
+        this.$set(parasm, 'productBrand', item.brandNum);
+        this.$set(parasm, 'productCategoryName', item.categoryLevelPath);
+        this.$set(parasm, 'productCode', item.code);
+        this.$set(parasm, 'productName', item.name);
+        this.$set(parasm, 'modelType', item.modelType);
+        this.$set(parasm, 'availableCountBase', item.availableCountBase);
+        this.$set(parasm, 'measuringUnit', item.measuringUnit);
+        this.$set(parasm, 'specification', item.specification);
+        this.$set(parasm, 'weightUnit', item.weightUnit);
+        this.$set(parasm, 'imgCode', item.imgCode);
+        this.$set(parasm, 'produceType', item.componentAttribute);
+        this.$set(parasm, 'approvalNumber', item.extField?.approvalNumber);
+        this.$set(parasm, 'singleWeight', item.netWeight);
+        this.$set(parasm, 'pricingWay', item.pricingWay || 1);
+        this.$set(parasm, 'goodsLevel', item.level);
+        this.$set(parasm, 'taxRate', 13);
+        this.$set(parasm, 'totalCount', 0);
+        if (this.showSaleCount) {
+          this.$set(parasm, 'purchaseCount', item.saleCount);
+        }
+        if (item.modelKey) {
+          this.$set(parasm, 'modelKey', item.modelKey?.split(',')?.[0] || '');
+        }
+        if (item.colorKey) {
+          this.$set(parasm, 'colorKey', item.colorKey?.split(',')?.[0] || '');
+        }
 
-          this.$set(parasm, 'discountRatio', parasm.discountRatio || 100);
+        this.$set(parasm, 'discountRatio', parasm.discountRatio || 100);
 
-          this.$set(parasm, 'quoteWay', parasm.quoteWay || 100);
+        this.$set(parasm, 'quoteWay', parasm.quoteWay || 100);
 
+        this.$set(
+          parasm,
+          'packingSpecification',
+          item.extField?.packingSpecification
+        );
+        this.$set(
+          parasm,
+          'packageDispositionList',
+          item.packageDispositionList
+        );
+        if (item.packageDispositionList?.length) {
           this.$set(
             parasm,
-            'packingSpecification',
-            item.extField?.packingSpecification
+            'purchaseUnitId',
+            item.packageDispositionList[0].id
           );
           this.$set(
             parasm,
-            'packageDispositionList',
-            item.packageDispositionList
+            'purchaseUnit',
+            item.packageDispositionList[0].conversionUnit
           );
-          if (item.packageDispositionList?.length) {
-            this.$set(
-              parasm,
-              'purchaseUnitId',
-              item.packageDispositionList[0].id
-            );
-            this.$set(
-              parasm,
-              'purchaseUnit',
-              item.packageDispositionList[0].conversionUnit
-            );
-          }
-          this.$set(parasm, 'supplierMark', this.supplierMark);
-          // this.$set(parasm, 'batchNo', await getCode('1900101420390125570'));
-          if (item.purchaseOrigins?.length > 0) {
-            item.purchaseOrigins = item.purchaseOrigins.map((val) => val + '');
-          }
-          this.$set(parasm, 'provenance', item.purchaseOrigins || []);
-          if (idx == -1) {
-            this.form.datasource.push(row);
-          }
-        });
+        }
+        this.$set(parasm, 'supplierMark', this.supplierMark);
+        if (item.purchaseOrigins?.length > 0) {
+          item.purchaseOrigins = item.purchaseOrigins.map((val) => val + '');
+        }
+        this.$set(parasm, 'provenance', item.purchaseOrigins || []);
 
-        // 等待所有异步操作完成
-        await Promise.all(promises);
-        
-        // 只有当idx == -1时才emit数据给父组件
+        return parasm;
+      },
+      updateProductRow(item, idx) {
+        this.$set(
+          this.form.datasource,
+          idx,
+          this.buildProductRow(
+            item,
+            this.form.datasource[idx],
+            this.form.datasource[idx]?.key || idx + 1
+          )
+        );
+        this.recalculateTableOnce([idx]);
+      },
+      //选择产品回调
+      changeParent(obj = [], idx) {
         if (idx == -1) {
-          this.$emit('getProductData', this.form.datasource);
-          this.changeCount();
+          // 新增:先构造好所有行,再分批追加并渲染
+          const startIndex = this.form.datasource.length;
+          const maxKey = this.form.datasource.reduce(
+            (max, item) => Math.max(max, Number(item.key) || 0),
+            0
+          );
+          const rows = obj.map((item, index) =>
+            this.buildProductRow(item, null, maxKey + index + 1)
+          );
+          this.runAfterPaint(() => {
+            this.appendRowsInBatches(rows, startIndex, () => {
+              this.$emit('getProductData', this.form.datasource);
+              this.changeCount();
+            });
+          });
+          return;
         }
+        // 编辑已有行:单行替换
+        this.runAfterPaint(() => {
+          try {
+            this.updateProductRow(obj[0], idx);
+            this.afterReplaceTable();
+          } finally {
+            this.endTableLoading();
+          }
+        });
       },
       remove(row, index) {
+        if (this.tableBusy) return;
         this.form.datasource.splice(index, 1);
         // let index = this.form.datasource.findIndex((n) => n.key == row.key);
         // if (index !== -1) {
@@ -1642,6 +1837,7 @@
       },
       // 添加
       handlAdd() {
+        if (this.tableBusy) return;
         let item = JSON.parse(JSON.stringify(this.defaultForm));
         item.key = this.form.datasource.length + 1;
         this.form.datasource.push(item);
@@ -1683,6 +1879,7 @@
       },
 
       validateForm(callback) {
+        // 保持 purchase 既有:计价方式=重量且单重为空时的预校验
         let singleWeightData = {};
         this.form.datasource.forEach((item) => {
           if (item.pricingWay == 2 && !item.singleWeight) {
@@ -1694,11 +1891,25 @@
             singleWeightData.name + '计价方式为重量,单重不能为空'
           );
           callback(false);
+          return;
         }
-        //开始表单校验
-        this.$refs.form.validate((valid) => {
+        // 开始表单校验,并把第一个错误信息提示给用户
+        this.$refs.form.validate((valid, obj) => {
+          if (obj) {
+            let messages = Object.keys(obj).map((key) => obj[key][0]);
+            if (messages.length > 0) {
+              this.$message.warning(messages[0].message);
+            }
+          }
           callback(valid);
         });
+      },
+      // 列头拖拽结束 - 保存列宽到本地缓存
+      handleHeaderDragend(newWidth, oldWidth, column) {
+        if (!column.property) return;
+        const widths = this.getStorage(this.cacheKeyUrl + 'Widths') || {};
+        widths[column.property] = newWidth;
+        this.setStorage(this.cacheKeyUrl + 'Widths', widths);
       }
     }
   };
@@ -1752,4 +1963,45 @@
     align-items: center;
     font-weight: bold;
   }
+
+  .inventory-table-wrap {
+    position: relative;
+  }
+
+  .table-loading-mask {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 10;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background-color: rgba(255, 255, 255, 0.75);
+  }
+
+  .table-loading-mask .el-icon-loading {
+    font-size: 30px;
+    color: #409eff;
+  }
+
+  .table-loading-text {
+    margin-top: 10px;
+    font-size: 13px;
+    color: #909399;
+  }
+
+  /* 批量追加期间禁止单元格交互:只关掉 .cell 的指针事件,
+     滚轮事件仍会落在滚动容器上,所以表格照常可以滚动 */
+  ::v-deep .time-form-busy .el-table__body-wrapper .cell,
+  ::v-deep .time-form-busy .el-table__fixed-body-wrapper .cell {
+    pointer-events: none;
+  }
+
+  ::v-deep .time-form-busy .el-table__body-wrapper,
+  ::v-deep .time-form-busy .el-table__fixed-body-wrapper {
+    cursor: progress;
+  }
 </style>