vue3 实现表格列拖拽排序、显示隐藏; 以及行内编辑表格自带校验

关注公众号: 微信搜索 前端工具人 ; 收货更多的干货

原文链接: 自己掘金文章 https://juejin.cn/post/7067039547091058696/

一、需求

  • 所有表格需根据用户自定义显示列、及列的显示顺序;
  • 支持左侧、右侧固定列、列宽修改
  • 行内编辑表格(新增、编辑)必填项自带校验(类似于form表单的校验拦截)
  • 效果图

5e2oy-b8ft5.gif

aarzi-iecpn.gif

二、方案

2.1 拖拽插件

vue-draggable-next

2.2 初期使用的是 element-plus 实现;

缺点:

  1. 当表格字段 40+ 及以上的时候, 表格卡顿,初始显示很慢;
  2. 表格涉及行展开操作时,也响应很慢;
  3. 版本 1.1.0-beta.18, 有点旧的版本, 因为该项目是去年中旬写的;最近 element-plus 大改升级稳定版,不知道修复没

列表列40+甚至有的页面60+, 确实少见夸张, 原因是表格支持导出, 导出要详细的

2.3 ant-design-vue 实现

  1. 使用 ant-design-vue 自带的 table 控件实现
  2. 使用 ant-design-vue 增强的 Surely Vue 高性能组件

文章使用的 1 , 因为 ant-design-vue 自带的 table 控件已经满足了需求, 考虑到 Surely Vue 组件需求单独引入就没尝试了

2.4 表格自带校验

  • 新增数据的时候是在表格内新增一行,有的列是必填项、有的是自带校验规则,比如只能输入数字、中文之类的校验
  • 编辑同上; 对 table 控件进行 二次封装 实现

三、代码片段

3.1 element-plus 版 自定义 table 组件

