feat(ssa): Complete T-test end-to-end testing with 9 bug fixes - Phase 1 core 85% complete. R service: missing value auto-filter. Backend: error handling, variable matching, dynamic filename. Frontend: module activation, session isolation, error propagation. Full flow verified.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -997,9 +997,7 @@
|
||||
|
||||
.message-bubble .markdown-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.message-bubble .markdown-content ol {
|
||||
}.message-bubble .markdown-content ol {
|
||||
list-style-type: decimal;
|
||||
}.message-bubble .markdown-content li {
|
||||
margin: 6px 0;
|
||||
@@ -1017,13 +1015,11 @@
|
||||
.message-bubble .markdown-content em {
|
||||
font-style: italic;
|
||||
color: #4B5563;
|
||||
}
|
||||
|
||||
.message-bubble .markdown-content code {
|
||||
}.message-bubble .markdown-content code {
|
||||
background: rgba(79, 110, 242, 0.1);
|
||||
color: #4F6EF2;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
130
frontend-v2/src/modules/ssa/components/APATable.tsx
Normal file
130
frontend-v2/src/modules/ssa/components/APATable.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* APA 格式统计结果表格
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Typography, Empty } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface APATableProps {
|
||||
result: any;
|
||||
}
|
||||
|
||||
const formatPValue = (p: number | undefined): string => {
|
||||
if (p === undefined || p === null) return '-';
|
||||
if (p < 0.001) return '< .001';
|
||||
if (p < 0.01) return p.toFixed(3).replace('0.', '.');
|
||||
return p.toFixed(2).replace('0.', '.');
|
||||
};
|
||||
|
||||
const formatStatistic = (value: number | undefined): string => {
|
||||
if (value === undefined || value === null) return '-';
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
const APATable: React.FC<APATableProps> = ({ result }) => {
|
||||
// 处理空数据
|
||||
if (!result || Object.keys(result).length === 0) {
|
||||
return <Empty description="暂无统计结果" />;
|
||||
}
|
||||
|
||||
// 兼容 camelCase 和 snake_case
|
||||
const method = result.method || '统计分析';
|
||||
const statistic = result.statistic;
|
||||
const pValue = result.pValue ?? result.p_value;
|
||||
const effectSize = result.effectSize ?? result.effect_size;
|
||||
const ci = result.ci ?? result.conf_int;
|
||||
const columns = [
|
||||
{
|
||||
title: '统计量',
|
||||
dataIndex: 'label',
|
||||
key: 'label',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '值',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
render: (val: string | number) => (
|
||||
<Text strong>{val}</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource: any[] = [
|
||||
{ key: 'method', label: '分析方法', value: method },
|
||||
];
|
||||
|
||||
// 添加统计量(如果有)
|
||||
if (statistic !== undefined) {
|
||||
dataSource.push({
|
||||
key: 'statistic',
|
||||
label: '检验统计量',
|
||||
value: formatStatistic(statistic),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加 p 值(如果有)
|
||||
if (pValue !== undefined) {
|
||||
dataSource.push({
|
||||
key: 'pValue',
|
||||
label: 'p 值',
|
||||
value: (
|
||||
<Text
|
||||
type={pValue < 0.05 ? 'success' : 'secondary'}
|
||||
strong={pValue < 0.05}
|
||||
>
|
||||
{formatPValue(pValue)}
|
||||
{pValue < 0.05 && ' *'}
|
||||
{pValue < 0.01 && '*'}
|
||||
{pValue < 0.001 && '*'}
|
||||
</Text>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (effectSize !== undefined) {
|
||||
dataSource.push({
|
||||
key: 'effectSize',
|
||||
label: '效应量',
|
||||
value: effectSize.toFixed(3),
|
||||
});
|
||||
}
|
||||
|
||||
if (ci && Array.isArray(ci) && ci.length >= 2) {
|
||||
dataSource.push({
|
||||
key: 'ci',
|
||||
label: '95% 置信区间',
|
||||
value: `[${ci[0].toFixed(2)}, ${ci[1].toFixed(2)}]`,
|
||||
});
|
||||
}
|
||||
|
||||
// 其他详细信息
|
||||
const details = result.details || result.group_stats;
|
||||
if (details && typeof details === 'object') {
|
||||
Object.entries(details).forEach(([key, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
dataSource.push({
|
||||
key,
|
||||
label: key,
|
||||
value: value.toFixed(3),
|
||||
});
|
||||
} else if (typeof value === 'string') {
|
||||
dataSource.push({ key, label: key, value });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="ssa-apa-table"
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default APATable;
|
||||
42
frontend-v2/src/modules/ssa/components/ExecutionProgress.tsx
Normal file
42
frontend-v2/src/modules/ssa/components/ExecutionProgress.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 执行进度条组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Progress, Space, Typography } from 'antd';
|
||||
import type { TraceStep } from '../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ExecutionProgressProps {
|
||||
steps: TraceStep[];
|
||||
}
|
||||
|
||||
const ExecutionProgress: React.FC<ExecutionProgressProps> = ({ steps }) => {
|
||||
const completedCount = steps.filter(
|
||||
(s) => s.status === 'success' || s.status === 'switched'
|
||||
).length;
|
||||
const failedCount = steps.filter((s) => s.status === 'failed').length;
|
||||
const total = steps.length;
|
||||
const percent = Math.round((completedCount / total) * 100);
|
||||
|
||||
const currentStep = steps.find((s) => s.status === 'running');
|
||||
const status =
|
||||
failedCount > 0 ? 'exception' : percent === 100 ? 'success' : 'active';
|
||||
|
||||
return (
|
||||
<div className="ssa-execution-progress">
|
||||
<Progress
|
||||
percent={percent}
|
||||
status={status}
|
||||
format={() => `${completedCount}/${total}`}
|
||||
/>
|
||||
{currentStep && (
|
||||
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
||||
正在执行: {currentStep.name}...
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionProgress;
|
||||
90
frontend-v2/src/modules/ssa/components/ExecutionTrace.tsx
Normal file
90
frontend-v2/src/modules/ssa/components/ExecutionTrace.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 执行追踪组件 - 显示分析步骤进度
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Steps, Tag } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
SwapOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { TraceStep } from '../types';
|
||||
|
||||
interface ExecutionTraceProps {
|
||||
steps: TraceStep[];
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const getIcon = (step: TraceStep) => {
|
||||
switch (step.status) {
|
||||
case 'success':
|
||||
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
|
||||
case 'failed':
|
||||
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
|
||||
case 'running':
|
||||
return <LoadingOutlined style={{ color: '#1890ff' }} />;
|
||||
case 'switched':
|
||||
return <SwapOutlined style={{ color: '#722ed1' }} />;
|
||||
default:
|
||||
return <ClockCircleOutlined style={{ color: '#d9d9d9' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionTag = (step: TraceStep) => {
|
||||
if (!step.actionType) return null;
|
||||
|
||||
const config = {
|
||||
Block: { color: 'red', icon: <CloseCircleOutlined /> },
|
||||
Warn: { color: 'orange', icon: <ExclamationCircleOutlined /> },
|
||||
Switch: { color: 'purple', icon: <SwapOutlined /> },
|
||||
};
|
||||
|
||||
const tagConfig = config[step.actionType];
|
||||
|
||||
return (
|
||||
<Tag color={tagConfig.color} icon={tagConfig.icon}>
|
||||
{step.actionType}
|
||||
{step.switchTarget && `: ${step.switchTarget}`}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||
steps,
|
||||
compact = false,
|
||||
}) => {
|
||||
const currentStep = steps.findIndex(
|
||||
(s) => s.status === 'running' || s.status === 'pending'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`ssa-execution-trace ${compact ? 'compact' : ''}`}>
|
||||
<Steps
|
||||
orientation={compact ? 'horizontal' : 'vertical'}
|
||||
size="small"
|
||||
current={currentStep === -1 ? steps.length : currentStep}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Steps.Step
|
||||
key={step.index}
|
||||
title={
|
||||
<span className="ssa-trace-title">
|
||||
{step.name}
|
||||
{getActionTag(step)}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
step.message || (step.durationMs ? `${step.durationMs}ms` : '')
|
||||
}
|
||||
icon={getIcon(step)}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionTrace;
|
||||
50
frontend-v2/src/modules/ssa/components/ModeSwitch.tsx
Normal file
50
frontend-v2/src/modules/ssa/components/ModeSwitch.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 模式切换组件 - 智能分析 / 咨询模式
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Segmented } from 'antd';
|
||||
import { ExperimentOutlined, CommentOutlined } from '@ant-design/icons';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import type { SSAMode } from '../types';
|
||||
|
||||
interface ModeSwitchProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ModeSwitch: React.FC<ModeSwitchProps> = ({ disabled = false }) => {
|
||||
const { mode, setMode } = useSSAStore();
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'analysis' as SSAMode,
|
||||
label: (
|
||||
<span className="ssa-mode-option">
|
||||
<ExperimentOutlined />
|
||||
<span>智能分析</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'consult' as SSAMode,
|
||||
label: (
|
||||
<span className="ssa-mode-option">
|
||||
<CommentOutlined />
|
||||
<span>咨询模式</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="ssa-mode-switch">
|
||||
<Segmented
|
||||
options={options}
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as SSAMode)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModeSwitch;
|
||||
105
frontend-v2/src/modules/ssa/components/PlanCard.tsx
Normal file
105
frontend-v2/src/modules/ssa/components/PlanCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 分析计划卡片组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Button, Tag, Descriptions, Space, Tooltip } from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
SafetyOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AnalysisPlan, GuardrailConfig } from '../types';
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: AnalysisPlan;
|
||||
canExecute: boolean;
|
||||
onExecute: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
const getActionColor = (action: GuardrailConfig['actionType']) => {
|
||||
switch (action) {
|
||||
case 'Block':
|
||||
return 'red';
|
||||
case 'Warn':
|
||||
return 'orange';
|
||||
case 'Switch':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({
|
||||
plan,
|
||||
canExecute,
|
||||
onExecute,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className="ssa-plan-card"
|
||||
title={
|
||||
<Space>
|
||||
<span>{plan.toolName}</span>
|
||||
<Tag color="blue">{plan.toolCode}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Tag color={plan.confidence > 0.8 ? 'green' : 'orange'}>
|
||||
置信度: {(plan.confidence * 100).toFixed(0)}%
|
||||
</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={onExecute}
|
||||
loading={isExecuting}
|
||||
disabled={!canExecute}
|
||||
>
|
||||
执行分析
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="分析描述">
|
||||
{plan.description}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="参数配置">
|
||||
<pre className="ssa-params-preview">
|
||||
{JSON.stringify(plan.parameters, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{plan.guardrails && plan.guardrails.length > 0 && (
|
||||
<div className="ssa-guardrails">
|
||||
<div className="ssa-guardrails-title">
|
||||
<SafetyOutlined /> 统计护栏
|
||||
</div>
|
||||
<Space wrap>
|
||||
{plan.guardrails.map((gr, idx) => (
|
||||
<Tooltip
|
||||
key={idx}
|
||||
title={
|
||||
<span>
|
||||
检查: {gr.checkCode}
|
||||
{gr.threshold && ` | 阈值: ${gr.threshold}`}
|
||||
{gr.actionTarget && ` | 目标: ${gr.actionTarget}`}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tag color={getActionColor(gr.actionType)}>
|
||||
{gr.checkName} ({gr.actionType})
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
177
frontend-v2/src/modules/ssa/components/ResultCard.tsx
Normal file
177
frontend-v2/src/modules/ssa/components/ResultCard.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 结果展示卡片组件
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Tabs, Button, Space, message, Alert, Typography } from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
CopyOutlined,
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
CodeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ExecutionResult } from '../types';
|
||||
import APATable from './APATable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ResultCardProps {
|
||||
result: ExecutionResult;
|
||||
onDownloadCode: () => void;
|
||||
}
|
||||
|
||||
const ResultCard: React.FC<ResultCardProps> = ({ result, onDownloadCode }) => {
|
||||
const [activeTab, setActiveTab] = useState('results');
|
||||
|
||||
// 安全获取结果数据(处理后端返回格式差异:camelCase vs snake_case)
|
||||
const rawResult = result as any;
|
||||
const results = rawResult.results || rawResult.result || {};
|
||||
const plots = rawResult.plots || [];
|
||||
const reproducibleCode = rawResult.reproducibleCode || rawResult.reproducible_code || '';
|
||||
const executionMs = rawResult.executionMs || rawResult.execution_ms || 0;
|
||||
const guardrailResults = rawResult.guardrailResults || rawResult.guardrail_results || [];
|
||||
const methodName = results.method || '统计分析';
|
||||
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(reproducibleCode);
|
||||
message.success('代码已复制到剪贴板');
|
||||
};
|
||||
|
||||
const renderGuardrailAlerts = () => {
|
||||
const warnings = guardrailResults.filter(
|
||||
(gr: any) => gr.actionType === 'Warn' || gr.action_type === 'Warn' ||
|
||||
((gr.actionType === 'Switch' || gr.action_type === 'Switch') && (gr.actionTaken || gr.action_taken))
|
||||
);
|
||||
|
||||
if (!warnings || warnings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ssa-guardrail-alerts">
|
||||
{warnings.map((gr: any, idx: number) => (
|
||||
<Alert
|
||||
key={idx}
|
||||
type={(gr.actionType || gr.action_type) === 'Switch' ? 'info' : 'warning'}
|
||||
message={
|
||||
(gr.actionType || gr.action_type) === 'Switch'
|
||||
? `方法已切换: ${gr.switchTarget || gr.switch_target}`
|
||||
: (gr.checkName || gr.check_name)
|
||||
}
|
||||
description={gr.message}
|
||||
showIcon
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'results',
|
||||
label: (
|
||||
<span>
|
||||
<FileTextOutlined /> 统计结果
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="ssa-results-tab">
|
||||
{renderGuardrailAlerts()}
|
||||
<APATable result={results} />
|
||||
<div className="ssa-execution-info">
|
||||
<Text type="secondary">执行耗时: {executionMs}ms</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'plots',
|
||||
label: (
|
||||
<span>
|
||||
<BarChartOutlined /> 可视化 ({plots.length || 0})
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="ssa-plots-tab">
|
||||
{plots.map((plot: any, idx: number) => {
|
||||
// 兼容两种格式:
|
||||
// 1. 对象格式: {title: "xxx", imageBase64: "xxx"}
|
||||
// 2. 字符串格式: "data:image/png;base64,xxx"
|
||||
const isString = typeof plot === 'string';
|
||||
const title = isString ? `图表 ${idx + 1}` : (plot.title || `图表 ${idx + 1}`);
|
||||
const imageSrc = isString
|
||||
? plot
|
||||
: (plot.imageBase64 || plot.image_base64
|
||||
? (plot.imageBase64 || plot.image_base64).startsWith('data:')
|
||||
? (plot.imageBase64 || plot.image_base64)
|
||||
: `data:image/png;base64,${plot.imageBase64 || plot.image_base64}`
|
||||
: '');
|
||||
|
||||
return (
|
||||
<div key={idx} className="ssa-plot-item">
|
||||
<h4>{title}</h4>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary">图片加载失败</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
label: (
|
||||
<span>
|
||||
<CodeOutlined /> 可复现代码
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="ssa-code-tab">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<CopyOutlined />} onClick={copyCode}>
|
||||
复制代码
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={onDownloadCode}>
|
||||
下载 R 脚本
|
||||
</Button>
|
||||
</Space>
|
||||
<pre className="ssa-code-block">
|
||||
{reproducibleCode}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const statusColor =
|
||||
result.status === 'success'
|
||||
? '#52c41a'
|
||||
: result.status === 'warning'
|
||||
? '#faad14'
|
||||
: '#ff4d4f';
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="ssa-result-card"
|
||||
title={
|
||||
<Space>
|
||||
<span
|
||||
className="ssa-status-dot"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
/>
|
||||
<span>分析结果 - {methodName}</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={items} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultCard;
|
||||
79
frontend-v2/src/modules/ssa/components/SAPDownloadButton.tsx
Normal file
79
frontend-v2/src/modules/ssa/components/SAPDownloadButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* SAP 下载按钮组件
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Dropdown, message } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { DownloadOutlined, FileWordOutlined, FilePdfOutlined, FileMarkdownOutlined } from '@ant-design/icons';
|
||||
|
||||
interface SAPDownloadButtonProps {
|
||||
sessionId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SAPDownloadButton: React.FC<SAPDownloadButtonProps> = ({
|
||||
sessionId,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const downloadSAP = async (format: 'docx' | 'pdf' | 'md') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ssa/consult/${sessionId}/download-sap?format=${format}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('下载失败');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `SAP_${sessionId}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
message.success('SAP 下载成功');
|
||||
} catch (error) {
|
||||
message.error('下载失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'docx',
|
||||
icon: <FileWordOutlined />,
|
||||
label: 'Word 文档 (.docx)',
|
||||
onClick: () => downloadSAP('docx'),
|
||||
},
|
||||
{
|
||||
key: 'pdf',
|
||||
icon: <FilePdfOutlined />,
|
||||
label: 'PDF 文档 (.pdf)',
|
||||
onClick: () => downloadSAP('pdf'),
|
||||
},
|
||||
{
|
||||
key: 'md',
|
||||
icon: <FileMarkdownOutlined />,
|
||||
label: 'Markdown (.md)',
|
||||
onClick: () => downloadSAP('md'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} disabled={disabled || loading}>
|
||||
<Button icon={<DownloadOutlined />} loading={loading}>
|
||||
下载 SAP
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SAPDownloadButton;
|
||||
74
frontend-v2/src/modules/ssa/components/SAPPreview.tsx
Normal file
74
frontend-v2/src/modules/ssa/components/SAPPreview.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* SAP 统计分析计划预览组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Timeline, Typography, Descriptions, Tag } from 'antd';
|
||||
import { FileTextOutlined, ExperimentOutlined } from '@ant-design/icons';
|
||||
import type { SAP } from '../types';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface SAPPreviewProps {
|
||||
sap: SAP;
|
||||
}
|
||||
|
||||
const SAPPreview: React.FC<SAPPreviewProps> = ({ sap }) => {
|
||||
return (
|
||||
<Card className="ssa-sap-preview">
|
||||
<div className="ssa-sap-header">
|
||||
<FileTextOutlined style={{ fontSize: 24, marginRight: 12 }} />
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{sap.title}
|
||||
</Title>
|
||||
<Text type="secondary">版本 {sap.version}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
style={{ marginTop: 16, marginBottom: 16 }}
|
||||
>
|
||||
<Descriptions.Item label="研究目的">
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{sap.objectives.map((obj, idx) => (
|
||||
<li key={idx}>{obj}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据描述">
|
||||
<Paragraph style={{ margin: 0 }}>{sap.dataDescription}</Paragraph>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div className="ssa-sap-steps">
|
||||
<Title level={5}>
|
||||
<ExperimentOutlined /> 分析步骤
|
||||
</Title>
|
||||
<Timeline>
|
||||
{sap.analysisSteps.map((step) => (
|
||||
<Timeline.Item key={step.order}>
|
||||
<div className="ssa-sap-step">
|
||||
<Text strong>
|
||||
步骤 {step.order}: {step.name}
|
||||
</Text>
|
||||
<Paragraph type="secondary" style={{ margin: '4px 0' }}>
|
||||
{step.description}
|
||||
</Paragraph>
|
||||
<div>
|
||||
<Tag color="blue">{step.method}</Tag>
|
||||
{step.variables.map((v, idx) => (
|
||||
<Tag key={idx}>{v}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SAPPreview;
|
||||
9
frontend-v2/src/modules/ssa/components/index.ts
Normal file
9
frontend-v2/src/modules/ssa/components/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as ModeSwitch } from './ModeSwitch';
|
||||
export { default as PlanCard } from './PlanCard';
|
||||
export { default as ExecutionTrace } from './ExecutionTrace';
|
||||
export { default as ResultCard } from './ResultCard';
|
||||
export { default as APATable } from './APATable';
|
||||
export { default as ExecutionProgress } from './ExecutionProgress';
|
||||
export { default as SAPPreview } from './SAPPreview';
|
||||
export { default as SAPDownloadButton } from './SAPDownloadButton';
|
||||
// ConsultChat 已删除,使用 @/shared/components/Chat/AIStreamChat 替代
|
||||
1
frontend-v2/src/modules/ssa/hooks/index.ts
Normal file
1
frontend-v2/src/modules/ssa/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useAnalysis } from './useAnalysis';
|
||||
288
frontend-v2/src/modules/ssa/hooks/useAnalysis.ts
Normal file
288
frontend-v2/src/modules/ssa/hooks/useAnalysis.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* SSA 分析相关的自定义 Hook
|
||||
*
|
||||
* 遵循规范:
|
||||
* - 使用 apiClient(带认证的 axios 实例)
|
||||
* - 使用 getAccessToken 处理文件上传
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
import { getAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import type { AnalysisPlan, ExecutionResult, Message, TraceStep } from '../types';
|
||||
|
||||
const API_BASE = '/api/v1/ssa';
|
||||
|
||||
interface UploadResult {
|
||||
sessionId: string;
|
||||
schema: {
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
uniqueValues?: number;
|
||||
nullCount?: number;
|
||||
}>;
|
||||
rowCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface UseAnalysisReturn {
|
||||
uploadData: (file: File) => Promise<UploadResult>;
|
||||
generatePlan: (query: string) => Promise<AnalysisPlan>;
|
||||
executePlan: (planId: string) => Promise<ExecutionResult>;
|
||||
downloadCode: () => Promise<DownloadResult>;
|
||||
isUploading: boolean;
|
||||
uploadProgress: number;
|
||||
}
|
||||
|
||||
export function useAnalysis(): UseAnalysisReturn {
|
||||
const {
|
||||
currentSession,
|
||||
setCurrentPlan,
|
||||
setExecutionResult,
|
||||
setTraceSteps,
|
||||
updateTraceStep,
|
||||
addMessage,
|
||||
setLoading,
|
||||
setExecuting,
|
||||
setError,
|
||||
} = useSSAStore();
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const uploadData = useCallback(async (file: File): Promise<UploadResult> => {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
// 文件上传使用 fetch + 手动添加认证头(不设置 Content-Type)
|
||||
const token = getAccessToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/sessions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setUploadProgress(100);
|
||||
return result;
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : '上传出错');
|
||||
throw error;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [setError]);
|
||||
|
||||
const generatePlan = useCallback(
|
||||
async (query: string): Promise<AnalysisPlan> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
contentType: 'text',
|
||||
content: { text: query },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(userMessage);
|
||||
|
||||
const response = await apiClient.post(
|
||||
`${API_BASE}/sessions/${currentSession.id}/plan`,
|
||||
{ query }
|
||||
);
|
||||
|
||||
const plan: AnalysisPlan = response.data;
|
||||
setCurrentPlan(plan);
|
||||
|
||||
const planMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
contentType: 'plan',
|
||||
content: { plan, canExecute: true },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(planMessage);
|
||||
|
||||
return plan;
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : '生成计划出错');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentSession, addMessage, setCurrentPlan, setLoading, setError]
|
||||
);
|
||||
|
||||
const executePlan = useCallback(
|
||||
async (_planId: string): Promise<ExecutionResult> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
// 获取当前 plan(从 store)
|
||||
const plan = useSSAStore.getState().currentPlan;
|
||||
if (!plan) {
|
||||
throw new Error('请先生成分析计划');
|
||||
}
|
||||
|
||||
setExecuting(true);
|
||||
setExecutionResult(null);
|
||||
|
||||
const initialSteps: TraceStep[] = [
|
||||
{ index: 0, name: '参数验证', status: 'pending' },
|
||||
{ index: 1, name: '护栏检查', status: 'pending' },
|
||||
{ index: 2, name: '统计计算', status: 'pending' },
|
||||
{ index: 3, name: '可视化生成', status: 'pending' },
|
||||
{ index: 4, name: '结果格式化', status: 'pending' },
|
||||
];
|
||||
setTraceSteps(initialSteps);
|
||||
|
||||
try {
|
||||
updateTraceStep(0, { status: 'running' });
|
||||
|
||||
// 发送完整的 plan 对象(转换为后端格式)
|
||||
const response = await apiClient.post(
|
||||
`${API_BASE}/sessions/${currentSession.id}/execute`,
|
||||
{
|
||||
plan: {
|
||||
tool_code: plan.toolCode,
|
||||
tool_name: plan.toolName,
|
||||
params: plan.parameters,
|
||||
guardrails: plan.guardrails
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result: ExecutionResult = response.data;
|
||||
|
||||
initialSteps.forEach((_, i) => {
|
||||
updateTraceStep(i, { status: 'success' });
|
||||
});
|
||||
|
||||
result.guardrailResults?.forEach((gr) => {
|
||||
if (gr.actionType === 'Switch' && gr.actionTaken) {
|
||||
updateTraceStep(1, {
|
||||
status: 'switched',
|
||||
actionType: 'Switch',
|
||||
switchTarget: gr.switchTarget,
|
||||
message: gr.message,
|
||||
});
|
||||
} else if (gr.actionType === 'Warn') {
|
||||
updateTraceStep(1, {
|
||||
actionType: 'Warn',
|
||||
message: gr.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setExecutionResult(result);
|
||||
|
||||
const resultMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
contentType: 'result',
|
||||
content: { execution: result },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(resultMessage);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
initialSteps.forEach((step, i) => {
|
||||
if (step.status === 'running' || step.status === 'pending') {
|
||||
updateTraceStep(i, { status: 'failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// 提取 R 服务返回的具体错误信息
|
||||
const errorData = error.response?.data;
|
||||
const errorMessage = errorData?.user_hint || errorData?.error ||
|
||||
(error instanceof Error ? error.message : '执行出错');
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentSession,
|
||||
addMessage,
|
||||
setExecutionResult,
|
||||
setTraceSteps,
|
||||
updateTraceStep,
|
||||
setExecuting,
|
||||
setError,
|
||||
]
|
||||
);
|
||||
|
||||
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`${API_BASE}/sessions/${currentSession.id}/download-code`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// 从 Content-Disposition header 提取文件名
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `analysis_${currentSession.id}.R`;
|
||||
|
||||
if (contentDisposition) {
|
||||
// 尝试匹配 filename="xxx" 或 filename*=UTF-8''xxx
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (filenameMatch) {
|
||||
let extractedName = filenameMatch[1].replace(/['"]/g, '');
|
||||
// 处理 URL 编码的文件名
|
||||
try {
|
||||
extractedName = decodeURIComponent(extractedName);
|
||||
} catch {
|
||||
// 解码失败,使用原始值
|
||||
}
|
||||
if (extractedName) {
|
||||
filename = extractedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blob: response.data, filename };
|
||||
}, [currentSession]);
|
||||
|
||||
return {
|
||||
uploadData,
|
||||
generatePlan,
|
||||
executePlan,
|
||||
downloadCode,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAnalysis;
|
||||
@@ -1,34 +1,296 @@
|
||||
import React from 'react'
|
||||
import Placeholder from '../../shared/components/Placeholder'
|
||||
|
||||
/**
|
||||
* 智能统计分析模块
|
||||
* Java团队开发,前端仅做导航集成
|
||||
* SSA 智能统计分析模块主入口
|
||||
*
|
||||
* 遵循规范:
|
||||
* - 使用 AIStreamChat(通用 Chat 组件)
|
||||
* - 使用 apiClient(带认证的 axios)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Upload,
|
||||
Input,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
SendOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
|
||||
import { useSSAStore } from './stores/ssaStore';
|
||||
import { useAnalysis } from './hooks/useAnalysis';
|
||||
import {
|
||||
ModeSwitch,
|
||||
PlanCard,
|
||||
ExecutionTrace,
|
||||
ResultCard,
|
||||
ExecutionProgress,
|
||||
SAPDownloadButton,
|
||||
} from './components';
|
||||
// 使用通用 Chat 组件(遵循通用能力层清单)
|
||||
import { AIStreamChat } from '@/shared/components/Chat';
|
||||
import './styles/ssa.css';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const SSAModule: React.FC = () => {
|
||||
const {
|
||||
mode,
|
||||
currentSession,
|
||||
messages,
|
||||
currentPlan,
|
||||
executionResult,
|
||||
traceSteps,
|
||||
isLoading,
|
||||
isExecuting,
|
||||
setCurrentSession,
|
||||
reset,
|
||||
} = useSSAStore();
|
||||
|
||||
// 组件挂载时重置 store,确保不同用户看到独立的状态
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const {
|
||||
uploadData,
|
||||
generatePlan,
|
||||
executePlan,
|
||||
downloadCode,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
} = useAnalysis();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleUpload: UploadProps['customRequest'] = async (options) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
try {
|
||||
const result = await uploadData(file as File);
|
||||
setCurrentSession({
|
||||
id: result.sessionId,
|
||||
title: (file as File).name,
|
||||
mode: mode,
|
||||
status: 'active',
|
||||
dataSchema: {
|
||||
columns: result.schema.columns.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type as 'numeric' | 'categorical' | 'datetime' | 'text',
|
||||
uniqueValues: c.uniqueValues,
|
||||
nullCount: c.nullCount,
|
||||
})),
|
||||
rowCount: result.schema.rowCount,
|
||||
preview: [],
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
message.success('数据上传成功');
|
||||
onSuccess?.({});
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePlan = async () => {
|
||||
if (!query.trim()) {
|
||||
message.warning('请输入分析需求');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await generatePlan(query);
|
||||
setQuery('');
|
||||
} catch (error) {
|
||||
message.error('生成计划失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!currentPlan) return;
|
||||
try {
|
||||
await executePlan(currentPlan.id);
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '执行失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadCode = async () => {
|
||||
try {
|
||||
const { blob, filename } = await downloadCode();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
message.success('代码下载成功');
|
||||
} catch (error) {
|
||||
message.error('下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 咨询模式使用 AIStreamChat,无需自定义消息处理
|
||||
|
||||
return (
|
||||
<Placeholder
|
||||
moduleName="智能统计分析"
|
||||
message="由Java团队开发中,前端集成规划中"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SSAModule
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="ssa-page">
|
||||
<div className="ssa-page-header">
|
||||
<Title level={3}>智能统计分析</Title>
|
||||
<Paragraph type="secondary">
|
||||
上传数据,描述分析需求,AI 自动生成分析方案并执行
|
||||
</Paragraph>
|
||||
<ModeSwitch disabled={isUploading || isExecuting} />
|
||||
</div>
|
||||
|
||||
<div className="ssa-workspace">
|
||||
<div className="ssa-main-panel">
|
||||
{!currentSession ? (
|
||||
<Card>
|
||||
<div className="ssa-upload-area">
|
||||
<Upload.Dragger
|
||||
name="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
customRequest={handleUpload}
|
||||
showUploadList={false}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<p className="ssa-upload-icon">
|
||||
<CloudUploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSAModule;
|
||||
|
||||
86
frontend-v2/src/modules/ssa/stores/ssaStore.ts
Normal file
86
frontend-v2/src/modules/ssa/stores/ssaStore.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* SSA 状态管理 - Zustand Store
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
SSAMode,
|
||||
SSASession,
|
||||
Message,
|
||||
AnalysisPlan,
|
||||
ExecutionResult,
|
||||
TraceStep,
|
||||
DataSchema,
|
||||
} from '../types';
|
||||
|
||||
interface SSAState {
|
||||
mode: SSAMode;
|
||||
currentSession: SSASession | null;
|
||||
messages: Message[];
|
||||
currentPlan: AnalysisPlan | null;
|
||||
executionResult: ExecutionResult | null;
|
||||
traceSteps: TraceStep[];
|
||||
isLoading: boolean;
|
||||
isExecuting: boolean;
|
||||
error: string | null;
|
||||
|
||||
setMode: (mode: SSAMode) => void;
|
||||
setCurrentSession: (session: SSASession | null) => void;
|
||||
addMessage: (message: Message) => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setCurrentPlan: (plan: AnalysisPlan | null) => void;
|
||||
setExecutionResult: (result: ExecutionResult | null) => void;
|
||||
setTraceSteps: (steps: TraceStep[]) => void;
|
||||
updateTraceStep: (index: number, step: Partial<TraceStep>) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setExecuting: (executing: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
mode: 'analysis' as SSAMode,
|
||||
currentSession: null,
|
||||
messages: [],
|
||||
currentPlan: null,
|
||||
executionResult: null,
|
||||
traceSteps: [],
|
||||
isLoading: false,
|
||||
isExecuting: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const useSSAStore = create<SSAState>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
setCurrentSession: (session) => set({ currentSession: session }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
setCurrentPlan: (plan) => set({ currentPlan: plan }),
|
||||
|
||||
setExecutionResult: (result) => set({ executionResult: result }),
|
||||
|
||||
setTraceSteps: (steps) => set({ traceSteps: steps }),
|
||||
|
||||
updateTraceStep: (index, step) =>
|
||||
set((state) => ({
|
||||
traceSteps: state.traceSteps.map((s, i) =>
|
||||
i === index ? { ...s, ...step } : s
|
||||
),
|
||||
})),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setExecuting: (executing) => set({ isExecuting: executing }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
export default useSSAStore;
|
||||
291
frontend-v2/src/modules/ssa/styles/ssa.css
Normal file
291
frontend-v2/src/modules/ssa/styles/ssa.css
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* SSA 智能统计分析模块样式
|
||||
*/
|
||||
|
||||
/* 模式切换 */
|
||||
.ssa-mode-switch {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 计划卡片 */
|
||||
.ssa-plan-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-plan-card .ant-card-head {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ssa-params-preview {
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ssa-guardrails {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ssa-guardrails-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
/* 执行追踪 */
|
||||
.ssa-execution-trace {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-execution-trace.compact {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ssa-trace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 执行进度 */
|
||||
.ssa-execution-progress {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 结果卡片 */
|
||||
.ssa-result-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ssa-results-tab,
|
||||
.ssa-plots-tab,
|
||||
.ssa-code-tab {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.ssa-guardrail-alerts {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-execution-info {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ssa-plot-item {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ssa-plot-item h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ssa-code-block {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* APA 表格 */
|
||||
.ssa-apa-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ssa-apa-table .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SAP 预览 */
|
||||
.ssa-sap-preview {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-sap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ssa-sap-steps {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ssa-sap-step {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 咨询聊天 */
|
||||
.ssa-consult-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.ssa-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-chat-message {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ssa-chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.ssa-chat-avatar.user {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.ssa-chat-avatar.assistant {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.ssa-chat-bubble {
|
||||
max-width: 70%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ssa-chat-message.user .ssa-chat-bubble {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ssa-chat-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.ssa-chat-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ssa-chat-message.user .ssa-chat-time {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.ssa-chat-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.ssa-chat-input .ant-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 主页面布局 */
|
||||
.ssa-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.ssa-page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ssa-workspace {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.ssa-main-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ssa-side-panel {
|
||||
width: 350px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ssa-upload-area {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.ssa-upload-area:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.ssa-upload-icon {
|
||||
font-size: 48px;
|
||||
color: #1890ff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ssa-query-input {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.ssa-workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ssa-side-panel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
136
frontend-v2/src/modules/ssa/types/index.ts
Normal file
136
frontend-v2/src/modules/ssa/types/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* SSA 智能统计分析模块类型定义
|
||||
*/
|
||||
|
||||
export type SSAMode = 'analysis' | 'consult';
|
||||
|
||||
export type SessionStatus = 'active' | 'completed' | 'archived';
|
||||
|
||||
export interface SSASession {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: SSAMode;
|
||||
status: SessionStatus;
|
||||
dataSchema?: DataSchema;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DataSchema {
|
||||
columns: ColumnInfo[];
|
||||
rowCount: number;
|
||||
preview: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
type: 'numeric' | 'categorical' | 'datetime' | 'text';
|
||||
uniqueValues?: number;
|
||||
nullCount?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
contentType: 'text' | 'plan' | 'result' | 'error';
|
||||
content: TextContent | PlanContent | ResultContent | ErrorContent;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TextContent {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PlanContent {
|
||||
plan: AnalysisPlan;
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
export interface ResultContent {
|
||||
execution: ExecutionResult;
|
||||
}
|
||||
|
||||
export interface ErrorContent {
|
||||
code: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface AnalysisPlan {
|
||||
id: string;
|
||||
toolCode: string;
|
||||
toolName: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
guardrails: GuardrailConfig[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface GuardrailConfig {
|
||||
checkName: string;
|
||||
checkCode: string;
|
||||
threshold?: string;
|
||||
actionType: 'Block' | 'Warn' | 'Switch';
|
||||
actionTarget?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
status: 'success' | 'warning' | 'failed';
|
||||
results: StatisticalResult;
|
||||
plots: PlotData[];
|
||||
reproducibleCode: string;
|
||||
guardrailResults: GuardrailResult[];
|
||||
executionMs: number;
|
||||
}
|
||||
|
||||
export interface StatisticalResult {
|
||||
method: string;
|
||||
statistic: number;
|
||||
pValue: number;
|
||||
effectSize?: number;
|
||||
ci?: [number, number];
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PlotData {
|
||||
type: string;
|
||||
title: string;
|
||||
imageBase64: string;
|
||||
}
|
||||
|
||||
export interface GuardrailResult {
|
||||
checkName: string;
|
||||
passed: boolean;
|
||||
actionType: 'Block' | 'Warn' | 'Switch';
|
||||
actionTaken: boolean;
|
||||
switchTarget?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SAP {
|
||||
id: string;
|
||||
title: string;
|
||||
version: string;
|
||||
objectives: string[];
|
||||
dataDescription: string;
|
||||
analysisSteps: AnalysisStep[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AnalysisStep {
|
||||
order: number;
|
||||
name: string;
|
||||
description: string;
|
||||
method: string;
|
||||
variables: string[];
|
||||
}
|
||||
|
||||
export interface TraceStep {
|
||||
index: number;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed' | 'switched';
|
||||
actionType?: 'Block' | 'Warn' | 'Switch';
|
||||
switchTarget?: string;
|
||||
message?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user