如何实现好看的移动端表格

3,748 阅读7分钟

这两天在公司遇到了一个需求,需要在移动端展示报表。我之前在网上简单调研过,移动端的表格没找到几个好用的,ag-grid还算不错。但是我们往往都是需要根据ui的设计来定制好看的移动端报表,又要支持横向、竖向的滚动等等功能,所以只能自己去封装实现。

1、需要实现的功能和想法

功能:表格行/列合并、固定头和列、样式自定义等等,想法:基于better-scroll、单元格宽度计算设置

2、技术栈

vue3+typescript+vant

3、table组件

<template>
  <div :style="{ height: `${height}px` }">
    <template v-if="list && list.length">
      <header class="table-header">
        <section class="table-header-fixed">
          <div
            v-for="column in fixedColumns"
            :key="column.key"
            :id="column.key"
            class="normal-text normal-padding table-header-unit">{{ column.title }}</div>
        </section>
        <Scroll
          ref="headerScroll"
          :scrollX="true"
          :scrollY="false"
          :bounce="false"
          :probeType="3"
          :useTransition="false"
          class="table-header-flow"
          @scroll="handleScroll($event, 'headerScroll')">
          <section class="table-header-flow-content">
            <div
              v-for="column in flowColumns"
              :key="column.key"
              :id="column.key"
              class="normal-text normal-padding table-header-unit">{{ column.title }}</div>
          </section>
        </Scroll>
      </header>
      <Scroll class="table-article">
        <section class="table-article-content">
          <section id="table-article-fixed" class="table-article-fixed">
            <div
              v-for="(item, index) in list"
              :key="`fixed${index}`"
              class="normal-text table-article-fixed-list"
              :class="index === list.length - 1 ? 'table-article-fixed-list-last' : ''">
              <div
                v-for="column in fixedColumns"
                :key="column.key"
                :class="column.key"
                class="normal-padding table-article-unit">
                <template v-if="column.scopedSlots">
                  <slot :name="column.scopedSlots.customRender" :value="item[column.key]" :item="item">
                    {{ item[column.key] === null ? '-' : item[column.key] }}
                  </slot>
                </template>
                <span v-else>{{ item[column.key] === null ? '-' : item[column.key] }}</span>
              </div>
            </div>
            <!-- 这里合并单元格,我想到的方法是指定覆盖 -->
            <template v-if="colSpans && colSpans.length">
              <div
                v-for="item in colSpans"
                :key="item.id"
                :data-row-index="item.rowIndex"
                :data-col-index="item.colIndex"
                :data-content-index="item.contentIndex"
                :data-spans="item.spans"
                :data-direction="item.direction"
                class="normal-text col-row-span"
                :style="{ backgroundColor: item.bgColor ? item.bgColor : '', color: item.color ? item.color : '' }"></div>
            </template>
          </section>
          <Scroll
            ref="tableScroll"
            :scrollX="true"
            :scrollY="false"
            :bounce="false"
            :probeType="3"
            :useTransition="false"
            class="table-article-flow"
            @scroll="handleScroll($event, 'tableScroll')">
            <section class="table-article-flow-content">
              <div
                v-for="(item, index) in list"
                :key="`flow${index}`"
                class="normal-text table-article-flow-list">
                <div
                  v-for="column in flowColumns"
                  :key="column.key"
                  :class="column.key"
                  class="normal-padding table-article-unit">
                    <template v-if="column.scopedSlots">
                      <slot :name="column.scopedSlots.customRender" :value="item[column.key]" :item="item">
                        {{ item[column.key] === null ? '-' : item[column.key] }}
                      </slot>
                    </template>
                    <span v-else>{{ item[column.key] === null ? '-' : item[column.key] }}</span>
                  </div>
              </div>
            </section>
          </Scroll>
        </section>
      </Scroll>
    </template>
    <div v-else class="no-data-wrap">
      <img :src="require('@/assets/images/icon_no_data.png')" alt="暂无数据">
      <span>暂无数据~</span>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType, ref, computed, onUpdated, onMounted } from 'vue'
