Files
AIclinicalresearch/frontend-v2/src/modules/dc/pages/tool-c/index.tsx
HaHafeng b64896a307 feat(deploy): Complete PostgreSQL migration and Docker image build
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
2025-12-24 18:21:55 +08:00

489 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;