// monthlyRevenueExport.ts - 月度营收分析专用导出方法 import ExcelJS from 'exceljs'; /** ======== 列类型(按需裁剪) ======== */ type AnyCol = { title?: any; dataIndex?: string | (string | number)[]; children?: AnyCol[]; valueEnum?: Record; renderText?: (text: any, record: any, index: number) => any; hideInTable?: boolean; valueType?: 'index' | string; align?: 'left' | 'center' | 'right'; }; /** ========== 新增:拍平树形数据 ========== */ function flattenTree>( list: T[] = [], childrenKey = 'children', out: T[] = [] ): T[] { for (const node of list) { // 先推当前节点(浅拷贝去掉 children,避免把对象树写入单元格) const { [childrenKey]: kids, ...rest } = node as any; out.push(rest as T); if (Array.isArray(kids) && kids.length) { flattenTree(kids, childrenKey, out); } } return out; } /** 抽取 React 节点文本 */ function extractText(node: any): string { if (node == null || node === false) return ''; if (typeof node === 'string' || typeof node === 'number') return String(node); const children = node?.props?.children; if (Array.isArray(children)) return children.map(extractText).join(''); if (children != null) return extractText(children); const html = node?.props?.dangerouslySetInnerHTML?.__html; if (typeof html === 'string') return html.replace(/<[^>]+>/g, ''); return String(node ?? ''); } /** dataIndex 路径取值 */ const toPath = (di?: AnyCol['dataIndex']): (string | number)[] => Array.isArray(di) ? di : (typeof di === 'string' ? di.split('.') : []); const getByPath = (obj: any, path: (string | number)[]) => path.reduce((acc, k) => (acc == null ? acc : acc[k]), obj); /** 过滤 hideInTable(父级联动) */ function pruneHiddenColumns(cols: AnyCol[]): AnyCol[] { const walk = (arr: AnyCol[]): AnyCol[] => (arr || []) .filter(col => !col?.hideInTable) .map(col => { if (col.children?.length) { const kids = walk(col.children); if (!kids.length) return null as any; return { ...col, children: kids }; } return col; }) .filter(Boolean); return walk(cols); } /** 叶子列(有 dataIndex 的) */ const getLeaves = (cols: AnyCol[]): AnyCol[] => { const out: AnyCol[] = []; const walk = (arr: AnyCol[]) => { arr.forEach(c => { if (c?.children?.length) { walk(c.children!); } else if (c && (c.dataIndex || c.valueType === 'index')) { // << 修改 out.push(c); } }); }; walk(cols); return out; }; /** 深度和列跨度 */ const getDepth = (cols: AnyCol[]): number => { const dfs = (c: AnyCol): number => c.children?.length ? 1 + Math.max(...c.children.map(dfs)) : 1; return Math.max(...cols.map(dfs)); }; const getColSpan = (c: AnyCol): number => c.children?.length ? c.children.map(getColSpan).reduce((a, b) => a + b, 0) : 1; /** 构造多级表头矩阵 & 合并信息(不含顶部大标题/信息行) */ function buildHeaderMatrix(cols: AnyCol[]) { const depth = getDepth(cols); const rows: string[][] = Array.from({ length: depth }, () => []); let colCursor = 0; const merges: Array<{ r1: number; c1: number; r2: number; c2: number }> = []; const place = (list: AnyCol[], level: number) => { list.forEach(col => { const span = getColSpan(col); const rowSpan = col.children?.length ? 1 : depth - level; const title = extractText(col.title ?? ''); rows[level][colCursor] = title; for (let i = 1; i < span; i++) rows[level][colCursor + i] = ''; if (span > 1 || rowSpan > 1) { merges.push({ r1: level + 1, c1: colCursor + 1, r2: level + rowSpan, c2: colCursor + span }); } if (col.children?.length) { place(col.children, level + 1); } else { colCursor += 1; } }); }; place(cols, 0); const maxLen = Math.max(...rows.map(r => r.length)); rows.forEach(r => { for (let i = 0; i < maxLen; i++) if (typeof r[i] === 'undefined') r[i] = ''; }); return { headerAOA: rows, merges, depth, columnCount: maxLen }; } /** 单元格显示值(valueEnum / renderText / 原始值) */ function getCellValue(col: AnyCol, record: any, rowIndex: number) { // << 新增:序号列 if (col.valueType === 'index') return rowIndex + 1; const raw = getByPath(record, toPath(col.dataIndex)); if (col.valueEnum) { const ve = col.valueEnum[raw as any]; if (typeof ve === 'string') return ve; if (ve?.text != null) return ve.text; } if (col.renderText) { try { return col.renderText(raw, record, rowIndex); } catch { } } return raw; } /** 估算列宽(简单) */ const estimateWidth = (v: any) => { const s = (v ?? '').toString(); const len = Array.from(s).reduce((n, ch) => n + (/[^\x00-\xff]/.test(ch) ? 2 : 1), 0); return Math.min(Math.max(len + 2, 8), 60); }; export async function exportMonthlyRevenueAnalysisExcel( rawColumns: AnyCol[], dataSource: any[], filename?: string, options?: { sheetName?: string; chunkSize?: number; topTitle?: string; // 顶部大标题(整表合并 + 居中) infoRowLeft?: string; // 标题下插入的左侧文字 infoRowRight?: string; // 标题下插入的右侧文字(右对齐) freezeHeader?: boolean; // 冻结到哪一行(自动计算) childrenKey?: string; } ) { const { sheetName = '数据', chunkSize = 100_000, topTitle, infoRowLeft, infoRowRight, freezeHeader = true, childrenKey = 'children', } = options || {}; // === 新增:拍平树形数据 === const flatData = flattenTree(Array.isArray(dataSource) ? dataSource : [], childrenKey); const columns = pruneHiddenColumns(rawColumns); const leafCols = getLeaves(columns); if (!leafCols.length) throw new Error('无可导出的列(可能被 hideInTable 全部隐藏)'); const { headerAOA, merges, columnCount } = buildHeaderMatrix(columns); const wb = new ExcelJS.Workbook(); wb.created = new Date(); wb.modified = new Date(); const total = flatData?.length ?? 0; const totalSheets = Math.max(1, Math.ceil(total / chunkSize)); for (let si = 0; si < totalSheets; si++) { const ws = wb.addWorksheet(totalSheets === 1 ? sheetName : `${sheetName}_${si + 1}`, { views: [{ state: 'frozen' }], }); // 1) 顶部大标题(可选) let currentRowIndex = 1; if (topTitle) { const row = ws.getRow(currentRowIndex); // 写入一个单元格,再合并整行 row.getCell(1).value = topTitle; ws.mergeCells(currentRowIndex, 1, currentRowIndex, columnCount); // 居中 + 加粗 + 较大字号 const cell = ws.getCell(currentRowIndex, 1); cell.alignment = { horizontal: 'center', vertical: 'middle' }; cell.font = { bold: true, size: 14 }; row.height = 22; currentRowIndex += 1; } // 2) 信息行(可选,左右结构) if (infoRowLeft != null || infoRowRight != null) { const row = ws.getRow(currentRowIndex); // 左侧:合并 1..(columnCount/2),左对齐 const split = Math.max(1, Math.floor(columnCount / 2)); if (infoRowLeft != null) { row.getCell(1).value = infoRowLeft; ws.mergeCells(currentRowIndex, 1, currentRowIndex, split); const leftCell = ws.getCell(currentRowIndex, 1); leftCell.alignment = { horizontal: 'left', vertical: 'middle' }; leftCell.font = { size: 11 }; } // 右侧:合并 (split+1)..columnCount,右对齐 if (infoRowRight != null) { row.getCell(split + 1).value = infoRowRight; ws.mergeCells(currentRowIndex, split + 1, currentRowIndex, columnCount); const rightCell = ws.getCell(currentRowIndex, split + 1); rightCell.alignment = { horizontal: 'right', vertical: 'middle' }; rightCell.font = { size: 11 }; } row.height = 18; currentRowIndex += 1; } // 3) 多级表头(全部居中 + 加粗) const headerStartRow = currentRowIndex; headerAOA.forEach((r, idx) => { const row = ws.getRow(headerStartRow + idx); r.forEach((v, cIdx) => { const cell = row.getCell(cIdx + 1); cell.value = v; cell.alignment = { horizontal: 'center', vertical: 'middle' }; cell.font = { bold: true }; }); row.height = 18; }); // 应用表头合并 merges.forEach(m => { ws.mergeCells(headerStartRow + (m.r1 - 1), m.c1, headerStartRow + (m.r2 - 1), m.c2); }); currentRowIndex += headerAOA.length; // 4) 数据(从 currentRowIndex 开始) const start = si * chunkSize; const end = Math.min(start + chunkSize, total); const batch = flatData.slice(start, end); // 收集第一列的值用于合并 const firstColumnValues: string[] = []; for (let i = 0; i < batch.length; i++) { const rec = batch[i]; const row = ws.getRow(currentRowIndex + i); leafCols.forEach((col, j) => { const cell = row.getCell(j + 1); const cellValue = getCellValue(col, rec, start + i); cell.value = cellValue; // 设置单元格对齐方式 if (col.align) { cell.alignment = { horizontal: col.align, vertical: 'middle' }; } // 收集第一列的值 if (j === 0) { firstColumnValues.push(String(cellValue || '')); } }); } // 5) 第一列相同值的单元格合并 if (firstColumnValues.length > 0) { let mergeStart = 0; for (let i = 1; i <= firstColumnValues.length; i++) { // 当到达末尾或值不同时,处理合并 if (i === firstColumnValues.length || firstColumnValues[i] !== firstColumnValues[mergeStart]) { if (i - mergeStart > 1) { // 合并第一列的单元格 ws.mergeCells( currentRowIndex + mergeStart, 1, currentRowIndex + i - 1, 1 ); // 设置合并后单元格的对齐方式为居中 const mergedCell = ws.getCell(currentRowIndex + mergeStart, 1); mergedCell.alignment = { horizontal: 'center', vertical: 'middle' }; } mergeStart = i; } } } currentRowIndex += batch.length; // 6) 列宽:基于表头 + 采样数据估算 const sampleRows = Math.min(batch.length, 200); for (let c = 1; c <= columnCount; c++) { const headerMax = Math.max(...headerAOA.map(r => estimateWidth(r[c - 1]))); let dataMax = 8; for (let i = 0; i < sampleRows; i++) { const v = leafCols[c - 1] ? getCellValue(leafCols[c - 1], batch[i], start + i) : ''; dataMax = Math.max(dataMax, estimateWidth(v)); } ws.getColumn(c).width = Math.max(headerMax, dataMax); } // 7) 冻结窗格:冻结到(标题 + 信息行 + 表头)这一行的下一行 if (freezeHeader) { // const freezeRow = (topTitle ? 1 : 0) + (infoRowLeft != null || infoRowRight != null ? 1 : 0) + headerAOA.length; // ws.views = [{ state: 'frozen', ySplit: freezeRow }]; } } // 生成并下载(浏览器环境) const buf = await wb.xlsx.writeBuffer(); const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${filename}.xlsx`; a.click(); URL.revokeObjectURL(url); }