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

@@ -15,7 +15,7 @@ import {
import { ChatArea } from './components/ChatArea';
import { StatePanel } from './components/StatePanel';
import { DocumentPanel } from './components/DocumentPanel';
import { ResizableSplitPane } from './components/ResizableSplitPane';
import { ResizableSplitPane } from '@/shared/components/Layout';
import { ViewSwitcher } from './components/ViewSwitcher';
import { useProtocolContext } from './hooks/useProtocolContext';
import { useProtocolConversations } from './hooks/useProtocolConversations';

View File

@@ -0,0 +1,90 @@
/**
* SSA-Pro 智能统计工作台 V11
*
* 100% 还原 V11 原型图设计
* - 左侧抽屉栏(可展开/收起)
* - 中间对话区(居中布局)
* - 右侧工作区(动态显示)
*
* 键盘快捷键:
* - Esc: 关闭工作区/代码模态框
* - Ctrl+B: 切换侧边栏
* - Ctrl+N: 新建分析
*/
import React, { useEffect, useCallback } from 'react';
import { useSSAStore } from './stores/ssaStore';
import { SSASidebar } from './components/SSASidebar';
import { SSAChatPane } from './components/SSAChatPane';
import { SSAWorkspacePane } from './components/SSAWorkspacePane';
import { SSACodeModal } from './components/SSACodeModal';
import { SSAToast } from './components/SSAToast';
const SSAWorkspace: React.FC = () => {
const {
workspaceOpen,
setWorkspaceOpen,
codeModalVisible,
setCodeModalVisible,
sidebarExpanded,
setSidebarExpanded,
reset,
} = useSSAStore();
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (codeModalVisible) {
setCodeModalVisible(false);
} else if (workspaceOpen) {
setWorkspaceOpen(false);
}
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 'b' || e.key === 'B') {
e.preventDefault();
setSidebarExpanded(!sidebarExpanded);
}
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
reset();
}
}
}, [
codeModalVisible,
workspaceOpen,
sidebarExpanded,
setCodeModalVisible,
setWorkspaceOpen,
setSidebarExpanded,
reset,
]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return (
<div className="ssa-v11">
{/* Toast 容器 */}
<SSAToast />
{/* 左侧抽屉栏 */}
<SSASidebar />
{/* 主容器 */}
<div className="ssa-v11-main">
{/* 对话区 */}
<SSAChatPane />
{/* 工作区 */}
<SSAWorkspacePane />
</div>
{/* 代码模态框 */}
<SSACodeModal />
</div>
);
};
export default SSAWorkspace;

View File

@@ -1,130 +0,0 @@
/**
* 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

@@ -1,42 +0,0 @@
/**
* 执行进度条组件
*/
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

@@ -1,90 +0,0 @@
/**
* 执行追踪组件 - 显示分析步骤进度
*/
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

@@ -1,50 +0,0 @@
/**
* 模式切换组件 - 智能分析 / 咨询模式
*/
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

@@ -1,105 +0,0 @@
/**
* 分析计划卡片组件
*/
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

@@ -1,177 +0,0 @@
/**
* 结果展示卡片组件
*/
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

