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

38 KiB
Raw Blame History

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计划确认卡片

// 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执行路径树

// 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📌 执行进度动画)

// 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结果报告卡片

// 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三线表

// 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>
  );
};
/* 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含模式切换

// 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 封装(含咨询模式)

// 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. 模块注册

// 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 颜色系统(与原型对齐)

/* 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 动画

/* 渐入动画 */
.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

// 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无数据咨询界面

// 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 文档预览)

// 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 增强(支持仅下载方案)

// components/cards/PlanCard.tsx 增加的按钮
// 在 "确认并执行" 按钮旁边添加:

{/* 🆕 仅下载方案(咨询模式下或用户选择不执行) */}
<Button 
  icon={<DownloadOutlined />} 
  onClick={onDownloadPlanOnly}
>
  仅下载方案
</Button>

9. 关键配置

9.1 Axios 全局超时配置

// 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