Vue3封装一个非常简单的表格,带表单搜索和分页

533 阅读6分钟

效果

  1. 包含了表单搜索、表格、分页
  2. 可以修改表格slots,不影响el-table原始的方法、属性
  3. 表格上面可以添加按钮

image.png

代码

image.png

表格列配置
export const columns = [
  { type: 'index', label: '序号', width: 100, align: 'center' },
  {
    type: 'selection',
    width: 55
  },
  {
    prop: 'name',
    label: '模型名称',
    width: 200
  },
  {
    prop: 'identification',
    label: '创建方式'
  },
  {
    prop: 'topModel',
    label: '故障模式',
    width: 200
  },
  {
    prop: 'createDate',
    label: '最后更新时间',
    width: 200
  },
  {
    prop: 'user',
    label: '修改人',
    width: 120
  },
  {
    prop: 'publishStatus',
    label: '模型状态'
  },
  {
    label: '操作',
    fixed: 'right',
    width: 200,
    slots: 'operator'
  }
]

列的类型,可以通过children生成多级表头

image.png

表单配置

image.png

时间范围的输入框,v-model绑定的是一个数组,propArray代表搜索的时候接口传入的参数

表格的属性方法

在我们的自定义组件上可以直接传el-table原生的属性和事件 image.png

表格数据获取

getListApi传入接口,defaultParams是接口默认的参数

image.png

image.png useCustomTable方法有两个部分,一个是获取表格接口,然后导出了表格数据,分页,loading等信息 还有就是生成表格的DOM对象,表单分页的操作在useTableDom里,然后传入了tableObject,分页修改时,tableObject的分页信息变化,useTableApi就会重新获取接口

image.png

表单数据传入的是useTableApi的响应式数据

image.png

使用createVNode的方式导出el-table, 在父组件就可以直接给Table传属性和获取Table的事件

image.png

多级表头通过递归来实现

组件的全部代码

目录结构

image.png


export interface ColumnsType {
  type?: 'selection' | 'expand' | 'index' | string
  prop?: string
  label?: string | '操作'
  width?: number
  minWidth?: number
  visible?: boolean
  fixed?: 'left' | 'right' | string
  align?: 'left' | 'right' | 'center' | string
  children?: ColumnsType[]
  slots?: string
}

interface UseCustomTableConfig {
  api: ApiConfig
  columns: ColumnsType[]
  form?: FormConfig
  slots?: {
    [key: string]: (arg: any) => VNode
  }
  toolbar?: () => VNode
}

interface ApiConfig {
  getListApi: (option: any) => Promise<any>
  defaultParams?: any
}

import { FormConfig } from './useForm'

import { useTableApi } from './useTableApi'

import { useTableDom } from './useTableDom'

import { onMounted, VNode } from 'vue'

export const useCustomTable = (config: UseCustomTableConfig) => {
  const { getListApi, defaultParams } = config.api
  const { columns, form, slots, toolbar } = config
  const { getList, tableObject } = useTableApi({
    getListApi: getListApi,
    defaultParams: defaultParams
  })

  onMounted(getList)

  const { Table } = useTableDom({
    tableObject: tableObject,
    columns: columns,
    getList: getList,
    form: form,
    slots: slots,
    toolbar: toolbar
  })

  return {
    getList,
    tableObject,
    Table
  }
}

/*
 * @Author: zengzhaoyan
 * @Date: 2023-12-13 11:09:13
 * @LastEditors: zengzhaoyan
 * @LastEditTime: 2023-12-15 16:36:22
 * @Description: 表单组件
 * @FilePath: /zzy/src/hooks/useCustomTable/useForm.ts
 */

import {
  ElForm,
  ElFormItem,
  ElSwitch,
  ElInput,
  ElInputNumber,
  ElCheckbox,
  ElCheckboxGroup,
  ElRadioGroup,
  ElRadio,
  ElSelect,
  ElOption,
  ElDatePicker
} from 'element-plus'
import { createVNode, reactive, ref, VNode, Ref } from 'vue'

