Skip to content
目录

Vue3后台管理系统(六):表格导出

导出功能特点

  1. 灵活的配置选项:支持设置文件名、标题、副标题、列宽、排除特定列等
  2. 数据格式处理:自动识别和处理数字格式,保持与原表格一致
  3. 样式支持:支持表头样式、单元格边框、对齐方式等样式设置
  4. 图片导出:支持导出表格中的图片到Excel中
  5. 多级表头支持:可以处理复杂的多级嵌套表头结构
  6. 自适应列宽:基于内容自动计算最佳列宽,考虑中英文字符宽度差异
  7. 用户友好的交互:导出过程中显示加载提示,出错时提供错误信息

技术实现关键点

  1. DOM操作:通过DOM选择器获取表格结构和数据
  2. ExcelJS库:利用ExcelJS库创建和格式化Excel文件
  3. 异步处理:使用async/await处理异步操作,如图片加载
  4. 递归算法:通过递归方式处理多级表头结构
  5. 数据转换:将DOM表格数据转换为适合Excel导出的格式
  6. 单元格合并:处理多级表头中的单元格合并

一、导出功能核心实现

导出功能主要通过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
        // 其他操作...
    }
}

Released under the MIT License.