318 lines
9.5 KiB
TypeScript
318 lines
9.5 KiB
TypeScript
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;
|
||
}
|