vue3+elementplus实现大数据表格加载,并且实现勾选、合并单元格功能

1,088 阅读4分钟

前言

在做大数据系统应用时,经常需要实现大数据表格渲染、表格勾选、全选、合并单元格显示等功能,这里利用elementplus框架 Virtualized Table 虚拟化表格 组件来实现这些功能,并记录在实现中遇到的问题与解决方法。

elementplus文档地址:element-plus.org/zh-CN/compo…

image.png

实现效果如下: image.png

问题与解决办法

1.设置固定行高:设置el-table-v2组件的row-height属性
2.设置动态合并行:添加el-table-v2组件的#row插槽,然后设置动态cells,原理就是设置单元格高度覆盖要合并的单元格,设置zIndex显示在最上层

<el-table-v2
  class="pay-table"
  fixed
  :columns="returnColumns"
  :data="returnData"
  :width="1334"
  :height="500"
  :row-height="tableHeight"
  :cache="50"
>
  <template #row="props">
    <ReturnRow v-bind="props" />
  </template>
</el-table-v2>

const ReturnRow = ({ rowData, cells, columns }: { rowData: ReturnTableData; rowIndex: number; cells: any; columns: any }) => {
  const rowSpan = rowData.rowspan // 这里提前将要合并的单元格行数赋值给行数据
  const colNums = [0, 5, 7, 8] // 要合并单元格的列
  for (let i = 0; i < columns.length; ++i) {
    if (rowSpan > 1 && colNums.includes(i)) {
      Object.assign(cells[i].props.style, {
        backgroundColor: 'var(--el-color-white)',
        height: `${rowSpan * tableHeight - 1}px`,
        alignSelf: 'flex-start',
        zIndex: 9
      })
    }
  }
  return cells
}

3.设置表格加载loading:添加el-table-v2组件的#overlay插槽

<el-table-v2
  class="pay-table"
  fixed
  :columns="returnColumns"
  :data="returnData"
  :width="1334"
  :height="500"
  :row-height="tableHeight"
  :cache="50"
>
  <template #overlay v-if="refundLoading">
    <div class="el-loading-mask flex items-center justify-center">
      <el-icon class="is-loading" color="var(--el-color-primary)" :size="26">
        <loading-icon />
      </el-icon>
    </div>
  </template>
</el-table-v2>

4.当合并的单元格行数太多时就会出现渲染没有合并问题,因为表格行数据是按需加载的,可以设置表格组件的cache属性来设置最大加载的行数据。但是cache设置越大,表格加载性能越差。
5.当需要一个合并总单元格(所有行),就会出现问题4情况,这时候可以采用position:absolute添加一个浮在上层显示总数据的div展示总合并行。

具体代码参考

<template>
  <div class="wrapper">
    <div class="return">
      <div class="title">'选择退货商品'</div>
      <div class="return-table">
        <!-- <div class="total" v-if="returnData.length > 10">
            {{ amountSeparationHasPrefix(getRefundTotalMoney()) }}
          </div> -->
        <el-table-v2
          class="pay-table"
          fixed
          :columns="returnColumns"
          :data="returnData"
          :width="1334"
          :height="500"
          :row-height="tableHeight"
          :cache="50"
        >
          <template #row="props">
            <ReturnRow v-bind="props" />
          </template>
          <template #overlay v-if="refundLoading">
            <div class="el-loading-mask flex items-center justify-center">
              <el-icon class="is-loading" color="var(--el-color-primary)" :size="26">
                <loading-icon />
              </el-icon>
            </div>
          </template>
        </el-table-v2>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
export default {
  name: 'batch-after-sale-drawer'
}
</script>

<script lang="ts" setup>
import { ref, onMounted, h } from 'vue'
import { ElMessage, ElCheckbox, CheckboxValueType, ElInputNumber, type Column } from 'element-plus'
import { orderListApi } from '~/api/member-center/order-list'
import type { ReturnTableData } from '~/types/order-list'
import NP from 'number-precision'
import { showHttpErrorMsg } from '~/utils/http-error-msg'
import ProductInfo from '~/components/product-info.vue'
import { amountSeparationHasPrefix } from '~/utils/amount-format'
import OverflowEllipsis from '~/components/overflow-ellipsis.vue'
import { Loading as LoadingIcon } from '@element-plus/icons-vue'

