elementplus el-table 动态表格与行编辑 DTable

3,279 阅读5分钟

介绍

基于elementPlus的el-table + vite + vue3 + jsx 实现的动态表格和行编辑demo

编辑11.gif

背景

由于复杂的业务系统需要在表格行内支持查看,编辑,删除功能。由于直接在template编写不同的条件和标签会让系统变的及其复杂和难以维护,需要优化整个编码的方式。

思路

解决上面问题,主要要实现动态表格渲染,通过配置可以大大简化的嵌套复杂度,同时把重复的逻辑封装在自定义的表格组件里,由于是自定义的组件,在组件里再编辑时候的展示逻辑。

  1. 新增DTable组件,通配置可以渲染不同的列
  2. 新增DCloumn组件,根据不同的配置和数据源展示不同状态和组件

实现功能:

  1. 基于配置生成表格
  2. 行内支持编辑和查看 两种状态显示
  3. 支持添加,删除,编辑行
  4. 支持必填校验和批量提示,部分校验忽略
  5. 支持自定义模板
  6. 支持的显示组件有 input 输入框 , select(当超过1000条切换到虚拟滚动select)下拉控件 , date-pick 日期控件
  7. 输入框支持数字,正负,小数位显示
  8. 支持回调事件,可实现不同组件间的联通,如省市区联动逻辑
  9. 日期控件支持格式化显示

动态列与表格显示

image.png

新增行

新增.gif

编辑行/删除行

image.png

删除.gif

校验批量提示/部分不校验

image.png

校验22.gif

自定义模板列

image.png

下拉

image.png

日期控件

image.png

部分只读

image.png

动态添加下拉项

动态添加下拉项.gif

使用方式

//定义列格式
let columns = 
[ 
  { label: '城市', property: 'city', render:'component',  type: 'select',  options: cityOptions , validate:true },  
  { label: '介绍', property: 'info', render:'component',width:120,  type: 'input'},   
]
//定义表格数据
let tableData = [
{"id":2,"city":1,"area":1,"username":3,"account":4,"age":5,"createtime":"2024-01-01",birthday:"202405",score:5,info:'人品好',readonlyKeyList:[]},
{"id":3,"city":2,"area":3,"username":3,"account":4,"age":5,"createtime":"2024-01-02",birthday:"202406",score:-2,info:'孤僻',ignoreList:['birthday']}, 
]

//组件使用
  <DTable ref="tableRef" rowKey="id"  :tableData="tableData" :columns="columns"></DTable>   

//表格某一行提交
const handleRowSubmit = (row:any) => { 
  let {isValidate,errorMsg,errorColList} = tableRef.value?.validate(row)
  if(!isValidate){//验证不通过
    row.errorList = errorColList //错误列设置,用于标识红框提示
    const warnMessage = errorMsg.join('<br/>') 
    // 输出错误提示 ....warnMessage
    return
  } 
}

columns 数据结构介绍

property: 'key' //对应表格的列属性
render 'component' | 'slot'; // 可选的固定组件,也可以选择自定义渲染方式, 不传则按员el-table的方式渲染
validate: true | false //是否需要校验
options:[] // 下拉数据
attr: {} // 扩展属性 包含allowCreate  onPropValChange ,自定义属性,onPropValChange可以做数据变化的监听控制

tableData数据内容介绍

uiEdit: true | false; //是否可编辑
readonlyKeyList: [] //某一行限制部分列只读
ignoreList: []   // 某一行取消校验限制
errorList:  []   // 某一行数据异常标识

具体使用代码

src/App.vue

