feat(ssa): Complete QPER architecture - Query, Planner, Execute, Reflection layers
Implement the full QPER intelligent analysis pipeline: - Phase E+: Block-based standardization for all 7 R tools, DynamicReport renderer, Word export enhancement - Phase Q: LLM intent parsing with dynamic Zod validation against real column names, ClarificationCard component, DataProfile is_id_like tagging - Phase P: ConfigLoader with Zod schema validation and hot-reload API, DecisionTableService (4-dimension matching), FlowTemplateService with EPV protection, PlannedTrace audit output - Phase R: ReflectionService with statistical slot injection, sensitivity analysis conflict rules, ConclusionReport with section reveal animation, conclusion caching API, graceful R error classification End-to-end test: 40/40 passed across two complete analysis scenarios. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -983,9 +983,7 @@
|
||||
|
||||
.message-bubble .markdown-content h1 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.message-bubble .markdown-content h2 {
|
||||
}.message-bubble .markdown-content h2 {
|
||||
font-size: 1.2em;
|
||||
}.message-bubble .markdown-content h3 {
|
||||
font-size: 1.1em;
|
||||
@@ -1022,4 +1020,4 @@
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
69
frontend-v2/src/modules/ssa/components/ClarificationCard.tsx
Normal file
69
frontend-v2/src/modules/ssa/components/ClarificationCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* ClarificationCard — Phase Q 追问卡片组件
|
||||
*
|
||||
* 当 LLM 意图解析置信度低于阈值时,展示封闭式数据驱动选项卡。
|
||||
* 用户点击选项后自动补全 ParsedQuery 缺失字段,触发重新规划。
|
||||
*/
|
||||
import React from 'react';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
export interface ClarificationOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ClarificationCardData {
|
||||
question: string;
|
||||
options: ClarificationOption[];
|
||||
}
|
||||
|
||||
interface ClarificationCardProps {
|
||||
cards: ClarificationCardData[];
|
||||
onSelect: (selections: Record<string, string>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ClarificationCard: React.FC<ClarificationCardProps> = ({
|
||||
cards,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleOptionClick = (question: string, value: string) => {
|
||||
if (disabled) return;
|
||||
onSelect({ [question]: value });
|
||||
};
|
||||
|
||||
if (!cards || cards.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="clarification-cards">
|
||||
{cards.map((card, idx) => (
|
||||
<div key={idx} className="clarification-card">
|
||||
<div className="clarification-question">
|
||||
<HelpCircle size={14} className="text-blue-500" />
|
||||
<span>{card.question}</span>
|
||||
</div>
|
||||
<div className="clarification-options">
|
||||
{card.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
className="clarification-option-btn"
|
||||
onClick={() => handleOptionClick(card.question, opt.value)}
|
||||
disabled={disabled}
|
||||
title={opt.description}
|
||||
>
|
||||
<span className="option-label">{opt.label}</span>
|
||||
{opt.description && (
|
||||
<span className="option-desc">{opt.description}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClarificationCard;
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* 综合结论报告组件
|
||||
*
|
||||
* Phase 2A: 多步骤工作流执行完成后的综合结论展示
|
||||
* 综合结论报告组件 (Phase R)
|
||||
*
|
||||
* 特性:
|
||||
* - 逐 section 渐入动画(reflection_complete 到达后依次展现)
|
||||
* - 一键复制全文到剪贴板
|
||||
* - 来源标识(LLM / 规则引擎)
|
||||
* - 折叠/展开详细步骤结果
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { ConclusionReport as ConclusionReportType, WorkflowStepResult } from '../types';
|
||||
|
||||
interface ConclusionReportProps {
|
||||
report: ConclusionReportType;
|
||||
stepResults?: WorkflowStepResult[];
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
interface StepResultDetailProps {
|
||||
@@ -26,9 +31,9 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
|
||||
<span className="step-badge">步骤 {stepSummary.step_number}</span>
|
||||
<span className="tool-name">{stepSummary.tool_name}</span>
|
||||
{stepSummary.p_value !== undefined && (
|
||||
<span
|
||||
<span
|
||||
className="p-value-badge"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: stepSummary.is_significant ? '#ecfdf5' : '#f8fafc',
|
||||
color: stepSummary.is_significant ? '#059669' : '#64748b',
|
||||
}}
|
||||
@@ -40,12 +45,11 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
|
||||
</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">
|
||||
@@ -69,14 +73,13 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图表 */}
|
||||
{stepResult.result.plots && stepResult.result.plots.length > 0 && (
|
||||
<div className="result-plots">
|
||||
{stepResult.result.plots.map((plot, idx) => (
|
||||
{(stepResult.result.plots as any[]).map((plot: any, idx: number) => (
|
||||
<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}`}
|
||||
<img
|
||||
src={plot.imageBase64?.startsWith('data:') ? plot.imageBase64 : `data:image/png;base64,${plot.imageBase64}`}
|
||||
alt={plot.title}
|
||||
className="plot-image"
|
||||
/>
|
||||
@@ -85,11 +88,10 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详细解释 */}
|
||||
{stepResult.result.interpretation && (
|
||||
<div className="interpretation-box">
|
||||
<span className="interpretation-label">💡 解读:</span>
|
||||
<p>{stepResult.result.interpretation}</p>
|
||||
<span className="interpretation-label">解读:</span>
|
||||
<p>{String(stepResult.result.interpretation)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -98,132 +100,246 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
|
||||
);
|
||||
};
|
||||
|
||||
export const ConclusionReport: React.FC<ConclusionReportProps> = ({ report, stepResults = [] }) => {
|
||||
/**
|
||||
* Build plain-text version of the report for clipboard
|
||||
*/
|
||||
function buildPlainText(report: ConclusionReportType): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(report.title);
|
||||
lines.push('');
|
||||
lines.push('【综合结论】');
|
||||
lines.push(report.executive_summary);
|
||||
lines.push('');
|
||||
|
||||
if (report.key_findings.length > 0) {
|
||||
lines.push('【主要发现】');
|
||||
report.key_findings.forEach((f, i) => lines.push(`${i + 1}. ${f}`));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.step_summaries.length > 0) {
|
||||
lines.push('【分析步骤】');
|
||||
report.step_summaries.forEach(s => {
|
||||
const sig = s.is_significant ? '(显著)' : '';
|
||||
const pStr = s.p_value != null ? `, P=${s.p_value < 0.001 ? '<0.001' : s.p_value.toFixed(4)}` : '';
|
||||
lines.push(` 步骤${s.step_number} ${s.tool_name}: ${s.summary}${pStr} ${sig}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.recommendations && report.recommendations.length > 0) {
|
||||
lines.push('【建议】');
|
||||
report.recommendations.forEach((r, i) => lines.push(`${i + 1}. ${r}`));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.limitations.length > 0) {
|
||||
lines.push('【局限性】');
|
||||
report.limitations.forEach((l, i) => lines.push(`${i + 1}. ${l}`));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`生成时间: ${new Date(report.generated_at).toLocaleString('zh-CN')}`);
|
||||
if (report.source) {
|
||||
lines.push(`生成方式: ${report.source === 'llm' ? 'AI 智能分析' : '规则引擎'}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const ConclusionReport: React.FC<ConclusionReportProps> = ({
|
||||
report,
|
||||
stepResults = [],
|
||||
animate = false,
|
||||
}) => {
|
||||
const [showFullReport, setShowFullReport] = useState(true);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [visibleSections, setVisibleSections] = useState<Set<string>>(
|
||||
animate ? new Set() : new Set(['summary', 'findings', 'stats', 'steps', 'recommendations', 'limitations', 'methods'])
|
||||
);
|
||||
const animationTimer = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) return;
|
||||
|
||||
const sections = ['summary', 'findings', 'stats', 'steps', 'recommendations', 'limitations', 'methods'];
|
||||
const delays = [0, 300, 600, 900, 1100, 1300, 1500];
|
||||
|
||||
sections.forEach((section, idx) => {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleSections(prev => new Set([...prev, section]));
|
||||
}, delays[idx]);
|
||||
animationTimer.current.push(timer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
animationTimer.current.forEach(clearTimeout);
|
||||
};
|
||||
}, [animate]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildPlainText(report));
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = buildPlainText(report);
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => {
|
||||
return stepResults.find(r => r.step_number === stepNumber);
|
||||
};
|
||||
|
||||
const sectionClass = (name: string) =>
|
||||
`conclusion-section ${visibleSections.has(name) ? 'conclusion-section-visible' : 'conclusion-section-hidden'}`;
|
||||
|
||||
return (
|
||||
<div className="conclusion-report">
|
||||
{/* 报告头部 */}
|
||||
{/* Header */}
|
||||
<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 className="report-header-left">
|
||||
<h2 className="report-title">{report.title}</h2>
|
||||
<div className="report-meta">
|
||||
<span className="generated-time">
|
||||
{new Date(report.generated_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
{report.source && (
|
||||
<span className={`source-badge source-${report.source}`}>
|
||||
{report.source === 'llm' ? 'AI 生成' : '规则生成'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`copy-btn ${copySuccess ? 'copy-success' : ''}`}
|
||||
onClick={handleCopy}
|
||||
title="复制全文"
|
||||
>
|
||||
{copySuccess ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Executive Summary */}
|
||||
<div className={sectionClass('summary')}>
|
||||
<div className="executive-summary">
|
||||
<div className="summary-header">
|
||||
<span className="summary-label">综合结论</span>
|
||||
</div>
|
||||
<div className="summary-content">{report.executive_summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Findings */}
|
||||
{report.key_findings.length > 0 && (
|
||||
<div className={sectionClass('findings')}>
|
||||
<div className="key-findings">
|
||||
<div className="section-header">
|
||||
<span className="section-title">主要发现</span>
|
||||
</div>
|
||||
<ul className="findings-list">
|
||||
{report.key_findings.map((finding, idx) => (
|
||||
<li key={idx}>{finding}</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
{/* Stats Overview */}
|
||||
<div className={sectionClass('stats')}>
|
||||
<div className="stats-overview">
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{report.statistical_summary.total_tests}</span>
|
||||
<span className="stat-label">统计检验</span>
|
||||
</div>
|
||||
<div className="stat-card significant">
|
||||
<span className="stat-value">{report.statistical_summary.significant_results}</span>
|
||||
<span className="stat-label">显著结果</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{report.statistical_summary.methods_used.length}</span>
|
||||
<span className="stat-label">分析方法</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开/折叠按钮 */}
|
||||
<button
|
||||
{/* Toggle */}
|
||||
<button
|
||||
className="toggle-details-btn"
|
||||
onClick={() => setShowFullReport(!showFullReport)}
|
||||
>
|
||||
{showFullReport ? '收起详细结果 ▲' : '展开详细结果 ▼'}
|
||||
</button>
|
||||
|
||||
{/* 详细步骤结果 */}
|
||||
{/* Step Results */}
|
||||
{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 className={sectionClass('steps')}>
|
||||
<div className="step-results-section">
|
||||
<div className="section-header">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 建议 */}
|
||||
{report.recommendations.length > 0 && (
|
||||
<div className="recommendations-section">
|
||||
<div className="section-header">
|
||||
<span className="section-icon">💡</span>
|
||||
<span className="section-title">建议</span>
|
||||
{/* Recommendations */}
|
||||
{report.recommendations && report.recommendations.length > 0 && (
|
||||
<div className={sectionClass('recommendations')}>
|
||||
<div className="recommendations-section">
|
||||
<div className="section-header">
|
||||
<span className="section-title">建议</span>
|
||||
</div>
|
||||
<ul className="recommendations-list">
|
||||
{report.recommendations.map((rec, idx) => (
|
||||
<li key={idx}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="recommendations-list">
|
||||
{report.recommendations.map((rec, idx) => (
|
||||
<li key={idx}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 局限性 */}
|
||||
{/* Limitations */}
|
||||
{report.limitations.length > 0 && (
|
||||
<div className="limitations-section">
|
||||
<div className="section-header">
|
||||
<span className="section-icon">⚠️</span>
|
||||
<span className="section-title">局限性</span>
|
||||
<div className={sectionClass('limitations')}>
|
||||
<div className="limitations-section">
|
||||
<div className="section-header">
|
||||
<span className="section-title">局限性</span>
|
||||
</div>
|
||||
<ul className="limitations-list">
|
||||
{report.limitations.map((lim, idx) => (
|
||||
<li key={idx}>{lim}</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
))}
|
||||
{/* Methods */}
|
||||
<div className={sectionClass('methods')}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
183
frontend-v2/src/modules/ssa/components/DynamicReport.tsx
Normal file
183
frontend-v2/src/modules/ssa/components/DynamicReport.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* DynamicReport — Block-based 动态报告渲染组件
|
||||
*
|
||||
* 消费后端/R 端返回的 ReportBlock[] 数组,按序渲染:
|
||||
* markdown → 富文本段落
|
||||
* table → 三线表
|
||||
* image → base64 图片
|
||||
* key_value → 键值对网格
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { ImageOff, Loader2 } from 'lucide-react';
|
||||
import type { ReportBlock } from '../types';
|
||||
|
||||
interface DynamicReportProps {
|
||||
blocks: ReportBlock[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DynamicReport: React.FC<DynamicReportProps> = ({ blocks, className }) => {
|
||||
if (!blocks || blocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`dynamic-report ${className ?? ''}`}>
|
||||
{blocks.map((block, idx) => (
|
||||
<DynamicBlock key={idx} block={block} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
|
||||
switch (block.type) {
|
||||
case 'key_value':
|
||||
return <KVBlock block={block} />;
|
||||
case 'table':
|
||||
return <TableBlock block={block} index={index} />;
|
||||
case 'image':
|
||||
return <ImageBlock block={block} index={index} />;
|
||||
case 'markdown':
|
||||
return <MarkdownBlock block={block} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ─── key_value ─── */
|
||||
const KVBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
|
||||
const items = block.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="dr-kv-section">
|
||||
{block.title && <h4 className="dr-section-title">{block.title}</h4>}
|
||||
<div className="result-stats-grid">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="stat-card">
|
||||
<div className="stat-label">{item.key}</div>
|
||||
<div className="stat-value">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── table ─── */
|
||||
const TableBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
|
||||
const headers = block.headers ?? [];
|
||||
const rows = block.rows ?? [];
|
||||
if (headers.length === 0 && rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="result-table-section">
|
||||
{block.title && (
|
||||
<h4 className="table-label">
|
||||
Table {index + 1}. {block.title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="sci-table-wrapper">
|
||||
<table className="sci-table">
|
||||
{headers.length > 0 && (
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className={isPValueCell(cell) ? 'p-value' : ''}>
|
||||
{formatTableCell(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{block.footnote && <p className="dr-table-footnote">{block.footnote}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── image ─── */
|
||||
const ImageBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const src = React.useMemo(() => {
|
||||
if (!block.data) return '';
|
||||
if (block.data.startsWith('data:')) return block.data;
|
||||
return `data:image/png;base64,${block.data}`;
|
||||
}, [block.data]);
|
||||
|
||||
if (hasError || !block.data) {
|
||||
return (
|
||||
<div className="chart-error">
|
||||
<ImageOff size={32} className="text-slate-300" />
|
||||
<span>图片加载失败</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="result-chart-section">
|
||||
<h4 className="chart-label">
|
||||
Figure {index + 1}. {block.title ?? block.alt ?? '可视化'}
|
||||
</h4>
|
||||
<div className="chart-image-wrapper">
|
||||
{isLoading && (
|
||||
<div className="chart-loading">
|
||||
<Loader2 size={24} className="spin text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={block.alt ?? block.title ?? 'chart'}
|
||||
className={`chart-image ${isLoading ? 'loading' : ''}`}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => { setHasError(true); setIsLoading(false); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── markdown ─── */
|
||||
const MarkdownBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
|
||||
if (!block.content) return null;
|
||||
|
||||
return (
|
||||
<div className="dr-markdown-section">
|
||||
{block.title && <h4 className="dr-section-title">{block.title}</h4>}
|
||||
<div className="dr-markdown-content">
|
||||
{block.content.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── helpers ─── */
|
||||
const isPValueCell = (cell: string | number): boolean => {
|
||||
if (typeof cell === 'string') {
|
||||
return cell.includes('<') || cell.includes('*');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const formatTableCell = (cell: string | number): string => {
|
||||
if (cell === null || cell === undefined) return '-';
|
||||
if (typeof cell === 'number') {
|
||||
return Number.isInteger(cell) ? cell.toString() : cell.toFixed(4);
|
||||
}
|
||||
return String(cell);
|
||||
};
|
||||
|
||||
export default DynamicReport;
|
||||
@@ -29,6 +29,8 @@ import { useWorkflow } from '../hooks/useWorkflow';
|
||||
import type { SSAMessage } from '../types';
|
||||
import { TypeWriter } from './TypeWriter';
|
||||
import { DataProfileCard } from './DataProfileCard';
|
||||
import { ClarificationCard } from './ClarificationCard';
|
||||
import type { ClarificationCardData, IntentResult } from '../types';
|
||||
|
||||
export const SSAChatPane: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -46,15 +48,21 @@ export const SSAChatPane: React.FC = () => {
|
||||
error,
|
||||
setError,
|
||||
addToast,
|
||||
addMessage,
|
||||
selectAnalysisRecord,
|
||||
dataProfile,
|
||||
dataProfileLoading,
|
||||
} = useSSAStore();
|
||||
|
||||
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
|
||||
const { generateDataProfile, generateWorkflowPlan, isProfileLoading, isPlanLoading } = useWorkflow();
|
||||
const { generateDataProfile, generateWorkflowPlan, parseIntent, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
|
||||
const [pendingClarification, setPendingClarification] = useState<{
|
||||
cards: ClarificationCardData[];
|
||||
originalQuery: string;
|
||||
intent: IntentResult;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -143,20 +151,83 @@ export const SSAChatPane: React.FC = () => {
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const query = inputValue;
|
||||
setInputValue('');
|
||||
|
||||
try {
|
||||
// Phase 2A: 如果已有 session,使用多步骤工作流规划
|
||||
if (currentSession?.id) {
|
||||
await generateWorkflowPlan(currentSession.id, inputValue);
|
||||
// Phase Q: 先做意图解析,低置信度时追问
|
||||
const intentResp = await parseIntent(currentSession.id, query);
|
||||
|
||||
if (intentResp.needsClarification && intentResp.clarificationCards?.length > 0) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `我大致理解了你的意图(${intentResp.intent.reasoning}),但为了生成更精确的分析方案,想确认几个细节:`,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
setPendingClarification({
|
||||
cards: intentResp.clarificationCards,
|
||||
originalQuery: query,
|
||||
intent: intentResp.intent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 置信度足够 → 直接生成工作流计划
|
||||
await generateWorkflowPlan(currentSession.id, query);
|
||||
} else {
|
||||
// 没有数据时,使用旧流程
|
||||
await generatePlan(inputValue);
|
||||
await generatePlan(query);
|
||||
}
|
||||
setInputValue('');
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || '生成计划失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClarificationSelect = async (selections: Record<string, string>) => {
|
||||
if (!currentSession?.id || !pendingClarification) return;
|
||||
|
||||
setPendingClarification(null);
|
||||
|
||||
const selectedLabel = Object.values(selections).join(', ');
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: selectedLabel,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await handleClarify(
|
||||
currentSession.id,
|
||||
pendingClarification.originalQuery,
|
||||
selections
|
||||
);
|
||||
|
||||
if (resp.needsClarification && resp.clarificationCards?.length > 0) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '还需要确认一下:',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
setPendingClarification({
|
||||
cards: resp.clarificationCards,
|
||||
originalQuery: pendingClarification.originalQuery,
|
||||
intent: resp.intent,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || '处理追问失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -308,6 +379,20 @@ export const SSAChatPane: React.FC = () => {
|
||||
旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片
|
||||
*/}
|
||||
|
||||
{/* Phase Q: 追问卡片 */}
|
||||
{pendingClarification && (
|
||||
<div className="message-row assistant">
|
||||
<div className="avatar-col"><Bot size={18} /></div>
|
||||
<div className="msg-content">
|
||||
<ClarificationCard
|
||||
cards={pendingClarification.cards}
|
||||
onSelect={handleClarificationSelect}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
|
||||
<div ref={chatEndRef} className="scroll-spacer" />
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import { useAnalysis } from '../hooks/useAnalysis';
|
||||
import { useWorkflow } from '../hooks/useWorkflow';
|
||||
import type { TraceStep } from '../types';
|
||||
import type { TraceStep, ReportBlock } from '../types';
|
||||
import { WorkflowTimeline } from './WorkflowTimeline';
|
||||
import { DynamicReport } from './DynamicReport';
|
||||
import { exportBlocksToWord } from '../utils/exportBlocksToWord';
|
||||
|
||||
type ExecutionPhase = 'planning' | 'executing' | 'completed' | 'error';
|
||||
|
||||
@@ -137,7 +139,21 @@ export const SSAWorkspacePane: React.FC = () => {
|
||||
|
||||
const handleExportReport = async () => {
|
||||
try {
|
||||
await exportReport();
|
||||
// 优先使用 block-based 导出
|
||||
const allBlocks = workflowSteps
|
||||
.filter(s => s.status === 'success')
|
||||
.flatMap(s => {
|
||||
const r = s.result as any;
|
||||
return s.reportBlocks ?? r?.report_blocks ?? [];
|
||||
}) as ReportBlock[];
|
||||
|
||||
if (allBlocks.length > 0) {
|
||||
await exportBlocksToWord(allBlocks, {
|
||||
title: workflowPlan?.title || currentSession?.title || '统计分析报告',
|
||||
});
|
||||
} else {
|
||||
await exportReport();
|
||||
}
|
||||
addToast('报告导出成功', 'success');
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || '导出失败', 'error');
|
||||
@@ -521,13 +537,13 @@ export const SSAWorkspacePane: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 各步骤结果汇总 - MVP 风格 */}
|
||||
{/* 各步骤结果汇总 — 优先 Block-based,fallback 旧 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 === '描述性统计';
|
||||
|
||||
const blocks: ReportBlock[] | undefined =
|
||||
step.reportBlocks ?? r?.report_blocks;
|
||||
const hasBlocks = blocks && blocks.length > 0;
|
||||
|
||||
return (
|
||||
<div key={step.step_number} className="workflow-step-result">
|
||||
<h4 className="table-label">
|
||||
@@ -535,134 +551,11 @@ export const SSAWorkspacePane: React.FC = () => {
|
||||
{step.duration_ms && <span className="step-duration-badge">耗时 {step.duration_ms}ms</span>}
|
||||
</h4>
|
||||
|
||||
{/* 描述性统计 - 专用渲染 */}
|
||||
{isDescriptive ? (
|
||||
<DescriptiveResultView result={r} />
|
||||
{/* Block-based 渲染(优先) */}
|
||||
{hasBlocks ? (
|
||||
<DynamicReport blocks={blocks} />
|
||||
) : (
|
||||
<>
|
||||
{/* 非描述性统计 - 统计量汇总 */}
|
||||
<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>
|
||||
<LegacyStepResultView step={step} stepIdx={stepIdx} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1116,4 +1009,145 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LegacyStepResultView — 旧版(非 Block-based)步骤结果渲染
|
||||
* 当 R 工具未返回 report_blocks 时作为 fallback 使用
|
||||
*/
|
||||
const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step, stepIdx }) => {
|
||||
const r = step.result as any;
|
||||
if (!r) return null;
|
||||
|
||||
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 (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSAWorkspacePane;
|
||||
|
||||
@@ -75,12 +75,21 @@ const StepItem: React.FC<StepItemProps> = ({ step, result, isLast, isCurrent })
|
||||
<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>
|
||||
{step.is_sensitivity && (
|
||||
<span className="sensitivity-badge">敏感性分析</span>
|
||||
)}
|
||||
{result?.duration_ms && (
|
||||
<span className="step-duration">{result.duration_ms}ms</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-description">{step.description}</div>
|
||||
|
||||
{step.switch_condition && (
|
||||
<div className="step-guardrail">
|
||||
🛡️ 护栏:{step.switch_condition}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.params && Object.keys(step.params).length > 0 && (
|
||||
<div className="step-params">
|
||||
@@ -146,6 +155,23 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan.epv_warning && (
|
||||
<div className="epv-warning-banner">
|
||||
<span className="epv-icon">⚠️</span>
|
||||
<span>{plan.epv_warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.planned_trace?.fallbackTool && (
|
||||
<div className="guardrail-banner">
|
||||
<span className="guardrail-icon">🛡️</span>
|
||||
<span>
|
||||
主方法:{plan.planned_trace.primaryTool}
|
||||
→ 若{plan.planned_trace.switchCondition}则自动降级为 {plan.planned_trace.fallbackTool}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExecuting && (
|
||||
<div className="timeline-progress">
|
||||
<div className="progress-bar">
|
||||
|
||||
@@ -13,13 +13,25 @@ import type {
|
||||
WorkflowStepResult,
|
||||
SSEMessage,
|
||||
SSAMessage,
|
||||
IntentResult,
|
||||
ClarificationCardData,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api/v1/ssa';
|
||||
|
||||
interface IntentResponse {
|
||||
success: boolean;
|
||||
intent: IntentResult;
|
||||
needsClarification: boolean;
|
||||
clarificationCards: ClarificationCardData[];
|
||||
plan?: WorkflowPlan;
|
||||
}
|
||||
|
||||
interface UseWorkflowReturn {
|
||||
generateDataProfile: (sessionId: string) => Promise<DataProfile>;
|
||||
generateWorkflowPlan: (sessionId: string, query: string) => Promise<WorkflowPlan>;
|
||||
parseIntent: (sessionId: string, query: string) => Promise<IntentResponse>;
|
||||
handleClarify: (sessionId: string, userQuery: string, selections: Record<string, string>) => Promise<IntentResponse>;
|
||||
executeWorkflow: (sessionId: string, workflowId: string) => Promise<void>;
|
||||
cancelWorkflow: () => void;
|
||||
isProfileLoading: boolean;
|
||||
@@ -147,8 +159,78 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
addToast
|
||||
]);
|
||||
|
||||
/**
|
||||
* Phase Q: 解析用户意图(不直接生成计划)
|
||||
*/
|
||||
const parseIntent = useCallback(async (
|
||||
sessionId: string,
|
||||
query: string
|
||||
): Promise<IntentResponse> => {
|
||||
setWorkflowPlanLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/intent`, {
|
||||
sessionId,
|
||||
userQuery: query,
|
||||
});
|
||||
return response.data as IntentResponse;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || error.message || '意图解析失败';
|
||||
setError(errorMsg);
|
||||
throw error;
|
||||
} finally {
|
||||
setWorkflowPlanLoading(false);
|
||||
}
|
||||
}, [setWorkflowPlanLoading, setError]);
|
||||
|
||||
/**
|
||||
* Phase Q: 处理用户追问回答
|
||||
*/
|
||||
const handleClarify = useCallback(async (
|
||||
sessionId: string,
|
||||
userQuery: string,
|
||||
selections: Record<string, string>
|
||||
): Promise<IntentResponse> => {
|
||||
setWorkflowPlanLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/clarify`, {
|
||||
sessionId,
|
||||
userQuery,
|
||||
selections,
|
||||
});
|
||||
|
||||
const data = response.data as IntentResponse;
|
||||
|
||||
if (data.plan) {
|
||||
setWorkflowPlan(data.plan);
|
||||
setActivePane('sap');
|
||||
setWorkspaceOpen(true);
|
||||
setIsWorkflowMode(true);
|
||||
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${data.plan.title}\n共 ${data.plan.total_steps} 个分析步骤`,
|
||||
artifactType: 'sap',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || error.message || '处理追问失败';
|
||||
setError(errorMsg);
|
||||
throw error;
|
||||
} finally {
|
||||
setWorkflowPlanLoading(false);
|
||||
}
|
||||
}, [setWorkflowPlanLoading, setError, setWorkflowPlan, setActivePane, setWorkspaceOpen, setIsWorkflowMode, addMessage]);
|
||||
|
||||
const executeWorkflow = useCallback(async (
|
||||
sessionId: string,
|
||||
_sessionId: string,
|
||||
workflowId: string
|
||||
): Promise<void> => {
|
||||
setExecuting(true);
|
||||
@@ -238,12 +320,16 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
if (stepNumber !== undefined) {
|
||||
const result = message.result || message.data?.result;
|
||||
const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms;
|
||||
const reportBlocks = message.reportBlocks
|
||||
|| (result as any)?.report_blocks
|
||||
|| message.data?.reportBlocks;
|
||||
|
||||
updateWorkflowStep(stepNumber, {
|
||||
status: message.status || message.data?.status || 'success',
|
||||
completed_at: new Date().toISOString(),
|
||||
duration_ms: durationMs,
|
||||
result: result,
|
||||
reportBlocks: reportBlocks || undefined,
|
||||
});
|
||||
|
||||
const totalSteps = message.total_steps || message.totalSteps || 2;
|
||||
@@ -365,6 +451,8 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
return {
|
||||
generateDataProfile,
|
||||
generateWorkflowPlan,
|
||||
parseIntent,
|
||||
handleClarify,
|
||||
executeWorkflow,
|
||||
cancelWorkflow,
|
||||
isProfileLoading: dataProfileLoading,
|
||||
|
||||
@@ -2953,6 +2953,64 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workflow-timeline .sensitivity-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-guardrail {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workflow-timeline .epv-warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.workflow-timeline .epv-warning-banner .epv-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .guardrail-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.workflow-timeline .guardrail-banner .guardrail-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .ready-hint {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
@@ -3671,3 +3729,222 @@
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
/* DynamicReport — Block-based 动态报告 */
|
||||
/* ========================================== */
|
||||
|
||||
.dynamic-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dr-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.dr-markdown-section {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dr-markdown-content p {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dr-markdown-content p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dr-table-footnote {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin: 4px 0 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dr-kv-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
/* ClarificationCard — Phase Q 追问卡片 */
|
||||
/* ========================================== */
|
||||
|
||||
.clarification-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.clarification-card {
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.clarification-question {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.clarification-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clarification-option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.clarification-option-btn:hover:not(:disabled) {
|
||||
background: #dbeafe;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.clarification-option-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clarification-option-btn .option-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.clarification-option-btn .option-desc {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* Phase R: ConclusionReport 渐入动画 + 增强样式
|
||||
* ============================================ */
|
||||
|
||||
.conclusion-section {
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.conclusion-section-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.conclusion-section-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-llm {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.source-rule_based {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.copy-btn.copy-success {
|
||||
background: #ecfdf5;
|
||||
border-color: #86efac;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.reflecting-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #faf5ff, #ede9fe);
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 10px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.reflecting-indicator .reflecting-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #c4b5fd;
|
||||
border-top-color: #7c3aed;
|
||||
border-radius: 50%;
|
||||
animation: reflecting-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes reflecting-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.reflecting-indicator .reflecting-text {
|
||||
font-size: 13px;
|
||||
color: #6d28d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -233,6 +233,19 @@ export interface WorkflowStepDef {
|
||||
params: Record<string, unknown>;
|
||||
depends_on?: number[];
|
||||
fallback_tool?: string;
|
||||
is_sensitivity?: boolean;
|
||||
switch_condition?: string | null;
|
||||
}
|
||||
|
||||
/** P 层策略日志 */
|
||||
export interface PlannedTrace {
|
||||
matchedRule: string;
|
||||
primaryTool: string;
|
||||
fallbackTool: string | null;
|
||||
switchCondition: string | null;
|
||||
templateUsed: string;
|
||||
reasoning: string;
|
||||
epvWarning: string | null;
|
||||
}
|
||||
|
||||
/** 工作流计划 */
|
||||
@@ -245,6 +258,8 @@ export interface WorkflowPlan {
|
||||
steps: WorkflowStepDef[];
|
||||
estimated_time_seconds?: number;
|
||||
created_at: string;
|
||||
planned_trace?: PlannedTrace;
|
||||
epv_warning?: string | null;
|
||||
}
|
||||
|
||||
/** 工作流步骤执行状态 */
|
||||
@@ -269,14 +284,16 @@ export interface WorkflowStepResult {
|
||||
headers: string[];
|
||||
rows: (string | number)[][];
|
||||
};
|
||||
report_blocks?: ReportBlock[];
|
||||
plots?: PlotData[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
reportBlocks?: ReportBlock[];
|
||||
error?: string;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
/** 综合结论报告 */
|
||||
/** 综合结论报告(Phase R 统一格式,前后端对齐) */
|
||||
export interface ConclusionReport {
|
||||
workflow_id: string;
|
||||
title: string;
|
||||
@@ -297,10 +314,59 @@ export interface ConclusionReport {
|
||||
recommendations: string[];
|
||||
limitations: string[];
|
||||
generated_at: string;
|
||||
source?: 'llm' | 'rule_based';
|
||||
}
|
||||
|
||||
/** SSE 消息类型 */
|
||||
export type SSEMessageType = 'connected' | 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete' | 'workflow_error';
|
||||
// ============================================
|
||||
// Block-based 输出协议(与后端 ReportBlock 对应)
|
||||
// ============================================
|
||||
|
||||
export interface ReportBlock {
|
||||
type: 'markdown' | 'table' | 'image' | 'key_value';
|
||||
title?: string;
|
||||
content?: string; // markdown
|
||||
headers?: string[]; // table
|
||||
rows?: (string | number)[][]; // table
|
||||
footnote?: string; // table
|
||||
data?: string; // image (base64 data URI)
|
||||
alt?: string; // image
|
||||
items?: { key: string; value: string }[]; // key_value
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Phase Q: Intent / Clarification 类型
|
||||
// ============================================
|
||||
|
||||
export interface IntentResult {
|
||||
goal: string;
|
||||
outcome_var: string | null;
|
||||
outcome_type: string | null;
|
||||
predictor_vars: string[];
|
||||
grouping_var: string | null;
|
||||
design: string;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
needsClarification: boolean;
|
||||
clarificationCards?: ClarificationCardData[];
|
||||
}
|
||||
|
||||
export interface ClarificationCardData {
|
||||
question: string;
|
||||
options: ClarificationOptionData[];
|
||||
}
|
||||
|
||||
export interface ClarificationOptionData {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** SSE 消息类型(Phase R 扩展:qper_status + reflection_complete) */
|
||||
export type SSEMessageType =
|
||||
| 'connected'
|
||||
| 'step_start' | 'step_progress' | 'step_complete' | 'step_error'
|
||||
| 'workflow_complete' | 'workflow_error'
|
||||
| 'qper_status' | 'reflection_complete';
|
||||
|
||||
/** SSE 消息 */
|
||||
export interface SSEMessage {
|
||||
@@ -317,6 +383,7 @@ export interface SSEMessage {
|
||||
durationMs?: number;
|
||||
duration_ms?: number;
|
||||
result?: Record<string, unknown>;
|
||||
reportBlocks?: ReportBlock[];
|
||||
// 兼容嵌套格式
|
||||
data?: WorkflowStepResult & {
|
||||
tool_code?: string;
|
||||
|
||||
284
frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts
Normal file
284
frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* exportBlocksToWord — 将 ReportBlock[] 导出为 Word (.docx)
|
||||
*
|
||||
* 与 DynamicReport.tsx 对应:将 block-based 报告序列化为 docx 格式。
|
||||
*/
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
WidthType,
|
||||
BorderStyle,
|
||||
AlignmentType,
|
||||
ImageRun,
|
||||
} from 'docx';
|
||||
import type { ReportBlock, ConclusionReport } from '../types';
|
||||
|
||||
interface ExportOptions {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
generatedAt?: string;
|
||||
conclusion?: ConclusionReport;
|
||||
}
|
||||
|
||||
const TABLE_BORDERS = {
|
||||
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 },
|
||||
};
|
||||
|
||||
function makeRow(cells: string[], isHeader = false): TableRow {
|
||||
return new TableRow({
|
||||
children: cells.map(text =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: String(text ?? '-'), bold: isHeader })],
|
||||
}),
|
||||
],
|
||||
width: { size: Math.floor(100 / cells.length), type: WidthType.PERCENTAGE },
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Table)[] {
|
||||
const elements: (Paragraph | Table)[] = [];
|
||||
|
||||
switch (block.type) {
|
||||
case 'key_value': {
|
||||
if (block.title) {
|
||||
elements.push(new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_2 }));
|
||||
}
|
||||
const items = block.items ?? [];
|
||||
if (items.length > 0) {
|
||||
elements.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: TABLE_BORDERS,
|
||||
rows: [
|
||||
makeRow(['指标', '值'], true),
|
||||
...items.map(it => makeRow([it.key, it.value])),
|
||||
],
|
||||
})
|
||||
);
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'table': {
|
||||
const headers = block.headers ?? [];
|
||||
const rows = block.rows ?? [];
|
||||
if (headers.length > 0 || rows.length > 0) {
|
||||
if (block.title) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
text: `Table ${index + 1}. ${block.title}`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
})
|
||||
);
|
||||
}
|
||||
const tableRows: TableRow[] = [];
|
||||
if (headers.length > 0) {
|
||||
tableRows.push(makeRow(headers.map(String), true));
|
||||
}
|
||||
for (const row of rows) {
|
||||
tableRows.push(makeRow(row.map(c => (c === null || c === undefined ? '-' : String(c)))));
|
||||
}
|
||||
elements.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: TABLE_BORDERS,
|
||||
rows: tableRows,
|
||||
})
|
||||
);
|
||||
if (block.footnote) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: block.footnote, italics: true, color: '999999', size: 18 })],
|
||||
})
|
||||
);
|
||||
}
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
if (block.data) {
|
||||
try {
|
||||
const raw = block.data.replace(/^data:image\/\w+;base64,/, '');
|
||||
const imageBuffer = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
|
||||
if (block.title) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
text: `Figure ${index + 1}. ${block.title}`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
})
|
||||
);
|
||||
}
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' }),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
})
|
||||
);
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
} catch {
|
||||
/* skip broken image */
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'markdown': {
|
||||
if (block.title) {
|
||||
elements.push(new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_2 }));
|
||||
}
|
||||
if (block.content) {
|
||||
for (const line of block.content.split('\n')) {
|
||||
if (line.trim()) {
|
||||
elements.push(new Paragraph({ text: line }));
|
||||
}
|
||||
}
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function conclusionToDocxElements(conclusion: ConclusionReport): (Paragraph | Table)[] {
|
||||
const elements: (Paragraph | Table)[] = [];
|
||||
|
||||
elements.push(new Paragraph({ text: '综合结论', heading: HeadingLevel.HEADING_1 }));
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
|
||||
// Executive Summary
|
||||
elements.push(new Paragraph({ text: '摘要', heading: HeadingLevel.HEADING_2 }));
|
||||
for (const line of conclusion.executive_summary.split('\n')) {
|
||||
if (line.trim()) {
|
||||
elements.push(new Paragraph({ text: line }));
|
||||
}
|
||||
}
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
|
||||
// Key Findings
|
||||
if (conclusion.key_findings.length > 0) {
|
||||
elements.push(new Paragraph({ text: '主要发现', heading: HeadingLevel.HEADING_2 }));
|
||||
for (const finding of conclusion.key_findings) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `• ${finding}` })],
|
||||
})
|
||||
);
|
||||
}
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if (conclusion.recommendations && conclusion.recommendations.length > 0) {
|
||||
elements.push(new Paragraph({ text: '建议', heading: HeadingLevel.HEADING_2 }));
|
||||
for (const rec of conclusion.recommendations) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `• ${rec}` })],
|
||||
})
|
||||
);
|
||||
}
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
|
||||
// Limitations
|
||||
if (conclusion.limitations.length > 0) {
|
||||
elements.push(new Paragraph({ text: '局限性', heading: HeadingLevel.HEADING_2 }));
|
||||
for (const lim of conclusion.limitations) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `• ${lim}` })],
|
||||
})
|
||||
);
|
||||
}
|
||||
elements.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export async function exportBlocksToWord(
|
||||
blocks: ReportBlock[],
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
const {
|
||||
title = '统计分析报告',
|
||||
subtitle,
|
||||
generatedAt = now.toLocaleString('zh-CN'),
|
||||
conclusion,
|
||||
} = options;
|
||||
|
||||
const children: (Paragraph | Table)[] = [];
|
||||
|
||||
children.push(
|
||||
new Paragraph({ text: title, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER })
|
||||
);
|
||||
if (subtitle) {
|
||||
children.push(new Paragraph({ text: subtitle, alignment: AlignmentType.CENTER }));
|
||||
}
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `生成时间:${generatedAt}`, italics: true, color: '666666' })],
|
||||
}),
|
||||
new Paragraph({ text: '' })
|
||||
);
|
||||
|
||||
// Phase R: Conclusion section at the top (if available)
|
||||
if (conclusion) {
|
||||
children.push(...conclusionToDocxElements(conclusion));
|
||||
children.push(new Paragraph({ text: '详细分析结果', heading: HeadingLevel.HEADING_1 }));
|
||||
children.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
children.push(...blockToDocxElements(blocks[i], i));
|
||||
}
|
||||
|
||||
children.push(
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `本报告由智能统计分析系统自动生成${conclusion?.source === 'llm' ? '(结论由 AI 生成)' : ''}`,
|
||||
italics: true,
|
||||
color: '666666',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const doc = new Document({ sections: [{ children }] });
|
||||
const blob = await Packer.toBlob(doc);
|
||||
|
||||
const dateStr = `${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 safeTitle = title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_');
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${safeTitle}_${dateStr}.docx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user