1307 lines
38 KiB
Markdown
1307 lines
38 KiB
Markdown
# SSA-Pro 前端开发指南
|
||
|
||
> **文档版本:** v1.5
|
||
> **创建日期:** 2026-02-18
|
||
> **最后更新:** 2026-02-18(纳入专家配置体系 + 护栏 Action 展示)
|
||
> **目标读者:** 前端工程师
|
||
> **原型参考:** `03-UI设计/智能统计分析V2.html`
|
||
|
||
---
|
||
|
||
## 1. 模块目录结构
|
||
|
||
```
|
||
frontend-v2/src/modules/ssa/
|
||
├── index.ts # 模块入口,导出路由
|
||
├── pages/
|
||
│ └── SSAWorkspace.tsx # 主页面(工作区)
|
||
├── components/
|
||
│ ├── layout/
|
||
│ │ ├── SSASidebar.tsx # 左侧边栏
|
||
│ │ ├── SSAHeader.tsx # 顶部标题栏
|
||
│ │ ├── SSAInputArea.tsx # 底部输入区
|
||
│ │ └── ModeSwitch.tsx # 🆕 模式切换 Tab
|
||
│ ├── chat/
|
||
│ │ ├── MessageList.tsx # 消息流容器
|
||
│ │ ├── SystemMessage.tsx # 系统消息气泡
|
||
│ │ ├── UserMessage.tsx # 用户消息气泡
|
||
│ │ └── AssistantMessage.tsx # AI 消息(含卡片)
|
||
│ ├── cards/
|
||
│ │ ├── DataUploader.tsx # 数据上传区
|
||
│ │ ├── DataStatus.tsx # 数据集状态卡片
|
||
│ │ ├── PlanCard.tsx # 分析计划确认卡片 ⭐
|
||
│ │ ├── ExecutionTrace.tsx # 执行路径树 ⭐
|
||
│ │ ├── ExecutionProgress.tsx# 📌 执行进度动画 ⭐
|
||
│ │ ├── ResultCard.tsx # 结果报告卡片 ⭐
|
||
│ │ └── SAPPreview.tsx # 🆕 SAP 文档预览/下载
|
||
│ ├── consult/ # 🆕 咨询模式组件
|
||
│ │ ├── ConsultChat.tsx # 无数据对话界面
|
||
│ │ └── SAPDownloadButton.tsx# SAP 下载按钮
|
||
│ └── common/
|
||
│ ├── APATable.tsx # 三线表组件
|
||
│ └── PlotViewer.tsx # 图表查看器
|
||
├── hooks/
|
||
│ ├── useSSASession.ts # 会话管理 Hook
|
||
│ ├── useSSAExecution.ts # 执行控制 Hook
|
||
│ └── useSSAConsult.ts # 🆕 咨询模式 Hook
|
||
├── store/
|
||
│ └── ssaStore.ts # Zustand Store(含 mode 状态)
|
||
├── api/
|
||
│ └── ssaApi.ts # API 封装(含咨询 API)
|
||
├── types/
|
||
│ └── index.ts # 类型定义
|
||
└── styles/
|
||
└── ssa.css # 模块样式
|
||
```
|
||
|
||
### 1.1 🆕 双模式设计原则
|
||
|
||
| 原则 | 说明 |
|
||
|------|------|
|
||
| **模式切换** | 顶部 Tab 切换"智能分析"/"统计咨询" |
|
||
| **无数据友好** | 咨询模式不要求上传数据 |
|
||
| **SAP 导出** | 咨询完成后可下载 Word/Markdown |
|
||
|
||
---
|
||
|
||
## 2. 原型图核心元素解析
|
||
|
||
根据 `智能统计分析V2.html` 原型,需实现以下核心 UI:
|
||
|
||
### 2.1 整体布局(含模式切换)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ┌───────────┐ ┌─────────────────────────────────────────────┐ │
|
||
│ │ │ │ 🆕 [智能分析] [统计咨询] ← 模式切换 Tab │ │
|
||
│ │ Sidebar │ │ Header (会话标题) │ │
|
||
│ │ │ ├─────────────────────────────────────────────┤ │
|
||
│ │ - 导入数据│ │ │ │
|
||
│ │ - 新会话 │ │ Chat Flow (消息流) │ │
|
||
│ │ - 历史 │ │ │ │
|
||
│ │ │ │ - SystemMessage (欢迎/上传引导) │ │
|
||
│ │ │ │ - UserMessage (用户输入) │ │
|
||
│ │ │ │ - PlanCard (计划确认) │ │
|
||
│ │ ─────── │ │ - ExecutionTrace (执行路径) │ │
|
||
│ │ 数据状态 │ │ - ResultCard (结果报告) │ │
|
||
│ │ (分析模式) │ │ - 🆕 SAPPreview (咨询模式) │ │
|
||
│ │ │ │ │ │
|
||
│ │ │ ├─────────────────────────────────────────────┤ │
|
||
│ │ │ │ InputArea (输入框 + 发送按钮) │ │
|
||
│ └───────────┘ └─────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 核心组件设计规范
|
||
|
||
| 组件 | 原型特征 | 实现要点 |
|
||
|------|---------|---------|
|
||
| **SSASidebar** | 宽 256px,白色背景,阴影分隔 | Logo + 按钮 + 历史列表 + 数据状态 |
|
||
| **PlanCard** | 圆角卡片,分组/检验变量展示,护栏警告 | 支持参数编辑、确认/修改按钮 |
|
||
| **ExecutionTrace** | 竖向树状结构,带状态图标和连接线 | 动画展开,步骤状态(成功/警告/进行中) |
|
||
| **ResultCard** | 多区块:三线表 + 图表 + 解读 + 下载 | APA 格式表格,Base64 图片渲染 |
|
||
| **APATable** | 顶线(2px) + 表头下线(1px) + 底线(2px) | 数字右对齐,等宽字体 |
|
||
|
||
---
|
||
|
||
## 3. 核心组件实现
|
||
|
||
### 3.1 PlanCard(计划确认卡片)
|
||
|
||
```tsx
|
||
// components/cards/PlanCard.tsx
|
||
import React from 'react';
|
||
import { Card, Button, Tag, Alert, Space, Descriptions } from 'antd';
|
||
import { PlayCircleOutlined, EditOutlined, SafetyOutlined } from '@ant-design/icons';
|
||
|
||
interface PlanCardProps {
|
||
plan: {
|
||
tool_code: string;
|
||
tool_name: string;
|
||
reasoning: string;
|
||
params: Record<string, any>;
|
||
guardrails: {
|
||
check_normality?: boolean;
|
||
auto_fix?: boolean;
|
||
};
|
||
};
|
||
dataSchema: {
|
||
columns: Array<{ name: string; type: string; uniqueValues?: string[] }>;
|
||
};
|
||
onConfirm: () => void;
|
||
onEdit: () => void;
|
||
loading?: boolean;
|
||
}
|
||
|
||
export const PlanCard: React.FC<PlanCardProps> = ({
|
||
plan,
|
||
dataSchema,
|
||
onConfirm,
|
||
onEdit,
|
||
loading = false
|
||
}) => {
|
||
// 查找变量类型信息
|
||
const getColumnInfo = (colName: string) => {
|
||
const col = dataSchema.columns.find(c => c.name === colName);
|
||
if (!col) return '';
|
||
if (col.type === 'categorical' && col.uniqueValues) {
|
||
return `(分类: ${col.uniqueValues.slice(0, 3).join('/')})`;
|
||
}
|
||
return `(${col.type === 'numeric' ? '数值型' : '分类型'})`;
|
||
};
|
||
|
||
return (
|
||
<Card
|
||
className="plan-card"
|
||
title={
|
||
<Space>
|
||
<span>分析方案确认</span>
|
||
<Tag color="blue">{plan.tool_name}</Tag>
|
||
</Space>
|
||
}
|
||
styles={{ header: { background: '#f8fafc' } }}
|
||
>
|
||
{/* 变量映射 */}
|
||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||
{Object.entries(plan.params).map(([key, value]) => (
|
||
<div key={key} className="bg-slate-50 p-3 rounded border border-slate-100">
|
||
<div className="text-xs text-slate-400 uppercase font-bold mb-1">
|
||
{key.replace(/_/g, ' ')}
|
||
</div>
|
||
<div className="text-sm font-medium text-slate-800">
|
||
{String(value)}
|
||
<span className="text-xs text-slate-400 font-normal ml-1">
|
||
{getColumnInfo(String(value))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 护栏提示 */}
|
||
{plan.guardrails.check_normality && (
|
||
<Alert
|
||
type="warning"
|
||
icon={<SafetyOutlined />}
|
||
showIcon
|
||
message="统计护栏 (自动执行)"
|
||
description={
|
||
<ul className="list-disc list-inside text-xs mt-1 space-y-1">
|
||
<li>Shapiro-Wilk 正态性检验</li>
|
||
<li>Levene 方差齐性检验</li>
|
||
{plan.guardrails.auto_fix && (
|
||
<li className="font-medium">
|
||
⚠️ 若正态性检验失败,将自动降级为 Wilcoxon 秩和检验
|
||
</li>
|
||
)}
|
||
</ul>
|
||
}
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex justify-end gap-3 pt-3 border-t border-slate-100">
|
||
<Button icon={<EditOutlined />} onClick={onEdit}>
|
||
修改参数
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlayCircleOutlined />}
|
||
onClick={onConfirm}
|
||
loading={loading}
|
||
>
|
||
确认并执行
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 3.2 ExecutionTrace(执行路径树)
|
||
|
||
```tsx
|
||
// components/cards/ExecutionTrace.tsx
|
||
import React from 'react';
|
||
import { CheckCircleFilled, ExclamationCircleFilled,
|
||
SwapOutlined, CalculatorOutlined, LoadingOutlined } from '@ant-design/icons';
|
||
|
||
interface TraceStep {
|
||
id: string;
|
||
label: string;
|
||
status: 'success' | 'warning' | 'error' | 'running' | 'pending' | 'switched'; // 🆕 switched
|
||
detail?: string;
|
||
subLabel?: string;
|
||
actionType?: 'Block' | 'Warn' | 'Switch'; // 🆕 护栏 Action 类型
|
||
switchTarget?: string; // 🆕 Switch 目标工具
|
||
}
|
||
|
||
interface ExecutionTraceProps {
|
||
steps: TraceStep[];
|
||
}
|
||
|
||
export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({ steps }) => {
|
||
const getIcon = (status: TraceStep['status']) => {
|
||
switch (status) {
|
||
case 'success':
|
||
return <CheckCircleFilled className="text-green-500" />;
|
||
case 'warning':
|
||
return <ExclamationCircleFilled className="text-amber-500" />;
|
||
case 'error':
|
||
return <ExclamationCircleFilled className="text-red-500" />;
|
||
case 'switched': // 🆕 方法切换
|
||
return <SwapOutlined className="text-blue-500" />;
|
||
case 'running':
|
||
return <LoadingOutlined className="text-blue-500" spin />;
|
||
default:
|
||
return <div className="w-4 h-4 rounded-full bg-slate-200" />;
|
||
}
|
||
};
|
||
|
||
// 🆕 获取 Action 类型标签
|
||
const getActionTag = (step: TraceStep) => {
|
||
if (!step.actionType) return null;
|
||
|
||
const tagStyles = {
|
||
'Block': 'bg-red-100 text-red-700 border-red-200',
|
||
'Warn': 'bg-amber-100 text-amber-700 border-amber-200',
|
||
'Switch': 'bg-blue-100 text-blue-700 border-blue-200'
|
||
};
|
||
|
||
return (
|
||
<span className={`ml-2 px-1.5 py-0.5 text-xs rounded border ${tagStyles[step.actionType]}`}>
|
||
{step.actionType}
|
||
{step.switchTarget && <span className="ml-1">→ {step.switchTarget}</span>}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||
<div className="text-xs font-bold text-slate-400 uppercase mb-3 tracking-wider flex justify-between">
|
||
<span>执行路径</span>
|
||
{steps.every(s => s.status === 'success') && (
|
||
<span className="text-green-600">
|
||
<CheckCircleFilled /> 完成
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-3 font-mono text-xs relative pl-2">
|
||
{/* 连接线 */}
|
||
<div className="absolute left-[19px] top-2 bottom-4 w-px bg-slate-200" />
|
||
|
||
{steps.map((step, idx) => (
|
||
<div key={step.id} className="flex items-start gap-3 relative z-10">
|
||
<div className="w-4 h-4 flex items-center justify-center mt-0.5">
|
||
{getIcon(step.status)}
|
||
</div>
|
||
<div>
|
||
<span className={`
|
||
${step.status === 'warning' ? 'text-amber-700 font-medium' : ''}
|
||
${step.status === 'success' && step.detail ? 'text-slate-800 font-medium' : 'text-slate-600'}
|
||
`}>
|
||
{step.label}
|
||
</span>
|
||
{step.detail && (
|
||
<div className="mt-1">
|
||
<span className={`
|
||
px-1.5 py-0.5 rounded border font-bold
|
||
${step.status === 'error'
|
||
? 'bg-red-50 text-red-600 border-red-100'
|
||
: 'bg-green-50 text-green-600 border-green-100'}
|
||
`}>
|
||
{step.detail}
|
||
</span>
|
||
{step.subLabel && (
|
||
<span className="ml-2 text-slate-400">{step.subLabel}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 使用示例
|
||
const mockSteps: TraceStep[] = [
|
||
{ id: '1', label: '加载数据 (n=150)', status: 'success' },
|
||
{
|
||
id: '2',
|
||
label: '正态性检验 (Shapiro-Wilk)',
|
||
status: 'error',
|
||
detail: 'P = 0.002 (< 0.05) ❌',
|
||
subLabel: '-> 拒绝正态假设'
|
||
},
|
||
{ id: '3', label: '策略切换: T-Test -> Wilcoxon Test', status: 'warning' },
|
||
{ id: '4', label: '计算完成', status: 'success' },
|
||
];
|
||
```
|
||
|
||
### 3.3 ExecutionProgress(📌 执行进度动画)
|
||
|
||
```tsx
|
||
// components/cards/ExecutionProgress.tsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import { LoadingOutlined, CheckCircleFilled } from '@ant-design/icons';
|
||
import { Progress, Typography } from 'antd';
|
||
|
||
const { Text } = Typography;
|
||
|
||
interface ExecutionProgressProps {
|
||
isExecuting: boolean;
|
||
onComplete?: () => void;
|
||
}
|
||
|
||
// 📌 模拟进度文案(缓解用户等待焦虑)
|
||
const PROGRESS_MESSAGES = [
|
||
'正在加载数据...',
|
||
'执行统计护栏检验...',
|
||
'进行核心计算...',
|
||
'生成可视化图表...',
|
||
'格式化结果...',
|
||
'即将完成...'
|
||
];
|
||
|
||
export const ExecutionProgress: React.FC<ExecutionProgressProps> = ({
|
||
isExecuting,
|
||
onComplete
|
||
}) => {
|
||
const [progress, setProgress] = useState(0);
|
||
const [messageIndex, setMessageIndex] = useState(0);
|
||
|
||
useEffect(() => {
|
||
if (!isExecuting) {
|
||
setProgress(0);
|
||
setMessageIndex(0);
|
||
return;
|
||
}
|
||
|
||
// 📌 模拟进度(实际进度由后端控制)
|
||
const progressInterval = setInterval(() => {
|
||
setProgress(prev => {
|
||
if (prev >= 90) return prev; // 卡在 90%,等待真正完成
|
||
return prev + Math.random() * 10;
|
||
});
|
||
}, 500);
|
||
|
||
const messageInterval = setInterval(() => {
|
||
setMessageIndex(prev =>
|
||
prev < PROGRESS_MESSAGES.length - 1 ? prev + 1 : prev
|
||
);
|
||
}, 2000);
|
||
|
||
return () => {
|
||
clearInterval(progressInterval);
|
||
clearInterval(messageInterval);
|
||
};
|
||
}, [isExecuting]);
|
||
|
||
if (!isExecuting) return null;
|
||
|
||
return (
|
||
<div className="bg-white border border-blue-200 rounded-xl p-6 shadow-sm animate-pulse">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<LoadingOutlined className="text-blue-500 text-xl" spin />
|
||
<Text strong className="text-blue-700">正在执行统计分析</Text>
|
||
</div>
|
||
|
||
<Progress
|
||
percent={Math.round(progress)}
|
||
status="active"
|
||
strokeColor={{
|
||
'0%': '#3b82f6',
|
||
'100%': '#10b981'
|
||
}}
|
||
/>
|
||
|
||
<Text type="secondary" className="text-sm mt-2 block">
|
||
{PROGRESS_MESSAGES[messageIndex]}
|
||
</Text>
|
||
|
||
<Text type="secondary" className="text-xs mt-4 block opacity-60">
|
||
复杂计算可能需要 10-30 秒,请耐心等待...
|
||
</Text>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 3.4 ResultCard(结果报告卡片)
|
||
|
||
```tsx
|
||
// components/cards/ResultCard.tsx
|
||
import React from 'react';
|
||
import { Button, Divider, Space, Typography } from 'antd';
|
||
import { DownloadOutlined, FileWordOutlined } from '@ant-design/icons';
|
||
import { APATable } from '../common/APATable';
|
||
import { PlotViewer } from '../common/PlotViewer';
|
||
|
||
const { Title, Paragraph, Text } = Typography;
|
||
|
||
interface ResultCardProps {
|
||
result: {
|
||
method: string;
|
||
statistic: number;
|
||
p_value: number;
|
||
p_value_fmt: string; // 🆕 R 服务返回的格式化 p 值
|
||
group_stats: Array<{
|
||
group: string;
|
||
n: number;
|
||
mean?: number;
|
||
median?: number;
|
||
sd?: number;
|
||
iqr?: [number, number];
|
||
}>;
|
||
};
|
||
plots: string[]; // Base64 图片
|
||
interpretation?: string; // Critic 解读
|
||
reproducibleCode: string;
|
||
onDownloadCode: () => void;
|
||
}
|
||
|
||
export const ResultCard: React.FC<ResultCardProps> = ({
|
||
result,
|
||
plots,
|
||
interpretation,
|
||
reproducibleCode,
|
||
onDownloadCode
|
||
}) => {
|
||
// 构造表格数据
|
||
const tableData = result.group_stats.map(g => ({
|
||
group: g.group,
|
||
n: g.n,
|
||
value: g.median
|
||
? `${g.median.toFixed(2)} [${g.iqr?.[0].toFixed(2)} - ${g.iqr?.[1].toFixed(2)}]`
|
||
: `${g.mean?.toFixed(2)} ± ${g.sd?.toFixed(2)}`
|
||
}));
|
||
|
||
const columns = [
|
||
{ key: 'group', title: 'Group', width: 120 },
|
||
{ key: 'n', title: 'N', width: 60, align: 'right' as const },
|
||
{ key: 'value', title: result.method.includes('Wilcoxon') ? 'Median [IQR]' : 'Mean ± SD' },
|
||
{
|
||
key: 'statistic',
|
||
title: 'Statistic',
|
||
render: () => result.statistic.toFixed(2),
|
||
rowSpan: tableData.length
|
||
},
|
||
{
|
||
key: 'pValue',
|
||
title: 'P-Value',
|
||
render: () => (
|
||
<Text strong>
|
||
{/* 🆕 直接使用 R 服务返回的格式化值 */}
|
||
{result.p_value_fmt}
|
||
{result.p_value < 0.01 ? ' **' : result.p_value < 0.05 ? ' *' : ''}
|
||
</Text>
|
||
),
|
||
rowSpan: tableData.length
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-md overflow-hidden">
|
||
{/* Header */}
|
||
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
||
<Title level={5} className="mb-0">分析结果报告</Title>
|
||
<Text type="secondary" className="text-xs">
|
||
基于 {result.method}
|
||
</Text>
|
||
</div>
|
||
|
||
{/* 1. 统计表格 */}
|
||
<div className="p-6 border-b border-slate-100">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||
<Text strong>表 1. 组间差异比较 (三线表)</Text>
|
||
</div>
|
||
|
||
<APATable columns={columns} data={tableData} />
|
||
|
||
<Text type="secondary" className="text-xs mt-2 block italic">
|
||
Note: {result.method.includes('Wilcoxon') ? 'IQR = Interquartile Range' : 'SD = Standard Deviation'};
|
||
* P < 0.05, ** P < 0.01.
|
||
</Text>
|
||
</div>
|
||
|
||
{/* 2. 图表 */}
|
||
{plots.length > 0 && (
|
||
<div className="p-6 border-b border-slate-100">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="w-1 h-4 bg-blue-600 rounded-full" />
|
||
<Text strong>图 1. 可视化结果</Text>
|
||
</div>
|
||
<PlotViewer src={plots[0]} alt="Statistical Plot" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 3. 方法与解读 */}
|
||
{interpretation && (
|
||
<div className="p-6 bg-slate-50/50 border-b border-slate-100">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="w-1 h-4 bg-indigo-600 rounded-full" />
|
||
<Text strong>方法与结果解读</Text>
|
||
</div>
|
||
<div
|
||
className="prose prose-sm max-w-none text-slate-600"
|
||
dangerouslySetInnerHTML={{ __html: interpretation }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 4. 资产交付 */}
|
||
<div className="bg-slate-100 p-4 flex items-center justify-between">
|
||
<Text type="secondary" className="text-xs font-semibold uppercase tracking-wide">
|
||
资产交付
|
||
</Text>
|
||
<Space>
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
onClick={onDownloadCode}
|
||
>
|
||
下载 R 代码
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<FileWordOutlined />}
|
||
disabled // MVP 阶段禁用
|
||
>
|
||
导出分析报告 (Word)
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 3.5 APATable(三线表)
|
||
|
||
```tsx
|
||
// components/common/APATable.tsx
|
||
import React from 'react';
|
||
import './APATable.css';
|
||
|
||
interface Column {
|
||
key: string;
|
||
title: string;
|
||
width?: number;
|
||
align?: 'left' | 'center' | 'right';
|
||
render?: (value: any, record: any, index: number) => React.ReactNode;
|
||
rowSpan?: number;
|
||
}
|
||
|
||
interface APATableProps {
|
||
columns: Column[];
|
||
data: Record<string, any>[];
|
||
}
|
||
|
||
export const APATable: React.FC<APATableProps> = ({ columns, data }) => {
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="apa-table">
|
||
<thead>
|
||
<tr>
|
||
{columns.map(col => (
|
||
<th
|
||
key={col.key}
|
||
style={{ width: col.width, textAlign: col.align || 'left' }}
|
||
>
|
||
{col.title}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.map((row, rowIdx) => (
|
||
<tr key={rowIdx}>
|
||
{columns.map((col, colIdx) => {
|
||
// 处理 rowSpan
|
||
if (col.rowSpan && rowIdx > 0) return null;
|
||
|
||
const value = col.render
|
||
? col.render(row[col.key], row, rowIdx)
|
||
: row[col.key];
|
||
|
||
return (
|
||
<td
|
||
key={col.key}
|
||
rowSpan={col.rowSpan}
|
||
style={{ textAlign: col.align || 'left' }}
|
||
className={col.rowSpan ? 'align-middle border-l border-slate-100' : ''}
|
||
>
|
||
{value}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
```css
|
||
/* components/common/APATable.css */
|
||
.apa-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.apa-table thead th {
|
||
border-top: 2px solid #1e293b;
|
||
border-bottom: 1px solid #1e293b;
|
||
padding: 8px 12px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: #334155;
|
||
}
|
||
|
||
.apa-table tbody td {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
color: #475569;
|
||
}
|
||
|
||
.apa-table tbody tr:last-child td {
|
||
border-bottom: 2px solid #1e293b;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Zustand Store(含模式切换)
|
||
|
||
```typescript
|
||
// store/ssaStore.ts
|
||
import { create } from 'zustand';
|
||
|
||
// 🆕 模式类型
|
||
type SSAMode = 'analysis' | 'consult';
|
||
|
||
interface Message {
|
||
id: string;
|
||
role: 'user' | 'assistant' | 'system';
|
||
contentType: 'text' | 'plan' | 'result' | 'trace' | 'sap'; // 🆕 增加 sap 类型
|
||
content: any;
|
||
createdAt: string;
|
||
}
|
||
|
||
// 🆕 SAP 文档类型
|
||
interface SAPDocument {
|
||
title: string;
|
||
sections: Array<{ heading: string; content: string }>;
|
||
recommendedTools: string[];
|
||
}
|
||
|
||
interface SSAState {
|
||
// 🆕 模式
|
||
mode: SSAMode;
|
||
|
||
// 会话
|
||
sessionId: string | null;
|
||
sessionTitle: string;
|
||
|
||
// 数据(分析模式)
|
||
dataLoaded: boolean;
|
||
dataSchema: object | null;
|
||
dataFileName: string;
|
||
dataRowCount: number;
|
||
|
||
// 消息
|
||
messages: Message[];
|
||
|
||
// 执行状态
|
||
isPlanning: boolean;
|
||
isExecuting: boolean;
|
||
currentPlan: object | null;
|
||
|
||
// 🆕 咨询模式状态
|
||
currentSAP: SAPDocument | null;
|
||
isGeneratingSAP: boolean;
|
||
|
||
// Actions
|
||
setMode: (mode: SSAMode) => void; // 🆕
|
||
setSession: (id: string, title?: string) => void;
|
||
setDataLoaded: (schema: object, fileName: string, rowCount: number) => void;
|
||
addMessage: (message: Omit<Message, 'id' | 'createdAt'>) => void;
|
||
setPlanning: (planning: boolean) => void;
|
||
setExecuting: (executing: boolean) => void;
|
||
setCurrentPlan: (plan: object | null) => void;
|
||
setCurrentSAP: (sap: SAPDocument | null) => void; // 🆕
|
||
setGeneratingSAP: (generating: boolean) => void; // 🆕
|
||
reset: () => void;
|
||
}
|
||
|
||
export const useSSAStore = create<SSAState>((set, get) => ({
|
||
mode: 'analysis', // 🆕 默认分析模式
|
||
sessionId: null,
|
||
sessionTitle: '新会话',
|
||
dataLoaded: false,
|
||
dataSchema: null,
|
||
dataFileName: '',
|
||
dataRowCount: 0,
|
||
messages: [],
|
||
isPlanning: false,
|
||
isExecuting: false,
|
||
currentPlan: null,
|
||
currentSAP: null, // 🆕
|
||
isGeneratingSAP: false, // 🆕
|
||
|
||
// 🆕 切换模式
|
||
setMode: (mode) => set({
|
||
mode,
|
||
// 切换模式时重置会话
|
||
sessionId: null,
|
||
messages: [],
|
||
dataLoaded: false,
|
||
currentPlan: null,
|
||
currentSAP: null
|
||
}),
|
||
|
||
setSession: (id, title = '新会话') => set({ sessionId: id, sessionTitle: title }),
|
||
|
||
setDataLoaded: (schema, fileName, rowCount) => set({
|
||
dataLoaded: true,
|
||
dataSchema: schema,
|
||
dataFileName: fileName,
|
||
dataRowCount: rowCount
|
||
}),
|
||
|
||
addMessage: (message) => set(state => ({
|
||
messages: [
|
||
...state.messages,
|
||
{
|
||
...message,
|
||
id: crypto.randomUUID(),
|
||
createdAt: new Date().toISOString()
|
||
}
|
||
]
|
||
})),
|
||
|
||
setPlanning: (planning) => set({ isPlanning: planning }),
|
||
setExecuting: (executing) => set({ isExecuting: executing }),
|
||
setCurrentPlan: (plan) => set({ currentPlan: plan }),
|
||
setCurrentSAP: (sap) => set({ currentSAP: sap }), // 🆕
|
||
setGeneratingSAP: (generating) => set({ isGeneratingSAP: generating }), // 🆕
|
||
|
||
reset: () => set({
|
||
mode: 'analysis',
|
||
sessionId: null,
|
||
sessionTitle: '新会话',
|
||
dataLoaded: false,
|
||
dataSchema: null,
|
||
dataFileName: '',
|
||
dataRowCount: 0,
|
||
messages: [],
|
||
isPlanning: false,
|
||
isExecuting: false,
|
||
currentPlan: null,
|
||
currentSAP: null,
|
||
isGeneratingSAP: false
|
||
})
|
||
}));
|
||
```
|
||
|
||
---
|
||
|
||
## 5. API 封装(含咨询模式)
|
||
|
||
```typescript
|
||
// api/ssaApi.ts
|
||
import { apiClient } from '@/common/api/client';
|
||
|
||
const BASE = '/api/v1/ssa';
|
||
|
||
export const ssaApi = {
|
||
// ==================== 智能分析模式 ====================
|
||
|
||
// 会话
|
||
createSession: () =>
|
||
apiClient.post<{ id: string }>(`${BASE}/sessions`),
|
||
|
||
getSession: (id: string) =>
|
||
apiClient.get(`${BASE}/sessions/${id}`),
|
||
|
||
listSessions: () =>
|
||
apiClient.get(`${BASE}/sessions`),
|
||
|
||
// 数据上传
|
||
uploadData: (sessionId: string, file: File) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
return apiClient.post(`${BASE}/sessions/${sessionId}/upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
});
|
||
},
|
||
|
||
// 生成计划
|
||
generatePlan: (sessionId: string, query: string) =>
|
||
apiClient.post(`${BASE}/sessions/${sessionId}/plan`, { query }),
|
||
|
||
// 执行分析(📌 超时 120s)
|
||
executeAnalysis: (sessionId: string, plan: object) =>
|
||
apiClient.post(`${BASE}/sessions/${sessionId}/execute`, { plan }, {
|
||
timeout: 120000 // 📌 120s 超时,应对复杂计算
|
||
}),
|
||
|
||
// 下载代码
|
||
downloadCode: (sessionId: string, messageId: string) =>
|
||
apiClient.get(`${BASE}/sessions/${sessionId}/download-code/${messageId}`, {
|
||
responseType: 'blob'
|
||
}),
|
||
|
||
// ==================== 🆕 咨询模式 ====================
|
||
|
||
// 创建咨询会话(无数据)
|
||
createConsultSession: () =>
|
||
apiClient.post<{ id: string }>(`${BASE}/consult`),
|
||
|
||
// 咨询对话
|
||
consultChat: (sessionId: string, message: string) =>
|
||
apiClient.post<{ response: string }>(`${BASE}/consult/${sessionId}/chat`, { message }),
|
||
|
||
// 生成 SAP 文档
|
||
generateSAP: (sessionId: string) =>
|
||
apiClient.post<{
|
||
title: string;
|
||
sections: Array<{ heading: string; content: string }>;
|
||
recommendedTools: string[];
|
||
}>(`${BASE}/consult/${sessionId}/generate-sap`),
|
||
|
||
// 下载 SAP(Word/Markdown)
|
||
downloadSAP: (sessionId: string, format: 'word' | 'markdown' = 'word') =>
|
||
apiClient.get(`${BASE}/consult/${sessionId}/download-sap`, {
|
||
params: { format },
|
||
responseType: 'blob'
|
||
}),
|
||
|
||
// ==================== 🆕 配置中台 ====================
|
||
|
||
// 导入配置
|
||
importConfig: (file: File) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
return apiClient.post(`${BASE}/config/import`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
});
|
||
},
|
||
|
||
// 获取工具列表
|
||
getConfigTools: () =>
|
||
apiClient.get(`${BASE}/config/tools`),
|
||
|
||
// 热加载配置(Admin)
|
||
reloadConfig: () =>
|
||
apiClient.post(`${BASE}/config/reload`),
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 模块注册
|
||
|
||
```typescript
|
||
// index.ts
|
||
import { lazy } from 'react';
|
||
|
||
const SSAWorkspace = lazy(() => import('./pages/SSAWorkspace'));
|
||
|
||
export const ssaRoutes = [
|
||
{
|
||
path: '/ssa',
|
||
element: <SSAWorkspace />,
|
||
meta: {
|
||
title: '智能统计分析',
|
||
icon: 'BarChartOutlined',
|
||
requireAuth: true
|
||
}
|
||
}
|
||
];
|
||
|
||
// 在 moduleRegistry.ts 中注册
|
||
// import { ssaRoutes } from './modules/ssa';
|
||
// registerModule('ssa', ssaRoutes);
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 样式规范
|
||
|
||
### 7.1 颜色系统(与原型对齐)
|
||
|
||
```css
|
||
/* styles/ssa.css */
|
||
:root {
|
||
--ssa-primary: #3b82f6; /* blue-500 */
|
||
--ssa-primary-hover: #2563eb; /* blue-600 */
|
||
--ssa-bg: #f8fafc; /* slate-50 */
|
||
--ssa-card-bg: #ffffff;
|
||
--ssa-border: #e2e8f0; /* slate-200 */
|
||
--ssa-text: #334155; /* slate-700 */
|
||
--ssa-text-muted: #94a3b8; /* slate-400 */
|
||
--ssa-success: #22c55e; /* green-500 */
|
||
--ssa-warning: #f59e0b; /* amber-500 */
|
||
--ssa-error: #ef4444; /* red-500 */
|
||
}
|
||
```
|
||
|
||
### 7.2 动画
|
||
|
||
```css
|
||
/* 渐入动画 */
|
||
.ssa-fade-in {
|
||
animation: ssaFadeIn 0.4s ease-out forwards;
|
||
opacity: 0;
|
||
}
|
||
|
||
/* 上滑动画 */
|
||
.ssa-slide-up {
|
||
animation: ssaSlideUp 0.4s ease-out forwards;
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
|
||
@keyframes ssaFadeIn {
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes ssaSlideUp {
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 开发检查清单
|
||
|
||
| 组件 | 功能 | 状态 |
|
||
|------|------|------|
|
||
| SSASidebar | 导入数据、新建会话、历史列表、数据状态 | ⬜ |
|
||
| 🆕 **ModeSwitch** | **模式切换 Tab(智能分析/统计咨询)** | ⬜ |
|
||
| DataUploader | 拖拽/点击上传,进度显示 | ⬜ |
|
||
| MessageList | 消息流滚动,自动滚底 | ⬜ |
|
||
| PlanCard | 参数展示、护栏提示、确认/修改按钮 | ⬜ |
|
||
| 🆕 PlanCard | **增加"仅下载方案"按钮(咨询模式)** | ⬜ |
|
||
| ExecutionTrace | 步骤树、状态图标、连接线 | ⬜ |
|
||
| **ExecutionProgress** | **📌 执行中进度动画,缓解等待焦虑** | ⬜ |
|
||
| ResultCard | 三线表、图表、解读、下载按钮 | ⬜ |
|
||
| APATable | APA 格式表格样式 | ⬜ |
|
||
| 🆕 **ConsultChat** | **无数据咨询对话界面** | ⬜ |
|
||
| 🆕 **SAPPreview** | **SAP 文档预览/下载** | ⬜ |
|
||
| 🆕 **SAPDownloadButton** | **Word/Markdown 下载选择** | ⬜ |
|
||
| Zustand Store | 状态管理,**含 mode 切换** | ⬜ |
|
||
| API 对接 | 所有接口联调,**含咨询 API** | ⬜ |
|
||
|
||
---
|
||
|
||
## 9. 🆕 新增组件实现
|
||
|
||
### 9.1 ModeSwitch(模式切换 Tab)
|
||
|
||
```tsx
|
||
// components/layout/ModeSwitch.tsx
|
||
import React from 'react';
|
||
import { Segmented } from 'antd';
|
||
import { BarChartOutlined, MessageOutlined } from '@ant-design/icons';
|
||
import { useSSAStore } from '../../store/ssaStore';
|
||
|
||
export const ModeSwitch: React.FC = () => {
|
||
const { mode, setMode } = useSSAStore();
|
||
|
||
return (
|
||
<Segmented
|
||
value={mode}
|
||
onChange={(value) => setMode(value as 'analysis' | 'consult')}
|
||
options={[
|
||
{
|
||
label: (
|
||
<div className="flex items-center gap-2 px-2">
|
||
<BarChartOutlined />
|
||
<span>智能分析</span>
|
||
</div>
|
||
),
|
||
value: 'analysis',
|
||
},
|
||
{
|
||
label: (
|
||
<div className="flex items-center gap-2 px-2">
|
||
<MessageOutlined />
|
||
<span>统计咨询</span>
|
||
</div>
|
||
),
|
||
value: 'consult',
|
||
},
|
||
]}
|
||
className="bg-slate-100"
|
||
/>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 9.2 ConsultChat(无数据咨询界面)
|
||
|
||
```tsx
|
||
// components/consult/ConsultChat.tsx
|
||
import React, { useState } from 'react';
|
||
import { Input, Button, Alert } from 'antd';
|
||
import { SendOutlined, FileWordOutlined } from '@ant-design/icons';
|
||
import { useSSAStore } from '../../store/ssaStore';
|
||
import { ssaApi } from '../../api/ssaApi';
|
||
|
||
export const ConsultChat: React.FC = () => {
|
||
const [input, setInput] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const {
|
||
sessionId,
|
||
messages,
|
||
addMessage,
|
||
setSession,
|
||
setCurrentSAP,
|
||
setGeneratingSAP,
|
||
isGeneratingSAP
|
||
} = useSSAStore();
|
||
|
||
const handleSend = async () => {
|
||
if (!input.trim()) return;
|
||
|
||
setLoading(true);
|
||
|
||
// 如果没有会话,先创建
|
||
let currentSessionId = sessionId;
|
||
if (!currentSessionId) {
|
||
const { data } = await ssaApi.createConsultSession();
|
||
currentSessionId = data.id;
|
||
setSession(data.id, '统计咨询');
|
||
}
|
||
|
||
// 添加用户消息
|
||
addMessage({ role: 'user', contentType: 'text', content: { text: input } });
|
||
setInput('');
|
||
|
||
// 发送咨询
|
||
const { data } = await ssaApi.consultChat(currentSessionId!, input);
|
||
|
||
// 添加 AI 回复
|
||
addMessage({ role: 'assistant', contentType: 'text', content: { text: data.response } });
|
||
|
||
setLoading(false);
|
||
};
|
||
|
||
const handleGenerateSAP = async () => {
|
||
if (!sessionId) return;
|
||
|
||
setGeneratingSAP(true);
|
||
const { data } = await ssaApi.generateSAP(sessionId);
|
||
setCurrentSAP(data);
|
||
addMessage({ role: 'assistant', contentType: 'sap', content: data });
|
||
setGeneratingSAP(false);
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* 引导提示 */}
|
||
<Alert
|
||
message="统计咨询模式"
|
||
description="描述您的研究设计和分析需求,无需上传数据。完成咨询后可生成统计分析计划(SAP)文档。"
|
||
type="info"
|
||
showIcon
|
||
className="mx-4 mt-4"
|
||
/>
|
||
|
||
{/* 消息流 */}
|
||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||
{messages.map(msg => (
|
||
<div
|
||
key={msg.id}
|
||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||
>
|
||
<div className={`
|
||
max-w-[80%] p-3 rounded-lg
|
||
${msg.role === 'user'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-slate-100 text-slate-800'}
|
||
`}>
|
||
{msg.contentType === 'sap'
|
||
? <SAPPreview sap={msg.content} />
|
||
: msg.content.text
|
||
}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 输入区 */}
|
||
<div className="p-4 border-t border-slate-200">
|
||
<div className="flex gap-2">
|
||
<Input.TextArea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="描述您的研究设计和统计分析需求..."
|
||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||
onPressEnter={(e) => {
|
||
if (!e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex flex-col gap-2">
|
||
<Button
|
||
type="primary"
|
||
icon={<SendOutlined />}
|
||
onClick={handleSend}
|
||
loading={loading}
|
||
>
|
||
发送
|
||
</Button>
|
||
<Button
|
||
icon={<FileWordOutlined />}
|
||
onClick={handleGenerateSAP}
|
||
loading={isGeneratingSAP}
|
||
disabled={messages.length < 2}
|
||
>
|
||
生成 SAP
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 9.3 SAPPreview(SAP 文档预览)
|
||
|
||
```tsx
|
||
// components/cards/SAPPreview.tsx
|
||
import React from 'react';
|
||
import { Card, Button, Space, Typography, Divider, Tag } from 'antd';
|
||
import { DownloadOutlined, FileWordOutlined, FileMarkdownOutlined } from '@ant-design/icons';
|
||
import { ssaApi } from '../../api/ssaApi';
|
||
import { useSSAStore } from '../../store/ssaStore';
|
||
|
||
const { Title, Paragraph, Text } = Typography;
|
||
|
||
interface SAPDocument {
|
||
title: string;
|
||
sections: Array<{ heading: string; content: string }>;
|
||
recommendedTools: string[];
|
||
}
|
||
|
||
interface SAPPreviewProps {
|
||
sap: SAPDocument;
|
||
}
|
||
|
||
export const SAPPreview: React.FC<SAPPreviewProps> = ({ sap }) => {
|
||
const { sessionId } = useSSAStore();
|
||
|
||
const handleDownload = async (format: 'word' | 'markdown') => {
|
||
if (!sessionId) return;
|
||
|
||
const response = await ssaApi.downloadSAP(sessionId, format);
|
||
const blob = new Blob([response.data]);
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = format === 'word' ? 'SAP.docx' : 'SAP.md';
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<Card
|
||
className="sap-preview"
|
||
title={
|
||
<Space>
|
||
<FileWordOutlined className="text-blue-500" />
|
||
<span>统计分析计划 (SAP)</span>
|
||
</Space>
|
||
}
|
||
extra={
|
||
<Space>
|
||
<Button
|
||
icon={<FileWordOutlined />}
|
||
onClick={() => handleDownload('word')}
|
||
>
|
||
Word
|
||
</Button>
|
||
<Button
|
||
icon={<FileMarkdownOutlined />}
|
||
onClick={() => handleDownload('markdown')}
|
||
>
|
||
Markdown
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<Title level={4}>{sap.title}</Title>
|
||
|
||
{sap.sections.map((section, idx) => (
|
||
<div key={idx} className="mb-4">
|
||
<Title level={5} className="text-slate-700">{section.heading}</Title>
|
||
<Paragraph className="text-slate-600">{section.content}</Paragraph>
|
||
</div>
|
||
))}
|
||
|
||
<Divider />
|
||
|
||
<div>
|
||
<Text strong>推荐统计方法:</Text>
|
||
<div className="mt-2">
|
||
{sap.recommendedTools.map((tool, idx) => (
|
||
<Tag key={idx} color="blue">{tool}</Tag>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 9.4 PlanCard 增强(支持仅下载方案)
|
||
|
||
```tsx
|
||
// components/cards/PlanCard.tsx 增加的按钮
|
||
// 在 "确认并执行" 按钮旁边添加:
|
||
|
||
{/* 🆕 仅下载方案(咨询模式下或用户选择不执行) */}
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
onClick={onDownloadPlanOnly}
|
||
>
|
||
仅下载方案
|
||
</Button>
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 关键配置
|
||
|
||
### 9.1 Axios 全局超时配置
|
||
|
||
```typescript
|
||
// api/client.ts
|
||
import axios from 'axios';
|
||
|
||
export const apiClient = axios.create({
|
||
baseURL: '/api',
|
||
timeout: 60000, // 默认 60s
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
// 📌 对于 SSA 执行接口,单独设置 120s 超时
|
||
// 见 ssaApi.executeAnalysis
|
||
```
|