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