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:
@@ -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';
|
||||
|
||||
90
frontend-v2/src/modules/ssa/SSAWorkspace.tsx
Normal file
90
frontend-v2/src/modules/ssa/SSAWorkspace.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
442
frontend-v2/src/modules/ssa/components/SSAChatPane.tsx
Normal file
442
frontend-v2/src/modules/ssa/components/SSAChatPane.tsx
Normal 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;
|
||||
108
frontend-v2/src/modules/ssa/components/SSACodeModal.tsx
Normal file
108
frontend-v2/src/modules/ssa/components/SSACodeModal.tsx
Normal 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;
|
||||
137
frontend-v2/src/modules/ssa/components/SSASidebar.tsx
Normal file
137
frontend-v2/src/modules/ssa/components/SSASidebar.tsx
Normal 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;
|
||||
47
frontend-v2/src/modules/ssa/components/SSAToast.tsx
Normal file
47
frontend-v2/src/modules/ssa/components/SSAToast.tsx
Normal 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;
|
||||
655
frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx
Normal file
655
frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx
Normal 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 < 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;
|
||||
81
frontend-v2/src/modules/ssa/components/TypeWriter.tsx
Normal file
81
frontend-v2/src/modules/ssa/components/TypeWriter.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { useAnalysis } from './useAnalysis';
|
||||
export { useArtifactParser, parseArtifactMarkers } from './useArtifactParser';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
90
frontend-v2/src/modules/ssa/hooks/useArtifactParser.ts
Normal file
90
frontend-v2/src/modules/ssa/hooks/useArtifactParser.ts
Normal 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;
|
||||
@@ -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">
|
||||
支持 CSV、Excel 格式,单文件最大 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
2095
frontend-v2/src/modules/ssa/styles/ssa-workspace.css
Normal file
2095
frontend-v2/src/modules/ssa/styles/ssa-workspace.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
202
frontend-v2/src/shared/components/Layout/ResizableSplitPane.tsx
Normal file
202
frontend-v2/src/shared/components/Layout/ResizableSplitPane.tsx
Normal 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;
|
||||
5
frontend-v2/src/shared/components/Layout/index.ts
Normal file
5
frontend-v2/src/shared/components/Layout/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Layout 通用布局组件
|
||||
*/
|
||||
export { ResizableSplitPane } from './ResizableSplitPane';
|
||||
export type { default as ResizableSplitPaneType } from './ResizableSplitPane';
|
||||
Reference in New Issue
Block a user