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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
233
frontend-v2/src/modules/ssa/components/ConclusionReport.tsx
Normal file
233
frontend-v2/src/modules/ssa/components/ConclusionReport.tsx
Normal 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;
|
||||
145
frontend-v2/src/modules/ssa/components/DataProfileCard.tsx
Normal file
145
frontend-v2/src/modules/ssa/components/DataProfileCard.tsx
Normal 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;
|
||||
284
frontend-v2/src/modules/ssa/components/DataProfileModal.tsx
Normal file
284
frontend-v2/src/modules/ssa/components/DataProfileModal.tsx
Normal 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;
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 < 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 < 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;
|
||||
|
||||
163
frontend-v2/src/modules/ssa/components/StepProgressCard.tsx
Normal file
163
frontend-v2/src/modules/ssa/components/StepProgressCard.tsx
Normal 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;
|
||||
184
frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx
Normal file
184
frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useAnalysis } from './useAnalysis';
|
||||
export { useArtifactParser, parseArtifactMarkers } from './useArtifactParser';
|
||||
export { useWorkflow } from './useWorkflow';
|
||||
|
||||
@@ -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,
|
||||
|
||||
376
frontend-v2/src/modules/ssa/hooks/useWorkflow.ts
Normal file
376
frontend-v2/src/modules/ssa/hooks/useWorkflow.ts
Normal 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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user