最近需求越来越离谱,加班越来越严重,干活的牛马也越来越卑微。写了一个可编辑表格,并已封装好组件,可直接使用。
基于这位大佬的 动态表格自由编辑 方法和思路,于是参考和重写了表格,在基础上增加和删除了一些功能点。
实现功能如下:
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-columntype="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-numberv-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 clearablecollapse-tagscollapse-tags-tooltipplaceholder="请选择"><el-optionv-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]" clearablecollapse-tagsplaceholder="截止日期"><el-optionv-for="item in 15":key="item":label="item + '号'":value="item"/></el-select><divclass="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-dialogv-model="visible"title="请输入行数"width="200"align-centerdraggable><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) returnstate.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; //右键菜单位置 Ylet left = x + distance + 200; //右键菜单位置 Xlet 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>