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
38 KiB
38 KiB
工具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个按钮)
- ✅ 基础布局可见
阶段3:AG 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小时)
阶段4:AI 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
- ✅ 消息列表渲染
- ✅ 代码块语法高亮
- ✅ 输入框交互
阶段5:API集成(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:文件上传 + InsightsPanel(1小时)
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:上传文件
- 访问
/data-cleaning/tool-c - 点击"上传CSV/Excel文件"
- 选择
cqol-demo.csv - 验证:表格显示21列x300+行数据
场景2:AI缺失值处理
- 在Chat输入:"把sex列的缺失值填补为众数"
- 验证:AI生成代码
- 点击"执行代码"
- 验证:表格数据更新,缺失值消失
场景3:AI年龄分组
- 在Chat输入:"把age列按18、60分为未成年、成年、老年三组"
- 验证:AI生成代码
- 点击"执行代码"
- 验证:表格新增age_group列
场景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开发了吗?