import { useWindowSize } from '@vant/use'
import Scroll from '@/components/Scroll.vue'
import { ColumnProps, ListProps, SpanProps } from '@/props'
import useTableWidth from '@/hooks/useTableWidth'
import useTableColSpan from '@/hooks/useTableSpan'
import { Pos } from '@/hooks/useScroll'

export default defineComponent({
  name: 'Table',
  components: {
    Scroll
  },
  props: {
    topHeight: {
      type: Number,
      required: true
    },
    columns: {
      type: Array as PropType<ColumnProps[]>,
      required: true
    },
    list: {
      type: Array as PropType<ListProps[] | null>,
      required: true
    },
    colSpans: {
      type: Array as PropType<SpanProps[] | null>,
      required: false
    },
    isClone: {
      type: Boolean,
      default: false
    }
  },
  setup(props) {
    const windowHeight = useWindowSize().height
    const height = ref(windowHeight.value - props.topHeight)
    const fixedColumns = computed(() => {
      return props.columns.filter(v => v.fixed)
    })
    const flowColumns = computed(() => {
      return props.columns.filter(v => !v.fixed)
    })

    onMounted(() => {
      useTableWidth(props.columns)
      useTableColSpan(true, props.isClone, props.colSpans)
    })
    onUpdated(() => {
      useTableWidth(props.columns)
      useTableColSpan(true, props.isClone, props.colSpans)
    })

    const headerScroll = ref()
    const tableScroll = ref()
    const handleScroll = (pos: Pos, key: 'headerScroll' | 'tableScroll') => {
      if (key === 'headerScroll') {
        tableScroll.value?.scroll.scrollTo(pos.x, 0, 0, undefined)
      } else {
        headerScroll.value?.scroll.scrollTo(pos.x, 0, 0, undefined)
      }
    }

    return {
      height,
      fixedColumns,
      flowColumns,
      headerScroll,
      tableScroll,
      handleScroll
    }
  }
})
</script>

<style lang="stylus" scoped>
.col-row-span
  position: absolute
  background-color: #fff
  overflow: hidden
  display: flex
  align-items: center
  justify-content: center
  z-index 100
.normal-text
  color: #555555
  font-size: 12px
.normal-padding
  padding: 0 6px
.table-header
  height: 39px
  display: flex
  align-items: center
  &-fixed
    display: flex
    align-items: center
    height: 100%
    background-color: #F4F8FD
    position relative
    &::after
      position: absolute;
      box-sizing: border-box;
      content: ' ';
      pointer-events: none;
      top: -50%;
      right: -50%;
      bottom: -50%;
      left: -50%;
      border: 0 solid #EDEDED;
      -webkit-transform: scale(.5);
      transform: scale(.5);
      border-bottom-width: 1px;
  &-flow
    min-width 1px
    flex: 1
    height: 100%
    overflow: hidden
    &-content
      display: inline-block
      height: 100%
      white-space nowrap
      background-color: #F4F8FD
      position relative
      &::after
        position: absolute;
        box-sizing: border-box;
        content: ' ';
        pointer-events: none;
        top: -50%;
        right: -50%;
        bottom: -50%;
        left: -50%;
        border: 0 solid #EDEDED;
        -webkit-transform: scale(.5);
        transform: scale(.5);
        border-bottom-width: 1px;
  &-unit
    display: inline-block
    height: 39px
    line-height 39px
    text-align: center
    white-space: nowrap
    box-sizing: border-box
    position relative
    &::after
      position: absolute;
      box-sizing: border-box;
      content: ' ';
      pointer-events: none;
      top: -50%;
      right: -50%;
      bottom: -50%;
      left: -50%;
      border: 0 solid #EDEDED;
      -webkit-transform: scale(.5);
      transform: scale(.5);
      border-left-width: 1px;