@@ -1,79 +0,0 @@
/**
* 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

@@ -1,74 +0,0 @@
/**
* 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,442 @@
/**
* SSAChatPane - V11 对话区
*
* 100% 还原 V11 原型图
* - 顶部 Header标题 + 返回按钮 + 状态指示)
* - 居中对话列表
* - 底部悬浮输入框
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Bot,
User,
Paperclip,
ArrowUp,
FileSpreadsheet,
X,
ArrowLeft,
FileSignature,
ArrowRight,
Zap,
Loader2,
AlertCircle,
CheckCircle
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import type { SSAMessage } from '../types';
import { TypeWriter } from './TypeWriter';
export const SSAChatPane: React.FC = () => {
const navigate = useNavigate();
const {
currentSession,
messages,
mountedFile,
setMountedFile,
setCurrentSession,
setActivePane,
setWorkspaceOpen,
currentPlan,
isLoading,
isExecuting,
error,
setError,
addToast,
selectAnalysisRecord,
} = useSSAStore();
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
const [inputValue, setInputValue] = useState('');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
const fileInputRef = useRef<HTMLInputElement>(null);
const chatEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部,确保最新内容可见
const scrollToBottom = useCallback(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, []);
useEffect(() => {
// 延迟滚动,确保 DOM 更新完成
const timer = setTimeout(scrollToBottom, 100);
return () => clearTimeout(timer);
}, [messages, currentPlan, scrollToBottom]);
const handleBack = () => {
navigate(-1);
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadStatus('uploading');
setError(null);
try {
setUploadStatus('parsing');
const result = await uploadData(file);
setCurrentSession({
id: result.sessionId,
title: file.name.replace(/\.(csv|xlsx|xls)$/i, ''),
mode: 'analysis',
status: 'active',
dataSchema: {
columns: result.schema.columns.map((c: any) => ({
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(),
});
setMountedFile({
name: file.name,
size: file.size,
rowCount: result.schema.rowCount,
});
setUploadStatus('success');
addToast('数据读取成功,正在分析结构...', 'success');
} catch (err: any) {
setUploadStatus('error');
const errorMsg = err?.message || '上传失败,请检查文件格式';
setError(errorMsg);
addToast(errorMsg, 'error');
}
};
const handleRemoveFile = () => {
setMountedFile(null);
setWorkspaceOpen(false);
setUploadStatus('idle');
setError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSend = async () => {
if (!inputValue.trim()) return;
try {
await generatePlan(inputValue);
setInputValue('');
} catch (err: any) {
addToast(err?.message || '生成计划失败', 'error');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 打开工作区,可选择特定的分析记录
const handleOpenWorkspace = useCallback((recordId?: string) => {
if (recordId) {
selectAnalysisRecord(recordId);
} else {
setWorkspaceOpen(true);
setActivePane('sap');
}
}, [selectAnalysisRecord, setWorkspaceOpen, setActivePane]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
return (
<section className="ssa-chat-pane">
{/* Chat Header */}
<header className="chat-header">
<div className="chat-header-left">
<button className="back-btn" onClick={handleBack}>
<ArrowLeft size={16} />
<span></span>
</button>
<span className="header-divider" />
<span className="header-title">
{currentSession?.title || '新的统计分析'}
</span>
</div>
<EngineStatus
isExecuting={isExecuting}
isLoading={isLoading}
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
/>
</header>
{/* Chat Messages */}
<div className="chat-messages" ref={messagesContainerRef}>
<div className="chat-messages-inner">
{/* 欢迎语 */}
<div className="message message-ai slide-up">
<div className="message-avatar ai-avatar">
<Bot size={12} />
</div>
<div className="message-bubble ai-bubble">
SSA-Pro
<br />
📎 <b></b>
</div>
</div>
{/* 动态消息 */}
{messages.map((msg: SSAMessage, idx: number) => {
const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1;
const showTypewriter = isLastAiMessage && !msg.artifactType;
return (
<div
key={msg.id || idx}
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
style={{ animationDelay: `${idx * 0.1}s` }}
>
<div className={`message-avatar ${msg.role === 'user' ? 'user-avatar' : 'ai-avatar'}`}>
{msg.role === 'user' ? <User size={12} /> : <Bot size={12} />}
</div>
<div className={`message-bubble ${msg.role === 'user' ? 'user-bubble' : 'ai-bubble'}`}>
{showTypewriter ? (
<TypeWriter content={msg.content} speed={15} />
) : (
msg.content
)}
{/* SAP 卡片 */}
{msg.artifactType === 'sap' && (
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
<div className="sap-card-left">
<div className="sap-card-icon">
<FileSignature size={16} />
</div>
<div className="sap-card-content">
<div className="sap-card-title"> (SAP)</div>
<div className="sap-card-hint"></div>
</div>
</div>
<ArrowRight size={16} className="sap-card-arrow" />
</button>
)}
</div>
</div>
);
})}
{/* AI 正在思考指示器 */}
{isLoading && (
<div className="message message-ai slide-up">
<div className="message-avatar ai-avatar">
<Bot size={12} />
</div>
<div className="message-bubble ai-bubble thinking-bubble">
<div className="thinking-dots">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
</div>
)}
{/* 数据挂载成功消息 */}
{mountedFile && currentPlan && !messages.some((m: SSAMessage) => m.artifactType === 'sap') && (
<div className="message message-ai slide-up">
<div className="message-avatar ai-avatar">
<Bot size={12} />
</div>
<div className="message-bubble ai-bubble">
<div className="data-mounted-msg">
<Zap size={14} className="text-amber-500" />
<b></b> (SAP)
</div>
<button className="sap-card" onClick={() => handleOpenWorkspace()}>
<div className="sap-card-left">
<div className="sap-card-icon">
<FileSignature size={16} />
</div>
<div className="sap-card-content">
<div className="sap-card-title"> (SAP)</div>
<div className="sap-card-hint"></div>
</div>
</div>
<ArrowRight size={16} className="sap-card-arrow" />
</button>
</div>
</div>
)}
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
<div ref={chatEndRef} className="scroll-spacer" />
</div>
</div>
{/* Chat Input */}
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="input-box">
{/* 上传进度条 */}
{uploadStatus === 'uploading' && (
<div className="upload-progress-zone pop-in">
<div className="upload-progress-card">
<Loader2 size={16} className="spin text-blue-500" />
<div className="upload-progress-info">
<span className="upload-progress-text">...</span>
<div className="upload-progress-bar">
<div
className="upload-progress-fill"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
</div>
</div>
)}
{/* 解析中状态 */}
{uploadStatus === 'parsing' && (
<div className="upload-progress-zone pop-in">
<div className="upload-progress-card parsing">
<Loader2 size={16} className="spin text-amber-500" />
<span className="upload-progress-text">...</span>
</div>
</div>
)}
{/* 上传错误 */}
{uploadStatus === 'error' && error && (
<div className="upload-error-zone pop-in">
<div className="upload-error-card">
<AlertCircle size={16} className="text-red-500" />
<span className="upload-error-text">{error}</span>
<button
className="upload-retry-btn"
onClick={() => {
setUploadStatus('idle');
setError(null);
}}
>
</button>
</div>
</div>
)}
{/* 数据挂载区 */}
{mountedFile && uploadStatus !== 'error' && (
<div className="data-mount-zone pop-in">
<div className="mount-file-card">
<div className="mount-file-icon">
<FileSpreadsheet size={14} />
</div>
<div className="mount-file-info">
<span className="mount-file-name">{mountedFile.name}</span>
<span className="mount-file-meta">
{mountedFile.rowCount} rows {formatFileSize(mountedFile.size)}
</span>
</div>
<CheckCircle size={14} className="text-green-500" />
<div className="mount-divider" />
<button className="mount-remove-btn" onClick={handleRemoveFile}>
<X size={12} />
</button>
</div>
</div>
)}
{/* 输入行 */}
<div className="input-row">
<input
type="file"
ref={fileInputRef}
accept=".csv,.xlsx,.xls"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
className={`upload-btn ${mountedFile ? 'disabled' : ''}`}
onClick={() => fileInputRef.current?.click()}
disabled={!!mountedFile || isUploading}
>
<Paperclip size={18} />
</button>
<textarea
className="message-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="发送消息,或点击回形针 📎 上传数据触发分析..."
rows={1}
disabled={isLoading}
/>
<button
className="send-btn"
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
>
<ArrowUp size={14} />
</button>
</div>
</div>
<div className="input-hint">
AI
</div>
</div>
</div>
</section>
);
};
interface EngineStatusProps {
isExecuting: boolean;
isLoading: boolean;
isUploading: boolean;
}
const EngineStatus: React.FC<EngineStatusProps> = ({
isExecuting,
isLoading,
isUploading
}) => {
const getStatus = () => {
if (isExecuting) {
return { text: 'R Engine Running...', className: 'status-running' };
}
if (isLoading) {
return { text: 'AI Processing...', className: 'status-processing' };
}
if (isUploading) {
return { text: 'Parsing Data...', className: 'status-uploading' };
}
return { text: 'R Engine Ready', className: 'status-ready' };
};
const { text, className } = getStatus();
return (
<div className={`engine-status ${className}`}>
<span className="status-dot" />
<span>{text}</span>
</div>
);
};
export default SSAChatPane;

View File

@@ -0,0 +1,108 @@
/**
* SSACodeModal - V11 R代码模态框
*
* 100% 还原 V11 原型图
* 调用后端 API 获取真实执行代码
*/
import React, { useEffect, useState } from 'react';
import { X, Download, Loader2 } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
export const SSACodeModal: React.FC = () => {
const { codeModalVisible, setCodeModalVisible, executionResult, addToast } = useSSAStore();
const { downloadCode } = useAnalysis();
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (codeModalVisible) {
loadCode();
}
}, [codeModalVisible]);
const loadCode = async () => {
setIsLoading(true);
try {
const result = await downloadCode();
const text = await result.blob.text();
setCode(text);
} catch (error) {
if (executionResult?.reproducibleCode) {
setCode(executionResult.reproducibleCode);
} else {
setCode('# 暂无可用代码\n# 请先执行分析');
}
} finally {
setIsLoading(false);
}
};
if (!codeModalVisible) return null;
const handleClose = () => {
setCodeModalVisible(false);
};
const handleDownload = async () => {
try {
const result = await downloadCode();
const url = URL.createObjectURL(result.blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
handleClose();
} catch (error) {
addToast('下载失败', 'error');
}
};
const handleCopy = () => {
navigator.clipboard.writeText(code);
addToast('代码已复制', 'success');
};
return (
<div className="code-modal-overlay" onClick={handleClose}>
<div className="code-modal pop-in" onClick={(e) => e.stopPropagation()}>
<header className="code-modal-header">
<h3 className="code-modal-title">
<span className="r-icon">R</span>
R
</h3>
<button className="code-modal-close" onClick={handleClose}>
<X size={16} />
</button>
</header>
<div className="code-modal-body">
{isLoading ? (
<div className="code-loading">
<Loader2 size={24} className="spin" />
<span>...</span>
</div>
) : (
<pre className="code-block">
<code>{code}</code>
</pre>
)}
</div>
<footer className="code-modal-footer">
<button className="copy-btn" onClick={handleCopy} disabled={isLoading}>
</button>
<button className="download-btn" onClick={handleDownload} disabled={isLoading}>
<Download size={14} />
.R
</button>
</footer>
</div>
</div>
);
};
export default SSACodeModal;

