feat(ssa): Complete T-test end-to-end testing with 9 bug fixes - Phase 1 core 85% complete. R service: missing value auto-filter. Backend: error handling, variable matching, dynamic filename. Frontend: module activation, session isolation, error propagation. Full flow verified.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-19 20:57:00 +08:00
parent 8137e3cde2
commit 49b5c37cb1
86 changed files with 21207 additions and 252 deletions

View File

@@ -0,0 +1,288 @@
/**
* SSA 分析相关的自定义 Hook
*
* 遵循规范:
* - 使用 apiClient带认证的 axios 实例)
* - 使用 getAccessToken 处理文件上传
*/
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';
const API_BASE = '/api/v1/ssa';
interface UploadResult {
sessionId: string;
schema: {
columns: Array<{
name: string;
type: string;
uniqueValues?: number;
nullCount?: number;
}>;
rowCount: number;
};
}
interface DownloadResult {
blob: Blob;
filename: string;
}
interface UseAnalysisReturn {
uploadData: (file: File) => Promise<UploadResult>;
generatePlan: (query: string) => Promise<AnalysisPlan>;
executePlan: (planId: string) => Promise<ExecutionResult>;
downloadCode: () => Promise<DownloadResult>;
isUploading: boolean;
uploadProgress: number;
}
export function useAnalysis(): UseAnalysisReturn {
const {
currentSession,
setCurrentPlan,
setExecutionResult,
setTraceSteps,
updateTraceStep,
addMessage,
setLoading,
setExecuting,
setError,
} = useSSAStore();
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const uploadData = useCallback(async (file: File): Promise<UploadResult> => {
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
// 文件上传使用 fetch + 手动添加认证头(不设置 Content-Type
const token = getAccessToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}/sessions`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const result = await response.json();
setUploadProgress(100);
return result;
} catch (error) {
setError(error instanceof Error ? error.message : '上传出错');
throw error;
} finally {
setIsUploading(false);
}
}, [setError]);
const generatePlan = useCallback(
async (query: string): Promise<AnalysisPlan> => {
if (!currentSession) {
throw new Error('请先上传数据');
}
setLoading(true);
try {
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
contentType: 'text',
content: { text: query },
createdAt: new Date().toISOString(),
};
addMessage(userMessage);
const response = await apiClient.post(
`${API_BASE}/sessions/${currentSession.id}/plan`,
{ query }
);
const plan: AnalysisPlan = response.data;
setCurrentPlan(plan);
const planMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
contentType: 'plan',
content: { plan, canExecute: true },
createdAt: new Date().toISOString(),
};
addMessage(planMessage);
return plan;
} catch (error) {
setError(error instanceof Error ? error.message : '生成计划出错');
throw error;
} finally {
setLoading(false);
}
},
[currentSession, addMessage, setCurrentPlan, setLoading, setError]
);
const executePlan = useCallback(
async (_planId: string): Promise<ExecutionResult> => {
if (!currentSession) {
throw new Error('请先上传数据');
}
// 获取当前 plan从 store
const plan = useSSAStore.getState().currentPlan;
if (!plan) {
throw new Error('请先生成分析计划');
}
setExecuting(true);
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' },
];
setTraceSteps(initialSteps);
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
}
}
);
const result: ExecutionResult = response.data;
initialSteps.forEach((_, i) => {
updateTraceStep(i, { status: 'success' });
});
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 resultMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
contentType: 'result',
content: { execution: result },
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 服务返回的具体错误信息
const errorData = error.response?.data;
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,
]
);
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
if (!currentSession) {
throw new Error('请先上传数据');
}
const response = await apiClient.get(
`${API_BASE}/sessions/${currentSession.id}/download-code`,
{ 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 {
// 解码失败,使用原始值
}
if (extractedName) {
filename = extractedName;
}
}
}
return { blob: response.data, filename };
}, [currentSession]);
return {
uploadData,
generatePlan,
executePlan,
downloadCode,
isUploading,
uploadProgress,
};
}
export default useAnalysis;