基于 vue2 + element ui 封装的 pro-table

300 阅读11分钟

想说的话

说到后台管理站开发,ant design 是一个绕不开的话题,它的设计和思想都很有借鉴意义。Antd 组件几乎可以满足任意的业务需求,而 antd pro commponent 更是让人惊为天人。我在第一家公司有幸使用过优秀的同事独自开发的 vue3 + antd vue 封装的一套完整后台系统,其中实现的多个 pro component 尤其使我印象深刻。我在使用时,感受到 api 的简洁,功能强大,可惜当时没有探究的心,只是了解到 api 是参考 antd prop component。这两天我因工作原因和探究的心,试着揭开它的面纱。考虑到 antd pro table 封装完成度极高,而我在实现中部分 api 我采用了相对基础的思路,所以会有些许区别。

第一个 "Hello World"

每每做一个程序,我都会记起 "Hello World" 的输出。首先让我们完成初始工作吧,我们先创建一个 vue2 项目vue create vue2-el-pro-table,为简化流程,我们选择 vue2 默认模板,如下图:

image.png 下一步我们引入 element ui 组件,这个按官方文档引入即可,接下来我们写创建 pro-table 组件的基本模板,如下,包括定义了我们的组件名称。

<template>
  <div class="pro-table-container">
    <div class="form-wrapper">
      <el-form></el-form>
    </div>
    <div class="button-group"></div>
    <div class="table-wrapper">
      <el-table></el-table>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'ProTable'
  }
</script>

最后引入组件再运行我们的项目,结果如下:

image.png

Form 实现

当我们注意到 antd pro table 中的 columns 字段时,我们想到了 v-for 遍历实现我们的表单元素。很明显,我们需要定义一个 type 来指定是什么元素(注意到 type 是 column 的一个属性,我们用 fieldType 来定义)。首先我们来实现一个 el-input 的显示,我们在父组件新增一个 columns 数据,放在 data 属性里,代码如下,定义完成我们绑定到 pro-table 组件上并在 pro-table 组件定义好 props。

data() {
  return {
    columns: [
      {
        fieldType: 'input'
      }
    ]
  }
}

我们开始遍历 columns,但是我们很快就需要思考 input 怎么生成 el-input 呢?

image.png 根据我们开发的经验,不难想到 vue 的条件渲染,通过指定 fieldType 生成特定的 el-[fieldType] 组件,它是一种可行的方法。但我们写了很多个 v-else-if 的时候我们可能会开始抱怨,我们要匹配所有查询表格常用的表单组件,虽然不算很多,但总归还是希望能减少 v-else-if 的编写。根据我们的开发经验,我们曾几何时看过这样一个可以动态更新的 vue 组件 <component is="comp" />,我们知道可以通过切换 is 绑定的值动态渲染相应的组件,这或许是一个可行的办法。话不多说,我们试着写一下,代码如下:

<el-form-item v-for="(column, $index) in columns" :key="$index">
  <component :is="`el-${column.fieldType}`" />
</el-form-item>

我们看一下效果,很好!它生效了。补充:<component is="el-input" /> 能生效的原因是我们把 ElementUI 组件注册到了 vue。

image.png 下一步是渲染 el-input 的属性,我们定义一个 fieldProps 字段,通过 v-bind 绑定到 el-input 上,这样你可以添加任意 el-input 的属性了。相应的,定义一个 fieldEventProps,通过 v-on 绑定到 el-input上,这样你可以添加任意 el-input 的方法了。要想 el-input 可以输入,我们需要进行双向绑定,我们在 pro-table 组件进行 el-form 的绑定,所以我们需要给 el-input 添加一个 prop 属性,那这个属性字段我们放哪呢?可以用排除法,如果放在和 field-value 同一级,因为 table column 也是需要 prop 属性的,如果表单字段和列字段在接口的定义不一致时,这样会引起冲突。如果我们放在 field-value 里,上个问题就不存在了,不过如果需要调用 resetFields 等表单方法时,我们需要在 el-form-item 添加 prop,所以我们添加一个 formItemProps 属性。如下:

columns: [
  {
    fieldType: 'input',
    formItemProps: {
      label: '名称',
      prop: 'name',
    },
    fieldProps: {
      placeholder: '我是一个 Input',
      clearable: true
    },
    fieldEventProps: {
      input: this.handleInput
    },
  }
]
<el-form>
  <el-form-item v-for="(column, $index) in columns" v-bind="column.formItemProps" :key="$index">
    <component :is="`el-${column.fieldType}`" v-model="modelForm[column.formItemProps.prop]" v-bind="column.fieldProps" v-on="column.fieldEventProps" />
  </el-form-item>
</el-form>

data() {
  return {
    modelForm: {}, // 表单数据
  }
},

image.png

image.png 我们继续添加一些表单组件,像 el-input-number, el-select, el-cascader, el-date-picker。同时,我们添加布局。首先是 el-form 属性,我们添加一个 form 字段,同时我们需要添加 el-row 属性,再添加一个 search 字段。同时补充 css 样式。同时,我们注意到,el-select 需要 el-option 组件,我们可以通过条件渲染实现 如下:

