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
320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { AlertTriangle, CheckCircle2, Download, ArrowRight, FileText, Check, RotateCcw, X } from 'lucide-react';
|
||
import { ToolBState } from './index';
|
||
import * as toolBApi from '../../api/toolB';
|
||
|
||
interface Step4VerifyProps {
|
||
state: ToolBState;
|
||
updateState: (updates: Partial<ToolBState>) => void;
|
||
onComplete: () => void;
|
||
}
|
||
|
||
interface VerifyRow {
|
||
id: string;
|
||
rowIndex: number;
|
||
text: string; // 原文摘要
|
||
fullText: string; // 原文全文
|
||
results: Record<string, {
|
||
A: string; // DeepSeek
|
||
B: string; // Qwen
|
||
chosen: string | null; // 用户采纳的值,null表示冲突未解决
|
||
}>;
|
||
status: 'clean' | 'conflict' | 'pending' | 'failed'; // 行状态
|
||
}
|
||
|
||
const Step4Verify: React.FC<Step4VerifyProps> = ({ state, onComplete }) => {
|
||
const [rows, setRows] = useState<VerifyRow[]>([]);
|
||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const hasLoaded = React.useRef(false); // 🔑 防止重复加载
|
||
|
||
// 从API加载验证数据
|
||
useEffect(() => {
|
||
// 🔑 如果已加载过,跳过
|
||
if (hasLoaded.current || !state.taskId) {
|
||
return;
|
||
}
|
||
hasLoaded.current = true;
|
||
|
||
const fetchItems = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
console.log('Fetching items for taskId:', state.taskId);
|
||
const { items } = await toolBApi.getTaskItems(state.taskId!);
|
||
|
||
// 🔑 转换后端数据到前端格式
|
||
const transformedRows: VerifyRow[] = items.map(item => {
|
||
const results: Record<string, { A: string; B: string; chosen: string | null }> = {};
|
||
|
||
// 从resultA和resultB构建results对象
|
||
const resultA = item.resultA || {};
|
||
const resultB = item.resultB || {};
|
||
const finalResult = item.finalResult || {};
|
||
|
||
// 获取所有字段名(合并两个模型的结果)
|
||
const allFields = new Set([
|
||
...Object.keys(resultA),
|
||
...Object.keys(resultB)
|
||
]);
|
||
|
||
allFields.forEach(fieldName => {
|
||
results[fieldName] = {
|
||
A: resultA[fieldName] || '未提取',
|
||
B: resultB[fieldName] || '未提取',
|
||
chosen: finalResult[fieldName] || null // 如果已有finalResult,使用它
|
||
};
|
||
});
|
||
|
||
return {
|
||
id: item.id,
|
||
rowIndex: item.rowIndex,
|
||
text: item.originalText.substring(0, 50) + '...', // 摘要
|
||
fullText: item.originalText,
|
||
results,
|
||
status: item.status
|
||
};
|
||
});
|
||
|
||
setRows(transformedRows);
|
||
console.log('Items loaded successfully:', transformedRows.length, 'rows');
|
||
} catch (error) {
|
||
console.error('Failed to load items:', error);
|
||
setRows([]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchItems();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [state.taskId]);
|
||
|
||
// 采纳值
|
||
const handleAdopt = async (rowId: string, fieldName: string, value: string | null) => {
|
||
// 先更新本地状态(乐观更新)
|
||
setRows(prev => prev.map(row => {
|
||
if (row.id !== rowId) return row;
|
||
const newResults = { ...row.results };
|
||
newResults[fieldName].chosen = value;
|
||
|
||
// 检查该行是否还有未解决的冲突
|
||
const hasConflict = Object.values(newResults).some(f => f.chosen === null);
|
||
return { ...row, results: newResults, status: hasConflict ? 'conflict' : 'clean' };
|
||
}));
|
||
|
||
// 如果value不为null,调用API保存
|
||
if (value !== null) {
|
||
try {
|
||
await toolBApi.resolveConflict(rowId, fieldName, value);
|
||
console.log('Conflict resolved:', { rowId, fieldName, value });
|
||
} catch (error) {
|
||
console.error('Failed to resolve conflict:', error);
|
||
// 可以在这里添加错误提示
|
||
}
|
||
}
|
||
};
|
||
|
||
// 统计数据
|
||
const conflictRowsCount = rows.filter(r => r.status === 'conflict').length;
|
||
|
||
return (
|
||
<div className="flex-1 flex flex-col relative h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||
{isLoading && (
|
||
<div className="text-center py-8 text-slate-500">
|
||
<div className="animate-spin w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||
<div>加载验证数据中...</div>
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && (
|
||
<>
|
||
{/* Toolbar */}
|
||
<div className="flex justify-between items-center mb-4">
|
||
<div className="flex items-center gap-4 text-sm">
|
||
<div className="flex items-center gap-2 bg-slate-100 px-3 py-1.5 rounded-lg">
|
||
<span className="text-slate-500">总数据:</span>
|
||
<span className="font-bold text-slate-900">{rows.length}</span>
|
||
</div>
|
||
{conflictRowsCount > 0 ? (
|
||
<div className="flex items-center gap-2 bg-orange-50 px-3 py-1.5 rounded-lg text-orange-700 animate-pulse">
|
||
<AlertTriangle className="w-4 h-4" />
|
||
<span className="font-bold">{conflictRowsCount} 条冲突待裁决</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 bg-emerald-50 px-3 py-1.5 rounded-lg text-emerald-700">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span className="font-bold">所有冲突已解决</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
className="px-4 py-2 bg-white border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 flex items-center gap-2"
|
||
onClick={async () => {
|
||
if (!state.taskId) return;
|
||
try {
|
||
const blob = await toolBApi.exportResults(state.taskId);
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${state.fileName}_当前结果.xlsx`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
console.error('Export failed:', error);
|
||
alert('导出失败,请重试');
|
||
}
|
||
}}
|
||
>
|
||
<Download className="w-4 h-4" /> 导出当前结果
|
||
</button>
|
||
<button
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 flex items-center gap-2 shadow-md shadow-purple-200"
|
||
onClick={onComplete}
|
||
>
|
||
完成并入库 <ArrowRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Data Grid */}
|
||
<div className="flex-1 bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden flex relative">
|
||
<div className="flex-1 overflow-auto">
|
||
<table className="w-full text-left text-sm border-collapse">
|
||
<thead className="bg-slate-50 text-slate-500 font-semibold border-b border-slate-200 sticky top-0 z-10">
|
||
<tr>
|
||
<th className="px-4 py-3 w-16 text-center">#</th>
|
||
<th className="px-4 py-3 w-64">原文摘要</th>
|
||
{state.fields.map(f => (
|
||
<th key={f.id} className={`px-4 py-3 ${f.width || 'w-40'}`}>{f.name}</th>
|
||
))}
|
||
<th className="px-4 py-3 w-24 text-center">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{rows.map((row, idx) => (
|
||
<tr
|
||
key={row.id}
|
||
className={`hover:bg-slate-50 transition-colors cursor-pointer ${selectedRowId === row.id ? 'bg-purple-50/50' : ''}`}
|
||
onClick={() => setSelectedRowId(row.id)}
|
||
>
|
||
<td className="px-4 py-3 text-center text-slate-400">{idx + 1}</td>
|
||
<td className="px-4 py-3 group relative">
|
||
<div className="flex items-center gap-2">
|
||
<FileText className="text-slate-300 shrink-0" size={14} />
|
||
<span className="truncate w-48 block text-slate-600" title={row.text}>{row.text}</span>
|
||
</div>
|
||
</td>
|
||
{/* 动态列 */}
|
||
{state.fields.map(f => {
|
||
const cell = row.results[f.name];
|
||
if (!cell) return <td key={f.id} className="px-4 py-3 text-slate-400">-</td>;
|
||
|
||
const isConflict = cell.A !== cell.B && cell.chosen === null;
|
||
const isResolved = cell.chosen !== null;
|
||
|
||
if (isConflict) {
|
||
return (
|
||
<td key={f.id} className="px-2 py-2 bg-orange-50/50 border-x border-orange-100 align-top">
|
||
<div className="flex flex-col gap-1.5">
|
||
<button
|
||
className="text-left text-xs px-2 py-1.5 rounded border border-blue-200 bg-white hover:bg-blue-50 hover:border-blue-400 transition-all flex justify-between group"
|
||
onClick={(e) => { e.stopPropagation(); handleAdopt(row.id, f.name, cell.A); }}
|
||
>
|
||
<span className="truncate max-w-[100px] text-slate-700" title={cell.A}>{cell.A}</span>
|
||
<span className="text-[10px] text-blue-400 group-hover:text-blue-600">DS</span>
|
||
</button>
|
||
<button
|
||
className="text-left text-xs px-2 py-1.5 rounded border border-orange-200 bg-white hover:bg-orange-50 hover:border-orange-400 transition-all flex justify-between group"
|
||
onClick={(e) => { e.stopPropagation(); handleAdopt(row.id, f.name, cell.B); }}
|
||
>
|
||
<span className="truncate max-w-[100px] text-slate-700" title={cell.B}>{cell.B}</span>
|
||
<span className="text-[10px] text-orange-400 group-hover:text-orange-600">QW</span>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<td key={f.id} className="px-4 py-3 align-top">
|
||
{isResolved && cell.chosen !== cell.A && cell.chosen !== cell.B ? (
|
||
<div className="flex items-center justify-between group">
|
||
<span className="text-purple-700 font-medium">{cell.chosen}</span>
|
||
<button
|
||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-blue-600"
|
||
title="重置"
|
||
onClick={(e) => { e.stopPropagation(); handleAdopt(row.id, f.name, null); }}
|
||
>
|
||
<RotateCcw size={12} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-1.5 text-slate-600">
|
||
<Check size={12} className="text-emerald-400" />
|
||
{cell.chosen || cell.A}
|
||
</div>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-4 py-3 text-center align-top">
|
||
{row.status === 'clean' ? (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">通过</span>
|
||
) : (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-700 animate-pulse">待裁决</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Drawer (侧边栏) */}
|
||
<div
|
||
className={`absolute right-0 top-0 bottom-0 w-96 bg-white border-l border-slate-200 shadow-xl transform transition-transform duration-300 z-20 flex flex-col ${selectedRowId ? 'translate-x-0' : 'translate-x-full'}`}
|
||
>
|
||
{selectedRowId && (() => {
|
||
const row = rows.find(r => r.id === selectedRowId);
|
||
if (!row) return null;
|
||
return (
|
||
<>
|
||
<div className="p-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
||
<div>
|
||
<h3 className="font-bold text-slate-800">病历原文详情</h3>
|
||
<p className="text-xs text-slate-500">Row ID: {row.id}</p>
|
||
</div>
|
||
<button onClick={() => setSelectedRowId(null)} className="text-slate-400 hover:text-slate-600"><X size={20} /></button>
|
||
</div>
|
||
<div className="flex-1 p-5 overflow-y-auto bg-white">
|
||
<p className="text-sm leading-7 text-slate-700 whitespace-pre-wrap font-medium font-serif">
|
||
{row.fullText}
|
||
</p>
|
||
</div>
|
||
<div className="p-4 bg-slate-50 border-t border-slate-200">
|
||
<h4 className="text-xs font-bold text-slate-500 uppercase mb-2">快速导航</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(row.results).map(([k, v]) => (
|
||
<span key={k} className={`text-xs px-2 py-1 rounded border ${v.chosen === null ? 'bg-orange-50 text-orange-700 border-orange-200' : 'bg-white text-slate-600 border-slate-200'}`}>
|
||
{k}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Step4Verify;
|