Files
AIclinicalresearch/docs/03-业务模块/DC-数据清洗整理/04-开发计划/工具C_Day4-5前端开发计划.md
HaHafeng dac3cecf78 feat(iit): Complete IIT Manager Agent Day 1 - Environment initialization and WeChat integration
Summary:
- Complete IIT Manager Agent MVP Day 1 (12.5% progress)
- Database: Create iit_schema with 5 tables (IitProject, IitPendingAction, IitTaskRun, IitUserMapping, IitAuditLog)
- Backend: Add module structure (577 lines) and types (223 lines)
- WeChat: Configure Enterprise WeChat app (CorpID, AgentID, Secret)
- WeChat: Obtain web authorization and JS-SDK authorization
- WeChat: Configure trusted domain (iit.xunzhengyixue.com)
- Frontend: Deploy v1.2 with WeChat domain verification file
- Frontend: Fix CRLF issue in docker-entrypoint.sh (CRLF -> LF)
- Testing: 11/11 database CRUD tests passed
- Testing: Access Token retrieval test passed
- Docs: Create module status and development guide
- Docs: Update MVP task list with Day 1 completion
- Docs: Rename deployment doc to SAE real-time status record
- Deployment: Update frontend internal IP to 172.17.173.80

Technical Details:
- Prisma: Multi-schema support (iit_schema)
- pg-boss: Job queue integration prepared
- Taro 4.x: Framework selected for WeChat Mini Program
- Shadow State: Architecture foundation laid
- Docker: Fix entrypoint script line endings for Linux container

Status: Day 1/14 complete, ready for Day 2 REDCap integration
2026-01-01 14:32:58 +08:00

38 KiB
Raw Blame History

工具C Day 4-5 前端开发计划(最终版)

制定日期: 2025-12-07
开发目标: Tool C前端MVP - AI驱动的科研数据编辑器
预计工时: 12-16小时Day 4: 6-8h, Day 5: 6-8h
核心目标: 端到端AI数据清洗流程可用


🎯 核心决策

决策项 最终方案 理由
表格组件 AG Grid Community 用户强烈要求Excel级体验
开发优先级 AI核心功能优先 先验证后端UI细节后续优化
UI风格 严格还原原型V6 Emerald绿色主题符合Tool C定位
测试数据 真实医疗数据 cqol-demo.csv (21列x300+行)
Day 4-5目标 AI核心功能可用 上传→AI对话→执行→更新表格
代码风格 同Tool B标准 TypeScript + Tailwind + Lucide

📦 技术栈

核心依赖

{
  "ag-grid-community": "^31.0.0",        // 表格核心
  "ag-grid-react": "^31.0.0",            // React集成
  "react": "^18.2.0",
  "react-router-dom": "^6.x",
  "lucide-react": "^0.x",                // 图标库
  "axios": "^1.x"                        // HTTP客户端
}

技术选型

  • React 18: Hooks + TypeScript
  • Tailwind CSS: 原子化CSS
  • AG Grid Community: 开源版功能足够MVP
  • Prism.js: 代码语法高亮
  • React Markdown: Markdown渲染AI回复

📐 页面布局设计

┌────────────────────────────────────────────────────────────┐
│ Header (h-14)                                              │
│ [🟢 科研数据编辑器] lung_cancer.csv  [撤销/重做] [导出]    │
└────────────────────────────────────────────────────────────┘
┌──────────────────────────┬─────────────────────────────────┐
│ Left Panel (flex-1)      │ Right Sidebar (w-[420px])       │
│                          │                                 │
│ ┌──────────────────────┐ │ ┌───────────────────────────┐ │
│ │ Toolbar (h-16)       │ │ │ Tab: [Chat] [Insights]    │ │
│ │ 7个快捷按钮 + 搜索    │ │ └───────────────────────────┘ │
│ └──────────────────────┘ │                                 │
│                          │ ┌───────────────────────────┐ │
│ ┌──────────────────────┐ │ │ Chat Messages Area        │ │
│ │                      │ │ │ - 用户消息                 │ │
│ │  AG Grid Table       │ │ │ - AI消息含代码块       │ │
│ │  (Excel风格)         │ │ │ - 系统消息                 │ │
│ │                      │ │ │                           │ │
│ │  21列 x 300+行       │ │ └───────────────────────────┘ │
│ │                      │ │                                 │
│ │  支持:              │ │ ┌───────────────────────────┐ │
│ │  - 列排序             │ │ │ Input + Send Button       │ │
│ │  - 列宽调整           │ │ └───────────────────────────┘ │
│ │  - 单元格编辑(后期) │ │                                 │
│ │  - 选择高亮           │ │                                 │
│ └──────────────────────┘ │                                 │
└──────────────────────────┴─────────────────────────────────┘