const refundLoading = ref(false)

const getRefundAmount = async () => {
  try {
    refundLoading.value = true
    const params = {
      hjsIds: [1, 2, 3],
      aftersaleType: 1,
      applyReason: 1
    }
    const { code, msg, data } = await orderListApi.batchAvailableAmount(params)
    if (code === 200) {
      const result = []
      for (let i = 0; i < data.orderList.length; i++) {
        const parent = data.orderList[i]
        for (let j = 0; j < parent.detailList.length; j++) {
          const obj = parent.detailList[j]
          result.push(
            Object.assign(obj, {
              checked: false,
              rowspan: j === 0 ? parent.detailList.length : 1,
              refundQty: obj.qty,
              parent_additionalFreight: j === 0 ? parent.additionalFreight : 0, // parent.additionalFreight,
              parent_availableAmount: parent.availableAmount,
              parent_availableFreight: j === 0 ? parent.availableFreight : 0, // parent.availableFreight,
              parent_goodsDeprecitionAmount: parent.goodsDeprecitionAmount,
              parent_goodsUnpackAmount: j === 0 ? parent.goodsUnpackAmount : 0, // parent.goodsUnpackAmount,
              together_goodsUnpackAmount: parent.goodsUnpackAmount,
              together_additionalFreight: parent.additionalFreight,
              together_availableFreight: parent.availableFreight,
              parent_hjsOrderId: parent.hjsOrderId,
              parent_orderId: parent.orderId,
              parent_hjsId: parent.hjsId
            })
          )
        }
      }
      returnData.value = result as ReturnTableData[]
    } else {
      ElMessage.error(msg)
    }
  } catch (error) {
    showHttpErrorMsg(error)
  } finally {
    refundLoading.value = false
  }
}

const tableHeight = 63

