feat(ssa): Complete Phase 2A frontend integration - multi-step workflow end-to-end

Phase 2A: WorkflowPlannerService, WorkflowExecutorService, Python data quality, 6 bug fixes, DescriptiveResultView, multi-step R code/Word export, MVP UI reuse. V11 UI: Gemini-style, multi-task, single-page scroll, Word export. Architecture: Block-based rendering consensus (4 block types). New R tools: chi_square, correlation, descriptive, logistic_binary, mann_whitney, t_test_paired. Docs: dev summary, block-based plan, status updates, task list v2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-20 23:09:27 +08:00
parent 23b422f758
commit 428a22adf2
62 changed files with 15416 additions and 299 deletions

View File

@@ -327,6 +327,11 @@ export function useAnalysis(): UseAnalysisReturn {
const plan = useSSAStore.getState().currentPlan;
const session = useSSAStore.getState().currentSession;
const mountedFile = useSSAStore.getState().mountedFile;
const { isWorkflowMode, workflowSteps, workflowPlan, conclusionReport } = useSSAStore.getState();
if (isWorkflowMode && workflowSteps.some(s => s.status === 'success')) {
return exportWorkflowReport(workflowSteps, workflowPlan, conclusionReport, session, mountedFile);
}
if (!result) {
setError('暂无分析结果可导出');
@@ -563,6 +568,295 @@ export function useAnalysis(): UseAnalysisReturn {
URL.revokeObjectURL(url);
}, [setError]);
const exportWorkflowReport = async (
steps: any[],
wfPlan: any,
conclusion: any,
session: any,
mountedFile: any
) => {
const now = new Date();
const dateStr = now.toLocaleString('zh-CN');
const dataFileName = mountedFile?.name || session?.title || '数据文件';
const createTableRow = (cells: string[], isHeader = false) => {
return new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: String(text ?? '-'), bold: isHeader })],
})],
width: { size: 100 / cells.length, type: WidthType.PERCENTAGE },
})),
});
};
const tableBorders = {
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 },
};
const sections: (Paragraph | Table)[] = [];
let sectionNum = 1;
sections.push(
new Paragraph({ text: '多步骤统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
new Paragraph({ text: '' }),
new Paragraph({ children: [
new TextRun({ text: '研究课题:', bold: true }),
new TextRun(wfPlan?.title || session?.title || '统计分析'),
]}),
new Paragraph({ children: [
new TextRun({ text: '数据文件:', bold: true }),
new TextRun(dataFileName),
]}),
new Paragraph({ children: [
new TextRun({ text: '生成时间:', bold: true }),
new TextRun(dateStr),
]}),
new Paragraph({ children: [
new TextRun({ text: '分析步骤:', bold: true }),
new TextRun(`${steps.length} 个步骤`),
]}),
new Paragraph({ text: '' }),
);
if (conclusion?.executive_summary) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 摘要`, heading: HeadingLevel.HEADING_1 }),
new Paragraph({ text: conclusion.executive_summary }),
new Paragraph({ text: '' }),
);
}
const successSteps = steps.filter(s => s.status === 'success' && s.result);
for (const step of successSteps) {
const r = step.result as any;
sections.push(
new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }),
);
if (step.duration_ms) {
sections.push(new Paragraph({ children: [
new TextRun({ text: `执行耗时:${step.duration_ms}ms`, italics: true, color: '666666' }),
]}));
}
const isDescStep = step.tool_code === 'ST_DESCRIPTIVE' || r?.summary;
if (isDescStep && (r?.summary || r?.variables)) {
if (r?.summary) {
sections.push(
new Paragraph({ text: `总观测数: ${r.summary.n_total ?? '-'}, 分析变量数: ${r.summary.n_variables ?? '-'}, 数值变量: ${r.summary.n_numeric ?? '-'}, 分类变量: ${r.summary.n_categorical ?? '-'}` }),
new Paragraph({ text: '' }),
);
}
if (r?.variables && typeof r.variables === 'object') {
const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
if (!v) return 'unknown';
if (v.type === 'numeric') return 'numeric';
if (v.type === 'categorical') return 'categorical';
if (v.mean !== undefined) return 'numeric';
if (v.levels && Array.isArray(v.levels)) return 'categorical';
if (v.by_group) {
const first = Object.values(v.by_group)[0] as any;
if (first?.mean !== undefined) return 'numeric';
if (first?.levels) return 'categorical';
}
return 'unknown';
};
const varEntries = Object.entries(r.variables);
const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric');
const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical');
if (numericVars.length > 0) {
const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)];
for (const [varName, rawVs] of numericVars) {
const vs = rawVs as any;
if (vs.by_group) {
for (const [gName, gStats] of Object.entries(vs.by_group)) {
const g = gStats as any;
numRows.push(createTableRow([
`${vs.variable || varName} [${gName}]`, String(g.n ?? '-'),
g.formatted || (g.mean !== undefined ? `${g.mean} ± ${g.sd}` : '-'),
String(g.median ?? '-'), String(g.q1 ?? '-'), String(g.q3 ?? '-'),
String(g.min ?? '-'), String(g.max ?? '-'),
]));
}
} else {
numRows.push(createTableRow([
vs.variable || varName, String(vs.n ?? '-'),
vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-'),
String(vs.median ?? '-'), String(vs.q1 ?? '-'), String(vs.q3 ?? '-'),
String(vs.min ?? '-'), String(vs.max ?? '-'),
]));
}
}
sections.push(
new Paragraph({ text: '数值变量统计', heading: HeadingLevel.HEADING_2 }),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: numRows }),
new Paragraph({ text: '' }),
);
}
if (catVars.length > 0) {
sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 }));
for (const [varName, rawVs] of catVars) {
const vs = rawVs as any;
if (vs.by_group) {
for (const [gName, gStats] of Object.entries(vs.by_group)) {
const g = gStats as any;
const levels = g.levels || [];
if (levels.length > 0) {
sections.push(
new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName} [${gName}]`, bold: true }), new TextRun(` (N=${g.n ?? '-'})`)] }),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [
createTableRow(['类别', '频数', '百分比'], true),
...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])),
]}),
new Paragraph({ text: '' }),
);
}
}
} else {
const levels = vs.levels || [];
if (levels.length > 0) {
sections.push(
new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName}`, bold: true }), new TextRun(` (N=${vs.n ?? '-'}, 缺失=${vs.missing ?? 0})`)] }),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [
createTableRow(['类别', '频数', '百分比'], true),
...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])),
]}),
new Paragraph({ text: '' }),
);
}
}
}
}
}
} else {
const statsRows = [
createTableRow(['指标', '值'], true),
createTableRow(['统计方法', r?.method || step.tool_name]),
];
if (r?.statistic !== undefined) statsRows.push(createTableRow(['统计量', Number(r.statistic).toFixed(4)]));
if (r?.p_value !== undefined) statsRows.push(createTableRow(['P 值', r.p_value_fmt || (r.p_value < 0.001 ? '< 0.001' : Number(r.p_value).toFixed(4))]));
if (r?.effect_size !== undefined) {
const esStr = typeof r.effect_size === 'object'
? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ')
: Number(r.effect_size).toFixed(3);
statsRows.push(createTableRow(['效应量', esStr]));
}
if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`]));
sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows }));
sections.push(new Paragraph({ text: '' }));
}
if (r?.group_stats?.length > 0) {
sections.push(
new Paragraph({ text: '分组统计', heading: HeadingLevel.HEADING_2 }),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['分组', 'N', '均值', '标准差'], true),
...r.group_stats.map((g: any) => createTableRow([
String(g.group), String(g.n),
g.mean !== undefined ? Number(g.mean).toFixed(4) : '-',
g.sd !== undefined ? Number(g.sd).toFixed(4) : '-',
])),
],
}),
new Paragraph({ text: '' }),
);
}
if (r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0) {
sections.push(
new Paragraph({ text: '回归系数', heading: HeadingLevel.HEADING_2 }),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['变量', '估计值', 'OR', '95% CI', 'P 值'], true),
...r.coefficients.map((c: any) => createTableRow([
c.variable || c.term || '-',
Number(c.estimate || c.coef || 0).toFixed(4),
c.OR !== undefined ? Number(c.OR).toFixed(4) : '-',
c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-',
c.p_value_fmt || (c.p_value !== undefined ? (c.p_value < 0.001 ? '< 0.001' : Number(c.p_value).toFixed(4)) : '-'),
])),
],
}),
new Paragraph({ text: '' }),
);
}
if (r?.plots?.length > 0) {
for (const plot of r.plots) {
const imageBase64 = typeof plot === 'string' ? plot : plot.imageBase64;
if (imageBase64) {
try {
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
const imageBuffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
sections.push(
new Paragraph({
children: [new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' })],
alignment: AlignmentType.CENTER,
}),
new Paragraph({ text: '' }),
);
} catch (e) { /* skip */ }
}
}
}
}
if (conclusion?.key_findings?.length > 0) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 主要发现`, heading: HeadingLevel.HEADING_1 }),
...conclusion.key_findings.map((f: string) => new Paragraph({ text: `${f}` })),
new Paragraph({ text: '' }),
);
}
if (conclusion?.recommendations?.length > 0) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }),
...conclusion.recommendations.map((r: string) => new Paragraph({ text: `${r}` })),
new Paragraph({ text: '' }),
);
}
sections.push(
new Paragraph({ text: '' }),
new Paragraph({ children: [
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
]}),
new Paragraph({ children: [
new TextRun({ text: `总执行耗时: ${steps.reduce((s, st) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
]}),
);
const doc = new Document({ sections: [{ children: sections }] });
const dateTimeStr = `${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 safeFileName = dataFileName.replace(/\.(csv|xlsx|xls)$/i, '').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_');
const blob = await Packer.toBlob(doc);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `多步骤分析报告_${safeFileName}_${dateTimeStr}.docx`;
a.click();
URL.revokeObjectURL(url);
};
return {
uploadData,
generatePlan,