基于vue3和elementplus实现的自定义table组件

发布时间 2023-12-21 16:50:37作者: 沐雨辰沨

基于vue3和elementplus实现的自定义table组件,集成查询工具栏分页,可通过配置直接实现基础的列表页基于vue3和elementplus实现的自定义table组件,集成查询工具栏分页,可通过配置直接实现基础的列表页

目录结构如下:
image
类型声明:

declare type DictType = {
  value: string | boolean | number
  label: string
  type?: string
}

/**
 * table传入column的配置项
 * 注:selection,多选配置下接口和elementPlus接口一致
 * @param label:名称
 * @param key:key值,key值不可以使用type属性规定几个关键字
 * @param type: element Table-column type属性	selection / index / expand / operation / link / tag ( 注意:暂只支持使用index,selection,operation(暂时无内置按钮,需自己slot传入))
 * @param format:表格回显格式化函数
 * @param onlyTable: 是否只在表格中显示
 * @param onlySearch: 是否只在查询中显示
 * @param searchFormatDate: 查询中含有日期选择器时的格式化
 * @param searchType:查询条件以哪种方式展示,暂时支持input,select,tree(tree多选)以及date下的type( 'year','month','date','datetime','week','datetimerange','daterange')等
 * @param searchKey: 查询参数表单key值,不传默认使用key
 * @param dict:字典,searchType为select需要填写
 * @param unit: 单位,可设置单位添加在单元格数据后面
 * @param btnConfig: type=operation时的操作按钮配置
 * @param sortable: 开启列排序,默认不开启列排序,遵循element-plus table sortable规则
 * @param children: 多级表头
 */
declare type ColumnProps = {
  label?: string
  key: string
  type?: 'selection' | 'index' | 'expand' | 'operation' | 'link' | 'tag'
  linkClick?: any
  format?: any
  onlyTable?: boolean
  onlySearch?: boolean
  searchFormatDate?: string
  searchType?:
    | 'input'
    | 'select'
    | 'select-multiple'
    | 'tree'
    | 'tree-strictly'
    | IDatePickerType
  searchKey?: string | string[]
  searchDefaultValue?: any
  dict?: Array<DictType>
  unit?: string
  btnConfig?: Array<Operation>
  sortable?: boolean | 'custom'
  width?: string | number
  fixed?: true | 'left' | 'right'
  children?: Array<ColumnProps>
}

/**
 * 自定义表格查询组件
 * @param rowKey: 行key值,开启多选必填
 * @param labelWidth: 查询表单label宽度
 * @param searchAble: 是否需要查询
 * @param api: 请求数据方法名(需在api目录中什么请求接口)
 * @param tableConfig: table配置
 * @param optBtnCfg: 操作按钮配置
 * @param hasOptOrToolBtnCfg?: 是否需要操作或者工具栏
 * @param hasPagination?: 是否需要分页
 * @param toolBtnCfg: 工具按钮配置
 */
declare type CustomTable = {
  rowKey?: string
  expandRow: any
  labelWidth?: string
  searchAble?: boolean
  api: string
  tableConfig: ColumnProps[]
  hasOptOrToolBtnCfg?: boolean
  hasPagination?: boolean
  optBtnCfg?: any
  toolBtnCfg?: any
  queryParam?: object
}

declare type PageRefType = {
  HTMLElement
  pageSize: number
  currentPage: number
  reset: any
  returnPage: any
}

/**
 * @param type: 内置类型"detail" | "delete" | "edit",暂时只有detail
 * @param flowKey: 流程关键字,type=detail时必填
 */
declare type Operation = {
  type: 'detail' | 'delete'
  flowKey?: string
}

declare type OptType =
  | 'add'
  | 'edit'
  | 'delete-all'
  | 'delete-select'
  | 'export'

declare type ToolType = 'refresh' | 'printer' | 'operation' | 'search'
declare type OptBtnCfg = {
  type: OptType
  api?: string
}
declare type ToolBtnCfg = {
  type: ToolType
  api?: string
}

