vue3+ts+element-ui实现的可编辑table表格组件 插入单行多行 组件代码可直接使用
最近需求越来越离谱,加班越来越严重,干活的牛马也越来越卑微。写了一个可编辑表格,并已封装好组件,可直接使用。
基于这位大佬的 动态表格自由编辑 方法和思路,于是参考和重写了表格,在基础上增加和删除了一些功能点。
实现功能如下:
1、双击单元格可编辑格子内容,输入框和文本的高度自适应。
2、右击表格弹出菜单,可插入单行、多行和删除行。
3、可配置字段是否可以编辑。
效果图
组件参数截图
使用组件
使用组件代码
<!-- 可编辑表格 -->
<EditTableForm
:list="state.questionChoiceVOlist"
:headerList="columnList"
:selectUserList="scorerUserList"
:forbidPop="['itemScore','scorerUserIdList','expirationDay']" />
组件EditTableForm
完整代码
<template>
<el-table
:data="tableData"
@cell-dblclick="cellDblclick"
@row-contextmenu="cellRightClick"
:row-class-name="tableRowClassName" border>
<el-table-column
type="index"
label="序号"
align="center"
:resizable="false"
width="70"
/>
<el-table-column
:resizable="false"
align="left"
v-for="(col, idx) in columnList"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:index="idx"
>
<template #default="scope">
<el-input-number
v-if="col.type === 'input-number'"
v-model.number="scope.row[col.prop]"
style="width: 120px;"
:min="0"
:max="100"
:step="1"
/>
<el-select
v-if="col.type && col.type === 'select'"
v-model="scope.row[col.prop]"
multiple
:multiple-limit="1"
filterable
clearable
collapse-tags
collapse-tags-tooltip
placeholder="请选择">
<el-option
v-for="item in props.selectUserList"
:key="item.id"
:label="item.nickname"
:value="item.id"
/>
</el-select>
<el-select
v-if="col.type && col.type === 'select-day'"
v-model="scope.row[col.prop]"
clearable
collapse-tags
placeholder="截止日期">
<el-option
v-for="item in 15"
:key="item"
:label="item + '号'"
:value="item"
/>
</el-select>
<div
class="cell-text"
v-if="!scope.row[`${col.prop}_ifWrite`] && !isPop(scope.column)"
v-html="filterHtml(scope.row[col.prop])">
</div>
<el-input
v-show="scope.row[`${col.prop}_ifWrite`]"
:ref="setInputRef(scope.$index, col.prop)"
type="textarea"
autosize
:maxRows="4"
v-model="scope.row[col.prop]"
@blur="scope.row[`${col.prop}_ifWrite`] = false" />
</template>
</el-table-column>
</el-table>
<!-- 右键菜单框 -->
<div v-show="showMenu" id="contextmenu" @mouseleave="menuMouseleave">
<el-button type="primary" @click="addRow(false)" v-show="!curTarget.isHead">上方插入一行</el-button>
<el-button @click="openAddMore(false)" v-show="!curTarget.isHead">上方插入多行</el-button>
<el-button type="primary" @click="addRow(true)" v-show="!curTarget.isHead">下方插入一行</el-button>
<el-button @click="openAddMore(true)" v-show="!curTarget.isHead">下方插入多行</el-button>
<el-button type="danger" @click="delRow" v-show="!curTarget.isHead" >删除当前行</el-button>
<el-dialog
v-model="visible"
title="请输入行数"
width="200"
align-center
draggable
>
<el-input-number style="width: 100%;" v-model="addMoreNumber" :min="1" :max="20" />
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false" style="width: 49%;">取消</el-button>
<el-button type="primary" style="width: 49%;" @click="addMoreRow(addMorelater,addMoreNumber)">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'EditTableForm' })
const props = defineProps({
// 表格数据
list: {
type: Array as PropType<any[]>,
default: () => [],
},
// 表格表头
headerList: {
type: Array as PropType<{ prop: string; label: string; type?: string }[]>,
default: () => [],
},
// 禁止填写的字段
forbidPop: {
type: Array as PropType<string[]>,
default: () => [],
},
// 评分人列表
selectUserList: {
type: Array as PropType<{ id: number; nickname: string }[]>,
default: () => [],
},
})
// const itemBox = {
// id: 1,
// date: '',
// name: '',
// address: '',
// }
// tableData.forEach(item => {
// columnList.forEach(col => {
// item[col.prop + '_ifWrite'] = false;
// item[col.prop + '_ref'] = null;
// })
// })
interface Target {
rowIdx: number | null;
colIdx: number | null;
val: string | null;
isHead: boolean | undefined;
}
const state = reactive({
tableData: [] as any[],
columnList: props.headerList as any[],
itemBox: getItemBox(props.list) as {},
showMenu: false, // 显示右键菜单
curTarget: {
// 当前目标信息
rowIdx: null, // 行下标
colIdx: null, // 列下标
val: null, // 单元格内容/列名
isHead: undefined, // 当前目标是表头?
} as Target,
});
const { tableData,columnList,showMenu,curTarget } = toRefs(state);
const inputRefs = ref<{ [key: string]: any }>({});
const visible = ref(false)
const addMoreNumber = ref(1);
const addMorelater= ref(false);
/**监听表头的变化 */
watch(
() => props.headerList,
(val:any) => {
if (!val) return
state.columnList = val;
},
{
deep: true,
immediate: true
}
)
/**监听表格数据的变化 */
watch(
() => props.list,
async (val: any) => {
if (!val || val.length === 0) return;
// 使用 nextTick 确保 DOM 更新后进行操作,防止 offsetHeight 报错
await nextTick();
// 在此处进行安全的表格数据更新
// console.log('表格数据已更新:', val);
state.tableData = val;
},
{ deep: true, immediate: true }
)
/**设置添加的行元素 */
function getItemBox(data:Array<any>) {
let obj:any = {};
if(data.length > 0){
let item = data[0];
for(let key of Object.keys(item)){
obj[key] = '';
}
for (const col of props.headerList) {
obj[col.prop + '_ifWrite'] = false;
obj[col.prop + '_ref'] = null;
}
}
return obj;
}
// 打开添加行弹窗
const openAddMore = (val:boolean) => {
visible.value = true;
addMorelater.value = val;
}
// 鼠标移出菜单
const menuMouseleave = ()=>{
showMenu.value = false;
visible.value = false;
}
// 这个方法动态为每个 el-input 实例设置 ref,并将其存储在 inputRefs 中,以便后续访问。
const setInputRef = (rowIdx: number, colProp: string) => (el: any) => {
inputRefs.value[`${rowIdx}-${colProp}`] = el;
};
// 添加表格行下标和控制每个row的显示隐藏字段
const tableRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
row.row_index = rowIndex;
};
// 控制某字段不能打开弹框
const isPop = (column: { index: null; property: string; label: string }) => {
// return column.property === "itemScore" || column.property === "scorerUserIdList" || column.property === "expirationDay";
return props.forbidPop.includes(column.property);
};
// 双击左键输入框
const cellDblclick = (
row: { [x: string]: any; row_index: any },
column: any,
cell: HTMLTableCellElement,
event: MouseEvent
) => {
// 如果禁填项,不执行后续代码
if (isPop(column)) return;
// 显示输入框的控制
Object.keys(row).forEach(key => {
// endsWith判断 以_ifWrite结束的
if (key.endsWith('_ifWrite')) {
row[key] = false;
}
})
row[`${column.property}_ifWrite`] = true;
// 获取input焦点
nextTick(() => {
const inputKey = `${row.row_index}-${column.property}`;
const inputComponent = inputRefs.value[inputKey];
if (inputComponent) {
inputComponent.focus();
}
});
};
// 单元格右击事件 - 打开菜单
const cellRightClick = (row: any, column: any, event: MouseEvent) => {
// 阻止事件的默认行为(禁止浏览器右键菜单)
event.preventDefault();
showMenu.value = false;
// 定位菜单/编辑框
locateMenuOrEditInput('contextmenu', -500, event); // 右键输入框
showMenu.value = true;
// 获取当前单元格的值
curTarget.value = {
rowIdx: row ? row.row_index : null,
colIdx: column.index,
val: row ? row[column.property] : column.label,
isHead: !row,
};
};
// 新增行
const addRow = (later: boolean) => {
showMenu.value = false;
const idx = later ? curTarget.value.rowIdx! + 1 : curTarget.value.rowIdx!;
let obj: any = {
...state.itemBox,
id: Math.floor(Math.random() * 100000),
};
state.tableData.splice(idx, 0, obj);
};
// 新增多行
const addMoreRow = (later: boolean,lineNum: number) => {
showMenu.value = false;
const idx = later ? curTarget.value.rowIdx! + 1 : curTarget.value.rowIdx!;
// 创建一个包含要插入数据的新数组
let newRows: any[] = [];
for (let i = 0; i < lineNum; i++) {
let obj: any = {
...state.itemBox,
id: Math.floor(Math.random() * 100000),
};
newRows.push(obj);
}
// 使用Vue的响应式更新方法来插入新数据
// 这里假设tableData是通过reactive函数创建的响应式数据
state.tableData.splice(idx, 0,...newRows);
};
// 删除行
const delRow = () => {
ElMessageBox.confirm(`此操作将永久删除该行, 是否继续 ?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
showMenu.value = false;
curTarget.value.rowIdx !== null && state.tableData.splice(curTarget.value.rowIdx!, 1);
ElMessage.success('删除成功');
})
.catch(() => ElMessage.info('已取消删除'));
};
// 定位菜单/编辑框
const locateMenuOrEditInput = (eleId: string, distance: number, event: MouseEvent) => {
if (window.innerWidth < 1130 || window.innerWidth < 660)
return ElMessage.warning('窗口太小,已经固定菜单位置,或请重新调整窗口大小');
const ele = document.getElementById(eleId) as HTMLElement;
const x = event.pageX;
const y = event.clientY + 200; //右键菜单位置 Y
let left = x + distance + 200; //右键菜单位置 X
let top;
if (eleId == 'editInput') {
// 左键
top = y + distance;
left = x + distance + 50;
} else {
// 右键
top = y + distance + 180;
left = x + distance + 270;
}
ele.style.left = `${left}px`;
ele.style.top = `${top}px`;
};
// 缓存每个分组的合并信息
const mergeCache = ref<Record<number, [number, number]>>({});
// 合并单元格的方法
const objectSpanMethod = ({
row,
column,
rowIndex,
columnIndex,
}) => {
if (columnIndex === 1) {
// 检查缓存中是否已经存在该行的合并数据
if (mergeCache.value[rowIndex]) {
return mergeCache.value[rowIndex];
}
// 计算需要的合并行数
let rowspan = 1;
for (let i = rowIndex + 1; i < tableData.value.length; i++) {
if (tableData.value[i].bigTargetClassify === row.bigTargetClassify) {
rowspan++;
} else {
break;
}
}
// 更新缓存
mergeCache.value[rowIndex] = [rowspan, 1];
// 对于合并行中的其他行,缓存 [0, 0] 表示隐藏
for (let i = 1; i < rowspan; i++) {
mergeCache.value[rowIndex + i] = [0, 0];
}
return [rowspan, 1];
}
};
// 当数据变化时清除缓存
watch(tableData, () => {
mergeCache.value = {};
});
// 字符串格式转换
function filterHtml(text){
if(text) {
let text1 = String(text);
return text1.replace(/(\r\n|\n)/g, '<br/>')
}else{
return text
}
}
</script>
<style lang="scss" scoped>
:deep(.el-textarea__inner){
padding: 0;
}
.cell-text{
width: 100%;
min-height: 100%;
// min-height: 42px;
}
/* 右键 */
#contextmenu {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 999;
top: 0;
left: 0;
height: auto;
width: 180px;
border-radius: 3px;
border: #e2e2e2 1px solid;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
background-color: #fff;
border-radius: 6px;
padding: 15px 10px 14px 12px;
button {
display: block;
margin: 0 0 5px;
width: 100%;
}
}
.dialog-footer{
display: flex;
justify-content: space-between;
}
</style>