data() {
  return {
    columns: [
      {
        fieldType: 'input',
        formItemProps: {
          label: '名称',
          prop: 'name',
        },
        fieldProps: {
          placeholder: '我是一个 Input',
          clearable: true
        },
        fieldEventProps: {
          input: this.handleInput
        },
      },
      {
        fieldType: 'input-number',
        formItemProps: {
          label: '数量',
          prop: 'amount',
        },
        fieldProps: {
          placeholder: '请输入数量',
          'controls-position': 'right'
        },
      },
      {
        fieldType: 'select',
        formItemProps: {
          label: '角色',
          prop: 'role',
        },
        fieldProps: {
          placeholder: '请选择角色!!!',
        },
      },
      {
        fieldType: 'cascader',
        formItemProps: {
          label: '标签',
          prop: 'label',
        },
        fieldProps: {
          options: [
            { label: '标签一', value: 'label', children: [{ label: '标签1-1', value: 'label-1' }]},
            { label: '标签二', value: 'label2', children: [{ label: '标签2-1', value: 'label-2' }]}
          ]
        },
      },
      {
        fieldType: 'date-picker',
        formItemProps: {
          label: '日期',
          prop: 'date',
        },
        fieldProps: {
          type: "daterange",
          'range-separator': "至",
          'start-placeholder': "开始日期",
          'end-placeholder': "结束日期",
          'default-time': ['00:00:00', '23:59:59'],
          'value-format': 'yyyy-MM-dd HH:mm:ss',
        },
      },
    ],
    form: {
      'label-width': '120px',
    },
    search: {
      labelWidth: '120px',
      span: 6,
      class: 'pro-table-demo-form',
    },
  }
},
<el-form ref="formRef" :model="modelForm">
  <el-form-item v-for="(column, $index) in columns" v-bind="column.formItemProps" :key="$index">
    <component :is="`el-${column.fieldType}`" v-model="modelForm[column.formItemProps.prop]" v-bind="column.fieldProps" v-on="column.fieldEventProps">
      <template v-if="column.fieldType === 'select'">
        <el-option v-for="option in column.fieldProps.options" :value="option.value" :label="option.label" :key="option.value" :disabled="option.disabled"></el-option>
      </template>
    </component>
  </el-form-item>
</el-form>

<style scoped>
  .el-form-item__content .el-select,
  .el-form-item__content .el-input-number,
  .el-form-item__content .el-date-editor,
  .el-form-item__content .el-cascader
  {
    width: 100%;
  }
</style>

image.png 现在,我们补充一下 select options 的来由,我们添加一个 optionLoader 字段,如下:

{
  fieldType: 'select',
  formItemProps: {
    label: '角色',
    prop: 'role',
  },
  fieldProps: {
    placeholder: '请选择角色!!!',
  },
  optionLoader: () => new Promise(resolve => setTimeout(() => resolve([
    { label: '全部', value: 'all' },
    { label: '未解决', value: 'open' },
  ]), 1000)),
},

我们在 pro-table 组件获取到接口数据,数据结构是 { [prop]: res, [prop2]: res2 },我们再通过 options 更新 columns,更改遍历数据 columns 变为 formColumns。

computed: {
  formColumns() { // 表单配置项
    return this.columns.map(column => {
      const { fieldType, fieldProps, formItemProps: { prop } } = column

      // 更新 select/cacader options
      if (['select', 'cascader'].includes(fieldType)) {
        // this.options 更新后会重新计算
        if (!fieldProps.options) {
          fieldProps.options = this.options[prop]
        }
      }
      
      return {
        ...column,
        fieldProps,
      }
    })
  },
},
data() {
  return {
    options: {}, // 异步数据
  }
},
created () {
  this.getOptions() // 获取异步数据
},
methods: {
  /**
   * @desc 获取异步数据
   */
  async getOptions() {
    for (const column of this.columns) {
      const { formItemProps: { prop }, optionLoader } = column
      if (typeof optionLoader === 'function') {
        optionLoader().then(res => {
          this.options = {
            ...this.options,
            [prop]: res
          }
        })
      }
    }
  },
},

