Summary: - PostgreSQL database migration to RDS completed (90MB SQL, 11 schemas) - Frontend Nginx Docker image built and pushed to ACR (v1.0, ~50MB) - Python microservice Docker image built and pushed to ACR (v1.0, 1.12GB) - Created 3 deployment documentation files Docker Configuration Files: - frontend-v2/Dockerfile: Multi-stage build with nginx:alpine - frontend-v2/.dockerignore: Optimize build context - frontend-v2/nginx.conf: SPA routing and API proxy - frontend-v2/docker-entrypoint.sh: Dynamic env injection - extraction_service/Dockerfile: Multi-stage build with Aliyun Debian mirror - extraction_service/.dockerignore: Optimize build context - extraction_service/requirements-prod.txt: Production dependencies (removed Nougat) Deployment Documentation: - docs/05-部署文档/00-部署进度总览.md: One-stop deployment status overview - docs/05-部署文档/07-前端Nginx-SAE部署操作手册.md: Frontend deployment guide - docs/05-部署文档/08-PostgreSQL数据库部署操作手册.md: Database deployment guide - docs/00-系统总体设计/00-系统当前状态与开发指南.md: Updated with deployment status Database Migration: - RDS instance: pgm-2zex1m2y3r23hdn5 (2C4G, PostgreSQL 15.0) - Database: ai_clinical_research - Schemas: 11 business schemas migrated successfully - Data: 3 users, 2 projects, 1204 literatures verified - Backup: rds_init_20251224_154529.sql (90MB) Docker Images: - Frontend: crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v1.0 - Python: crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.0 Key Achievements: - Resolved Docker Hub network issues (using generic tags) - Fixed 30 TypeScript compilation errors - Removed Nougat OCR to reduce image size by 1.5GB - Used Aliyun Debian mirror to resolve apt-get network issues - Implemented multi-stage builds for optimization Next Steps: - Deploy Python microservice to SAE - Build Node.js backend Docker image - Deploy Node.js backend to SAE - Deploy frontend Nginx to SAE - End-to-end verification testing Status: Docker images ready, SAE deployment pending
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
/**
|
||
* 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 FilterDialog from './components/FilterDialog';
|
||
import RecodeDialog from './components/RecodeDialog';
|
||
import BinningDialog from './components/BinningDialog';
|
||
import ConditionalDialog from './components/ConditionalDialog';
|
||
import MissingValueDialog from './components/MissingValueDialog';
|
||
import ComputeDialog from './components/ComputeDialog';
|
||
import TransformDialog from './components/TransformDialog';
|
||
import { useSessionStatus } from './hooks/useSessionStatus';
|
||
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;
|
||
isAlertClosed: boolean; // ✨ 新增:提示条关闭状态
|
||
|
||
// ✨ 上传进度状态(Postgres-Only架构 - 异步处理)
|
||
uploadProgress: number; // 0-100
|
||
uploadStatus: 'idle' | 'uploading' | 'parsing' | 'completed' | 'error';
|
||
uploadMessage: string;
|
||
|
||
// ✨ 轮询控制(React Query)
|
||
pollingInfo: { sessionId: string; jobId: string } | null;
|
||
|
||
// ✨ 功能按钮对话框状态
|
||
filterDialogVisible: boolean;
|
||
recodeDialogVisible: boolean;
|
||
binningDialogVisible: boolean;
|
||
conditionalDialogVisible: boolean;
|
||
dropnaDialogVisible: boolean;
|
||
computeDialogVisible: boolean;
|
||
pivotDialogVisible: 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,
|
||
isAlertClosed: false, // ✨ 初始状态:未关闭
|
||
uploadProgress: 0,
|
||
uploadStatus: 'idle',
|
||
uploadMessage: '',
|
||
pollingInfo: null, // ✨ 轮询控制
|
||
filterDialogVisible: false,
|
||
recodeDialogVisible: false,
|
||
binningDialogVisible: false,
|
||
conditionalDialogVisible: false,
|
||
dropnaDialogVisible: false,
|
||
computeDialogVisible: false,
|
||
pivotDialogVisible: false,
|
||
});
|
||
|
||
// 更新状态辅助函数
|
||
const updateState = (updates: Partial<ToolCState>) => {
|
||
setState((prev) => ({ ...prev, ...updates }));
|
||
};
|
||
|
||
// ==================== React Query 轮询(Postgres-Only架构 - 自动串行) ====================
|
||
|
||
// ✅ 使用 React Query Hook 进行轮询(自动防并发、自动清理)
|
||
const { progress, isReady, isError } = useSessionStatus({
|
||
sessionId: state.pollingInfo?.sessionId || null,
|
||
jobId: state.pollingInfo?.jobId || null,
|
||
enabled: !!state.pollingInfo, // ← 有 pollingInfo 时才启用
|
||
});
|
||
|
||
// ✅ 监听状态变化(解析完成时自动加载数据)
|
||
useEffect(() => {
|
||
if (isReady && state.pollingInfo) {
|
||
console.log('[ToolC] ✅ 解析完成(React Query检测),开始加载数据');
|
||
|
||
// 停止轮询
|
||
const currentSessionId = state.pollingInfo.sessionId;
|
||
updateState({ pollingInfo: null });
|
||
|
||
// 加载数据
|
||
loadPreviewData(currentSessionId);
|
||
}
|
||
}, [isReady, state.pollingInfo]);
|
||
|
||
// ✅ 监听轮询错误
|
||
useEffect(() => {
|
||
if (isError) {
|
||
console.error('[ToolC] ❌ 解析失败(React Query检测)');
|
||
|
||
updateState({
|
||
pollingInfo: null,
|
||
messages: [
|
||
{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `❌ 解析失败,请检查文件格式后重试。`,
|
||
},
|
||
],
|
||
isLoading: false,
|
||
uploadProgress: 0,
|
||
uploadStatus: 'error',
|
||
uploadMessage: '解析失败',
|
||
});
|
||
}
|
||
}, [isError]);
|
||
|
||
// ✅ 更新进度条(基于React Query的轮询结果)
|
||
useEffect(() => {
|
||
if (state.pollingInfo && progress > 0) {
|
||
const progressMessage =
|
||
progress < 30 ? '正在读取文件...' :
|
||
progress < 70 ? '正在解析Excel...' :
|
||
'正在清洗数据...';
|
||
|
||
updateState({
|
||
uploadProgress: progress,
|
||
uploadStatus: 'parsing',
|
||
uploadMessage: progressMessage,
|
||
});
|
||
}
|
||
}, [progress, state.pollingInfo]);
|
||
|
||
// ==================== 加载预览数据(独立函数) ====================
|
||
const loadPreviewData = async (sessionId: string) => {
|
||
try {
|
||
console.log('[ToolC] 🔄 加载预览数据:', sessionId);
|
||
|
||
// 显示100%进度
|
||
updateState({
|
||
uploadProgress: 100,
|
||
uploadStatus: 'completed',
|
||
uploadMessage: '解析完成!正在加载数据...',
|
||
});
|
||
|
||
// 获取预览数据
|
||
const preview = await api.getPreviewData(sessionId);
|
||
console.log('[ToolC] 📦 API 返回结果:', preview);
|
||
|
||
if (preview.success) {
|
||
const previewData = preview.data.previewData || preview.data.rows || [];
|
||
|
||
console.log('[ToolC] 📊 加载数据成功:', {
|
||
rows: previewData.length,
|
||
cols: preview.data.columns?.length || 0,
|
||
firstRow: previewData[0],
|
||
});
|
||
|
||
updateState({
|
||
data: previewData,
|
||
columns: (preview.data.columns || []).map((col) => ({
|
||
id: col,
|
||
name: col,
|
||
type: 'text',
|
||
})),
|
||
messages: [
|
||
{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `✅ 解析完成!共 ${preview.data.totalRows || 0} 行 × ${preview.data.totalCols || 0} 列数据。`,
|
||
},
|
||
],
|
||
isLoading: false,
|
||
uploadProgress: 0,
|
||
uploadStatus: 'idle',
|
||
uploadMessage: '',
|
||
});
|
||
} else {
|
||
throw new Error('API returned success=false');
|
||
}
|
||
} catch (error: any) {
|
||
console.error('[ToolC] ❌ 加载数据失败:', error);
|
||
updateState({
|
||
messages: [
|
||
{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `❌ 加载数据失败:${error.message}`,
|
||
},
|
||
],
|
||
isLoading: false,
|
||
uploadProgress: 0,
|
||
uploadStatus: 'error',
|
||
uploadMessage: '加载失败',
|
||
});
|
||
}
|
||
};
|
||
|
||
// ==================== 文件上传(Postgres-Only架构 - 异步处理) ====================
|
||
const handleFileUpload = async (file: File) => {
|
||
try {
|
||
// 初始化状态
|
||
updateState({
|
||
isLoading: true,
|
||
uploadProgress: 0,
|
||
uploadStatus: 'uploading',
|
||
uploadMessage: '正在上传文件...',
|
||
});
|
||
|
||
// 1. ⚡ 上传文件(立即返回 sessionId + jobId)
|
||
const result = await api.uploadFile(file);
|
||
|
||
if (result.success) {
|
||
const { sessionId, jobId } = result.data as any;
|
||
|
||
console.log('[ToolC] ✅ 文件上传成功,启动 React Query 轮询');
|
||
console.log('[ToolC] 📊 sessionId:', sessionId, 'jobId:', jobId);
|
||
|
||
updateState({
|
||
sessionId,
|
||
fileName: file.name,
|
||
uploadProgress: 10,
|
||
uploadStatus: 'parsing',
|
||
uploadMessage: '文件上传成功!正在解析中...',
|
||
pollingInfo: { sessionId, jobId }, // ✅ 启动 React Query 轮询
|
||
messages: [
|
||
{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `📤 文件上传成功!正在解析中,请稍候...`,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
console.error('[ToolC] 上传失败:', error);
|
||
updateState({
|
||
messages: [
|
||
{
|
||
id: Date.now(),
|
||
role: 'system',
|
||
content: `❌ 上传失败:${error.response?.data?.error || error.message}`,
|
||
},
|
||
],
|
||
isLoading: false,
|
||
uploadProgress: 0,
|
||
uploadStatus: 'error',
|
||
uploadMessage: '上传失败',
|
||
});
|
||
}
|
||
};
|
||
|
||
// ==================== 功能按钮数据更新 ====================
|
||
const handleQuickActionDataUpdate = (newData: any[]) => {
|
||
if (newData && newData.length > 0) {
|
||
const newColumns = Object.keys(newData[0]).map(key => ({
|
||
id: key,
|
||
name: key,
|
||
type: 'text',
|
||
}));
|
||
updateState({
|
||
data: newData,
|
||
columns: newColumns,
|
||
});
|
||
}
|
||
};
|
||
|
||
// ==================== 导出Excel ====================
|
||
const handleExport = async () => {
|
||
if (!state.sessionId) {
|
||
alert('请先上传文件');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// ✅ 从后端读取完整数据(AI处理后的数据已保存到OSS)
|
||
const response = await fetch(`/api/v1/dc/tool-c/sessions/${state.sessionId}/export`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('导出失败');
|
||
}
|
||
|
||
// 创建下载链接
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${state.fileName.replace(/\.[^/.]+$/, '')}_cleaned.xlsx`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (error: any) {
|
||
console.error('导出失败:', error);
|
||
alert('导出失败:' + error.message);
|
||
}
|
||
};
|
||
|
||
// ==================== 心跳机制 ====================
|
||
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-gradient-to-br from-slate-50 to-slate-100 overflow-hidden">
|
||
{/* 顶部栏 */}
|
||
<Header
|
||
fileName={state.fileName || '未上传文件'}
|
||
onExport={handleExport}
|
||
isSidebarOpen={state.isSidebarOpen}
|
||
onToggleSidebar={() => updateState({ isSidebarOpen: !state.isSidebarOpen })}
|
||
/>
|
||
|
||
{/* ✨ 上传进度提示(Postgres-Only 异步处理) */}
|
||
{state.uploadStatus !== 'idle' && state.uploadStatus !== 'error' && (
|
||
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-medium text-blue-900">
|
||
{state.uploadMessage}
|
||
</span>
|
||
<span className="text-sm text-blue-700">
|
||
{state.uploadProgress}%
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-blue-200 rounded-full h-2">
|
||
<div
|
||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||
style={{ width: `${state.uploadProgress}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主工作区 - ⭐ Phase 1: 添加overflow-hidden禁止页面滚动 */}
|
||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||
{/* 左侧:表格区域 - ⭐ 添加overflow-hidden */}
|
||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||
<Toolbar
|
||
sessionId={state.sessionId}
|
||
onFilterClick={() => updateState({ filterDialogVisible: true })}
|
||
onRecodeClick={() => updateState({ recodeDialogVisible: true })}
|
||
onBinningClick={() => updateState({ binningDialogVisible: true })}
|
||
onConditionalClick={() => updateState({ conditionalDialogVisible: true })}
|
||
onDropnaClick={() => updateState({ dropnaDialogVisible: true })}
|
||
onComputeClick={() => updateState({ computeDialogVisible: true })}
|
||
onPivotClick={() => updateState({ pivotDialogVisible: true })}
|
||
/>
|
||
<div className="flex-1 p-4 flex flex-col min-h-0 overflow-hidden">
|
||
{/* ✨ 优化:提示只显示前50行(可关闭) */}
|
||
{/* ⭐ 已删除:表格仅展示前50行提示(现在是全量显示)*/}
|
||
<div className="flex-1 min-h-0 overflow-hidden">
|
||
<DataGrid data={state.data} columns={state.columns} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:AI 数据清洗助手 - 独立滚动 */}
|
||
{state.isSidebarOpen && (
|
||
<Sidebar
|
||
isOpen={state.isSidebarOpen}
|
||
onClose={() => updateState({ isSidebarOpen: false })}
|
||
sessionId={state.sessionId}
|
||
onFileUpload={handleFileUpload}
|
||
onDataUpdate={(newData) => {
|
||
// ✅ 修复:同时更新列定义(从新数据中提取列名)
|
||
if (newData && newData.length > 0) {
|
||
const newColumns = Object.keys(newData[0]).map(key => ({
|
||
id: key,
|
||
name: key,
|
||
type: 'text',
|
||
}));
|
||
updateState({
|
||
data: newData,
|
||
columns: newColumns,
|
||
});
|
||
} else {
|
||
updateState({ data: newData });
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* ✨ 功能按钮对话框 */}
|
||
<FilterDialog
|
||
visible={state.filterDialogVisible}
|
||
columns={state.columns}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ filterDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
|
||
<RecodeDialog
|
||
visible={state.recodeDialogVisible}
|
||
columns={state.columns}
|
||
data={state.data}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ recodeDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
|
||
<BinningDialog
|
||
visible={state.binningDialogVisible}
|
||
columns={state.columns}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ binningDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
|
||
<ConditionalDialog
|
||
visible={state.conditionalDialogVisible}
|
||
columns={state.columns.map(col => ({ field: col.id, headerName: col.name }))}
|
||
data={state.data}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ conditionalDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
|
||
<MissingValueDialog
|
||
visible={state.dropnaDialogVisible}
|
||
columns={state.columns}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ dropnaDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
|
||
<ComputeDialog
|
||
visible={state.computeDialogVisible}
|
||
columns={state.columns}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ computeDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
|
||
<TransformDialog
|
||
visible={state.pivotDialogVisible}
|
||
columns={state.columns}
|
||
sessionId={state.sessionId}
|
||
onClose={() => updateState({ pivotDialogVisible: false })}
|
||
onApply={handleQuickActionDataUpdate}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ToolC;
|
||
|