index.vue
<template>
  <div ref="tableList" class="table-list" style="width: 100%">
    <SearchForm
      v-if="searchAble"
      :label-width="labelWidth"
      :query-param="queryParam"
      :search-from-config="searchFromConfig"
      @condition-change="conditionChange"
    />
    <div class="table-center" v-if="hasOptOrToolBtnCfg">
      <slot name="opt-btn">
        <OptionsBtn
          v-if="optBtnCfg.length > 0"
          :config="optBtnCfg"
          :params="condForm"
          :total="total"
        />
      </slot>
      <slot name="tool-btn">
        <ToolBtn
          v-if="toolBtnCfg.length > 0"
          :config="toolBtnCfg"
          :params="condForm"
          :total="total"
        />
      </slot>
    </div>
    <el-table
      ref="cusElTable"
      :data="tableData"
      :expand-row-keys="expandRow"
      :row-key="rowKey"
      style="width: 100%"
      @expand-change="expandChange"
      @row-click="rowClick"
      @select="select"
      @select-all="selectAll"
      @selection-change="selectionChange"
      @sort-change="sortChange"
    >
      <template v-for="column in tableConfig" :key="column.key">
        <el-table-column
          v-if="column.type === 'expand' || column.key === 'expand'"
          :fixed="column.fixed"
          type="expand"
          :width="column.width || 44"
          :reserve-selection="true"
        >
          <template #default="scope">
            <!-- 提供默认插槽 -->
            <slot name="expand" :scope="scope.row">
              <el-empty
                description="暂无数据"
                :image-size="0"
                style="padding: 10px"
              />
            </slot>
          </template>
        </el-table-column>
        <el-table-column
          v-else-if="column.type === 'selection' || column.key === 'selection'"
          :fixed="column.fixed"
          :label="column.label"
          type="selection"
          :width="column.width || 44"
        />
        <el-table-column
          v-else-if="column.type === 'index' || column.key === 'index'"
          :fixed="column.fixed"
          :label="column.label"
          type="index"
          :width="column.width || 55"
        />
        <el-table-column
          v-else-if="column.type === 'operation' || column.key === 'operation'"
          fixed="right"
          label="操作"
          :width="column.width || 120"
        >
          <template #default="scope">
            <!-- 提供默认插槽 -->
            <slot name="operation" :scope="scope.row">
              <template v-for="operation in column.btnConfig">
                <el-button
                  v-if="operation.type === 'detail'"
                  :key="operation.type"
                  link
                  size="small"
                  type="primary"
                  @click="toDetail(scope.row, operation.flowKey)"
                >
                  详情
                </el-button>
              </template>
            </slot>
          </template>
        </el-table-column>
        <TableColumn
          v-else-if="!column.onlySearch"
          :column="column"
          :params="condForm"
        />
      </template>
    </el-table>
    <!--分页查询工具条-->
    <TablePagination
      v-if="hasPagination"
      ref="cusPage"
      :total="total"
      @page-change="handlePageChange"
    />
  </div>
