feat(ssa): Complete V11 UI development and frontend-backend integration - Pixel-perfect V11 UI, multi-task support, Word export, input overlay fix, code cleanup. MVP Phase 1 core 95% complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-20 14:46:45 +08:00
parent 49b5c37cb1
commit 8d496d1515
38 changed files with 7255 additions and 1074 deletions

View File

@@ -9,7 +9,21 @@ 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, Message, TraceStep } from '../types';
import type { AnalysisPlan, ExecutionResult, SSAMessage, TraceStep } from '../types';
import {
Document,
Packer,
Paragraph,
Table,
TableRow,
TableCell,
TextRun,
HeadingLevel,
WidthType,
BorderStyle,
AlignmentType,
ImageRun,
} from 'docx';
const API_BASE = '/api/v1/ssa';
@@ -35,14 +49,18 @@ interface UseAnalysisReturn {
uploadData: (file: File) => Promise<UploadResult>;
generatePlan: (query: string) => Promise<AnalysisPlan>;
executePlan: (planId: string) => Promise<ExecutionResult>;
executeAnalysis: () => Promise<ExecutionResult>;
downloadCode: () => Promise<DownloadResult>;
exportReport: () => void;
isUploading: boolean;
isExecuting: boolean;
uploadProgress: number;
}
export function useAnalysis(): UseAnalysisReturn {
const {
currentSession,
currentPlan,
setCurrentPlan,
setExecutionResult,
setTraceSteps,
@@ -50,7 +68,10 @@ export function useAnalysis(): UseAnalysisReturn {
addMessage,
setLoading,
setExecuting,
isExecuting,
setError,
addAnalysisRecord,
updateAnalysisRecord,
} = useSSAStore();
const [isUploading, setIsUploading] = useState(false);
@@ -101,11 +122,10 @@ export function useAnalysis(): UseAnalysisReturn {
setLoading(true);
try {
const userMessage: Message = {
const userMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'user',
contentType: 'text',
content: { text: query },
content: query,
createdAt: new Date().toISOString(),
};
addMessage(userMessage);
@@ -116,17 +136,30 @@ export function useAnalysis(): UseAnalysisReturn {
);
const plan: AnalysisPlan = response.data;
setCurrentPlan(plan);
const planMessage: Message = {
// 创建分析记录(支持多任务)
const recordId = addAnalysisRecord(query, plan);
// 消息中携带 recordId便于点击时定位
const planMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
contentType: 'plan',
content: { plan, canExecute: true },
content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`,
artifactType: 'sap',
recordId, // 关联到分析记录
createdAt: new Date().toISOString(),
};
addMessage(planMessage);
const confirmMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '请确认数据映射并执行分析。',
artifactType: 'confirm',
createdAt: new Date().toISOString(),
};
addMessage(confirmMessage);
return plan;
} catch (error) {
setError(error instanceof Error ? error.message : '生成计划出错');
@@ -135,7 +168,7 @@ export function useAnalysis(): UseAnalysisReturn {
setLoading(false);
}
},
[currentSession, addMessage, setCurrentPlan, setLoading, setError]
[currentSession, addMessage, setCurrentPlan, setLoading, setError, addAnalysisRecord]
);
const executePlan = useCallback(
@@ -154,11 +187,11 @@ export function useAnalysis(): UseAnalysisReturn {
setExecutionResult(null);
const initialSteps: TraceStep[] = [
{ index: 0, name: '参数验证', status: 'pending' },
{ index: 1, name: '护栏检查', status: 'pending' },
{ index: 2, name: '统计计算', status: 'pending' },
{ index: 3, name: '可视化生成', status: 'pending' },
{ index: 4, name: '结果格式化', status: 'pending' },
{ 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);
@@ -201,12 +234,22 @@ export function useAnalysis(): UseAnalysisReturn {
});
setExecutionResult(result);
// 更新分析记录
const recordId = useSSAStore.getState().currentRecordId;
if (recordId) {
updateAnalysisRecord(recordId, {
executionResult: result,
traceSteps: useSSAStore.getState().traceSteps,
});
}
const resultMessage: Message = {
const resultMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
contentType: 'result',
content: { execution: result },
content: result.interpretation || '分析完成,请查看右侧结果面板。',
artifactType: 'result',
recordId: recordId || undefined, // 关联到分析记录
createdAt: new Date().toISOString(),
};
addMessage(resultMessage);
@@ -241,6 +284,13 @@ export function useAnalysis(): UseAnalysisReturn {
]
);
const executeAnalysis = useCallback(async (): Promise<ExecutionResult> => {
if (!currentPlan) {
throw new Error('请先生成分析计划');
}
return executePlan(currentPlan.id);
}, [currentPlan, executePlan]);
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
if (!currentSession) {
throw new Error('请先上传数据');
@@ -251,16 +301,13 @@ export function useAnalysis(): UseAnalysisReturn {
{ responseType: 'blob' }
);
// 从 Content-Disposition header 提取文件名
const contentDisposition = response.headers['content-disposition'];
let filename = `analysis_${currentSession.id}.R`;
if (contentDisposition) {
// 尝试匹配 filename="xxx" 或 filename*=UTF-8''xxx
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch) {
let extractedName = filenameMatch[1].replace(/['"]/g, '');
// 处理 URL 编码的文件名
try {
extractedName = decodeURIComponent(extractedName);
} catch {
@@ -275,12 +322,256 @@ export function useAnalysis(): UseAnalysisReturn {
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;
if (!result) {
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: '' }),
);
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: '' }),
);
}
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: '' }),
);
}
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);
}, [setError]);
return {
uploadData,
generatePlan,
executePlan,
executeAnalysis,
downloadCode,
exportReport,
isUploading,
isExecuting,
uploadProgress,
};
}