View File

@@ -0,0 +1,137 @@
/**
* SSASidebar - V11 左侧抽屉栏
*
* 100% 还原 V11 原型图
* - 汉堡菜单切换展开/收起
* - Logo + 新建按钮
* - 历史记录列表(展开时显示)
*/
import React, { useState, useEffect } from 'react';
import {
Menu,
BarChart3,
Plus,
MessageSquare
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import apiClient from '@/common/api/axios';
import type { SSASession } from '../types';
interface HistoryItem {
id: string;
title: string;
status: 'active' | 'completed' | 'archived';
}
export const SSASidebar: React.FC = () => {
const {
sidebarExpanded,
setSidebarExpanded,
currentSession,
hydrateFromHistory,
reset
} = useSSAStore();
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (sidebarExpanded) {
fetchHistory();
}
}, [sidebarExpanded]);
const fetchHistory = async () => {
setIsLoading(true);
try {
const response = await apiClient.get('/api/v1/ssa/sessions');
setHistoryItems(response.data.sessions || []);
} catch (error) {
console.error('Failed to fetch history:', error);
setHistoryItems([]);
} finally {
setIsLoading(false);
}
};
const handleToggleSidebar = () => {
setSidebarExpanded(!sidebarExpanded);
};
const handleNewAnalysis = () => {
reset();
};
const handleSelectSession = async (sessionId: string) => {
if (sessionId === currentSession?.id) return;
try {
const response = await apiClient.get(`/api/v1/ssa/sessions/${sessionId}`);
const session: SSASession = response.data;
hydrateFromHistory(session);
} catch (error) {
console.error('Failed to load session:', error);
}
};
return (
<aside className={`ssa-sidebar ${sidebarExpanded ? 'expanded' : ''}`}>
{/* 顶部:汉堡菜单与 Logo */}
<div className="sidebar-header">
<button
className="sidebar-toggle-btn"
onClick={handleToggleSidebar}
>
<Menu size={18} />
</button>
{sidebarExpanded && (
<div className="sidebar-logo fade-in">
<div className="logo-icon">
<BarChart3 size={14} />
</div>
<span className="logo-text">SSA-Pro</span>
</div>
)}
</div>
{/* 核心操作区 */}
<div className="sidebar-actions">
{/* 新建按钮 */}
<button
className={`sidebar-new-btn ${sidebarExpanded ? 'expanded' : ''}`}
onClick={handleNewAnalysis}
>
<Plus size={16} className="new-btn-icon" />
{sidebarExpanded && <span className="new-btn-text"></span>}
</button>
</div>
{/* 历史记录列表 (展开时显示) */}
{sidebarExpanded && (
<div className="sidebar-history fade-in">
<div className="history-label"></div>
<div className="history-list">
{isLoading ? (
<div className="history-loading">...</div>
) : historyItems.length === 0 ? (
<div className="history-empty"></div>
) : (
historyItems.map((item) => (
<button
key={item.id}
className={`history-item ${item.id === currentSession?.id ? 'active' : ''}`}
onClick={() => handleSelectSession(item.id)}
>
<MessageSquare size={14} className="history-item-icon" />
<span className="history-item-title">{item.title}</span>
</button>
))
)}
</div>
</div>
)}
</aside>
);
};
export default SSASidebar;

View File

@@ -0,0 +1,47 @@
/**
* SSAToast - V11 Toast 通知组件
*
* 100% 还原 V11 原型图
*/
import React, { useEffect } from 'react';
import { CheckCircle, Info, AlertCircle } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
export const SSAToast: React.FC = () => {
const { toasts, removeToast } = useSSAStore();
useEffect(() => {
if (toasts.length > 0) {
const timer = setTimeout(() => {
removeToast(toasts[0].id);
}, 2500);
return () => clearTimeout(timer);
}
}, [toasts, removeToast]);
if (toasts.length === 0) return null;
const getIcon = (type: string) => {
switch (type) {
case 'success':
return <CheckCircle size={16} className="text-green-500" />;
case 'error':
return <AlertCircle size={16} className="text-red-500" />;
default:
return <Info size={16} className="text-blue-500" />;
}
};
return (
<div className="toast-container">
{toasts.map((toast) => (
<div key={toast.id} className="toast slide-up">
{getIcon(toast.type)}
<span>{toast.message}</span>
</div>
))}
</div>
);
};
export default SSAToast;

View File