// CustomTable.vue
// 代码有删减
<template>
  <div class="custom-table-container">
    <div class="custom-buttons">
      <el-tooltip class="item" effect="dark" content="表格设置" placement="top-start">
        <el-button icon="el-icon-setting" circle @click="fixedVisible = true"></el-button>
      </el-tooltip>
      <el-tooltip class="item" effect="dark" content="表格排序" placement="top-start">
        <el-button icon="el-icon-sort" circle @click="showVisible = true"></el-button>
      </el-tooltip>
      <el-tooltip class="item" effect="dark" content="表格刷新" placement="top-start">
        <el-button icon="el-icon-refresh" circle @click="onRefresh"></el-button>
      </el-tooltip>
    </div>
    <div class="custom-content">
      <!-- row-key="id" :tree-props="{children: 'children', hasChildren: 'hasChildren'}" -->
      <el-table
        :data="tableData" center border align="left" style="width: 100%" @selection-change="handleSelectionChange"
        :row-key="rowId" lazy :load="loadChildren" :tree-props="{children: 'children', hasChildren: 'hasChildren'}">
        <el-table-column fixed="left" width="50" align="center" type="selection"> </el-table-column>
        <el-table-column fixed="left" width="50" align="center" label="序号" type="index"></el-table-column>
        <el-table-column fixed="left" width="50" align="center" label="展开" v-if="isChildren"></el-table-column>
        <el-table-column fixed="left" :width="oWidth" align="center" label="操作" v-if="isShowOperaRow">
          <template #default="{ row }">
            <slot name="operate" :scope="row" v-if="!row.isChildren"></slot>
          </template>
        </el-table-column>
        <!-- 左侧固定列 -->
        <el-table-column
          v-for="(item, index) in leftList"
          :key="item.fieldCode + index"
          align="center"
          fixed="left"
          :width="item.fieldWidth ? item.fieldWidth : '100'"
          :prop="item.fieldCode"
          :label="item.fieldName"
          show-overflow-tooltip>
        </el-table-column>
        <!-- 中间滑动列 -->
        <el-table-column
          v-for="(item, index) in showList"
          :key="item.fieldCode + index"
          align="center"
          :min-width="item.fieldWidth ? item.fieldWidth : '120'"
          :prop="item.fieldCode"
          :label="item.fieldName"
          show-overflow-tooltip>
        </el-table-column>
        <!-- 右侧固定列 -->
        <el-table-column
          v-for="(item, index) in rightList"
          :key="item.fieldCode + index"
          align="center"
          fixed="right"
          :width="item.fieldWidth ? item.fieldWidth : '100'"
          :prop="item.fieldCode"
          :label="item.fieldName"
          show-overflow-tooltip>
        </el-table-column>
      </el-table>
    </div>
    <!-- 显示隐藏 -->
    <el-dialog title="显示隐藏" v-model="showVisible" top="100px" width="1000px" :lock-scroll="true" :close-on-click-modal="false">
      <section class="section-drag">
        <div class="item">
          <div class="title">隐藏</div>
          <VueDraggableNext class="list-group" :list="hiddenList" group="people">
            <transition-group type="transition" name="flip-list">
              <div class="list-group-item" v-for="element in hiddenList" :key="element.fieldCode"> {{ element.fieldName }} </div>
            </transition-group>
          </VueDraggableNext>
        </div>
        <div class="item">
          <div class="title">显示</div>
          <VueDraggableNext class="list-group" :list="showList" group="people" @change="onChangeShow">
            <transition-group type="transition" name="flip-list">
              <div class="list-group-item" v-for="element in showList" :key="element.fieldCode"> {{ element.fieldName }} </div>
            </transition-group>
          </VueDraggableNext>
        </div>
      </section>
      <section class="fotter-buttons">
        <el-button size="medium" @click="showVisible = false">取消</el-button>
        <el-button size="medium" type="primary" @click="onSaveShow">保存</el-button>
      </section>
    </el-dialog>
    <!-- 固定列数 -->
    <el-dialog title="固定列数" v-model="fixedVisible" top="100px" width="1000px" :lock-scroll="true" :close-on-click-modal="false">
      <section class="section-fixed section-drag">
        <div class="item">
          <div class="title">固定左侧</div>
          <VueDraggableNext class="list-group" :list="tailFixedList" group="people">
            <transition-group type="transition" name="flip-list">
              <div class="list-group-item" v-for="element in tailFixedList" :key="element.fieldCode"> {{ element.fieldName }} </div>
            </transition-group>
          </VueDraggableNext>
        </div>
        <div class="item">
          <div class="title">中间滑动列</div>
          <VueDraggableNext class="list-group" :list="showList" group="people">
            <transition-group type="transition" name="flip-list">
              <div class="list-group-item" v-for="element in showList" :key="element.fieldCode"> {{ element.fieldName }} </div>
            </transition-group>
          </VueDraggableNext>
        </div>
        <div class="item">
          <div class="title">固定右侧</div>
          <VueDraggableNext class="list-group" :list="frontFixedList" group="people" @change="onChangeFixed">
            <transition-group type="transition" name="flip-list">
              <div class="list-group-item" v-for="element in frontFixedList" :key="element.fieldCode"> {{ element.fieldName }} </div>
            </transition-group>
          </VueDraggableNext>
        </div>
      </section>
      <section class="fotter-buttons">
        <el-button size="medium" @click="fixedVisible = false">取消</el-button>
        <el-button size="medium" type="primary" @click="onSaveFixed">保存</el-button>
      </section>
    </el-dialog>
  </div>
</template>
 
<script lang="ts">
import { defineComponent, reactive, toRefs, onMounted, PropType, UnwrapRef } from 'vue'
import { IColItem, ICustomTableState } from './type'
import { VueDraggableNext } from 'vue-draggable-next'
import * as INTERFACE from '@/interface'
import { IResponseData } from '@/types'
import { ElMessage } from 'element-plus'
export default defineComponent({
  name: 'CustomTable',
  components: { VueDraggableNext },
  props: {
    oWidth: {
      type: Number,
      default: 170,
    },
    isShowOperaRow: {
      type: Boolean,
      default: true
    },
    isChildren: {
      type: Boolean,
      default: false
    },
    rowId: {
      type: [Number, String],
      default: 'id'
    },
    tableCode: {
      type: String,
      default: 'testTable',
      required: true
    },
    tableData: {
      type: Array as PropType<IColItem[]>,
      default: () => []
    }
  },
  emits: { 'onRefresh': null, 'loadChildren': null ,'handleSelectionChange': null },
  setup(props, ctx) {
    // 避免排序、显示隐藏相互影响, 深拷贝列表字段
    const state: UnwrapRef<ICustomTableState> = reactive({
      showVisible: false,
      fixedVisible: false,
      leftList: [],
      centerList: [],
      rightList: [],
      hiddenList: [],
      showList: [],
      tailFixedList: [],
      middleSlidingList: [],
      frontFixedList: []
    })
    onMounted(() => {
      // 初始根据传递的 tableCode 获取对应的列表字段
      getTableHeaders()
    })
    // ... 省略业务代码
    return { ...toRefs(state), onSaveShow, onSaveFixed, onRefresh, loadChildren, handleSelectionChange }
  }
})
</script>