<script setup lang="ts">
import DTable from './components/DTable.vue' 
import { reactive, ref, toRefs , nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { zhCn } from "element-plus/es/locale/index.mjs";
import {cloneDeep} from 'lodash'

//定义省份的数组
const cityOptions = [
  { label: '广州', value: 1},
  { label: '深圳', value: 2},
  { label: '北京', value: 3},
  { label: '上海', value: 4},
  { label: '杭州', value: 5},
  { label: '天津', value: 6},
  { label: '重庆', value: 7},
  { label: '南京', value: 8},
  { label: '香港', value: 9},
]

//定义市区的数组以来省份 补充多5
const areaOptions = [
  { label: '荔湾', value: 1, parantId:1},
  { label: '增城', value: 2, parantId:1},
  { label: '罗湖', value: 3, parantId:2},
  { label: '福田', value: 4, parantId:2},
  { label: 'xx1', value: 5, parantId:3}, 
  { label: 'xx2', value: 6, parantId:3}, 
  { label: 'xx3', value: 7, parantId:4}, 
  { label: 'xx4', value: 8, parantId:4}, 
  { label: 'xx5', value: 9, parantId:5}, 
]
//用户数组
const userOptions = [
  { label: '张三', value: 1},
  { label: '李四', value: 2},
  { label: '王五', value: 3},
  { label: '赵六', value: 4},
  { label: '钱七', value: 5},
  { label: '孙八', value: 6},
  { label: '周九',value: 7},
  { label: '周九1',value: 8},
  { label: '周九2',value: 9},
]


const usernameChange = (value:string,props:any) => {
  if( value === '') return  {isError:true,val:value}
  if(props.options.some((o:any)=> o.value === value)) {
    return false
  }
  let  regex = /^[\u4e00-\u9fa5]*$/; // 定义正则表达式
  if (value && !regex.test(value)) { 
    ElMessage.warning("只能输入中文")
    return  {isError:true,val:''}
  }
  return  {isError:true,val:value}
}

const getTableAreaOptions = (row:any,column:any) => { 
  return row.city ? areaOptions.filter( o => o.parantId === row.city) : areaOptions
} 

const tableRef = ref({})
const state = reactive({
  tableData:[],
  columns:[
  { label: 'ID', property: 'id',align:'center', type:'selection' , width:30},
  { label: '操作', property: 'slot', render:'slot',slotName:'operateSlot', width:100 ,fixed:'left'  }, 
  { label: '城市', property: 'city', render:'component',  type: 'select',  options: cityOptions , validate:true },  
  //市区与省份联动,通过
  { label: '区域', property: 'area', render:'component',  type: 'select',  options: getTableAreaOptions , validate:true },  
  //支持下拉动态创建(输入后回车),以及输入内容验证控制
  { label: '用户', property: 'username', render:'component',  type: 'select',width:120,   options: userOptions , attr: {allowCreate:true,onPropValChange:usernameChange}},
  //限制正数输入,保留两位小数
  { label: '钱包', property: 'account', render:'component',  type: 'input', attr: {type:'number',number:'float',precision:2} }, 
  //限制正数输入
  { label: '年龄', property: 'age', render:'component',  type: 'input', attr: {type:'number',number:'positive'} , validate:true},  
  //可以输入负数,限制最大最小值
  { label: '学分', property: 'score', render:'component',  type: 'input', attr: {type:'number',number:'positiveNegative',max:100,min:-100} },  
  { label: '生日年月', property: 'birthday', render:'component',  type: 'date' ,width:120, attr:{type:'month',format:'YYYYMM',valueFormat:'YYYYMM'} , validate:true}, 
  { label: '介绍', property: 'info', render:'component',width:120,  type: 'input'},  
  { label: '提交日期', property: 'createtime',width:180},
]})
const {columns,tableData} = toRefs(state)

// tableData数组设置 以columns的property数据为属性的数据 
let data = [
{"id":2,"city":1,"area":1,"username":3,"account":4,"age":5,"createtime":"2024-01-01",birthday:"202405",score:5,info:'人品好'},
{"id":3,"city":2,"area":3,"username":3,"account":4,"age":5,"createtime":"2024-01-02",birthday:"202406",score:-2,info:'孤僻',ignoreList:['birthday']}, 
{"id":4,"city":3,"area":5,"username":3,"account":4,"age":5,"createtime":"2024-01-03",birthday:"202407",score:10,info:'吝啬',readonlyKeyList:['city', 'area',]},//支持设定某一行部分字段临时不能编辑
{"id":5,"city":4,"area":2,"username":3,"account":4,"age":5,"createtime":"2024-01-04",birthday:"202408",score:20,info:'大方'},
{"id":6,"city":5,"area":2,"username":3,"account":4,"age":5,"createtime":"2024-01-05",birthday:"202409",score:-1,info:'穷'},
{"id":7,"city":6,"area":2,"username":3,"account":4,"age":5,"createtime":"2024-01-06",birthday:"202410",score:-2,info:'有钱'},
{"id":8,"city":7,"area":2,"username":3,"account":4,"age":5,"createtime":"2024-01-07",birthday:"202405",score:-3,info:'凤凰男'},
{"id":9,"city":8,"area":2,"username":3,"account":4,"age":5,"createtime":"2024-01-08",birthday:"202405",score:-4,info:'单身'},
{"id":10,"city":9,"area":2,"username":3,"account":4,"age":5,"createtime":"2024-01-09",birthday:"202405",score:20,info:'双身'}];

  tableData.value = data  
  let copyTableData =  cloneDeep(data)

  const batchDelete = () => { 
    if(!multipleSelection.value) { 
      ElMessage.warning("请先勾选数据")
      return
    }
    ElMessageBox.confirm('是否确认删除数据?')
    .then(function () { 
     const indexesToRemove = multipleSelection.value.map(item => tableData.value.indexOf(item)).filter(index => index !== -1);
    indexesToRemove.sort((a, b) => b - a).forEach(index => tableData.value.splice(index, 1));
    }) 
  }

  const createRow = () => { 
  let row = {"id": tableData.value.length + 1,"city":null,"area":null,"username":null,"account":null,"age":null,"createtime":"2024-01-01",birthday:"",score:0,info:'',uiEdit:true}
  tableData.value.unshift(row)
}
const handleRowSubmit = (row:any) => { 
  let {isValidate,errorMsg,errorColList} = tableRef.value?.validate(row)
  if(!isValidate){
    row.errorList = errorColList 
    const warnMessage = errorMsg.join('<br/>') 
    ElMessage({
        message: warnMessage,
        grouping: true,
        type: 'warning',
        dangerouslyUseHTMLString: true,
        duration : 3000
      })
    return
  }
  row.errorList = []
  // 开始提交后台
  ElMessage.success('处理成功') 
  row.uiEdit = !row.uiEdit
  nextTick( () => {
    copyTableData =  cloneDeep(tableData.value)
  })
}
 
const handleRowDelete = (row:any) => { 
  ElMessageBox.confirm('是否确认删除数据?')
  .then(function () { 
        let deleteIndex = tableData.value.findIndex(o => o.id === row.id)
        tableData.value.splice(deleteIndex, 1); 
        ElMessage.success('删除成功') 
  })
}

const handleRowCancel = (row:any) => { 
  const index = tableData.value.findIndex( o => row.id === o.id)
  if(index >= 0) {
    let copy =  cloneDeep(copyTableData.find( o => row.id === o.id))
    tableData.value[index] = copy
  }
}
const handleRowEdit = (row:any) => { 
  row.uiEdit = !row.uiEdit
}


const onRowChange = (val,row,column) => { 
  if(column.property === 'city') { // 这里可以做一些清空操作
      row['area'] = ''
  }
}
const multipleSelection = ref([])
const handleSelectionChange = (val) => { 
  multipleSelection.value = val
}
</script>

<template>
  <div>
    <el-config-provider :locale="zhCn"> 
      <h1>element table 动态表格与行编辑</h1>
      <el-button @click="createRow">新增行</el-button>
      <el-button @click="batchDelete">删除行</el-button>
      <DTable ref="tableRef" rowKey="id"  :tableData="tableData" :columns="columns" @onRowChange="onRowChange" @selection-change="handleSelectionChange">  
          <template #operateSlot="{row}"> 
              <el-button v-show="row.uiEdit" type="primary" link @click="handleRowSubmit(row)">保存</el-button> 
              <el-button v-show="!row.uiEdit" type="primary" link @click="handleRowEdit(row)">编辑</el-button> 
              <el-button v-show="row.uiEdit" type="primary" link @click="handleRowCancel(row)" >取消</el-button>
              <el-button v-show="!row.uiEdit" type="primary" link @click="handleRowDelete(row)">删除</el-button>
            </template>    
      </DTable>  
  </el-config-provider>
  </div> 
</template>

<style scoped> 
</style>

源码介绍

结构

image.png

src/components/DTable.vue

<template>
  <el-table :data="props.tableData" :row-key="props.rowKey">
    <el-table-column
      v-for="column in props.columns"
      :key="column.property"
      :label="column.label"
      :prop="column.property"
      :align="column.align ? column.align : 'center'"
      :type="column.type"
      :width="column.width ? column.width : 100"
      :fixed="column.fixed ? column.fixed : false"
    >  
    <template #header>
      <span v-if="column.validate" class="validateTips">*</span>
      <span class="title">{{column.label}}</span>
    </template>  
    <template v-if="column.render === 'component' " #default="scope"> 
      <DColumn  
        :column="column"
        :type="column.type"
        :label="column.label"
        :property="column.property"
        :options="column.options" 
        :attr="column.attr ? column.attr  : {}"
        :readonly="column.readonly ? column.readonly  : false" 
        :modelValue="scope.row[column.property]"
        :row="scope.row"
        @update:modelValue="($event) => {onChange($event,scope.row,column)} " 
      >
      </DColumn> 
    </template>
    <template v-if="column.render === 'slot'"  #default="scope">
      <slot  :name="column.slotName" :row="scope.row" ></slot> 
    </template> 
    </el-table-column>
</el-table>


</template>
  
<script setup lang="tsx"> 
import DColumn from './DColumn.vue'; 
  interface Props { 
    rowKey: string;
    tableData: Array<any>;
    columns: Array<any>;  
      rules?: any;   
  }
const emits = defineEmits(['onRowChange']);
 const onChange = (val,row,column) => { 
   row[column.property] = val
   emits('onRowChange',val,row,column)
 }

const props = defineProps<Props>();
//校验当前行
const validate = (row:any) => { 
  const isIgnore = (val:string) => row.ignore && row.ignore.includes(val)
  const isEmpty = (val:any) =>  val === '' || val == null
  const isSmallZero = (val:any) =>  val  < 0
  const isNumberInput = (col:any) => (col.attr || {}).type === 'number'; 
  const validateColumn = (col:any) => { 
    let value = row[col.property]
    if(isNumberInput(col) && ['positive','float'].includes(col.attr.number) ) {
      if(isSmallZero(value)) { 
        return `${col.label}不能小于0`
      } else {
        return isEmpty(value) ? `${col.label}不能为空` : undefined
      }
    } 
    return !value ? `${col.label}不能为空` : undefined
  }
  const  validateObj = props.columns.filter(col => !isIgnore(col.property) && col.validate).reduce((obj,col) => {
    let msg = validateColumn(col) 
    if(msg) {
      obj.isValidate = false 
      obj.errorMsg.push(msg) 
      obj.errorColList.push(col.property) 
    }
    return obj
  },{isValidate:true,errorMsg:[],errorColList:[]}) 
  return validateObj
}

defineExpose({
  validate
})

  </script>

src/components/DColumn.vue

<template>
  <div :class="{isError: props.row.errorList && props.row.errorList.includes(props.property)}">
    <RenderContent />
  </div>
</template>

<script setup lang="tsx">
import { ElSelect, ElOption, ElInput, ElText, ElDatePicker, ElSelectV2 } from 'element-plus';
import { JSX } from 'vue/jsx-runtime';

interface Option {
  value: number | string;
  label: string;
}

// 定义了表格列配置的属性类型
interface Props {
  column: any, // 表格列的原始数据对象
  type: 'select' | 'input' | 'date'; // 表格列的输入类型,可以是选择框、输入框或者日期选择器
  label: string; // 表格列的标签
  property: string; // 表格列的属性名
  render?: 'component' | 'slot'; // 可选的自定义渲染方式,不传则按员el-table的方式渲染
  options?: Array<Option> | Function; // 选择项数据源,可以是选项数组或者一个返回选项数组的函数
  row: any; // 当前行的数据对象
  modelValue: any; // 当前列的绑定值
  attr?: { // 额外的属性配置对象
    type?: string; // 自定义属性类型
    optionlabel?: string; // 选项标签属性名
    optionValue?: string; // 选项值属性名
    allowCreate?: false; // 是否允许创建新选项(目前仅支持选择框)
    onPropValChange?: Function; // 属性值变化时的回调函数
    precision?: number; // 数字类型时,保留的小数位数
    number?: string; // 是否是数字类型
    maxlength?: number; // 最大长度限制
    format?: string; // 日期格式
    valueFormat?: string; // 绑定值的格式
    max?: number; // 最大值限制
    min?: number; // 最小值限制
  } | undefined;
  readonly?: boolean; // 是否强制只读
}

const props = defineProps<Props>();
const emits = defineEmits(['update:modelValue']);

const regexMap = {
  positive: /[^0-9]/g,
  positiveNegative: /(?<!^)-|[^0-9-]/g,
  float: /[^0-9.]/g,
  floatNegative: /(?<!^)-|[^-0-9.]/g,
};

const processValue = (value: any, props: any) => {
  const regexType = props.attr?.number || 'positive';
  const regex = regexMap[regexType as keyof typeof regexMap];
  value = value.replace(regex, '');

  if (regexType === 'float' || regexType === 'floatNegative') {
    value = value.replace(/^\D*([0-9]\d*\.?\d{0,2})?.*$/, '$1');
  }

  if (props.attr?.max !== undefined && value > props.attr.max) {
    value = props.attr.max;
  }

  if (props.attr?.min !== undefined && value < props.attr.min) {
    value = props.attr.min;
  }

  return value;
};

const onValChange = (value: any) => {
  if (props.attr?.onPropValChange) {
    const obj = props.attr.onPropValChange(value, props);
    if (obj.isError) {
      value = obj.val;
    }
  }
  emits('update:modelValue', value);
};

const onValNumberChange = (value: any) => {
  console.log("onValNumberChange", value)
  value = processValue(value, props);
  emits('update:modelValue', value);
};

const isReadOnly = (props: Props) => {
  if (props.readonly) return true;
  if (props.row.readonlyKeyList?.includes(props.property)) return true;
  return !props.row.uiEdit;
};

const getOptions = (props: Props) => {
  if (typeof props.options === 'function') {
    return props.options(props.row, props.column);
  }
  return props.options || [];
};

const renderSelect = (nowVal: any, readOnly: boolean, options: Option[]) => {
  if (readOnly) {
    const selectedOption = options.find((o: any) => {
      const valueKey = props.attr?.optionValue || 'value';
      return o[valueKey] == nowVal;
    });
    const label = selectedOption?.label || (props.attr?.allowCreate ? nowVal : '');
    return <ElText>{label}</ElText>;
  }

  const selectOptions = options.map((option: Option) => ({
    label: props.attr?.optionlabel ? option[props.attr.optionlabel] : option.label,
    value: props.attr?.optionValue ? option[props.attr.optionValue] : option.value,
  }));

  return options.length > 1000 ? (
    <ElSelectV2
      v-model={props.row[props.property]}
      options={selectOptions}
      onChange={onValChange}
      allow-create={props.attr?.allowCreate}
      filterable
      clearable
    />
  ) : (
    <ElSelect
      v-model={props.row[props.property]}
      onChange={onValChange}
      allow-create={props.attr?.allowCreate}
      default-first-option={props.attr?.allowCreate}
      filterable
      clearable
    >
      {selectOptions.map((option) => (
        <ElOption label={option.label} value={option.value} />
      ))}
    </ElSelect>
  );
};

const renderDate = (nowVal: any, readOnly: boolean) => {
  return readOnly ? (
    <ElText>{nowVal}</ElText>
  ) : (
    <ElDatePicker
      v-model={props.row[props.property]}
      format={props.attr?.format}
      value-format={props.attr?.valueFormat}
      type={props.attr?.type}
    />
  );
};

const renderInput = (nowVal: any, readOnly: boolean) => {
  const formatVal = props.attr?.type === 'number'
    ? formatNum(nowVal, props.attr?.precision || 0)
    : nowVal;

  return readOnly ? (
    <ElText>{formatVal}</ElText>
  ) : (
    <ElInput
      maxlength={props.attr?.maxlength || Infinity}
      v-model={props.row[props.property]}
      onInput={(value: any) => {
        if (props.attr?.type === 'number') {
          onValNumberChange(value);
        } else {
          onValChange(value);
        }
      }}
    />
  );
};

const RenderContent = () => {
  const nowVal = props.row[props.property];
  const readOnly = isReadOnly(props);
  const options = getOptions(props);

  const renderMap: { [key: string]: () => JSX.Element } = {
    select: () => renderSelect(nowVal, readOnly, options),
    date: () => renderDate(nowVal, readOnly),
    input: () => renderInput(nowVal, readOnly),
  };

  return renderMap[props.type] ? renderMap[props.type]() : <ElText>{nowVal}</ElText>;
};

const formatNum = (num: string, fixedDigit: number = 0) => {
  if (num === undefined) {
    return '';
  }
  let numFixed = Number(num).toFixed(fixedDigit);
  let formatNum = `${numFixed}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  return `${formatNum}`;
};
</script>

源码地址

github.com/mjsong07/dt…