🗂️ 文件结构规划

frontend-v2/src/modules/dc/pages/tool-c/
├── index.tsx                        # 主入口(状态管理+布局)
├── components/
│   ├── Header.tsx                   # 顶部栏
│   ├── Toolbar.tsx                  # 工具栏7个按钮
│   ├── DataGrid.tsx                 # AG Grid表格核心
│   ├── Sidebar.tsx                  # 右侧栏容器
│   ├── ChatPanel.tsx                # Chat面板
│   ├── InsightsPanel.tsx            # Insights面板
│   ├── MessageList.tsx              # 消息列表
│   ├── MessageItem.tsx              # 单条消息
│   ├── CodeBlock.tsx                # 代码块渲染
│   └── InputArea.tsx                # 输入框
├── hooks/
│   ├── useToolC.ts                  # 核心状态管理Hook
│   ├── useSession.ts                # Session管理
│   ├── useChat.ts                   # AI对话逻辑
│   └── useDataGrid.ts               # 表格数据管理
└── types/
    └── index.ts                     # TypeScript类型定义

frontend-v2/src/modules/dc/api/
└── toolC.ts                         # API封装6个方法

⏱️ Day 4 开发计划6-8小时

阶段1项目初始化1小时

1.1 安装依赖

cd frontend-v2
npm install ag-grid-community ag-grid-react
npm install prismjs @types/prismjs
npm install react-markdown

1.2 创建文件结构

mkdir -p src/modules/dc/pages/tool-c/{components,hooks,types}
touch src/modules/dc/pages/tool-c/index.tsx
# ... 创建其他文件

1.3 更新路由配置

// src/App.tsx 或路由配置文件
<Route path="/data-cleaning/tool-c" element={<ToolC />} />

1.4 更新Portal页面

// src/modules/dc/pages/Portal.tsx
// 将Tool C的status从'disabled'改为'ready'
{
  id: 'tool-c',
  title: '科研数据编辑器',
  status: 'ready', // ⭐ 修改这里
  route: '/data-cleaning/tool-c'
}

交付物

  • 依赖安装完成
  • 文件结构创建
  • Portal可点击进入Tool C

阶段2页面框架搭建2小时

2.1 创建主入口 (index.tsx)

import { useState } from 'react';
import Header from './components/Header';
import Toolbar from './components/Toolbar';
import DataGrid from './components/DataGrid';
import Sidebar from './components/Sidebar';

interface ToolCState {
  sessionId: string | null;
  fileName: string;
  data: any[];
  columns: any[];
  messages: any[];
  isLoading: boolean;
}

const ToolC = () => {
  const [state, setState] = useState<ToolCState>({
    sessionId: null,
    fileName: 'cqol-demo.csv',
    data: [],
    columns: [],
    messages: [],
    isLoading: false,
  });

  return (
    <div className="h-screen w-screen flex flex-col bg-slate-50">
      <Header fileName={state.fileName} />
      <div className="flex-1 flex overflow-hidden">
        <div className="flex-1 flex flex-col">
          <Toolbar />
          <div className="flex-1 overflow-auto p-6">
            <DataGrid data={state.data} columns={state.columns} />
          </div>
        </div>
        <Sidebar messages={state.messages} />
      </div>
    </div>
  );
};

export default ToolC;

2.2 创建Header组件