export interface ObjectForm {
  loading?: boolean
  type?:
    | 'switch'
    | 'textarea'
    | 'number'
    | ''
    | 'text'
    | 'checkbox'
    | 'radio'
    | 'multipleselect'
    | 'select'
    | 'datetime'
    | 'daterange'
    | 'upload'
    | 'group'
    | 'password'
    | string
  rule?: rule[]
  hidden?: boolean
  prop: string
  propArray?: [string, string] | undefined // 代表v-model绑定一个数组
  label: string
  disabled?: boolean
  strictly?: number
  multiple?: boolean
  stepStrictly?: number
  placeholder?: string
  width?: string
  clearable?: boolean
  filterable?: boolean
  defaultProp?: { label: string; value: string }
  maxlength?: number
  options?: Array<{ label: string; value: string | number }>
  valueFormat?: string // 时间值的格式化
  format?: string // 时间显示的格式化
  remoteMethod?: (value: string, callback: (list: any[]) => void) => any
  disabledDate?: (time: any) => boolean
  apiFun?: (params: any) => Promise<any>
  params?: any
  slots?: (arg: any) => VNode
}

export type rule = {
  validator?: any
  required?: boolean
  message?: string
  pattern?: RegExp
  trigger?: 'blur' | 'change' | string
}

type RulesType = Record<string, Array<rule>>

export interface FormConfig {
  itemWidth?: string
  labelWidth?: string
  inline?: boolean
  labelPosition?: string | 'top' | 'left' | 'right'
  disabledAll?: boolean
  rules?: RulesType
  item: ObjectForm[]
}

interface FormProps {
  disabledAll?: boolean
  labelWidth?: string
  labelPosition?: string
  itemWidth?: string
  inline?: boolean
}

