diff --git a/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/index.tsx b/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/index.tsx index d5302fa..c0fe7e9 100644 --- a/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/index.tsx +++ b/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/index.tsx @@ -12,7 +12,7 @@ import type { ActionType } from "@ant-design/pro-table"; import ProTable from "@ant-design/pro-table"; import LeftSelectTree from "@/pages/reports/settlementAccount/component/leftSelectTree"; import PageTitleBox from "@/components/PageTitleBox"; -import { exportXlsxFromProColumnsExcelJS } from "@/utils/exportExcelFun"; +import { exportMonthlyRevenueAnalysisExcel } from "./monthlyRevenueExport"; import { ValueType } from "exceljs"; import moment from 'moment' import { handleGetBusinessItemSummary } from "../revenueQOQReport/service"; @@ -74,19 +74,26 @@ const monthlyRevenueAnalysis: React.FC<{ currentUser: CurrentUser }> = (props) = format: 'YYYY-MM', } }, - { - title:
类别
, - width: 120, - dataIndex: "Item_Name", - hideInSearch: true, - ellipsis: true, - }, { title:
项目
, - width: 120, - dataIndex: "Serverpart_Name", hideInSearch: true, - ellipsis: true, + align: 'center', + children: [ + { + title:
类别
, + width: 120, + dataIndex: "Item_Name", + hideInSearch: true, + ellipsis: true, + }, + { + title:
项目
, + width: 120, + dataIndex: "Serverpart_Name", + hideInSearch: true, + ellipsis: true, + }, + ] }, { title: `本月${tableTitle?.thisMonthStart && tableTitle?.thisMonthEnd ? `(${tableTitle?.thisMonthStart}-${tableTitle?.thisMonthEnd})` : ""}`, @@ -356,7 +363,7 @@ const monthlyRevenueAnalysis: React.FC<{ currentUser: CurrentUser }> = (props) = type="primary" onClick={(e) => { if (reqDetailList && reqDetailList.length > 0) { - exportXlsxFromProColumnsExcelJS(columns, + exportMonthlyRevenueAnalysisExcel(columns, reqDetailList, `月度营收分析${searchParams?.searchMonth ? moment(searchParams?.searchMonth).format('YYYYMM') : ""}`, { diff --git a/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/monthlyRevenueExport.ts b/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/monthlyRevenueExport.ts new file mode 100644 index 0000000..54969be --- /dev/null +++ b/src/pages/reports/BusinessAnalysis/monthlyRevenueAnalysis/monthlyRevenueExport.ts @@ -0,0 +1,331 @@ +// 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); +} \ No newline at end of file