前端小蜜蜂
首页/列表页/详情页/
模仿ProTable创建ProTable组件
2024-01-18269

不多说废话直接上代码 父组件

// index.jsx

/**
 * @description 此ProTable是根据ProComponents里的ProTable模仿封装的简易版本
 * */
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'
import { Card, Table } from 'antd'
import dayjs from 'dayjs'
import { connect } from 'dva'
import { cloneDeep } from 'lodash-es'

import './index.less'
import SearchForm from './components/SearchForm'

/**
 * 默认分页选择
 * */
const defaultTableProps = {
  pageSizeOptions: ['10', '20', '50', '100'], // 指定每页展示多少条
  bordered: true,
}

let isFirstRequest = true

/**
 * @description Table结合搜索封装组件
 *
 * @function SearchForm
 * @param {object} props 父组件传参
 * @param {array} props.columns 列表&搜索类型参数集合
 * @param {string | null} props.size 表格大小
 * @default small
 * @param {array | null} props.pageSizeOptions 分页每页选择条数
 * @param {boolean} props.search 是否展示搜索
 * @param {array} props.optionsButton 操作按钮
 * @param {function} props.request 列表请求
 * @param {boolean} props.bordered 列表是否有边框
 * @param {ReactNode} props.middleDOM
 * @param {object} props.initialValues Form默认参数
 * @param {function | null} props.handlePrecedenceFatherLaterChildren 用于父组件执行结束之后再执行当前组件函数
 * @param {object | null} props.proTable 状态存储搜索参数
 * */
