Files
AIclinicalresearch/frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts
HaHafeng 85fda830c2 feat(ssa): Complete Phase V-A editable analysis plan variables
Features:
- Add editable variable selection in workflow plan (SingleVarSelect + MultiVarTags)
- Implement 3-layer flexible interception (warning bar + icon + blocking dialog)
- Add tool_param_constraints.json for 12 statistical tools parameter validation
- Add PATCH /workflow/:id/params API with Zod structural validation
- Implement synchronous parameter sync before execution (Promise chaining)
- Fix LLM hallucination by strict system prompt constraints
- Fix DynamicReport object-based rows compatibility (R baseline_table)
- Fix Word export row.map error with same normalization logic
- Restore inferGroupingVar for smart default variable selection
- Add ReactMarkdown rendering in SSAChatPane
- Update SSA module status document to v3.5

Modified files:
- backend: workflow.routes, ChatHandlerService, SystemPromptService, FlowTemplateService
- frontend: WorkflowTimeline, SSAWorkspacePane, DynamicReport, SSAChatPane, ssaStore, ssa.css
- config: tool_param_constraints.json (new)
- docs: SSA status doc, team review reports

Tested: Cohort study end-to-end execution + report export verified
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 13:08:29 +08:00

292 lines
8.6 KiB
TypeScript

/**
* 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<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);
}