介绍
基于elementPlus的el-table + vite + vue3 + jsx 实现的动态表格和行编辑demo
背景
由于复杂的业务系统需要在表格行内支持查看,编辑,删除功能。由于直接在template编写不同的条件和标签会让系统变的及其复杂和难以维护,需要优化整个编码的方式。
思路
解决上面问题,主要要实现动态表格渲染,通过配置可以大大简化的嵌套复杂度,同时把重复的逻辑封装在自定义的表格组件里,由于是自定义的组件,在组件里再编辑时候的展示逻辑。
- 新增DTable组件,通配置可以渲染不同的列
- 新增DCloumn组件,根据不同的配置和数据源展示不同状态和组件
实现功能:
- 基于配置生成表格
- 行内支持编辑和查看 两种状态显示
- 支持添加,删除,编辑行
- 支持必填校验和批量提示,部分校验忽略
- 支持自定义模板
- 支持的显示组件有 input 输入框 , select(当超过1000条切换到虚拟滚动select)下拉控件 , date-pick 日期控件
- 输入框支持数字,正负,小数位显示
- 支持回调事件,可实现不同组件间的联通,如省市区联动逻辑
- 日期控件支持格式化显示
动态列与表格显示
新增行
编辑行/删除行
校验批量提示/部分不校验
自定义模板列
下拉
日期控件
部分只读
动态添加下拉项
使用方式
//定义列格式
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>
源码介绍
结构
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>