Files
sunProduct/src/utils/xlsx.ts
2026-08-19 08:48:36 +08:00

318 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as XLSX from 'xlsx-js-style';
import { saveAs } from 'file-saver';
import { ElMessage } from 'element-plus';
/**
* 列配置类型(支持多级表头)
*/
export type ColumnConfig = {
label: string; // 列名
prop?: string; // 字段名(叶子节点必须有)
children?: ColumnConfig[]; // 子列(用于多级表头)
align?: 'left' | 'center' | 'right'; // 对齐方式
width?: number; // 列宽
};
/**
* 合并单元格回调函数类型(类似 Element Plus 的 span-method)
*/
export type SpanMethodCallback = (params: { row: any; rowIndex: number; columnIndex: number }) => { rowspan: number; colspan: number };
/**
* 前端导出Excel(支持多级表头)
*/
export function exportToExcel(data: any[], columns: ColumnConfig[], filename: string, spanMethod?: SpanMethodCallback) {
if (!data || data.length === 0) {
ElMessage.warning('没有可导出的数据');
return;
}
try {
// 获取叶子列(实际数据列)
const leafColumns = getLeafColumns(columns);
// 获取表头深度
const headerRows = getMaxHeaderDepth(columns);
// 手动创建工作表(不使用 json_to_sheet)
const worksheet: XLSX.WorkSheet = {};
// 1. 填充表头
fillHeaderCells(worksheet, columns, 0, 0, headerRows);
// 2. 填充数据(从 headerRows 行开始)
data.forEach((row, rowIndex) => {
leafColumns.forEach((col, colIndex) => {
if (col.prop) {
const cellAddress = XLSX.utils.encode_cell({ r: rowIndex + headerRows, c: colIndex });
const value = getCellValue(row, col.prop);
worksheet[cellAddress] = { t: 's', v: value !== undefined && value !== null ? String(value) : '' };
}
});
});
// 3. 设置表头合并
const merges = calculateHeaderMerges(columns, leafColumns);
// 4. 处理数据行合并
if (spanMethod) {
const dataMerges = calculateMergesBySpanMethod(data, leafColumns, spanMethod, headerRows);
merges.push(...dataMerges);
}
worksheet['!merges'] = merges;
// 5. 设置列宽
worksheet['!cols'] = leafColumns.map((col) => ({ wch: col.width || 20 }));
// 6. 设置范围
const lastRow = data.length + headerRows - 1;
const lastCol = leafColumns.length - 1;
worksheet['!ref'] = XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: lastRow, c: lastCol } });
// 7. 设置所有单元格样式(包含黑色边框)
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C });
// ✅ 确保单元格存在(即使没有内容也要创建,以便设置边框)
if (!worksheet[cellAddress]) {
worksheet[cellAddress] = { t: 's', v: '' };
}
const isHeader = R < headerRows;
worksheet[cellAddress].s = {
fill: isHeader
? {
fgColor: { rgb: 'bec4cb' },
patternType: 'solid'
}
: undefined,
font: isHeader
? {
color: { rgb: 'FFFFFF' },
bold: true,
sz: 11
}
: undefined,
border: {
top: { style: 'thin', color: { rgb: '000000' } },
bottom: { style: 'thin', color: { rgb: '000000' } },
left: { style: 'thin', color: { rgb: '000000' } },
right: { style: 'thin', color: { rgb: '000000' } }
},
alignment: {
horizontal: 'center',
vertical: 'center'
}
};
}
}
// 8. 设置合并单元格居中
if (worksheet['!merges']) {
worksheet['!merges'].forEach((merge) => {
for (let R = merge.s.r; R <= merge.e.r; ++R) {
for (let C = merge.s.c; C <= merge.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C });
if (worksheet[cellAddress]) {
worksheet[cellAddress].s = {
...worksheet[cellAddress].s,
alignment: {
horizontal: 'center',
vertical: 'center'
}
};
}
}
}
});
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
// 导出文件
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, `${filename}.xlsx`);
ElMessage.success('导出成功');
} catch (error) {
console.error('导出失败:', error);
ElMessage.error('导出失败,请重试');
}
}
/**
* 根据 spanMethod 回调计算需要合并的单元格范围
* @param data
* @param columns
* @param spanMethod
* @param headerRows 表头行数(数据行需要偏移)
*/
function calculateMergesBySpanMethod(data: any[], columns: ColumnConfig[], spanMethod: SpanMethodCallback, headerRows: number = 1): XLSX.Range[] {
const merges: XLSX.Range[] = [];
const coveredCells = new Set<string>();
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
for (let colIndex = 0; colIndex < columns.length; colIndex++) {
const cellKey = `${rowIndex}-${colIndex}`;
if (coveredCells.has(cellKey)) continue;
const result = spanMethod({ row, rowIndex, columnIndex: colIndex });
if (result && (result.rowspan > 1 || result.colspan > 1)) {
// 数据行需要加上表头行数的偏移
const startRow = rowIndex + headerRows;
const startCol = colIndex;
const endRow = startRow + result.rowspan - 1;
const endCol = startCol + result.colspan - 1;
merges.push({
s: { r: startRow, c: startCol },
e: { r: endRow, c: endCol }
});
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r !== startRow || c !== startCol) {
coveredCells.add(`${r - headerRows}-${c}`);
}
}
}
}
}
}
return merges;
}
/**
* 获取所有叶子列(实际数据列)
*/
function getLeafColumns(columns: ColumnConfig[]): ColumnConfig[] {
const leaves: ColumnConfig[] = [];
function traverse(cols: ColumnConfig[]) {
cols.forEach((col) => {
if (col.children && col.children.length > 0) {
traverse(col.children);
} else {
leaves.push(col);
}
});
}
traverse(columns);
return leaves;
}
/**
* 获取最大表头深度
*/
function getMaxHeaderDepth(columns: ColumnConfig[]): number {
function getDepth(cols: ColumnConfig[]): number {
let maxDepth = 0;
cols.forEach((col) => {
if (col.children && col.children.length > 0) {
maxDepth = Math.max(maxDepth, 1 + getDepth(col.children));
} else {
maxDepth = Math.max(maxDepth, 1);
}
});
return maxDepth;
}
return getDepth(columns);
}
/**
* 计算表头合并范围
*/
function calculateHeaderMerges(columns: ColumnConfig[], leafColumns: ColumnConfig[]): XLSX.Range[] {
const merges: XLSX.Range[] = [];
const maxDepth = getMaxHeaderDepth(columns);
function traverse(cols: ColumnConfig[], depth: number, startLeafIndex: number): number {
let currentLeafIndex = startLeafIndex;
cols.forEach((col) => {
if (col.children && col.children.length > 0) {
const endLeafIndex = traverse(col.children, depth + 1, currentLeafIndex);
if (endLeafIndex - currentLeafIndex > 0) {
merges.push({
s: { r: depth, c: currentLeafIndex },
e: { r: depth, c: endLeafIndex }
});
}
if (endLeafIndex > currentLeafIndex) {
merges.push({
s: { r: depth, c: currentLeafIndex },
e: { r: depth, c: endLeafIndex } // 只水平合并
});
}
currentLeafIndex = endLeafIndex + 1;
} else {
if (depth < maxDepth - 1) {
merges.push({
s: { r: depth, c: currentLeafIndex },
e: { r: maxDepth - 1, c: currentLeafIndex }
});
}
currentLeafIndex++;
}
});
return currentLeafIndex - 1;
}
traverse(columns, 0, 0);
return merges;
}
/**
* 获取单元格值(支持嵌套属性)
*/
function getCellValue(row: any, prop: string): any {
if (!prop.includes('.')) {
return row[prop];
}
const keys = prop.split('.');
let value = row;
for (const key of keys) {
if (value === null || value === undefined) {
return undefined;
}
value = value[key];
}
return value;
}
/**
* 填充表头单元格
*/
function fillHeaderCells(worksheet: XLSX.WorkSheet, columns: ColumnConfig[], startCol: number, depth: number, maxDepth: number): number {
let currentCol = startCol;
columns.forEach((col) => {
if (col.children && col.children.length > 0) {
// 有子列,递归处理
const endCol = fillHeaderCells(worksheet, col.children, currentCol, depth + 1, maxDepth);
// 父列表头:在 depth 行,从 currentCol 到 endCol
const cellAddress = XLSX.utils.encode_cell({ r: depth, c: currentCol });
worksheet[cellAddress] = { t: 's', v: col.label };
currentCol = endCol + 1;
} else {
// 叶子节点
const cellAddress = XLSX.utils.encode_cell({ r: depth, c: currentCol });
worksheet[cellAddress] = { t: 's', v: col.label };
currentCol++;
}
});
return currentCol - 1;
}