一、功能
在vue3项目中实现可编辑的图表,可以点击单元格动态录入表头和表项数据。鼠标右键单击弹出自定义菜单,具有行列的复制、粘贴、插入、删除等操作。
二、实现
1.表格与数据结构
表格我直接采用了element-ui -> el-table 的结构。为了实现表项的自定义编辑,还需要借助el-table的内置插槽进行处理。
html
<el-table
:data="tableData"
height="300"
border
style="width: 100%;z-index: 2;"
:header-cell-style="{
'font-size': '1.2rem',
'line-height': '2rem',
'font-weight': '600',
}">
<el-table-column label="" type="index" width="40" fixed></el-table-column>
<el-table-column
v-for="(column,index) in columnList"
:prop="column.prop"
:label="column.label"
>
<!-- 自定义表头 -->
<template #header>
<p> {{column.label}} </p>
</template>
<template #default="{ row }">
<p> {{column.label}} </p>
</template>
</el-table-column>
</el-table>
接下来构建表内数据tableData和columnList的结构。因为想要实现点击单元格可以输入数据,那么就需要添加一个"show"属性控制当前表格是否为编辑模式,也就是说控制当前单元格是`<p> {{column.label}} </p>`展示形式还是`<el-input />`输入形式。数据结构示例如下:
javascript
const tableData = ref([
{
'日期':{ content: '2024-08-08', show: true },
'姓名':{ content: '张三', show: true },
'年龄':{ content: 12, show: true },
},
{
'日期':{ content: '2024-08-08', show: true },
'姓名':{ content: '张三', show: true },
'年龄':{ content: 12, show: true },
}
])
const columnList = ref([
{ prop:"日期", label:"日期", show:true, },
{ prop:"姓名", label:"姓名", show:true, },
{ prop:"年龄", label:"年龄", show:true, }
])
借助"show"属性我们就可以完善一下表格结构:
html
<el-table
:data="tableData"
height="300"
border
style="width: 100%;z-index: 2;"
:header-cell-style="{
'font-size': '1.2rem',
'line-height': '2rem',
'font-weight': '600',
}">
<el-table-column label="" type="index" width="40" fixed></el-table-column>
<el-table-column
v-for="(column,index) in columnList"
:prop="column.prop"
:label="column.label"
>
<!-- 自定义表头 -->
<template #header>
<p
v-show="column.show"
>
{{column.label}}
</p>
<el-input
v-show="!column.show"
v-model="column.label"
>
</el-input>
</template>
<template #default="{ row }">
<p
v-show="row[column.prop].show"
>
{{row[column.prop].content}}
</p>
<el-input
type="textarea"
:autosize="{minRows:1,maxRows:4}"
v-show="!row[column.prop].show"
v-model="row[column.prop].content"
>
</el-input>
</template>
</el-table-column>
</el-table>
2.单元格内编辑输入标签与展示标签切换的函数事件
为单元格内标签添加双击事件dblclick控制"show"属性修改,同时在el-input上添加失去焦点也可以取消编辑的函数事件。(取消编辑同时添加双击事件和失去焦点事件是因为若只添加双击事件,用户在编辑后必须要双击才能将输入框变为展示标签;若只添加失去焦点事件,用户必须在双击控制输入框显示后,点击输入框聚焦,再点击外部失焦才可以将输入框隐藏)
html
<el-table
:data="tableData"
height="300"
border
style="width: 100%;z-index: 2;"
:header-cell-style="{
'font-size': '1.2rem',
'line-height': '2rem',
'font-weight': '600',
}">
<el-table-column label="" type="index" width="40" fixed></el-table-column>
<el-table-column
v-for="(column,index) in columnList"
:prop="column.prop"
:label="column.label"
>
<!-- 自定义表头 -->
<template #header>
<p
v-show="column.show"
@dblclick="$event => handleEdit(column, $event.target)"
>
{{column.label}}
</p>
<el-input
v-show="!column.show"
v-model="column.label"
@dblclick="column.show = !column.show"
@blur="column.show = true">
</el-input>
</template>
<template #default="{ row }">
<p
v-show="row[column.prop].show"
@dblclick="$event => handleEdit(row[column.prop], $event.target)"
>
{{row[column.prop].content}}
</p>
<el-input
type="textarea"
:autosize="{minRows:1,maxRows:4}"
v-show="!row[column.prop].show"
v-model="row[column.prop].content"
@dblclick="row[column.prop].show = !row[column.prop].show"
@blur="row[column.prop].show = true">
</el-input>
</template>
</el-table-column>
</el-table>
javascript
function handleEdit(cell, pEl) {
console.log("双击被调用");
const editIputEl = Array.from(pEl.nextSibling.childNodes).find(n => ['INPUT','TEXTAREA'].includes(n.tagName))
cell.show = false
editIputEl && nextTick(() => {
editIputEl.focus()
})
}
3.清除浏览器默认鼠标右键事件,修改为自定义事件。
html
<el-table
:data="tableData"
height="700"
border
@header-contextmenu="(column, $event) => rightClick(null, column, $event)"
@row-contextmenu="rightClick"
:row-class-name="tableRowClassName"
style="width: 100%;z-index: 4;"
:header-cell-style="{
'font-size': '1.2rem',
'line-height': '3.2rem',
'font-weight': '600',
}">
</el-table>
对表行和表头添加鼠标右键点击事件。
javascript
const curTarget = ref(
{ // 当前目标信息
rowIdx: null, // 行下标
colIdx: null, // 列下标
isHead: undefined // 当前目标是表头?
}
)
function rightClick(row, column, $event) {
// 阻止浏览器自带的右键菜单弹出
$event.preventDefault()
if(column.index === null) return
// 表格容器的位置
const { x: tbX, y: tbY } = tbContainerRef.value.getBoundingClientRect()
// 当前鼠标位置
const { x: pX, y: pY } = $event
// 定位菜单
const ele = document.getElementById('rightMenu')
ele.style.top = pY + 'px'
ele.style.left = pX + 'px'
// 边界调整
if(window.innerWidth - 140 < pX - tbX) {
ele.style.left = 'unset'
ele.style.right = 0
}
ele.style.display="block" // 原生代码,显示
// 当前目标
curTarget.value = {
rowIdx: row ? row.row_index : null,
colIdx: column.index,
isHead: !row
}
}
菜单结构:
javascript
<div id="rightMenu" class="rightMenu" >
<div class="item" @click="copyCurrentRow">
<span style="margin-left: 4px"> 复制当前行</span>
</div>
<div class="item" @click="pasteCurrentRow">
<span style="margin-left: 4px"> 粘贴到当前行</span>
</div>
<div class="item" @click="copyCurrentColumn">
<span style="margin-left: 4px"> 复制当前列</span>
</div>
<div class="item add-column" @click="pasteCurrentColumn">
<span style="margin-left: 4px"> 粘贴到当前列</span>
<div class="column-box"></div>
</div>
<div class="item delete-table">
<el-popconfirm title="确定删除该行吗?" @confirm="delRow" placement="top">
<template #reference>
<span style="margin-left: 4px"> 删除当前行</span>
</template>
</el-popconfirm>
</div>
<div class="item delete-table" >
<el-popconfirm title="确定删除该列吗?" @confirm="delColumn" placement="top">
<template #reference>
<span style="margin-left: 4px"> 删除当前列</span>
</template>
</el-popconfirm>
</div>
<div class="item" @click="addRow()">
<span style="margin-left: 4px"> 上方插入*空白行</span>
</div>
<div class="item" @click="addRow(true)">
<span style="margin-left: 4px"> 下方插入空白行</span>
</div>
<div class="item" @click="addColumn()">
<span style="margin-left: 4px"> 左侧插入空白列</span>
</div>
<div class="item" @click="addColumn(true)">
<span style="margin-left: 4px"> 右侧插入空白列</span>
</div>
</div>
除了控制菜单显示还要控制菜单隐藏
const hideMenu = (e) => {
const menu = document.getElementById('rightMenu');
menu.style.display="none" // 原生代码,显示
};
document.addEventListener("click", hideMenu);
4.复制、粘贴、插入、删除等excel表格工具功能。
复制行列内容时,注意一定要使用深拷贝!
javascript
// 新增行
function addRow(later) {
hideMenu()
if(curTarget.value.rowIdx === null) return
const idx = later ? curTarget.value.rowIdx + 1 : curTarget.value.rowIdx
let obj = {}
columnList.value.forEach(p => {
obj[p.prop] = { content: '', show: true }
})
tableData.value.splice(idx, 0, obj)
}
// 删除行
const popperVisibleRow = ref(false)
function delRow() {
hideMenu()
curTarget.value.rowIdx !== null && tableData.value.splice(curTarget.value.rowIdx, 1)
}
const count_col = ref(0)
// 新增列
function addColumn(later) {
hideMenu()
const idx = later ? curTarget.value.colIdx + 1 : curTarget.value.colIdx
let key = 'col_' + ++count_col.value
for(let i =0;i<columnList.value.length;i++){
if(key == columnList.value[i].prop){
key = 'col_' + ++count_col.value
}
}
let obj = { prop: key, label: key, show: true }
columnList.value.splice(idx, 0, obj)
console.log(columnList.value);
tableData.value.forEach(p => {
p[obj.prop] = { content: '', show: true }
})
}
// 删除列
function delColumn() {
hideMenu()
let colKey = columnList.value[curTarget.value.colIdx].prop
columnList.value.splice(curTarget.value.colIdx, 1)
tableData.value.forEach(p => delete p[colKey] )
}
//复制行列 拷贝时深拷贝不能简单的复制
const currentRow = ref({})
const currentColumn = ref({
col:[],
data:[]
})
function copyCurrentRow(){
console.log(curTarget.value.rowIdx,"curTarget.value.rowIdx");
if(curTarget.value.rowIdx === null) return
currentRow.value = deepcopy(tableData.value[curTarget.value.rowIdx])
hideMenu()
}
function copyCurrentColumn(){
console.log(curTarget.value.colIdx,"curTarget.value.colIdx");
const colData = []
currentColumn.value.col = deepcopy(columnList.value[curTarget.value.colIdx])
//复制列名,复制列的数据
for(let i = 0; i < tableData.value.length;i++){
colData.push(deepcopy(tableData.value[i][currentColumn.value.col.prop]))
}
currentColumn.value.data = colData
hideMenu()
}
function pasteCurrentRow(){
if(curTarget.value.rowIdx === null) return
const idx = curTarget.value.rowIdx + 1
currentRow.value.row_index = currentRow.value.row_index + 1
tableData.value.splice(idx, 0, deepcopy(currentRow.value))
console.log(tableData.value);
}
function pasteCurrentColumn(){
const idx = curTarget.value.colIdx + 1
const obj = deepcopy(currentColumn.value.col)
let index = 0
let key = obj.prop + '('+ ++index +')'
for(let i =0;i<columnList.value.length;i++){
if(key == columnList.value[i].prop){
key = obj.prop + '('+ ++index +')'
}
}
obj.prop = key
obj.label = key
columnList.value.splice(idx, 0, obj)
for(let i =0; i <tableData.value.length;i++){
console.log(tableData.value[i][obj.prop],deepcopy(currentColumn.value));
tableData.value[i][obj.prop] = deepcopy(currentColumn.value.data[i])
}
}
5.插入粘贴时prop属性确保唯一(由于业务需求prop不能随意取值,这里的对prop和label进行了同步,prop=label)。
javascript
//编辑列名
function changeColumnLabel(column){
column.show = true;
for(let i = 0; i< tableData.value.length;i++){
tableData.value[i][column.label] = tableData.value[i][column.prop]
//删除原属性数据
delete tableData.value[i][column.prop]
}
column.prop=column.label;
console.log(tableData);
}