// components/Header.tsx
import { Table2, Undo2, Redo2, Download, ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';

interface HeaderProps {
  fileName: string;
  onUndo?: () => void;
  onRedo?: () => void;
  onExport?: () => void;
}

const Header: React.FC<HeaderProps> = ({ fileName, onUndo, onRedo, onExport }) => {
  const navigate = useNavigate();

  return (
    <header className="bg-white border-b border-slate-200 h-14 flex-none flex items-center justify-between px-4 z-20 shadow-sm">
      <div className="flex items-center gap-3">
        <button
          onClick={() => navigate('/data-cleaning')}
          className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 text-slate-600"
        >
          <ArrowLeft className="w-4 h-4" />
          <span className="text-sm">返回</span>
        </button>
        <div className="h-4 w-px bg-slate-300"></div>
        <div className="w-8 h-8 bg-emerald-600 rounded-lg flex items-center justify-center text-white shadow-md">
          <Table2 size={20} />
        </div>
        <span className="font-bold text-lg text-slate-900">
          科研数据编辑器
          <span className="text-emerald-600 text-xs px-1.5 py-0.5 bg-emerald-50 rounded-full ml-1">
            Pro
          </span>
        </span>
        <div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
        <span className="text-xs text-slate-500 font-mono">{fileName}</span>
      </div>
      <div className="flex items-center gap-3">
        <div className="flex items-center bg-slate-100 rounded-lg p-1">
          <button
            onClick={onUndo}
            className="p-1.5 text-slate-400 hover:text-slate-700 rounded-md hover:bg-white transition-all"
            disabled
          >
            <Undo2 size={16} />
          </button>
          <button
            onClick={onRedo}
            className="p-1.5 text-slate-400 hover:text-slate-700 rounded-md hover:bg-white transition-all"
            disabled
          >
            <Redo2 size={16} />
          </button>
        </div>
        <button
          onClick={onExport}
          className="flex items-center gap-2 px-4 py-1.5 bg-slate-900 text-white rounded-lg text-xs font-medium hover:bg-slate-800 transition-all shadow-md"
        >
          <Download size={12} /> 导出结果
        </button>
      </div>
    </header>
  );
};

export default Header;

2.3 创建Toolbar组件

// components/Toolbar.tsx
import { Calculator, CalendarClock, ArrowLeftRight, FileSearch, Wand2, Filter, Search } from 'lucide-react';

const ToolbarButton = ({ icon: Icon, label, colorClass }: any) => (
  <button className={`flex flex-col items-center justify-center w-20 h-14 rounded-lg transition-all hover:shadow-sm ${colorClass}`}>
    <Icon className="w-5 h-5 mb-1" />
    <span className="text-[10px] font-medium">{label}</span>
  </button>
);

const Toolbar = () => {
  return (
    <div className="bg-white border-b border-slate-200 px-4 py-2 flex items-center gap-1 overflow-x-auto flex-none shadow-sm z-10">
      <ToolbarButton icon={Calculator} label="生成新变量" colorClass="text-emerald-600 bg-emerald-50 hover:bg-emerald-100" />
      <ToolbarButton icon={CalendarClock} label="时间差" colorClass="text-blue-600 bg-blue-50 hover:bg-blue-100" />
      <ToolbarButton icon={ArrowLeftRight} label="横纵转换" colorClass="text-cyan-600 bg-cyan-50 hover:bg-cyan-100" />
      <div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
      <ToolbarButton icon={FileSearch} label="查重" colorClass="text-orange-600 bg-orange-50 hover:bg-orange-100" />
      <ToolbarButton icon={Wand2} label="多重插补" colorClass="text-rose-600 bg-rose-50 hover:bg-rose-100" />
      <div className="w-[1px] h-8 bg-slate-200 mx-2"></div>
      <ToolbarButton icon={Filter} label="筛选分析集" colorClass="text-indigo-600 bg-indigo-50 hover:bg-indigo-100" />
      <div className="flex-1"></div>
      <div className="relative">
        <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
        <input
          className="pl-9 pr-4 py-1.5 text-sm bg-slate-100 border-none rounded-full w-48 focus:w-64 transition-all outline-none focus:ring-2 focus:ring-emerald-500/20"
          placeholder="搜索值..."
        />
      </div>
    </div>
  );
};

export default Toolbar;

交付物

  • Header完成带返回按钮
  • Toolbar完成7个按钮
  • 基础布局可见

阶段3AG Grid集成3-4小时

3.1 创建DataGrid组件

// components/DataGrid.tsx
import { AgGridReact } from 'ag-grid-react';
import { ColDef } from 'ag-grid-community';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import { useMemo } from 'react';

interface DataGridProps {
  data: any[];
  columns: Array<{ id: string; name: string; type?: string }>;
  onCellValueChanged?: (params: any) => void;
}

const DataGrid: React.FC<DataGridProps> = ({ data, columns, onCellValueChanged }) => {
  // 将列定义转换为AG Grid格式
  const columnDefs: ColDef[] = useMemo(() => {
    return columns.map(col => ({
      field: col.id,
      headerName: col.name,
      sortable: true,
      filter: true,
      resizable: true,
      editable: false, // MVP阶段暂不支持编辑
      width: 120,
      minWidth: 80,
      // 根据类型设置样式
      cellClass: (params) => {
        if (params.value === null || params.value === undefined || params.value === '') {
          return 'bg-red-50 text-red-400 italic';
        }
        return '';
      },
    }));
  }, [columns]);

  // 默认列配置
  const defaultColDef: ColDef = {
    flex: 0,
    sortable: true,
    filter: true,
    resizable: true,
  };

  // 如果没有数据,显示空状态
  if (data.length === 0) {
    return (
      <div className="bg-white border border-slate-200 shadow-sm rounded-xl p-12 text-center">
        <div className="text-slate-400 text-sm">
          <p className="mb-2">暂无数据</p>
          <p>请在右侧AI助手中上传文件</p>
        </div>
      </div>
    );
  }

  return (
    <div className="bg-white border border-slate-200 shadow-sm rounded-xl overflow-hidden h-full">
      <div className="ag-theme-alpine h-full">
        <AgGridReact
          rowData={data}
          columnDefs={columnDefs}
          defaultColDef={defaultColDef}
          animateRows={true}
          rowSelection="multiple"
          onCellValueChanged={onCellValueChanged}
          domLayout="normal"
          suppressCellFocus={false}
          enableCellTextSelection={true}
        />
      </div>
    </div>
  );
};

export default DataGrid;

3.2 自定义AG Grid样式

/* 在全局CSS或组件内添加 */
.ag-theme-alpine {
  --ag-header-background-color: #f8fafc;
  --ag-header-foreground-color: #475569;
  --ag-border-color: #e2e8f0;
  --ag-row-hover-color: #f0fdf4;
  --ag-selected-row-background-color: #d1fae5;
  --ag-font-family: inherit;
  --ag-font-size: 13px;
}

.ag-theme-alpine .ag-header-cell {
  font-weight: 600;
}

.ag-theme-alpine .ag-cell {
  display: flex;
  align-items: center;
}

3.3 测试数据加载

// 临时测试在index.tsx中硬编码测试数据
const mockData = [
  { id: 'P001', age: 27, sex: '女', bmi: 23.1, smoke: '否' },
  { id: 'P002', age: 24, sex: '男', bmi: 16.7, smoke: '是' },
  { id: 'P003', age: null, sex: '男', bmi: null, smoke: '是' },
];

const mockColumns = [
  { id: 'id', name: '病人ID', type: 'text' },
  { id: 'age', name: '年龄', type: 'number' },
  { id: 'sex', name: '性别', type: 'category' },
  { id: 'bmi', name: 'BMI', type: 'number' },
  { id: 'smoke', name: '吸烟史', type: 'category' },
];

交付物

  • AG Grid成功渲染
  • 缺失值高亮显示
  • 列排序/过滤可用
  • 列宽可调整

⏱️ Day 5 开发计划6-8小时

阶段4AI Chat面板3小时

4.1 创建Sidebar组件

// components/Sidebar.tsx
import { useState } from 'react';
import { MessageSquare, Lightbulb, X } from 'lucide-react';
import ChatPanel from './ChatPanel';
import InsightsPanel from './InsightsPanel';

interface SidebarProps {
  isOpen: boolean;
  onClose: () => void;
  messages: any[];
  onSendMessage: (message: string) => void;
  dataStats?: any;
}

const Sidebar: React.FC<SidebarProps> = ({ isOpen, onClose, messages, onSendMessage, dataStats }) => {
  const [activeTab, setActiveTab] = useState<'chat' | 'insights'>('chat');

  if (!isOpen) return null;

  return (
    <div className="w-[420px] bg-white border-l border-slate-200 flex flex-col">
      {/* Tab Header */}
      <div className="h-14 border-b border-slate-200 flex items-center justify-between px-4">
        <div className="flex gap-1">
          <button
            onClick={() => setActiveTab('chat')}
            className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
              activeTab === 'chat'
                ? 'bg-emerald-50 text-emerald-700'
                : 'text-slate-600 hover:bg-slate-50'
            }`}
          >
            <MessageSquare size={16} />
            AI助手
          </button>
          <button
            onClick={() => setActiveTab('insights')}
            className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
              activeTab === 'insights'
                ? 'bg-emerald-50 text-emerald-700'
                : 'text-slate-600 hover:bg-slate-50'
            }`}
          >
            <Lightbulb size={16} />
            数据洞察
          </button>
        </div>
        <button
          onClick={onClose}
          className="p-1 rounded-md hover:bg-slate-100 text-slate-400 hover:text-slate-600"
        >
          <X size={18} />
        </button>
      </div>

      {/* Tab Content */}
      <div className="flex-1 overflow-hidden">
        {activeTab === 'chat' ? (
          <ChatPanel messages={messages} onSendMessage={onSendMessage} />
        ) : (
          <InsightsPanel dataStats={dataStats} />
        )}
      </div>
    </div>
  );
};