.table-article
  height: calc(100% - 39px)
  overflow: hidden
  &-content
    display: flex
  &-fixed
    position relative
    &-list
      display: flex
      align-items: center
      height: 32px
  &-flow
    min-width 1px
    flex: 1
    overflow: hidden
    position relative
    &-list
      display: flex
      align-items: center
      height: 32px
    &::after
      position: absolute;
      box-sizing: border-box;
      content: ' ';
      pointer-events: none;
      top: -50%;
      right: -50%;
      bottom: -50%;
      left: -50%;
      border: 0 solid #EDEDED;
      -webkit-transform: scale(.5);
      transform: scale(.5);
      border-right-width: 1px;
    &-content
      display: inline-block
      white-space nowrap
  &-unit
    display: inline-block
    height: 100%
    line-height 32px
    text-align: center
    white-space: nowrap
    box-sizing: border-box
    position relative
    &::after
      position: absolute;
      box-sizing: border-box;
      content: ' ';
      pointer-events: none;
      top: -50%;
      right: -50%;
      bottom: -50%;
      left: -50%;
      border: 0 solid #EDEDED;
      -webkit-transform: scale(.5);
      transform: scale(.5);
      border-left-width: 1px;
      border-bottom-width: 1px;
.no-data-wrap
  display: flex
  flex-direction: column
  align-items: center
  padding-top: 30px
  img
    width: 132px
    height: 113.5px
    margin-bottom: 19px
  span
    font-size: 15px
    color: #888888
    margin-left: 5px
</style>

4、Scroll组件

<template>
  <div ref="rootRef">
    <slot></slot>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import useScroll from '@/hooks/useScroll'

export default defineComponent({
  name: 'Scroll',
  props: {
    click: {
      type: Boolean,
      default: true
    },
    probeType: {
      type: Number,
      default: 0
    },
    scrollX: {
      type: Boolean,
      default: false
    },
    scrollY: {
      type: Boolean,
      default: true
    },
    bounce: {
      type: Boolean,
      default: true
    },
    useTransition: {
      type: Boolean,
      default: true
    }
  },
  emits: ['scroll'],
  setup(props, { emit }) {
    const rootRef = ref(null)
    const scroll = useScroll(rootRef, props, emit)

    return {
      rootRef,
      scroll
    }
  }
})
</script>

5、useScroll hook

import BScroll, { Options, BScrollInstance } from '@better-scroll/core'
import ObserveDOM from '@better-scroll/observe-dom'
import { onMounted, onUnmounted, onActivated, onDeactivated, ref, Ref } from 'vue'

BScroll.use(ObserveDOM)

