月度营收分析页面
This commit is contained in:
parent
9b689aff3d
commit
fbad9d08e7
@ -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,6 +74,11 @@ const monthlyRevenueAnalysis: React.FC<{ currentUser: CurrentUser }> = (props) =
|
||||
format: 'YYYY-MM',
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>项目</div>,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
children: [
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>类别</div>,
|
||||
width: 120,
|
||||
@ -88,6 +93,8 @@ const monthlyRevenueAnalysis: React.FC<{ currentUser: CurrentUser }> = (props) =
|
||||
hideInSearch: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: `本月${tableTitle?.thisMonthStart && tableTitle?.thisMonthEnd ? `(${tableTitle?.thisMonthStart}-${tableTitle?.thisMonthEnd})` : ""}`,
|
||||
dataIndex: "RevenueAmount",
|
||||
@ -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') : ""}`,
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user