</template>
<script setup lang="ts">
  import { ref, nextTick, PropType, watch } from 'vue'
  import SearchForm from './SearchForm.vue'
  import TablePagination from './TablePagination.vue'
  import TableColumn from './TableColumn.vue'
  import ToolBtn from './ToolBtn.vue'
  import OptionsBtn from './OptionsBtn.vue'
  import Api from '@/api/index.ts'

  import { useRouter } from 'vue-router'
  import { id } from 'element-plus/es/locale'

  const router = useRouter()

  const props: CustomTable = defineProps({
    /**
     *@description rowKey: 行数据key值
     */
    rowKey: {
      type: String,
      default: 'id',
    },
    // 默认展开行
    expandRow: { type: Array, default: () => [] },
    /**
     * 是否需要查询,默认true
     */
    searchAble: {
      type: Boolean,
      default: true,
    },
    /**
     * 是否需要中间操作按钮,默认true
     */
    hasOptOrToolBtnCfg: {
      type: Boolean,
      default: true,
    },
    /**
     * 是否需要分页,默认true
     */
    hasPagination: {
      type: Boolean,
      default: true,
    },
    labelWidth: {
      type: String,
      default: '100px',
    },
    api: {
      type: String,
      default: '',
      required: true,
    },
    tableConfig: {
      type: Array as PropType<ColumnProps[]>,
      default: () => [],
      required: true,
    },
    optBtnCfg: {
      type: Array as PropType<OptBtnCfg[]>,
      default: () => [],
    },
    toolBtnCfg: {
      type: Array as PropType<ToolBtnCfg[]>,
      default: () => [],
    },
    // 默认查询参数
    queryParam: {
      type: Object,
      required: false,
      default: () => {},
    },
  })

  const cusElTable = ref<any>()
  const tableList = ref<HTMLElement>()
  const typeEum: any = ['selection', 'index', 'expand', 'operation']
  const searchFromConfig = computed(() => {
    return props.tableConfig.filter(
      (el) => !el.onlyTable && !typeEum.includes([el?.type])
    )
  })

  // 构建条件查询form,没有申明searchKey,使用key作为表单属性
  const condForm = ref({})

  onMounted(async () => {
    await nextTick()
    getRecordList()
  })

  const tableData = ref([])

  const conditionChange = (form: any) => {
    if (props.queryParam) {
      const info: any = props.queryParam
      Object.keys(info).forEach((el) => {
        if (!form[el]) {
          form[el] = info[el]
        }
      })
    }
    condForm.value = form
    cusPage?.value?.reset()
    getRecordList()
  }

  const toDetail = (scope: any, key: string | undefined) => {
    // 流程id
    // console.log('scope>>>>>', scope)
    if (key) {
      router.push(`/bpm/bpm/instanceDetail?instId=${key ? scope[key] : ''}`)
    } else {
      throw '当type为detail时,flowKey是必填项'
    }
  }

  // 分页相关
  const total = ref(0)
  const cusPage: any = ref<PageRefType>()
  const selectedDataOld: any = ref([])
  const handlePageChange = (e: any) => {
    // TODO 查询
    if (selectedData.value[e.currentPage - 1]) {
      selectedDataOld.value = JSON.parse(
        JSON.stringify(selectedData.value[e.currentPage - 1])
      )
    }
    getRecordList()
  }

  const getRecordList = async (prop?: string, order?: 'DESC' | 'ASC') => {
    const params = JSON.parse(JSON.stringify(condForm.value))
    Object.keys(condForm.value).forEach((element) => {
      if (element.split(',').length > 1) {
        element.split(',').forEach((el, index) => {
          params[el] = condForm.value[element]
            ? condForm.value[element][index]
            : ''
        })
        delete params[element]
      }
    })
    // 删除为空的条件
    Object.keys(params).forEach((el) => {
      if (!params[el] && typeof params[el] !== 'boolean' && params[el] !== 0) {
        delete params[el]
      }
    })
    const info = {
      offset: cusPage?.value
        ? cusPage?.value?.pageSize * (cusPage?.value?.currentPage - 1)
        : 0,
      limit: cusPage?.value ? cusPage?.value?.pageSize : 10,
      sortColumn: prop ? prop : '',
      sortOrder: order ? order : '',
      enablePage: !props.hasPagination,
      searchCount: true,
      queryParam: {
        ...params,
      },
    }
    const apiParam = props.api.split('.')
    let Fn: any = Api
    apiParam.forEach((el) => {
      Fn = Fn[el]
    })
    const _data = await Fn(info)

    tableData.value = _data.data.rows
    total.value = _data.data.total
    await nextTick()
    if (selectedDataOld.value.length > 0) {
      tableData.value.forEach((element: any) => {
        let ids = selectedDataOld.value.findIndex(
          (el: any) => el.id === element.id
        )
        if (ids !== -1) {
          toggleRowSelection(element, true)
        }
      })
    }
  }
  // 排序
  const sortChange = ({ column, prop, order }: any) => {
    //prop:name, order: 'ascending' 'descending'
    const _order =
      order === 'ascending'
        ? 'ASC'
        : order === 'descending'
        ? 'DESC'
        : undefined
    getRecordList(prop, _order)
  }
  const emit = defineEmits([
    'select',
    'select-all',
    'selection-change',
    'row-click',
    'expand-click',
  ])
  // 多选相关方法
  // 已经选择的选项
  const selectedData: any = ref([])
  const select = (selection: any, row: any) => {
    // console.log('select', selection, row)
    emit('select', selection, row)
  }
  const selectAll = (selection: any) => {
    // console.log('selectAll', selection)
    emit('select-all', selection)
  }
  const selectionChange = (selection: any) => {
    // 页面选中的map
    selectedData.value[cusPage?.value?.currentPage - 1] = selection
    emit('selection-change', selection, selectedData.value)
  }
  const rowClick = (row: any, column: any, event: any) => {
    emit('row-click', row, column, event)
  }
  const expandChange = (row: any, expand: any) => {
    emit('expand-click', row, expand)
  }
  const clearSelection = () => {
    cusElTable.value.clearSelection()
  }
  const toggleRowSelection = (row: any, selected: any) => {
    cusElTable.value.toggleRowSelection(row, selected)
  }
  const toggleAllSelection = () => {
    cusElTable.value.toggleAllSelection()
  }
  const toggleRowExpansion = (row: any, expanded: any) => {
    cusElTable.value.toggleRowExpansion(row, expanded)
  }
  const setCurrentRow = (row: any) => {
    cusElTable.value.setCurrentRow(row)
  }
  const clearSort = () => {
    cusElTable.value.clearSort()
  }
  const refresh = () => {
    selectedData.value = []
    selectedDataOld.value = []
    cusPage?.value?.reset()
    getRecordList()
  }
  onUnmounted(() => {
    console.log(23)

    cusPage?.value?.reset()
    selectedData.value = []
    selectedDataOld.value = []
  })
  defineExpose({
    clearSelection,
    toggleRowSelection,
    toggleAllSelection,
    toggleRowExpansion,
    setCurrentRow,
    clearSort,
    refresh,
  })