export interface Pos {
  x: number;
  y: number;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useScroll(wrapperRef: Ref, options: Options, emit: (event: "scroll", ...args: any[]) => void): Ref {
  const scroll = ref()

  onMounted(() => {
    const scrollVal: BScrollInstance = scroll.value = new BScroll(wrapperRef.value, {
      observeDOM: true,
      ...options
    })

    if (options.probeType && options.probeType > 0) {
      scrollVal.on('scroll', (pos: Pos) => {
        emit('scroll', pos)
      })
    }
  })

  onUnmounted(() => {
    scroll.value.destroy()
  })

  onActivated(() => {
    scroll.value.enable()
    scroll.value.refresh()
  })

  onDeactivated(() => {
    scroll.value.disable()
  })

  return scroll
}

6、useTableWidth hook

import { ColumnProps } from '@/props'

/**
 * 先获取header的宽度,然后获取这个header下面的每个内容的宽度的最大值,然后做对比最后设置
 */
export default (columns: ColumnProps[]):void => {
  columns.forEach(column => {
    const headerKey: string = column.key
    const headerDiv: HTMLDivElement | null = document.querySelector(`#${headerKey}`)
    const tableDivs: NodeListOf<HTMLDivElement> = document.querySelectorAll(`.${headerKey}`)
    if (headerDiv) {
      const headerUnitWidth: number = headerDiv.clientWidth
      let maxedUnitWidth: number = headerUnitWidth
      const length = tableDivs.length
      for (let index = 0; index < length; index++) {
        const tableDiv = tableDivs[index]
        const tableUnitWidth: number = tableDiv.clientWidth
        if (tableUnitWidth > maxedUnitWidth) {
          maxedUnitWidth = tableUnitWidth
          continue
        }
      }

      // 设置
      headerDiv.style.width = maxedUnitWidth + 'px'
      tableDivs.forEach(tableDiv => {
        tableDiv.style.width = maxedUnitWidth + 'px'
      })
    }
  })
}

7、useTableSpan hook

import { SpanProps } from '@/props'

export default (hasFixed: boolean, isClone: boolean, colSpans: SpanProps[] | null | undefined): void => {
  if (hasFixed) {
    const wrap: HTMLDivElement | null = document.querySelector('#table-article-fixed')
    const list: NodeListOf<HTMLDivElement> = document.querySelectorAll('.table-article-fixed-list')

    if (wrap) {
      // 找到所有的colspan div
      const spanDivs: NodeListOf<HTMLDivElement> = document.querySelectorAll('.col-row-span')
      spanDivs.forEach(spanItem => {
        // 先拿到rowIndex、colIndex、contentIndex、spans这些属性
        const rowIndex: string | undefined = spanItem.dataset.rowIndex
        const colIndex: string | undefined = spanItem.dataset.colIndex
        const contentIndex: string | undefined = spanItem.dataset.contentIndex
        const spans: string | undefined = spanItem.dataset.spans
        const direction: string | undefined = spanItem.dataset.direction

        // 根据上面这些属性计算出left、top、width、height
        let left = 0
        let top = 0
        let width = 0
        let height = 0
        let contentElement = null
        const rowIndexNum = Number(rowIndex)
        const colIndexNum = Number(colIndex)
        const spansNum = Number(spans)
        const contentIndexNum = Number(contentIndex)

        if (rowIndex && colIndex && contentIndex && spans) {
          if (direction === 'col') {
            // 计算出height和top
            const listItem = list[rowIndexNum]
            height += listItem.clientHeight
            for (let index = 0; index < list.length; index++) {
              const element = list[index]
              if (rowIndexNum > index) {
                top += element.clientHeight
              } else {
                break
              }
            }

            let count = 0
            const spanDivs: HTMLCollection = listItem.children
            for (let index = 0; index < spanDivs.length; index++) {
              const spanDiv = spanDivs.item(index)
              if (index >= colIndexNum && spanDiv && count < spansNum) {
                width += spanDiv.clientWidth
                count++
              }
              if (colIndexNum > index && spanDiv) {
                left += spanDiv.clientWidth
              }
            }

            // 根据contentIndex拿到内容
            contentElement = list[rowIndexNum].children.item(contentIndexNum)
          }

          if (direction === 'row') {
            // 计算出height和top
            let count = 0
            for (let index = 0; index < list.length; index++) {
              const element = list[index]
              if (index >= rowIndexNum && count < spansNum) {
                height += element.clientHeight
                count++
              }
              if (rowIndexNum > index) {
                top += element.clientHeight
              }
            }

            // 计算width和left
            const listItem = list[rowIndexNum]
            const spanDivs: HTMLCollection = listItem.children
            const spanDiv = spanDivs.item(colIndexNum)
            if (spanDiv) {
              width += spanDiv.clientWidth
            }
            for (let index = 0; index < spanDivs.length; index++) {
              const spanDiv = spanDivs.item(index)
              if (colIndexNum > index && spanDiv) {
                left += spanDiv.clientWidth
              }
            }

            // 根据contentIndex拿到内容
            contentElement = list[rowIndexNum].children.item(contentIndexNum)
          }

          // 设置样式和内容
          spanItem.style.width = (width - 4) + 'px'
          spanItem.style.height = (height - 4) + 'px'
          spanItem.style.top = (top + 2) + 'px'
          spanItem.style.left = (left + 2) + 'px'
          // 这里有个问题如果是动态切换一个表格的话
          if (contentElement) {
            for (let index = 0; index < contentElement.children.length; index++) {
              const element = contentElement.children[index]
              const copyElement = element.cloneNode(true)
              if (isClone) {
                spanItem.appendChild(copyElement)
              } else {
                spanItem.appendChild(element)
              }
            }
          }
        }
      })
    }

    if (colSpans) {
      colSpans.forEach(colSpan => {
        if (colSpan.bgColor) {
          if (colSpan.direction === 'col') {
            // 找到rowIndex所标注的行变色
            const tableArticleFixedElementList: NodeListOf<HTMLDivElement> = document.querySelectorAll('.table-article-fixed-list')
            const tableArticleFixedElementSure = tableArticleFixedElementList.item(colSpan.rowIndex)
            tableArticleFixedElementSure.style.backgroundColor = colSpan.bgColor
            tableArticleFixedElementSure.style.color = colSpan.color || ''
            const tableArticleFlowElementList: NodeListOf<HTMLDivElement> = document.querySelectorAll('.table-article-flow-list')
            const tableArticleFlowElementSure = tableArticleFlowElementList.item(colSpan.rowIndex)
            tableArticleFlowElementSure.style.backgroundColor = colSpan.bgColor
            tableArticleFlowElementSure.style.color = colSpan.color || ''
          }
          if (colSpan.direction === 'row') {
            // 找到rowIndex所标注的行加上span的数量变色
            const spans = colSpan.spans
            const tableArticleFixedElementList: NodeListOf<HTMLDivElement> = document.querySelectorAll('.table-article-fixed-list')
            const tableArticleFlowElementList: NodeListOf<HTMLDivElement> = document.querySelectorAll('.table-article-flow-list')
            for (let index = 0; index < spans; index++) {
              const tableArticleFixedElementSure = tableArticleFixedElementList.item(colSpan.rowIndex + index)
              tableArticleFixedElementSure.style.backgroundColor = colSpan.bgColor
              tableArticleFixedElementSure.style.color = colSpan.color || ''
              const tableArticleFlowElementSure = tableArticleFlowElementList.item(colSpan.rowIndex + index)
              tableArticleFlowElementSure.style.backgroundColor = colSpan.bgColor
              tableArticleFlowElementSure.style.color = colSpan.color || ''
            }
          }
        }
      })
    }
  }
}

8、接口

export interface ColumnProps {
  title: string;
  key: string;
  fixed?: boolean;
  width?: number;
  align?: 'left' | 'right' | 'center';
  scopedSlots?: { customRender: string; };
}

export interface ListProps {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [key: string]: any;
}

export interface RespData<T = {}> {
  code: number;
  data: T;
  message?: string;
}

export interface DDLoginRequestData {
  requestAuthCode: string;
}

export interface DDLoginResponseData {
  token: string;
  userName: string;
}

export interface DailySalesRequestData {
  statisticDate: string;
}

export interface ProjectDailySalesRequestData {
  city: string;
  startTime: string;
  endTime: string;
  propertyType: string;
}

export interface DailySalesResponseData {
  index: number;
  city: string;
  propertyType: string | null;
  cityName: string;
  propertyName: string;

