前期回顾
《 穿越时空的代码、在回首:Evil.js两年后的全新解读 》-CSDN博客
Vue3 + TS + Element-Plus 封装Tree组件 《亲测可用》_ https://blog.csdn.net/m0_57904695/article/details/131664157?spm=1001.2014.3001.5501
态表格 自由编辑
目录
♻️ 效果图
🚩 Vue2 版本
🐗 Vue3 版本
♻️ 效果图
👉 在线预览
🚩 Vue2 版本
<template>
<!-- 可编辑表格V2 -->
<div id="hello">
<!-- 表格 -->
<p class="tips">单击 右键菜单,单击 左键编辑</p>
<el-table
:data="tableData"
height="500px"
border
style="width: 100%; margin-top: 10px"
@cell-click="cellDblclick"
@header-contextmenu="(column, event) => rightClick(null, column, event)"
@row-contextmenu="rightClick"
:row-class-name="tableRowClassName"
>
<el-table-column
v-if="columnList.length > 0"
type="index"
:label="'No.'"
/>
<el-table-column
v-for="(col, idx) in columnList"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:index="idx"
/>
</el-table>
<div>
<h3 style="text-align: center">实时数据展示</h3>
<label>当前目标:</label>
<p>{{ JSON.stringify(curTarget) }}</p>
<label>表头:</label>
<p v-for="col in columnList" :key="col.prop">{{ JSON.stringify(col) }}</p>
<label>数据:</label>
<p v-for="(data, idx) in tableData" :key="idx">
{{ JSON.stringify(data) }}
</p>
</div>
<!-- 右键菜单框 -->
<div v-show="showMenu" id="contextmenu" @mouseleave="showMenu = false">
<p style="margin-bottom: 10px">列:</p>
<el-button size="mini" type="primary" @click="addColumn()">
前方插入一列
</el-button>
<el-button size="mini" type="primary" @click="addColumn(true)">
后方插入一列
</el-button>
<el-button
type="primary"
size="mini"
@click="openColumnOrRowSpringFrame('列')"
>
删除当前列
</el-button>
<el-button size="mini" type="primary" @click="renameCol($event)">
更改列名
</el-button>
<div class="line"></div>
<p style="margin-bottom: 12px">行:</p>
<el-button
size="mini"
type="primary"
@click="addRow()"
v-show="!curTarget.isHead"
>
上方插入一行
</el-button>
<el-button
size="mini"
type="primary"
@click="addRow(true)"
v-show="!curTarget.isHead"
>
下方插入一行
</el-button>
<el-button
size="mini"
type="primary"
@click="addRowHead(true)"
v-show="curTarget.isHead"
>
下方插入一行
</el-button>
<el-button
type="primary"
size="mini"
@click="openColumnOrRowSpringFrame('行')"
v-show="!curTarget.isHead"
>
删除当前行
</el-button>
</div>
<!-- 单元格/表头内容编辑框 -->
<div v-show="showEditInput" id="editInput">
<el-input
v-focus
placeholder="请输入内容"
v-model="curTarget.val"
clearable
@change="updTbCellOrHeader"
@blur="showEditInput = false"
@keyup="onKeyUp($event)"
>
<template #prepend>{{ curColumn.label || curColumn.prop }}</template>
</el-input>
</div>
</div>
</template>
<script>
export default {
data() {
return {
columnList: [
{ prop: "name", label: "姓名" },
{ prop: "age", label: "年龄" },
{ prop: "city", label: "城市" },
{ prop: "tel", label: "电话" }
],
tableData: [
{ name: "张三", age: 24, city: "广州", tel: "13312345678" },
{ name: "李四", age: 25, city: "九江", tel: "18899998888" }
],
showMenu: false, // 显示右键菜单
showEditInput: false, // 显示单元格/表头内容编辑输入框
curTarget: {
// 当前目标信息
rowIdx: null, // 行下标
colIdx: null, // 列下标
val: null, // 单元格内容/列名
isHead: undefined // 当前目标是表头?
},
countCol: 0 // 新建列计数
};
},
computed: {
curColumn() {
return this.columnList[this.curTarget.colIdx] || {};
}
},
methods: {
// 删除当前列或当前行
openColumnOrRowSpringFrame(type) {
this.$confirm(
`此操作将永久删除该 ${type === "列" ? "列" : "行"}, 是否继续 ?, '提示'`,
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}
)
.then(() => {
if (type === "列") {
this.delColumn();
} else if (type === "行") {
this.delRow();
}
this.$message({
type: "success",
message: "删除成功!"
});
})
.catch(() => {
this.$message({
type: "info",
message: "已取消删除"
});
});
},
// 回车键关闭编辑框
onKeyUp(e) {
if (e.keyCode === 13) {
this.showEditInput = false;
}
},
// 单元格双击事件 - 更改单元格数值
cellDblclick(row, column, cell, event) {
this.showEditInput = false;
if (column.index == null) return;
this.locateMenuOrEditInput("editInput", 200, event); // 编辑框定位
this.showEditInput = true;
// 当前目标
this.curTarget = {
rowIdx: row.row_index,
colIdx: column.index,
val: row[column.property],
isHead: false
};
},
// 单元格/表头右击事件 - 打开菜单
rightClick(row, column, event) {
// 阻止浏览器自带的右键菜单弹出
event.preventDefault(); // window.event.returnValue = false
this.showMenu = false;
if (column.index == null) return;
this.locateMenuOrEditInput("contextmenu", 140, event); // 菜单定位
this.showMenu = true;
// 当前目标
this.curTarget = {
rowIdx: row ? row.row_index : null, // 目标行下标,表头无 row_index
colIdx: column.index, // 目标项下标
val: row ? row[column.property] : column.label, // 目标值,表头记录名称
isHead: !row
};
},
// 去更改列名
renameCol($event) {
this.showEditInput = false;
if (this.curTarget.colIdx === null) return;
this.locateMenuOrEditInput("editInput", 200, $event); // 编辑框定位
this.showEditInput = true;
},
// 更改单元格内容/列名
updTbCellOrHeader(val) {
if (!this.curTarget.isHead)
// 更改单元格内容
this.tableData[this.curTarget.rowIdx][this.curColumn.prop] = val;
else {
// 更改列名
if (!val) return;
this.columnList[this.curTarget.colIdx].label = val;
}
},
// 新增行
addRow(later) {
this.showMenu = false;
const idx = later ? this.curTarget.rowIdx + 1 : this.curTarget.rowIdx;
let obj = {};
this.columnList.forEach((p) => (obj[p.prop] = ""));
this.tableData.splice(idx, 0, obj);
},
// 表头下新增行
addRowHead() {
// 关闭菜单
this.showMenu = false;
// 新增行
let obj = {};
// 初始化行数据
this.columnList.forEach((p) => (obj[p.prop] = ""));
// 插入行数据
this.tableData.unshift(obj);
},
// 删除行
delRow() {
this.showMenu = false;
this.curTarget.rowIdx !== null &&
this.tableData.splice(this.curTarget.rowIdx, 1);
},
// 新增列
addColumn(later) {
this.showMenu = false;
const idx = later ? this.curTarget.colIdx + 1 : this.curTarget.colIdx;
const colStr = { prop: "col_" + ++this.countCol, label: "" };
this.columnList.splice(idx, 0, colStr);
this.tableData.forEach((p) => (p[colStr.prop] = ""));
},
// 删除列
delColumn() {
this.showMenu = false;
this.tableData.forEach((p) => {
delete p[this.curColumn.prop];
});
this.columnList.splice(this.curTarget.colIdx, 1);
},
// 添加表格行下标
tableRowClassName({ row, rowIndex }) {
row.row_index = rowIndex;
},
// 定位菜单/编辑框
locateMenuOrEditInput(eleId, eleWidth, event) {
let ele = document.getElementById(eleId);
ele.style.top = event.clientY - 100 + "px";
ele.style.left = event.clientX - 50 + "px";
if (window.innerWidth - eleWidth < event.clientX) {
ele.style.left = "unset";
ele.style.right = 0;
}
}
}
};
</script>
<style lang="scss" scoped>
#hello {
position: relative;
height: 100%;
width: 100%;
}
.tips {
margin-top: 10px;
color: #999;
}
#contextmenu {
position: absolute;
top: 0;
left: 0;
height: auto;
width: 120px;
border-radius: 3px;
box-shadow: 0 0 10px 10px #e4e7ed;
background-color: #fff;
border-radius: 6px;
padding: 15px 0 10px 15px;
button {
display: block;
margin: 0 0 5px;
}
}
.hideContextMenu {
position: absolute;
top: -4px;
right: -5px;
}
#editInput,
#headereditInput {
position: absolute;
top: 0;
left: 0;
height: auto;
min-width: 200px;
max-width: 400px;
padding: 0;
}
#editInput .el-input,
#headereditInput .el-input {
outline: 0;
border: 1px solid #c0f2f9;
border-radius: 5px;
box-shadow: 0px 0px 10px 0px #c0f2f9;
}
.line {
width: 100%;
border: 1px solid #e4e7ed;
margin: 10px 0;
}
</style>
🐗 Vue3 版本
<template>
<div id="table-wrap">
<!-- 可编辑表格-Vue3 + ElementPlus -->
<el-table
:data="questionChoiceVOlist"
stripe
border
@cell-click="cellClick"
@row-contextmenu="rightClick"
:row-class-name="tableRowClassName"
@header-contextmenu="(column: any, event: MouseEvent) => rightClick(null, column, event)"
>
<el-table-column
type="index"
label="序号"
align="center"
:resizable="false"
width="70"
/>
<template #empty>
<el-empty description="暂无数据" />
</template>
<el-table-column
:resizable="false"
align="center"
v-for="(col, idx) in columnList"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:index="idx"
>
<template #default="{ row }">
<div
v-if="col.type === 'button'"
style="height: 75px; padding-top: 26px; width: 100%"
>
<el-badge type="warning" :value="getRiskLenght(row.riskIds)">
<el-button size="small">
{{ paramsIdType == 'detail' ? '查看' : '选择' }}
</el-button>
</el-badge>
</div>
<el-input-number
v-if="col.type === 'input-number'"
v-model.number="row[col.prop]"
:min="0"
:max="10"
:step="0.1"
:precision="2"
/>
</template>
</el-table-column>
</el-table>
<!-- 右键菜单框 -->
<div v-show="showMenu" id="contextmenu" @mouseleave="showMenu = false">
<p style="margin-bottom: 10px; text-align: left">列:</p>
<el-button :icon="CaretTop" @click="addColumn(false)"> 前方插入一列 </el-button>
<el-button :icon="CaretBottom" @click="addColumn(true)"> 后方插入一列 </el-button>
<el-button :icon="DeleteFilled" @click="openColumnOrRowSpringFrame('列')">
删除当前列
</el-button>
<el-button @click="renameCol" :icon="EditPen"> 更改列名 </el-button>
<div style="color: #ccc">-----------------------</div>
<p style="margin-bottom: 12px">行:</p>
<el-button :icon="CaretTop" @click="addRow(false)" v-show="!curTarget.isHead">
上方插入一行
</el-button>
<el-button :icon="CaretBottom" @click="addRow(true)" v-show="!curTarget.isHead">
下方插入一行
</el-button>
<el-button :icon="DeleteFilled" @click="addRowHead" v-show="curTarget.isHead">
下方插入一行
</el-button>
<el-button
:icon="DeleteFilled"
@click="openColumnOrRowSpringFrame('行')"
v-show="!curTarget.isHead"
>
删除当前行
</el-button>
</div>
<!-- 输入框 -->
<div v-show="showEditInput" id="editInput">
<el-input
ref="iptRef"
placeholder="请输入内容"
v-model="curTarget.val"
clearable
@change="updTbCellOrHeader"
@blur="showEditInput = false"
@keyup="onKeyUp($event)"
>
<template #prepend>{{ curColumn.label || curColumn.prop }}</template>
</el-input>
</div>
<!-- 实时数据展示 Start-->
<!--
第二个和第三个参数来格式化JSON输出,其中null作为替换函数(这里不进行替换),2表示缩进级别。
这样JSON数据会以格式化的形式展示,增加了可读性
-->
<div>
<h3 style="text-align: center; margin-top: 15px">实时数据展示</h3>
<label>当前目标:</label>
<pre><code>{{ JSON.stringify(curTarget, null, 2) }}</code></pre>
<div style="width: 100%; height: auto">
<label>表头:</label>
<pre><code v-for="col in columnList" :key="col.prop">{{ JSON.stringify(col, null, 2) }}</code></pre>
</div>
<div>
<label>数据:</label>
<pre><code v-for="(data, idx) in questionChoiceVOlist" :key="idx">
{{ JSON.stringify(data, null, 2) }}
</code></pre>
</div>
</div>
<!-- 实时数据展示 End-->
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, toRefs, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { DeleteFilled, CaretBottom, CaretTop, EditPen } from '@element-plus/icons-vue';
// Tips: locateMenuOrEditInput 可调整编辑框位置
interface Column {
prop: string;
label: string;
type?: string;
}
interface Data {
choiceCode: string;
choiceContent: string;
riskIds: string;
itemScore: string | number;
[key: string]: unknown;
}
interface Target {
rowIdx: number | null;
colIdx: number | null;
val: string | null;
isHead: boolean | undefined;
}
// 接收addEdit父组件传过来的数据,用于判断是新增、编辑、详情页面
const paramsIdType = 'detail';
const state = reactive({
columnList: [
{ prop: 'choiceCode', label: '选项编码' },
{ prop: 'choiceContent', label: '选项内容' },
{ prop: 'riskIds', label: '风险点', type: 'button' },
{ prop: 'itemScore', label: '选项分值', type: 'input-number' },
] as Column[],
questionChoiceVOlist: [
{
choiceCode: 'A',
choiceContent: '是',
riskIds: '45,47',
itemScore: 1,
isClickCheckBtn: true,
id: 1,
},
{
choiceCode: 'B',
choiceContent: '否',
riskIds: '46',
itemScore: 4,
isClickCheckBtn: true,
id: 2,
},
{
choiceCode: 'C',
choiceContent: '否',
riskIds: '',
itemScore: 4,
isClickCheckBtn: true,
id: 3,
},
] as Data[],
showMenu: false, // 显示右键菜单
showEditInput: false, // 显示单元格/表头内容编辑输入框
curTarget: {
// 当前目标信息
rowIdx: null, // 行下标
colIdx: null, // 列下标
val: null, // 单元格内容/列名
isHead: undefined, // 当前目标是表头?
} as Target,
countCol: 0, // 新建列计数
});
const iptRef = ref();
const { columnList, questionChoiceVOlist, showMenu, showEditInput, curTarget } = toRefs(state);
// 当前列
const curColumn = computed(() => {
return curTarget.value.colIdx !== null
? columnList.value[curTarget.value.colIdx]
: { prop: '', label: '' };
});
// 计算风险点数量
const getRiskLenght = computed(() => {
return (riskIds: string) => riskIds.split(',').filter(Boolean).length;
});
/**
* 删除列/行
* @method delColumn
* @param { string } type - '列' | '行'
**/
const openColumnOrRowSpringFrame = (type: string) => {
ElMessageBox.confirm(`此操作将永久删除该${type === '列' ? '列' : '行'}, 是否继续 ?, '提示'`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
if (type === '列') {
delColumn();
} else if (type === '行') delRow();
ElMessage.success('删除成功');
})
.catch(() => ElMessage.info('已取消删除'));
};
// 回车键关闭编辑框
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
showEditInput.value = false;
}
};
// 控制某字段不能打开弹框
const isPop = (column: { label: string }) => {
return column.label === '风险点' || column.label === '选项分值';
};
// 左键输入框
const cellClick = (
row: { [x: string]: any; row_index: any },
column: { index: null; property: string | number; label: string },
_cell: any,
event: MouseEvent
) => {
// 如果是风险点或选项分值,不执行后续代码
if (isPop(column)) return;
iptRef.value.focus();
if (column.index == null) return;
locateMenuOrEditInput('editInput', -300, event); // 左键输入框定位 Y
showEditInput.value = true;
iptRef.value.focus();
// 当前目标
curTarget.value = {
rowIdx: row.row_index,
colIdx: column.index,
val: row[column.property],
isHead: false,
};
};
// 表头右击事件 - 打开菜单
const rightClick = (row: any, column: any, event: MouseEvent) => {
event.preventDefault();
if (column.index == null) return;
// 如果tableData有数据并且当前目标是表头,那么就返回,不执行后续操作
// if (questionChoiceVOlist.value.length > 0 && !row) return;
if (isPop(column)) return;
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 renameCol = () => {
showEditInput.value = false;
if (curTarget.value.colIdx === null) return;
showEditInput.value = true;
nextTick(() => {
iptRef.value.focus();
});
};
// 更改单元格内容/列名
const updTbCellOrHeader = (val: string) => {
if (!curTarget.value.isHead) {
if (curTarget.value.rowIdx !== null) {
(questionChoiceVOlist.value[curTarget.value.rowIdx] as Data)[curColumn.value.prop] =
val;
}
} else {
if (!val) return;
if (curTarget.value.colIdx !== null) {
columnList.value[curTarget.value.colIdx].label = val;
}
}
};
// 新增行
const addRow = (later: boolean) => {
showMenu.value = false;
const idx = later ? curTarget.value.rowIdx! + 1 : curTarget.value.rowIdx!;
let obj: any = {};
columnList.value.forEach((p) => obj[p.prop]);
questionChoiceVOlist.value.splice(idx, 0, obj);
// 设置新增行数据默认值
questionChoiceVOlist.value[idx] = {
choiceCode: '',
choiceContent: '',
riskIds: '',
itemScore: 0,
id: Math.floor(Math.random() * 100000),
};
};
// 表头下新增行
const addRowHead = () => {
showMenu.value = false;
let obj: any = {};
columnList.value.forEach((p) => obj[p.prop]);
questionChoiceVOlist.value.unshift(obj);
questionChoiceVOlist.value[0] = {
choiceCode: '',
choiceContent: '',
riskIds: '',
itemScore: 0,
id: Math.floor(Math.random() * 100000),
};
};
// 删除行
const delRow = () => {
showMenu.value = false;
curTarget.value.rowIdx !== null &&
questionChoiceVOlist.value.splice(curTarget.value.rowIdx!, 1);
};
// 新增列
const addColumn = (later: boolean) => {
showMenu.value = false;
const idx = later ? curTarget.value.colIdx! + 1 : curTarget.value.colIdx!;
const colStr = { prop: 'Zk-NewCol - ' + ++state.countCol, label: '' };
columnList.value.splice(idx, 0, colStr);
questionChoiceVOlist.value.forEach((p) => (p[colStr.prop] = ''));
};
// 删除列
const delColumn = () => {
showMenu.value = false;
questionChoiceVOlist.value.forEach((p) => {
delete p[curColumn.value.prop];
});
columnList.value.splice(curTarget.value.colIdx!, 1);
};
// 添加表格行下标
const tableRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
row.row_index = rowIndex;
};
// 定位菜单/编辑框
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 - 120;
} else {
// 右键
top = y + distance + 170;
}
ele.style.left = `${left}px`;
ele.style.top = `${top}px`;
};
defineExpose({
questionChoiceVOlist,
});
</script>
<style lang="scss" scoped>
#table-wrap {
width: 100%;
height: 100%;
/* 左键 */
#contextmenu {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 999999;
top: 0;
left: 0;
height: auto;
width: 200px;
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;
}
}
/* 右键 */
#editInput {
position: absolute;
top: 0;
left: 0;
z-index: 999999;
}
/* 实时数据 */
pre {
border: 1px solid #cccccc;
padding: 10px;
overflow: auto;
}
}
</style>
_______________________________ 期待再见 _______________________________