'use strict'; /** * 通用表格导出 * -------------------------------------------------------------------------- * 把 [{ name, header: string[], rows: any[][] }] 渲染成: * - xlsx:多工作表工作簿(基于 backend 已依赖的 xlsx 包) * - csv :单工作表、带 UTF-8 BOM(Excel 直接打开中文不乱码) * 语义与 frontend/src/utils/tableExport.js 的「表头 + 行」保持一致。 */ const xlsx = require('xlsx'); const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'; // Excel 工作表名限制:<=31 字符,且不能包含 : \ / ? * [ ] const sanitizeSheetName = (name, fallback) => { const cleaned = String(name || '').replace(/[:\\/?*[\]]/g, ' ').replace(/\s+/g, ' ').trim(); return (cleaned || fallback).slice(0, 31); }; const toCellValue = (value) => ( value === null || value === undefined || value === '' ? '' : value ); const sheetToAoa = (sheet) => ([ (sheet?.header || []).map(toCellValue), ...((sheet?.rows || []).map((row) => (row || []).map(toCellValue))), ]); /** 多工作表 xlsx buffer;工作表名重复/非法时自动去重与截断 */ const buildXlsxBuffer = (sheets = []) => { const workbook = xlsx.utils.book_new(); const usedNames = new Set(); sheets.forEach((sheet, index) => { const base = sanitizeSheetName(sheet?.name, `工作表${index + 1}`); let name = base; let suffix = 2; while (usedNames.has(name)) { name = `${base.slice(0, 27)}(${suffix})`; suffix += 1; } usedNames.add(name); const worksheet = xlsx.utils.aoa_to_sheet(sheetToAoa(sheet)); worksheet['!cols'] = (sheet?.header || []).map((title) => ({ wch: Math.max(10, Math.min(32, String(title || '').length * 2 + 6)), })); xlsx.utils.book_append_sheet(workbook, worksheet, name); }); return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); }; const quoteCsvCell = (value) => { const text = value === null || value === undefined ? '' : String(value); return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; }; /** 单工作表 csv buffer(只取第一个工作表),前置 BOM */ const buildCsvBuffer = (sheets = []) => { const sheet = sheets[0] || { header: [], rows: [] }; const lines = [(sheet.header || []).map(quoteCsvCell).join(',')]; (sheet.rows || []).forEach((row) => { lines.push((row || []).map(quoteCsvCell).join(',')); }); return Buffer.from(`\ufeff${lines.join('\r\n')}`, 'utf8'); }; const sanitizeFileName = (fileName) => String(fileName || '导出数据').replace(/[\\/:*?"<>|]/g, '_').trim() || '导出数据'; /** * 直接把导出结果写回响应。 * @param {import('express').Response} res * @param {{ fileName?: string, format?: 'xlsx'|'csv', sheets?: Array }} payload */ const sendWorkbookDownload = (res, { fileName, format = 'xlsx', sheets = [] } = {}) => { const isCsv = String(format).toLowerCase() === 'csv'; const exportSheets = isCsv ? sheets.slice(0, 1) : sheets; const buffer = isCsv ? buildCsvBuffer(exportSheets) : buildXlsxBuffer(exportSheets); const totalRows = exportSheets.reduce((sum, sheet) => sum + ((sheet?.rows || []).length), 0); const fullName = `${sanitizeFileName(fileName)}${isCsv ? '.csv' : '.xlsx'}`; res.setHeader('Content-Type', isCsv ? CSV_CONTENT_TYPE : XLSX_CONTENT_TYPE); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fullName)}`); res.setHeader('X-Export-Count', String(totalRows)); res.send(buffer); return { totalRows, fullName }; }; module.exports = { XLSX_CONTENT_TYPE, CSV_CONTENT_TYPE, sanitizeSheetName, buildXlsxBuffer, buildCsvBuffer, sendWorkbookDownload, };