  daySubCount: number | null; // 今日销售数据-认购套数
  daySubAmount: number | null; // 今日销售数据-认购金额
  daySaleCount: number | null; // 今日销售数据-销售套数
  daySaleAmount: number | null; // 今日销售数据-销售金额
  dayHkAmount: number | null; // 今日销售数据-回款金额

  monthSubCount: number | null; // 本月销售数据-认购套数
  monthSubAmount: number | null; // 本月销售数据-认购金额
  monthSaleCount: number | null; // 本月销售数据-销售套数
  monthSaleAmount: number | null; // 本月销售数据-销售金额
  monthHkAmount: number | null; // 本月销售数据-回款金额
  monthTarget: number | null; // 本月销售数据-销售目标
  monthPercent: number | null; // 本月销售数据-完成比例

  yearSubCount: number | null; // 本年销售数据-认购套数
  yearSubAmount: number | null; // 本年销售数据-认购金额
  yearSaleCount: number | null; // 本年销售数据-销售套数
  yearSaleAmount: number | null; // 本年销售数据-销售金额
  yearHkAmount: number | null; // 本年销售数据-回款金额
  yearTarget: number | null; // 本年销售数据-销售目标
  yearPercent: number | null; // 本年销售数据-完成比例
}

export interface ProjectDailySalesResponseData {
  index: number;
  cityName: string;
  projectName: string;
  propertyName: string;

  subCount: number | null; // 认购套数
  subAmount: number | null; // 认购金额
  saleCount: number | null; // 销售套数
  saleAmount: number | null; // 销售金额
  saleNoLicenseCount: number | null; // 无证销售套数
  saleNoLicenseAmount: number | null; // 无证销售金额
  tfCount: number | null; // 退房套数
  tfAmount: number | null; // 退房金额
  bgCount: number | null; // 变更套数
  bgAmount: number | null; // 变更金额