@@ -0,0 +1,655 @@
/**
* SSAWorkspacePane - V11 右侧工作区
*
* 单页滚动布局SAP → 执行日志 → 分析结果
* 支持步骤条导航和渐进式内容展示
*/
import React, { useState, useEffect, useRef } from 'react';
import {
X,
Play,
FileDown,
Code,
CheckCircle,
XCircle,
Loader2,
Shield,
Star,
Lightbulb,
CornerDownRight,
AlertTriangle,
RefreshCw,
FileQuestion,
BarChart3,
ImageOff,
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import type { TraceStep } from '../types';
type ExecutionPhase = 'planning' | 'executing' | 'completed' | 'error';
export const SSAWorkspacePane: React.FC = () => {
const {
workspaceOpen,
setWorkspaceOpen,
currentPlan,
traceSteps,
executionResult: analysisResult,
setCodeModalVisible,
addToast,
currentRecordId,
} = useSSAStore();
const { executeAnalysis, exportReport, isExecuting } = useAnalysis();
const [elapsedTime, setElapsedTime] = useState(0);
const [executionError, setExecutionError] = useState<string | null>(null);
const [phase, setPhase] = useState<ExecutionPhase>('planning');
const executionRef = useRef<HTMLDivElement>(null);
const resultRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 当切换记录或执行结果变化时,同步 phase 状态
useEffect(() => {
// 如果正在执行中,不要覆盖 phase
if (isExecuting) return;
// 根据当前记录的执行结果来判断 phase
if (analysisResult) {
setPhase('completed');
} else {
// 没有执行结果,重置为 planning
setPhase('planning');
}
setExecutionError(null);
}, [currentRecordId, analysisResult, isExecuting]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isExecuting) {
timer = setInterval(() => {
setElapsedTime((t) => t + 1);
}, 1000);
} else {
setElapsedTime(0);
}
return () => clearInterval(timer);
}, [isExecuting]);
// 根据执行状态自动滚动到对应区块
useEffect(() => {
if (phase === 'executing' && executionRef.current) {
executionRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else if (phase === 'completed' && resultRef.current) {
setTimeout(() => {
resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 300);
}
}, [phase]);
const handleClose = () => {
setWorkspaceOpen(false);
};
const handleRun = async () => {
if (!currentPlan) return;
setPhase('executing');
setExecutionError(null);
try {
await executeAnalysis();
setPhase('completed');
} catch (err: any) {
const errorMsg = err?.message || '执行失败,请重试';
setExecutionError(errorMsg);
setPhase('error');
addToast(errorMsg, 'error');
}
};
const handleRetry = () => {
setExecutionError(null);
setPhase('planning');
};
const handleExportReport = () => {
exportReport();
addToast('报告导出成功', 'success');
};
const handleExportCode = () => {
setCodeModalVisible(true);
};
const scrollToSection = (section: 'sap' | 'execution' | 'result') => {
const refs: Record<string, React.RefObject<HTMLDivElement | null>> = {
sap: containerRef,
execution: executionRef,
result: resultRef,
};
refs[section]?.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
if (!workspaceOpen) return null;
return (
<section className={`ssa-workspace-pane ${workspaceOpen ? 'open' : ''}`}>
<div className="workspace-inner">
{/* 顶部工具栏 */}
<header className="workspace-header">
<div className="workspace-header-left">
{/* 步骤条 */}
<div className="workspace-steps">
<button
className={`step-item ${phase === 'planning' ? 'active' : ''} ${phase !== 'planning' ? 'completed' : ''}`}
onClick={() => scrollToSection('sap')}
>
<span className="step-number">1</span>
<span className="step-label"></span>
</button>
<span className="step-connector" />
<button
className={`step-item ${phase === 'executing' ? 'active' : ''} ${phase === 'completed' || analysisResult ? 'completed' : ''}`}
onClick={() => phase !== 'planning' && scrollToSection('execution')}
disabled={phase === 'planning'}
>
<span className="step-number">2</span>
<span className="step-label"></span>
</button>
<span className="step-connector" />
<button
className={`step-item ${phase === 'completed' ? 'active' : ''}`}
onClick={() => analysisResult && scrollToSection('result')}
disabled={!analysisResult}
>
<span className="step-number">3</span>
<span className="step-label"></span>
</button>
</div>
</div>
<div className="workspace-header-right">
{/* 导出报告按钮 - 有结果时显示 */}
{analysisResult && (
<button
className="workspace-text-btn"
onClick={handleExportReport}
>
<FileDown size={14} />
<span></span>
</button>
)}
{/* 查看代码按钮 - 有结果时显示 */}
{analysisResult && (
<button
className="workspace-text-btn"
onClick={handleExportCode}
>
<Code size={14} />
<span></span>
</button>
)}
{/* 关闭按钮 */}
<button
className="workspace-close-btn"
onClick={handleClose}
title="关闭"
>
<X size={18} />
</button>
</div>
</header>
{/* 工作区画布 - 单页滚动 */}
<div className="workspace-canvas" ref={containerRef}>
<div className="workspace-scroll-container">
{/* 空状态 */}
{!currentPlan && (
<div className="view-empty fade-in">
<div className="empty-icon">
<FileQuestion size={48} />
</div>
<h3 className="empty-title"></h3>
<p className="empty-desc">
<br />
AI
</p>
</div>
)}
{/* ========== 区块 1: SAP 分析计划 ========== */}
{currentPlan && (
<div className="section-block sap-section-block">
<div className="section-divider">
<span className="divider-line" />
<span className="divider-label"></span>
<span className="divider-line" />
</div>
<div className="view-sap fade-in">
<h1 className="sap-title">
{currentPlan.title || currentPlan.description?.split('')[0] || '统计分析'}
</h1>
<div className="sap-sections">
{/* 推荐统计方法 */}
<section className="sap-section">
<h3 className="section-label">1. </h3>
<div className="method-card">
<div className="method-header">
<Star size={14} className="text-amber-500" />
{currentPlan.recommendedMethod || currentPlan.toolName || '独立样本 T 检验 (Independent T-Test)'}
</div>
<div className="method-body">
<div className="var-box">
<div className="var-label"> (X)</div>
<code className="var-code">{currentPlan.parameters?.groupVar || currentPlan.parameters?.group_var || '-'}</code>
<span className="var-type">()</span>
</div>
<div className="var-box">
<div className="var-label"> (Y)</div>
<code className="var-code">{currentPlan.parameters?.valueVar || currentPlan.parameters?.value_var || '-'}</code>
<span className="var-type">()</span>
</div>
</div>
</div>
</section>
{/* 统计护栏 */}
<section className="sap-section">
<h3 className="section-label">2. </h3>
<ul className="guardrails-list">
{(currentPlan.guardrails || []).length > 0 ? (
currentPlan.guardrails.map((guardrail, idx) => (
<li key={idx} className="guardrail-item">
<Shield size={14} className="text-blue-500" />
<div className="guardrail-content">
<b>{guardrail.checkName}</b>
<span className="guardrail-desc">
{guardrail.actionType === 'Switch'
? `若检验未通过,将自动切换为 ${guardrail.actionTarget || '备选方法'}`
: guardrail.actionType === 'Warn'
? '若检验未通过,将显示警告信息'
: '若检验未通过,将阻止执行'}
</span>
</div>
</li>
))
) : (
<li className="guardrail-item">
<Shield size={14} className="text-blue-500" />
<div className="guardrail-content">
<b> (Shapiro-Wilk)</b>
<span className="guardrail-desc">
P &lt; 0.05
</span>
</div>
</li>
)}
</ul>
</section>
</div>
{/* 执行按钮 */}
<div className="sap-actions">
<button
className="run-btn"
onClick={handleRun}
disabled={isExecuting || phase !== 'planning'}
>
{isExecuting ? (
<>
<Loader2 size={14} className="spin" />
...
</>
) : phase !== 'planning' ? (
<>
<CheckCircle size={14} />
</>
) : (
<>
<Play size={14} />
</>
)}
</button>
</div>
</div>
</div>
)}
{/* ========== 区块 2: 执行日志 ========== */}
{(phase === 'executing' || phase === 'completed' || phase === 'error' || traceSteps.length > 0) && (
<div className="section-block execution-section" ref={executionRef}>
<div className="section-divider">
<span className="divider-line" />
<span className="divider-label"></span>
<span className="divider-line" />
</div>
{/* 执行中状态 */}
{phase === 'executing' && !executionError && (
<div className="view-execution fade-in">
<div className="execution-header">
<Loader2 size={24} className="text-blue-600 spin" />
<h3> R ...</h3>
<span className="execution-timer">{elapsedTime}s</span>
</div>
<div className="terminal-box">
<div className="terminal-timeline" />
<div className="terminal-logs">
{traceSteps.length === 0 ? (
<div className="terminal-waiting">
<Loader2 size={14} className="spin text-slate-400" />
<span> R ...</span>
</div>
) : (
traceSteps.map((step, idx) => (
<TraceLogItem key={idx} step={step} index={idx} />
))
)}
</div>
</div>
</div>
)}
{/* 执行完成后的日志(折叠显示) */}
{phase === 'completed' && traceSteps.length > 0 && (
<div className="view-execution-completed fade-in">
<div className="execution-completed-header">
<CheckCircle size={16} className="text-green-500" />
<span></span>
<span className="execution-duration"> {analysisResult?.executionMs || 0}ms</span>
</div>
<div className="terminal-box collapsed">
<div className="terminal-logs">
{traceSteps.map((step, idx) => (
<TraceLogItem key={idx} step={step} index={idx} />
))}
</div>
</div>
</div>
)}
{/* 执行错误 */}
{phase === 'error' && executionError && (
<div className="view-error fade-in">
<div className="error-icon">
<AlertTriangle size={48} />
</div>
<h3 className="error-title"></h3>
<p className="error-message">{executionError}</p>
<button className="retry-btn" onClick={handleRetry}>
<RefreshCw size={14} />
</button>
</div>
)}
</div>
)}
{/* ========== 区块 3: 分析结果 ========== */}
{analysisResult && (
<div className="section-block result-section" ref={resultRef}>
<div className="section-divider">
<span className="divider-line" />
<span className="divider-label"></span>
<span className="divider-line" />
</div>
<div className="view-result fade-in">
{/* AI 解读 */}
<div className="result-summary">
<Lightbulb size={20} className="text-blue-500" />
<div className="result-summary-content">
<h4>AI </h4>
<p>
{analysisResult.interpretation || generateDefaultInterpretation(analysisResult)}
</p>
</div>
</div>
{/* 护栏检查结果 */}
{analysisResult.guardrailResults?.length > 0 && (
<div className="guardrail-results">
{analysisResult.guardrailResults.map((gr, idx) => (
<div
key={idx}
className={`guardrail-result-item ${gr.passed ? 'passed' : 'failed'} ${gr.actionTaken ? 'action-taken' : ''}`}
>
{gr.passed ? (
<CheckCircle size={14} className="text-green-500" />
) : gr.actionType === 'Switch' ? (
<CornerDownRight size={14} className="text-amber-500" />
) : (
<XCircle size={14} className="text-red-500" />
)}
<span className="guardrail-result-text">{gr.message}</span>
{gr.actionTaken && gr.switchTarget && (
<span className="guardrail-switch-badge">
{gr.switchTarget}
</span>
)}
</div>
))}
</div>
)}
{/* 动态表格 */}
{analysisResult.result_table ? (
<div className="result-table-section">
<h4 className="table-label">Table 1. </h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr>
{analysisResult.result_table.headers.map((h, i) => (
<th key={i}>{h}</th>
))}
</tr>
</thead>
<tbody>
{analysisResult.result_table.rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td
key={j}
className={isPValue(cell) ? 'p-value' : ''}
>
{formatCell(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="result-stats-grid">
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{analysisResult.results?.method || '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{analysisResult.results?.statistic?.toFixed(4) || '-'}</div>
</div>
<div className="stat-card highlight">
<div className="stat-label">P </div>
<div className={`stat-value ${(analysisResult.results?.pValue ?? (analysisResult.results as any)?.p_value) < 0.05 ? 'significant' : ''}`}>
{(analysisResult.results as any)?.p_value_fmt || formatPValue(analysisResult.results?.pValue ?? (analysisResult.results as any)?.p_value)}
</div>
</div>
{analysisResult.results?.effectSize && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{analysisResult.results.effectSize.toFixed(3)}</div>
</div>
)}
</div>
</div>
)}
{/* 动态图表 */}
{analysisResult.plots?.length > 0 ? (
<div className="result-chart-section">
<h4 className="chart-label">Figure 1. </h4>
<ChartImage plot={
typeof analysisResult.plots[0] === 'string'
? { type: 'boxplot', title: '分布可视化', imageBase64: analysisResult.plots[0] }
: analysisResult.plots[0]
} />
</div>
) : (
<div className="result-chart-section">
<h4 className="chart-label">Figure 1. </h4>
<div className="chart-placeholder">
<div className="chart-placeholder-text">
<BarChart3 size={32} className="text-slate-300" />
<span></span>
</div>
</div>
</div>
)}
{/* 执行时间 */}
<div className="execution-meta">
<span>: {analysisResult.executionMs}ms</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</section>
);
};
interface PlotData {
type: string;
title: string;
imageBase64: string;
}
const ChartImage: React.FC<{ plot: PlotData }> = ({ plot }) => {
const [hasError, setHasError] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const imageSrc = React.useMemo(() => {
if (!plot.imageBase64) return '';
if (plot.imageBase64.startsWith('data:')) {
return plot.imageBase64;
}
return `data:image/png;base64,${plot.imageBase64}`;
}, [plot.imageBase64]);
if (hasError || !plot.imageBase64) {
return (
<div className="chart-error">
<ImageOff size={32} className="text-slate-300" />
<span></span>
</div>
);
}
return (
<div className="chart-image-wrapper">
{isLoading && (
<div className="chart-loading">
<Loader2 size={24} className="spin text-slate-400" />
</div>
)}
<img
src={imageSrc}
alt={plot.title}
className={`chart-image ${isLoading ? 'loading' : ''}`}
onLoad={() => setIsLoading(false)}
onError={() => {
setHasError(true);
setIsLoading(false);
}}
/>
</div>
);
};
const generateDefaultInterpretation = (result: any): string => {
const pValue = result.results?.pValue;
const method = result.results?.method || '统计检验';
if (pValue === undefined) {
return '分析已完成,请查看详细结果。';
}
const significant = pValue < 0.05;
const pText = pValue < 0.001 ? '< 0.001' : pValue.toFixed(4);
if (significant) {
return `${method}结果显示,组间差异具有统计学意义 (P = ${pText})。建议结合临床意义进行解读。`;
}
return `${method}结果显示,未发现具有统计学意义的差异 (P = ${pText})。`;
};
const formatPValue = (p: number | undefined): string => {
if (p === undefined) return '-';
if (p < 0.001) return '< 0.001 ***';
if (p < 0.01) return `${p.toFixed(4)} **`;
if (p < 0.05) return `${p.toFixed(4)} *`;
return p.toFixed(4);
};
const isPValue = (cell: string | number): boolean => {
if (typeof cell === 'string') {
return cell.toLowerCase().includes('p') || cell.includes('<') || cell.includes('*');
}
return false;
};
const formatCell = (cell: string | number): string => {
if (typeof cell === 'number') {
return Number.isInteger(cell) ? cell.toString() : cell.toFixed(4);
}
return cell;
};
const TraceLogItem: React.FC<{ step: TraceStep; index: number }> = ({ step, index }) => {
const getIcon = () => {
switch (step.status) {
case 'success':
return <CheckCircle size={14} className="text-green-400" />;
case 'failed':
return <XCircle size={14} className="text-red-400" />;
case 'running':
return <Loader2 size={14} className="text-blue-400 spin" />;
case 'downgrade':
case 'switched':
return <CornerDownRight size={14} className="text-amber-400" />;
case 'pending':
return <div className="trace-pending-dot" />;
default:
return <CheckCircle size={14} className="text-slate-400" />;
}
};
const getLabel = () => {
if (step.name) return `[${step.name}]`;
return `[Step ${index + 1}]`;
};
return (
<div
className={`trace-log-item ${step.status} slide-up`}
style={{ animationDelay: `${index * 0.15}s` }}
>
{getIcon()}
<span className="trace-log-label">{getLabel()}</span>
<span className="trace-log-text">{step.message || step.name}</span>
</div>
);
};
export default SSAWorkspacePane;

View File

@@ -0,0 +1,81 @@
/**
* TypeWriter - 打字机效果组件
*
* 用于流式显示 AI 回复内容
*/
import React, { useState, useEffect, useRef } from 'react';
interface TypeWriterProps {
content: string;
speed?: number;
onComplete?: () => void;
isStreaming?: boolean;
}
export const TypeWriter: React.FC<TypeWriterProps> = ({
content,
speed = 20,
onComplete,
isStreaming = false,
}) => {
const [displayedContent, setDisplayedContent] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const previousContentRef = useRef('');
useEffect(() => {
if (isStreaming) {
setDisplayedContent(content);
return;
}
if (content === previousContentRef.current) {
return;
}
if (content.startsWith(previousContentRef.current)) {
const newPart = content.slice(previousContentRef.current.length);
let i = 0;
const timer = setInterval(() => {
if (i < newPart.length) {
setDisplayedContent((prev) => prev + newPart[i]);
i++;
} else {
clearInterval(timer);
previousContentRef.current = content;
onComplete?.();
}
}, speed);
return () => clearInterval(timer);
} else {
setDisplayedContent('');
setCurrentIndex(0);
previousContentRef.current = '';
}
}, [content, speed, onComplete, isStreaming]);
useEffect(() => {
if (isStreaming || content === previousContentRef.current) return;
if (currentIndex < content.length) {
const timer = setTimeout(() => {
setDisplayedContent((prev) => prev + content[currentIndex]);
setCurrentIndex((prev) => prev + 1);
}, speed);
return () => clearTimeout(timer);
} else if (currentIndex === content.length && currentIndex > 0) {
previousContentRef.current = content;
onComplete?.();
}
}, [currentIndex, content, speed, onComplete, isStreaming]);
return (
<span className="typewriter-text">
{displayedContent}
{!isStreaming && currentIndex < content.length && (
<span className="typewriter-cursor">|</span>
)}
</span>
);
};
export default TypeWriter;

View File

@@ -1,9 +1,13 @@
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 替代
/**
* SSA 组件导出
*
* V11 版本 - 全屏 Gemini 风格
*/
// V11 核心组件
export { default as SSASidebar } from './SSASidebar';
export { default as SSAChatPane } from './SSAChatPane';
export { default as SSAWorkspacePane } from './SSAWorkspacePane';
export { default as SSACodeModal } from './SSACodeModal';
export { default as SSAToast } from './SSAToast';
export { default as TypeWriter } from './TypeWriter';

View File

@@ -1 +1,2 @@
export { useAnalysis } from './useAnalysis';
export { useArtifactParser, parseArtifactMarkers } from './useArtifactParser';

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,
};
}

View File

@@ -0,0 +1,90 @@
/**
* useArtifactParser - 流式 Artifact 标记解析 Hook
*
* 功能:解析 AI 流式响应中的 Artifact 标记,触发右侧窗格视图切换
*
* 支持的标记格式:
* - [ARTIFACT:sap] - 切换到 SAP 视图
* - [ARTIFACT:execution] - 切换到执行视图
* - [ARTIFACT:result] - 切换到结果视图
*/
import { useCallback, useRef } from 'react';
import { useSSAStore } from '../stores/ssaStore';
type ArtifactType = 'sap' | 'execution' | 'result';
interface ArtifactMarker {
type: ArtifactType;
position: number;
}
const ARTIFACT_PATTERN = /\[ARTIFACT:(sap|execution|result)\]/g;
export interface ArtifactParserResult {
cleanContent: string;
artifacts: ArtifactMarker[];
}
export const parseArtifactMarkers = (content: string): ArtifactParserResult => {
const artifacts: ArtifactMarker[] = [];
let match;
while ((match = ARTIFACT_PATTERN.exec(content)) !== null) {
artifacts.push({
type: match[1] as ArtifactType,
position: match.index,
});
}
const cleanContent = content.replace(ARTIFACT_PATTERN, '');
return { cleanContent, artifacts };
};
export const useArtifactParser = () => {
const setActivePane = useSSAStore((state) => state.setActivePane);
const setWorkspaceOpen = useSSAStore((state) => state.setWorkspaceOpen);
const lastProcessedMarker = useRef<string | null>(null);
const processStreamContent = useCallback((content: string) => {
const { cleanContent, artifacts } = parseArtifactMarkers(content);
if (artifacts.length > 0) {
const latestArtifact = artifacts[artifacts.length - 1];
const markerKey = `${latestArtifact.type}-${latestArtifact.position}`;
if (lastProcessedMarker.current !== markerKey) {
lastProcessedMarker.current = markerKey;
setActivePane(latestArtifact.type);
setWorkspaceOpen(true);
}
}
return cleanContent;
}, [setActivePane, setWorkspaceOpen]);
const processMessage = useCallback((content: string): string => {
const { cleanContent, artifacts } = parseArtifactMarkers(content);
if (artifacts.length > 0) {
const latestArtifact = artifacts[artifacts.length - 1];
setActivePane(latestArtifact.type);
setWorkspaceOpen(true);
}
return cleanContent;
}, [setActivePane, setWorkspaceOpen]);
const resetParser = useCallback(() => {
lastProcessedMarker.current = null;
}, []);
return {
processStreamContent,
processMessage,
resetParser,
parseArtifactMarkers,
};
};
export default useArtifactParser;

View File

@@ -1,296 +1,25 @@
/**
* SSA 智能统计分析模块主入口
*
* 遵循规范:
* - 使用 AIStreamChat通用 Chat 组件
* - 使用 apiClient带认证的 axios
* V11 版本 - 100% 还原原型图设计
* - 左侧抽屉栏(可展开/收起
* - 中间对话区(居中布局 max-w-3xl
* - 右侧工作区(动态 60% 宽度)
*/
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 React, { useEffect } from 'react';
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;
import SSAWorkspace from './SSAWorkspace';
import './styles/ssa-workspace.css';
const SSAModule: React.FC = () => {
const {
mode,
currentSession,
messages,
currentPlan,
executionResult,
traceSteps,
isLoading,
isExecuting,
setCurrentSession,
reset,
} = useSSAStore();
const { 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 (
<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>
);
return <SSAWorkspace />;
};
export default SSAModule;

View File

@@ -1,21 +1,47 @@
/**
* SSA 状态管理 - Zustand Store
*
* V11 版本 - 完全还原原型图设计
* 支持多任务模式:同一会话中可进行多次分析
*/
import { create } from 'zustand';
import type {
SSAMode,
SSASession,
Message,
SSAMessage,
AnalysisPlan,
ExecutionResult,
TraceStep,
DataSchema,
} from '../types';
type ArtifactPane = 'empty' | 'sap' | 'execution' | 'result';
interface MountedFile {
name: string;
size: number;
rowCount: number;
}
interface Toast {
id: string;
message: string;
type: 'info' | 'success' | 'error';
}
/** 分析记录 - 支持多任务 */
export interface AnalysisRecord {
id: string;
query: string;
createdAt: string;
plan: AnalysisPlan;
executionResult: ExecutionResult | null;
traceSteps: TraceStep[];
}
interface SSAState {
mode: SSAMode;
currentSession: SSASession | null;
messages: Message[];
messages: SSAMessage[];
currentPlan: AnalysisPlan | null;
executionResult: ExecutionResult | null;
traceSteps: TraceStep[];
@@ -23,10 +49,22 @@ interface SSAState {
isExecuting: boolean;
error: string | null;
activePane: ArtifactPane;
mountedFile: MountedFile | null;
codeModalVisible: boolean;
sidebarExpanded: boolean;
workspaceOpen: boolean;
toasts: Toast[];
// 多任务支持
analysisHistory: AnalysisRecord[];
currentRecordId: string | null;
setMode: (mode: SSAMode) => void;
setCurrentSession: (session: SSASession | null) => void;
addMessage: (message: Message) => void;
setMessages: (messages: Message[]) => void;
addMessage: (message: SSAMessage) => void;
setMessages: (messages: SSAMessage[]) => void;
setCurrentPlan: (plan: AnalysisPlan | null) => void;
setExecutionResult: (result: ExecutionResult | null) => void;
setTraceSteps: (steps: TraceStep[]) => void;
@@ -35,6 +73,22 @@ interface SSAState {
setExecuting: (executing: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
setActivePane: (pane: ArtifactPane) => void;
setMountedFile: (file: MountedFile | null) => void;
setCodeModalVisible: (visible: boolean) => void;
setSidebarExpanded: (expanded: boolean) => void;
setWorkspaceOpen: (open: boolean) => void;
addToast: (message: string, type?: 'info' | 'success' | 'error') => void;
removeToast: (id: string) => void;
hydrateFromHistory: (session: SSASession) => void;
// 多任务操作
addAnalysisRecord: (query: string, plan: AnalysisPlan) => string;
updateAnalysisRecord: (id: string, update: Partial<Omit<AnalysisRecord, 'id'>>) => void;
selectAnalysisRecord: (id: string) => void;
getCurrentRecord: () => AnalysisRecord | null;
}
const initialState = {
@@ -47,6 +101,14 @@ const initialState = {
isLoading: false,
isExecuting: false,
error: null,
activePane: 'empty' as ArtifactPane,
mountedFile: null,
codeModalVisible: false,
sidebarExpanded: false,
workspaceOpen: false,
toasts: [] as Toast[],
analysisHistory: [] as AnalysisRecord[],
currentRecordId: null as string | null,
};
export const useSSAStore = create<SSAState>((set) => ({
@@ -81,6 +143,129 @@ export const useSSAStore = create<SSAState>((set) => ({
setError: (error) => set({ error }),
reset: () => set(initialState),
setActivePane: (pane) => set({ activePane: pane }),
setMountedFile: (file) => set({ mountedFile: file }),
setCodeModalVisible: (visible) => set({ codeModalVisible: visible }),
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
setWorkspaceOpen: (open) => set({ workspaceOpen: open }),
addToast: (message, type = 'info') =>
set((state) => ({
toasts: [
...state.toasts,
{ id: Date.now().toString(), message, type },
],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
hydrateFromHistory: (session) => {
if (session.executionResult) {
set({
activePane: 'result',
executionResult: session.executionResult,
currentSession: session,
workspaceOpen: true,
});
} else if (session.currentPlan) {
set({
activePane: 'sap',
currentPlan: session.currentPlan,
currentSession: session,
workspaceOpen: true,
});
} else {
set({
activePane: 'empty',
currentSession: session,
workspaceOpen: false,
});
}
if (session.dataSchema) {
set({
mountedFile: {
name: session.title || 'data.csv',
size: 0,
rowCount: session.dataSchema.rowCount,
}
});
}
},
// 添加新的分析记录
addAnalysisRecord: (query, plan) => {
const recordId = plan.id || `record_${Date.now()}`;
const newRecord: AnalysisRecord = {
id: recordId,
query,
createdAt: new Date().toISOString(),
plan,
executionResult: null,
traceSteps: [],
};
set((state) => ({
analysisHistory: [...state.analysisHistory, newRecord],
currentRecordId: recordId,
currentPlan: plan,
executionResult: null,
traceSteps: [],
}));
return recordId;
},
// 更新分析记录(如执行结果)
updateAnalysisRecord: (id, update) => {
set((state) => {
const updatedHistory = state.analysisHistory.map((record) =>
record.id === id ? { ...record, ...update } : record
);
// 如果更新的是当前记录,同步更新当前状态
const isCurrentRecord = state.currentRecordId === id;
return {
analysisHistory: updatedHistory,
...(isCurrentRecord && update.executionResult !== undefined
? { executionResult: update.executionResult }
: {}),
...(isCurrentRecord && update.traceSteps !== undefined
? { traceSteps: update.traceSteps }
: {}),
};
});
},
// 选择/切换到某个分析记录
selectAnalysisRecord: (id) => {
set((state) => {
const record = state.analysisHistory.find((r) => r.id === id);
if (!record) return state;
return {
currentRecordId: id,
currentPlan: record.plan,
executionResult: record.executionResult,
traceSteps: record.traceSteps,
activePane: record.executionResult ? 'result' : 'sap',
workspaceOpen: true,
};
});
},
// 获取当前记录(使用 get 方法避免循环引用)
getCurrentRecord: (): AnalysisRecord | null => {
return null; // 此方法在组件中通过直接访问 state 实现
},
}));
export default useSSAStore;

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@ export interface SSASession {
mode: SSAMode;
status: SessionStatus;
dataSchema?: DataSchema;
currentPlan?: AnalysisPlan;
executionResult?: ExecutionResult;
createdAt: string;
updatedAt: string;
}
@@ -56,14 +58,40 @@ export interface ErrorContent {
suggestion?: string;
}
export interface AnalysisPlanParameters {
groupVar?: string;
valueVar?: string;
testVar?: string;
group_var?: string; // snake_case 兼容
value_var?: string; // snake_case 兼容
[key: string]: unknown;
}
export interface AnalysisPlan {
id: string;
toolCode: string;
toolName: string;
description: string;
parameters: Record<string, unknown>;
parameters: AnalysisPlanParameters;
guardrails: GuardrailConfig[];
confidence: number;
// V8 扩展字段
title?: string;
purpose?: string;
recommendedMethod?: string;
independentVar?: string;
dependentVar?: string;
}
export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result';
export interface SSAMessage {
id: string;
role: 'user' | 'assistant';
content: string;
artifactType?: ArtifactType;
recordId?: string; // 关联到分析记录(支持多任务)
createdAt: string;
}
export interface GuardrailConfig {
@@ -81,6 +109,12 @@ export interface ExecutionResult {
reproducibleCode: string;
guardrailResults: GuardrailResult[];
executionMs: number;
// V8 扩展字段
interpretation?: string;
result_table?: {
headers: string[];
rows: (string | number)[][];
};
}
export interface StatisticalResult {
@@ -128,9 +162,9 @@ export interface AnalysisStep {
export interface TraceStep {
index: number;
name: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'switched';
status: 'pending' | 'running' | 'success' | 'failed' | 'switched' | 'warning' | 'error' | 'downgrade';
actionType?: 'Block' | 'Warn' | 'Switch';
switchTarget?: string;
message?: string;
message: string;
durationMs?: number;
}

View File

@@ -0,0 +1,202 @@
/**
* ResizableSplitPane - 可拖拽分栏组件
*
* 从 AIA Protocol Agent 下沉的通用组件
*
* 支持:
* - 动态调整左右面板比例
* - 拖拽手柄
* - 比例记忆 (localStorage)
* - 平滑过渡动画
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
interface ResizableSplitPaneProps {
/** 左侧面板 */
leftPanel: React.ReactNode;
/** 右侧面板 */
rightPanel: React.ReactNode;
/** 默认左侧宽度百分比 (0-100) */
defaultLeftRatio?: number;
/** 最小左侧宽度百分比 */
minLeftRatio?: number;
/** 最大左侧宽度百分比 */
maxLeftRatio?: number;
/** 是否启用拖拽 */
enableDrag?: boolean;
/** 比例变化回调 */
onRatioChange?: (leftRatio: number) => void;
/** localStorage 存储 key */
storageKey?: string;
/** CSS 类名 */
className?: string;
}
export const ResizableSplitPane: React.FC<ResizableSplitPaneProps> = ({
leftPanel,
rightPanel,
defaultLeftRatio = 35,
minLeftRatio = 25,
maxLeftRatio = 50,
enableDrag = true,
onRatioChange,
storageKey,
className = '',
}) => {
const [leftRatio, setLeftRatio] = useState<number>(() => {
if (storageKey) {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = parseFloat(saved);
if (!isNaN(parsed) && parsed >= minLeftRatio && parsed <= maxLeftRatio) {
return parsed;
}
}
}
return defaultLeftRatio;
});
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLeftRatio(defaultLeftRatio);
}, [defaultLeftRatio]);
useEffect(() => {
if (storageKey && !isDragging) {
localStorage.setItem(storageKey, leftRatio.toString());
}
}, [leftRatio, storageKey, isDragging]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!enableDrag) return;
e.preventDefault();
setIsDragging(true);
}, [enableDrag]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
const clampedRatio = Math.max(minLeftRatio, Math.min(maxLeftRatio, newRatio));
setLeftRatio(clampedRatio);
onRatioChange?.(clampedRatio);
}, [isDragging, minLeftRatio, maxLeftRatio, onRatioChange]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
ref={containerRef}
className={`resizable-split-pane ${className} ${isDragging ? 'dragging' : ''}`}
style={{
display: 'flex',
width: '100%',
height: '100%',
overflow: 'hidden',
}}
>
{/* 左侧面板 */}
<div
className="split-pane-left"
style={{
width: `${leftRatio}%`,
flexShrink: 0,
overflow: 'hidden',
transition: isDragging ? 'none' : 'width 0.3s ease',
}}
>
{leftPanel}
</div>
{/* 拖拽手柄 */}
{enableDrag && (
<div
className="split-pane-handle"
onMouseDown={handleMouseDown}
style={{
width: '6px',
cursor: 'col-resize',
background: isDragging ? '#6366F1' : 'transparent',
position: 'relative',
flexShrink: 0,
zIndex: 10,
}}
>
<div
className="split-handle-indicator"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '4px',
height: '40px',
background: isDragging ? '#fff' : '#94a3b8',
borderRadius: '2px',
opacity: isDragging ? 1 : 0,
transition: 'opacity 0.2s',
}}
/>
<div
className="split-handle-hover-area"
style={{
position: 'absolute',
top: 0,
left: '-4px',
right: '-4px',
bottom: 0,
}}
onMouseEnter={(e) => {
const handle = e.currentTarget.previousSibling as HTMLElement;
if (handle) handle.style.opacity = '1';
}}
onMouseLeave={(e) => {
if (!isDragging) {
const handle = e.currentTarget.previousSibling as HTMLElement;
if (handle) handle.style.opacity = '0';
}
}}
/>
</div>
)}
{/* 右侧面板 */}
<div
className="split-pane-right"
style={{
flex: 1,
overflow: 'hidden',
transition: isDragging ? 'none' : 'width 0.3s ease',
}}
>
{rightPanel}
</div>
</div>
);
};
export default ResizableSplitPane;

View File

@@ -0,0 +1,5 @@
/**
* Layout 通用布局组件
*/
export { ResizableSplitPane } from './ResizableSplitPane';
export type { default as ResizableSplitPaneType } from './ResizableSplitPane';