3.2 ant-design-vue 版 自定义 table 组件

注意 ant-design-vueelement-plus API 不一样

// CustomTable.vue
// 代码其他部分变化, 主要是改变 table 部分
...
<a-table
  size="small"
  bordered
  :rowKey="rowKey"
  :pagination="false"
  :columns="columnsData"
  :data-source="tableData"
  :scroll="{ x: 1500, y: 500 }"
  @expand="onRowExpand"
  :expandRowByClick="true"
  childrenColumnName="childrenData"
  :expandIconColumnIndex="2"
  @resizeColumn="handleResizeColumn"
  :rowClassName="(record, index) => (index % 2 === 1 ? 'table-striped' : null)"
  :row-selection="{ selectedRowKeys: selectedKeys, onChange: onSelectChange }">
  <template #expandIcon="{ record }">
    <i v-if="record.expandStatus === 3" class="el-icon-arrow-down expand-icon"></i>
    <i v-if="record.expandStatus === 2" class="el-icon-loading expand-icon"></i>
    <i v-if="record.expandStatus === 1" class="el-icon-arrow-right expand-icon"></i>
  </template>
  <template #bodyCell="{ column, record, index }">
    <template v-if="column.key === 'index' && isChildren">
      <span v-if="!record.isChildren">{{ index + 1 }}</span>
    </template>
    <template v-if="column.key === 'operate' && isShowOperaRow">
      <div v-if="!record.isChildren"><slot name="operate" :scope="record"></slot></div>
      <el-button type="text" v-else style="padding: 6px 0"></el-button>
    </template>
  </template>
</a-table>
...

3.3 使用

各页面表格操作列不一样的使用 solt 插槽自定义

<CustomTable
  :tableData="tableData" tableCode="TransMasterTable" :oWidth="450" rowKey="id"
  rowId="id" :isChildren="true" @loadChildren="loadChildren" @onRefresh="onRefresh" @handleSelectionChange="handleSelectionChange">
  <!-- 操作列 -->
  <template #operate="{ scope }">
    <el-button type="text" @click="onTableOperate('details', scope)">查看</el-button>
    <el-button type="text" @click="onTableOperate('edit', scope)" :disabled="![0, 2, 3].includes(scope.tmDataState)">编辑</el-button>
    <el-button type="text" @click="onTableOperate('delete', scope)" :disabled="scope.tmDataState === 1 || scope.tmDataState === 4">删除</el-button>
    <el-button type="text" @click="onTableOperate('copy', scope)">复制</el-button>
    <el-button type="text" @click="onTableOperate('submit', scope)" :disabled="scope.tmDataState !== 2">委托与提交</el-button>
    <el-button type="text" @click="onTableOperate('print', scope)" disabled>打印</el-button>
  </template>
</CustomTable>

四 表格自带校验

4.1 自定义 table 控件

CustomTableForm, 关键在于 TableColumnForm 组件

// CustomTableForm.vue
<a-table bordered :columns="columns" :data-source="dataSource" :rowKey="rowKey" :scroll="{ x: scrollX }" @resizeColumn="handleResizeColumn">
  <template #headerCell="{ column }">
    <span :class="[{'is-required': column.required}]">{{ column.title }}</span>
  </template>
  <template #bodyCell="{ column, record, index }">
    <template v-if="column.key === 'index'">
      <span>{{ index + 1 }}</span>
    </template>
    <template v-if="column.key === 'operate'">
      <!-- 是否需要自定义操作列 -->
      <template v-if="showDefaultOperate">
        <el-button type="text" @click="onTableOperate('save', record)" v-if="record.isEdit">保存</el-button>
        <el-button type="text" @click="onTableOperate('edit', record)" v-else>编辑</el-button>
        <el-button type="text" @click="onTableOperate('delete', record, index)" class="delete-button">删除</el-button>
      </template>
      <!-- 插槽 -->
      <slot name="operate" :scope="record" :index="index"></slot>
    </template>
    <template v-if="column.key !== 'index' && column.key !== 'operate'">
      <!-- 核心 -->
      <TableColumnForm :column="column" :record="record"></TableColumnForm>
    </template>
  </template>
