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:
288
frontend-v2/src/modules/ssa/hooks/useAnalysis.ts
Normal file
288
frontend-v2/src/modules/ssa/hooks/useAnalysis.ts
Normal 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;
|
||||
Reference in New Issue
Block a user