等待数据获取后下拉列表回显。 image.png 我们知道目前表单组件的渲染顺序是 columns 的排列顺序,如果需要更改排列顺序,我们在 columns 的每一条数据添加一个 order 权重字段,权重越大,排列越靠前。 如果我们需要动态隐藏某一列,我们可以定义一个 hideInForm 字段,这样可以动态控制字段的显示隐藏。 下图的效果是我们给“数量”这一列加了权重,隐藏了角色这一栏,效果如下:

    computed: {
      formColumns() { // 表单配置项
        return this.columns
        .map(column => {
          const { fieldType, fieldProps, formItemProps: { prop } } = column

          // 更新 select/cacader options
          if (['select', 'cascader'].includes(fieldType)) {
            // this.options 更新后会重新计算
            if (!fieldProps.options) {
              fieldProps.options = this.options[prop]
            }
          }
          
          return {
            ...column,
            fieldProps,
          }
        }).sort((a, b) => a.order && b.order ? b.order - a.order : a.order ? -1 : b.order ? 1 : 0)
        .filter(column => !column.hideInForm)
      },

image.png 考虑我们给表单加一个初始值,我们可以在 columns 的每一条数据添加一个 initialValue 字段,我们可以在 pro-table 组件进行初始化,代码如下:

data() {
  return {
    modelForm: this.initValue(), // 表单数据
  }
}
methods: {
  /**
   * @desc 获取初始值
   */
  initValue() {
    // 获取初始值
    return this.columns.filter(column => {
      return column.initialValue
    }).reduce((accu, cur) => {
      const { formItemProps: { prop} } = cur
      return {
        ...accu,
        [prop]: cur.initialValue
      }
    }, {})
  },
}

我们给“名称”这一列加了一个初始值"Hello World",效果如下:

image.png 我们再补充一下查询按钮,search 字段添加一些属性,代码如下

  search: {
    searchText: '查询',
    resetText: '重置',
    span: 6,
    labelWidth: '120px',
    class: 'pro-table-demo-form',
  },
  <el-form ref="formRef" :model="modelForm" v-bind="form">
    <el-row type="flex" style="flex-wrap: wrap">
      <el-col :span="search.span" v-for="column in formColumns" :key="column.formItemProps.prop">
        <el-form-item v-bind="column.formItemProps">
          <component :is="`el-${column.fieldType}`" v-model="modelForm[column.formItemProps.prop]" v-bind="column.fieldProps" v-on="column.fieldEventProps">
            <template v-if="column.fieldType === 'select'">
              <el-option v-for="option in column.fieldProps.options" :value="option.value" :label="option.label" :key="option.value" :disabled="option.disabled"></el-option>
            </template>
          </component>
        </el-form-item>
      </el-col>
      <el-col :span="search.span" key="btn">
        <el-form-item :label-width="search.labelWidth">
          <el-button type="primary" icon="el-icon-search">{{ search.searchText }}</el-button>
          <el-button icon="el-icon-refresh">{{ search.resetText }}</el-button>
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>

效果如下:

image.png

Table Column

类似的,我们添加 table 字段作为 table 的属性,tableEvents 作为 table events 的属性。我们给 columns 每一项都加上 label, prop 等属性,效果如下:

  table: {
    size: 'medium',
    border: true
  },
  tableEvent: {
    'row-click': this.handleRowClick
  }
<el-table ref="tableRef" v-bind="table" v-on="tableEvents">
  <el-table-column v-for="column in columns" v-bind="column" :key="column.prop"></el-table-column>
</el-table>

image.png 目前 table 的功能相当有限,我们先按下不表,先进行分页数据绑定。我们先定义一个 pagination 字段,代码如下:

pagination: { // 分页数据
    'current-page': 1,
    'page-size': 10,
    'page-sizes': [10, 20, 30, 50],
    layout: "total, sizes, prev, pager, next, jumper",
    total: 400
}

考虑到我们需要在查询时需要传递 page, pageSize 字段,所以这里需要进行双向绑定,我们在 pro-table 组件定义 page, pageSize 字段。

<div style="margin: 16px 0; display: flex; justify-content: flex-end;">
  <el-pagination
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
    v-bind="paginationProps"
  />
</div>
      
data() {
  return {
    page: this.pagination?.['current-page'] || 1, // 分页
    pageSize: this.pagination?.['page-size'] || 10, // 页数
  }
},
computed() {
  paginationProps() { // 分页配置项
    return {
      ...this.pagination,
      'current-page': this.page || this.pagination['current-page'],
      'page-size': this.pageSize || this.pagination['page-size'],
    }
  }
},
methods: {
  /**
   * @desc 监听 page-size 更新
   */
  handleSizeChange(pageSize) {
    this.pageSize = pageSize
  },
  /**
   * @desc 监听 current-page 更新
   */
  handleCurrentChange(page) {
    this.page = page
  }
}

image.png 现在进行查询、重置方法调用。代码如下:

methods: {
/**
   * @desc 获取查询参数
   */
  getParams() {
    const { page, pageSize } = this
    return {
      ...this.modelForm,
      page,
      pageSize
    }
  },
  /**
   * @desc 重置
   */
  handleReset() {
    this.$refs.formRef.resetFields()
    this.$nextTick(() => {
      this.page = this.pagination?.['current-page'] || 1
      this.pageSize = this.pagination?.['page-size'] || 10

      this.$emit('on-params', {
        ...this.initValue(),
        page: this.page,
        pageSize: this.pageSize
      })
    })
  },
  /**
   * @desc 查询
   */
  handleSearch() {
    this.$emit('on-params', this.getParams())
  },
  /**
   * @desc 监听 page-size 更新
   */
  handleSizeChange(pageSize) {
    this.$emit('on-params', { ...this.getParams(), pageSize })
    this.pageSize = pageSize
  },
  /**
   * @desc 监听 current-page 更新
   */
  handleCurrentChange(page) {
    this.$emit('on-params', { ...this.getParams(), page })
    this.page = page
  }
}

结合列表数据 dataSource、加载状态 loading、总条数字段 total 渲染数据。

<el-table ref="tableRef" v-bind="table" :data="dataSource" v-loading="loading" v-on="tableEvents">
    <el-table-column v-for="column in columns" v-bind="column" :key="column.prop"></el-table-column>
</el-table>
<pro-table @on-params="getParams" />

methods: {
    /**
     * @desc 查询
     * @param {params} Object
     */
    getParams(params) {
      // 自定义排序关注 prop, order 字段
      this.loading = true

      console.log('params', params)

      setTimeout(() => {
        let data = []
        for (let i = 0; i < 10; i++) {
          data.push(
            {
              name: '标题' + Math.random().toString().slice(2, 5),
              amount: Math.random().toString().slice(2, 5),
              state: i % 2 === 0 ? 'all' : 'open',
              label: '标签一/标签1-1' + i,
              date: '2025-4-30'
            }
          )
        }
        this.dataSource = data
        this.total = 10

        this.loading = false
      }, 500)
    }
}

效果如下:

image.png 现在来处理一下自定义项,我们先定义一个操作项,然后添加按钮。我们在 columns 定义一个 render 函数,要实现自定义渲染,我们会想到 vue render 函数,但是对于复杂组件很难编码和阅读,而我们可以使用 react 的写法 jsx

{
  label: '操作',
  prop: 'operation',
  width: 280,
  fixed: 'right',
  render: (h, { $index, prop }) => {
    return (
      <div>
        <el-button type="text" size="small" onClick={() => this.handleUpdate($index, prop)}>修改</el-button>
        <el-button type="text" size="small">删除</el-button>
      </div>
    )
  }
}

这里添加了不同的属性,修改一下代码才能正常运行,首先我们需要筛选 formOptions 和 getOptions 的修改。

image.png

image.png

我们先定义一个 CustomTableRender 组件,代码如下:

<script>
  export default {
    name: 'CustomTableColumn',
    props: {
      scope: {
        type: Object,
      },
      render: {
        type: Function,
        required: true
      }
    },
    render: function (h) {
      return this.render(h, { ...this.scope })
    }
  }
</script>

然后在 pro-table 组件中条件渲染,代码如下:

<el-table ref="tableRef" v-bind="table" :data="dataSource" v-loading="loading" v-on="tableEvents">
  <el-table-column v-for="column in columns" v-bind="column" :key="column.prop">
    <template v-if="column.render" slot-scope="scope">
      <custom-table-column :scope="{ ...scope, prop: column.prop }" :render="column.render" />
    </template>
  </el-table-column>
</el-table>

import CustomTableColumn from './custom-table-column.vue'

components: {
  CustomTableColumn,
},

效果如下,Oops! 条件渲染竟然无效。

image.png 好吧,这下...

如果不生效,那原因大概率是权重的原因,那么我们可以在 el-table-column 进行条件渲染。

<el-table ref="tableRef" v-bind="table" :data="dataSource" v-loading="loading" v-on="tableEvents">
  <template v-for="column in columns">
    <el-table-column v-if="column.render" v-bind="column" :key="column.prop">
      <template slot-scope="scope">
        <custom-table-column :scope="{ ...scope, prop: column.prop }" :render="column.render" />
      </template>
    </el-table-column>
    <el-table-column v-else v-bind="column" :key="column.prop"></el-table-column>
  </template>
</el-table>

效果如下,perfect!

image.png

结尾

组件介绍就大致到这里了,里面还有很多细节需要完善,但是拓展也是有迹可循的。

完整实现

本人能力有限,编码可能存在错误,如果发现烦请指出!

export default {
  form: { // el-form 的配置(不含 el-form-item 的配置)
    inline: false, // 行内表单模式
    'label-position': 'right', // 表单域标签的位置,如果值为 left 或者 right 时,则需要设置 label-width
    'label-width': '80px', // 表单域标签的宽度,例如 '50px'。作为 Form 直接子元素的 form-item 会继承该值。支持 auto。
    size: 'medium', // 用于控制该表单内组件的尺寸
  },
  search: { // 搜索表单(false 表示不展示表单)
    searchText: '查询', // 查询按钮的文本
    resetText: '重置', // 重置按钮的文本
    labelWidth: '80px', // 按钮距离标签的宽度
    span: 8, // 配置查询表单的列数
    class: 'pro-table-form', // 封装的搜索 Form 的 class
  } | false,
  columns: [ // 列定义 el-table-column 的配置(含其它配置项)
    {
      label: '名称', // 显示的标题
      prop: 'name', // 对应列内容的字段名
      width: '180', // 对应列的宽度
      'min-width': '120', // 对应列的最小宽度,与 width 的区别是 width 是固定的,min-width 会把剩余宽度按比例分配给设置了 min-width 的列
      fixed: 'right', // 列是否固定在左侧或者右侧,true 标识固定在左侧
      sortable: false, // 对应列是否可以排序,如果设置为 custom,则代表用户希望远程排序,需要监听 Table 的 sort-change 事件
      resizable: true, // 对应列是否可以通过拖动改变宽度(需要在 el-table 上设置 border 属性为真)
      // eslint-disable-next-line no-unused-vars
      formatter: (row, column, cellValue, index) => {}, // 用来格式化内容
      'show-overflow-tooltip': false, // 当内容过长被隐藏时显示 tooltip
      align: 'right', // 表头对齐方式,若不设置该项,则使用表格的对齐方式
      // ...
      // 表单项配置
      fieldType: 'input', // el-component 取表单组件 el- 后名称
      order: 3, // 查询表单中的权重,权重大排序靠前
      fieldProps: { // 查询表单的 props,会透传给表单项,如果渲染出来的是 Input,就支持 Input 的所有 props,不支持方法传入
        placeholder: '请输入', //输入框占位文本
        maxlength: 10, // 原生属性,最大输入长度
        clearable: true, // 是否可清空
        options: [], // !!! 可支持 select 组件, 格式为 options 默认格式 !!!
      },
      valueEnum: { // 枚举 (只用于 select 组件,生成 options 且表格内数据自动回显,可以被 render 函数覆盖)
        value: 'label',
        value2: 'label2'
      },
      optionLoader: () => new Promise(), // 异步请求,返回值为 select/cascader options 数据类型
      // 优先级 fieldProps.options > valueEnum > optionLoader
      fieldEventProps: { // 查询表单的 event props
        blur: () => {},
        input: () => {}
      },
      formItemProps: { // 传递给 el-form-item 的配置
        label: '名称', // 标签文本(如果和上一级 label 字段相同,可省略)
        prop: 'name', // 表单域 model 字段,在使用 validate, resetFields 方法的情况下,该属性是必填的
        'label-width': '80px', // 表单域标签的宽度
      },
      render: () => <div></div>, // 支持 jsx、render 函数(支持 expand、数据内容)
      renderCustomHeader: () => <div></div>, // 支持 jsx、render 函数(自定义 header)
      hideInForm: false, // 在查询表单中不展示此项
      hideInTable: false, // 在查询表格中不展示此项
      initialValue: 'Hello World', // 默认值
    }
  ],
  table: { // 参考 el-table attributes
    size: 'small', // Table 的尺寸
    border: false, // 是否带有纵向边框
    'row-key': 'id', // 行数据的 Key,用来优化 Table 的渲染;在使用reserve-selection 功能与显示树形数据时,该属性是必填的。
  },
  tableEvents: { // 参考 el-table events
    'sort-change': () => {}, // 监听字段排序
  },
  pagination: { // el-pagination 的配置
    'current-page': 1, // 当前分页(默认 1)
    'page-size': 10, // 每页显示条目个数(默认 10)
    'page-sizes': [10, 20, 30, 50], // 每页显示个数选择器的选项设置
  },
  total: 0, // 总条数
  dataSource: [], // 列表数据
  manualRequest: true, // 是否需要手动触发首次请求(默认为 true)
  loading: false, // 列表显示加载状态
}
<template>
  <div class="pro-table-container">
    <div class="search-wrapper">
    <el-form v-if="search" ref="formRef" :model="modelForm" v-bind="formProps" :class="searchProps.class">
      <el-row type="flex" style="flex-wrap: wrap">
        <el-col :span="searchProps.span" v-for="column in formColumns" :key="column.formItemProps.prop">
          <el-form-item v-bind="column.formItemProps">
            <component :is="`el-${column.fieldType}`" v-model="modelForm[column.formItemProps.prop]" v-bind="column.fieldProps" v-on="column.fieldEventProps">
              <template v-if="column.fieldType === 'select'">
                <el-option v-for="option in column.fieldProps.options" :value="option.value" :label="option.label" :key="option.value" :disabled="option.disabled"></el-option>
              </template>
            </component>
          </el-form-item>
        </el-col>
        <el-col :span="searchProps.span" key="btn">
          <el-form-item :label-width="searchProps.labelWidth">
            <el-button type="primary" icon="el-icon-search" @click="handleSearch">{{ searchProps.searchText }}</el-button>
            <el-button icon="el-icon-refresh" @click="handleReset">{{ searchProps.resetText }}</el-button>
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
    </div>
    <!-- 按钮 -->
    <!-- start -->
    <slot name="button-group"></slot>
    <!-- end -->
    <el-table ref="tableRef" v-bind="tableProps" v-on="tableEventProps" :data="dataSource" v-loading="loading">
      <template v-for="column in columnProps">
        <el-table-column v-if="column.render" v-bind="column" :key="column.prop">
          <template slot="header" slot-scope="scope">
            <custom-table-column-header :scope="{ ...scope, prop: column.prop }" :render="column.renderCustomHeader" />
          </template>
          <template slot-scope="scope">
            <custom-table-column :scope="{ ...scope, prop: column.prop }" :render="column.render" />
          </template>
        </el-table-column>
        <el-table-column v-else-if="column.valueEnum" v-bind="column" :key="column.prop">
          <template slot-scope="scope">
            <span>{{ column.valueEnum[scope.row[column.prop]] }}</span>
          </template>
        </el-table-column>
        <el-table-column v-else v-bind="column" :key="column.prop">
          <template slot="header" slot-scope="scope">
            <custom-table-column-header :scope="{ ...scope, prop: column.prop }" :render="column.renderCustomHeader" />
          </template>
        </el-table-column>
      </template>
    </el-table>
    <div style="margin: 16px 0; display: flex; justify-content: flex-end;">
      <el-pagination
        v-if="paginationProps.total > 0"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
        v-bind="paginationProps"
      />
    </div>
  </div>
</template>

<script>
  import CustomTableColumn from './render.vue'
  import CustomTableColumnHeader from './custom-render-header.vue'

  // --------------------------------------------------
  // 配置项参考 @/components/pro-table/api.js
  // --------------------------------------------------
  export default {
    name: 'ProTable',
    components: {
      CustomTableColumn,
      CustomTableColumnHeader
    },
    props: {
      form: { // el-form 的配置
        type: Object,
      },
      search: { // 搜索表单
        type: [Object, Boolean],
      },
      columns: { // 列定义
        type: Array,
        required: true
      },
      table: { // 表格属性
        type: Object
      },
      tableEvents: { // 表格事件
        type: Object
      },
      pagination: { // 分页
        type: Object
      },
      total: { // 总条数
        type: Number,
        required: true,
        default: 0
      },
      dataSource: { // 列表数据
        type: Array,
        required: true,
        default: () => []
      },
      manualRequest: { // 是否需要手动触发首次请求
        type: Boolean,
        default: true
      },
      loading: { // 加载中...
        type: Boolean,
        default: false
      }
    },
    computed: {
      formProps() { // el-form 的配置,添加默认值
        const { size = 'small' } = this.form
        return { size, 'label-width': '80px', ...this.form }
      },
      searchProps() { // 搜索表单,添加默认值
        if (this.search) {
          const { searchText = '查询', resetText = '重置', labelWidth = '80px', span = 8, } = this.search
          return { searchText, resetText, labelWidth, span, class: 'pro-table-form', ...this.search }
        }

        return this.search
      },
      formColumns() { // 表单配置项
        return this.columns.filter(column => !!column.fieldType && !column.hideInForm)
          .sort((a, b) => a.order && b.order ? b.order - a.order : a.order ? -1 : b.order ? 1 : 0)
          .map(column => {
            const { label, prop, fieldType, fieldProps = {}, formItemProps = {} } = column
            // 更新标签
            formItemProps.label = formItemProps.label || label
            // 更新 prop
            formItemProps.prop = formItemProps.prop || prop
            // 更新 placeholder
            if (!fieldProps.placeholder) {
              switch (fieldType) {
                case 'input':
                case 'input-number':
                  fieldProps.placeholder = `请输入${label}`
                  break;
                case 'select':
                case 'time-picker':
                case 'date-picker':
                case 'cascader':
                  fieldProps.placeholder = `请选择${label}`
                  break;
                default:
                  break;
              }
            }

            // 更新 select/cacader options
            if (['select', 'cascader'].includes(column.fieldType)) {
              const { valueEnum, fieldProps: { options }, optionLoader  } = column

              if (!options) {
                if (valueEnum) {
                  fieldProps.options = Object.entries(valueEnum).map(([key, value]) => ({ label: value, value: key }))
                } else if (optionLoader) {
                  // this.options 更新后会重新计算
                  fieldProps.options = this.options[formItemProps.prop]
                }
              }
            }
            
            return {
              ...column,
              fieldProps,
              formItemProps
            }
          })
      },
      columnProps() { // 列属性
        return this.columns.filter(column => !column.hideInTable)
      },
      tableProps() { // 表格属性
        const { size = 'medium' } = this.table

        return {
          size,
          ...this.table
        }
      },
      tableColumns() { // 表格配置项
        return this.columns.filter(column => !column.hideInForm)
          .map(column => {
            return column
          })
      },
      paginationProps() { // 分页配置项
        return {
          'page-sizes': [10, 20, 30, 50],
          layout: "total, sizes, prev, pager, next, jumper",
          ...this.pagination,
          'current-page': this.page || this.pagination['current-page'],
          'page-size': this.pageSize || this.pagination['page-size'],
          total: this.total
        }
      }
    },
    data() {
      return {
        modelForm: this.initValue(), // 表单数据
        options: {}, // 列表异步数据
        tableEventProps: { // 表格事件属性
          'sort-change': this.sortChange,
          ...this.tableEvents
        },
        page: this.pagination?.['current-page'] || 1, // 分页
        pageSize: this.pagination?.['page-size'] || 10, // 页数
      }
    },
    created () {
      // 是否需要手动触发请求
      if (this.manualRequest) {
        this.handleSearch()
      }
      this.getOptions() // 获取异步数据
    },
    methods: {
      /**
       * @desc 获取初始值
       */
      initValue() {
        // 获取初始值
        return this.columns.filter(column => {
          return column.initialValue
        }).reduce((accu, cur) => {
          const { prop: columnProp, formItemProps = {} } = cur
          const { prop } = formItemProps
          return {
            ...accu,
            [prop || columnProp]: cur.initialValue
          }
        }, {})
      },
      /**
       * @desc 获取异步数据
       */
      async getOptions() {
        for (const column of this.formColumns) {
          const { formItemProps: { prop }, optionLoader, valueEnum, fieldProps: { options } } = column
          if (!options && !valueEnum && typeof optionLoader === 'function') {
            optionLoader().then(res => {
              this.options = {
                ...this.options,
                [prop]: res
              }
            })
          }
        }
      },
      /**
       * @desc 获取查询参数
       */
      getParams() {
        const { page, pageSize } = this
        return {
          ...this.modelForm,
          page,
          pageSize
        }
      },
      /**
       * @desc 重置
       */
      handleReset() {
        this.$refs.formRef.resetFields()
        this.$nextTick(() => {
          this.page = this.pagination?.['current-page'] || 1
          this.pageSize = this.pagination?.['page-size'] || 10

          this.$emit('on-params', {
            ...this.initValue(),
            page: this.page,
            pageSize: this.pageSize
          })
        })
      },
      /**
       * @desc 查询
       */
      handleSearch() {
        this.$emit('on-params', this.getParams())
      },
      /**
       * @desc 自定义排序
       */
      sortChange({ prop, order }) {
        this.$emit('on-params', { ...this.getParams(), prop, order })
      },
      /**
       * @desc 监听 page-size 更新
       */
      handleSizeChange(pageSize) {
        this.pageSize = pageSize
        // 会自动触发 current-change, 所以防止接口多次调用
        if (pageSize * this.page > this.total && this.page !== 1) {
          return
        }

        this.$emit('on-params', { ...this.getParams(), pageSize })
      },
      /**
       * @desc 监听 current-page 更新
       */
      handleCurrentChange(page) {
        this.$emit('on-params', { ...this.getParams(), page })
        this.page = page
      }
    },
  }
</script>

<style scoped>
  .el-form-item__content .el-select,
  .el-form-item__content .el-input-number,
  .el-form-item__content .el-date-editor,
  .el-form-item__content .el-cascader
  {
    width: 100%;
  }
</style>

<script>
  export default {
    name: 'CustomTableColumnHeader',
    props: {
      scope: {
        type: Object,
      },
      render: {
        type: Function || undefined,
      }
    },
    render: function (h) {
        return this.render ? this.render(h, { ...this.scope }) : <span>{ this.scope.column.label }</span>
    }
  }
</script>
<script>
  export default {
    name: 'CustomTableColumn',
    props: {
      scope: {
        type: Object,
      },
      render: {
        type: Function,
        required: true
      }
    },
    render: function (h) {
      return this.render(h, { ...this.scope })
    }
  }
</script>
<template>
  <div>
    <pro-table 
      ref="proTableRef"
      :form="form"
      :search="search"
      :columns="columns"
      :table="table"
      :tableEvents="tableEvents"
      :pagination="pagination"
      :total="total"
      :data-source="dataSource"
      :loading="loading"
      @on-params="getParams"
    >
      <!-- 操作按钮 -->
      <!-- start -->
      <template v-slot:button-group>
        <div class="button-group-wrapper" style="margin-bottom: 18px;">
          <el-button type="primary" icon="el-icon-plus" size="small">新增</el-button>
          <el-button size="small" icon="el-icon-download">导出</el-button>
        </div>
      </template>
      <!-- end -->
    </pro-table>    
  </div>
</template>

<script>
import ProTable from './components/pro-table'

export default {
  name: 'App',
  components: {
    ProTable,
  },
  computed: {
    columns() { // 列配置
      return [
        {
          type: 'selection',
          width: 50
        },
        {
          type: 'expand',
          render: (h, scope) => {
            return (
              <el-form label-position="left" inline class="demo-table-expand">
                <el-form-item label="名称">
                  <span>{ scope.row.name }</span>
                </el-form-item>
                <el-form-item label="名称2">
                  <span>{ scope.row.name2 }</span>
                </el-form-item>
                <el-form-item label="名称3">
                  <span>{ scope.row.name3 }</span>
                </el-form-item>
              </el-form>
            )
          }
        },
        {
          label: '标题',
          prop: 'name',
          width: 240,
          fieldType: 'input',
          fieldProps: {
            clearable: true,
          },
          fieldEventProps: {
            input: this.handleInput
          },
          initialValue: 'Hello',
          renderCustomHeader: (h, { column }) => {
            return (
              <span>
                { column.label }
                <el-tooltip class="item" effect="dark" content="这是一段提示" placement="right">
                  <i class="el-icon-info" style="margin-left: 4px;" />
                </el-tooltip>
              </span>
            )
          }
        },
        {
          label: '标题2',
          prop: 'name2',
          width: 240,
          fieldType: 'input',
          fieldProps: {
            clearable: true,
          },
          order: 1
        },
        {
          label: '标题3',
          prop: 'name3',
          width: 240,
          fieldType: 'input',
          fieldProps: {
            clearable: true,
          },
          align: 'right',
          order: 2
        },
        {
          label: '标题4',
          prop: 'name4',
          width: 240,
          fieldType: 'input',
          fieldProps: {
            clearable: true,
          },
          order: 2,
          'show-overflow-tooltip': true,
        },
        {
          label: '数量',
          prop: 'amount',
          width: 180,
          sortable: 'custom',
          fieldType: 'input-number',
          fieldProps: {
            'controls-position': 'right'
          },
          formatter: (row, column, cellValue) => {
            return `${cellValue}个`
          },
          initialValue: 10
        },
        {
          label: '状态',
          prop: 'state',
          width: 180,
          fieldType: 'select',
          fieldProps: {
            placeholder: '请选择角色!!!',
            clearable: true,
            // options: [
            //   { label: '全部', value: 'all' },
            //   { label: '未解决', value: 'open' },
            // ]
          },
          valueEnum: {
            all: '全部',
            open: '未解决',
            closed: '已解决',
            processing: '解决中'
          },
          // optionLoader: () => new Promise(resolve => setTimeout(() => resolve([
          //     { label: '全部', value: 'all' },
          //     { label: '未解决', value: 'open' },
          //   ]), 3000)),
          initialValue: 'all'
        },
        {
          label: '标签',
          prop: 'label',
          width: 320,
          fieldType: 'cascader',
          fieldProps: {
            // options: [
            //   { label: '标签一', value: 'label', children: [{ label: '标签1-1', value: 'label-1' }]},
            //   { label: '标签二', value: 'label2', children: [{ label: '标签2-1', value: 'label-2' }]}
            // ]
          },
          optionLoader: () => new Promise(resolve => setTimeout(() => resolve([
              { label: '标签一', value: 'label', children: [{ label: '标签1-1', value: 'label-1' }]},
            ]), 1000)),
          formItemProps: {
            label: '标签111',
            prop: 'label111',
          },
          initialValue: ['label', 'label-1']
        },
        {
          label: '日期',
          prop: 'date',
          width: 180,
          fieldType: 'date-picker',
          fieldProps: {
            type: "daterange",
            'range-separator': "至",
            'start-placeholder': "开始日期",
            'end-placeholder': "结束日期",
            'default-time': ['00:00:00', '23:59:59'],
            'value-format': 'yyyy-MM-dd HH:mm:ss',
          },
          initialValue: [new Date('2025-5-3 00:00:00'), new Date('2025-5-3 23:59:59')], // 初始值
        },
        {
          label: '操作',
          prop: 'operation',
          width: 280,
          fixed: 'right',
          render: (h, { $index, prop }) => {
            return (
              <div>
                <el-button type="text" size="small" onClick={() => this.handleUpdate($index, prop)}>修改</el-button>
                <el-button type="text" size="small">删除</el-button>
              </div>
            )
          }
        }
      ]
    }
  },
  data() {
    return {
      form: {
        'label-width': '120px',
      },
      search: {
        labelWidth: '120px',
        span: 6,
        class: 'pro-table-demo-form',
      },
      table: {
      },
      tableEvents: {
        'selection-change': this.handleSelectionChange
      },
      pagination: {},
      total: 0, // 总条数
      dataSource: [], // 列表数据
      loading: false, // 加载中...
    }
  },
  mounted () {
  },
  methods: {
    /**
     * @desc 查询
     * @param {params} Object
     */
    getParams(params) {
      const { date } = params
      let [startDate, endDate] = date || []
      if (typeof startDate === 'object') { // new Date
        startDate = startDate.toLocaleString()
        endDate = endDate.toLocaleString()
      }

      console.log('========================')
      console.log('params', { ...params, startDate, endDate })
      console.log('========================')

      // 自定义排序关注 prop, order 字段

      this.loading = true

      setTimeout(() => {
        let data = []
        for (let i = 0; i < 10; i++) {
          data.push(
            {
              name: '标题' + Math.random().toString().slice(2, 5),
              name2: '标题2' + Math.random().toString().slice(2, 5),
              name3: '标题3' + Math.random().toString().slice(2, 5),
              name4: '这是一段很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长的标题' + Math.random().toString().slice(2, 5),
              amount: Math.random().toString().slice(2, 5),
              state: i % 2 === 0 ? 'all' : 'open',
              label: '标签一/标签1-1' + i,
              date: '2025-4-30'
            }
          )
        }
        this.dataSource = data
        this.total = 10

        this.loading = false
      }, 500)
    },
    /**
     * @desc 监听输入
     */
    handleInput(event) {
      console.log('==========================')
      console.log('event input', event)
      console.log('==========================')
    },
    /**
     * @desc 点击修改
     */
    handleUpdate(index, prop) {
      console.log('====================')
      console.log('index', index, 'prop', prop)
      console.log('====================')
    },
    /**
     * @desc 监听 selection
     */
    handleSelectionChange(val) {
      console.log('====================')
      console.log('val', val)
      console.log('====================')
    }
  },
}
</script>

<style>
</style>

完整效果:

image.png