const ProTable = forwardRef((props, ref) => {
  const {
    columns = [],
    tableKey = 'code',
    size = 'small',
    pageSizeOptions = defaultTableProps.pageSizeOptions,
    bordered = defaultTableProps.bordered,
    search = true,
    optionsButton = [],
    middleDOM,
    initialValues,
    request,
    handlePrecedenceFatherLaterChildren,
    proTable,
    dispatch,
    rowSelection,
    loading,
    scroll,
  } = props

  const [current, setCurrent] = useState(1)
  const [pageSize, setPageSize] = useState(20)
  const [total, setTotal] = useState(0)
  const [dataSource, setDataSource] = useState([])
  const [searchValues, setSearchValues] = useState({})
  const [tableColumns, setTableColumns] = useState([])
  const [tableLoading, setTableLoading] = useState(false)

  const tableProps = {
    showQuickJumper: true,
    showSizeChanger: true,
    total,
    current,
    pageSize,
    onShowSizeChange: (page, size) => {
      setCurrent(page)
      setPageSize(size)
    },
    showTotal: totals => `共${totals}条记录`,
    onChange: (page, size) => {
      setCurrent(page)
      setPageSize(size)
    },
  }

  useEffect(() => {
    isFirstRequest = false
    const list = cloneDeep(columns)
      ?.filter(item => !item?.hideInTable)
      ?.map(item => {
        return { ...item, width: item?.width ?? getTextWidth(item?.title) + 20 }
      })
    setTableColumns(() => [...list])

    // 结构状态参数,并判断是否需要保存数据
    let { key, values } = proTable
    if (key !== location.pathname) {
      values = {}
      setSearchValues({})
    } else {
      setSearchValues(val => {
        return { ...val, ...values }
      })
    }
    // 放入异步队列,让执行顺序小于父组件
    if (handlePrecedenceFatherLaterChildren) {
      handlePrecedenceFatherLaterChildren().then(async () => {
        await handleSearch({ ...initialValues, ...values })
      })
    } else {
      handleSearch({ ...initialValues, ...values }).then()
    }
  }, [])

  useEffect(() => {
    const {
      proTable: { values },
    } = props

    if (
      (current !== values.current && values.current > 0) ||
      (pageSize !== values.pageSize && values.pageSize > 0)
    ) {
      isFirstRequest = true
      handleSearch({ ...initialValues, ...searchValues }).then(res => res)
    }
  }, [current, pageSize])

  // 此方法 实际计算出来结果 会比手动计算大8px左右
  const getTextWidth = (text, font = '14px Microsoft YaHei') => {
    const canvas = document.createElement('canvas')
    let context = canvas.getContext('2d')
    context.font = font
    let textmetrics = context.measureText(`${text}:`)
    return textmetrics.width
  }

  // 遍历查询转换时间格式化
  const handleMapSearchTime = values => {
    for (let key in values) {
      if (key?.indexOf(',') > 0 && key?.indexOf('.') < 0) {
        // 判断某个参数是否传入默认format,如果没传默认为 YYYY-MM-DD
        let format =
          initialValues && initialValues[`${key}.format`]
            ? initialValues[`${key}.format`]
            : 'YYYY-MM-DD'
        // 格式化搜索参数
        let paramsList = key.split(',')
        if (paramsList?.length > 0 && values[key]?.length > 0) {
          values = {
            ...values,
            [paramsList[0]]: dayjs(values[key][0]).format(format),
            [paramsList[1]]: dayjs(values[key][1]).format(format),
          }
          delete values[key]
        }
      }
    }
    return values
  }

  // 列表搜索
  const handleSearch = useCallback(
    async ({ current: searchCurrent, pageSize: searchPageSize, ...values }) => {
      if (loading.global || values?.isSearch || isFirstRequest) {
        delete values.isSearch
        setTableLoading(true)
      }
      try {
        setSearchValues(val => ({ ...values }))
        if (searchCurrent || searchPageSize) {
          setCurrent(searchCurrent)
          setPageSize(searchPageSize)
        }

        dispatch({
          type: 'proTable/setSearchFormValues',
          payload: {
            key: location.pathname,
            values: {
              current: searchCurrent ?? current,
              pageSize: searchPageSize ?? pageSize,
              ...values,
            },
          },
        })

        const resValues = handleMapSearchTime(values)
        const res = await request({
          current: searchCurrent ?? current,
          pageSize: searchPageSize ?? pageSize,
          values: resValues,
        })
        const { total: resTotal = 0, dataSource: resDataSource = [] } = res
        setTotal(resTotal)
        setDataSource(() => [...resDataSource])
      } catch (e) {
        console.error('获取列表错误:', e)
      } finally {
        setTableLoading(false)
      }
    },
    [current, pageSize, searchValues]
  )

  // 暴漏子组件参数
  useImperativeHandle(ref, () => ({
    handleSearch,
  }))

  // DOM
  return (
    <div className='pro-table'>
      {search && (
        <Card>
          <SearchForm handleSearch={handleSearch} {...props} />
        </Card>
      )}
      <div>{middleDOM}</div>
      <Card
        className='table-card'
        extra={optionsButton?.length > 0 ? optionsButton?.map(item => item) : ''}
      >
        <Table
          loading={tableLoading}
          columns={tableColumns}
          rowKey={tableKey}
          dataSource={dataSource}
          pagination={{ ...tableProps, pageSizeOptions }}
          size={size}
          bordered={bordered}
          sticky={true}
          rowSelection={rowSelection}
          scroll={scroll}
          className='table'
        />
      </Card>
    </div>
  )
})

// 由于ref被Hoc高阶组件{connect}隔离了
// 所以我们需要使用函数进行包裹
function wrap(Component) {
  const ForwardRefComp = props => {
    const { forwardedRef, ...rest } = props
    return <Component ref={forwardedRef} {...rest} />
  }

  const StateComp = connect(state => state)(ForwardRefComp)

  return forwardRef((props, ref) => <StateComp {...props} forwardedRef={ref} />)
}

export default wrap(ProTable)

子组件

/**
 * @description 此ProTable是根据ProComponents里的ProTable模仿封装的简易版本,搜索组件
 * */
