fix(ssa): Fix 7 integration bugs and refactor frontend unified state management
Bug fixes: - Fix garbled error messages in chat (TypeWriter rendering issue) - Fix R engine NA crash in descriptive.R (defensive isTRUE/is.na checks) - Fix intent misclassification for statistical significance queries - Fix step 2 results not displayed (accept warning status alongside success) - Fix incomplete R code download (only step 1 included) - Fix multi-task state confusion (clicking old card shows new results) - Add R engine and backend parameter logging for debugging Refactor - Unified Record Architecture: - Replace 12 global singleton fields with AnalysisRecord as single source of truth - Remove isWorkflowMode branching across all components - One Analysis = One Record = N Steps paradigm - selectRecord only sets currentRecordId, all rendering derives from currentRecord - Fix cross-hook-instance issue: executeWorkflow fallback to store currentRecordId Updated files: ssaStore, useWorkflow, useAnalysis, SSAChatPane, SSAWorkspacePane, SSACodeModal, WorkflowTimeline, QueryService, WorkflowExecutorService, descriptive.R Tested: Manual integration test passed - multi-task switching, R code completeness Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* SSA 分析相关的自定义 Hook
|
||||
*
|
||||
* 遵循规范:
|
||||
* - 使用 apiClient(带认证的 axios 实例)
|
||||
* - 使用 getAccessToken 处理文件上传
|
||||
* SSA 分析相关的自定义 Hook (Updated for Unified Record Architecture)
|
||||
*
|
||||
* uploadData / generatePlan 仍由 SSAChatPane 使用。
|
||||
* executeAnalysis / exportReport 保留但已切换到 record-based 存储。
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
import { getAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import type { AnalysisPlan, ExecutionResult, SSAMessage, TraceStep } from '../types';
|
||||
import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types';
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
@@ -60,18 +59,13 @@ interface UseAnalysisReturn {
|
||||
export function useAnalysis(): UseAnalysisReturn {
|
||||
const {
|
||||
currentSession,
|
||||
currentPlan,
|
||||
setCurrentPlan,
|
||||
setExecutionResult,
|
||||
setTraceSteps,
|
||||
updateTraceStep,
|
||||
addMessage,
|
||||
setLoading,
|
||||
setExecuting,
|
||||
isExecuting,
|
||||
setError,
|
||||
addAnalysisRecord,
|
||||
updateAnalysisRecord,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
} = useSSAStore();
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -85,7 +79,6 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
// 文件上传使用 fetch + 手动添加认证头(不设置 Content-Type)
|
||||
const token = getAccessToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
@@ -98,10 +91,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('上传失败');
|
||||
const result = await response.json();
|
||||
setUploadProgress(100);
|
||||
return result;
|
||||
@@ -115,10 +105,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
|
||||
const generatePlan = useCallback(
|
||||
async (query: string): Promise<AnalysisPlan> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
if (!currentSession) throw new Error('请先上传数据');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -134,31 +121,38 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
`${API_BASE}/sessions/${currentSession.id}/plan`,
|
||||
{ query }
|
||||
);
|
||||
|
||||
const plan: AnalysisPlan = response.data;
|
||||
|
||||
// 创建分析记录(支持多任务)
|
||||
const recordId = addAnalysisRecord(query, plan);
|
||||
|
||||
// 消息中携带 recordId,便于点击时定位
|
||||
|
||||
const recordId = addRecord(query, {
|
||||
workflow_id: plan.id || `legacy_${Date.now()}`,
|
||||
title: plan.title || plan.toolName || '统计分析',
|
||||
total_steps: 1,
|
||||
steps: [{
|
||||
step_number: 1,
|
||||
tool_code: plan.toolCode || 'unknown',
|
||||
tool_name: plan.toolName || '统计分析',
|
||||
description: plan.description || '',
|
||||
params: plan.parameters || {},
|
||||
}],
|
||||
} as any);
|
||||
|
||||
const planMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`,
|
||||
artifactType: 'sap',
|
||||
recordId, // 关联到分析记录
|
||||
recordId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(planMessage);
|
||||
|
||||
const confirmMessage: SSAMessage = {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '请确认数据映射并执行分析。',
|
||||
artifactType: 'confirm',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(confirmMessage);
|
||||
});
|
||||
|
||||
return plan;
|
||||
} catch (error) {
|
||||
@@ -168,411 +162,132 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentSession, addMessage, setCurrentPlan, setLoading, setError, addAnalysisRecord]
|
||||
[currentSession, addMessage, setLoading, setError, addRecord]
|
||||
);
|
||||
|
||||
const executePlan = useCallback(
|
||||
async (_planId: string): Promise<ExecutionResult> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
if (!currentSession) throw new Error('请先上传数据');
|
||||
|
||||
// 获取当前 plan(从 store)
|
||||
const plan = useSSAStore.getState().currentPlan;
|
||||
if (!plan) {
|
||||
throw new Error('请先生成分析计划');
|
||||
}
|
||||
const record = (() => {
|
||||
const s = useSSAStore.getState();
|
||||
return s.currentRecordId
|
||||
? s.analysisHistory.find((r) => r.id === s.currentRecordId) ?? null
|
||||
: null;
|
||||
})();
|
||||
const planStep = record?.plan?.steps?.[0];
|
||||
if (!planStep) throw new Error('请先生成分析计划');
|
||||
|
||||
setExecuting(true);
|
||||
setExecutionResult(null);
|
||||
|
||||
const initialSteps: TraceStep[] = [
|
||||
{ index: 0, name: '参数验证', status: 'pending', message: '等待执行' },
|
||||
{ index: 1, name: '护栏检查', status: 'pending', message: '等待执行' },
|
||||
{ index: 2, name: '统计计算', status: 'pending', message: '等待执行' },
|
||||
{ index: 3, name: '可视化生成', status: 'pending', message: '等待执行' },
|
||||
{ index: 4, name: '结果格式化', status: 'pending', message: '等待执行' },
|
||||
];
|
||||
setTraceSteps(initialSteps);
|
||||
const rid = record!.id;
|
||||
updateRecord(rid, { status: 'executing', steps: [], progress: 0 });
|
||||
|
||||
try {
|
||||
updateTraceStep(0, { status: 'running' });
|
||||
|
||||
// 发送完整的 plan 对象(转换为后端格式)
|
||||
const response = await apiClient.post(
|
||||
`${API_BASE}/sessions/${currentSession.id}/execute`,
|
||||
{
|
||||
{
|
||||
plan: {
|
||||
tool_code: plan.toolCode,
|
||||
tool_name: plan.toolName,
|
||||
params: plan.parameters,
|
||||
guardrails: plan.guardrails
|
||||
}
|
||||
tool_code: planStep.tool_code,
|
||||
tool_name: planStep.tool_name,
|
||||
params: planStep.params,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const result: ExecutionResult = response.data;
|
||||
|
||||
initialSteps.forEach((_, i) => {
|
||||
updateTraceStep(i, { status: 'success' });
|
||||
updateRecord(rid, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
steps: [{
|
||||
step_number: 1,
|
||||
tool_code: planStep.tool_code,
|
||||
tool_name: planStep.tool_name,
|
||||
status: 'success',
|
||||
result: result as any,
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
duration_ms: result.executionMs,
|
||||
logs: [],
|
||||
}],
|
||||
});
|
||||
|
||||
result.guardrailResults?.forEach((gr) => {
|
||||
if (gr.actionType === 'Switch' && gr.actionTaken) {
|
||||
updateTraceStep(1, {
|
||||
status: 'switched',
|
||||
actionType: 'Switch',
|
||||
switchTarget: gr.switchTarget,
|
||||
message: gr.message,
|
||||
});
|
||||
} else if (gr.actionType === 'Warn') {
|
||||
updateTraceStep(1, {
|
||||
actionType: 'Warn',
|
||||
message: gr.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setExecutionResult(result);
|
||||
|
||||
// 更新分析记录
|
||||
const recordId = useSSAStore.getState().currentRecordId;
|
||||
if (recordId) {
|
||||
updateAnalysisRecord(recordId, {
|
||||
executionResult: result,
|
||||
traceSteps: useSSAStore.getState().traceSteps,
|
||||
});
|
||||
}
|
||||
|
||||
const resultMessage: SSAMessage = {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: result.interpretation || '分析完成,请查看右侧结果面板。',
|
||||
artifactType: 'result',
|
||||
recordId: recordId || undefined, // 关联到分析记录
|
||||
recordId: rid,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(resultMessage);
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
initialSteps.forEach((step, i) => {
|
||||
if (step.status === 'running' || step.status === 'pending') {
|
||||
updateTraceStep(i, { status: 'failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// 提取 R 服务返回的具体错误信息
|
||||
updateRecord(rid, { status: 'error' });
|
||||
const errorData = error.response?.data;
|
||||
const errorMessage = errorData?.user_hint || errorData?.error ||
|
||||
const errorMessage = errorData?.user_hint || errorData?.error ||
|
||||
(error instanceof Error ? error.message : '执行出错');
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentSession,
|
||||
addMessage,
|
||||
setExecutionResult,
|
||||
setTraceSteps,
|
||||
updateTraceStep,
|
||||
setExecuting,
|
||||
setError,
|
||||
]
|
||||
[currentSession, addMessage, setExecuting, setError, updateRecord]
|
||||
);
|
||||
|
||||
const executeAnalysis = useCallback(async (): Promise<ExecutionResult> => {
|
||||
if (!currentPlan) {
|
||||
throw new Error('请先生成分析计划');
|
||||
}
|
||||
return executePlan(currentPlan.id);
|
||||
}, [currentPlan, executePlan]);
|
||||
return executePlan('current');
|
||||
}, [executePlan]);
|
||||
|
||||
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
if (!currentSession) throw new Error('请先上传数据');
|
||||
const response = await apiClient.get(
|
||||
`${API_BASE}/sessions/${currentSession.id}/download-code`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `analysis_${currentSession.id}.R`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (filenameMatch) {
|
||||
let extractedName = filenameMatch[1].replace(/['"]/g, '');
|
||||
try {
|
||||
extractedName = decodeURIComponent(extractedName);
|
||||
} catch {
|
||||
// 解码失败,使用原始值
|
||||
}
|
||||
if (extractedName) {
|
||||
filename = extractedName;
|
||||
}
|
||||
try { extractedName = decodeURIComponent(extractedName); } catch { /* ok */ }
|
||||
if (extractedName) filename = extractedName;
|
||||
}
|
||||
}
|
||||
|
||||
return { blob: response.data, filename };
|
||||
}, [currentSession]);
|
||||
|
||||
const exportReport = useCallback(async () => {
|
||||
const result = useSSAStore.getState().executionResult;
|
||||
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) {
|
||||
const state = useSSAStore.getState();
|
||||
const record = state.currentRecordId
|
||||
? state.analysisHistory.find((r) => r.id === state.currentRecordId) ?? null
|
||||
: null;
|
||||
if (!record) {
|
||||
setError('暂无分析结果可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleString('zh-CN');
|
||||
const pValue = result.results?.pValue ?? (result.results as any)?.p_value;
|
||||
const pValueStr = pValue !== undefined
|
||||
? (pValue < 0.001 ? '< 0.001' : pValue.toFixed(4))
|
||||
: '-';
|
||||
|
||||
const groupVar = String(plan?.parameters?.groupVar || plan?.parameters?.group_var || '-');
|
||||
const valueVar = String(plan?.parameters?.valueVar || plan?.parameters?.value_var || '-');
|
||||
const dataFileName = mountedFile?.name || session?.title || '数据文件';
|
||||
const rowCount = mountedFile?.rowCount || session?.dataSchema?.rowCount || 0;
|
||||
|
||||
const createTableRow = (cells: string[], isHeader = false) => {
|
||||
return new TableRow({
|
||||
children: cells.map(text => new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ 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(session?.title || plan?.title || '未命名分析'),
|
||||
]}),
|
||||
new Paragraph({ children: [
|
||||
new TextRun({ text: '生成时间:', bold: true }),
|
||||
new TextRun(dateStr),
|
||||
]}),
|
||||
new Paragraph({ text: '' }),
|
||||
const steps = record.steps.filter(
|
||||
(s) => (s.status === 'success' || s.status === 'warning') && s.result
|
||||
);
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 数据描述`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['项目', '内容'], true),
|
||||
createTableRow(['数据文件', dataFileName]),
|
||||
createTableRow(['样本量', `${rowCount} 行`]),
|
||||
createTableRow(['分组变量 (X)', groupVar]),
|
||||
createTableRow(['分析变量 (Y)', valueVar]),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 分析方法`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `本研究采用 ${result.results?.method || plan?.toolName || '统计检验'} 方法,` +
|
||||
`比较 ${groupVar} 分组下 ${valueVar} 的差异。`,
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
|
||||
if (result.guardrailResults?.length) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 前提条件检验`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['检查项', '结果', '说明'], true),
|
||||
...result.guardrailResults.map((gr: { checkName: string; passed: boolean; actionType: string; message: string }) => createTableRow([
|
||||
gr.checkName,
|
||||
gr.passed ? '通过' : gr.actionType === 'Switch' ? '降级' : '未通过',
|
||||
gr.message,
|
||||
])),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
if (steps.length === 0) {
|
||||
setError('暂无分析结果可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultAny = result.results as any;
|
||||
const groupStats = resultAny?.groupStats || resultAny?.group_stats;
|
||||
if (groupStats?.length) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 描述性统计`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['分组', '样本量 (n)', '均值 (Mean)', '标准差 (SD)'], true),
|
||||
...groupStats.map((gs: any) => createTableRow([
|
||||
gs.group,
|
||||
String(gs.n),
|
||||
gs.mean?.toFixed(4) || '-',
|
||||
gs.sd?.toFixed(4) || '-',
|
||||
])),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
const session = state.currentSession;
|
||||
const mountedFile = state.mountedFile;
|
||||
const conclusion = record.conclusionReport;
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 统计检验结果`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['指标', '值'], true),
|
||||
createTableRow(['统计方法', resultAny?.method || '-']),
|
||||
createTableRow(['统计量 (t/F/χ²)', resultAny?.statistic?.toFixed(4) || '-']),
|
||||
createTableRow(['自由度 (df)', resultAny?.df?.toFixed(2) || '-']),
|
||||
createTableRow(['P 值', pValueStr]),
|
||||
...(resultAny?.effectSize ? [createTableRow(['效应量', resultAny.effectSize.toFixed(3)])] : []),
|
||||
...(resultAny?.confInt ? [createTableRow(['95% 置信区间', `[${resultAny.confInt[0]?.toFixed(4)}, ${resultAny.confInt[1]?.toFixed(4)}]`])] : []),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
|
||||
const plotData = result.plots?.[0];
|
||||
if (plotData) {
|
||||
const imageBase64 = typeof plotData === 'string' ? plotData : plotData.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({
|
||||
text: `${sectionNum++}. 可视化结果`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: { width: 450, height: 300 },
|
||||
type: 'png',
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `图 1. ${valueVar} 在 ${groupVar} 分组下的分布`,
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('图片导出失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 结论`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: result.interpretation ||
|
||||
(pValue !== undefined && pValue < 0.05
|
||||
? `${groupVar} 分组间的 ${valueVar} 差异具有统计学意义 (P = ${pValueStr})。`
|
||||
: `${groupVar} 分组间的 ${valueVar} 差异无统计学意义 (P = ${pValueStr})。`),
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `执行耗时: ${result.executionMs}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);
|
||||
await exportWorkflowReport(steps, record.plan, conclusion, session, mountedFile);
|
||||
}, [setError]);
|
||||
|
||||
const exportWorkflowReport = async (
|
||||
steps: any[],
|
||||
wfPlan: any,
|
||||
conclusion: any,
|
||||
session: any,
|
||||
steps: any[],
|
||||
wfPlan: any,
|
||||
conclusion: any,
|
||||
session: any,
|
||||
mountedFile: any
|
||||
) => {
|
||||
const now = new Date();
|
||||
@@ -603,7 +318,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
let sectionNum = 1;
|
||||
|
||||
sections.push(
|
||||
new Paragraph({ text: '多步骤统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ text: '统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({ children: [
|
||||
new TextRun({ text: '研究课题:', bold: true }),
|
||||
@@ -632,8 +347,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
);
|
||||
}
|
||||
|
||||
const successSteps = steps.filter(s => s.status === 'success' && s.result);
|
||||
for (const step of successSteps) {
|
||||
for (const step of steps) {
|
||||
const r = step.result as any;
|
||||
sections.push(
|
||||
new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }),
|
||||
@@ -654,7 +368,6 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
|
||||
if (r?.variables && typeof r.variables === 'object') {
|
||||
const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
|
||||
if (!v) return 'unknown';
|
||||
@@ -669,11 +382,9 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
}
|
||||
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) {
|
||||
@@ -703,7 +414,6 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
|
||||
if (catVars.length > 0) {
|
||||
sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 }));
|
||||
for (const [varName, rawVs] of catVars) {
|
||||
@@ -747,13 +457,12 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
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'
|
||||
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: '' }));
|
||||
}
|
||||
@@ -812,7 +521,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
} catch (e) { /* skip */ }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,11 +534,10 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
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}` })),
|
||||
...conclusion.recommendations.map((rec: string) => new Paragraph({ text: `• ${rec}` })),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
@@ -840,7 +548,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
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' }),
|
||||
new TextRun({ text: `总执行耗时: ${steps.reduce((s: number, st: any) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
|
||||
]}),
|
||||
);
|
||||
|
||||
@@ -852,7 +560,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `多步骤分析报告_${safeFileName}_${dateTimeStr}.docx`;
|
||||
a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user