前言
有许多库可以可以实现excel表格的预览、编辑等功能,比如Handsontable、Ag-Grid、Luckysheet等等。但是为什么个人要自己手动实现呢?原因是因为这些库要么停止维护了,要么有些库有问题,用起来很麻烦,更更更重要的是有些库是要付费的(资深白嫖党绝对不能接受!!!),再加上自己的需求和页面风格等等,最后决定自己手动实现。
废话少说,先上图,看完图后不想复制代码的可以关闭该标签页了:
好了,当你看到这句话时就表明你很大概率想复制代码了,那就开始动手实现吧。
引入依赖
npm install exceljs #该库用来解析excel文档
该库相关介绍就不说了,如果想仔细去了解的话,点击:exceljs的github地址;
文档解析
文档上传使用的是Element-Plus的el-upload
组件,当文件状态改变时会执行on-change
函数,所以我们可以在该函数内解析excel文档。
interface ExcelTableProps {
[tag: string]: {
headers: any[],
bodyData: any[][]
}
}
这是文档解析后的数据类型,因为一个excel文档可能有多张表,所以用一个对象来保存,其中对象的键值tag
是表名;每张表由表头headers
和具体内容bodyData
组成。因为表头指的是第一行,所以用一个数组就行,而具体内容则对应的是二维数组;
读取文档内容(代码讲解看注释):
const fileContent = ref<ExcelTableProps>({} as ExcelTableProps);
const handleChange = async (uploadFile: UploadFile) => {
fileContent.value = {};
const workbook = new ExcelJS.Workbook();
const buffer = await uploadFile.raw?.arrayBuffer();
if (!buffer) return;
//加载文件内容
await workbook.xlsx.load(buffer);
//遍历文档中没一张表
workbook.eachSheet((worksheet, sheetId) => {
const headers: any[] = [];//表头的内容,也就是第一行
const bodyData: any[][] = [];//表里具体的内容
//行读取
worksheet.eachRow((row, rowNumber) => {
const rowValues = (row.values as any[]).slice(1);//获取每行数据后删掉第一个,第一个一般都是empty值
if (rowNumber === 1) {
headers.push(...rowValues);
} else {
bodyData.push(rowValues);
}
});
//以表名作为键值,没有则手动生成一个
if (worksheet.name) {
fileContent.value[worksheet.name] = {
headers,
bodyData,
};
} else {
fileContent.value[`Sheet${sheetId}`] = {
headers,
bodyData,
};
}
})
}
到这里为止,一张excel文档就被解析完成了,接下来就是展示文档的内容。这里要用到Element-Plus的el-tabs
组件来模拟excel中表名的列表,但不同的是,excel中的表名是在下面,而el-tabs
的表名是在上面,所以需要做样式穿透,具体代码如下(代码讲解看注释):
ExcelTable组件:
<script setup lang="ts">
import {ref, watch} from "vue";
interface PropsObj {
data: ExcelTableProps
}
const props = defineProps<PropsObj>();
const editableTabsValue = ref(Object.keys(props.data)[0] || '');
/** 表格数据 */
const tableData = ref<any[][]>([]);
const headerData = ref<any[]>([]);
const sheets = ref<string[]>(Object.keys(props.data));
function setData() {
if (editableTabsValue.value !== '') {
const {bodyData, headers} = props.data[editableTabsValue.value];
tableData.value = [...bodyData];
headerData.value = [...headers];
}
}
setData();
/** 每列的宽度(初始化为 150px) */
const columnWidths = ref<number[]>(headerData.value.map(() => 150));
/** 当前调整的列 */
const resizingColumn = ref<number | null>(null); // 当前正在调整的列
const startX = ref<number>(0); // 鼠标按下的起始位置
const startWidth = ref<number>(0); // 列的初始宽度
/** 监听表头变化,动态初始化列宽 */
watch(
() => headerData,
(newHeaders) => {
columnWidths.value = newHeaders.value.map(() => 150); // 每列初始化为 150px 宽
}
);
/** 开始调整列宽 */
const startResize = (event: MouseEvent, colIndex: number) => {
resizingColumn.value = colIndex;
startX.value = event.clientX;
startWidth.value = columnWidths.value[colIndex];
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", stopResize);
};
/** 处理鼠标拖动事件 */
const handleMouseMove = (event: MouseEvent) => {
if (resizingColumn.value !== null) {
const delta = event.clientX - startX.value; // 鼠标移动的距离
columnWidths.value[resizingColumn.value] = Math.max(
startWidth.value + delta,
50 // 设置列宽最小值
);
}
};
/** 停止调整列宽 */
const stopResize = () => {
resizingColumn.value = null;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", stopResize);
};
/** 更新单元格数据 */
const updateCell = (rowIndex: number, colIndex: number, event: Event) => {
tableData.value[rowIndex][colIndex] = (event.target as HTMLElement).innerText;
};
const updateHeadCell = (colIndex: number, event: Event) => {
headerData.value[colIndex] = (event.target as HTMLElement).innerText;
}
</script>
<template>
<div class="excel">
<div class="table-container">
<el-tabs
v-model="editableTabsValue"
type="card"
class="demo-tabs"
@tab-change="setData">
<el-tab-pane v-for="(item,index) in sheets" :key="`tab-${index}`" :label="item" :name="item">
<table border="1">
<thead>
<tr>
<th></th>
<th
v-for="(header, colIndex) in headerData"
:key="colIndex"
:style="{ width: columnWidths[colIndex] + 'px' }"
contenteditable="true"
@input="updateHeadCell( colIndex, $event)"
>
<div class="header-content">
{{ header }}
<div
class="resize-handle"
@mousedown="startResize($event, colIndex)"
></div>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in tableData" :key="`t-${rowIndex}`">
<td>{{ rowIndex + 1 }}</td>
<td
v-for="(cell, colIndex) in row"
:key="`${rowIndex}-${colIndex}`"
contenteditable="true"
@input="updateCell(rowIndex, colIndex, $event)"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<style scoped lang="scss">
.excel {
height: calc(100% - 40px);
padding: 10px;
background-color: $pure-white;
margin: 10px;
}
.table-container {
height: 100%;
overflow: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
background: #ffffff;
}
&::-webkit-scrollbar-thumb {
width: 6px;
height: 6px;
background: rgba(100, 100, 100, .5);
}
}
table {
border-collapse: collapse;
margin-bottom: 10px;
border: 1px solid #ddd;
}
th:not([contenteditable="true"]), td:not([contenteditable="true"]) {
background-color: #f4f4f4;
width: 70px;
text-align: center;
}
th[contenteditable="true"],
td[contenteditable="true"] {
padding: 8px;
text-align: left;
position: relative;
box-sizing: border-box;
outline: none;
&:focus {
background-color: #ededed;
}
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.resize-handle {
width: 5px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
background-color: transparent;
}
:deep(.el-tabs) {
display: block !important;
.is-active {
background-color: #67C23A;
color: #FFFFFF;
}
.is-icon-close {
display: none;
}
.el-tabs__header {
background: #ffffff;
position: absolute;
width: calc(100% - 47px);
top: calc(100% - 61px);
left: 19px;
border: 1px solid #dbdcdf;
margin-bottom: 0;
}
.el-tabs__content{
margin-bottom: 31px !important;
}
}
</style>
最后的效果没有像前面的图片那样,可能是因为该组件的父元素需要配一些样式。比如:表明是固定在下面的,所以父元素需要加上position: relative;
等属性;反正样式自己调吧,代码复制粘贴就能用。
最后,经过这两天的时间终于把它弄出来了,所以就分享出来给你们,因为我开心,哈哈哈哈!!!