feat(ssa): Complete QPER architecture - Query, Planner, Execute, Reflection layers
Implement the full QPER intelligent analysis pipeline: - Phase E+: Block-based standardization for all 7 R tools, DynamicReport renderer, Word export enhancement - Phase Q: LLM intent parsing with dynamic Zod validation against real column names, ClarificationCard component, DataProfile is_id_like tagging - Phase P: ConfigLoader with Zod schema validation and hot-reload API, DecisionTableService (4-dimension matching), FlowTemplateService with EPV protection, PlannedTrace audit output - Phase R: ReflectionService with statistical slot injection, sensitivity analysis conflict rules, ConclusionReport with section reveal animation, conclusion caching API, graceful R error classification End-to-end test: 40/40 passed across two complete analysis scenarios. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
284
frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts
Normal file
284
frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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 rows = block.rows ?? [];
|
||||
if (headers.length > 0 || rows.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));
|
||||
}
|
||||
for (const row of rows) {
|
||||
tableRows.push(makeRow(row.map(c => (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<void> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user