const returnColumns: Column<any>[] = [
  {
    key: 'parentOrderId',
    dataKey: 'parentOrderId',
    title: '商城订单号',
    width: 160,
    cellRenderer: ({ rowData, rowIndex }: any) => {
      const _data = returnData.value
      const parentOrderId = _data[rowIndex].parent_orderId
      const onChange = (value: CheckboxValueType) => {
        returnData.value = _data.map((row) => {
          if (parentOrderId === row.parent_orderId) {
            row.checked = value
          }
          return row
        })
      }
      const filterData = _data.filter((v) => v.parent_orderId === parentOrderId)
      const allSelected = filterData.every((row) => row.checked)
      const containsChecked = filterData.some((row) => row.checked)
      const _check = h(ElCheckbox, {
        style: { marginRight: '8px' },
        onChange,
        modelValue: allSelected,
        indeterminate: containsChecked && !allSelected
      })
      const _info = h('div', { style: { fontSize: '12px' } }, h(OverflowEllipsis, { title: rowData.parent_orderId, line: 2 }))
      return h('div', { class: 'flex', style: { height: '100%', alignItems: 'center' } }, [_check, _info])
    },
    headerCellRenderer: () => {
      const _data = returnData.value
      const onChange = (value: CheckboxValueType) => {
        returnData.value = _data.map((row) => {
          row.checked = value
          return row
        })
      }
      const allSelected = _data.every((row) => row.checked)
      const containsChecked = _data.some((row) => row.checked)
      return h(
        ElCheckbox,
        {
          onChange,
          modelValue: allSelected,
          indeterminate: containsChecked && !allSelected
        },
        '商城订单号'
      )
    }
  },
  {
    key: 'orderId',
    dataKey: 'orderId',
    title: '商品信息',
    width: 280,
    cellRenderer: ({ rowData }: any) => {
      const { goodsName, goodsImg, goodsCode } = rowData
      const _info = h(ProductInfo, {
        name: goodsName,
        imgStyle: { width: '40px', height: '40px' },
        img: goodsImg,
        specs: `商品编码:${goodsCode}`
      })
      const onChange = (value: CheckboxValueType) => {
        rowData.checked = value
      }
      const _check = h(ElCheckbox, {
        style: { marginRight: '8px' },
        onChange,
        modelValue: rowData.checked
      })
      return h('div', { class: 'flex', style: { position: 'relative', zIndex: 9 } }, [_check, _info])
    }
  },
  {
    key: 'specName',
    dataKey: 'specName',
    title: '规格',
    width: 140,
    cellRenderer: ({ rowData }: any) => {
      return h('div', { style: { fontSize: '12px' } }, h(OverflowEllipsis, { title: rowData.specName, line: 2 }))
    }
  },
  {
    key: 'refundQty',
    dataKey: 'refundQty',
    title: '数量',
    width: 154,
    cellRenderer: ({ rowData }: any) => {
      const onChange = (value: number) => {
        rowData.refundQty = value
      }
      // @ts-ignore
      const _input = h(ElInputNumber, {
        style: { marginRight: '8px', marginBottom: '4px', width: '138px' },
        onChange,
        min: 1,
        step: 1,
        max: rowData.qty,
        modelValue: rowData.refundQty
      })
      const _info = h('div', { class: 'flex' }, '最多可退数量:' + rowData.qty || 0)
      return h('div', { style: { color: '#fba218', fontSize: '12px' } }, [_input, _info])
    }
  },
  {
    key: 'availableAmount',
    dataKey: 'availableAmount',
    title: '可退商品金额(已扣破损费)',
    width: 110,
    cellRenderer: ({ rowData }: any) => {
      let total: string | number = ''
      if (String(rowData.refundQty).includes('***') || String(rowData.availableAmount).includes('***')) {
        total = '***'
      } else {
        total = NP.times(rowData.refundQty || 0, rowData.availableAmount)
      }
      return h('div', [h('div', { style: { fontSize: '12px', marginBottom: '4px' } }, amountSeparationHasPrefix(total))])
    },
    headerCellRenderer: () => {
      return h('div', [h('div', '可退商品金额'), h('div', '(已扣破损费)')])
    }
  },
  {
    key: 'parent_availableFreight',
    dataKey: 'parent_availableFreight',
    title: '可退运费',
    width: 120,
    cellRenderer: ({ rowData }: any) => {
      let total: string | number = ''
      if (String(rowData.parent_availableFreight).includes('***') || String(rowData.parent_additionalFreight).includes('***')) {
        total = '***'
      } else {
        total = NP.plus(rowData.parent_availableFreight || 0, rowData.parent_additionalFreight || 0)
      }
      let text = ''
      if (rowData.parent_additionalFreight && rowData.parent_additionalFreight >= 0) {
        text = '含补扣运费' + amountSeparationHasPrefix(rowData.parent_additionalFreight)
      } else if (rowData.parent_additionalFreight && rowData.parent_additionalFreight < 0) {
        text = '已扣减退还运费' + amountSeparationHasPrefix(rowData.parent_additionalFreight)
      }
      return h('div', [
        h('div', { style: { fontSize: '12px' } }, amountSeparationHasPrefix(total)),
        h('div', { style: { marginTop: '4px', fontSize: '12px', color: '#5d6064' } }, text)
      ])
    }
  },
  {
    key: 'parent_amount',
    dataKey: 'parent_amount',
    title: '扣除破损费(已扣)',
    width: 130,
    cellRenderer: ({ rowData }: any) => {
      let total: string | number = ''
      if (String(rowData.refundQty).includes('***') || String(rowData.goodsDeprecitionAmount).includes('***')) {
        total = '***'
      } else {
        total = NP.times(rowData.refundQty || 0, rowData.goodsDeprecitionAmount || 0)
      }
      return h('div', { style: { fontSize: '12px' } }, amountSeparationHasPrefix(total))
    }
  },
  {
    key: 'parent_goodsUnpackAmount',
    dataKey: 'parent_goodsUnpackAmount',
    title: '扣除拆包费',
    width: 120,
    cellRenderer: ({ rowData }: any) => {
      return h('div', { style: { fontSize: '12px' } }, amountSeparationHasPrefix(rowData.parent_goodsUnpackAmount))
    }
  },
  {
    key: 'totalMoney',
    dataKey: 'totalMoney',
    title: '可退总金额',
    width: 120,
    cellRenderer: ({ rowData }: any) => {
      const total = getOrderTotalMoney(rowData.parent_hjsOrderId)
      return h('div', { class: 'hn-bold-font', style: { fontSize: '12px' } }, total)
    }
  }
]