</a-table>
// TableColumnForm.vue
<template>
  <span v-if="!row.isEdit || !column.colType" @click="onclick">{{ row[column.key] }}</span>
  <template v-else>
    <el-tooltip class="item" effect="dark" :content="row[column.key]" placement="top" v-if="!column.colType || column.colType === 'button'">
      <el-button type="text" @click="onclick">{{ row[column.key] }}</el-button>
    </el-tooltip>
    <!-- 输入框 -->
    <el-input
      v-if="!column.colType || column.colType === 'input'"
      v-model="row[column.key]"
      @blur="onBlur"
      :placeholder="column.placeholder"
      :class="[{'el-error': row.isError && !row[column.key] && column.required}]">
    </el-input>
    <!-- 下拉框 -->
    <el-select
      v-if="column.colType === 'select'"
      v-model="row[column.key]"
      :placeholder="column.placeholder"
      :class="[{'el-error': row.isError && !row[column.key] && column.required}]">
      <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"></el-option>
    </el-select>
    <!-- 日期选择 -->
    <el-date-picker
      v-if="column.colType === 'date'"
      v-model="row[column.key]"
      type="date"
      value-format="YYYY-MM-DD"
      :disabled-date="disabledDate"
      :class="[{'el-error': row.isError && !row[column.key] && column.required}]"
      placeholder="Pick a date"
    >
    </el-date-picker>
    <!-- 数字输入框 -->
    <el-input-number
      @blur="onBlur"
      @change="onChange"
      ref="inputNumberRef"
      v-if="column.colType === 'inputNumber'"
      v-model="row[column.key]"
      :min="column.min?column.min:''"
      controls-position="right" 
      :class="[{'el-error': row.isError && !row[column.key] && column.required}]" />
  </template>
</template>
 
