Files
AIclinicalresearch/docs/03-业务模块/SSA-智能统计分析/04-开发计划/04-前端开发指南.md

1307 lines
38 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 &lt; 0.05, ** P &lt; 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`),
// 下载 SAPWord/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 SAPPreviewSAP 文档预览)
```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
```