</script>
<style lang="scss">
  .btn-row {
    width: 100%;
    display: flex;
    justify-content: flex-start;
    flex-wrap: wrap;
  }
  .table-center {
    display: flex;
    justify-content: space-between;
  }
  .table-list {
    .el-empty__image {
      display: none;
    }
    .el-empty__description {
      margin-top: 0px;
    }
  }
</style>
OptionsBtn.vue
<template>
  <div style="display: flex; margin-bottom: 8px">
    <template v-for="(item, index) in config" :key="index">
      <el-button
        v-if="item.type === 'add'"
        type="success"
        @click="handleClick(item)"
      >
        新增
      </el-button>
      <el-button
        v-if="item.type === 'edit'"
        type="warning"
        @click="handleClick(item)"
      >
        修改
      </el-button>
      <el-button
        v-if="item.type === 'delete-all'"
        type="danger"
        @click="handleClick(item)"
      >
        全部删除
      </el-button>
      <el-button
        v-if="item.type === 'delete-select'"
        type="danger"
        @click="handleClick(item)"
      >
        批量删除
      </el-button>
      <el-button
        v-if="item.type === 'export'"
        type="primary"
        @click="handleExportClick(item)"
      >
        批量导出
      </el-button>
    </template>
  </div>
</template>
<script lang="ts" setup>
  import { ref, nextTick, PropType, watch } from 'vue'
  import AttendanceApi from '@/api/attendance'
  import { downLoadFile } from '~/src/utils'
  import { split } from 'lodash'

  const props = defineProps({
    config: {
      type: Array as PropType<OptBtnCfg[]>,
      default: () => [],
    },
    params: {
      type: Object,
      default: () => {},
    },
    total: {
      type: Number,
      default: 0,
    },
  })
  const queryParams = ref({})
  // 查询条件form
  watch(
    () => props.params,
    (newVal) => {
      if (props.params) {
        const info = JSON.parse(JSON.stringify(props.params))
        Object.keys(props.params).forEach((element) => {
          if (element.split(',').length > 1) {
            element.split(',').forEach((el, index) => {
              info[el] = props.params[element]
                ? props.params[element][index]
                : ''
            })
            delete info[element]
          }
        })
        // 删除为空的条件
        Object.keys(info).forEach((el) => {
          if (!info[el]) {
            delete info[el]
          }
        })
        queryParams.value = info
      }
    },
    {
      immediate: true,
      deep: true,
    }
  )
  const handleClick = (e: any) => {
    // console.log(e)
    ElMessage('功能开发中...')
  }
  const handleExportClick = async (e: any) => {
    const _info = {
      offset: 0,
      limit: props.total,
      // sortColumn: '',
      // sortOrder: '',
      queryParam: {
        ...queryParams.value,
      },
    }
    const _data = await AttendanceApi[e.api](_info)
    const fileSetting = decodeURIComponent(_data.headers['content-disposition'])
    // console.log('export---->', _data)
    downLoadFile(
      fileSetting.split('filename=')[1],
      _data.data,
      _data.headers['content-type']
    )
  }
</script>
<style lang="scss" scoped></style>
SearchBtn.vue
<template>
  <el-form-item style="margin-left: 0px; margin-right: 0">
    <div style="display: flex; height: 32px; align-items: center; width: 300px">
      <el-button :icon="Search" type="primary" @click="search">查询</el-button>
      <el-button :icon="Refresh" type="primary" @click="reset">重置</el-button>
      <el-button
        v-if="showArrow"
        link
        style="color: #409efc"
        @click="closeSearch"
      >
        {{ word }}
        <el-icon class="no-inherit" color="#409EFC">
          <ArrowUp v-if="!showAll" />
          <ArrowDown v-else />
        </el-icon>
      </el-button>
    </div>
  </el-form-item>