  hkxjAmount: number | null; // 回款金额小计
  xsxjCount: number | null; // 销售套数小计
  xsxjAmount: number | null; // 销售金额小计

  monthSaleAmount: number | null; // 本月销售数据-销售金额
  monthTarget: number | null; // 本月销售数据-销售目标
  monthPercent: number | null; // 本月销售数据-完成比例
  yearSaleAmount: number | null; // 本年销售数据-销售金额
  yearTarget: number | null; // 本年销售数据-销售目标
  yearPercent: number | null; // 本年销售数据-完成比例
}

export interface ProjectDailySalesRouteQuery {
  city: string;
  active: 0 | 1 | 2;
  propertyType: string;
  date: string;
}

export interface DailySalesRouteQuery {
  date: string;
}

export interface SpanProps {
  rowIndex: number;
  colIndex: number;
  contentIndex: number;
  spans: number;
  id: string;
  direction: 'col' | 'row';
  bgColor?: string;
  color?: string;
}


export interface EstateDailySalesResponseData {
  index: number;
  city: string;
  cityName: string;
  propertyType: string | null;
  propertyName: string | null;
  projectName: string;

  daySubCount: number | null; // 今日销售数据-认购套数
  daySubAmount: number | null; // 今日销售数据-认购金额
  daySaleCount: number | null; // 今日销售数据-销售套数
  daySaleAmount: number | null; // 今日销售数据-销售金额
  dayHkAmount: number | null; // 今日销售数据-回款金额

  monthSubCount: number | null; // 本月销售数据-认购套数
  monthSubAmount: number | null; // 本月销售数据-认购金额
  monthSaleCount: number | null; // 本月销售数据-销售套数
  monthSaleAmount: number | null; // 本月销售数据-销售金额
  monthHkAmount: number | null; // 本月销售数据-回款金额
  monthTarget: number | null; // 本月销售数据-销售目标
  monthPercent: number | null; // 本月销售数据-完成比例

  yearSubCount: number | null; // 本年销售数据-认购套数
  yearSubAmount: number | null; // 本年销售数据-认购金额
  yearSaleCount: number | null; // 本年销售数据-销售套数
  yearSaleAmount: number | null; // 本年销售数据-销售金额
  yearHkAmount: number | null; // 本年销售数据-回款金额
  yearTarget: number | null; // 本年销售数据-销售目标
  yearPercent: number | null; // 本年销售数据-完成比例
}

export interface InvestDailySalesResponseData {
  index: number;
  city: string;
  cityName: string;
  propertyType: string | null;
  projectName: string;

  daySubCount: number | null; // 今日销售数据-认购套数
  daySubAmount: number | null; // 今日销售数据-认购金额
  daySaleCount: number | null; // 今日销售数据-销售套数
  daySaleAmount: number | null; // 今日销售数据-销售金额
  dayHkAmount: number | null; // 今日销售数据-回款金额

  monthSubCount: number | null; // 本月销售数据-认购套数
  monthSubAmount: number | null; // 本月销售数据-认购金额
  monthSaleCount: number | null; // 本月销售数据-销售套数
  monthSaleAmount: number | null; // 本月销售数据-销售金额
  monthHkAmount: number | null; // 本月销售数据-回款金额
  monthTarget: number | null; // 本月销售数据-销售目标
  monthPercent: number | null; // 本月销售数据-完成比例

  yearSubCount: number | null; // 本年销售数据-认购套数
  yearSubAmount: number | null; // 本年销售数据-认购金额
  yearSaleCount: number | null; // 本年销售数据-销售套数
  yearSaleAmount: number | null; // 本年销售数据-销售金额
  yearHkAmount: number | null; // 本年销售数据-回款金额
  yearTarget: number | null; // 本年销售数据-销售目标
  yearPercent: number | null; // 本年销售数据-完成比例
}

export interface ParkingDailySalesResponseData {
  index: number;
  city: string;
  cityName: string;
  propertyType: string | null;
  projectName: string;

