142 lines
13 KiB
TypeScript
142 lines
13 KiB
TypeScript
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
|
|
import type { Cell, Worksheet } from 'exceljs';
|
|
|
|
export type FaultReport = ({ kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }) & { filterLabel?: string };
|
|
interface ReportChartImage { dataUrl: string; width: number; height: number }
|
|
const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' };
|
|
const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
|
|
const excelDate = (date: string) => new Date(`${date.length === 7 ? `${date}-01` : date}T00:00:00Z`);
|
|
const beijingTime = (date: string | Date) => new Date(date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
|
function merged(sheet: Worksheet, row: number, from: number, to: number, value: Cell['value']) {
|
|
if (to > from) sheet.mergeCells(row, from, row, to);
|
|
const cell = sheet.getCell(row, from); cell.value = value; return cell;
|
|
}
|
|
|
|
/** Browser-independent workbook builder; export the displayed authorized snapshot without requerying. */
|
|
export async function createFaultReportWorkbook(report: FaultReport, chartImage?: ReportChartImage, now = new Date()) {
|
|
const ExcelJS = (await import('exceljs')).default;
|
|
const book = new ExcelJS.Workbook();
|
|
book.creator = '羚牛氢能 BI'; book.created = now; book.modified = now;
|
|
const trend = report.kind === 'trend';
|
|
const title = trend ? '故障处理趋势' : '故障运维周报';
|
|
const headers = trend ? [report.data.granularity === 'month' ? '提报月份' : '提报日期', '新增故障(单)', '已闭环(单)', '未闭环(单)', '闭环率'] : ['日期', '星期', '新增故障(单)', '解决故障(单)'];
|
|
const columns = headers.length;
|
|
const sheet = book.addWorksheet(title, { properties: { defaultRowHeight: 22 }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, horizontalCentered: true, margins: { left: .3, right: .3, top: .4, bottom: .4, header: .2, footer: .2 } } });
|
|
sheet.columns = (trend ? [24, 19, 19, 19, 18] : [26, 16, 25, 25]).map(width => ({ width }));
|
|
const headerRow = 9;
|
|
const periods = trend ? [...report.data.periods].reverse() : [...report.data.days].reverse();
|
|
const start = headerRow + 1, end = headerRow + periods.length, totalRow = end + 1;
|
|
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
|
|
const bottom = totalRow + 3;
|
|
for (let row = 1; row <= bottom; row++) for (let col = 1; col <= columns; col++) {
|
|
const cell = sheet.getCell(row, col);
|
|
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
|
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
|
|
}
|
|
const titleCell = merged(sheet, 1, 1, columns, `羚牛氢能 · ${title}`);
|
|
titleCell.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } };
|
|
titleCell.fill = fill(palette.navy); titleCell.alignment = { vertical: 'middle', indent: 1 }; sheet.getRow(1).height = 48;
|
|
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
|
|
const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
|
sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10;
|
|
if (report.filterLabel) { const filter = merged(sheet, 4, 1, columns, `筛选条件:${report.filterLabel}`); filter.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; filter.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(4).height = 32; }
|
|
const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
|
|
const cards = trend
|
|
? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }]
|
|
: [{ from: 1, to: 2, label: '新增故障', col: 'C', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '解决故障', col: 'D', value: report.data.totals.closed, color: palette.green }];
|
|
for (const card of cards) {
|
|
const label = merged(sheet, 5, card.from, card.to, card.label);
|
|
const value = merged(sheet, 6, card.from, card.to, { formula: sum(card.col), result: card.value });
|
|
for (const cell of [label, value]) { cell.fill = fill(palette.stripe); cell.alignment = { horizontal: 'center', vertical: 'middle' }; }
|
|
label.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.muted}` } };
|
|
value.font = { name: 'Microsoft YaHei', size: 26, bold: true, color: { argb: `FF${card.color}` } }; value.numFmt = '#,##0';
|
|
}
|
|
sheet.getRow(5).height = 25; sheet.getRow(6).height = 40;
|
|
if (chartImage) {
|
|
const chart = book.addWorksheet('图表', { views: [{ showGridLines: false }], pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 1 } });
|
|
chart.columns = headers.map(() => ({ width: 900 / columns / 7 }));
|
|
chart.properties.defaultRowHeight = 24;
|
|
const heading = merged(chart, 1, 1, columns, `羚牛氢能 · ${title}`);
|
|
heading.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; heading.fill = fill(palette.navy); heading.alignment = { vertical: 'middle', indent: 1 }; chart.getRow(1).height = 48;
|
|
const period = merged(chart, 2, 1, columns, `${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
|
|
period.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
|
const key = merged(chart, 3, 1, columns, trend ? '绿色:已闭环 橙色:未闭环 柱总高为新增故障数' : '蓝色:新增故障 绿色:解决故障');
|
|
key.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
|
const id = book.addImage({ base64: chartImage.dataUrl, extension: 'png' });
|
|
const scale = Math.min(900 / chartImage.width, 360 / chartImage.height);
|
|
const width = chartImage.width * scale, height = chartImage.height * scale;
|
|
// Keep circles and labels proportional for both desktop and mobile snapshots.
|
|
chart.addImage(id, { tl: { col: (900 - width) / 2 / (900 / columns), row: 4 + (360 - height) / 64 }, ext: { width, height }, editAs: 'oneCell' });
|
|
const caption = merged(chart, 17, 1, columns, `图表为当前页面快照,精确数量见「${title}」工作表。`);
|
|
caption.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
|
chart.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}17`;
|
|
}
|
|
headers.forEach((label, i) => { const cell = sheet.getCell(headerRow, i + 1); cell.value = label; cell.fill = fill(palette.navy); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: 'FFFFFFFF' } }; });
|
|
sheet.getRow(headerRow).height = 30;
|
|
if (trend) [...report.data.periods].reverse().forEach((row, i) => {
|
|
const n = start + i;
|
|
sheet.getRow(n).values = [excelDate(row.period), row.reported, row.closed, row.unclosed, { formula: `IFERROR(C${n}/B${n},0)`, result: row.reported ? row.closed / row.reported : 0 }];
|
|
sheet.getCell(n, 1).numFmt = report.data.granularity === 'month' ? 'yyyy-mm' : 'yyyy-mm-dd';
|
|
sheet.getCell(n, 5).numFmt = '0.0%;-0.0%;"—"';
|
|
});
|
|
else [...report.data.days].reverse().forEach((row, i) => {
|
|
sheet.getRow(start + i).values = [excelDate(row.date), excelDate(row.date).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'UTC' }), row.reported, row.closed];
|
|
sheet.getCell(start + i, 1).numFmt = row.date === today ? 'yyyy-mm-dd" 今天"' : 'yyyy-mm-dd';
|
|
});
|
|
for (let row = start; row <= end; row++) {
|
|
const date = sheet.getCell(row, 1).value as Date;
|
|
const highlight = date.toISOString().slice(0, 10) === today && (!trend || report.data.granularity === 'day');
|
|
sheet.getRow(row).height = 26;
|
|
for (let col = 1; col <= columns; col++) {
|
|
const cell = sheet.getCell(row, col); cell.fill = fill(highlight ? palette.today : (row - start) % 2 ? palette.stripe : palette.white);
|
|
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
|
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
|
|
cell.border = { bottom: { style: 'hair', color: { argb: `FF${palette.line}` } } };
|
|
if (col > (trend ? 1 : 2) && !(trend && col === 5)) {
|
|
cell.numFmt = '#,##0'; const color = trend ? [palette.blue, palette.green, palette.amber][col - 2] : col === 3 ? palette.blue : palette.green;
|
|
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${color}` } };
|
|
}
|
|
}
|
|
}
|
|
sheet.getCell(totalRow, 1).value = '合计';
|
|
cards.forEach(card => { sheet.getCell(`${card.col}${totalRow}`).value = { formula: sum(card.col), result: card.value }; sheet.getCell(`${card.col}${totalRow}`).numFmt = '#,##0'; });
|
|
if (trend) { sheet.getCell(totalRow, 5).value = { formula: `IFERROR(C${totalRow}/B${totalRow},0)`, result: report.data.totals.reported ? report.data.totals.closed / report.data.totals.reported : 0 }; sheet.getCell(totalRow, 5).numFmt = '0.0%;-0.0%;"—"'; }
|
|
for (let col = 1; col <= columns; col++) { const cell = sheet.getCell(totalRow, col); cell.fill = fill('E8EFF9'); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: `FF${palette.navy}` } }; }
|
|
sheet.getRow(totalRow).height = 30;
|
|
const note = merged(sheet, totalRow + 2, 1, columns, trend ? '统计口径:按提报日期统计同批故障;已归档为已闭环,其余状态(含挂起)为未闭环。按月仅统计所选日期范围。' : '统计口径:新增故障按提报日期;解决故障按解决日期统计,不受提报日期限制。同一故障可在不同日期分别计入两个指标。');
|
|
note.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; note.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(totalRow + 2).height = 36;
|
|
const source = merged(sheet, bottom, 1, columns, `来源:羚牛氢能 BI 当前可见统计 · 导出时间:${beijingTime(now)}(北京时间)`); source.font = { name: 'Microsoft YaHei', size: 9, color: { argb: `FF${palette.muted}` } };
|
|
sheet.autoFilter = { from: { row: headerRow, column: 1 }, to: { row: Math.max(headerRow, end), column: columns } };
|
|
sheet.views = [{ state: 'frozen', ySplit: headerRow, topLeftCell: `A${start}`, showGridLines: false }];
|
|
sheet.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}${bottom}`;
|
|
sheet.pageSetup.printTitlesRow = `${headerRow}:${headerRow}`;
|
|
sheet.headerFooter.oddFooter = '&L羚牛氢能 · 故障管理&R第 &P 页 / 共 &N 页';
|
|
return book;
|
|
}
|
|
|
|
async function chartImage(svg: SVGSVGElement): Promise<ReportChartImage> {
|
|
const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800;
|
|
const height = svg.clientHeight || Number(svg.getAttribute('height')) || 240;
|
|
const clone = svg.cloneNode(true) as SVGSVGElement;
|
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(width)); clone.setAttribute('height', String(height));
|
|
clone.style.fontFamily = 'Arial, sans-serif';
|
|
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' }));
|
|
try {
|
|
const image = new Image();
|
|
await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('图表生成失败')); image.src = url; });
|
|
const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width * 2); canvas.height = Math.ceil(height * 2);
|
|
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('图表生成失败');
|
|
ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height);
|
|
return { dataUrl: canvas.toDataURL('image/png'), width, height };
|
|
} finally { URL.revokeObjectURL(url); }
|
|
}
|
|
export async function downloadFaultReport(report: FaultReport, svg?: SVGSVGElement) {
|
|
const png = svg ? await chartImage(svg) : undefined;
|
|
const book = await createFaultReportWorkbook(report, png);
|
|
const bytes = await book.xlsx.writeBuffer();
|
|
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
|
|
const link = document.createElement('a'); link.href = url;
|
|
link.download = `${report.kind === 'trend' ? `故障处理趋势_${report.data.granularity === 'day' ? '按日' : '按月'}` : '故障运维周报'}_${report.data.start}_${report.data.end}.xlsx`;
|
|
document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
}
|