/** * exportBlocksToWord — 将 ReportBlock[] 导出为 Word (.docx) * * 与 DynamicReport.tsx 对应:将 block-based 报告序列化为 docx 格式。 */ import { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, HeadingLevel, WidthType, BorderStyle, AlignmentType, ImageRun, } from 'docx'; import type { ReportBlock, ConclusionReport } from '../types'; interface ExportOptions { title?: string; subtitle?: string; generatedAt?: string; conclusion?: ConclusionReport; } const TABLE_BORDERS = { top: { style: BorderStyle.SINGLE, size: 1 }, bottom: { style: BorderStyle.SINGLE, size: 1 }, left: { style: BorderStyle.SINGLE, size: 1 }, right: { style: BorderStyle.SINGLE, size: 1 }, insideHorizontal: { style: BorderStyle.SINGLE, size: 1 }, insideVertical: { style: BorderStyle.SINGLE, size: 1 }, }; function makeRow(cells: string[], isHeader = false): TableRow { return new TableRow({ children: cells.map(text => new TableCell({ children: [ new Paragraph({ children: [new TextRun({ text: String(text ?? '-'), bold: isHeader })], }), ], width: { size: Math.floor(100 / cells.length), type: WidthType.PERCENTAGE }, }) ), }); } function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Table)[] { const elements: (Paragraph | Table)[] = []; switch (block.type) { case 'key_value': { if (block.title) { elements.push(new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_2 })); } const items = block.items ?? []; if (items.length > 0) { elements.push( new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: TABLE_BORDERS, rows: [ makeRow(['指标', '值'], true), ...items.map(it => makeRow([it.key, it.value])), ], }) ); elements.push(new Paragraph({ text: '' })); } break; } case 'table': { const headers = block.headers ?? []; const rawRows = block.rows ?? []; if (headers.length > 0 || (Array.isArray(rawRows) && rawRows.length > 0)) { if (block.title) { elements.push( new Paragraph({ text: `Table ${index + 1}. ${block.title}`, heading: HeadingLevel.HEADING_2, }) ); } const tableRows: TableRow[] = []; if (headers.length > 0) { tableRows.push(makeRow(headers.map(String), true)); } const normalizedRows = (Array.isArray(rawRows) ? rawRows : []).map((row: any) => { if (Array.isArray(row)) return row; if (row && typeof row === 'object') { return headers.length > 0 ? headers.map((h: string) => row[h] ?? '') : Object.values(row); } return [String(row ?? '')]; }); for (const row of normalizedRows) { tableRows.push(makeRow(row.map((c: any) => (c === null || c === undefined ? '-' : String(c))))); } elements.push( new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: TABLE_BORDERS, rows: tableRows, }) ); if (block.footnote) { elements.push( new Paragraph({ children: [new TextRun({ text: block.footnote, italics: true, color: '999999', size: 18 })], }) ); } elements.push(new Paragraph({ text: '' })); } break; } case 'image': { if (block.data) { try { const raw = block.data.replace(/^data:image\/\w+;base64,/, ''); const imageBuffer = Uint8Array.from(atob(raw), c => c.charCodeAt(0)); if (block.title) { elements.push( new Paragraph({ text: `Figure ${index + 1}. ${block.title}`, heading: HeadingLevel.HEADING_2, }) ); } elements.push( new Paragraph({ children: [ new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' }), ], alignment: AlignmentType.CENTER, }) ); elements.push(new Paragraph({ text: '' })); } catch { /* skip broken image */ } } break; } case 'markdown': { if (block.title) { elements.push(new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_2 })); } if (block.content) { for (const line of block.content.split('\n')) { if (line.trim()) { elements.push(new Paragraph({ text: line })); } } elements.push(new Paragraph({ text: '' })); } break; } } return elements; } function conclusionToDocxElements(conclusion: ConclusionReport): (Paragraph | Table)[] { const elements: (Paragraph | Table)[] = []; elements.push(new Paragraph({ text: '综合结论', heading: HeadingLevel.HEADING_1 })); elements.push(new Paragraph({ text: '' })); // Executive Summary elements.push(new Paragraph({ text: '摘要', heading: HeadingLevel.HEADING_2 })); for (const line of conclusion.executive_summary.split('\n')) { if (line.trim()) { elements.push(new Paragraph({ text: line })); } } elements.push(new Paragraph({ text: '' })); // Key Findings if (conclusion.key_findings.length > 0) { elements.push(new Paragraph({ text: '主要发现', heading: HeadingLevel.HEADING_2 })); for (const finding of conclusion.key_findings) { elements.push( new Paragraph({ children: [new TextRun({ text: `• ${finding}` })], }) ); } elements.push(new Paragraph({ text: '' })); } // Recommendations if (conclusion.recommendations && conclusion.recommendations.length > 0) { elements.push(new Paragraph({ text: '建议', heading: HeadingLevel.HEADING_2 })); for (const rec of conclusion.recommendations) { elements.push( new Paragraph({ children: [new TextRun({ text: `• ${rec}` })], }) ); } elements.push(new Paragraph({ text: '' })); } // Limitations if (conclusion.limitations.length > 0) { elements.push(new Paragraph({ text: '局限性', heading: HeadingLevel.HEADING_2 })); for (const lim of conclusion.limitations) { elements.push( new Paragraph({ children: [new TextRun({ text: `• ${lim}` })], }) ); } elements.push(new Paragraph({ text: '' })); } return elements; } export async function exportBlocksToWord( blocks: ReportBlock[], options: ExportOptions = {} ): Promise { const now = new Date(); const { title = '统计分析报告', subtitle, generatedAt = now.toLocaleString('zh-CN'), conclusion, } = options; const children: (Paragraph | Table)[] = []; children.push( new Paragraph({ text: title, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }) ); if (subtitle) { children.push(new Paragraph({ text: subtitle, alignment: AlignmentType.CENTER })); } children.push( new Paragraph({ children: [new TextRun({ text: `生成时间:${generatedAt}`, italics: true, color: '666666' })], }), new Paragraph({ text: '' }) ); // Phase R: Conclusion section at the top (if available) if (conclusion) { children.push(...conclusionToDocxElements(conclusion)); children.push(new Paragraph({ text: '详细分析结果', heading: HeadingLevel.HEADING_1 })); children.push(new Paragraph({ text: '' })); } for (let i = 0; i < blocks.length; i++) { children.push(...blockToDocxElements(blocks[i], i)); } children.push( new Paragraph({ text: '' }), new Paragraph({ children: [ new TextRun({ text: `本报告由智能统计分析系统自动生成${conclusion?.source === 'llm' ? '(结论由 AI 生成)' : ''}`, italics: true, color: '666666', }), ], }) ); const doc = new Document({ sections: [{ children }] }); const blob = await Packer.toBlob(doc); const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; const safeTitle = title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${safeTitle}_${dateStr}.docx`; a.click(); URL.revokeObjectURL(url); }