  daySubCount: number | null; // 今日销售数据-认购套数
  daySubAmount: number | null; // 今日销售数据-认购金额
  daySaleCount: number | null; // 今日销售数据-销售套数
  daySaleAmount: number | null; // 今日销售数据-销售金额
  dayHkAmount: number | null; // 今日销售数据-回款金额

  monthSubCount: number | null; // 本月销售数据-认购套数
  monthSubAmount: number | null; // 本月销售数据-认购金额
  monthSaleCount: number | null; // 本月销售数据-销售套数
  monthSaleAmount: number | null; // 本月销售数据-销售金额
  monthHkAmount: number | null; // 本月销售数据-回款金额
  monthTarget: number | null; // 本月销售数据-销售目标
  monthPercent: number | null; // 本月销售数据-完成比例

  yearSubCount: number | null; // 本年销售数据-认购套数
  yearSubAmount: number | null; // 本年销售数据-认购金额
  yearSaleCount: number | null; // 本年销售数据-销售套数
  yearSaleAmount: number | null; // 本年销售数据-销售金额
  yearHkAmount: number | null; // 本年销售数据-回款金额
  yearTarget: number | null; // 本年销售数据-销售目标
  yearPercent: number | null; // 本年销售数据-完成比例
}

9、用例

<Table :topHeight="186.5" :columns="columns" :list="list" :colSpans="colSpans" :isClone="true">
        <template v-slot:index="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:propertyName="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:subCount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:subAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
        <template v-slot:saleCount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:saleAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
        <template v-slot:saleNoLicenseCount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:saleNoLicenseAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
        <template v-slot:tfCount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:tfAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
        <template v-slot:bgCount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:bgAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
        <template v-slot:hkxjAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
        <template v-slot:xsxjCount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value }}</span>
        </template>
        <template v-slot:xsxjAmount="{ value, item }">
          <span :style="{ fontWeight: item.propertyType === '-1' ? 'bold' : 'normal' }">{{ value === null ? '-' : value.toFixed(2) }}</span>
        </template>
      </Table>
      
      export const columns: ColumnProps[] = [
  {
    title: '序号',
    key: 'index',
    scopedSlots: { customRender: 'index' },
    fixed: true
  },
  {
    title: '城市',
    key: 'cityName',
    fixed: true
  },
  {
    title: '项目',
    key: 'projectName',
    fixed: true
  },
  {
    title: '业态',
    key: 'propertyName',
    scopedSlots: { customRender: 'propertyName' },
    // fixed: true
  },
  {
    title: '认购套数',
    key: 'subCount',
    scopedSlots: { customRender: 'subCount' }
  },
  {
    title: '认购金额',
    key: 'subAmount',
    scopedSlots: { customRender: 'subAmount' }
  },
  {
    title: '销售套数',
    key: 'saleCount',
    scopedSlots: { customRender: 'saleCount' }
  },
  {
    title: '销售金额',
    key: 'saleAmount',
    scopedSlots: { customRender: 'saleAmount' }
  },
  {
    title: '无证销售套数',
    key: 'saleNoLicenseCount',
    scopedSlots: { customRender: 'saleNoLicenseCount' }
  },
  {
    title: '无证销售金额',
    key: 'saleNoLicenseAmount',
    scopedSlots: { customRender: 'saleNoLicenseAmount' }
  },
  {
    title: '退房套数',
    key: 'tfCount',
    scopedSlots: { customRender: 'tfCount' }
  },
  {
    title: '退房金额',
    key: 'tfAmount',
    scopedSlots: { customRender: 'tfAmount' }
  },
  {
    title: '变更套数',
    key: 'bgCount',
    scopedSlots: { customRender: 'bgCount' }
  },
  {
    title: '变更金额',
    key: 'bgAmount',
    scopedSlots: { customRender: 'bgAmount' }
  },
  {
    title: '回款金额小计',
    key: 'hkxjAmount',
    scopedSlots: { customRender: 'hkxjAmount' }
  },
  {
    title: '销售套数小计',
    key: 'xsxjCount',
    scopedSlots: { customRender: 'xsxjCount' }
  },
  {
    title: '销售金额小计',
    key: 'xsxjAmount',
    scopedSlots: { customRender: 'xsxjAmount' }
  },
]