import React, { useEffect, useState } from 'react'
import { Button, Col, DatePicker, Form, Input, Row, Select, Space } from 'antd'
import { DownOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { cloneDeep } from 'lodash-es'

import '../index.less'

/**
 * form表单默认参数
 * */
const defaultFormProps = {
  labelAlign: 'left',
  colon: true,
  buttonProps: {
    searchDisabled: false,
    resetDisabled: false,
    searchButtonText: '查询',
    searchResetText: '重置',
  },
}

// 日期解析
const { RangePicker } = DatePicker

// 默认布局
const rowCols = { xl: 8, lg: 8, md: 12, sm: 24 }

// 时间选择快捷键
const defaultPresets = [
  { label: '今天', value: [dayjs(), dayjs()] },
  { label: '昨天', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
  { label: '本周', value: [dayjs().startOf('week'), dayjs()] },
  {
    label: '上周',
    value: [
      dayjs()
        .subtract(1, 'week')
        .startOf('week'),
      dayjs()
        .subtract(1, 'week')
        .endOf('week'),
    ],
  },
  { label: '本月', value: [dayjs().startOf('month'), dayjs()] },
  {
    label: '上月',
    value: [
      dayjs()
        .subtract(1, 'month')
        .startOf('month'),
      dayjs()
        .subtract(1, 'month')
        .endOf('month'),
    ],
  },
  { label: '今年', value: [dayjs().startOf('year'), dayjs()] },
  {
    label: '去年',
    value: [
      dayjs()
        .subtract(1, 'year')
        .startOf('year'),
      dayjs()
        .subtract(1, 'year')
        .endOf('year'),
    ],
  },
]

/**
 * @description 搜索组件
 *
 * @function SearchForm
 * @param {object} props 父组件传参
 * @param {array} props.columns 列表&搜索类型参数集合
 * @param {object | null} props.buttonAuth Form操作按钮规则按钮
 * @param {function} props.handleSearch 搜索
 * @param {function} props.dispatch dva 状态管理
 * @param {object | null} props.initialValues Form初始化参数
 * @param {string} props.labelAlign 搜索默认布局
 * @param {object | null} props.proTable 状态存储搜索参数
 * */
const SearchForm = props => {
  const {
    columns = [],
    handleSearch,
    labelAlign = defaultFormProps.labelAlign,
    colon = defaultFormProps.colon,
    initialValues,
    pageSizeOptions,
    buttonProps: fatherButtonProps = defaultFormProps.buttonProps,
    proTable,
  } = props
  const buttonProps = { ...defaultFormProps.buttonProps, ...fatherButtonProps }
  const [form] = Form.useForm() // 初始化搜索实例
  const [expand, setExpand] = useState(false)
  const [puckOrOpen, setPuckOrOpen] = useState('展开')
  const [formList, setFormList] = useState([])

  useEffect(() => {
    const { key, values } = proTable
    if (key !== location?.pathname) {
      form.setFieldsValue({ ...initialValues })
    } else {
      form.setFieldsValue({ ...values })
    }
  }, [])

  // 根据columns更新列表
  useEffect(() => {
    if (columns?.length > 0) {
      getFormItemLabelWidth()
    }
  }, [columns])

  // 计算字体长度,返回最大宽度
  const getFormItemLabelWidth = () => {
    // sm 屏幕 ≥ 576px
    // md 屏幕 ≥ 768px
    // lg    屏幕 ≥ 992px
    // xl    屏幕 ≥ 1200px
    let cols = 1
    if (window.innerWidth >= 1200) {
      cols = 3
    } else if (window.innerWidth >= 992) {
      cols = 3
    } else if (window.innerWidth >= 768) {
      cols = 2
    }

    // 处理columns
    let columnsFilter = cloneDeep(columns).filter(({ hideInSearch = false }) => !hideInSearch)
    // 处理每行列数
    const groupedColumns = new Array(cols).fill(null).map(() => [])

    // 处理每行
    columnsFilter?.forEach((item, index) => {
      const columnIndex = index % cols
      groupedColumns[columnIndex].push(item)
    })

    // 处理每列
    const maxColumnWidths = groupedColumns.map(column => {
      return column.reduce((maxWidth, item) => {
        const label = item?.searchTitle ?? item?.title
        return label ? Math.max(maxWidth, getTextWidth(label)) : maxWidth
      }, 0)
    })

    // 处理每列标签宽度
    const resColumns = columnsFilter?.map((item, index) => {
      const columnIndex = index % cols
      item.width = `${maxColumnWidths[columnIndex]}px`
      return { ...item, width: `${maxColumnWidths[columnIndex]}px` }
    })

    setFormList(() => [...resColumns])
  }

  // 此方法 实际计算出来结果 会比手动计算大8px左右
  const getTextWidth = (text, font = '14px Microsoft YaHei') => {
    const canvas = document.createElement('canvas')
    let context = canvas.getContext('2d')
    context.font = font
    let textmetrics = context.measureText(`${text}:`)
    return textmetrics.width
  }

  /**
   * 组件类型
   * @param {object} params 仅需要搜索组件参数
   * @param {string} params.searchType 搜索组件类型 - Input|Select|
   * @param {array} params.options 搜索参数数组展示
   * @param {string} params.placeholder 占位符
   * @param {string} params.title 名称
   * @param {string} params.searchTitle 指定搜索名称
   * @param {string} params.mode 搜索多选模式
   * @param {string} params.picker 日期选择模式
   * @param {object} params.DatePickerOptions 日期时间参数
   * @param {array} params.RangePickerOptions 日期区间默认时间
   * @param {object} params.presets 快捷键设置
   * @param {string} params.searchIndex 搜索参数
   * @param {string} params.dataIndex 搜索&table参数
   * @param {object} params.selectOptions 选择框参数
   * @param {string} params.relevanceIndex 日期关联参数,用于日期限制
   * @param {string} params.relevanceTitle 日期关联名称,用于选择提示
   * */
  const componentsFormItem = params => {
    const {
      searchType,
      placeholder,
      searchTitle,
      title,
      selectOptions,
      picker,
      rangePickerOptions,
      datePickerOptions,
      presets = defaultPresets,
      renderExtraFooterText,
      searchIndex,
      dataIndex,
      timeRangeDay = 31,
    } = params
    switch (searchType) {
      case 'Select':
        return (
          <Select
            {...selectOptions}
            mode={selectOptions?.mode ?? ''}
            allowClear
            key='value'
            options={
              selectOptions?.options
                ? selectOptions.options?.map(item => ({
                  value: item[selectOptions?.props?.value ?? 'value'],
                  label: item[selectOptions?.props?.label ?? 'desc'],
                }))
                : []
            }
            onChange={selectOptions?.onChange}
            placeholder={placeholder ?? `请选择${searchTitle ?? title}`}
            style={{ width: '100%' }}
          />
        )
      case 'DatePicker':
        return (
          <DatePicker
            {...datePickerOptions}
            picker={picker}
            placeholder={placeholder ?? `请选择${searchTitle ?? title}`}
            style={{ width: '100%' }}
          />
        )
      case 'RangePicker':
        return (
          <RangePicker
            {...rangePickerOptions}
            renderExtraFooter={() =>
              renderExtraFooterText ?? (
                <div style={{ color: 'red' }}>注:最长可选择时间范围 {timeRangeDay} 天</div>
              )
            }
            presets={presets}
            picker={picker}
            disabledDate={
              timeRangeDay
                ? current =>
                  handleDisabledDateRangePicker(current, searchIndex ?? dataIndex, timeRangeDay)
                : null
            }
            onChange={val => handleRangePickerChange(val, searchIndex ?? dataIndex)}
            onCalendarChange={val => handleOnCalendarChange(val, searchIndex ?? dataIndex)}
            onOpenChange={open => handleOnOpenChange(open, searchIndex ?? dataIndex)}
            placeholder={placeholder ?? ['开始时间', '结束时间']}
            style={{ width: '100%' }}
          />
        )
      default:
        return (
          <Input
            allowClear
            placeholder={placeholder ?? `请输入${searchTitle ?? title}`}
            style={{ width: '100%' }}
          />
        )
    }
  }

  /**
   * 日期组件打开时操作
   * @function handleOnOpenChange
   * @param {boolean} open 是否打开了参数
   * @param {string | T | any} searchIndex 搜索参数
   * */
  const handleOnOpenChange = (open, searchIndex) => {
    if (open) {
      setTimeout(() => {
        form.setFieldsValue({ [`${searchIndex}`]: [null, null] })
      })
    } else {
      const date = form.getFieldValue(searchIndex)
      if ((!date || !date[0] || !date[1]) && initialValues && initialValues[`${searchIndex}`]) {
        form.setFieldsValue({ [`${searchIndex}`]: [...initialValues[`${searchIndex}`]] })
      }
    }
  }

  /**
   * 待选日期发生变化时回调
   * @function handleOnCalendarChange
   * @param {array[dayjs]} values 时间选择参数
   * @param {string | T | any} searchIndex 搜索参数
   * */
  const handleOnCalendarChange = (values, searchIndex) => {
    form.setFieldValue(searchIndex, values)
  }

  /**
   * 时间区间选择设置关联参数,返回可选择范围
   * @function handleDisabledDateRangePicker
   * @param {dayjs | any} current 时间
   * @param {string | T | any} searchIndex 搜索参数
   * @param {number} timeRangeDay 禁用范围天数
   * */
  const handleDisabledDateRangePicker = (current, searchIndex, timeRangeDay) => {
    if (!form.getFieldValue(searchIndex) || !form.getFieldValue(searchIndex)[0]) {
      return current && current > dayjs().endOf('day')
    }
    let tooLate =
      form.getFieldValue(searchIndex)[0] &&
      current?.diff(form.getFieldValue(searchIndex)[0], 'days') >= timeRangeDay
    let tooEarly =
      form.getFieldValue(searchIndex)[1] &&
      form.getFieldValue(searchIndex)[1].diff(current, 'days') >= timeRangeDay
    return !!tooEarly || !!tooLate || (current && current > dayjs().endOf('day'))
  }

  /**
   * 时间选择格式化输出
   * @function handleRangePickerChange
   * @param {array} values 时间框输出时间
   * @param {string | T | any} searchIndex 输出搜索参数
   // * @param {string} format 日期格式化 , format = 'YYYY-MM-DD'
   * */
  const handleRangePickerChange = (values, searchIndex) => {
    form.setFieldValue(searchIndex, values)
  }

  // FormItem子组件内容展示
  const renderFormItemChildren = () => {
    return (
      <>
        {formList?.map(item => {
          const { renderFormItem, title, searchTitle, dataIndex, searchIndex, width } = item
          if (renderFormItem) {
            return (
              <Col {...rowCols} key={searchIndex ?? dataIndex} style={{ marginTop: 10 }}>
                <Form.Item
                  label={searchTitle ?? title}
                  name={searchIndex ?? dataIndex}
                  labelCol={{ style: { minWidth: width } }}
                  key={searchIndex ?? dataIndex}
                >
                  {renderFormItem()}
                </Form.Item>
              </Col>
            )
          }
          return (
            <Col {...rowCols} key={searchIndex ?? dataIndex} style={{ marginTop: 10 }}>
              <Form.Item
                label={searchTitle ?? title}
                name={searchIndex ?? dataIndex}
                labelCol={{ style: { minWidth: width } }}
                key={searchIndex ?? dataIndex}
              >
                {componentsFormItem(item)}
              </Form.Item>
            </Col>
          )
        })}
      </>
    )
  }

  const handleIsShow = () => {
    return renderFormItemChildren()?.props?.children?.length >= 3
  }

  // 搜索
  const handleFormSearch = values => {
    handleSearch({
      ...values,
      pageSize: pageSizeOptions?.pageSize ?? 20,
      current: pageSizeOptions?.current ?? 1,
      isSearch: true,
    })
  }

  // 重置
  const handleFormReset = () => {
    form.resetFields()
    handleSearch({
      ...initialValues,
      pageSize: pageSizeOptions?.pageSize ?? 20,
      current: pageSizeOptions?.current ?? 1,
    })
  }

  // DOM
  return (
    <Form
      layout='inline'
      form={form}
      name='advanced_search'
      onFinish={handleFormSearch}
      labelAlign={labelAlign}
      colon={colon}
      initialValues={{ ...initialValues }}
      className='search-form'
    >
      <Row gutter={[16, 16]} className='row-search'>
        <Row style={{ width: '100%' }}>
          <Row style={{ display: expand ? 'inline-flex' : 'none' }}>
            {renderFormItemChildren()?.props?.children?.map(item => item)}
          </Row>
          <Row style={{ display: !expand ? 'inline-flex' : 'none' }}>
            {renderFormItemChildren()
              ?.props?.children?.slice(0, 2)
              ?.map(item => item)}
          </Row>
        </Row>
        {(!expand || renderFormItemChildren()?.props?.children?.length % 3 !== 0) && (
          <Col {...rowCols} className='col-left__one' style={{ bottom: 0 }}>
            <Space size='small'>
              <Button
                onClick={handleFormReset}
                disabled={buttonProps.resetDisabled}
                style={{ display: handleIsShow() ? 'flex' : 'none' }}
              >
                {buttonProps.searchResetText}
              </Button>
              <Button type='primary' htmlType='submit' disabled={buttonProps.searchDisabled}>
                {buttonProps.searchButtonText}
              </Button>
              <a
                onClick={() => {
                  setExpand(!expand)
                  setPuckOrOpen(expand ? '展开' : '收起')
                }}
                className='button-open'
                style={{ display: handleIsShow() ? 'flex' : 'none' }}
              >
                <DownOutlined rotate={expand ? 180 : 0} /> {puckOrOpen}
              </a>
            </Space>
          </Col>
        )}
      </Row>
      {expand && renderFormItemChildren()?.props?.children?.length % 3 === 0 && (
        <Row className='row-button' style={{ marginTop: expand ? 16 : -32 }}>
          <Col span={24} className='col-left__two'>
            <Space size='small'>
              <Button onClick={handleFormReset} disabled={buttonProps.resetDisabled}>
                {buttonProps.searchResetText}
              </Button>
              <Button type='primary' htmlType='submit' disabled={buttonProps.searchDisabled}>
                {buttonProps.searchButtonText}
              </Button>
              <a
                onClick={() => {
                  setExpand(!expand)
                  setPuckOrOpen(expand ? '展开' : '收起')
                }}
                className='button-open'
              >
                <DownOutlined rotate={expand ? 180 : 0} /> {puckOrOpen}
              </a>
            </Space>
          </Col>
        </Row>
      )}
    </Form>
  )
}

export default SearchForm

这里改变了FormItem的label宽度,是计算的,这里是每行三列,切每列的字数最多的为这列的label的宽度,这里看大家需求去改变就可以了

less文件

// index.less
.pro-table {
  .table-card {
    margin-top: 10px;
    .ant-card-body {
      padding: 10px;
    }

    .ant-card-extra{
      width: 100%;
    }
  }

  .search-form {
    position: relative;
    .row-search {
      width: 100%;
      .ant-row{
        width: 100%;
      }

      .col-left__one {
        position: absolute;
        right: 10px;
        display: flex;
        justify-content: flex-end;
      }
    }

    .row-button {
      width: 100%;
      margin-top: 10px;

      .col-left__two {
        display: flex;
        justify-content: flex-end;
      }
    }
  }

  .button-open {
    font-size: 12px;
    color: #2e85dd;
  }
}

代码就这么多,基本上都写了,备注也有,大家自己看吧

王者荣耀英雄联盟穿越火线
想写一首诗送给你
却发现早已没有资格
想写一句话送给我自己
不知何时
心如死灰