export const useForm = (
  formConfig: FormConfig | undefined,
  formData: { [key: string]: any },
  append?: () => VNode
) => {
  const formValue =
    reactive(JSON.parse(JSON.stringify(formData))) || reactive({})

  const rules = (formConfig && formConfig.rules) || {}
  const setRules = (item: ObjectForm) => {
    if (item.rule && !item.hidden) {
      rules[item.prop] = item.rule
    }
  }

  const initDefault = async () => {
    if (!formConfig || !formConfig.item.length) return
    for (let i = 0; i < formConfig.item.length; i++) {
      const item = formConfig.item[i]
      if (item.apiFun) {
        item.loading = true
        const res = await item.apiFun(item.params)
        item.loading = false
        item.options = res.data
      }
      setRules(item)
    }
  }
  initDefault()

  const formRef = ref()

  const resetFields = () => {
    formRef.value.resetFields()
  }

  const getValue = async () => {
    if (Object.keys(rules).length > 0) {
      const res = await formRef.value.validate()
      if (res) {
        return formValue
      }
      return null
    }
    return formValue
  }

  const props: Ref<FormProps> = ref({})

  const MyForm = (propss: any, context: any) => {
    if (!formConfig || !formConfig.item.length) return createVNode('')
    props.value = propss
    return createVNode(
      ElForm,
      {
        ref: formRef,
        ...context.attrs,
        model: formValue,
        rules: rules,
        inline: true,
        ...createFormAttrs()
      },
      [createItem(), appendDom()]
    )
  }

  const createFormAttrs = () => {
    if (!formConfig || !formConfig.item.length) return createVNode('')
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { item, rules, ...rest } = formConfig
    return rest
  }

  const createItem = () => {
    if (!formConfig || !formConfig.item.length) return
    return formConfig.item.map((item: ObjectForm) => {
      return createVNode(
        ElFormItem,
        {
          label: item.label,
          prop: item.prop,
          rules: item.rule
        },
        [createItemInput(item)]
      )
    })
  }

  const appendDom = () => {
    return createVNode(ElFormItem, {}, [append && append()])
  }

  const createItemInput = (item: ObjectForm) => {
    if (!formConfig || !formConfig.item.length) return
    if (item.slots) {
      return item.slots(item)
    }
    const defaultAttrs = {
      modelValue: formValue[item.prop],
      'onUpdate:modelValue': (newValue: any) => {
        formValue[item.prop] = newValue
        if (item.propArray) {
          formValue[item.propArray[0]] = newValue[0]
          formValue[item.propArray[1]] = newValue[1]
        }
      },
      disabled: item.disabled,
      style: {
        width: item.width ? item.width : formConfig.itemWidth
      }
    }
    const isCheckboxOrRadio = item.type
      ? ['checkbox', 'radio'].includes(item.type)
      : false
    if (item.type === 'switch') {
      return createVNode(ElSwitch, {
        ...defaultAttrs
      })
    } else if (item.type === 'textarea') {
      return createVNode(ElInput, {
        type: 'textarea',
        ...defaultAttrs
      })
    } else if (item.type === 'number') {
      return createVNode(ElInputNumber, {
        ...defaultAttrs,
        'controls-position': 'right',
        'step-strictly': item.strictly
      })
    } else if (isCheckboxOrRadio) {
      const options = item.options?.map((option: any) =>
        createVNode(
          item.type === 'checkbox' ? ElCheckbox : ElRadio,
          { label: option.value },
          [option.label]
        )
      )
      const Component =
        item.type === 'checkbox' ? ElCheckboxGroup : ElRadioGroup
      return createVNode(Component, { ...defaultAttrs }, options)
    } else if (item.type === 'text' || !item.type || item.type === 'password') {
      return createVNode(ElInput, {
        ...defaultAttrs,
        placeholder: item.placeholder
          ? item.placeholder
          : `请输入${item.label}`,
        maxlength: item.maxlength ? item.maxlength : null,
        type: item.type === 'password' ? 'password' : 'text',
        'auto-complete': 'off',
        'show-word-limit': true
      })
    } else if (item.type === 'select') {
      return createVNode(
        ElSelect,
        {
          ...defaultAttrs,
          clearable: item.clearable || true,
          multiple: item.multiple,
          filterable: item.filterable,
          'default-first-option': true,
          'v-loading': item.loading,
          remoteMethod: async (value: any) => {
            if (item.remoteMethod) {
              item.loading = true
              item.remoteMethod(value, (list) => {
                item.options = reactive(list)
                item.loading = false
              })
            }
          }
        },
        item.options?.map((option: any, index: number) => {
          return createVNode(ElOption, {
            key: index,
            label: item.defaultProp
              ? option[item.defaultProp.label]
              : option.label,
            value: item.defaultProp
              ? option[item.defaultProp.value]
              : option.value,
            disabled: option.disabled ? option.disabled : false
          })
        })
      )
    } else if (item.type === 'datetime') {
      return createVNode(ElDatePicker, {
        ...defaultAttrs,
        format: item.format || 'YYYY-MM-DD HH:mm:ss',
        'value-format': item.valueFormat,
        placeholder: '选择日期时间',
        'disabled-date': item.disabledDate
      })
    } else if (item.type === 'daterange') {
      return createVNode(ElDatePicker, {
        type: 'daterange',
        ...defaultAttrs,
        format: item.format || 'YYYY-MM-DD HH:mm:ss',
        'value-format': item.valueFormat,
        'range-separator': '至',
        'start-placeholder': '开始日期',
        'end-placeholder': '结束日期'
      })
    }
  }

  return {
    MyForm,
    getValue,
    resetFields,
    formRef
  }
}

/*
 * @Author: zengzhaoyan
 * @Date: 2023-12-15 09:55:28
 * @LastEditors: zengzhaoyan
 * @LastEditTime: 2023-12-15 16:31:26
 * @Description:
 * @FilePath: /zzy/src/hooks/useCustomTable/usePagination.tsx
 */

import { createVNode } from 'vue'
import { ElPagination } from 'element-plus'

import { TableObject } from './useTableApi'

export const usePagination = (tableObject: TableObject) => {
  const pagination = () => {
    return createVNode(ElPagination, {
      vShow: tableObject.total > 0,
      style: 'margin: 10px 10px 0 0; flex: 0 0 auto; align-self: flex-end;',
      background: true,
      layout: 'total, sizes, prev, pager, next, jumper',
      'v-model:currentPage': tableObject.page,
      'v-model:pageSize': tableObject.limit,
      total: tableObject.total,
      'page-sizes': [10, 20, 30, 50, 100],
      onSizeChange: handleSizeChange,
      onCurrentChange: handleCurrentChange
    })
  }

  const handleSizeChange = (val: number) => {
    tableObject.limit = val
    tableObject.page = 1
  }
  const handleCurrentChange = (val: number) => {
    tableObject.page = val
  }

  return {
    pagination
  }
}

