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>
292 lines
8.6 KiB
TypeScript
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);
|
|
}
|