月度营收分析页面

This commit is contained in:
ylj20011123 2025-09-19 09:56:09 +08:00
parent 9b689aff3d
commit fbad9d08e7
2 changed files with 350 additions and 12 deletions

View File

@ -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: <div style={{ textAlign: 'center' }}></div>,
width: 120,
dataIndex: "Item_Name",
hideInSearch: true,
ellipsis: true,
},
{
title: <div style={{ textAlign: 'center' }}></div>,
width: 120,
dataIndex: "Serverpart_Name",
hideInSearch: true,
ellipsis: true,
align: 'center',
children: [
{
title: <div style={{ textAlign: 'center' }}></div>,
width: 120,
dataIndex: "Item_Name",
hideInSearch: true,
ellipsis: true,
},
{
title: <div style={{ textAlign: 'center' }}></div>,
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') : ""}`,
{

View File

@ -0,0 +1,331 @@
// monthlyRevenueExport.ts - 月度营收分析专用导出方法
import ExcelJS from 'exceljs';
/** ======== 列类型(按需裁剪) ======== */
type AnyCol = {
title?: any;
dataIndex?: string | (string | number)[];
children?: AnyCol[];
valueEnum?: Record<string | number, { text?: string } | string>;
renderText?: (text: any, record: any, index: number) => any;
hideInTable?: boolean;
valueType?: 'index' | string;
align?: 'left' | 'center' | 'right';
};
/** ========== 新增:拍平树形数据 ========== */
function flattenTree<T extends Record<string, any>>(
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<any>(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);
}