feat(ssa): Complete Phase 2A frontend integration - multi-step workflow end-to-end

Phase 2A: WorkflowPlannerService, WorkflowExecutorService, Python data quality, 6 bug fixes, DescriptiveResultView, multi-step R code/Word export, MVP UI reuse. V11 UI: Gemini-style, multi-task, single-page scroll, Word export. Architecture: Block-based rendering consensus (4 block types). New R tools: chi_square, correlation, descriptive, logistic_binary, mann_whitney, t_test_paired. Docs: dev summary, block-based plan, status updates, task list v2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-20 23:09:27 +08:00
parent 23b422f758
commit 428a22adf2
62 changed files with 15416 additions and 299 deletions

View File

@@ -18,6 +18,7 @@ import { SSAChatPane } from './components/SSAChatPane';
import { SSAWorkspacePane } from './components/SSAWorkspacePane';
import { SSACodeModal } from './components/SSACodeModal';
import { SSAToast } from './components/SSAToast';
import { DataProfileModal } from './components/DataProfileModal';
const SSAWorkspace: React.FC = () => {
const {
@@ -83,6 +84,9 @@ const SSAWorkspace: React.FC = () => {
{/* 代码模态框 */}
<SSACodeModal />
{/* Phase 2A: 数据质量报告详情模态框 */}
<DataProfileModal />
</div>
);
};

View File

@@ -0,0 +1,233 @@
/**
* 综合结论报告组件
*
* Phase 2A: 多步骤工作流执行完成后的综合结论展示
*/
import React, { useState } from 'react';
import type { ConclusionReport as ConclusionReportType, WorkflowStepResult } from '../types';
interface ConclusionReportProps {
report: ConclusionReportType;
stepResults?: WorkflowStepResult[];
}
interface StepResultDetailProps {
stepSummary: ConclusionReportType['step_summaries'][0];
stepResult?: WorkflowStepResult;
}
const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepResult }) => {
const [expanded, setExpanded] = useState(false);
return (
<div className={`step-result-detail ${expanded ? 'expanded' : ''}`}>
<div className="detail-header" onClick={() => setExpanded(!expanded)}>
<div className="header-left">
<span className="step-badge"> {stepSummary.step_number}</span>
<span className="tool-name">{stepSummary.tool_name}</span>
{stepSummary.p_value !== undefined && (
<span
className="p-value-badge"
style={{
backgroundColor: stepSummary.is_significant ? '#ecfdf5' : '#f8fafc',
color: stepSummary.is_significant ? '#059669' : '#64748b',
}}
>
p = {stepSummary.p_value < 0.001 ? '< 0.001' : stepSummary.p_value.toFixed(4)}
{stepSummary.is_significant && ' *'}
</span>
)}
</div>
<span className={`expand-arrow ${expanded ? 'rotated' : ''}`}></span>
</div>
<div className="detail-summary">{stepSummary.summary}</div>
{expanded && stepResult?.result && (
<div className="detail-content">
{/* 结果表格 */}
{stepResult.result.result_table && (
<div className="result-table-wrapper">
<table className="result-table">
<thead>
<tr>
{stepResult.result.result_table.headers.map((header, idx) => (
<th key={idx}>{header}</th>
))}
</tr>
</thead>
<tbody>
{stepResult.result.result_table.rows.map((row, rowIdx) => (
<tr key={rowIdx}>
{row.map((cell, cellIdx) => (
<td key={cellIdx}>{typeof cell === 'number' ? cell.toFixed(4) : cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 图表 */}
{stepResult.result.plots && stepResult.result.plots.length > 0 && (
<div className="result-plots">
{stepResult.result.plots.map((plot, idx) => (
<div key={idx} className="plot-item">
<div className="plot-title">{plot.title}</div>
<img
src={plot.imageBase64.startsWith('data:') ? plot.imageBase64 : `data:image/png;base64,${plot.imageBase64}`}
alt={plot.title}
className="plot-image"
/>
</div>
))}
</div>
)}
{/* 详细解释 */}
{stepResult.result.interpretation && (
<div className="interpretation-box">
<span className="interpretation-label">💡 :</span>
<p>{stepResult.result.interpretation}</p>
</div>
)}
</div>
)}
</div>
);
};
export const ConclusionReport: React.FC<ConclusionReportProps> = ({ report, stepResults = [] }) => {
const [showFullReport, setShowFullReport] = useState(true);
const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => {
return stepResults.find(r => r.step_number === stepNumber);
};
return (
<div className="conclusion-report">
{/* 报告头部 */}
<div className="report-header">
<h2 className="report-title">📋 {report.title}</h2>
<span className="generated-time">
{new Date(report.generated_at).toLocaleString('zh-CN')}
</span>
</div>
{/* AI 总结摘要 - 始终显示 */}
<div className="executive-summary">
<div className="summary-header">
<span className="summary-icon">🤖</span>
<span className="summary-label">AI </span>
</div>
<div className="summary-content">
{report.executive_summary}
</div>
</div>
{/* 主要发现 */}
{report.key_findings.length > 0 && (
<div className="key-findings">
<div className="section-header">
<span className="section-icon">🎯</span>
<span className="section-title"></span>
</div>
<ul className="findings-list">
{report.key_findings.map((finding, idx) => (
<li key={idx}>{finding}</li>
))}
</ul>
</div>
)}
{/* 统计概览 */}
<div className="stats-overview">
<div className="stat-card">
<span className="stat-icon">📊</span>
<span className="stat-value">{report.statistical_summary.total_tests}</span>
<span className="stat-label"></span>
</div>
<div className="stat-card significant">
<span className="stat-icon"></span>
<span className="stat-value">{report.statistical_summary.significant_results}</span>
<span className="stat-label"></span>
</div>
<div className="stat-card">
<span className="stat-icon">🔬</span>
<span className="stat-value">{report.statistical_summary.methods_used.length}</span>
<span className="stat-label"></span>
</div>
</div>
{/* 展开/折叠按钮 */}
<button
className="toggle-details-btn"
onClick={() => setShowFullReport(!showFullReport)}
>
{showFullReport ? '收起详细结果 ▲' : '展开详细结果 ▼'}
</button>
{/* 详细步骤结果 */}
{showFullReport && (
<div className="step-results-section">
<div className="section-header">
<span className="section-icon">📝</span>
<span className="section-title"></span>
</div>
<div className="step-results-list">
{report.step_summaries.map((stepSummary) => (
<StepResultDetail
key={stepSummary.step_number}
stepSummary={stepSummary}
stepResult={getStepResult(stepSummary.step_number)}
/>
))}
</div>
</div>
)}
{/* 建议 */}
{report.recommendations.length > 0 && (
<div className="recommendations-section">
<div className="section-header">
<span className="section-icon">💡</span>
<span className="section-title"></span>
</div>
<ul className="recommendations-list">
{report.recommendations.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
)}
{/* 局限性 */}
{report.limitations.length > 0 && (
<div className="limitations-section">
<div className="section-header">
<span className="section-icon"></span>
<span className="section-title"></span>
</div>
<ul className="limitations-list">
{report.limitations.map((lim, idx) => (
<li key={idx}>{lim}</li>
))}
</ul>
</div>
)}
{/* 使用的方法列表 */}
<div className="methods-used">
<span className="methods-label">使:</span>
<div className="methods-tags">
{report.statistical_summary.methods_used.map((method, idx) => (
<span key={idx} className="method-tag">{method}</span>
))}
</div>
</div>
</div>
);
};
export default ConclusionReport;

View File

@@ -0,0 +1,145 @@
/**
* 数据质量核查报告卡片组件
*
* Phase 2A: 在对话区显示数据上传后的质量核查摘要
* 设计风格与 SAP 卡片保持一致
*/
import React from 'react';
import { useSSAStore } from '../stores/ssaStore';
import type { DataProfile, DataQualityGrade } from '../types';
interface DataProfileCardProps {
profile: DataProfile;
compact?: boolean;
}
const gradeConfig: Record<DataQualityGrade, { color: string; bg: string; label: string }> = {
A: { color: '#059669', bg: '#ecfdf5', label: '优秀' },
B: { color: '#2563eb', bg: '#eff6ff', label: '良好' },
C: { color: '#d97706', bg: '#fffbeb', label: '一般' },
D: { color: '#dc2626', bg: '#fef2f2', label: '需改进' },
};
export const DataProfileCard: React.FC<DataProfileCardProps> = ({ profile, compact = false }) => {
const { setDataProfileModalVisible } = useSSAStore();
const grade = gradeConfig[profile.quality_grade] || gradeConfig.C;
const handleViewDetails = () => {
setDataProfileModalVisible(true);
};
if (compact) {
return (
<div className="ssa-profile-card-compact">
<div className="profile-header">
<span className="profile-icon">📊</span>
<span className="profile-title"></span>
<span
className="quality-badge"
style={{ backgroundColor: grade.bg, color: grade.color }}
>
{grade.label} ({profile.quality_score})
</span>
</div>
<div className="profile-metrics">
<span>{profile.row_count} × {profile.column_count} </span>
<span className="separator">|</span>
<span> {(profile.missing_ratio * 100).toFixed(1)}%</span>
{profile.warnings.length > 0 && (
<>
<span className="separator">|</span>
<span className="warning-count"> {profile.warnings.length} </span>
</>
)}
</div>
<button className="view-details-btn" onClick={handleViewDetails}>
</button>
</div>
);
}
return (
<div className="ssa-profile-card">
<div className="card-header">
<div className="header-left">
<span className="card-icon">📊</span>
<span className="card-title"></span>
</div>
<div
className="quality-score-badge"
style={{ backgroundColor: grade.bg, color: grade.color }}
>
<span className="grade">{profile.quality_grade}</span>
<span className="score">{profile.quality_score}</span>
</div>
</div>
<div className="card-body">
<div className="metrics-grid">
<div className="metric-item">
<span className="metric-label"></span>
<span className="metric-value">{profile.row_count.toLocaleString()} × {profile.column_count} </span>
</div>
<div className="metric-item">
<span className="metric-label"></span>
<span className="metric-value" style={{ color: profile.missing_ratio > 0.1 ? '#d97706' : '#059669' }}>
{(profile.missing_ratio * 100).toFixed(2)}%
</span>
</div>
<div className="metric-item">
<span className="metric-label"></span>
<span className="metric-value" style={{ color: profile.duplicate_ratio > 0.05 ? '#d97706' : '#059669' }}>
{profile.duplicate_rows} ({(profile.duplicate_ratio * 100).toFixed(2)}%)
</span>
</div>
<div className="metric-item">
<span className="metric-label"></span>
<span className="metric-value">
{profile.numeric_columns} / {profile.categorical_columns}
</span>
</div>
</div>
{profile.warnings.length > 0 && (
<div className="warnings-section">
<div className="warnings-header">
<span className="warning-icon"></span>
<span> ({profile.warnings.length})</span>
</div>
<ul className="warnings-list">
{profile.warnings.slice(0, 3).map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
{profile.warnings.length > 3 && (
<li className="more-warnings"> {profile.warnings.length - 3} ...</li>
)}
</ul>
</div>
)}
{profile.recommendations.length > 0 && (
<div className="recommendations-section">
<div className="recommendations-header">
<span className="tip-icon">💡</span>
<span></span>
</div>
<ul className="recommendations-list">
{profile.recommendations.slice(0, 2).map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
)}
</div>
<div className="card-footer">
<button className="view-details-btn" onClick={handleViewDetails}>
</button>
</div>
</div>
);
};
export default DataProfileCard;

View File

@@ -0,0 +1,284 @@
/**
* 数据质量核查报告详情模态框
*
* Phase 2A: 显示完整的数据画像报告
*/
import React from 'react';
import { useSSAStore } from '../stores/ssaStore';
import type { ColumnProfile, DataQualityGrade } from '../types';
const gradeConfig: Record<DataQualityGrade, { color: string; bg: string; label: string }> = {
A: { color: '#059669', bg: '#ecfdf5', label: '优秀' },
B: { color: '#2563eb', bg: '#eff6ff', label: '良好' },
C: { color: '#d97706', bg: '#fffbeb', label: '一般' },
D: { color: '#dc2626', bg: '#fef2f2', label: '需改进' },
};
const typeIcons: Record<string, string> = {
numeric: '🔢',
categorical: '📋',
datetime: '📅',
text: '📝',
};
interface ColumnDetailProps {
column: ColumnProfile;
}
const ColumnDetail: React.FC<ColumnDetailProps> = ({ column }) => {
const isNumeric = column.inferred_type === 'numeric';
return (
<div className="column-detail-card">
<div className="column-header">
<span className="type-icon">{typeIcons[column.inferred_type] || '📄'}</span>
<span className="column-name">{column.name}</span>
<span className="column-type">{column.inferred_type}</span>
</div>
<div className="column-stats">
<div className="stat-row">
<span className="stat-label"></span>
<span className="stat-value">{column.non_null_count.toLocaleString()}</span>
</div>
<div className="stat-row">
<span className="stat-label"></span>
<span
className="stat-value"
style={{ color: column.null_ratio > 0.1 ? '#d97706' : '#64748b' }}
>
{(column.null_ratio * 100).toFixed(1)}%
</span>
</div>
<div className="stat-row">
<span className="stat-label"></span>
<span className="stat-value">{column.unique_count.toLocaleString()}</span>
</div>
{isNumeric && (
<>
<div className="stat-row">
<span className="stat-label"> ± </span>
<span className="stat-value">
{column.mean?.toFixed(2)} ± {column.std?.toFixed(2)}
</span>
</div>
<div className="stat-row">
<span className="stat-label"></span>
<span className="stat-value">
[{column.min?.toFixed(2)}, {column.max?.toFixed(2)}]
</span>
</div>
<div className="stat-row">
<span className="stat-label"> (Q1-Q3)</span>
<span className="stat-value">
{column.median?.toFixed(2)} ({column.q1?.toFixed(2)} - {column.q3?.toFixed(2)})
</span>
</div>
{column.outlier_count !== undefined && column.outlier_count > 0 && (
<div className="stat-row warning">
<span className="stat-label"> </span>
<span className="stat-value">
{column.outlier_count} ({(column.outlier_ratio! * 100).toFixed(1)}%)
</span>
</div>
)}
</>
)}
{column.top_categories && column.top_categories.length > 0 && (
<div className="categories-section">
<span className="stat-label"></span>
<div className="category-bars">
{column.top_categories.slice(0, 5).map((cat, idx) => (
<div key={idx} className="category-bar">
<div
className="bar-fill"
style={{ width: `${cat.ratio * 100}%` }}
/>
<span className="bar-label">{cat.value}</span>
<span className="bar-count">{cat.count}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="sample-values">
<span className="sample-label">:</span>
<span className="sample-content">
{column.sample_values.slice(0, 5).map(v =>
v === null ? 'NULL' : String(v)
).join(', ')}
</span>
</div>
</div>
);
};
export const DataProfileModal: React.FC = () => {
const { dataProfile, dataProfileModalVisible, setDataProfileModalVisible } = useSSAStore();
if (!dataProfileModalVisible || !dataProfile) {
return null;
}
const grade = gradeConfig[dataProfile.quality_grade] || gradeConfig.C;
const handleClose = () => {
setDataProfileModalVisible(false);
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
return (
<div className="ssa-profile-modal-overlay" onClick={handleBackdropClick}>
<div className="ssa-profile-modal">
<div className="modal-header">
<div className="header-content">
<h2>📊 </h2>
<span className="file-name">{dataProfile.file_name}</span>
</div>
<button className="close-btn" onClick={handleClose}>×</button>
</div>
<div className="modal-body">
{/* 概览卡片 */}
<div className="overview-section">
<div className="quality-overview">
<div
className="quality-circle"
style={{ backgroundColor: grade.bg, borderColor: grade.color }}
>
<span className="grade" style={{ color: grade.color }}>{dataProfile.quality_grade}</span>
<span className="score">{dataProfile.quality_score}</span>
</div>
<div className="quality-label">{grade.label}</div>
</div>
<div className="overview-metrics">
<div className="metric-card">
<span className="metric-icon">📋</span>
<span className="metric-value">{dataProfile.row_count.toLocaleString()}</span>
<span className="metric-label"></span>
</div>
<div className="metric-card">
<span className="metric-icon">📊</span>
<span className="metric-value">{dataProfile.column_count}</span>
<span className="metric-label"></span>
</div>
<div className="metric-card">
<span className="metric-icon"></span>
<span className="metric-value">{dataProfile.total_cells.toLocaleString()}</span>
<span className="metric-label"></span>
</div>
<div className="metric-card">
<span className="metric-icon"></span>
<span className="metric-value" style={{ color: dataProfile.missing_ratio > 0.1 ? '#d97706' : '#64748b' }}>
{(dataProfile.missing_ratio * 100).toFixed(1)}%
</span>
<span className="metric-label"></span>
</div>
<div className="metric-card">
<span className="metric-icon">🔄</span>
<span className="metric-value" style={{ color: dataProfile.duplicate_ratio > 0.05 ? '#d97706' : '#64748b' }}>
{dataProfile.duplicate_rows}
</span>
<span className="metric-label"></span>
</div>
</div>
</div>
{/* 变量类型分布 */}
<div className="type-distribution">
<h3></h3>
<div className="type-bars">
<div className="type-bar">
<span className="type-label">🔢 </span>
<div className="bar-wrapper">
<div
className="bar-fill numeric"
style={{ width: `${(dataProfile.numeric_columns / dataProfile.column_count) * 100}%` }}
/>
</div>
<span className="type-count">{dataProfile.numeric_columns}</span>
</div>
<div className="type-bar">
<span className="type-label">📋 </span>
<div className="bar-wrapper">
<div
className="bar-fill categorical"
style={{ width: `${(dataProfile.categorical_columns / dataProfile.column_count) * 100}%` }}
/>
</div>
<span className="type-count">{dataProfile.categorical_columns}</span>
</div>
<div className="type-bar">
<span className="type-label">📅 </span>
<div className="bar-wrapper">
<div
className="bar-fill datetime"
style={{ width: `${(dataProfile.datetime_columns / dataProfile.column_count) * 100}%` }}
/>
</div>
<span className="type-count">{dataProfile.datetime_columns}</span>
</div>
</div>
</div>
{/* 警告和建议 */}
{(dataProfile.warnings.length > 0 || dataProfile.recommendations.length > 0) && (
<div className="alerts-section">
{dataProfile.warnings.length > 0 && (
<div className="alert-box warning">
<h4> </h4>
<ul>
{dataProfile.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</div>
)}
{dataProfile.recommendations.length > 0 && (
<div className="alert-box info">
<h4>💡 </h4>
<ul>
{dataProfile.recommendations.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* 列详情 */}
<div className="columns-section">
<h3> ({dataProfile.columns.length})</h3>
<div className="columns-grid">
{dataProfile.columns.map((column, idx) => (
<ColumnDetail key={idx} column={column} />
))}
</div>
</div>
</div>
<div className="modal-footer">
<span className="generated-time">
: {new Date(dataProfile.generated_at).toLocaleString('zh-CN')}
</span>
<button className="close-footer-btn" onClick={handleClose}>
</button>
</div>
</div>
</div>
);
};
export default DataProfileModal;

View File

@@ -17,16 +17,18 @@ import {
ArrowLeft,
FileSignature,
ArrowRight,
Zap,
Loader2,
AlertCircle,
CheckCircle
CheckCircle,
BarChart2
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import { useWorkflow } from '../hooks/useWorkflow';
import type { SSAMessage } from '../types';
import { TypeWriter } from './TypeWriter';
import { DataProfileCard } from './DataProfileCard';
export const SSAChatPane: React.FC = () => {
const navigate = useNavigate();
@@ -45,9 +47,12 @@ export const SSAChatPane: React.FC = () => {
setError,
addToast,
selectAnalysisRecord,
dataProfile,
dataProfileLoading,
} = useSSAStore();
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
const { generateDataProfile, generateWorkflowPlan, isProfileLoading, isPlanLoading } = useWorkflow();
const [inputValue, setInputValue] = useState('');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -109,7 +114,14 @@ export const SSAChatPane: React.FC = () => {
rowCount: result.schema.rowCount,
});
setUploadStatus('success');
addToast('数据读取成功,正在分析结构...', 'success');
addToast('数据读取成功,正在进行质量核查...', 'success');
// Phase 2A: 自动触发数据质量核查
try {
await generateDataProfile(result.sessionId);
} catch (profileErr) {
console.warn('数据画像生成失败,继续使用基础模式:', profileErr);
}
} catch (err: any) {
setUploadStatus('error');
const errorMsg = err?.message || '上传失败,请检查文件格式';
@@ -132,7 +144,13 @@ export const SSAChatPane: React.FC = () => {
if (!inputValue.trim()) return;
try {
await generatePlan(inputValue);
// Phase 2A: 如果已有 session使用多步骤工作流规划
if (currentSession?.id) {
await generateWorkflowPlan(currentSession.id, inputValue);
} else {
// 没有数据时,使用旧流程
await generatePlan(inputValue);
}
setInputValue('');
} catch (err: any) {
addToast(err?.message || '生成计划失败', 'error');
@@ -178,8 +196,9 @@ export const SSAChatPane: React.FC = () => {
</div>
<EngineStatus
isExecuting={isExecuting}
isLoading={isLoading}
isLoading={isLoading || isPlanLoading}
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
isProfileLoading={isProfileLoading || dataProfileLoading}
/>
</header>
@@ -198,6 +217,18 @@ export const SSAChatPane: React.FC = () => {
</div>
</div>
{/* Phase 2A: 数据质量核查报告卡片 - 在欢迎语之后、用户消息之前显示 */}
{dataProfile && (
<div className="message message-ai slide-up">
<div className="message-avatar ai-avatar">
<Bot size={12} />
</div>
<div className="message-bubble ai-bubble profile-bubble">
<DataProfileCard profile={dataProfile} />
</div>
</div>
)}
{/* 动态消息 */}
{messages.map((msg: SSAMessage, idx: number) => {
const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1;
@@ -219,8 +250,8 @@ export const SSAChatPane: React.FC = () => {
msg.content
)}
{/* SAP 卡片 */}
{msg.artifactType === 'sap' && (
{/* SAP 卡片 - 只有消息中明确标记为 sap 类型时才显示 */}
{msg.artifactType === 'sap' && msg.recordId && (
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
<div className="sap-card-left">
<div className="sap-card-icon">
@@ -239,8 +270,23 @@ export const SSAChatPane: React.FC = () => {
);
})}
{/* 数据画像生成中指示器 */}
{dataProfileLoading && (
<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="profile-loading">
<BarChart2 size={16} className="spin text-blue-500" />
<span>...</span>
</div>
</div>
</div>
)}
{/* AI 正在思考指示器 */}
{isLoading && (
{(isLoading || isPlanLoading) && (
<div className="message message-ai slide-up">
<div className="message-avatar ai-avatar">
<Bot size={12} />
@@ -255,32 +301,12 @@ export const SSAChatPane: React.FC = () => {
</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>
)}
{/*
Phase 2A 新流程:
1. 上传数据 → 显示数据质量报告(已在上方处理)
2. 用户输入分析问题 → AI 回复消息中包含 SAP 卡片(通过 msg.artifactType === 'sap'
旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片
*/}
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
<div ref={chatEndRef} className="scroll-spacer" />
@@ -409,12 +435,14 @@ interface EngineStatusProps {
isExecuting: boolean;
isLoading: boolean;
isUploading: boolean;
isProfileLoading?: boolean;
}
const EngineStatus: React.FC<EngineStatusProps> = ({
isExecuting,
isLoading,
isUploading
isUploading,
isProfileLoading
}) => {
const getStatus = () => {
if (isExecuting) {
@@ -423,6 +451,9 @@ const EngineStatus: React.FC<EngineStatusProps> = ({
if (isLoading) {
return { text: 'AI Processing...', className: 'status-processing' };
}
if (isProfileLoading) {
return { text: 'Data Profiling...', className: 'status-profiling' };
}
if (isUploading) {
return { text: 'Parsing Data...', className: 'status-uploading' };
}

View File

@@ -10,7 +10,7 @@ import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
export const SSACodeModal: React.FC = () => {
const { codeModalVisible, setCodeModalVisible, executionResult, addToast } = useSSAStore();
const { codeModalVisible, setCodeModalVisible, executionResult, addToast, isWorkflowMode, workflowSteps } = useSSAStore();
const { downloadCode } = useAnalysis();
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
@@ -24,9 +24,21 @@ export const SSACodeModal: React.FC = () => {
const loadCode = async () => {
setIsLoading(true);
try {
const result = await downloadCode();
const text = await result.blob.text();
setCode(text);
if (isWorkflowMode && workflowSteps.length > 0) {
const allCode = workflowSteps
.filter(s => s.status === 'success' && s.result)
.map(s => {
const stepCode = (s.result as any)?.reproducible_code;
const header = `# ========================================\n# 步骤 ${s.step_number}: ${s.tool_name}\n# ========================================\n`;
return header + (stepCode || `# 该步骤暂无可用代码`);
})
.join('\n\n');
setCode(allCode || '# 暂无可用代码\n# 请先执行分析');
} else {
const result = await downloadCode();
const text = await result.blob.text();
setCode(text);
}
} catch (error) {
if (executionResult?.reproducibleCode) {
setCode(executionResult.reproducibleCode);
@@ -46,15 +58,27 @@ export const SSACodeModal: React.FC = () => {
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();
if (isWorkflowMode && code) {
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'workflow_analysis.R';
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
handleClose();
} else {
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');
}

View File

@@ -22,10 +22,13 @@ import {
FileQuestion,
BarChart3,
ImageOff,
StopCircle,
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import { useWorkflow } from '../hooks/useWorkflow';
import type { TraceStep } from '../types';
import { WorkflowTimeline } from './WorkflowTimeline';
type ExecutionPhase = 'planning' | 'executing' | 'completed' | 'error';
@@ -39,9 +42,17 @@ export const SSAWorkspacePane: React.FC = () => {
setCodeModalVisible,
addToast,
currentRecordId,
currentSession,
// Phase 2A: 多步骤工作流状态
isWorkflowMode,
workflowPlan,
workflowSteps,
workflowProgress,
conclusionReport,
} = useSSAStore();
const { executeAnalysis, exportReport, isExecuting } = useAnalysis();
const { executeWorkflow, cancelWorkflow, isExecuting: isWorkflowExecuting } = useWorkflow();
const [elapsedTime, setElapsedTime] = useState(0);
const [executionError, setExecutionError] = useState<string | null>(null);
const [phase, setPhase] = useState<ExecutionPhase>('planning');
@@ -50,20 +61,19 @@ export const SSAWorkspacePane: React.FC = () => {
const resultRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hasWorkflowResults = isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result);
// 当切换记录或执行结果变化时,同步 phase 状态
useEffect(() => {
// 如果正在执行中,不要覆盖 phase
if (isExecuting) return;
if (isExecuting || isWorkflowExecuting) return;
// 根据当前记录的执行结果来判断 phase
if (analysisResult) {
if (analysisResult || hasWorkflowResults) {
setPhase('completed');
} else {
// 没有执行结果,重置为 planning
setPhase('planning');
}
setExecutionError(null);
}, [currentRecordId, analysisResult, isExecuting]);
}, [currentRecordId, analysisResult, isExecuting, isWorkflowExecuting, hasWorkflowResults]);
useEffect(() => {
let timer: NodeJS.Timeout;
@@ -93,12 +103,19 @@ export const SSAWorkspacePane: React.FC = () => {
};
const handleRun = async () => {
if (!currentPlan) return;
setPhase('executing');
setExecutionError(null);
try {
await executeAnalysis();
setPhase('completed');
// Phase 2A: 多步骤工作流模式
if (isWorkflowMode && workflowPlan && currentSession) {
await executeWorkflow(currentSession.id, workflowPlan.workflow_id);
setPhase('completed');
} else if (currentPlan) {
// 单步骤模式(兼容原有逻辑)
await executeAnalysis();
setPhase('completed');
}
} catch (err: any) {
const errorMsg = err?.message || '执行失败,请重试';
setExecutionError(errorMsg);
@@ -107,14 +124,24 @@ export const SSAWorkspacePane: React.FC = () => {
}
};
const handleCancel = () => {
cancelWorkflow();
setPhase('planning');
addToast('分析已取消', 'info');
};
const handleRetry = () => {
setExecutionError(null);
setPhase('planning');
};
const handleExportReport = () => {
exportReport();
addToast('报告导出成功', 'success');
const handleExportReport = async () => {
try {
await exportReport();
addToast('报告导出成功', 'success');
} catch (err: any) {
addToast(err?.message || '导出失败', 'error');
}
};
const handleExportCode = () => {
@@ -169,7 +196,7 @@ export const SSAWorkspacePane: React.FC = () => {
</div>
<div className="workspace-header-right">
{/* 导出报告按钮 - 有结果时显示 */}
{analysisResult && (
{(analysisResult || hasWorkflowResults) && (
<button
className="workspace-text-btn"
onClick={handleExportReport}
@@ -179,7 +206,7 @@ export const SSAWorkspacePane: React.FC = () => {
</button>
)}
{/* 查看代码按钮 - 有结果时显示 */}
{analysisResult && (
{(analysisResult || hasWorkflowResults) && (
<button
className="workspace-text-btn"
onClick={handleExportCode}
@@ -203,8 +230,8 @@ export const SSAWorkspacePane: React.FC = () => {
<div className="workspace-canvas" ref={containerRef}>
<div className="workspace-scroll-container">
{/* 空状态 */}
{!currentPlan && (
{/* 空状态 - 同时检查旧版 currentPlan 和新版 workflowPlan */}
{!currentPlan && !workflowPlan && (
<div className="view-empty fade-in">
<div className="empty-icon">
<FileQuestion size={48} />
@@ -218,108 +245,145 @@ export const SSAWorkspacePane: React.FC = () => {
)}
{/* ========== 区块 1: SAP 分析计划 ========== */}
{currentPlan && (
{(currentPlan || workflowPlan) && (
<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>
{/* Phase 2A: 多步骤工作流时间线 */}
{isWorkflowMode && workflowPlan ? (
<div className="view-sap fade-in">
<WorkflowTimeline
plan={workflowPlan}
stepResults={workflowSteps}
currentStep={workflowSteps.find(s => s.status === 'running')?.step_number}
isExecuting={isWorkflowExecuting}
/>
{/* 执行按钮 */}
<div className="sap-actions">
{(isExecuting || isWorkflowExecuting) ? (
<button className="cancel-btn" onClick={handleCancel}>
<StopCircle size={14} />
</button>
) : phase !== 'planning' && conclusionReport ? (
<button className="run-btn completed" disabled>
<CheckCircle size={14} />
</button>
) : (
<button
className="run-btn"
onClick={handleRun}
disabled={phase !== 'planning'}
>
<Play size={14} />
</button>
)}
</div>
</section>
</div>
) : currentPlan && (
<div className="view-sap fade-in">
<h1 className="sap-title">
{currentPlan.title || currentPlan.description?.split('')[0] || '统计分析'}
</h1>
{/* 统计护栏 */}
<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">
<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>{guardrail.checkName}</b>
<b> (Shapiro-Wilk)</b>
<span className="guardrail-desc">
{guardrail.actionType === 'Switch'
? `若检验未通过,将自动切换为 ${guardrail.actionTarget || '备选方法'}`
: guardrail.actionType === 'Warn'
? '若检验未通过,将显示警告信息'
: '若检验未通过,将阻止执行'}
P &lt; 0.05
</span>
</div>
</li>
))
) : (
<li className="guardrail-item">
<Shield size={14} className="text-blue-500" />
<div className="guardrail-content">
<b> (Shapiro-Wilk)</b>
<span className="guardrail-desc">
P &lt; 0.05
</span>
</div>
</li>
)}
</ul>
</section>
</div>
)}
</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 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) && (
{(phase === 'executing' || phase === 'completed' || phase === 'error' || traceSteps.length > 0 || workflowSteps.length > 0) && (
<div className="section-block execution-section" ref={executionRef}>
<div className="section-divider">
<span className="divider-line" />
@@ -327,48 +391,94 @@ export const SSAWorkspacePane: React.FC = () => {
<span className="divider-line" />
</div>
{/* 执行中状态 */}
{phase === 'executing' && !executionError && (
{/* Phase 2A: 多步骤工作流执行进度 - 复用 MVP 风格 */}
{isWorkflowMode && workflowSteps.length > 0 ? (
<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">
{(isWorkflowExecuting || phase === 'executing') && (
<div className="execution-header">
<Loader2 size={24} className="text-blue-600 spin" />
<h3>...</h3>
<span className="execution-timer">{Math.round(workflowProgress)}%</span>
</div>
)}
{phase === 'completed' && (
<div className="execution-completed-header">
<CheckCircle size={16} className="text-green-500" />
<span></span>
<span className="execution-duration">
{workflowSteps.length}
</span>
</div>
)}
{/* 复用 MVP 的 terminal-box 风格 */}
<div className={`terminal-box ${phase === 'completed' ? 'collapsed' : ''}`}>
<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} />
{workflowSteps.flatMap((step) => {
const logs: Array<{ name: string; status: string; message?: string }> = [];
logs.push({
name: `[步骤 ${step.step_number}] ${step.tool_name}`,
status: step.status === 'success' ? 'success' : step.status === 'running' ? 'running' : step.status === 'failed' ? 'error' : 'pending',
message: step.status === 'running' ? '执行中...' : step.status === 'success' ? '完成' : step.status === 'failed' ? step.error : ''
});
(step.logs || []).forEach(log => {
logs.push({ name: `${log}`, status: 'info' });
});
return logs;
}).map((log, idx) => (
<TraceLogItem key={idx} step={log as TraceStep} index={idx} />
))}
</div>
</div>
</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>
)}
</>
)}
{/* 执行错误 */}
@@ -389,13 +499,181 @@ export const SSAWorkspacePane: React.FC = () => {
)}
{/* ========== 区块 3: 分析结果 ========== */}
{analysisResult && (
{(analysisResult || (isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result))) && (
<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>
{/* Phase 2A: 多步骤工作流结果 - 复用 MVP 风格 */}
{isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result) ? (
<div className="view-result fade-in">
{/* AI 解读 - 使用结论报告的摘要 */}
{conclusionReport && (
<div className="result-summary">
<Lightbulb size={20} className="text-blue-500" />
<div className="result-summary-content">
<h4>AI </h4>
<p>{conclusionReport.executive_summary || '多步骤分析已完成,请查看下方各步骤结果。'}</p>
</div>
</div>
)}
{/* 各步骤结果汇总 - MVP 风格 */}
{workflowSteps.filter(s => s.status === 'success' && s.result).map((step, stepIdx) => {
const r = step.result as any;
const pVal = r?.p_value ?? r?.pValue;
const pFmt = r?.p_value_fmt || (pVal !== undefined ? formatPValue(pVal) : undefined);
const isDescriptive = step.tool_code === 'ST_DESCRIPTIVE' || r?.method === '描述性统计';
return (
<div key={step.step_number} className="workflow-step-result">
<h4 className="table-label">
{step.step_number}. {step.tool_name}
{step.duration_ms && <span className="step-duration-badge"> {step.duration_ms}ms</span>}
</h4>
{/* 描述性统计 - 专用渲染 */}
{isDescriptive ? (
<DescriptiveResultView result={r} />
) : (
<>
{/* 非描述性统计 - 统计量汇总 */}
<div className="result-stats-grid">
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{r?.method || step.tool_name}</div>
</div>
{r?.statistic !== undefined && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{Number(r.statistic).toFixed(4)}</div>
</div>
)}
{pVal !== undefined && (
<div className="stat-card highlight">
<div className="stat-label">P </div>
<div className={`stat-value ${pVal < 0.05 ? 'significant' : ''}`}>
{pFmt}
</div>
</div>
)}
{r?.effect_size !== undefined && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">
{typeof r.effect_size === 'object'
? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ')
: Number(r.effect_size).toFixed(3)}
</div>
</div>
)}
{r?.conf_int && (
<div className="stat-card">
<div className="stat-label">95% CI</div>
<div className="stat-value">[{r.conf_int.map((v: number) => v.toFixed(3)).join(', ')}]</div>
</div>
)}
{r?.coefficients && !Array.isArray(r.coefficients) && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{Object.keys(r.coefficients).length}</div>
</div>
)}
</div>
{/* 分组统计表 */}
{r?.group_stats?.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr><th></th><th>N</th><th></th><th></th></tr>
</thead>
<tbody>
{r.group_stats.map((g: any, i: number) => (
<tr key={i}>
<td>{g.group}</td>
<td>{g.n}</td>
<td>{g.mean !== undefined ? Number(g.mean).toFixed(4) : '-'}</td>
<td>{g.sd !== undefined ? Number(g.sd).toFixed(4) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Logistic 回归系数表 */}
{r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr><th></th><th></th><th>OR</th><th>95% CI</th><th>P </th></tr>
</thead>
<tbody>
{r.coefficients.map((c: any, i: number) => (
<tr key={i}>
<td>{c.variable || c.term}</td>
<td>{Number(c.estimate || c.coef || 0).toFixed(4)}</td>
<td>{c.OR !== undefined ? Number(c.OR).toFixed(4) : '-'}</td>
<td>{c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-'}</td>
<td className={isPValue(c.p_value) ? 'p-value' : ''}>{c.p_value_fmt || formatPValue(c.p_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 详细表格数据 result_table */}
{r?.result_table && (
<div className="result-table-section">
<div className="sci-table-wrapper">
<table className="sci-table">
<thead><tr>{r.result_table.headers.map((h: string, i: number) => <th key={i}>{h}</th>)}</tr></thead>
<tbody>{r.result_table.rows.map((row: any[], i: number) => (
<tr key={i}>{row.map((cell, j) => (
<td key={j} className={isPValue(cell) ? 'p-value' : ''}>{formatCell(cell)}</td>
))}</tr>
))}</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* 图表 - 所有类型通用 */}
{r?.plots?.length > 0 && (
<div className="result-chart-section">
<h4 className="chart-label">Figure {stepIdx + 1}. </h4>
{r.plots.map((plot: any, plotIdx: number) => (
<ChartImage key={plotIdx} plot={
typeof plot === 'string'
? { type: 'chart', title: '统计图表', imageBase64: plot }
: plot
} />
))}
</div>
)}
</div>
);
})}
{/* 综合执行时间 */}
<div className="execution-meta">
<span>: {workflowSteps.reduce((sum, s) => sum + (s.duration_ms || 0), 0)}ms</span>
</div>
</div>
) : analysisResult && (
<div className="view-result fade-in">
{/* AI 解读 */}
<div className="result-summary">
@@ -519,6 +797,7 @@ export const SSAWorkspacePane: React.FC = () => {
<span>: {analysisResult.executionMs}ms</span>
</div>
</div>
)}
</div>
)}
@@ -652,4 +931,189 @@ const TraceLogItem: React.FC<{ step: TraceStep; index: number }> = ({ step, inde
);
};
/**
* 描述性统计专用结果展示组件
* R 服务返回: { summary: { n_total, n_variables, n_numeric, n_categorical }, variables: { varName: { ...stats } } }
* 数值型变量: { variable, type, n, mean, sd, median, q1, q3, min, max, formatted, missing }
* 分类型变量: { variable, type, n, missing, levels: [{ level, n, pct, formatted }] }
* 分组模式: { variable, type, by_group: { groupName: { ...stats } } }
*/
const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => {
if (!result) return null;
const summary = result.summary;
const variables = result.variables;
const classifyVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
if (!v) return 'unknown';
if (v.type === 'numeric') return 'numeric';
if (v.type === 'categorical') return 'categorical';
if (v.mean !== undefined || v.sd !== undefined) return 'numeric';
if (v.levels && Array.isArray(v.levels)) return 'categorical';
if (v.by_group) {
const firstGroup = Object.values(v.by_group)[0] as any;
if (firstGroup?.mean !== undefined) return 'numeric';
if (firstGroup?.levels) return 'categorical';
}
return 'unknown';
};
const varEntries = variables && typeof variables === 'object' && !Array.isArray(variables)
? Object.entries(variables)
: [];
const numericVars = varEntries.filter(([, v]) => classifyVar(v) === 'numeric');
const catVars = varEntries.filter(([, v]) => classifyVar(v) === 'categorical');
const getNumericStats = (vs: any) => {
if (vs.mean !== undefined) return vs;
if (vs.by_group) {
const groups = Object.entries(vs.by_group);
return { variable: vs.variable, n: groups.reduce((s, [, g]: [string, any]) => s + (g.n || 0), 0), by_group: vs.by_group };
}
return vs;
};
return (
<>
{summary && (
<div className="result-stats-grid">
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{summary.n_total ?? '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{summary.n_variables ?? '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{summary.n_numeric ?? '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{summary.n_categorical ?? '-'}</div>
</div>
</div>
)}
{numericVars.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr>
<th></th>
<th>N</th>
<th> ± </th>
<th></th>
<th>Q1</th>
<th>Q3</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{numericVars.map(([varName, rawVs]) => {
const vs = getNumericStats(rawVs as any);
if (vs.by_group) {
return Object.entries(vs.by_group).map(([gName, gStats]: [string, any]) => (
<tr key={`${varName}-${gName}`}>
<td>{vs.variable || varName} [{gName}]</td>
<td>{gStats.n ?? '-'}</td>
<td>{gStats.formatted || (gStats.mean !== undefined ? `${gStats.mean} ± ${gStats.sd}` : '-')}</td>
<td>{gStats.median ?? '-'}</td>
<td>{gStats.q1 ?? '-'}</td>
<td>{gStats.q3 ?? '-'}</td>
<td>{gStats.min ?? '-'}</td>
<td>{gStats.max ?? '-'}</td>
<td>{gStats.missing ?? 0}</td>
</tr>
));
}
return (
<tr key={varName}>
<td>{vs.variable || varName}</td>
<td>{vs.n ?? '-'}</td>
<td>{vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-')}</td>
<td>{vs.median ?? '-'}</td>
<td>{vs.q1 ?? '-'}</td>
<td>{vs.q3 ?? '-'}</td>
<td>{vs.min ?? '-'}</td>
<td>{vs.max ?? '-'}</td>
<td>{vs.missing ?? 0}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{catVars.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr>
<th></th>
<th>N</th>
<th></th>
<th> (%)</th>
<th></th>
</tr>
</thead>
<tbody>
{catVars.flatMap(([varName, rawVs]) => {
const vs = rawVs as any;
if (vs.by_group) {
return Object.entries(vs.by_group).flatMap(([gName, gStats]: [string, any]) => {
const levels = gStats.levels || [];
if (levels.length === 0) {
return [<tr key={`${varName}-${gName}`}><td>{vs.variable || varName} [{gName}]</td><td>{gStats.n ?? '-'}</td><td>-</td><td>-</td><td>{gStats.missing ?? 0}</td></tr>];
}
return levels.map((lv: any, i: number) => (
<tr key={`${varName}-${gName}-${i}`}>
{i === 0 && <td rowSpan={levels.length}>{vs.variable || varName} [{gName}]</td>}
{i === 0 && <td rowSpan={levels.length}>{gStats.n ?? '-'}</td>}
<td>{lv.level}</td>
<td>{lv.formatted || `${lv.n} (${lv.pct}%)`}</td>
{i === 0 && <td rowSpan={levels.length}>{gStats.missing ?? 0}</td>}
</tr>
));
});
}
const levels = vs.levels || [];
if (levels.length === 0) {
return [<tr key={varName}><td>{vs.variable || varName}</td><td>{vs.n ?? '-'}</td><td>-</td><td>-</td><td>{vs.missing ?? 0}</td></tr>];
}
return levels.map((lv: any, i: number) => (
<tr key={`${varName}-${i}`}>
{i === 0 && <td rowSpan={levels.length}>{vs.variable || varName}</td>}
{i === 0 && <td rowSpan={levels.length}>{vs.n ?? '-'}</td>}
<td>{lv.level}</td>
<td>{lv.formatted || `${lv.n} (${lv.pct}%)`}</td>
{i === 0 && <td rowSpan={levels.length}>{vs.missing ?? 0}</td>}
</tr>
));
})}
</tbody>
</table>
</div>
</div>
)}
{varEntries.length === 0 && !summary && (
<div className="result-raw-data">
<pre className="raw-json">{JSON.stringify(result, null, 2)}</pre>
</div>
)}
</>
);
};
export default SSAWorkspacePane;

View File

@@ -0,0 +1,163 @@
/**
* 步骤执行进度卡片组件
*
* Phase 2A: 在执行日志区域显示每个步骤的详细进度和日志
*/
import React, { useState } from 'react';
import type { WorkflowStepResult, WorkflowStepStatus } from '../types';
interface StepProgressCardProps {
step: WorkflowStepResult;
isExpanded?: boolean;
onToggle?: () => void;
}
const statusConfig: Record<WorkflowStepStatus, {
icon: string;
label: string;
color: string;
bg: string;
}> = {
pending: { icon: '⏳', label: '等待中', color: '#94a3b8', bg: '#f1f5f9' },
running: { icon: '⚡', label: '执行中', color: '#2563eb', bg: '#eff6ff' },
success: { icon: '✅', label: '成功', color: '#059669', bg: '#ecfdf5' },
failed: { icon: '❌', label: '失败', color: '#dc2626', bg: '#fef2f2' },
skipped: { icon: '⏭️', label: '跳过', color: '#94a3b8', bg: '#f8fafc' },
warning: { icon: '⚠️', label: '警告', color: '#d97706', bg: '#fffbeb' },
};
export const StepProgressCard: React.FC<StepProgressCardProps> = ({
step,
isExpanded = false,
onToggle,
}) => {
const [expanded, setExpanded] = useState(isExpanded);
const config = statusConfig[step.status];
const handleToggle = () => {
setExpanded(!expanded);
onToggle?.();
};
const formatTime = (isoString?: string) => {
if (!isoString) return '-';
return new Date(isoString).toLocaleTimeString('zh-CN');
};
return (
<div
className={`step-progress-card ${step.status} ${expanded ? 'expanded' : ''}`}
style={{ borderLeftColor: config.color }}
>
<div className="card-header" onClick={handleToggle}>
<div className="header-left">
<span
className={`status-icon ${step.status === 'running' ? 'spin' : ''}`}
>
{config.icon}
</span>
<span className="step-info">
<span className="step-number"> {step.step_number}</span>
<span className="tool-name">{step.tool_name}</span>
</span>
</div>
<div className="header-right">
<span
className="status-badge"
style={{ backgroundColor: config.bg, color: config.color }}
>
{config.label}
</span>
{step.duration_ms && (
<span className="duration">{step.duration_ms}ms</span>
)}
<span className={`expand-icon ${expanded ? 'rotated' : ''}`}></span>
</div>
</div>
{expanded && (
<div className="card-body">
{/* 时间信息 */}
<div className="time-info">
<span>: {formatTime(step.started_at)}</span>
{step.completed_at && (
<span>: {formatTime(step.completed_at)}</span>
)}
</div>
{/* 执行日志 */}
{step.logs.length > 0 && (
<div className="logs-section">
<div className="logs-header">📋 </div>
<div className="logs-content">
{step.logs.map((log, idx) => (
<div key={idx} className="log-line">
<span className="log-prefix">{'>'}</span>
<span className="log-text">{log}</span>
</div>
))}
</div>
</div>
)}
{/* 结果预览 */}
{step.status === 'success' && step.result && (
<div className="result-preview">
<div className="result-header">📊 </div>
<div className="result-content">
{step.result.method && (
<div className="result-row">
<span className="label">:</span>
<span className="value">{step.result.method}</span>
</div>
)}
{step.result.statistic !== undefined && (
<div className="result-row">
<span className="label">:</span>
<span className="value">{step.result.statistic.toFixed(4)}</span>
</div>
)}
{step.result.p_value !== undefined && (
<div className="result-row">
<span className="label">P值:</span>
<span
className="value"
style={{
color: step.result.p_value < 0.05 ? '#059669' : '#64748b',
fontWeight: step.result.p_value < 0.05 ? 600 : 400,
}}
>
{step.result.p_value < 0.001 ? '< 0.001' : step.result.p_value.toFixed(4)}
{step.result.p_value < 0.05 && ' *'}
</span>
</div>
)}
{step.result.effect_size !== undefined && (
<div className="result-row">
<span className="label">:</span>
<span className="value">{step.result.effect_size.toFixed(4)}</span>
</div>
)}
{step.result.interpretation && (
<div className="interpretation">
{step.result.interpretation}
</div>
)}
</div>
</div>
)}
{/* 错误信息 */}
{step.status === 'failed' && step.error && (
<div className="error-section">
<div className="error-header"> </div>
<div className="error-content">{step.error}</div>
</div>
)}
</div>
)}
</div>
);
};
export default StepProgressCard;

View File

@@ -0,0 +1,184 @@
/**
* 多步骤工作流时间线组件
*
* Phase 2A: 在工作区显示多步骤分析计划的垂直时间线
*/
import React from 'react';
import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types';
interface WorkflowTimelineProps {
plan: WorkflowPlan;
stepResults?: WorkflowStepResult[];
currentStep?: number;
isExecuting?: boolean;
}
const statusConfig: Record<WorkflowStepStatus | 'pending', {
icon: string;
color: string;
bg: string;
animation?: string;
}> = {
pending: { icon: '○', color: '#94a3b8', bg: '#f1f5f9' },
running: { icon: '◎', color: '#2563eb', bg: '#eff6ff', animation: 'pulse' },
success: { icon: '✓', color: '#059669', bg: '#ecfdf5' },
failed: { icon: '✕', color: '#dc2626', bg: '#fef2f2' },
skipped: { icon: '⊘', color: '#94a3b8', bg: '#f8fafc' },
warning: { icon: '!', color: '#d97706', bg: '#fffbeb' },
};
const toolIcons: Record<string, string> = {
't_test': '📊',
'welch_t_test': '📊',
'paired_t_test': '🔗',
'mann_whitney_u': '📈',
'wilcoxon_signed_rank': '📈',
'chi_square_test': '📋',
'fisher_exact_test': '📋',
'one_way_anova': '📉',
'kruskal_wallis': '📉',
'pearson_correlation': '🔄',
'spearman_correlation': '🔄',
'logistic_regression': '📐',
'default': '🔬',
};
const getToolIcon = (toolCode: string): string => {
return toolIcons[toolCode] || toolIcons.default;
};
interface StepItemProps {
step: WorkflowStepDef;
result?: WorkflowStepResult;
isLast: boolean;
isCurrent: boolean;
}
const StepItem: React.FC<StepItemProps> = ({ step, result, isLast, isCurrent }) => {
const status = result?.status || 'pending';
const config = statusConfig[status];
return (
<div className={`timeline-step ${status} ${isCurrent ? 'current' : ''}`}>
<div className="step-connector">
<div
className={`step-dot ${config.animation || ''}`}
style={{ backgroundColor: config.bg, borderColor: config.color }}
>
<span style={{ color: config.color }}>{config.icon}</span>
</div>
{!isLast && <div className="step-line" style={{ borderColor: result?.status === 'success' ? '#059669' : '#e2e8f0' }} />}
</div>
<div className="step-content">
<div className="step-header">
<span className="step-number"> {step.step_number}</span>
<span className="tool-icon">{getToolIcon(step.tool_code)}</span>
<span className="tool-name">{step.tool_name}</span>
{result?.duration_ms && (
<span className="step-duration">{result.duration_ms}ms</span>
)}
</div>
<div className="step-description">{step.description}</div>
{step.params && Object.keys(step.params).length > 0 && (
<div className="step-params">
{Object.entries(step.params).slice(0, 3).map(([key, value]) => (
<span key={key} className="param-tag">
{key}: {String(value)}
</span>
))}
</div>
)}
{result?.status === 'success' && result.result?.p_value !== undefined && (
<div className="step-result-preview">
<span className="result-badge">
p = {result.result.p_value < 0.001 ? '< 0.001' : result.result.p_value.toFixed(4)}
</span>
{result.result.p_value < 0.05 && (
<span className="significant-badge"> *</span>
)}
</div>
)}
{result?.status === 'failed' && result.error && (
<div className="step-error">
<span className="error-icon"></span>
<span className="error-message">{result.error}</span>
</div>
)}
</div>
</div>
);
};
export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
plan,
stepResults = [],
currentStep,
isExecuting = false,
}) => {
const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => {
return stepResults.find(r => r.step_number === stepNumber);
};
const completedSteps = stepResults.filter(r => r.status === 'success').length;
const progress = plan.total_steps > 0 ? (completedSteps / plan.total_steps) * 100 : 0;
return (
<div className="workflow-timeline">
<div className="timeline-header">
<div className="header-info">
<h3 className="timeline-title">{plan.title}</h3>
<p className="timeline-description">{plan.description}</p>
</div>
<div className="header-meta">
<span className="step-count">
{plan.total_steps}
</span>
{plan.estimated_time_seconds && (
<span className="estimated-time">
{Math.ceil(plan.estimated_time_seconds / 60)}
</span>
)}
</div>
</div>
{isExecuting && (
<div className="timeline-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="progress-text">
{completedSteps}/{plan.total_steps}
</span>
</div>
)}
<div className="timeline-steps">
{plan.steps.map((step, index) => (
<StepItem
key={step.step_number}
step={step}
result={getStepResult(step.step_number)}
isLast={index === plan.steps.length - 1}
isCurrent={currentStep === step.step_number}
/>
))}
</div>
{!isExecuting && stepResults.length === 0 && (
<div className="timeline-footer">
<span className="ready-hint"> </span>
</div>
)}
</div>
);
};
export default WorkflowTimeline;

View File

@@ -11,3 +11,10 @@ export { default as SSAWorkspacePane } from './SSAWorkspacePane';
export { default as SSACodeModal } from './SSACodeModal';
export { default as SSAToast } from './SSAToast';
export { default as TypeWriter } from './TypeWriter';
// Phase 2A: 多步骤工作流组件
export { DataProfileCard } from './DataProfileCard';
export { DataProfileModal } from './DataProfileModal';
export { WorkflowTimeline } from './WorkflowTimeline';
export { StepProgressCard } from './StepProgressCard';
export { ConclusionReport } from './ConclusionReport';

View File

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

View File

@@ -327,6 +327,11 @@ export function useAnalysis(): UseAnalysisReturn {
const plan = useSSAStore.getState().currentPlan;
const session = useSSAStore.getState().currentSession;
const mountedFile = useSSAStore.getState().mountedFile;
const { isWorkflowMode, workflowSteps, workflowPlan, conclusionReport } = useSSAStore.getState();
if (isWorkflowMode && workflowSteps.some(s => s.status === 'success')) {
return exportWorkflowReport(workflowSteps, workflowPlan, conclusionReport, session, mountedFile);
}
if (!result) {
setError('暂无分析结果可导出');
@@ -563,6 +568,295 @@ export function useAnalysis(): UseAnalysisReturn {
URL.revokeObjectURL(url);
}, [setError]);
const exportWorkflowReport = async (
steps: any[],
wfPlan: any,
conclusion: any,
session: any,
mountedFile: any
) => {
const now = new Date();
const dateStr = now.toLocaleString('zh-CN');
const dataFileName = mountedFile?.name || session?.title || '数据文件';
const createTableRow = (cells: string[], isHeader = false) => {
return new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: String(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(wfPlan?.title || session?.title || '统计分析'),
]}),
new Paragraph({ children: [
new TextRun({ text: '数据文件:', bold: true }),
new TextRun(dataFileName),
]}),
new Paragraph({ children: [
new TextRun({ text: '生成时间:', bold: true }),
new TextRun(dateStr),
]}),
new Paragraph({ children: [
new TextRun({ text: '分析步骤:', bold: true }),
new TextRun(`${steps.length} 个步骤`),
]}),
new Paragraph({ text: '' }),
);
if (conclusion?.executive_summary) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 摘要`, heading: HeadingLevel.HEADING_1 }),
new Paragraph({ text: conclusion.executive_summary }),
new Paragraph({ text: '' }),
);
}
const successSteps = steps.filter(s => s.status === 'success' && s.result);
for (const step of successSteps) {
const r = step.result as any;
sections.push(
new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }),
);
if (step.duration_ms) {
sections.push(new Paragraph({ children: [
new TextRun({ text: `执行耗时:${step.duration_ms}ms`, italics: true, color: '666666' }),
]}));
}
const isDescStep = step.tool_code === 'ST_DESCRIPTIVE' || r?.summary;
if (isDescStep && (r?.summary || r?.variables)) {
if (r?.summary) {
sections.push(
new Paragraph({ text: `总观测数: ${r.summary.n_total ?? '-'}, 分析变量数: ${r.summary.n_variables ?? '-'}, 数值变量: ${r.summary.n_numeric ?? '-'}, 分类变量: ${r.summary.n_categorical ?? '-'}` }),
new Paragraph({ text: '' }),
);
}
if (r?.variables && typeof r.variables === 'object') {
const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
if (!v) return 'unknown';
if (v.type === 'numeric') return 'numeric';
if (v.type === 'categorical') return 'categorical';
if (v.mean !== undefined) return 'numeric';
if (v.levels && Array.isArray(v.levels)) return 'categorical';
if (v.by_group) {
const first = Object.values(v.by_group)[0] as any;
if (first?.mean !== undefined) return 'numeric';
if (first?.levels) return 'categorical';
}
return 'unknown';
};
const varEntries = Object.entries(r.variables);
const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric');
const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical');
if (numericVars.length > 0) {
const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)];
for (const [varName, rawVs] of numericVars) {
const vs = rawVs as any;
if (vs.by_group) {
for (const [gName, gStats] of Object.entries(vs.by_group)) {
const g = gStats as any;
numRows.push(createTableRow([
`${vs.variable || varName} [${gName}]`, String(g.n ?? '-'),
g.formatted || (g.mean !== undefined ? `${g.mean} ± ${g.sd}` : '-'),
String(g.median ?? '-'), String(g.q1 ?? '-'), String(g.q3 ?? '-'),
String(g.min ?? '-'), String(g.max ?? '-'),
]));
}
} else {
numRows.push(createTableRow([
vs.variable || varName, String(vs.n ?? '-'),
vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-'),
String(vs.median ?? '-'), String(vs.q1 ?? '-'), String(vs.q3 ?? '-'),
String(vs.min ?? '-'), String(vs.max ?? '-'),
]));
}
}
sections.push(
new Paragraph({ text: '数值变量统计', heading: HeadingLevel.HEADING_2 }),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: numRows }),
new Paragraph({ text: '' }),
);
}
if (catVars.length > 0) {
sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 }));
for (const [varName, rawVs] of catVars) {
const vs = rawVs as any;
if (vs.by_group) {
for (const [gName, gStats] of Object.entries(vs.by_group)) {
const g = gStats as any;
const levels = g.levels || [];
if (levels.length > 0) {
sections.push(
new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName} [${gName}]`, bold: true }), new TextRun(` (N=${g.n ?? '-'})`)] }),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [
createTableRow(['类别', '频数', '百分比'], true),
...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])),
]}),
new Paragraph({ text: '' }),
);
}
}
} else {
const levels = vs.levels || [];
if (levels.length > 0) {
sections.push(
new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName}`, bold: true }), new TextRun(` (N=${vs.n ?? '-'}, 缺失=${vs.missing ?? 0})`)] }),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [
createTableRow(['类别', '频数', '百分比'], true),
...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])),
]}),
new Paragraph({ text: '' }),
);
}
}
}
}
}
} else {
const statsRows = [
createTableRow(['指标', '值'], true),
createTableRow(['统计方法', r?.method || step.tool_name]),
];
if (r?.statistic !== undefined) statsRows.push(createTableRow(['统计量', Number(r.statistic).toFixed(4)]));
if (r?.p_value !== undefined) statsRows.push(createTableRow(['P 值', r.p_value_fmt || (r.p_value < 0.001 ? '< 0.001' : Number(r.p_value).toFixed(4))]));
if (r?.effect_size !== undefined) {
const esStr = typeof r.effect_size === 'object'
? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ')
: Number(r.effect_size).toFixed(3);
statsRows.push(createTableRow(['效应量', esStr]));
}
if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`]));
sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows }));
sections.push(new Paragraph({ text: '' }));
}
if (r?.group_stats?.length > 0) {
sections.push(
new Paragraph({ text: '分组统计', heading: HeadingLevel.HEADING_2 }),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['分组', 'N', '均值', '标准差'], true),
...r.group_stats.map((g: any) => createTableRow([
String(g.group), String(g.n),
g.mean !== undefined ? Number(g.mean).toFixed(4) : '-',
g.sd !== undefined ? Number(g.sd).toFixed(4) : '-',
])),
],
}),
new Paragraph({ text: '' }),
);
}
if (r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0) {
sections.push(
new Paragraph({ text: '回归系数', heading: HeadingLevel.HEADING_2 }),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['变量', '估计值', 'OR', '95% CI', 'P 值'], true),
...r.coefficients.map((c: any) => createTableRow([
c.variable || c.term || '-',
Number(c.estimate || c.coef || 0).toFixed(4),
c.OR !== undefined ? Number(c.OR).toFixed(4) : '-',
c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-',
c.p_value_fmt || (c.p_value !== undefined ? (c.p_value < 0.001 ? '< 0.001' : Number(c.p_value).toFixed(4)) : '-'),
])),
],
}),
new Paragraph({ text: '' }),
);
}
if (r?.plots?.length > 0) {
for (const plot of r.plots) {
const imageBase64 = typeof plot === 'string' ? plot : plot.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({
children: [new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' })],
alignment: AlignmentType.CENTER,
}),
new Paragraph({ text: '' }),
);
} catch (e) { /* skip */ }
}
}
}
}
if (conclusion?.key_findings?.length > 0) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 主要发现`, heading: HeadingLevel.HEADING_1 }),
...conclusion.key_findings.map((f: string) => new Paragraph({ text: `${f}` })),
new Paragraph({ text: '' }),
);
}
if (conclusion?.recommendations?.length > 0) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }),
...conclusion.recommendations.map((r: string) => new Paragraph({ text: `${r}` })),
new Paragraph({ text: '' }),
);
}
sections.push(
new Paragraph({ text: '' }),
new Paragraph({ children: [
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
]}),
new Paragraph({ children: [
new TextRun({ text: `总执行耗时: ${steps.reduce((s, st) => s + (st.duration_ms || 0), 0)}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);
};
return {
uploadData,
generatePlan,

View File

@@ -0,0 +1,376 @@
/**
* 多步骤工作流 Hook
*
* Phase 2A: 处理数据画像、工作流规划、SSE 执行等
*/
import { useCallback, useRef } from 'react';
import apiClient from '@/common/api/axios';
import { getAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type {
DataProfile,
WorkflowPlan,
WorkflowStepResult,
SSEMessage,
SSAMessage,
} from '../types';
const API_BASE = '/api/v1/ssa';
interface UseWorkflowReturn {
generateDataProfile: (sessionId: string) => Promise<DataProfile>;
generateWorkflowPlan: (sessionId: string, query: string) => Promise<WorkflowPlan>;
executeWorkflow: (sessionId: string, workflowId: string) => Promise<void>;
cancelWorkflow: () => void;
isProfileLoading: boolean;
isPlanLoading: boolean;
isExecuting: boolean;
}
export function useWorkflow(): UseWorkflowReturn {
const {
setDataProfile,
setDataProfileLoading,
dataProfileLoading,
setWorkflowPlan,
setWorkflowPlanLoading,
workflowPlanLoading,
setWorkflowSteps,
updateWorkflowStep,
setWorkflowProgress,
setConclusionReport,
setIsWorkflowMode,
setActivePane,
setWorkspaceOpen,
addMessage,
setExecuting,
isExecuting,
setError,
addToast,
} = useSSAStore();
const abortControllerRef = useRef<AbortController | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const generateDataProfile = useCallback(async (sessionId: string): Promise<DataProfile> => {
setDataProfileLoading(true);
setError(null);
try {
const response = await apiClient.post(`${API_BASE}/workflow/profile`, { sessionId });
const profile: DataProfile = response.data.profile;
setDataProfile(profile);
const profileMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: `数据质量核查完成:${profile.quality_grade}级 (${profile.quality_score}分)`,
artifactType: 'sap',
createdAt: new Date().toISOString(),
};
addMessage(profileMessage);
return profile;
} catch (error: any) {
const errorMsg = error.response?.data?.message || error.message || '数据画像生成失败';
setError(errorMsg);
addToast(errorMsg, 'error');
throw error;
} finally {
setDataProfileLoading(false);
}
}, [setDataProfile, setDataProfileLoading, setError, addMessage, addToast]);
const generateWorkflowPlan = useCallback(async (
sessionId: string,
query: string
): Promise<WorkflowPlan> => {
setWorkflowPlanLoading(true);
setError(null);
setIsWorkflowMode(true);
try {
const userMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
};
addMessage(userMessage);
const response = await apiClient.post(`${API_BASE}/workflow/plan`, {
sessionId,
userQuery: query
});
const plan: WorkflowPlan = response.data.plan;
setWorkflowPlan(plan);
setActivePane('sap');
setWorkspaceOpen(true);
const planMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: `已生成分析方案:${plan.title}\n共 ${plan.total_steps} 个分析步骤`,
artifactType: 'sap',
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: any) {
const errorMsg = error.response?.data?.message || error.message || '工作流规划失败';
setError(errorMsg);
addToast(errorMsg, 'error');
throw error;
} finally {
setWorkflowPlanLoading(false);
}
}, [
setWorkflowPlan,
setWorkflowPlanLoading,
setIsWorkflowMode,
setActivePane,
setWorkspaceOpen,
addMessage,
setError,
addToast
]);
const executeWorkflow = useCallback(async (
sessionId: string,
workflowId: string
): Promise<void> => {
setExecuting(true);
setActivePane('execution');
setWorkflowSteps([]);
setWorkflowProgress(0);
setConclusionReport(null);
setError(null);
const token = getAccessToken();
return new Promise((resolve, reject) => {
const streamUrl = `${API_BASE}/workflow/${workflowId}/stream`;
abortControllerRef.current = new AbortController();
fetch(streamUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'text/event-stream',
},
signal: abortControllerRef.current.signal,
}).then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
const processLine = (line: string) => {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trim();
if (jsonStr) {
try {
const message: SSEMessage = JSON.parse(jsonStr);
handleSSEMessage(message);
} catch (e) {
console.warn('Failed to parse SSE message:', jsonStr);
}
}
}
};
const handleSSEMessage = (message: SSEMessage) => {
// 兼容后端的驼峰命名和顶层字段
const toolCode = message.toolCode || message.data?.tool_code || '';
const toolName = message.toolName || message.data?.tool_name || '';
const stepNumber = message.step;
switch (message.type) {
case 'step_start':
if (stepNumber !== undefined) {
const stepResult: WorkflowStepResult = {
step_number: stepNumber,
tool_code: toolCode,
tool_name: toolName,
status: 'running',
started_at: new Date().toISOString(),
logs: message.message ? [message.message] : [],
};
const currentSteps = useSSAStore.getState().workflowSteps;
// 避免重复添加
if (!currentSteps.some(s => s.step_number === stepNumber)) {
setWorkflowSteps([...currentSteps, stepResult]);
}
}
break;
case 'step_progress':
if (stepNumber !== undefined && message.message) {
updateWorkflowStep(stepNumber, {
logs: (useSSAStore.getState().workflowSteps
.find(s => s.step_number === stepNumber)?.logs || [])
.concat(message.message),
});
}
break;
case 'step_complete':
if (stepNumber !== undefined) {
const result = message.result || message.data?.result;
const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms;
updateWorkflowStep(stepNumber, {
status: message.status || message.data?.status || 'success',
completed_at: new Date().toISOString(),
duration_ms: durationMs,
result: result,
});
const totalSteps = message.total_steps || message.totalSteps || 2;
const progress = (stepNumber / totalSteps) * 100;
setWorkflowProgress(progress);
}
break;
case 'step_error':
if (stepNumber !== undefined) {
updateWorkflowStep(stepNumber, {
status: 'failed',
completed_at: new Date().toISOString(),
error: message.error || message.message,
});
}
break;
case 'workflow_complete':
setWorkflowProgress(100);
setExecuting(false);
if (message.conclusion) {
setConclusionReport(message.conclusion);
setActivePane('result');
const completeMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: `分析完成!${message.conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`,
artifactType: 'result',
createdAt: new Date().toISOString(),
};
addMessage(completeMessage);
} else {
// 即使没有 conclusion也标记为完成
const completeMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '分析执行完成!',
artifactType: 'result',
createdAt: new Date().toISOString(),
};
addMessage(completeMessage);
}
addToast('工作流执行完成', 'success');
resolve();
break;
case 'workflow_error':
const errorMsg = message.error || '工作流执行失败';
setError(errorMsg);
addToast(errorMsg, 'error');
setExecuting(false);
reject(new Error(errorMsg));
break;
case 'connected':
// 连接确认消息,忽略
break;
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
processLine(line);
}
}
if (buffer) {
processLine(buffer);
}
}).catch((error) => {
if (error.name === 'AbortError') {
addToast('工作流已取消', 'info');
resolve();
} else {
const errorMsg = error.message || '工作流执行失败';
setError(errorMsg);
addToast(errorMsg, 'error');
reject(error);
}
setExecuting(false);
});
});
}, [
setExecuting,
setActivePane,
setWorkflowSteps,
setWorkflowProgress,
setConclusionReport,
updateWorkflowStep,
addMessage,
setError,
addToast
]);
const cancelWorkflow = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setExecuting(false);
}, [setExecuting]);
return {
generateDataProfile,
generateWorkflowPlan,
executeWorkflow,
cancelWorkflow,
isProfileLoading: dataProfileLoading,
isPlanLoading: workflowPlanLoading,
isExecuting,
};
}
export default useWorkflow;

View File

@@ -12,6 +12,10 @@ import type {
AnalysisPlan,
ExecutionResult,
TraceStep,
DataProfile,
WorkflowPlan,
WorkflowStepResult,
ConclusionReport,
} from '../types';
type ArtifactPane = 'empty' | 'sap' | 'execution' | 'result';
@@ -61,6 +65,17 @@ interface SSAState {
analysisHistory: AnalysisRecord[];
currentRecordId: string | null;
// Phase 2A: 多步骤工作流状态
dataProfile: DataProfile | null;
dataProfileLoading: boolean;
dataProfileModalVisible: boolean;
workflowPlan: WorkflowPlan | null;
workflowPlanLoading: boolean;
workflowSteps: WorkflowStepResult[];
workflowProgress: number; // 0-100
conclusionReport: ConclusionReport | null;
isWorkflowMode: boolean; // 是否使用多步骤工作流模式
setMode: (mode: SSAMode) => void;
setCurrentSession: (session: SSASession | null) => void;
addMessage: (message: SSAMessage) => void;
@@ -89,6 +104,19 @@ interface SSAState {
updateAnalysisRecord: (id: string, update: Partial<Omit<AnalysisRecord, 'id'>>) => void;
selectAnalysisRecord: (id: string) => void;
getCurrentRecord: () => AnalysisRecord | null;
// Phase 2A: 多步骤工作流操作
setDataProfile: (profile: DataProfile | null) => void;
setDataProfileLoading: (loading: boolean) => void;
setDataProfileModalVisible: (visible: boolean) => void;
setWorkflowPlan: (plan: WorkflowPlan | null) => void;
setWorkflowPlanLoading: (loading: boolean) => void;
setWorkflowSteps: (steps: WorkflowStepResult[]) => void;
updateWorkflowStep: (stepNumber: number, update: Partial<WorkflowStepResult>) => void;
setWorkflowProgress: (progress: number) => void;
setConclusionReport: (report: ConclusionReport | null) => void;
setIsWorkflowMode: (isWorkflow: boolean) => void;
resetWorkflow: () => void;
}
const initialState = {
@@ -109,6 +137,16 @@ const initialState = {
toasts: [] as Toast[],
analysisHistory: [] as AnalysisRecord[],
currentRecordId: null as string | null,
// Phase 2A: 多步骤工作流初始状态
dataProfile: null as DataProfile | null,
dataProfileLoading: false,
dataProfileModalVisible: false,
workflowPlan: null as WorkflowPlan | null,
workflowPlanLoading: false,
workflowSteps: [] as WorkflowStepResult[],
workflowProgress: 0,
conclusionReport: null as ConclusionReport | null,
isWorkflowMode: false,
};
export const useSSAStore = create<SSAState>((set) => ({
@@ -266,6 +304,44 @@ export const useSSAStore = create<SSAState>((set) => ({
getCurrentRecord: (): AnalysisRecord | null => {
return null; // 此方法在组件中通过直接访问 state 实现
},
// Phase 2A: 多步骤工作流操作
setDataProfile: (profile) => set({ dataProfile: profile }),
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),
setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }),
setWorkflowPlan: (plan) => set({ workflowPlan: plan }),
setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }),
setWorkflowSteps: (steps) => set({ workflowSteps: steps }),
updateWorkflowStep: (stepNumber, update) =>
set((state) => ({
workflowSteps: state.workflowSteps.map((s) =>
s.step_number === stepNumber ? { ...s, ...update } : s
),
})),
setWorkflowProgress: (progress) => set({ workflowProgress: progress }),
setConclusionReport: (report) => set({ conclusionReport: report }),
setIsWorkflowMode: (isWorkflow) => set({ isWorkflowMode: isWorkflow }),
resetWorkflow: () =>
set({
dataProfile: null,
dataProfileLoading: false,
workflowPlan: null,
workflowPlanLoading: false,
workflowSteps: [],
workflowProgress: 0,
conclusionReport: null,
isWorkflowMode: false,
}),
}));
export default useSSAStore;

File diff suppressed because it is too large Load Diff

View File

@@ -168,3 +168,162 @@ export interface TraceStep {
message: string;
durationMs?: number;
}
// ============================================
// Phase 2A: 多步骤工作流类型定义
// ============================================
/** 数据质量等级 */
export type DataQualityGrade = 'A' | 'B' | 'C' | 'D';
/** 列级数据画像 */
export interface ColumnProfile {
name: string;
dtype: string;
inferred_type: 'numeric' | 'categorical' | 'datetime' | 'text';
non_null_count: number;
null_count: number;
null_ratio: number;
unique_count: number;
unique_ratio: number;
sample_values: (string | number | null)[];
// 数值型额外字段
mean?: number;
std?: number;
min?: number;
max?: number;
median?: number;
q1?: number;
q3?: number;
skewness?: number;
kurtosis?: number;
outlier_count?: number;
outlier_ratio?: number;
// 分类型额外字段
top_categories?: { value: string; count: number; ratio: number }[];
}
/** 数据质量画像 */
export interface DataProfile {
file_name: string;
row_count: number;
column_count: number;
total_cells: number;
missing_cells: number;
missing_ratio: number;
duplicate_rows: number;
duplicate_ratio: number;
numeric_columns: number;
categorical_columns: number;
datetime_columns: number;
quality_score: number;
quality_grade: DataQualityGrade;
columns: ColumnProfile[];
warnings: string[];
recommendations: string[];
generated_at: string;
}
/** 工作流步骤定义 */
export interface WorkflowStepDef {
step_number: number;
tool_code: string;
tool_name: string;
description: string;
params: Record<string, unknown>;
depends_on?: number[];
fallback_tool?: string;
}
/** 工作流计划 */
export interface WorkflowPlan {
workflow_id: string;
session_id: string;
title: string;
description: string;
total_steps: number;
steps: WorkflowStepDef[];
estimated_time_seconds?: number;
created_at: string;
}
/** 工作流步骤执行状态 */
export type WorkflowStepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'warning';
/** 工作流步骤执行结果 */
export interface WorkflowStepResult {
step_number: number;
tool_code: string;
tool_name: string;
status: WorkflowStepStatus;
started_at?: string;
completed_at?: string;
duration_ms?: number;
result?: {
method?: string;
statistic?: number;
p_value?: number;
effect_size?: number;
interpretation?: string;
result_table?: {
headers: string[];
rows: (string | number)[][];
};
plots?: PlotData[];
[key: string]: unknown;
};
error?: string;
logs: string[];
}
/** 综合结论报告 */
export interface ConclusionReport {
workflow_id: string;
title: string;
executive_summary: string;
key_findings: string[];
statistical_summary: {
total_tests: number;
significant_results: number;
methods_used: string[];
};
step_summaries: {
step_number: number;
tool_name: string;
summary: string;
p_value?: number;
is_significant?: boolean;
}[];
recommendations: string[];
limitations: string[];
generated_at: string;
}
/** SSE 消息类型 */
export type SSEMessageType = 'connected' | 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete' | 'workflow_error';
/** SSE 消息 */
export interface SSEMessage {
type: SSEMessageType;
workflowId?: string;
step?: number;
total_steps?: number;
totalSteps?: number;
status?: WorkflowStepStatus;
message?: string;
// 后端使用驼峰命名
toolCode?: string;
toolName?: string;
durationMs?: number;
duration_ms?: number;
result?: Record<string, unknown>;
// 兼容嵌套格式
data?: WorkflowStepResult & {
tool_code?: string;
tool_name?: string;
duration_ms?: number;
result?: Record<string, unknown>;
};
conclusion?: ConclusionReport;
error?: string;
}