feat(dc/tool-c): 完成前端基础框架(Day 4 MVP)
核心功能: - 新增Tool C主入口(index.tsx, 258行):状态管理+布局 - 新增Header组件(91行):顶栏+返回按钮+导出 - 新增Toolbar组件(104行):7个快捷按钮+搜索框 - 新增DataGrid组件(111行):AG Grid Community集成 - 新增Sidebar组件(149行):右侧栏骨架版 - 新增API封装(toolC.ts, 218行):8个API方法 - 新增类型定义(types/index.ts, 62行) AG Grid集成: - 安装ag-grid-community + ag-grid-react - Excel风格表格渲染 - 列排序、过滤、调整宽度 - 缺失值高亮显示(红色斜体) - 数值右对齐 - 自定义Emerald绿色主题(ag-grid-custom.css, 113行) - 虚拟滚动支持大数据 路由配置: - 更新dc/index.tsx:新增ToolCModule懒加载 - 更新Portal.tsx:Tool C状态改为ready - 路径:/data-cleaning/tool-c API封装(8个方法): - uploadFile(上传CSV/Excel) - getSession(获取Session元数据) - getPreviewData(获取预览数据) - updateHeartbeat(延长10分钟) - generateCode(生成代码,不执行) - executeCode(执行代码) - processMessage(生成+执行,一步到位)核心API - getChatHistory(对话历史) 文档更新: - 新增Day 4前端基础完成总结(213行) - 更新工具C当前状态文档 - 更新TODO清单(Day 1-4标记完成) - 更新系统总体设计文档 测试数据准备: - cqol-demo.csv(21列x313行真实医疗数据) - G鼓膜穿孔数据.xlsx(备用) Day 5待完成: - MessageItem组件(消息渲染) - CodeBlock组件(Prism.js代码高亮) - InputArea组件(输入框交互) - InsightsPanel组件(数据洞察) - 完善Sidebar(完整Chat交互) - 端到端测试 影响范围: - frontend-v2/src/modules/dc/pages/tool-c/*(新增11个文件) - frontend-v2/src/modules/dc/api/toolC.ts(新增) - frontend-v2/src/modules/dc/index.tsx(更新路由) - frontend-v2/src/modules/dc/pages/Portal.tsx(启用Tool C) - docs/03-业务模块/DC-数据清洗整理/*(文档更新) - package.json(新增依赖) Breaking Changes: 无 总代码行数:+1106行(前端基础框架) Refs: #Tool-C-Day4
This commit is contained in:
10
frontend-v2/package-lock.json
generated
10
frontend-v2/package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.555.0",
|
||||
"mathjs": "^15.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
@@ -6169,6 +6170,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.555.0",
|
||||
"mathjs": "^15.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
|
||||
217
frontend-v2/src/modules/dc/api/toolC.ts
Normal file
217
frontend-v2/src/modules/dc/api/toolC.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tool C API封装
|
||||
*
|
||||
* 提供6个核心API方法:
|
||||
* - Session管理:上传、获取、预览、心跳
|
||||
* - AI功能:生成代码、执行代码、一步到位处理、获取历史
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = '/api/v1/dc/tool-c';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface UploadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
sessionId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
totalRows: number;
|
||||
totalCols: number;
|
||||
columns: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
success: boolean;
|
||||
data: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalRows: number;
|
||||
totalCols: number;
|
||||
columns: string[];
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PreviewData {
|
||||
success: boolean;
|
||||
data: {
|
||||
sessionId: string;
|
||||
fileName: string;
|
||||
totalRows: number;
|
||||
totalCols: number;
|
||||
columns: string[];
|
||||
rows: Record<string, any>[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIProcessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
code: string;
|
||||
explanation: string;
|
||||
executeResult: {
|
||||
success: boolean;
|
||||
result?: any;
|
||||
newDataPreview?: Record<string, any>[];
|
||||
error?: string;
|
||||
};
|
||||
retryCount: number;
|
||||
messageId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatHistoryResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
sessionId: string;
|
||||
history: Array<{
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}>;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Session管理 ====================
|
||||
|
||||
/**
|
||||
* 上传CSV/Excel文件
|
||||
*/
|
||||
export const uploadFile = async (file: File): Promise<UploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await axios.post(`${BASE_URL}/sessions/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000, // 30秒超时
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取Session元数据
|
||||
*/
|
||||
export const getSession = async (sessionId: string): Promise<SessionData> => {
|
||||
const response = await axios.get(`${BASE_URL}/sessions/${sessionId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取预览数据(前100行)
|
||||
*/
|
||||
export const getPreviewData = async (sessionId: string): Promise<PreviewData> => {
|
||||
const response = await axios.get(`${BASE_URL}/sessions/${sessionId}/preview`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取完整数据
|
||||
*/
|
||||
export const getFullData = async (sessionId: string): Promise<PreviewData> => {
|
||||
const response = await axios.get(`${BASE_URL}/sessions/${sessionId}/full`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新心跳(延长10分钟)
|
||||
*/
|
||||
export const updateHeartbeat = async (sessionId: string): Promise<{ success: boolean }> => {
|
||||
const response = await axios.post(`${BASE_URL}/sessions/${sessionId}/heartbeat`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除Session
|
||||
*/
|
||||
export const deleteSession = async (sessionId: string): Promise<{ success: boolean }> => {
|
||||
const response = await axios.delete(`${BASE_URL}/sessions/${sessionId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==================== AI功能 ====================
|
||||
|
||||
/**
|
||||
* 生成代码(不执行)
|
||||
*/
|
||||
export const generateCode = async (
|
||||
sessionId: string,
|
||||
message: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
code: string;
|
||||
explanation: string;
|
||||
messageId: 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
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
result: any;
|
||||
newDataPreview: Record<string, any>[];
|
||||
} | null;
|
||||
error?: string;
|
||||
}> => {
|
||||
const response = await axios.post(`${BASE_URL}/ai/execute`, {
|
||||
sessionId,
|
||||
code,
|
||||
messageId,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一步到位:生成并执行(带重试)⭐ 核心API
|
||||
*/
|
||||
export const processMessage = async (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
maxRetries: number = 3
|
||||
): Promise<AIProcessResponse> => {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/ai/process`,
|
||||
{
|
||||
sessionId,
|
||||
message,
|
||||
maxRetries,
|
||||
},
|
||||
{
|
||||
timeout: 60000, // 60秒超时(包含重试时间)
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取对话历史
|
||||
*/
|
||||
export const getChatHistory = async (
|
||||
sessionId: string,
|
||||
limit: number = 10
|
||||
): Promise<ChatHistoryResponse> => {
|
||||
const response = await axios.get(`${BASE_URL}/ai/history/${sessionId}?limit=${limit}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* 路由结构:
|
||||
* - / → Portal工作台(主页)
|
||||
* - /tool-a → Tool A - 超级合并器(暂未开发)
|
||||
* - /tool-b → Tool B - 病历结构化机器人(开发中)
|
||||
* - /tool-c → Tool C - 科研数据编辑器(暂未开发)
|
||||
* - /tool-b → Tool B - 病历结构化机器人(✅ 已完成)
|
||||
* - /tool-c → Tool C - 科研数据编辑器(🚀 Day 4-5开发中)
|
||||
*/
|
||||
|
||||
import { Suspense, lazy } from 'react';
|
||||
@@ -17,6 +17,7 @@ import Placeholder from '@/shared/components/Placeholder';
|
||||
// 懒加载组件
|
||||
const Portal = lazy(() => import('./pages/Portal'));
|
||||
const ToolBModule = lazy(() => import('./pages/tool-b/index'));
|
||||
const ToolCModule = lazy(() => import('./pages/tool-c/index'));
|
||||
|
||||
const DCModule = () => {
|
||||
return (
|
||||
@@ -46,17 +47,8 @@ const DCModule = () => {
|
||||
{/* Tool B - 病历结构化机器人(开发中) */}
|
||||
<Route path="tool-b/*" element={<ToolBModule />} />
|
||||
|
||||
{/* Tool C - 科研数据编辑器(暂未开发) */}
|
||||
<Route
|
||||
path="tool-c/*"
|
||||
element={
|
||||
<Placeholder
|
||||
title="Tool C - 科研数据编辑器"
|
||||
description="该工具正在开发中,敬请期待"
|
||||
moduleName="Excel风格的在线数据清洗工具"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Tool C - 科研数据编辑器(Day 4-5开发中) */}
|
||||
<Route path="tool-c/*" element={<ToolCModule />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ const Portal = () => {
|
||||
description: 'Excel 风格的在线清洗工具。支持缺失值填补、值替换与分析集导出。',
|
||||
icon: 'Table2',
|
||||
color: 'emerald',
|
||||
status: 'disabled',
|
||||
status: 'ready', // ⭐ Day 4-5开发
|
||||
route: '/data-cleaning/tool-c'
|
||||
}
|
||||
];
|
||||
|
||||
111
frontend-v2/src/modules/dc/pages/tool-c/components/DataGrid.tsx
Normal file
111
frontend-v2/src/modules/dc/pages/tool-c/components/DataGrid.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tool C DataGrid组件
|
||||
*
|
||||
* 基于AG Grid Community的Excel风格表格
|
||||
* 核心功能:列排序、列过滤、列宽调整、缺失值高亮
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { ColDef } from 'ag-grid-community';
|
||||
|
||||
// 引入AG Grid样式
|
||||
import 'ag-grid-community/styles/ag-grid.css';
|
||||
import 'ag-grid-community/styles/ag-theme-alpine.css';
|
||||
import './ag-grid-custom.css';
|
||||
|
||||
interface DataGridProps {
|
||||
data: Record<string, 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 '';
|
||||
},
|
||||
// 数值类型右对齐
|
||||
cellStyle: (params) => {
|
||||
if (typeof params.value === 'number') {
|
||||
return { textAlign: 'right' as const };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
// 默认列配置
|
||||
const defaultColDef: ColDef = useMemo(
|
||||
() => ({
|
||||
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 space-y-2">
|
||||
<p className="text-lg">📊 暂无数据</p>
|
||||
<p>请在右侧AI助手中上传CSV或Excel文件</p>
|
||||
<div className="mt-4 text-xs text-slate-300">
|
||||
支持格式:.csv, .xlsx, .xls(最大10MB)
|
||||
</div>
|
||||
</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" style={{ width: '100%', height: '100%' }}>
|
||||
<AgGridReact
|
||||
rowData={data}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows={true}
|
||||
rowSelection="multiple"
|
||||
onCellValueChanged={onCellValueChanged}
|
||||
domLayout="normal"
|
||||
suppressCellFocus={false}
|
||||
enableCellTextSelection={true}
|
||||
// 性能优化
|
||||
rowBuffer={10}
|
||||
debounceVerticalScrollbar={true}
|
||||
// 主题样式
|
||||
getRowStyle={(params) => {
|
||||
if (params.rowIndex % 2 === 0) {
|
||||
return { background: '#fafafa' };
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGrid;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Tool C Header组件
|
||||
*
|
||||
* 顶部栏:工具名称、文件名、操作按钮
|
||||
*/
|
||||
|
||||
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 hover:text-slate-900 transition-all"
|
||||
title="返回数据清洗工作台"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">返回工作台</span>
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-slate-300"></div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-emerald-600 rounded-lg flex items-center justify-center text-white shadow-md">
|
||||
<Table2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold 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>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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:cursor-not-allowed"
|
||||
disabled={true}
|
||||
title="撤销(开发中)"
|
||||
>
|
||||
<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:cursor-not-allowed"
|
||||
disabled={true}
|
||||
title="重做(开发中)"
|
||||
>
|
||||
<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"
|
||||
title="导出Excel"
|
||||
>
|
||||
<Download size={12} /> 导出结果
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
148
frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx
Normal file
148
frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Tool C Sidebar组件
|
||||
*
|
||||
* 右侧栏:AI Copilot(Chat + Insights)
|
||||
* Day 4: 骨架版本
|
||||
* Day 5: 完整实现
|
||||
*/
|
||||
|
||||
import { MessageSquare, X, Upload } from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
messages: any[];
|
||||
onSendMessage: (message: string) => void;
|
||||
onFileUpload: (file: File) => void;
|
||||
isLoading?: boolean;
|
||||
hasSession: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
messages,
|
||||
onSendMessage,
|
||||
onFileUpload,
|
||||
isLoading,
|
||||
hasSession,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="w-[420px] bg-white border-l border-slate-200 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-slate-200 flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2 text-emerald-700 font-medium">
|
||||
<MessageSquare size={18} />
|
||||
<span>AI助手</span>
|
||||
</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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 如果没有Session,显示上传区域 */}
|
||||
{!hasSession ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Upload className="w-8 h-8 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">上传数据文件</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
支持 CSV、Excel 格式<br />最大 10MB
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onFileUpload(file);
|
||||
}}
|
||||
className="hidden"
|
||||
id="file-upload-sidebar"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload-sidebar"
|
||||
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 text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
选择文件
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 如果有Session,显示简化的消息列表
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-sm py-12">
|
||||
<p className="mb-2">👋 您好!我是您的AI数据分析师</p>
|
||||
<p>试试说:"把age列的缺失值填补为中位数"</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-emerald-600 text-white ml-auto max-w-[80%]'
|
||||
: msg.role === 'system'
|
||||
? 'bg-slate-100 text-slate-600 text-center text-xs'
|
||||
: 'bg-slate-50 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
{hasSession && (
|
||||
<div className="border-t border-slate-200 p-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="告诉AI你想做什么..."
|
||||
className="flex-1 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"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
|
||||
onSendMessage(e.currentTarget.value);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className="bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-2">
|
||||
按 Enter 发送消息
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
103
frontend-v2/src/modules/dc/pages/tool-c/components/Toolbar.tsx
Normal file
103
frontend-v2/src/modules/dc/pages/tool-c/components/Toolbar.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tool C Toolbar组件
|
||||
*
|
||||
* 扁平化工具栏:7个快捷操作按钮 + 搜索框
|
||||
*/
|
||||
|
||||
import {
|
||||
Calculator,
|
||||
CalendarClock,
|
||||
ArrowLeftRight,
|
||||
FileSearch,
|
||||
Wand2,
|
||||
Filter,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
colorClass: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarButton: React.FC<ToolbarButtonProps> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
colorClass,
|
||||
onClick,
|
||||
disabled = true, // MVP阶段暂不可用
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex flex-col items-center justify-center w-20 h-14 rounded-lg transition-all hover:shadow-sm disabled:opacity-50 disabled:cursor-not-allowed ${colorClass}`}
|
||||
title={disabled ? '开发中...' : label}
|
||||
>
|
||||
<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">
|
||||
{/* 7个快捷按钮 */}
|
||||
<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="搜索值..."
|
||||
disabled
|
||||
title="开发中..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* AG Grid 自定义样式
|
||||
*
|
||||
* 目标:让AG Grid看起来更像原型图V6
|
||||
* - Emerald绿色主题
|
||||
* - 更圆润的边角
|
||||
* - 柔和的颜色
|
||||
*/
|
||||
|
||||
.ag-theme-alpine {
|
||||
/* 背景色 */
|
||||
--ag-background-color: #ffffff;
|
||||
--ag-header-background-color: #f8fafc;
|
||||
--ag-odd-row-background-color: #fafafa;
|
||||
|
||||
/* 前景色 */
|
||||
--ag-header-foreground-color: #475569;
|
||||
--ag-foreground-color: #1e293b;
|
||||
|
||||
/* 边框 */
|
||||
--ag-border-color: #e2e8f0;
|
||||
--ag-row-border-color: #f1f5f9;
|
||||
|
||||
/* 悬停和选择 */
|
||||
--ag-row-hover-color: #f0fdf4;
|
||||
--ag-selected-row-background-color: #d1fae5;
|
||||
|
||||
/* 字体 */
|
||||
--ag-font-family: inherit;
|
||||
--ag-font-size: 13px;
|
||||
|
||||
/* 聚焦 */
|
||||
--ag-range-selection-border-color: #10b981;
|
||||
--ag-input-focus-border-color: #10b981;
|
||||
}
|
||||
|
||||
/* 表头样式 */
|
||||
.ag-theme-alpine .ag-header-cell {
|
||||
font-weight: 600;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-header-cell:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* 单元格样式 */
|
||||
.ag-theme-alpine .ag-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 缺失值高亮样式 */
|
||||
.ag-theme-alpine .ag-cell.bg-red-50 {
|
||||
background-color: #fef2f2 !important;
|
||||
color: #f87171 !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-cell.bg-red-50::before {
|
||||
content: '—';
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 选中行样式 */
|
||||
.ag-theme-alpine .ag-row-selected {
|
||||
background-color: #d1fae5 !important;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-row-selected:hover {
|
||||
background-color: #a7f3d0 !important;
|
||||
}
|
||||
|
||||
/* 排序指示器 */
|
||||
.ag-theme-alpine .ag-header-cell-sorted-asc,
|
||||
.ag-theme-alpine .ag-header-cell-sorted-desc {
|
||||
background-color: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar-track,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar-track {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar-thumb,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ag-theme-alpine .ag-body-horizontal-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.ag-theme-alpine .ag-body-vertical-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 列调整大小指示器 */
|
||||
.ag-theme-alpine .ag-header-cell-resize {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
257
frontend-v2/src/modules/dc/pages/tool-c/index.tsx
Normal file
257
frontend-v2/src/modules/dc/pages/tool-c/index.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Tool C - 科研数据编辑器
|
||||
*
|
||||
* AI驱动的Excel风格数据清洗工具
|
||||
* 核心功能:AG Grid表格 + AI Copilot
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Header from './components/Header';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import DataGrid from './components/DataGrid';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import * as api from '../../api/toolC';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface ToolCState {
|
||||
// Session信息
|
||||
sessionId: string | null;
|
||||
fileName: string;
|
||||
|
||||
// 表格数据
|
||||
data: Record<string, any>[];
|
||||
columns: Array<{ id: string; name: string; type?: string }>;
|
||||
|
||||
// AI对话
|
||||
messages: Message[];
|
||||
|
||||
// UI状态
|
||||
isLoading: boolean;
|
||||
isSidebarOpen: boolean;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
code?: {
|
||||
content: string;
|
||||
status: 'pending' | 'running' | 'success' | 'error';
|
||||
};
|
||||
onExecute?: () => void;
|
||||
}
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
|
||||
const ToolC = () => {
|
||||
const [state, setState] = useState<ToolCState>({
|
||||
sessionId: null,
|
||||
fileName: '',
|
||||
data: [],
|
||||
columns: [],
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
isSidebarOpen: true,
|
||||
});
|
||||
|
||||
// 更新状态辅助函数
|
||||
const updateState = (updates: Partial<ToolCState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const handleFileUpload = async (file: File) => {
|
||||
try {
|
||||
updateState({ isLoading: true });
|
||||
|
||||
// 调用上传API
|
||||
const result = await api.uploadFile(file);
|
||||
|
||||
if (result.success) {
|
||||
updateState({
|
||||
sessionId: result.data.sessionId,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
// 获取预览数据
|
||||
const preview = await api.getPreviewData(result.data.sessionId);
|
||||
|
||||
if (preview.success) {
|
||||
updateState({
|
||||
data: preview.data.rows,
|
||||
columns: preview.data.columns.map((col) => ({
|
||||
id: col,
|
||||
name: col,
|
||||
type: 'text',
|
||||
})),
|
||||
messages: [
|
||||
{
|
||||
id: Date.now(),
|
||||
role: 'system',
|
||||
content: `✅ 文件上传成功!共 ${preview.data.totalRows} 行 × ${preview.data.totalCols} 列数据。`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('上传失败:', error);
|
||||
updateState({
|
||||
messages: [
|
||||
{
|
||||
id: Date.now(),
|
||||
role: 'system',
|
||||
content: `❌ 上传失败:${error.response?.data?.error || error.message}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
updateState({ isLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== AI消息发送 ====================
|
||||
const handleSendMessage = async (message: string) => {
|
||||
if (!state.sessionId) {
|
||||
alert('请先上传文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: Message = {
|
||||
id: Date.now(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
};
|
||||
|
||||
updateState({
|
||||
messages: [...state.messages, userMessage],
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用AI处理接口(一步到位:生成+执行)
|
||||
const result = await api.processMessage(state.sessionId, message);
|
||||
|
||||
if (result.success) {
|
||||
const { code, explanation, executeResult, retryCount } = result.data;
|
||||
|
||||
// 添加AI消息
|
||||
const aiMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
role: 'assistant',
|
||||
content: explanation,
|
||||
code: {
|
||||
content: code,
|
||||
status: executeResult.success ? 'success' : 'error',
|
||||
},
|
||||
};
|
||||
|
||||
updateState({
|
||||
messages: [...state.messages, userMessage, aiMessage],
|
||||
});
|
||||
|
||||
// 如果执行成功,更新表格数据
|
||||
if (executeResult.success && executeResult.newDataPreview) {
|
||||
updateState({
|
||||
data: executeResult.newDataPreview,
|
||||
messages: [
|
||||
...state.messages,
|
||||
userMessage,
|
||||
aiMessage,
|
||||
{
|
||||
id: Date.now() + 2,
|
||||
role: 'system',
|
||||
content: `✅ 代码执行成功!表格已更新。${retryCount > 0 ? `(重试 ${retryCount} 次后成功)` : ''}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (!executeResult.success) {
|
||||
updateState({
|
||||
messages: [
|
||||
...state.messages,
|
||||
userMessage,
|
||||
aiMessage,
|
||||
{
|
||||
id: Date.now() + 2,
|
||||
role: 'system',
|
||||
content: `❌ 执行失败:${executeResult.error}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('AI处理失败:', error);
|
||||
updateState({
|
||||
messages: [
|
||||
...state.messages,
|
||||
userMessage,
|
||||
{
|
||||
id: Date.now() + 1,
|
||||
role: 'system',
|
||||
content: `❌ 处理失败:${error.response?.data?.error || error.message}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
updateState({ isLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 心跳机制 ====================
|
||||
useEffect(() => {
|
||||
if (!state.sessionId) return;
|
||||
|
||||
// 每5分钟更新一次心跳
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
await api.updateHeartbeat(state.sessionId!);
|
||||
console.log('心跳更新成功');
|
||||
} catch (error) {
|
||||
console.error('心跳更新失败:', error);
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5分钟
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [state.sessionId]);
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-50 overflow-hidden">
|
||||
{/* 顶部栏 */}
|
||||
<Header
|
||||
fileName={state.fileName || '未上传文件'}
|
||||
onExport={() => alert('导出功能开发中...')}
|
||||
/>
|
||||
|
||||
{/* 主工作区 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左侧:表格区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Toolbar />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<DataGrid data={state.data} columns={state.columns} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:AI Copilot */}
|
||||
{state.isSidebarOpen && (
|
||||
<Sidebar
|
||||
isOpen={state.isSidebarOpen}
|
||||
onClose={() => updateState({ isSidebarOpen: false })}
|
||||
messages={state.messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onFileUpload={handleFileUpload}
|
||||
isLoading={state.isLoading}
|
||||
hasSession={!!state.sessionId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolC;
|
||||
|
||||
61
frontend-v2/src/modules/dc/pages/tool-c/types/index.ts
Normal file
61
frontend-v2/src/modules/dc/pages/tool-c/types/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Tool C 类型定义
|
||||
*/
|
||||
|
||||
// ==================== 消息类型 ====================
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
code?: {
|
||||
content: string;
|
||||
status: 'pending' | 'running' | 'success' | 'error';
|
||||
};
|
||||
onExecute?: () => void;
|
||||
}
|
||||
|
||||
// ==================== 状态类型 ====================
|
||||
|
||||
export interface ToolCState {
|
||||
// Session信息
|
||||
sessionId: string | null;
|
||||
fileName: string;
|
||||
|
||||
// 表格数据
|
||||
data: Record<string, any>[];
|
||||
columns: ColumnConfig[];
|
||||
|
||||
// AI对话
|
||||
messages: Message[];
|
||||
|
||||
// UI状态
|
||||
isLoading: boolean;
|
||||
isSidebarOpen: boolean;
|
||||
}
|
||||
|
||||
// ==================== 列配置 ====================
|
||||
|
||||
export interface ColumnConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'text' | 'number' | 'date' | 'category';
|
||||
width?: number;
|
||||
}
|
||||
|
||||
// ==================== 数据洞察 ====================
|
||||
|
||||
export interface DataStats {
|
||||
totalRows: number;
|
||||
totalCols: number;
|
||||
missingCount: number;
|
||||
missingRate: number;
|
||||
columnStats?: {
|
||||
[columnName: string]: {
|
||||
type: string;
|
||||
missing: number;
|
||||
unique: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user