Vue3后台管理系统(六):表格导出
导出功能特点
- 灵活的配置选项:支持设置文件名、标题、副标题、列宽、排除特定列等
- 数据格式处理:自动识别和处理数字格式,保持与原表格一致
- 样式支持:支持表头样式、单元格边框、对齐方式等样式设置
- 图片导出:支持导出表格中的图片到Excel中
- 多级表头支持:可以处理复杂的多级嵌套表头结构
- 自适应列宽:基于内容自动计算最佳列宽,考虑中英文字符宽度差异
- 用户友好的交互:导出过程中显示加载提示,出错时提供错误信息
技术实现关键点
- DOM操作:通过DOM选择器获取表格结构和数据
- ExcelJS库:利用ExcelJS库创建和格式化Excel文件
- 异步处理:使用async/await处理异步操作,如图片加载
- 递归算法:通过递归方式处理多级表头结构
- 数据转换:将DOM表格数据转换为适合Excel导出的格式
- 单元格合并:处理多级表头中的单元格合并
一、导出功能核心实现
导出功能主要通过src/utils/exportTable.js
文件中的工具函数实现,核心功能包括:
javascript
/**
* @description 表格导出,支持样式、图片、数字格式等
* @Author Wangkaibing
* @Date 2025-04-29
*/
import ExcelJS from 'exceljs' // 使用ExcelJS库处理Excel文件
import { saveAs } from 'file-saver' // 使用file-saver库保存文件
import { ElLoading, ElMessage } from 'element-plus' // 使用Element Plus的UI组件
1. 主要导出入口函数
javascript
/**
* @description 导出Excel
* @param {string} filename 导出文件名
* @param {Object} tableRef BaseTable组件的ref
* @param {Object|Array} numberColumns 数字列配置或表头配置数组
* @param {string} title 主标题
* @param {string} subTitle 副标题
* @param {Object} columnWidth 列宽配置 { min: 最小宽度, max: 最大宽度, padding: 内边距 }
* @param {Array} excludeLabels 需要排除的列标题
*/
export const exportBaseTable = async (
filename,
tableRef,
numberColumns = { labels: [], format: '0.00' },
title = '',
subTitle = '',
columnWidth = { min: 8, max: 40, padding: 4 },
excludeLabels = ['操作'],
) => {
// 获取表格DOM元素
const table = tableRef.value?.$el?.querySelector('.el-table')
if (!table) {
ElMessage.error('找不到表格元素')
return
}
// 确保numberColumns格式正确
let formattedNumberColumns = numberColumns
if (!Array.isArray(numberColumns) && (!numberColumns || !numberColumns.labels)) {
formattedNumberColumns = { labels: [], format: '0.00' }
}
// 判断是否为多级表头
const isMultiLevel = Array.isArray(formattedNumberColumns) && formattedNumberColumns.some((col) => col.children)
try {
// 调用内部导出函数
await exportTableToExcel({
filename,
table,
autoWidth: true,
numberColumns: formattedNumberColumns,
columnWidth,
excludeLabels,
isMultiLevel,
title,
subTitle,
})
} catch (error) {
ElMessage.error(`导出失败:${error.message || error}`)
}
}
2. 核心导出处理函数
实际的导出处理由exportTableToExcel
函数完成:
javascript
export const exportTableToExcel = async (options) => {
// 显示加载遮罩层
const loadingInstance = ElLoading.service({
lock: true,
text: '正在导出Excel...',
background: 'rgba(0, 0, 0, 0.7)',
})
try {
const {
filename = '导出文件',
table,
autoWidth = true,
beforeExport,
excludeLabels = ['操作'],
columnWidth = { min: 8, max: 40, padding: 4 },
numberColumns = { labels: [], format: '0' },
isMultiLevel = false,
title = '',
subTitle = '',
} = options
// 创建工作簿和工作表
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('Sheet1')
// 根据表头类型选择不同的处理方式
if (isMultiLevel && Array.isArray(numberColumns)) {
// 处理多级表头的情况
await exportMultiLevelTable({
workbook,
worksheet,
table,
columns: numberColumns,
autoWidth,
columnWidth,
excludeLabels,
title,
subTitle,
})
} else {
// 处理普通表头的情况
// 获取表头单元格,排除隐藏列和gutter列
const headerCells = Array.from(table.querySelectorAll('.el-table__header-wrapper th:not(.is-hidden):not(.gutter)'))
// 计算要导出的列索引,排除指定标签的列
const exportColumnIndexes = headerCells
.map((th, index) => (!excludeLabels.includes(th.textContent.trim()) ? index : -1))
.filter((index) => index !== -1)
// 过滤并获取表头文本
const headers = headerCells
.filter((th) => !excludeLabels.includes(th.textContent.trim()))
.map((th) => th.textContent.trim())
// 获取数字列配置
const numberLabels = Array.isArray(numberColumns) ? [] : numberColumns.labels || []
const numberFormat = Array.isArray(numberColumns) ? '0.00' : numberColumns.format || '0.00'
const numberColumnIndexes = headers
.map((header, index) => (numberLabels.includes(header) ? index : -1))
.filter((index) => index !== -1)
// 获取表格数据
const rows = table.querySelectorAll('.el-table__body-wrapper tbody tr:not(.el-table__row--hidden)')
const data = Array.from(rows).map((row) => {
const cells = Array.from(row.querySelectorAll('td:not(.is-hidden):not(.gutter)'))
return exportColumnIndexes.map((index) => {
const td = cells[index]
const text = td.textContent.trim()
const filteredIndex = exportColumnIndexes.indexOf(index)
// 处理数值格式
const { value, isNumber, numFormat } = processNumericValue(text)
return {
value,
isNumber,
numFormat: numberColumnIndexes.includes(filteredIndex) ? numberFormat || numFormat : numFormat,
tagType: td.querySelector('.el-tag')?.getAttribute('type') || null,
imgSrc: getImageUrl(td), // 处理单元格中的图片
}
})
})
// 设置列宽和格式
worksheet.columns = headers.map((header, index) => {
// 计算最大宽度(考虑表头和内容)
const maxWidth = Math.min(
Math.max(
getStringWidth(header),
...data.map((row) => getStringWidth(String(row[index].value))),
columnWidth.min
) + columnWidth.padding,
columnWidth.max
)
return {
header,
key: String(index),
width: autoWidth ? maxWidth : 15,
}
})
// 添加标题和副标题
const titleRows = addTitleRows(worksheet, title, subTitle, headers.length)
// 设置表头样式
const headerRow = worksheet.getRow(titleRows + 1)
headerRow.height = 25
headerRow.eachCell((cell) => setCellStyle(cell, true))
// 添加数据和样式
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const rowData = data[rowIndex]
const row = worksheet.addRow([])
row.height = 30
row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
// 处理每个单元格
for (let colIndex = 0; colIndex < rowData.length; colIndex++) {
const cellData = rowData[colIndex]
const cell = row.getCell(colIndex + 1)
// 设置单元格值和样式
cell.value = cellData.value
setCellStyle(cell)
// 为数字单元格设置格式
if (cellData.isNumber) {
cell.numFmt = cellData.numFormat
}
// 处理图片
if (cellData.imgSrc) {
const imageBuffer = await getImageBuffer(cellData.imgSrc)
if (imageBuffer) {
const imageId = workbook.addImage({
buffer: imageBuffer,
extension: 'png',
})
// 添加图片到单元格
worksheet.addImage(imageId, {
tl: { col: colIndex, row: rowIndex + 1 + titleRows },
ext: { width: 35, height: 35 },
editAs: 'oneCell',
})
}
}
}
}
}
// 执行导出前的回调函数
if (beforeExport) {
await beforeExport(worksheet)
}
// 生成Excel文件并下载
const blob = new Blob([await workbook.xlsx.writeBuffer()])
saveAs(blob, `${filename}.xlsx`)
} catch (error) {
console.error('导出失败:', error)
ElMessage.error(`导出失败:${error.message || error}`)
throw error
} finally {
loadingInstance.close() // 关闭加载遮罩
}
}
二、主要辅助函数
1. 文本宽度计算
javascript
/**
* @description 计算字符串显示宽度(中文2个单位,其他1个单位)
* @param {string} str 要计算的字符串
* @returns {number} 字符串的显示宽度
*/
const getStringWidth = (str) => {
// 使用扩展运算符和reduce计算宽度,中文字符占2个单位,其他字符占1个单位
return [...str].reduce(
(width, char) => width + (char.charCodeAt(0) > 127 || char.charCodeAt(0) === 94 ? 2 : 1),
0
)
}
2. 图片处理
javascript
/**
* @description 从URL获取图片的Buffer数据
* @param {string} url 图片URL
* @returns {Promise<Buffer>} 图片buffer
*/
const getImageBuffer = async (url) => {
try {
// 通过fetch获取图片并转换为ArrayBuffer
return await (await fetch(url)).blob().then((blob) => blob.arrayBuffer())
} catch (error) {
console.log('🚀 ~ [获取图片失败]', error)
return null
}
}
/**
* @description 获取DOM元素中的图片URL,支持img、image、el-image等标签
* @param {HTMLElement} element DOM元素
* @returns {string|null} 图片URL
*/
const getImageUrl = (element) => {
// 检查img标签
const img = element.querySelector('img')
if (img?.src) return img.src
// 检查image标签
const image = element.querySelector('image')
if (image?.href?.baseVal) return image.href.baseVal
// 检查el-image组件
const elImageImg = element.querySelector('.el-image img')
if (elImageImg?.src) return elImageImg.src
return null
}
3. 数字值处理
javascript
/**
* @description 处理单元格中的数字值,识别格式
* @param {string} text 单元格文本
* @returns {Object} 处理后的数字信息
*/
const processNumericValue = (text) => {
// 移除千分位分隔符
const cleanText = text.replace(/,/g, '')
let isNumber = false
let value = text
let numFormat = '0'
// 判断是否为数字
if (!isNaN(parseFloat(cleanText)) && isFinite(cleanText)) {
isNumber = true
value = parseFloat(cleanText)
// 判断数字类型并设置合适的格式
if (cleanText.includes('.')) {
// 获取小数位数并保留原始格式
const decimalPlaces = cleanText.split('.')[1]?.length || 0
numFormat = decimalPlaces > 0 ? `0.${'0'.repeat(decimalPlaces)}` : '0'
}
}
return { value, isNumber, numFormat }
}
4. 单元格样式设置
javascript
/**
* @description 设置单元格样式
* @param {Object} cell Excel单元格对象
* @param {boolean} isHeader 是否为表头单元格
*/
const setCellStyle = (cell, isHeader = false) => {
// 设置字体
cell.font = { size: isHeader ? 13 : 11, bold: isHeader }
// 设置对齐方式
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
// 设置边框
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' },
}
// 为表头设置背景色
if (isHeader) {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFf0f2f5' }, // 浅灰色背景
}
}
}
5. 标题行处理
javascript
/**
* @description 添加标题和副标题行
* @param {Object} worksheet Excel工作表
* @param {string} title 主标题
* @param {string} subTitle 副标题
* @param {number} columnsCount 列数
* @returns {number} 添加的标题行数
*/
const addTitleRows = (worksheet, title, subTitle, columnsCount) => {
let titleRows = 0
// 计算需要插入的标题行数
if (title) titleRows++
if (subTitle) titleRows++
if (titleRows > 0) {
// 添加空行用于标题
worksheet.spliceRows(1, 0, ...Array(titleRows).fill([]))
// 添加主标题
if (title) {
const titleRow = worksheet.getRow(1)
const firstCell = titleRow.getCell(1)
firstCell.value = title
firstCell.font = { size: 16, bold: true }
firstCell.alignment = { vertical: 'middle', horizontal: 'center' }
// 合并标题单元格
if (columnsCount > 1) {
worksheet.mergeCells(1, 1, 1, columnsCount)
}
titleRow.height = 30
}
// 添加副标题
if (subTitle) {
const subTitleRowIndex = title ? 2 : 1
const subTitleRow = worksheet.getRow(subTitleRowIndex)
const firstCell = subTitleRow.getCell(1)
firstCell.value = subTitle
firstCell.font = { size: 14, bold: true }
firstCell.alignment = { vertical: 'middle', horizontal: 'center' }
// 合并副标题单元格
if (columnsCount > 1) {
worksheet.mergeCells(subTitleRowIndex, 1, subTitleRowIndex, columnsCount)
}
subTitleRow.height = 25
}
}
return titleRows
}
三、多级表头处理
支持导出多级表头表格:
1. 多级表头扁平化处理
javascript
/**
* @description 扁平化多级表头配置
* @param {Array} columns 表头配置数组
* @returns {Array} 扁平化的表头配置
*/
const flattenColumns = (columns) => {
const result = []
// 递归遍历表头配置
const traverse = (items, parentHeaders = []) => {
items.forEach((item) => {
if (item.children && item.children.length > 0) {
// 如果有子列,则递归处理
const currentPath = [...parentHeaders, item.label]
traverse(item.children, currentPath)
} else {
// 叶子节点,添加到结果中
const parentPath = parentHeaders.join(' - ')
result.push({
...item,
fullLabel: parentHeaders.length > 0 ? parentHeaders.join(' - ') + ' - ' + item.label : item.label,
parentLabel: parentPath || '',
parentHeaders: parentHeaders, // 保存完整的父级路径数组
})
}
})
}
traverse(columns)
return result
}
2. 多级表头导出处理
javascript
/**
* @description 导出多级表头表格
* @param {Object} options 配置项
*/
async function exportMultiLevelTable(options) {
const {
workbook,
worksheet,
table,
columns,
autoWidth = true,
columnWidth = { min: 8, max: 40, padding: 4 },
excludeLabels = ['操作'],
title = '',
subTitle = ''
} = options
// 扁平化多级表头,排除不需要的列
const flattenedColumns = flattenColumns(columns).filter((col) => !excludeLabels.includes(col.label))
// 获取最大嵌套层级数
const maxLevel = Math.max(...flattenedColumns.map((col) => (col.parentHeaders ? col.parentHeaders.length + 1 : 1)))
// 提取表头文本
const headers = flattenedColumns.map((col) => col.label)
// 从DOM获取表格数据
const rows = table.querySelectorAll('.el-table__body-wrapper tbody tr:not(.el-table__row--hidden)')
const data = Array.from(rows)
.map((row) => {
// 获取单元格并处理数据
return Array.from(row.querySelectorAll('td:not(.is-hidden):not(.gutter)')).map((cell) => {
const cellText = cell.textContent.trim()
return processNumericValue(cellText)
})
})
.filter((rowData) => rowData.length > 0)
// 设置列宽
worksheet.columns = headers.map((header, index) => {
// 计算最大宽度
const cellValues = data
.filter((row) => index < row.length)
.map((row) => String(row[index]?.value || ''))
const maxWidth = Math.min(
Math.max(getStringWidth(header), ...cellValues.map(getStringWidth), columnWidth.min) + columnWidth.padding,
columnWidth.max
)
return {
key: String(index),
width: autoWidth ? maxWidth : 15,
}
})
// 添加标题行并获取标题行数
const titleRows = addTitleRows(worksheet, title, subTitle, headers.length)
// 处理多级表头
if (maxLevel > 1) {
// 为每个级别的表头创建行
for (let level = 0; level < maxLevel; level++) {
worksheet.spliceRows(titleRows + level + 1, 0, [])
const headerRow = worksheet.getRow(titleRows + level + 1)
headerRow.height = 25
}
// 处理每一列的表头
let colIndex = 1
const headerGroups = {}
// 首先构建每个级别的表头组
flattenedColumns.forEach((col) => {
// 遍历每个级别
for (let level = 0; level < maxLevel; level++) {
const key = `level_${level}`
if (!headerGroups[key]) {
headerGroups[key] = []
}
// 获取当前级别的标签
let label = null
if (level < col.parentHeaders.length) {
// 父级表头
label = col.parentHeaders[level]
} else if (level === col.parentHeaders.length) {
// 叶子节点自身
label = col.label
}
if (label) {
// 记录这个标签及其所在的列位置
headerGroups[key].push({
label,
colIndex: colIndex,
isLeaf: level === col.parentHeaders.length,
})
}
}
colIndex++
})
// 合并相同的表头单元格
Object.keys(headerGroups).forEach((levelKey) => {
const level = parseInt(levelKey.split('_')[1])
const headerRow = worksheet.getRow(titleRows + level + 1)
// 查找相邻的相同标签并合并
let startCol = -1
let currentLabel = null
let count = 0
// 添加一个结束标记,让最后一组也能被处理
const headers = [...headerGroups[levelKey], { label: null, colIndex: -1 }]
headers.forEach((header, idx) => {
if (header.label !== currentLabel || idx === headers.length - 1) {
// 处理前一组
if (currentLabel !== null && count > 1) {
// 合并单元格
worksheet.mergeCells(
titleRows + level + 1,
startCol,
titleRows + level + 1,
startCol + count - 1
)
}
if (header.label !== null) {
// 开始一个新组
startCol = header.colIndex
currentLabel = header.label
count = 1
// 设置单元格值
const cell = headerRow.getCell(startCol)
cell.value = currentLabel
setCellStyle(cell, true)
}
} else {
// 继续当前组
count++
}
})
})
// 处理跨行的单元格
flattenedColumns.forEach((col, idx) => {
const leafLevel = col.parentHeaders.length
const leafCell = worksheet.getRow(titleRows + leafLevel + 1).getCell(idx + 1)
// 如果叶子节点下没有其他行,则向下合并单元格
if (leafLevel < maxLevel - 1) {
worksheet.mergeCells(
titleRows + leafLevel + 1,
idx + 1,
titleRows + maxLevel,
idx + 1
)
// 设置合并后单元格的值
leafCell.value = col.label
setCellStyle(leafCell, true)
}
})
} else {
// 单行表头的简单处理
const headerRow = worksheet.getRow(titleRows + 1)
headerRow.height = 25
headerRow.eachCell((cell) => setCellStyle(cell, true))
}
// 处理数据行
data.forEach((rowData, rowIndex) => {
// 创建行并添加值
const values = rowData.map((cell) => cell.value)
const excelRow = worksheet.addRow(values)
excelRow.height = 30
excelRow.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
// 设置单元格样式和格式
rowData.forEach((cellData, colIndex) => {
const cell = excelRow.getCell(colIndex + 1)
setCellStyle(cell)
// 为数字单元格设置与原始格式匹配的数字格式
if (cellData.isNumber) {
cell.numFmt = cellData.numFormat
}
})
})
}
四、实际应用示例
在实际应用中,以用户管理页面为例,导出功能的使用非常简洁:
javascript
// 导入导出功能
import { exportBaseTable } from '@/utils/exportTable'
// 定义表格列配置
const columns = [
{ prop: 'id', label: '用户ID', width: '100' },
{ prop: 'organizations', label: '关联区划', width: '200' },
{ prop: 'fullname', label: '用户姓名', width: '200', tip: true },
// 其他列定义...
]
// 在导出按钮点击时调用
const handleAction = ({ type }) => {
switch (type) {
case 'export':
// 调用导出函数,传入文件名、表格引用、列配置和标题
exportBaseTable('用户列表', tableRef, columns, '用户列表')
break
// 其他操作...
}
}