/*
 * @Author: zengzhaoyan
 * @Date: 2023-12-15 09:26:01
 * @LastEditors: zengzhaoyan
 * @LastEditTime: 2023-12-15 17:04:07
 * @Description:
 * @FilePath: /zzy/src/hooks/useCustomTable/useTableApi.ts
 */

import { reactive, unref, computed, watch } from 'vue'

interface UseTableConfig<T> {
  getListApi: (option: any) => Promise<T>
  defaultParams?: any
}

export interface TableObject<T = any> {
  limit: number
  page: number
  total: number
  tableList: T[]
  params: any
  loading: boolean
  exportLoading: boolean
  currentRow: any
}

interface ResponseType<T = any> {
  data: T[]
  statusCode: number
  total: number
  page: {
    dataCount: number
  }
}

export const useTableApi = <T = any>(config?: UseTableConfig<T>) => {
  const tableObject = reactive<TableObject<T>>({
    // 页数
    limit: 10,
    // 当前页
    page: 1,
    // 总条数
    total: 10,
    // 表格数据
    tableList: [],
    // AxiosConfig 配置
    params: {
      ...(config?.defaultParams || {})
    },
    // 加载中
    loading: true,
    // 导出加载中
    exportLoading: false,
    // 当前行的数据
    currentRow: null
  })

  const paramsObj = computed(() => {
    return {
      ...tableObject.params,
      limit: tableObject.limit,
      page: tableObject.page
    }
  })

  const methods = {
    getList: async () => {
      tableObject.loading = true
      const res = await config?.getListApi(unref(paramsObj)).finally(() => {
        tableObject.loading = false
      })
      console.log(res)
      if (res) {
        tableObject.tableList = (res as unknown as ResponseType).data
        if ((res as unknown as ResponseType).page.dataCount) {
          tableObject.total = (res as unknown as ResponseType).page.dataCount
        }
      }
    }
  }

  watch(
    () => tableObject.page,
    () => {
      methods.getList()
    }
  )

  watch(
    () => tableObject.limit,
    () => {
      // 当前页不为1时,修改页数后会导致多次调用getList方法
      if (tableObject.page === 1) {
        methods.getList()
      } else {
        tableObject.page = 1
        methods.getList()
      }
    }
  )

  return {
    methods,
    getList: methods.getList,
    tableList: tableObject.tableList,
    tableObject
  }
}

/*
 * @Author: zengzhaoyan
 * @Date: 2023-12-15 09:38:07
 * @LastEditors: zengzhaoyan
 * @LastEditTime: 2023-12-15 16:39:14
 * @Description:
 * @FilePath: /zzy/src/hooks/useCustomTable/useTableDom.tsx
 */

import { ColumnsType } from './index'

import { usePagination } from './usePagination'
import { useForm } from './useForm'

import { createVNode, VNode } from 'vue'

import { ElTable } from 'element-plus'

import { TableObject } from './useTableApi'

import { FormConfig } from './useForm'

interface TableConfig {
  tableObject: TableObject
  columns: ColumnsType[]
  getList: () => Promise<any>
  form?: FormConfig | undefined
  slots?: {
    [key: string]: (arg: any) => VNode
  }
  toolbar?: () => VNode
}