<script lang="ts">
type optionItem = {
  label: string,
  value: string | number
} 
import { defineComponent, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
export default defineComponent({
  name: 'TableFormItem',
  props: {
    record: {
      type: Object,
      required: true
    },
    column: {
      type: Object,
      required: true
    }
  },
  setup(props) {
    const row = ref<any>({})
    const column = ref<any>({})
    const options = ref<optionItem[]>([])
    const inputNumberRef = ref<any | HTMLElement>(null)
    onMounted(() => {
      row.value = props.record
      column.value = props.column
      options.value = props.column.options
      if (props.column.colType === 'inputNumber' && row.value[props.column.key]) {
        row.value[props.column.key] = +row.value[props.column.key]
      }
    })
    const onBlur = (event) => {
      const targetNode = event.target
      // 有值 在去走校验规则
      if (column.value.checkCallback && row.value[column.value.key as string]) {
        const flag = column.value.checkCallback(row.value[column.value.key as string].trim())
        if (!flag) {
          row.value[column.value.key as string] = ''
          targetNode.setAttribute('placeholder', props.column.message ? props.column.message : '必填项')
          ElMessage.warning(`${column.value.title}格式输入有误,请检查`)
        } else {
          targetNode.setAttribute('placeholder', props.column.placeholder)
        }
      }
      // 无值且必填触发
      if (!row.value[props.column.key as string] && props.column.required) {
        targetNode.setAttribute('placeholder', props.column.message ? props.column.message : '必填项')
        return targetNode.classList.add('el-checked-error')
      }
      if (row.value[props.column.key as string] && props.column.callBack) props.column.callBack(row.value)
      targetNode.classList.remove('el-checked-error')
    }
    const onclick = () => {
      if (row.value[props.column.key as string] &&  props.column.colType === 'button' && props.column.callBack){
        props.column.callBack(row.value)
      }
    }
    const onChange = () => {
      if (row.value[props.column.key as string] && props.column.callBack) props.column.callBack(row.value)
    }
    const disabledDate = (time: Date)=>{
      return time.getTime() > Date.now()
    }
    return { onBlur, row, options, onChange, onclick, inputNumberRef,disabledDate }
  }
})
</script>

4.2 使用

...
<CustomTableForm rowKey="systemGid" :scrollX="3600" :columns="columns" :dataSource="tableData" :showDefaultOperate="true"></CustomTableForm>
... 
<script lang="ts">
import { defineComponent, onMounted, reactive, toRefs } from 'vue'
import { useTableFormChecked } from '@/services/utils'
import { ElMessage } from 'element-plus'
// 下拉框数据源 用法1
const options1 = [
  {
    value: 'shenzhen',
    label: '深圳',
  },
  {
    value: 'guangzhou',
    label: '广州',
  },
  {
    value: 'shanghai',
    label: '上海',
  },
  {
    value: 'beijing',
    label: '北京',
  }
]
// checkCallback 输入校验方法 用法1
 const nameChecked = (key) => {
  const reg = /^[\u4e00-\u9fa5]+$/
  return reg.test(key)
}

/**
 * @name 动态表格校验方法
 * @param array table 数据   columns 表头数据
 * @returns 
 */
 const useTableFormChecked = (array: any[], columns: any[]) => {
  let flag = false
  const titles = []
  array.forEach(t => {
    Object.keys(t).forEach((key) => {
      const item = columns.find(l => l.key === key)
      // 有值 但是 值格式对
      if (t[key] && item?.checkCallback && typeof item.checkCallback == 'function') {
        const reg = item.checkCallback(t[key])
        if (reg) return
        titles.push(item.title)
        flag = true
      }
      // 没值 且 必填
      if (!t[key] && item?.required) {
        titles.push(item.title)
        flag = true
      }
      if ((!t[key] || t[key] === null) && t[key] !== 0 && t[key] !== false) t.isError = true
    })
  })
  return { flag: flag, titles: titles }
}

export default defineComponent({
  name: 'CustomTableFormContainer',
  setup() {
    const state = reactive({
      tableData: [{
        id: 1,
        name: '阿凡达',
        age: '18',
        gender: '女',
        weight: 88,
        country: '中国',
        city: 'shenzhen',
        province: '广东',
        surname: '阿',
        address: '深圳前海嘉里',
        isEdit: false,
        isError: false
      }],
      columns: [
        {
          title: '序号',
          dataIndex: 'index',
          key: 'index',
          width: 60,
          minWidth: 60,
          resizable: true,
          fixed: 'left'
        },
        {
          title: '操作',
          dataIndex: 'operate',
          key: 'operate',
          width: 120,
          fixed: 'left'
        },
        {
          title: '名称',
          dataIndex: 'name',
          key: 'name',
          resizable: true,
          width: 150,
          colType: 'input',
          required: true,
          checkCallback: nameChecked,
          placeholder: '请输入名称',
          message: '请输入汉字字符'
        },
        {
          title: '年龄',
          dataIndex: 'age',
          key: 'age',
          resizable: true,
          width: 150,
          colType: 'input',
          required: true,
          checkCallback: null,
          placeholder: '请输入名称'
        },
        {
          title: '性别',
          dataIndex: 'gender',
          key: 'gender',
          resizable: true,
          width: 150,
          colType: 'select',
          required: true,
          placeholder: '请选择性别',
          options: null
        },
        {
          title: '体重',
          dataIndex: 'weight',
          key: 'weight',
          resizable: true,
          required: true,
          width: 150,
          min: 80,
          max: 150,
          colType: 'inputNumber',
        },
        {
          title: '姓',
          dataIndex: 'name',
          key: 'name',
          resizable: true,
          width: 120,
          placeholder: '请输入名称'
        },
        {
          title: '地址',
          dataIndex: 'surname',
          key: 'surname',
          resizable: true,
          width: 150,
          placeholder: '请输入地址'
        },
        {
          title: '国家',
          dataIndex: 'country',
          key: 'country',
          width: 120,
          resizable: true,
          placeholder: '请输入国家'
        },
        {
          title: '省',
          dataIndex: 'province',
          key: 'province',
          width: 120,
          resizable: true,
          placeholder: '请输入省'
        },
        {
          title: '城市',
          dataIndex: 'city',
          key: 'city',
          resizable: true,
          width: 120,
          colType: 'select',
          options: options1
        }
      ],
      options: [
        {
          value: 'Option1',
          label: 'Option1',
        },
        {
          value: 'Option2',
          label: 'Option2',
        },
        {
          value: 'Option3',
          label: 'Option3',
        },
        {
          value: 'Option4',
          label: 'Option4',
        },
        {
          value: 'Option5',
          label: 'Option5',
        },
      ]
    })
    onMounted(() => {
      // checkCallback 输入校验方法 用法2
      const item = state.columns.find(t => t.key === 'age')
      item.checkCallback = ageChecked

      // 下拉框数据源 用法2
      const item1 = state.columns.find(t => t.key === 'gender')
      item1.options = state.options
    })
    const ageChecked = (key) => {
      const reg = /[^\d]/g
      return !reg.test(key)
    }
    // 添加一行
    const onOperate = () => {
      const index = state.tableData[state.tableData.length - 1].id
      state.tableData.push({
        id: index + 1,
        name: '',
        age: '',
        gender: '',
        country: '',
        city: '',
        province: '',
        surname: '',
        address: '',
        weight: 80,
        isEdit: true,
        isError: false
      })
    }
    const onSave = () => {
      const { flag, titles } = useTableFormChecked(state.tableData, state.columns)
      // 拦截 校验没过禁止往下走
      if (flag) return ElMessage.warning(`表格数据(${titles.toString()})输入有误,请检查`)
      // 下面是保存逻辑 .....
      ElMessage.success('保存成功')
    }
    return { ...toRefs(state), onOperate, onSave }
  }
})
</script>

4.3 使用文档

// README.md
## 源文件
  -  `CustomTableForm`
  -  `TableColumnForm` 

## 事例参考
  - 路由 `order-manage/customTableFormContainer`
  - 页面 `CustomTableFormContainer`

## 表格数据表单使用规则 (适应需求: 表格动态添加数据  --  和表单一样带校验)
## 使用方式  A 代表必填, B 非必填
  - `tableCode` (B):`用于动态获取标表头; 当需要右上角布局、自定义列显示按钮时, 为必填项`
  - `columns` (A) : `表头数据源`
    -  `title` (A):  `表头名称`
    -  `key`(A): `Vue 需要的 key / 列数据在数据项中对应的路径 -- 唯一值`
    -  `resizable` (B): , `是否需要自定义列宽 -- 拖动列宽` 
    -  `width` (B): `150`, `列初始宽度; 注:resizable 为true时 width必填`
    -  `required` (B): `是否必填, true 会为表头加上红色 *`,
    -  `placeholder` (B): `输入框以及下拉框的提示`,
    -  `min` (B): `colType 为 inputNumber 数字输入框时最小值`,
    -  `max` (B): `colType 为 inputNumber 数字输入框时最大值`,
    -  `message` (B): `校验提示`,
    -  `colType` (B): `动态表格项类型 默认不传 span; button: 提供点击回调; input: 输入框; select: 下拉框; inputNumber: 数字输入框`,
    -  `checkCallback` (B): `必填时自定义的校验规则回调函数; 使用方式参考事例`,
    -  `callBack` (B): `方法回调 使用方式参考交易单商品添加`,

  - `dataSource` (A): 列表数据
    - 注: 要和 `columns key` 一一对应

  - `rowKey` (A): `dataSource` 每一项的唯一标识
  - `tableCode` (B): 需要修改列宽的时候后台提供的表头接口标识
  - `scrollX` (B): 列表横向宽度
  - `scrollY` (B): 列表纵向高度
  - `showDefaultOperate` (B): 是否显示默认操作列; 值为false 时需要传入自定义的操作列,
## 提示

  - 改  `CustomTableForm` / `TableColumnForm` 文件之前 请先熟悉这 2 文件
  - 更改时间建议写在下面
  - 有问题微信联系我

## 更改日志
  - `12-14` - `laisheng` :使用方式添加

写的有点乱。有需要的请自行取舍; 这里大概介绍的就是思路及步骤

posted @ 2022-02-21 14:13  会写代码的赖先生  阅读(3698)  评论(0编辑  收藏  举报