export default Sidebar;

4.2 创建ChatPanel组件

// components/ChatPanel.tsx
import { useRef, useEffect } from 'react';
import MessageItem from './MessageItem';
import InputArea from './InputArea';

interface ChatPanelProps {
  messages: any[];
  onSendMessage: (message: string) => void;
  isLoading?: boolean;
}

const ChatPanel: React.FC<ChatPanelProps> = ({ messages, onSendMessage, isLoading }) => {
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  return (
    <div className="flex flex-col h-full">
      {/* Messages Area */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 && (
          <div className="text-center text-slate-400 text-sm py-12">
            <p className="mb-2">👋 您好!我是您的AI数据分析师</p>
            <p>试试说:"把年龄大于60的标记为老年组"</p>
          </div>
        )}
        {messages.map((msg) => (
          <MessageItem key={msg.id} message={msg} />
        ))}
        {isLoading && (
          <div className="flex items-center gap-2 text-slate-400 text-sm">
            <div className="animate-spin rounded-full h-4 w-4 border-2 border-emerald-500 border-t-transparent"></div>
            <span>AI正在思考...</span>
          </div>
        )}
        <div ref={messagesEndRef} />
      </div>

      {/* Input Area */}
      <InputArea onSend={onSendMessage} disabled={isLoading} />
    </div>
  );
};

