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

@@ -80,10 +80,9 @@ export const MODULES: ModuleDefinition[] = [
path: '/intelligent-analysis',
icon: BarChartOutlined,
component: lazy(() => import('@/modules/ssa')),
placeholder: true, // Java团队开发前端集成
placeholder: false, // ✅ MVP Phase 1 开发中
requiredVersion: 'premium',
description: '智能统计分析系统(Java团队开发',
isExternal: true, // 外部模块
description: '智能统计分析系统(AI+R统计引擎',
moduleCode: 'SSA', // 后端模块代码
},
{

View File

@@ -997,9 +997,7 @@
.message-bubble .markdown-content ul {
list-style-type: disc;
}
.message-bubble .markdown-content ol {
}.message-bubble .markdown-content ol {
list-style-type: decimal;
}.message-bubble .markdown-content li {
margin: 6px 0;
@@ -1017,13 +1015,11 @@
.message-bubble .markdown-content em {
font-style: italic;
color: #4B5563;
}
.message-bubble .markdown-content code {
}.message-bubble .markdown-content code {
background: rgba(79, 110, 242, 0.1);
color: #4F6EF2;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

View File

@@ -0,0 +1,130 @@
/**
* APA 格式统计结果表格
*/
import React from 'react';
import { Table, Typography, Empty } from 'antd';
const { Text } = Typography;
interface APATableProps {
result: any;
}
const formatPValue = (p: number | undefined): string => {
if (p === undefined || p === null) return '-';
if (p < 0.001) return '< .001';
if (p < 0.01) return p.toFixed(3).replace('0.', '.');
return p.toFixed(2).replace('0.', '.');
};
const formatStatistic = (value: number | undefined): string => {
if (value === undefined || value === null) return '-';
return value.toFixed(2);
};
const APATable: React.FC<APATableProps> = ({ result }) => {
// 处理空数据
if (!result || Object.keys(result).length === 0) {
return <Empty description="暂无统计结果" />;
}
// 兼容 camelCase 和 snake_case
const method = result.method || '统计分析';
const statistic = result.statistic;
const pValue = result.pValue ?? result.p_value;
const effectSize = result.effectSize ?? result.effect_size;
const ci = result.ci ?? result.conf_int;
const columns = [
{
title: '统计量',
dataIndex: 'label',
key: 'label',
width: 150,
},
{
title: '值',
dataIndex: 'value',
key: 'value',
render: (val: string | number) => (
<Text strong>{val}</Text>
),
},
];
const dataSource: any[] = [
{ key: 'method', label: '分析方法', value: method },
];
// 添加统计量(如果有)
if (statistic !== undefined) {
dataSource.push({
key: 'statistic',
label: '检验统计量',
value: formatStatistic(statistic),
});
}
// 添加 p 值(如果有)
if (pValue !== undefined) {
dataSource.push({
key: 'pValue',
label: 'p 值',
value: (
<Text
type={pValue < 0.05 ? 'success' : 'secondary'}
strong={pValue < 0.05}
>
{formatPValue(pValue)}
{pValue < 0.05 && ' *'}
{pValue < 0.01 && '*'}
{pValue < 0.001 && '*'}
</Text>
),
});
}
if (effectSize !== undefined) {
dataSource.push({
key: 'effectSize',
label: '效应量',
value: effectSize.toFixed(3),
});
}
if (ci && Array.isArray(ci) && ci.length >= 2) {
dataSource.push({
key: 'ci',
label: '95% 置信区间',
value: `[${ci[0].toFixed(2)}, ${ci[1].toFixed(2)}]`,
});
}
// 其他详细信息
const details = result.details || result.group_stats;
if (details && typeof details === 'object') {
Object.entries(details).forEach(([key, value]) => {
if (typeof value === 'number') {
dataSource.push({
key,
label: key,
value: value.toFixed(3),
});
} else if (typeof value === 'string') {
dataSource.push({ key, label: key, value });
}
});
}
return (
<Table
className="ssa-apa-table"
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
bordered
/>
);
};
export default APATable;

View File

@@ -0,0 +1,42 @@
/**
* 执行进度条组件
*/
import React from 'react';
import { Progress, Space, Typography } from 'antd';
import type { TraceStep } from '../types';
const { Text } = Typography;
interface ExecutionProgressProps {
steps: TraceStep[];
}
const ExecutionProgress: React.FC<ExecutionProgressProps> = ({ steps }) => {
const completedCount = steps.filter(
(s) => s.status === 'success' || s.status === 'switched'
).length;
const failedCount = steps.filter((s) => s.status === 'failed').length;
const total = steps.length;
const percent = Math.round((completedCount / total) * 100);
const currentStep = steps.find((s) => s.status === 'running');
const status =
failedCount > 0 ? 'exception' : percent === 100 ? 'success' : 'active';
return (
<div className="ssa-execution-progress">
<Progress
percent={percent}
status={status}
format={() => `${completedCount}/${total}`}
/>
{currentStep && (
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
: {currentStep.name}...
</Text>
)}
</div>
);
};
export default ExecutionProgress;

View File

@@ -0,0 +1,90 @@
/**
* 执行追踪组件 - 显示分析步骤进度
*/
import React from 'react';
import { Steps, Tag } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
SwapOutlined,
ExclamationCircleOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import type { TraceStep } from '../types';
interface ExecutionTraceProps {
steps: TraceStep[];
compact?: boolean;
}
const getIcon = (step: TraceStep) => {
switch (step.status) {
case 'success':
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
case 'failed':
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
case 'running':
return <LoadingOutlined style={{ color: '#1890ff' }} />;
case 'switched':
return <SwapOutlined style={{ color: '#722ed1' }} />;
default:
return <ClockCircleOutlined style={{ color: '#d9d9d9' }} />;
}
};
const getActionTag = (step: TraceStep) => {
if (!step.actionType) return null;
const config = {
Block: { color: 'red', icon: <CloseCircleOutlined /> },
Warn: { color: 'orange', icon: <ExclamationCircleOutlined /> },
Switch: { color: 'purple', icon: <SwapOutlined /> },
};
const tagConfig = config[step.actionType];
return (
<Tag color={tagConfig.color} icon={tagConfig.icon}>
{step.actionType}
{step.switchTarget && `: ${step.switchTarget}`}
</Tag>
);
};
const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
steps,
compact = false,
}) => {
const currentStep = steps.findIndex(
(s) => s.status === 'running' || s.status === 'pending'
);
return (
<div className={`ssa-execution-trace ${compact ? 'compact' : ''}`}>
<Steps
orientation={compact ? 'horizontal' : 'vertical'}
size="small"
current={currentStep === -1 ? steps.length : currentStep}
>
{steps.map((step) => (
<Steps.Step
key={step.index}
title={
<span className="ssa-trace-title">
{step.name}
{getActionTag(step)}
</span>
}
description={
step.message || (step.durationMs ? `${step.durationMs}ms` : '')
}
icon={getIcon(step)}
/>
))}
</Steps>
</div>
);
};
export default ExecutionTrace;

View File

@@ -0,0 +1,50 @@
/**
* 模式切换组件 - 智能分析 / 咨询模式
*/
import React from 'react';
import { Segmented } from 'antd';
import { ExperimentOutlined, CommentOutlined } from '@ant-design/icons';
import { useSSAStore } from '../stores/ssaStore';
import type { SSAMode } from '../types';
interface ModeSwitchProps {
disabled?: boolean;
}
const ModeSwitch: React.FC<ModeSwitchProps> = ({ disabled = false }) => {
const { mode, setMode } = useSSAStore();
const options = [
{
value: 'analysis' as SSAMode,
label: (
<span className="ssa-mode-option">
<ExperimentOutlined />
<span></span>
</span>
),
},
{
value: 'consult' as SSAMode,
label: (
<span className="ssa-mode-option">
<CommentOutlined />
<span></span>
</span>
),
},
];
return (
<div className="ssa-mode-switch">
<Segmented
options={options}
value={mode}
onChange={(value) => setMode(value as SSAMode)}
disabled={disabled}
/>
</div>
);
};
export default ModeSwitch;

View File

@@ -0,0 +1,105 @@
/**
* 分析计划卡片组件
*/
import React from 'react';
import { Card, Button, Tag, Descriptions, Space, Tooltip } from 'antd';
import {
PlayCircleOutlined,
SafetyOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import type { AnalysisPlan, GuardrailConfig } from '../types';
interface PlanCardProps {
plan: AnalysisPlan;
canExecute: boolean;
onExecute: () => void;
isExecuting: boolean;
}
const getActionColor = (action: GuardrailConfig['actionType']) => {
switch (action) {
case 'Block':
return 'red';
case 'Warn':
return 'orange';
case 'Switch':
return 'blue';
default:
return 'default';
}
};
const PlanCard: React.FC<PlanCardProps> = ({
plan,
canExecute,
onExecute,
isExecuting,
}) => {
return (
<Card
className="ssa-plan-card"
title={
<Space>
<span>{plan.toolName}</span>
<Tag color="blue">{plan.toolCode}</Tag>
</Space>
}
extra={
<Space>
<Tag color={plan.confidence > 0.8 ? 'green' : 'orange'}>
: {(plan.confidence * 100).toFixed(0)}%
</Tag>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={onExecute}
loading={isExecuting}
disabled={!canExecute}
>
</Button>
</Space>
}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="分析描述">
{plan.description}
</Descriptions.Item>
<Descriptions.Item label="参数配置">
<pre className="ssa-params-preview">
{JSON.stringify(plan.parameters, null, 2)}
</pre>
</Descriptions.Item>
</Descriptions>
{plan.guardrails && plan.guardrails.length > 0 && (
<div className="ssa-guardrails">
<div className="ssa-guardrails-title">
<SafetyOutlined />
</div>
<Space wrap>
{plan.guardrails.map((gr, idx) => (
<Tooltip
key={idx}
title={
<span>
: {gr.checkCode}
{gr.threshold && ` | 阈值: ${gr.threshold}`}
{gr.actionTarget && ` | 目标: ${gr.actionTarget}`}
</span>
}
>
<Tag color={getActionColor(gr.actionType)}>
{gr.checkName} ({gr.actionType})
</Tag>
</Tooltip>
))}
</Space>
</div>
)}
</Card>
);
};
export default PlanCard;

View File

@@ -0,0 +1,177 @@
/**
* 结果展示卡片组件
*/
import React, { useState } from 'react';
import { Card, Tabs, Button, Space, message, Alert, Typography } from 'antd';
import {
DownloadOutlined,
CopyOutlined,
BarChartOutlined,
FileTextOutlined,
CodeOutlined,
} from '@ant-design/icons';
import type { ExecutionResult } from '../types';
import APATable from './APATable';
const { Text } = Typography;
interface ResultCardProps {
result: ExecutionResult;
onDownloadCode: () => void;
}
const ResultCard: React.FC<ResultCardProps> = ({ result, onDownloadCode }) => {
const [activeTab, setActiveTab] = useState('results');
// 安全获取结果数据处理后端返回格式差异camelCase vs snake_case
const rawResult = result as any;
const results = rawResult.results || rawResult.result || {};
const plots = rawResult.plots || [];
const reproducibleCode = rawResult.reproducibleCode || rawResult.reproducible_code || '';
const executionMs = rawResult.executionMs || rawResult.execution_ms || 0;
const guardrailResults = rawResult.guardrailResults || rawResult.guardrail_results || [];
const methodName = results.method || '统计分析';
const copyCode = () => {
navigator.clipboard.writeText(reproducibleCode);
message.success('代码已复制到剪贴板');
};
const renderGuardrailAlerts = () => {
const warnings = guardrailResults.filter(
(gr: any) => gr.actionType === 'Warn' || gr.action_type === 'Warn' ||
((gr.actionType === 'Switch' || gr.action_type === 'Switch') && (gr.actionTaken || gr.action_taken))
);
if (!warnings || warnings.length === 0) return null;
return (
<div className="ssa-guardrail-alerts">
{warnings.map((gr: any, idx: number) => (
<Alert
key={idx}
type={(gr.actionType || gr.action_type) === 'Switch' ? 'info' : 'warning'}
message={
(gr.actionType || gr.action_type) === 'Switch'
? `方法已切换: ${gr.switchTarget || gr.switch_target}`
: (gr.checkName || gr.check_name)
}
description={gr.message}
showIcon
style={{ marginBottom: 8 }}
/>
))}
</div>
);
};
const items = [
{
key: 'results',
label: (
<span>
<FileTextOutlined />
</span>
),
children: (
<div className="ssa-results-tab">
{renderGuardrailAlerts()}
<APATable result={results} />
<div className="ssa-execution-info">
<Text type="secondary">: {executionMs}ms</Text>
</div>
</div>
),
},
{
key: 'plots',
label: (
<span>
<BarChartOutlined /> ({plots.length || 0})
</span>
),
children: (
<div className="ssa-plots-tab">
{plots.map((plot: any, idx: number) => {
// 兼容两种格式:
// 1. 对象格式: {title: "xxx", imageBase64: "xxx"}
// 2. 字符串格式: "data:image/png;base64,xxx"
const isString = typeof plot === 'string';
const title = isString ? `图表 ${idx + 1}` : (plot.title || `图表 ${idx + 1}`);
const imageSrc = isString
? plot
: (plot.imageBase64 || plot.image_base64
? (plot.imageBase64 || plot.image_base64).startsWith('data:')
? (plot.imageBase64 || plot.image_base64)
: `data:image/png;base64,${plot.imageBase64 || plot.image_base64}`
: '');
return (
<div key={idx} className="ssa-plot-item">
<h4>{title}</h4>
{imageSrc ? (
<img
src={imageSrc}
alt={title}
style={{ maxWidth: '100%' }}
/>
) : (
<Text type="secondary"></Text>
)}
</div>
);
})}
</div>
),
},
{
key: 'code',
label: (
<span>
<CodeOutlined />
</span>
),
children: (
<div className="ssa-code-tab">
<Space style={{ marginBottom: 16 }}>
<Button icon={<CopyOutlined />} onClick={copyCode}>
</Button>
<Button icon={<DownloadOutlined />} onClick={onDownloadCode}>
R
</Button>
</Space>
<pre className="ssa-code-block">
{reproducibleCode}
</pre>
</div>
),
},
];
const statusColor =
result.status === 'success'
? '#52c41a'
: result.status === 'warning'
? '#faad14'
: '#ff4d4f';
return (
<Card
className="ssa-result-card"
title={
<Space>
<span
className="ssa-status-dot"
style={{ backgroundColor: statusColor }}
/>
<span> - {methodName}</span>
</Space>
}
>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={items} />
</Card>
);
};
export default ResultCard;

View File

@@ -0,0 +1,79 @@
/**
* SAP 下载按钮组件
*/
import React, { useState } from 'react';
import { Button, Dropdown, message } from 'antd';
import type { MenuProps } from 'antd';
import { DownloadOutlined, FileWordOutlined, FilePdfOutlined, FileMarkdownOutlined } from '@ant-design/icons';
interface SAPDownloadButtonProps {
sessionId: string;
disabled?: boolean;
}
const SAPDownloadButton: React.FC<SAPDownloadButtonProps> = ({
sessionId,
disabled = false,
}) => {
const [loading, setLoading] = useState(false);
const downloadSAP = async (format: 'docx' | 'pdf' | 'md') => {
setLoading(true);
try {
const response = await fetch(
`/api/v1/ssa/consult/${sessionId}/download-sap?format=${format}`
);
if (!response.ok) {
throw new Error('下载失败');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `SAP_${sessionId}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('SAP 下载成功');
} catch (error) {
message.error('下载失败,请重试');
} finally {
setLoading(false);
}
};
const items: MenuProps['items'] = [
{
key: 'docx',
icon: <FileWordOutlined />,
label: 'Word 文档 (.docx)',
onClick: () => downloadSAP('docx'),
},
{
key: 'pdf',
icon: <FilePdfOutlined />,
label: 'PDF 文档 (.pdf)',
onClick: () => downloadSAP('pdf'),
},
{
key: 'md',
icon: <FileMarkdownOutlined />,
label: 'Markdown (.md)',
onClick: () => downloadSAP('md'),
},
];
return (
<Dropdown menu={{ items }} disabled={disabled || loading}>
<Button icon={<DownloadOutlined />} loading={loading}>
SAP
</Button>
</Dropdown>
);
};
export default SAPDownloadButton;

View File

@@ -0,0 +1,74 @@
/**
* SAP 统计分析计划预览组件
*/
import React from 'react';
import { Card, Timeline, Typography, Descriptions, Tag } from 'antd';
import { FileTextOutlined, ExperimentOutlined } from '@ant-design/icons';
import type { SAP } from '../types';
const { Title, Paragraph, Text } = Typography;
interface SAPPreviewProps {
sap: SAP;
}
const SAPPreview: React.FC<SAPPreviewProps> = ({ sap }) => {
return (
<Card className="ssa-sap-preview">
<div className="ssa-sap-header">
<FileTextOutlined style={{ fontSize: 24, marginRight: 12 }} />
<div>
<Title level={4} style={{ margin: 0 }}>
{sap.title}
</Title>
<Text type="secondary"> {sap.version}</Text>
</div>
</div>
<Descriptions
column={1}
size="small"
style={{ marginTop: 16, marginBottom: 16 }}
>
<Descriptions.Item label="研究目的">
<ul style={{ margin: 0, paddingLeft: 20 }}>
{sap.objectives.map((obj, idx) => (
<li key={idx}>{obj}</li>
))}
</ul>
</Descriptions.Item>
<Descriptions.Item label="数据描述">
<Paragraph style={{ margin: 0 }}>{sap.dataDescription}</Paragraph>
</Descriptions.Item>
</Descriptions>
<div className="ssa-sap-steps">
<Title level={5}>
<ExperimentOutlined />
</Title>
<Timeline>
{sap.analysisSteps.map((step) => (
<Timeline.Item key={step.order}>
<div className="ssa-sap-step">
<Text strong>
{step.order}: {step.name}
</Text>
<Paragraph type="secondary" style={{ margin: '4px 0' }}>
{step.description}
</Paragraph>
<div>
<Tag color="blue">{step.method}</Tag>
{step.variables.map((v, idx) => (
<Tag key={idx}>{v}</Tag>
))}
</div>
</div>
</Timeline.Item>
))}
</Timeline>
</div>
</Card>
);
};
export default SAPPreview;

View File

@@ -0,0 +1,9 @@
export { default as ModeSwitch } from './ModeSwitch';
export { default as PlanCard } from './PlanCard';
export { default as ExecutionTrace } from './ExecutionTrace';
export { default as ResultCard } from './ResultCard';
export { default as APATable } from './APATable';
export { default as ExecutionProgress } from './ExecutionProgress';
export { default as SAPPreview } from './SAPPreview';
export { default as SAPDownloadButton } from './SAPDownloadButton';
// ConsultChat 已删除,使用 @/shared/components/Chat/AIStreamChat 替代

View File

@@ -0,0 +1 @@
export { useAnalysis } from './useAnalysis';

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;

View File

@@ -1,34 +1,296 @@
import React from 'react'
import Placeholder from '../../shared/components/Placeholder'
/**
* 智能统计分析模块
* Java团队开发前端仅做导航集成
* SSA 智能统计分析模块主入口
*
* 遵循规范:
* - 使用 AIStreamChat通用 Chat 组件)
* - 使用 apiClient带认证的 axios
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
Card,
Upload,
Input,
Button,
Space,
Typography,
message,
Empty,
} from 'antd';
import {
UploadOutlined,
SendOutlined,
CloudUploadOutlined,
} from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { useSSAStore } from './stores/ssaStore';
import { useAnalysis } from './hooks/useAnalysis';
import {
ModeSwitch,
PlanCard,
ExecutionTrace,
ResultCard,
ExecutionProgress,
SAPDownloadButton,
} from './components';
// 使用通用 Chat 组件(遵循通用能力层清单)
import { AIStreamChat } from '@/shared/components/Chat';
import './styles/ssa.css';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const SSAModule: React.FC = () => {
const {
mode,
currentSession,
messages,
currentPlan,
executionResult,
traceSteps,
isLoading,
isExecuting,
setCurrentSession,
reset,
} = useSSAStore();
// 组件挂载时重置 store确保不同用户看到独立的状态
useEffect(() => {
reset();
}, [reset]);
const {
uploadData,
generatePlan,
executePlan,
downloadCode,
isUploading,
uploadProgress,
} = useAnalysis();
const [query, setQuery] = useState('');
const handleUpload: UploadProps['customRequest'] = async (options) => {
const { file, onSuccess, onError } = options;
try {
const result = await uploadData(file as File);
setCurrentSession({
id: result.sessionId,
title: (file as File).name,
mode: mode,
status: 'active',
dataSchema: {
columns: result.schema.columns.map((c) => ({
name: c.name,
type: c.type as 'numeric' | 'categorical' | 'datetime' | 'text',
uniqueValues: c.uniqueValues,
nullCount: c.nullCount,
})),
rowCount: result.schema.rowCount,
preview: [],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
message.success('数据上传成功');
onSuccess?.({});
} catch (error) {
message.error('上传失败');
onError?.(error as Error);
}
};
const handleGeneratePlan = async () => {
if (!query.trim()) {
message.warning('请输入分析需求');
return;
}
try {
await generatePlan(query);
setQuery('');
} catch (error) {
message.error('生成计划失败');
}
};
const handleExecute = async () => {
if (!currentPlan) return;
try {
await executePlan(currentPlan.id);
} catch (error: any) {
message.error(error.message || '执行失败');
}
};
const handleDownloadCode = async () => {
try {
const { blob, filename } = await downloadCode();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
message.success('代码下载成功');
} catch (error) {
message.error('下载失败');
}
};
// 咨询模式使用 AIStreamChat无需自定义消息处理
return (
<Placeholder
moduleName="智能统计分析"
message="由Java团队开发中前端集成规划中"
/>
)
}
export default SSAModule
<div className="ssa-page">
<div className="ssa-page-header">
<Title level={3}></Title>
<Paragraph type="secondary">
AI
</Paragraph>
<ModeSwitch disabled={isUploading || isExecuting} />
</div>
<div className="ssa-workspace">
<div className="ssa-main-panel">
{!currentSession ? (
<Card>
<div className="ssa-upload-area">
<Upload.Dragger
name="file"
accept=".csv,.xlsx,.xls"
customRequest={handleUpload}
showUploadList={false}
disabled={isUploading}
>
<p className="ssa-upload-icon">
<CloudUploadOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
CSVExcel 50MB
</p>
</Upload.Dragger>
</div>
</Card>
) : mode === 'analysis' ? (
<>
<Card style={{ marginBottom: 16 }}>
<div className="ssa-query-input">
<TextArea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="例如:比较 A 组和 B 组的身高差异是否有统计学意义"
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={isLoading}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleGeneratePlan}
loading={isLoading}
style={{ marginTop: 12 }}
>
</Button>
</div>
</Card>
{currentPlan && (
<PlanCard
plan={currentPlan}
canExecute={true}
onExecute={handleExecute}
isExecuting={isExecuting}
/>
)}
{isExecuting && traceSteps.length > 0 && (
<>
<ExecutionProgress steps={traceSteps} />
<ExecutionTrace steps={traceSteps} />
</>
)}
{executionResult && (
<ResultCard
result={executionResult}
onDownloadCode={handleDownloadCode}
/>
)}
{!currentPlan && !executionResult && (
<Card>
<Empty description="请输入分析需求AI 将为您生成统计分析计划" />
</Card>
)}
</>
) : (
<Card className="ssa-consult-card">
{/* 使用通用 AIStreamChat 组件(遵循通用能力层清单) */}
<AIStreamChat
apiEndpoint={`/api/v1/ssa/consult/${currentSession?.id}/chat/stream`}
conversationId={currentSession?.id}
agentId="SSA_CONSULT"
enableDeepThinking={true}
welcome={{
title: '统计分析咨询',
description: '请描述您的研究设计和分析需求我将为您生成统计分析计划SAP',
}}
/>
{currentSession && (
<div style={{ marginTop: 16, textAlign: 'right' }}>
<SAPDownloadButton
sessionId={currentSession.id}
disabled={messages.length < 2}
/>
</div>
)}
</Card>
)}
</div>
<div className="ssa-side-panel">
<Card title="数据信息" size="small">
{currentSession?.dataSchema ? (
<>
<Paragraph>
<Text strong>: </Text>
{currentSession.title}
</Paragraph>
<Paragraph>
<Text strong>: </Text>
{currentSession.dataSchema.rowCount}
</Paragraph>
<Paragraph>
<Text strong>: </Text>
{currentSession.dataSchema.columns.length}
</Paragraph>
<div style={{ marginTop: 12 }}>
<Text strong>:</Text>
<ul style={{ paddingLeft: 20, marginTop: 8 }}>
{currentSession.dataSchema.columns.map((col) => (
<li key={col.name}>
<Text code>{col.name}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
({col.type})
</Text>
</li>
))}
</ul>
</div>
</>
) : (
<Empty
description="暂无数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Card>
</div>
</div>
</div>
);
};
export default SSAModule;

View File

@@ -0,0 +1,86 @@
/**
* SSA 状态管理 - Zustand Store
*/
import { create } from 'zustand';
import type {
SSAMode,
SSASession,
Message,
AnalysisPlan,
ExecutionResult,
TraceStep,
DataSchema,
} from '../types';
interface SSAState {
mode: SSAMode;
currentSession: SSASession | null;
messages: Message[];
currentPlan: AnalysisPlan | null;
executionResult: ExecutionResult | null;
traceSteps: TraceStep[];
isLoading: boolean;
isExecuting: boolean;
error: string | null;
setMode: (mode: SSAMode) => void;
setCurrentSession: (session: SSASession | null) => void;
addMessage: (message: Message) => void;
setMessages: (messages: Message[]) => void;
setCurrentPlan: (plan: AnalysisPlan | null) => void;
setExecutionResult: (result: ExecutionResult | null) => void;
setTraceSteps: (steps: TraceStep[]) => void;
updateTraceStep: (index: number, step: Partial<TraceStep>) => void;
setLoading: (loading: boolean) => void;
setExecuting: (executing: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
const initialState = {
mode: 'analysis' as SSAMode,
currentSession: null,
messages: [],
currentPlan: null,
executionResult: null,
traceSteps: [],
isLoading: false,
isExecuting: false,
error: null,
};
export const useSSAStore = create<SSAState>((set) => ({
...initialState,
setMode: (mode) => set({ mode }),
setCurrentSession: (session) => set({ currentSession: session }),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
setMessages: (messages) => set({ messages }),
setCurrentPlan: (plan) => set({ currentPlan: plan }),
setExecutionResult: (result) => set({ executionResult: result }),
setTraceSteps: (steps) => set({ traceSteps: steps }),
updateTraceStep: (index, step) =>
set((state) => ({
traceSteps: state.traceSteps.map((s, i) =>
i === index ? { ...s, ...step } : s
),
})),
setLoading: (loading) => set({ isLoading: loading }),
setExecuting: (executing) => set({ isExecuting: executing }),
setError: (error) => set({ error }),
reset: () => set(initialState),
}));
export default useSSAStore;

View File

@@ -0,0 +1,291 @@
/**
* SSA 智能统计分析模块样式
*/
/* 模式切换 */
.ssa-mode-switch {
margin-bottom: 16px;
}
.ssa-mode-option {
display: flex;
align-items: center;
gap: 6px;
}
/* 计划卡片 */
.ssa-plan-card {
margin-bottom: 16px;
}
.ssa-plan-card .ant-card-head {
background: #fafafa;
}
.ssa-params-preview {
background: #f5f5f5;
padding: 12px;
border-radius: 6px;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.ssa-guardrails {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.ssa-guardrails-title {
font-weight: 500;
margin-bottom: 8px;
color: #52c41a;
}
/* 执行追踪 */
.ssa-execution-trace {
padding: 16px;
background: #fafafa;
border-radius: 8px;
margin-bottom: 16px;
}
.ssa-execution-trace.compact {
padding: 12px;
}
.ssa-trace-title {
display: flex;
align-items: center;
gap: 8px;
}
/* 执行进度 */
.ssa-execution-progress {
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 16px;
}
/* 结果卡片 */
.ssa-result-card {
margin-bottom: 16px;
}
.ssa-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.ssa-results-tab,
.ssa-plots-tab,
.ssa-code-tab {
padding: 8px 0;
}
.ssa-guardrail-alerts {
margin-bottom: 16px;
}
.ssa-execution-info {
margin-top: 16px;
text-align: right;
}
.ssa-plot-item {
margin-bottom: 24px;
text-align: center;
}
.ssa-plot-item h4 {
margin-bottom: 12px;
color: #666;
}
.ssa-code-block {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 500px;
overflow-y: auto;
}
/* APA 表格 */
.ssa-apa-table {
margin-top: 8px;
}
.ssa-apa-table .ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
/* SAP 预览 */
.ssa-sap-preview {
margin-bottom: 16px;
}
.ssa-sap-header {
display: flex;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.ssa-sap-steps {
margin-top: 16px;
}
.ssa-sap-step {
padding: 8px 0;
}
/* 咨询聊天 */
.ssa-consult-chat {
display: flex;
flex-direction: column;
height: 100%;
min-height: 400px;
}
.ssa-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 16px;
}
.ssa-chat-message {
display: flex;
margin-bottom: 16px;
gap: 12px;
}
.ssa-chat-message.user {
flex-direction: row-reverse;
}
.ssa-chat-avatar.user {
background: #1890ff;
}
.ssa-chat-avatar.assistant {
background: #52c41a;
}
.ssa-chat-bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.ssa-chat-message.user .ssa-chat-bubble {
background: #1890ff;
color: #fff;
}
.ssa-chat-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.ssa-chat-time {
font-size: 11px;
color: #999;
margin-top: 4px;
text-align: right;
}
.ssa-chat-message.user .ssa-chat-time {
color: rgba(255, 255, 255, 0.7);
}
.ssa-chat-input {
display: flex;
gap: 12px;
align-items: flex-end;
}
.ssa-chat-input .ant-input {
flex: 1;
}
/* 主页面布局 */
.ssa-page {
padding: 24px;
min-height: 100vh;
height: auto;
overflow-y: auto;
background: #f0f2f5;
}
.ssa-page-header {
margin-bottom: 24px;
}
.ssa-workspace {
display: flex;
gap: 24px;
}
.ssa-main-panel {
flex: 1;
min-width: 0;
}
.ssa-side-panel {
width: 350px;
flex-shrink: 0;
}
.ssa-upload-area {
padding: 48px 24px;
text-align: center;
background: #fff;
border: 2px dashed #d9d9d9;
border-radius: 8px;
margin-bottom: 24px;
transition: border-color 0.3s;
}
.ssa-upload-area:hover {
border-color: #1890ff;
}
.ssa-upload-icon {
font-size: 48px;
color: #1890ff;
margin-bottom: 16px;
}
.ssa-query-input {
margin-bottom: 16px;
}
/* 响应式 */
@media (max-width: 1200px) {
.ssa-workspace {
flex-direction: column;
}
.ssa-side-panel {
width: 100%;
}
}

View File

@@ -0,0 +1,136 @@
/**
* SSA 智能统计分析模块类型定义
*/
export type SSAMode = 'analysis' | 'consult';
export type SessionStatus = 'active' | 'completed' | 'archived';
export interface SSASession {
id: string;
title: string;
mode: SSAMode;
status: SessionStatus;
dataSchema?: DataSchema;
createdAt: string;
updatedAt: string;
}
export interface DataSchema {
columns: ColumnInfo[];
rowCount: number;
preview: Record<string, unknown>[];
}
export interface ColumnInfo {
name: string;
type: 'numeric' | 'categorical' | 'datetime' | 'text';
uniqueValues?: number;
nullCount?: number;
}
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
contentType: 'text' | 'plan' | 'result' | 'error';
content: TextContent | PlanContent | ResultContent | ErrorContent;
createdAt: string;
}
export interface TextContent {
text: string;
}
export interface PlanContent {
plan: AnalysisPlan;
canExecute: boolean;
}
export interface ResultContent {
execution: ExecutionResult;
}
export interface ErrorContent {
code: string;
message: string;
suggestion?: string;
}
export interface AnalysisPlan {
id: string;
toolCode: string;
toolName: string;
description: string;
parameters: Record<string, unknown>;
guardrails: GuardrailConfig[];
confidence: number;
}
export interface GuardrailConfig {
checkName: string;
checkCode: string;
threshold?: string;
actionType: 'Block' | 'Warn' | 'Switch';
actionTarget?: string;
}
export interface ExecutionResult {
status: 'success' | 'warning' | 'failed';
results: StatisticalResult;
plots: PlotData[];
reproducibleCode: string;
guardrailResults: GuardrailResult[];
executionMs: number;
}
export interface StatisticalResult {
method: string;
statistic: number;
pValue: number;
effectSize?: number;
ci?: [number, number];
details: Record<string, unknown>;
}
export interface PlotData {
type: string;
title: string;
imageBase64: string;
}
export interface GuardrailResult {
checkName: string;
passed: boolean;
actionType: 'Block' | 'Warn' | 'Switch';
actionTaken: boolean;
switchTarget?: string;
message: string;
}
export interface SAP {
id: string;
title: string;
version: string;
objectives: string[];
dataDescription: string;
analysisSteps: AnalysisStep[];
createdAt: string;
}
export interface AnalysisStep {
order: number;
name: string;
description: string;
method: string;
variables: string[];
}
export interface TraceStep {
index: number;
name: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'switched';
actionType?: 'Block' | 'Warn' | 'Switch';
switchTarget?: string;
message?: string;
durationMs?: number;
}