export const useTableDom = (config: TableConfig) => {
  const { tableObject, columns, getList, form, slots, toolbar } = config

  const append = () => {
    return (
      <>
        <el-button onClick={Cancel}>重置</el-button>
        <el-button type="primary" onClick={onSubmit}>
          确认
        </el-button>
      </>
    )
  }

  const onSubmit = () => {
    const searchValue: Promise<Record<string, any>> = getValue()
    searchValue.then((res) => {
      Object.assign(tableObject.params, res)
      getList()
    })
  }

  const Cancel = () => {
    resetFields()
    tableObject.params = {}
    getList()
  }

  const { MyForm, getValue, resetFields } = useForm(
    form,
    tableObject.params,
    append
  )

  const { pagination } = usePagination(tableObject)

  const renderTableColumn = (column: ColumnsType) => (
    <el-table-column
      {...createTableColumnConfig(column)}
      v-slots={{
        default: (scope: any) => {
          if (column.slots && slots) {
            return slots[column.slots](scope)
          }
        }
      }}
    >
      {column.children && column.children.map(renderTableColumn)}
    </el-table-column>
  )

  // el-table-column属性
  const createTableColumnConfig = (column: ColumnsType) => {
    return {
      type: column.type,
      prop: column.prop,
      label: column.label,
      width: column.width,
      minWidth: column.minWidth,
      fixed: column.fixed,
      align: column.align || 'center'
    }
  }

  const Table = (props: Record<string, any>) => {
    return createVNode(
      'div',
      {
        style: {
          height: '100%',
          'overflow-y': 'auto',
          display: 'flex',
          'flex-direction': 'column'
        }
      },
      [
        createVNode(MyForm, { style: { flex: '0 0 auto' } }),
        createToolBar(),
        createVNode(
          ElTable,
          {
            style: {
              flex: 1,
              height: '100%',
              'overflow-y': 'auto'
            },
            data: tableObject.tableList,
            'highlight-current-row': true,
            border: true,
            stripe: true,
            ...props,
            'v-loading': tableObject.loading
          },
          columns.map(renderTableColumn)
        ),
        pagination()
      ]
    )
  }

  const createToolBar = () => {
    if (toolbar) {
      return createVNode(
        'div',
        { style: 'flex: 0 0 auto;align-self: flex-end;padding-bottom:10px' },
        [toolbar()]
      )
    }
  }

  return {
    Table
  }
}

如何使用

image.png

可以将列配置和表单配置单独提取出来也可以写在一个文件中

import { defineComponent } from 'vue'
import { useCustomTable } from '@/hooks/useCustomTable'
import { listByPage } from '@/api/faultTree'
import { columns } from './columns.config'
import { formConfig } from './form.config'

export default defineComponent({
  setup() {
    const api = {
      getListApi: listByPage,
      defaultParams: {
        publishStatus: 0
      }
    }

    const toolbar = () => {
      return <el-button>导出</el-button>
    }

    const slots = {
      operator: () => {
        return <el-button>编辑</el-button>
      }
    }

    const { Table } = useCustomTable({
      api: api,
      columns: columns,
      form: formConfig,
      toolbar: toolbar,
      slots: slots
    })

    const onSelectionChange = (selection: any) => {
      console.log(selection)
    }

    return () => {
      return (
        <div style="padding: 20px 40px">
          <Table border={false} onSelectionChange={onSelectionChange} />
        </div>
      )
    }
  }
})

export const columns = [
  { type: 'index', label: '序号', width: 100, align: 'center' },
  {
    type: 'selection',
    width: 55
  },
  {
    prop: 'name',
    label: '模型名称',
    width: 200
  },
  {
    prop: 'identification',
    label: '创建方式'
  },
  {
    prop: 'topModel',
    label: '故障模式',
    width: 200
  },
  {
    prop: 'createDate',
    label: '最后更新时间',
    width: 200
  },
  {
    prop: 'user',
    label: '修改人',
    width: 120
  },
  {
    prop: 'publishStatus',
    label: '模型状态'
  },
  {
    label: '操作',
    fixed: 'right',
    width: 200,
    slots: 'operator'
  }
]

import { reactive } from 'vue'

export const formConfig = reactive({
  labelWidth: '120px',
  itemWidth: '200px',
  labelPosition: 'right',
  item: [
    {
      label: '模型名称',
      prop: 'name',
      type: 'text'
    },
    {
      label: '故障模式',
      prop: 'topName',
      type: 'text'
    },
    {
      label: '最后更新时间',
      prop: 'date',
      propArray: ['beginUpdateTime', 'endUpdateTime'] as [string, string],
      type: 'daterange',
      format: 'YYYY-MM-DD',
      valueFormat: 'YYYY-MM-DD HH:mm:ss'
    }
  ]
})