const returnData = ref<ReturnTableData[]>([])

const ReturnRow = ({ rowData, cells, columns }: { rowData: ReturnTableData; rowIndex: number; cells: any; columns: any }) => {
  const rowSpan = rowData.rowspan
  for (let i = 0; i < columns.length; ++i) {
    if (rowSpan > 1 && [0, 5, 7, 8].includes(i)) {
      Object.assign(cells[i].props.style, {
        backgroundColor: 'var(--el-color-white)',
        height: `${rowSpan * tableHeight - 1}px`,
        alignSelf: 'flex-start',
        zIndex: 9
      })
    }
  }
  return cells
}

// 可退总金额
const getOrderTotalMoney = (hjsOrderId: string) => {
  const data = returnData.value.filter((v) => v.parent_hjsOrderId === hjsOrderId && v.checked)
  let currentHjsOrderId = ''
  let total = 0
  let hidden = false
  for (let i = 0; i < data.length; i++) {
    let availableFreight: string | number = 0
    let additionalFreight: string | number = 0
    let goodsUnpackAmount: string | number = 0
    if (currentHjsOrderId !== data[i].parent_hjsOrderId) {
      currentHjsOrderId = data[i].parent_hjsOrderId
      availableFreight = data[i].together_availableFreight || 0
      additionalFreight = data[i].together_additionalFreight || 0
      goodsUnpackAmount = data[i].together_goodsUnpackAmount || 0
    }
    if ([String(data[i].refundQty), String(data[i].availableAmount), availableFreight, additionalFreight, goodsUnpackAmount].includes('***')) {
      hidden = true
      break
    }
    const refundAmount = NP.times(Number(data[i].refundQty), data[i].availableAmount ?? 0)
    total = NP.plus(total, refundAmount || 0)
    // 运费 = 可退运费 + 补扣运费 - 拆包费
    total = NP.plus(availableFreight, additionalFreight, total)
    total = NP.minus(total, goodsUnpackAmount)
  }
  if (hidden) return '¥***'
  if (total < 0) total = 0
  return amountSeparationHasPrefix(total)
}

onMounted(() => {
  getRefundAmount()
})
</script>

<style lang="scss" scoped>
.wrapper {
  :deep(.table-box) {
    padding: 0 0 18px;
    .table-title {
      @include boldFont;
      margin-bottom: 9px;
      font-size: 14px;
      color: #2e3135;
      line-height: 20px;
    }
    .el-table__cell .cell {
      &:empty::before {
        content: '--';
      }
    }
    .el-table__empty-text {
      padding: 0 !important;
    }
  }
}
:deep(.return) {
  & > .title {
    @include boldFont;
    height: 20px;
    margin: 6px 0 8px;
    font-size: 14px;
    color: #2e3135;
    line-height: 20px;
  }
  .return-table {
    position: relative;
    .total {
      @include boldFont;
      position: absolute;
      top: 51px;
      right: 1px;
      width: 118px;
      height: 449px;
      z-index: 9;
      display: flex;
      justify-content: center;
      align-items: center;
      background: #fff;
      color: #2e3135;
      font-size: 14px;
      color: #323232;
      line-height: 20px;
    }
  }
  .el-table__empty-text {
    padding: 0 !important;
  }
  .max-qty {
    height: 16px;
    margin-top: 4px;
    font-size: 12px;
    color: #fba218;
    line-height: 16px;
  }
  .refund-money-ipt {
    position: relative;
    .el-input__icon::before {
      content: '¥';
      width: 40px;
      font-size: 13px;
      color: #2e3135;
      font-style: normal;
    }
    .el-input__icon {
      margin-right: 0;
    }
  }
}
</style>