export default ChatPanel;

4.3 创建MessageItem组件

// components/MessageItem.tsx
import { User, Bot, CheckCircle2, XCircle, Play } from 'lucide-react';
import CodeBlock from './CodeBlock';

interface MessageItemProps {
  message: {
    id: string;
    role: 'user' | 'assistant' | 'system';
    content: string;
    code?: {
      content: string;
      status: 'pending' | 'running' | 'success' | 'error';
    };
    onExecute?: () => void;
  };
}

const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
  const { role, content, code } = message;

  // 系统消息
  if (role === 'system') {
    return (
      <div className="text-center">
        <div className="inline-block bg-slate-100 text-slate-600 text-xs px-3 py-1 rounded-full">
          {content}
        </div>
      </div>
    );
  }

  // 用户消息
  if (role === 'user') {
    return (
      <div className="flex items-start gap-3 justify-end">
        <div className="bg-emerald-600 text-white px-4 py-2 rounded-2xl rounded-tr-sm max-w-[80%] text-sm">
          {content}
        </div>
        <div className="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center flex-shrink-0">
          <User size={16} className="text-slate-600" />
        </div>
      </div>
    );
  }

  // AI消息
  return (
    <div className="flex items-start gap-3">
      <div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
        <Bot size={16} className="text-emerald-600" />
      </div>
      <div className="flex-1 space-y-2">
        <div className="bg-slate-50 text-slate-800 px-4 py-2 rounded-2xl rounded-tl-sm text-sm">
          {content}
        </div>
        {code && (
          <div className="space-y-2">
            <CodeBlock code={code.content} language="python" />
            <div className="flex items-center gap-2">
              <button
                onClick={message.onExecute}
                disabled={code.status === 'running'}
                className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
                  code.status === 'pending'
                    ? 'bg-emerald-600 text-white hover:bg-emerald-700'
                    : code.status === 'running'
                    ? 'bg-slate-300 text-slate-500 cursor-not-allowed'
                    : code.status === 'success'
                    ? 'bg-green-100 text-green-700'
                    : 'bg-red-100 text-red-700'
                }`}
              >
                {code.status === 'pending' && (
                  <>
                    <Play size={12} /> 执行代码
                  </>
                )}
                {code.status === 'running' && '执行中...'}
                {code.status === 'success' && (
                  <>
                    <CheckCircle2 size={12} /> 执行成功
                  </>
                )}
                {code.status === 'error' && (
                  <>
                    <XCircle size={12} /> 执行失败
                  </>
                )}
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
};

export default MessageItem;

4.4 创建CodeBlock组件

// components/CodeBlock.tsx
import { Copy, Check } from 'lucide-react';
import { useState } from 'react';
import Prism from 'prismjs';
import 'prismjs/themes/prism-tomorrow.css';
import 'prismjs/components/prism-python';

interface CodeBlockProps {
  code: string;
  language?: string;
}

const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = 'python' }) => {
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    navigator.clipboard.writeText(code);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  const highlightedCode = Prism.highlight(
    code,
    Prism.languages[language] || Prism.languages.python,
    language
  );

  return (
    <div className="relative bg-slate-900 rounded-lg overflow-hidden">
      <div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
        <span className="text-slate-400 text-xs font-mono">{language}</span>
        <button
          onClick={handleCopy}
          className="flex items-center gap-1 text-slate-400 hover:text-white text-xs transition-colors"
        >
          {copied ? (
            <>
              <Check size={12} /> 已复制
            </>
          ) : (
            <>
              <Copy size={12} /> 复制
            </>
          )}
        </button>
      </div>
      <pre className="p-4 overflow-x-auto text-xs">
        <code
          className={`language-${language}`}
          dangerouslySetInnerHTML={{ __html: highlightedCode }}
        />
      </pre>
    </div>
  );
};

export default CodeBlock;

4.5 创建InputArea组件

// components/InputArea.tsx
import { Send } from 'lucide-react';
import { useState, KeyboardEvent } from 'react';

interface InputAreaProps {
  onSend: (message: string) => void;
  disabled?: boolean;
}

const InputArea: React.FC<InputAreaProps> = ({ onSend, disabled }) => {
  const [input, setInput] = useState('');

  const handleSend = () => {
    if (!input.trim() || disabled) return;
    onSend(input);
    setInput('');
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="border-t border-slate-200 p-4">
      <div className="flex gap-2">
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="告诉AI你想做什么..."
          className="flex-1 resize-none border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
          rows={3}
          disabled={disabled}
        />
        <button
          onClick={handleSend}
          disabled={!input.trim() || disabled}
          className="bg-emerald-600 text-white p-3 rounded-lg hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed transition-colors"
        >
          <Send size={18} />
        </button>
      </div>
      <div className="text-xs text-slate-400 mt-2">
         <kbd className="px-1 py-0.5 bg-slate-100 rounded">Enter</kbd> 发送,
        <kbd className="px-1 py-0.5 bg-slate-100 rounded">Shift + Enter</kbd> 换行
      </div>
    </div>
  );
};

export default InputArea;

交付物

  • Chat面板完整UI
  • 消息列表渲染
  • 代码块语法高亮
  • 输入框交互

阶段5API集成2-3小时

5.1 创建API封装

// api/toolC.ts
import axios from 'axios';

const BASE_URL = '/api/v1/dc/tool-c';

// Session管理
export const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);
  
  const response = await axios.post(`${BASE_URL}/sessions/upload`, formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
  });
  
  return response.data;
};

export const getSession = async (sessionId: string) => {
  const response = await axios.get(`${BASE_URL}/sessions/${sessionId}`);
  return response.data;
};

export const getPreviewData = async (sessionId: string) => {
  const response = await axios.get(`${BASE_URL}/sessions/${sessionId}/preview`);
  return response.data;
};

export const updateHeartbeat = async (sessionId: string) => {
  const response = await axios.post(`${BASE_URL}/sessions/${sessionId}/heartbeat`);
  return response.data;
};

// AI功能
export const generateCode = async (sessionId: string, message: string) => {
  const response = await axios.post(`${BASE_URL}/ai/generate`, {
    sessionId,
    message,
  });
  return response.data;
};

export const executeCode = async (sessionId: string, code: string, messageId: string) => {
  const response = await axios.post(`${BASE_URL}/ai/execute`, {
    sessionId,
    code,
    messageId,
  });
  return response.data;
};

export const processMessage = async (sessionId: string, message: string) => {
  const response = await axios.post(`${BASE_URL}/ai/process`, {
    sessionId,
    message,
    maxRetries: 3,
  });
  return response.data;
};

export const getChatHistory = async (sessionId: string, limit = 10) => {
  const response = await axios.get(`${BASE_URL}/ai/history/${sessionId}?limit=${limit}`);
  return response.data;
};

5.2 创建核心Hook

// hooks/useToolC.ts
import { useState, useCallback } from 'react';
import * as api from '../../api/toolC';

export const useToolC = () => {
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [fileName, setFileName] = useState('');
  const [data, setData] = useState<any[]>([]);
  const [columns, setColumns] = useState<any[]>([]);
  const [messages, setMessages] = useState<any[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  // 上传文件
  const handleFileUpload = useCallback(async (file: File) => {
    try {
      setIsLoading(true);
      const result = await api.uploadFile(file);
      
      if (result.success) {
        setSessionId(result.data.sessionId);
        setFileName(file.name);
        
        // 获取预览数据
        const preview = await api.getPreviewData(result.data.sessionId);
        if (preview.success) {
          setData(preview.data.rows);
          setColumns(preview.data.columns.map((col: string) => ({
            id: col,
            name: col,
            type: 'text',
          })));
        }
        
        // 添加欢迎消息
        setMessages([{
          id: Date.now(),
          role: 'system',
          content: `✅ 文件上传成功!共${preview.data.totalRows}行 x ${preview.data.totalCols}列`,
        }]);
      }
    } catch (error: any) {
      console.error('上传失败:', error);
      setMessages([{
        id: Date.now(),
        role: 'system',
        content: `❌ 上传失败: ${error.message}`,
      }]);
    } finally {
      setIsLoading(false);
    }
  }, []);

  // 发送AI消息一步到位生成+执行)
  const handleSendMessage = useCallback(async (message: string) => {
    if (!sessionId) {
      alert('请先上传文件');
      return;
    }

    // 添加用户消息
    setMessages(prev => [...prev, {
      id: Date.now(),
      role: 'user',
      content: message,
    }]);

    try {
      setIsLoading(true);
      
      // 调用process接口生成+执行,一步到位)
      const result = await api.processMessage(sessionId, message);
      
      if (result.success) {
        // 添加AI消息带代码和执行结果
        setMessages(prev => [...prev, {
          id: Date.now() + 1,
          role: 'assistant',
          content: result.data.explanation,
          code: {
            content: result.data.code,
            status: result.data.executeResult.success ? 'success' : 'error',
          },
        }]);

        // 如果执行成功,更新表格数据
        if (result.data.executeResult.success && result.data.executeResult.newDataPreview) {
          setData(result.data.executeResult.newDataPreview);
          
          setMessages(prev => [...prev, {
            id: Date.now() + 2,
            role: 'system',
            content: `✅ 代码执行成功!表格已更新。${result.data.retryCount > 0 ? `(重试${result.data.retryCount}次后成功)` : ''}`,
          }]);
        } else if (!result.data.executeResult.success) {
          setMessages(prev => [...prev, {
            id: Date.now() + 2,
            role: 'system',
            content: `❌ 执行失败: ${result.data.executeResult.error}`,
          }]);
        }
      }
    } catch (error: any) {
      console.error('AI处理失败:', error);
      setMessages(prev => [...prev, {
        id: Date.now() + 1,
        role: 'system',
        content: `❌ 处理失败: ${error.response?.data?.error || error.message}`,
      }]);
    } finally {
      setIsLoading(false);
    }
  }, [sessionId]);

  return {
    sessionId,
    fileName,
    data,
    columns,
    messages,
    isLoading,
    handleFileUpload,
    handleSendMessage,
  };
};

5.3 在主组件中使用Hook

// index.tsx (更新)
import { useToolC } from './hooks/useToolC';

const ToolC = () => {
  const {
    fileName,
    data,
    columns,
    messages,
    isLoading,
    handleFileUpload,
    handleSendMessage,
  } = useToolC();

  return (
    <div className="h-screen w-screen flex flex-col bg-slate-50">
      <Header fileName={fileName} />
      <div className="flex-1 flex overflow-hidden">
        <div className="flex-1 flex flex-col">
          <Toolbar />
          <div className="flex-1 overflow-auto p-6">
            <DataGrid data={data} columns={columns} />
          </div>
        </div>
        <Sidebar
          isOpen={true}
          onClose={() => {}}
          messages={messages}
          onSendMessage={handleSendMessage}
        />
      </div>
    </div>
  );
};

交付物

  • 6个API方法封装
  • useToolC核心Hook
  • 端到端流程可用

阶段6文件上传 + InsightsPanel1小时

6.1 添加文件上传UI

// 在ChatPanel中添加上传区域messages为空时显示
{messages.length === 0 && (
  <div className="text-center py-12">
    <div className="mb-6">
      <input
        type="file"
        accept=".csv,.xlsx,.xls"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) handleFileUpload(file);
        }}
        className="hidden"
        id="file-upload"
      />
      <label
        htmlFor="file-upload"
        className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg cursor-pointer hover:bg-emerald-700 transition-all"
      >
        <Upload size={18} />
        上传CSV/Excel文件
      </label>
    </div>
    <p className="text-slate-400 text-sm">
      支持格式:CSV, XLSX, XLS(最大10MB
    </p>
  </div>
)}

6.2 创建InsightsPanel组件

// components/InsightsPanel.tsx
import { BarChart3, AlertCircle, Database, Layers } from 'lucide-react';

interface InsightsPanelProps {
  dataStats?: {
    totalRows: number;
    totalCols: number;
    missingCount: number;
    missingRate: number;
  };
}

const InsightsPanel: React.FC<InsightsPanelProps> = ({ dataStats }) => {
  if (!dataStats) {
    return (
      <div className="p-6 text-center text-slate-400">
        <p>暂无数据</p>
      </div>
    );
  }

  return (
    <div className="p-4 space-y-4 overflow-y-auto">
      <div className="bg-slate-50 rounded-lg p-4">
        <div className="flex items-center gap-2 mb-3">
          <Database size={16} className="text-slate-600" />
          <h3 className="font-semibold text-sm text-slate-900">数据规模</h3>
        </div>
        <div className="space-y-2 text-sm">
          <div className="flex justify-between">
            <span className="text-slate-600">总行数</span>
            <span className="font-medium">{dataStats.totalRows}</span>
          </div>
          <div className="flex justify-between">
            <span className="text-slate-600">总列数</span>
            <span className="font-medium">{dataStats.totalCols}</span>
          </div>
        </div>
      </div>

      <div className="bg-red-50 rounded-lg p-4">
        <div className="flex items-center gap-2 mb-3">
          <AlertCircle size={16} className="text-red-600" />
          <h3 className="font-semibold text-sm text-red-900">缺失值</h3>
        </div>
        <div className="space-y-2 text-sm">
          <div className="flex justify-between">
            <span className="text-red-700">缺失数量</span>
            <span className="font-medium text-red-900">{dataStats.missingCount}</span>
          </div>
          <div className="flex justify-between">
            <span className="text-red-700">缺失率</span>
            <span className="font-medium text-red-900">{dataStats.missingRate.toFixed(1)}%</span>
          </div>
        </div>
      </div>
    </div>
  );
};

export default InsightsPanel;

交付物

  • 文件上传功能
  • Insights面板

🎯 Day 4-5 验收标准

必达目标MVP

  • 页面可访问Portal可点击进入Tool C
  • 文件上传支持CSV/Excel上传
  • 表格展示AG Grid渲染真实数据cqol-demo.csv
  • AI对话右侧Chat面板可发送消息
  • 代码生成AI生成Python代码并显示
  • 代码执行:点击执行按钮,表格数据更新
  • 对话历史:多轮对话正常显示

可选目标Day 6优化

  • 单元格手动编辑
  • 工具栏按钮功能
  • 撤销/重做
  • 导出Excel
  • 数据洞察卡片完善
  • 响应式布局

📝 测试场景

场景1上传文件

  1. 访问 /data-cleaning/tool-c
  2. 点击"上传CSV/Excel文件"
  3. 选择 cqol-demo.csv
  4. 验证表格显示21列x300+行数据

场景2AI缺失值处理

  1. 在Chat输入"把sex列的缺失值填补为众数"
  2. 验证AI生成代码
  3. 点击"执行代码"
  4. 验证:表格数据更新,缺失值消失

场景3AI年龄分组

  1. 在Chat输入"把age列按18、60分为未成年、成年、老年三组"
  2. 验证AI生成代码
  3. 点击"执行代码"
  4. 验证表格新增age_group列

场景4对话历史

  1. 发送多条消息
  2. 验证:所有对话保留
  3. 刷新页面
  4. 验证:对话历史加载正常

🚨 风险与应对

风险 概率 影响 应对措施
AG Grid学习成本高 Day 4上午集中学习官方文档
API调试时间长 先用Mock数据再对接真实API
代码语法高亮问题 Prism.js配置不复杂有现成方案
性能问题300+行数据) AG Grid自带虚拟滚动无需优化

📦 依赖安装清单

# AG Grid
npm install ag-grid-community ag-grid-react

# 代码高亮
npm install prismjs @types/prismjs

# Markdown渲染可选
npm install react-markdown

# 已有依赖(无需安装)
# - react
# - react-router-dom
# - lucide-react
# - axios
# - tailwindcss

📚 参考资源


最终交付物清单

Day 4

  • 文件结构创建10个文件
  • Header组件
  • Toolbar组件
  • DataGrid组件AG Grid
  • 基础布局完成

Day 5

  • Sidebar组件
  • ChatPanel组件
  • MessageItem组件
  • CodeBlock组件
  • InputArea组件
  • InsightsPanel组件
  • API封装toolC.ts
  • useToolC Hook
  • 端到端流程测试通过

文档

  • 本开发计划
  • Day 4-5开发完成总结Day 5结束后
  • API对接文档Day 5结束后

制定人: AI Assistant
确认人: 用户
开始时间: Day 4上午
预期完成: Day 5下午


🚀 准备好开始Day 4开发了吗