</template>
<script setup lang="ts">
  import { Refresh, Search, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
  import { toRefs, computed } from 'vue'

  const props = defineProps({
    showAll: {
      type: Boolean,
      default: true,
    },
    showArrow: {
      type: Boolean,
      default: true,
    },
  })
  const emit = defineEmits(['close-pop', 'btn-click'])
  const { showAll } = toRefs(props)
  const word = computed(() => {
    if (showAll.value == false) {
      //对文字进行处理
      return '收起'
    } else {
      return '展开'
    }
  })

  const closeSearch = () => {
    // console.log(`子组件的状态:${showAll.value}`)
    emit('close-pop')
  }
  const search = () => {
    emit('btn-click', 'search')
  }
  const reset = () => {
    emit('btn-click', 'reset')
  }
</script>
<style lang="scss"></style>
SearchForm.vue
<template>
  <el-form
    ref="form"
    :inline="true"
    :label-width="labelWidth"
    :model="condForm"
  >
    <div ref="formItemRef" class="btn-row">
      <template v-for="(item, index) in searchFromConfig" :key="item.key">
        <el-form-item
          v-show="isShow(index, item)"
          class="formList"
          :label="item.label"
          :prop="item.key"
          style="margin-right: 16px"
        >
          <el-input
            v-if="item.searchType === 'input'"
            v-model="
              condForm[
                Array.isArray(item.searchKey)
                  ? item.searchKey.join(',')
                  : item.searchKey || item.key
              ]
            "
            clearable
            :placeholder="`请输入${item.label}`"
            :style="{ width: valueWidth }"
            @change="conditionChange"
          />
          <el-select
            v-if="
              item.searchType === 'select' ||
              item.searchType === 'select-multiple'
            "
            v-model="
              condForm[
                Array.isArray(item.searchKey)
                  ? item.searchKey.join(',')
                  : item.searchKey || item.key
              ]
            "
            clearable
            :multiple="item.searchType === 'select-multiple'"
            :placeholder="`请选择${item.label}`"
            :style="{ width: valueWidth }"
            @change="conditionChange"
          >
            <el-option
              v-for="opt in item.dict"
              :key="opt.value"
              :label="opt.label"
              :value="opt.value"
            />
          </el-select>
          <el-tree-select
            v-if="
              item.searchType === 'tree' || item.searchType === 'tree-strictly'
            "
            v-model="
              condForm[
                Array.isArray(item.searchKey)
                  ? item.searchKey.join(',')
                  : item.searchKey || item.key
              ]
            "
            check-on-click-node
            :check-strictly="item.searchType === 'tree-strictly'"
            collapse-tags
            collapse-tags-tooltip
            :data="item.dict"
            :max-collapse-tags="2"
            multiple
            :render-after-expand="false"
            show-checkbox
            :style="{ width: valueWidth }"
            @change="conditionChange"
          />
          <el-date-picker
            v-if="dateType.includes(item.searchType || '')"
            v-model="
              condForm[
                Array.isArray(item.searchKey)
                  ? item.searchKey.join(',')
                  : item.searchKey || item.key
              ]
            "
            clearable
            end-placeholder="结束时间"
            :placeholder="`请选择${item.label}`"
            start-placeholder="开始时间"
            :style="{ width: valueWidth }"
            :type="item.searchType"
            :value-format="item.searchFormatDate"
            @change="conditionChange"
          />
        </el-form-item>
      </template>
      <SearchBtn
        :show-all="showAll"
        :show-arrow="showArrow"
        @btn-click="optionClick"
        @close-pop="closePop"
      />
    </div>
  </el-form>
</template>

<script lang="ts" setup>
  import SearchBtn from './SearchBtn.vue'
  import { useRoute } from 'vue-router' //1.先在需要跳转的页面引入useRouter
  const router = useRoute()

  const props = defineProps({
    labelWidth: {
      type: String,
      default: '100px',
    },
    valueWidth: {
      type: String,
      default: '250px',
    },
    searchFromConfig: {
      type: Array<any>,
      default: () => [],
    },
    // 默认查询参数
    queryParam: {
      type: Object,
      default: () => {},
    },
  })
  // 构建条件查询form,没有申明searchKey,使用key作为表单属性
  const condForm = ref({})
  const defaultTime: [Date, Date] = [
    new Date(2000, 1, 1, 0, 0, 0),
    new Date(2000, 2, 1, 23, 59, 59),
  ] // '12:00:00', '08:00:00'

  const dateType = [
    'year',
    'month',
    'date',
    'datetime',
    'week',
    'datetimerange',
    'daterange',
    'dates',
    'monthrange',
  ]

  const extendIndex = ref<number>(20)

  const form = ref<HTMLElement>()
  const formItemRef = ref<HTMLElement>()

  const showAll = ref(true)

  const conditionNum = ref(0)

  const emit = defineEmits(['condition-change'])
  const typeEum: any = ['selection', 'index', 'expand', 'operation']
  // 查询条件form
  watch(
    () => props.searchFromConfig,
    async (newVal) => {
      const obj = {}
      conditionNum.value = props.searchFromConfig.length
      newVal.forEach((el) => {
        if (typeof el.searchKey === 'string') {
          obj[el.searchKey] = el.searchDefaultValue || ''
        } else if (Array.isArray(el.searchKey)) {
          obj[el.searchKey.join(',')] = el.searchDefaultValue || ''
        } else {
          obj[el.key] = el.searchDefaultValue || ''
        }
      })
      condForm.value = obj
      await nextTick()
      emit('condition-change', condForm.value)
      const parent = formItemRef.value?.clientWidth || 0
      const child =
        Number(props.valueWidth.split('px')[0]) +
          Number(props.labelWidth.split('px')[0]) || 350
      const num = Math.floor(parent / Number(child))
      if (num > conditionNum.value) {
        showArrow.value = false
        extendIndex.value = 20
      } else {
        extendIndex.value = num - 1
      }
    },
    {
      immediate: true,
      deep: true,
    }
  )
  const showArrow = ref(true)

  onMounted(async () => {
    const obj = JSON.parse(JSON.stringify(router.query))
    Object.keys(router.query).forEach((el) => {
      const item: any = router?.query[el]
      if (el.includes(',')) {
        obj[el] = item.split(',')
      } else {
        obj[el] = item
      }
    })
    condForm.value = obj
    emit('condition-change', condForm.value)
    await nextTick()
    const parent = formItemRef.value?.clientWidth || 0
    const child =
      Number(props.valueWidth.split('px')[0]) +
        Number(props.labelWidth.split('px')[0]) || 350
    const num = Math.floor(parent / Number(child))
    if (num > conditionNum.value) {
      showArrow.value = false
      extendIndex.value = 20
    } else {
      extendIndex.value = num - 1
    }
  })

  const closePop = () => {
    showAll.value = !showAll.value
    extendIndex.value = 0 - extendIndex.value
  }

  const conditionChange = () => {
    emit('condition-change', condForm.value)
  }

  const optionClick = (e: string) => {
    const obj = {}
    switch (e) {
      case 'search':
        // 查询
        emit('condition-change', condForm.value)
        break
      case 'reset':
        // 重置
        props.searchFromConfig.forEach((el) => {
          if (typeof el.searchKey === 'string') {
            obj[el.searchKey] = el.searchDefaultValue || ''
          } else if (Array.isArray(el.searchKey)) {
            obj[el.searchKey.join(',')] = el.searchDefaultValue || ''
          } else {
            obj[el.key] = el.searchDefaultValue || ''
          }
        })
        condForm.value = obj
        emit('condition-change', condForm.value)
        break
      default:
        break
    }
  }

  const isShow = (index: any, item: any) => {
    if (extendIndex.value < 0) {
      return true
    } else {
      return index < extendIndex.value
    }
  }
</script>

<style scoped lang="scss"></style>
TableColumn.vue
<!-- 表格列组件 -->
<template>
  <el-table-column
    align="center"
    :fixed="column.fixed"
    :label="column.label"
    :prop="column.key"
    show-overflow-tooltip
    :sortable="column.sortable"
    :width="column.width"
  >
    <template #default="scope">
      <div v-if="!column.children">
        <!-- 具有优先级 format > unit > 其他 -->
        <el-tag
          v-if="column.type === 'tag'"
          :type="setTagType(scope.row[column.key], column.dict)"
        >
          <div v-if="column.format">
            {{ column.format(scope.row, column.dict) }}{{ column.unit || '' }}
          </div>
          <div v-else>
            {{ setDictValue(scope.row[column.key], column.dict) }}
            {{ column.unit || '' }}
          </div>
        </el-tag>
        <el-link
          v-else-if="column.type === 'link'"
          type="primary"
          @click="column.linkClick(scope.row, params)"
        >
          <div v-if="column.format">
            {{ column.format(scope.row) }}{{ column.unit || '' }}
          </div>
          <div v-else>{{ scope.row[column.key] }}{{ column.unit || '' }}</div>
        </el-link>
        <div v-else>
          <div v-if="column.format">
            {{ column.format(scope.row) }}{{ column.unit || '' }}
          </div>
          <div v-else>{{ scope.row[column.key] }}{{ column.unit || '' }}</div>
        </div>
      </div>
      <template v-else>
        <TableColumn
          v-for="(child, index) in column.children"
          :key="index"
          :column="child"
        />
      </template>
    </template>
  </el-table-column>
</template>

<script lang="ts" setup>
  const props = defineProps({
    column: {
      type: Object,
      require: true,
      default: () => {},
    },
    params: {
      type: Object,
      default: () => {},
    },
  })
  const setDictValue = (value: any, opts: any) => {
    const idx = opts.findIndex((el: any) => value === el.key)
    if (idx !== -1) {
      return opts[idx]?.name || '--'
    } else {
      return value || '--'
    }
  }
  const setTagType = (value: any, opts: any) => {
    const idx = opts.findIndex((el: any) => value === el.value)
    if (idx !== -1) {
      return opts[idx]?.type
    } else {
      return 'info'
    }
  }
</script>

<style scoped lang="scss"></style>
TablePagination.vue
<template>
  <div class="customer-pagination">
    <el-pagination
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      layout="total, sizes, prev, pager, next, jumper"
      :page-sizes="pageSizes"
      :total="total"
      @current-change="handleCurrentChange"
      @size-change="handleSizeChange"
    />
  </div>
</template>
vue
<script setup lang="ts">
  const emit = defineEmits(['pageChange'])
  const props = defineProps({
    total: {
      type: Number,
      default: 0,
    },
    resetPage: {
      type: Boolean,
      default: false,
    },
  })
  // 分页相关
  const currentPage = ref(1)
  const pageSize = ref(10)
  const pageSizes = [2, 5, 10, 20, 30, 40, 50]
  const handleSizeChange = (val: number) => {
    pageSize.value = val
    // TODO 查询
    // getRecordList()
    emit('pageChange', {
      currentPage: currentPage.value,
      pageSize: pageSize.value,
    })
  }
  const handleCurrentChange = (val: number) => {
    currentPage.value = val
    // TODO 查询
    // getRecordList()
    emit('pageChange', {
      currentPage: currentPage.value,
      pageSize: pageSize.value,
    })
  }

  /**
   * 页码重置
   */
  const reset = () => {
    currentPage.value = 1
    pageSize.value = 10
  }

  const returnPage = () => {
    return { currentPage: currentPage.value, pageSize: pageSize.value }
  }

  defineExpose({
    returnPage,
    reset,
    currentPage,
    pageSize,
  })
</script>
<style lang="scss"></style>
ToolBtn.vue
<template>
  <div style="display: flex; margin-bottom: 8px">
    <el-button
      v-if="types.includes('refresh')"
      circle
      :icon="Refresh"
      @click="handleClick"
    />
    <el-button
      v-if="types.includes('printer')"
      circle
      :icon="Printer"
      @click="handleClick"
    />
    <el-button
      v-if="types.includes('operation')"
      circle
      :icon="Operation"
      @click="handleClick"
    />
    <el-button
      v-if="types.includes('search')"
      circle
      :icon="Search"
      @click="handleClick"
    />
  </div>
</template>
<script lang="ts" setup>
  import { ref, nextTick, PropType, watch, computed } from 'vue'
  import { Printer, Refresh, Operation, Search } from '@element-plus/icons-vue'
  const props = defineProps({
    config: {
      type: Array as PropType<ToolBtnCfg[]>,
      default: () => [],
    },
    params: {
      type: Object,
      default: () => {},
    },
  })
  const types = computed(() => {
    return props.config.map((el) => el.type)
  })
  const handleClick = () => {
    ElMessage('功能开发中...')
  }
</script>
<style lang="scss" scoped></style>