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 { 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((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); }