Files
AIclinicalresearch/docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具A_超级合并器_原型设计.tsx
HaHafeng d4d33528c7 feat(dc): Complete Phase 1 - Portal workbench page development
Summary:
- Implement DC module Portal page with 3 tool cards
- Create ToolCard component with decorative background and hover animations
- Implement TaskList component with table layout and progress bars
- Implement AssetLibrary component with tab switching and file cards
- Complete database verification (4 tables confirmed)
- Complete backend API verification (6 endpoints ready)
- Optimize UI to match prototype design (V2.html)

Frontend Components (~715 lines):
- components/ToolCard.tsx - Tool cards with animations
- components/TaskList.tsx - Recent tasks table view
- components/AssetLibrary.tsx - Data asset library with tabs
- hooks/useRecentTasks.ts - Task state management
- hooks/useAssets.ts - Asset state management
- pages/Portal.tsx - Main portal page
- types/portal.ts - TypeScript type definitions

Backend Verification:
- Backend API: 1495 lines code verified
- Database: dc_schema with 4 tables verified
- API endpoints: 6 endpoints tested (templates API works)

Documentation:
- Database verification report
- Backend API test report
- Phase 1 completion summary
- UI optimization report
- Development task checklist
- Development plan for Tool B

Status: Phase 1 completed (100%), ready for browser testing
Next: Phase 2 - Tool B Step 1 and 2 development
2025-12-02 21:53:24 +08:00

452 lines
24 KiB
TypeScript
Raw Permalink 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.
import React, { useState, useEffect } from 'react';
import {
UploadCloud,
FileSpreadsheet,
ArrowRight,
CheckCircle2,
AlertCircle,
Settings2,
Table2,
CalendarClock,
Columns,
Download,
Bot,
ChevronRight,
ChevronDown,
Trash2,
Info
} from 'lucide-react';
// --- 类型定义 ---
type Step = 'upload' | 'anchor' | 'columns' | 'processing' | 'result';
interface FileItem {
id: string;
name: string;
size: string;
rows: number;
status: 'ready' | 'error';
columns: string[];
}
const ToolA_SuperMerger = () => {
const [currentStep, setCurrentStep] = useState<Step>('upload');
// 模拟已上传的文件
const [files, setFiles] = useState<FileItem[]>([
{ id: 'f1', name: '2023_住院记录_主表.xlsx', size: '2.4 MB', rows: 3500, status: 'ready', columns: ['住院号', '姓名', '入院日期', '出院日期', '主诊断', '科室'] },
{ id: 'f2', name: '检验科_血常规.xlsx', size: '15.1 MB', rows: 45000, status: 'ready', columns: ['病人ID', '报告时间', '白细胞', '红细胞', '血红蛋白', '审核医生'] },
{ id: 'f3', name: '超声检查报告.xlsx', size: '8.2 MB', rows: 12000, status: 'ready', columns: ['申请号', 'PatientNo', '检查时间', '超声描述', '超声提示'] },
]);
// 配置状态
const [mainFileId, setMainFileId] = useState<string>('f1');
const [idColumn, setIdColumn] = useState<string>('住院号');
const [timeColumn, setTimeColumn] = useState<string>('入院日期');
const [timeWindow, setTimeWindow] = useState<string>('window'); // 'window' | 'nearest'
// 模拟列选择状态 (默认全选)
const [selectedCols, setSelectedCols] = useState<Record<string, string[]>>({
'f1': ['住院号', '入院日期', '主诊断'],
'f2': ['白细胞', '红细胞'],
'f3': ['超声提示']
});
// 处理列勾选切换
const toggleColumn = (fileId: string, col: string) => {
setSelectedCols(prev => {
const currentCols = prev[fileId] || [];
const isSelected = currentCols.includes(col);
let newCols;
if (isSelected) {
newCols = currentCols.filter(c => c !== col);
} else {
newCols = [...currentCols, col];
}
return {
...prev,
[fileId]: newCols
};
});
};
// 处理状态
const [progress, setProgress] = useState(0);
// 模拟处理动画
useEffect(() => {
if (currentStep === 'processing') {
const interval = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(interval);
setCurrentStep('result');
return 100;
}
return prev + 2;
});
}, 50);
return () => clearInterval(interval);
}
}, [currentStep]);
// --- 渲染函数 ---
// 步骤导航条
const renderSteps = () => (
<div className="flex items-center justify-center mb-8 px-4">
{[
{ id: 'upload', label: '1. 上传数据' },
{ id: 'anchor', label: '2. 定基准(骨架)' },
{ id: 'columns', label: '3. 选列(血肉)' },
{ id: 'result', label: '4. 结果报告' }
].map((step, idx, arr) => (
<React.Fragment key={step.id}>
<div className={`flex flex-col items-center z-10 ${['processing'].includes(currentStep) && step.id === 'result' ? 'opacity-50' : ''}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold mb-2 transition-colors
${currentStep === step.id ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' :
(arr.findIndex(s => s.id === currentStep) > idx || currentStep === 'result') ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-500'}`}>
{(arr.findIndex(s => s.id === currentStep) > idx || currentStep === 'result') && step.id !== currentStep ? <CheckCircle2 className="w-5 h-5" /> : idx + 1}
</div>
<span className={`text-xs font-medium ${currentStep === step.id ? 'text-blue-700' : 'text-slate-500'}`}>{step.label}</span>
</div>
{idx < arr.length - 1 && (
<div className={`h-[2px] w-16 -mt-6 mx-2 ${arr.findIndex(s => s.id === currentStep) > idx ? 'bg-emerald-500' : 'bg-slate-200'}`}></div>
)}
</React.Fragment>
))}
</div>
);
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-800 p-8">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-sm border border-slate-200 min-h-[600px] flex flex-col">
{/* Header */}
<div className="px-8 py-6 border-b border-slate-100 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
<FileSpreadsheet className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="text-xs text-slate-500">访(Visit)</p>
</div>
</div>
<button className="text-slate-400 hover:text-slate-600">
<Info className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 p-8">
{renderSteps()}
{/* Step 1: Upload */}
{currentStep === 'upload' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="border-2 border-dashed border-blue-200 bg-blue-50/50 rounded-xl p-10 flex flex-col items-center justify-center text-center cursor-pointer hover:bg-blue-50 transition-colors">
<div className="w-16 h-16 bg-white rounded-full shadow-sm flex items-center justify-center mb-4 text-blue-500">
<UploadCloud className="w-8 h-8" />
</div>
<h3 className="text-lg font-medium text-slate-900"> Excel </h3>
<p className="text-sm text-slate-500 mt-2 max-w-md"> .xlsx, .csv 1 </p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-bold text-slate-700 mb-2"> ({files.length})</h4>
{files.map(file => (
<div key={file.id} className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg hover:border-blue-300 transition-colors">
<div className="flex items-center gap-3">
<FileSpreadsheet className="w-5 h-5 text-emerald-600" />
<div>
<div className="text-sm font-medium text-slate-900">{file.name}</div>
<div className="text-xs text-slate-500">{file.size} {file.rows.toLocaleString()} </div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs px-2 py-1 bg-emerald-50 text-emerald-700 rounded-md border border-emerald-100"></span>
<button className="text-slate-400 hover:text-red-500"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
))}
</div>
</div>
)}
{/* Step 2: Anchor (骨架配置) */}
{currentStep === 'anchor' && (
<div className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-500">
<div className="bg-blue-50 border border-blue-100 p-4 rounded-lg flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-800">
<span className="font-bold"></span> <strong>ID</strong> <strong></strong>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* 2.1 选主表 */}
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-slate-900 text-white text-xs flex items-center justify-center">A</span>
(Visit Base)
</h3>
<div className="space-y-2">
{files.map(file => (
<label key={file.id} className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-all ${mainFileId === file.id ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-slate-200 hover:border-slate-300'}`}>
<div className="flex items-center gap-3">
<input type="radio" name="mainFile" className="text-blue-600" checked={mainFileId === file.id} onChange={() => setMainFileId(file.id)} />
<span className="text-sm font-medium text-slate-900">{file.name}</span>
</div>
{mainFileId === file.id && <span className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded"></span>}
</label>
))}
</div>
</div>
{/* 2.2 关键列映射 */}
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-slate-900 text-white text-xs flex items-center justify-center">B</span>
</h3>
<div className="bg-slate-50 p-4 rounded-lg space-y-4 border border-slate-200">
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1"> (Patient ID)</label>
<select className="w-full p-2 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" value={idColumn} onChange={(e) => setIdColumn(e.target.value)}>
{files.find(f => f.id === mainFileId)?.columns.map(col => (
<option key={col} value={col}>{col}</option>
))}
</select>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1"> (Time Anchor)</label>
<select className="w-full p-2 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" value={timeColumn} onChange={(e) => setTimeColumn(e.target.value)}>
{files.find(f => f.id === mainFileId)?.columns.map(col => (
<option key={col} value={col}>{col}</option>
))}
</select>
</div>
<div className="pt-2 border-t border-slate-200">
<label className="block text-xs font-semibold text-slate-500 mb-2"></label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={timeWindow === 'window'} onChange={() => setTimeWindow('window')} />
<span className="text-sm"> (±7)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={timeWindow === 'nearest'} onChange={() => setTimeWindow('nearest')} />
<span className="text-sm"></span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Columns (列选择 - 核心需求) */}
{currentStep === 'columns' && (
<div className="h-full flex flex-col animate-in fade-in slide-in-from-right-4 duration-500">
<div className="mb-4">
<h3 className="text-lg font-bold text-slate-900"></h3>
<p className="text-sm text-slate-500"></p>
</div>
<div className="flex-1 flex border border-slate-200 rounded-xl overflow-hidden min-h-[400px]">
{/* 左侧:源列树状选择 */}
<div className="w-1/2 border-r border-slate-200 bg-slate-50 flex flex-col">
<div className="p-3 bg-white border-b border-slate-200 font-semibold text-sm text-slate-700 flex justify-between">
<span></span>
<span className="text-xs text-blue-600 cursor-pointer"></span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{files.map(file => (
<div key={file.id} className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<div className={`px-3 py-2 flex items-center justify-between ${file.id === mainFileId ? 'bg-blue-50' : 'bg-slate-100'}`}>
<div className="flex items-center gap-2">
<input type="checkbox" checked readOnly className="rounded text-blue-600" />
<span className={`text-sm font-medium ${file.id === mainFileId ? 'text-blue-700' : 'text-slate-700'}`}>
{file.name} {file.id === mainFileId && '(主表)'}
</span>
</div>
<ChevronDown className="w-4 h-4 text-slate-400" />
</div>
<div className="p-2 space-y-1">
{file.columns.map(col => (
<label key={col} className="flex items-center gap-2 px-6 py-1 hover:bg-slate-50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedCols[file.id]?.includes(col)}
onChange={() => toggleColumn(file.id, col)}
className="rounded border-slate-300 text-blue-600 focus:ring-0"
/>
<span className="text-sm text-slate-600">{col}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
{/* 右侧:结果预览 (Schema Preview) */}
<div className="w-1/2 bg-white flex flex-col">
<div className="p-3 border-b border-slate-200 font-semibold text-sm text-slate-700">
()
</div>
<div className="flex-1 overflow-x-auto p-4">
<div className="flex gap-0 border border-slate-300 rounded shadow-sm">
{/* 主表列 */}
{files.filter(f => f.id === mainFileId).map(f => (
f.columns.filter(c => selectedCols[f.id]?.includes(c)).map((col, idx) => (
<div key={col} className={`flex-shrink-0 w-32 border-r border-slate-200 last:border-0 ${idx === 0 ? 'bg-blue-50' : 'bg-white'}`}>
<div className="h-8 px-2 flex items-center bg-slate-100 border-b border-slate-200 text-xs font-bold text-slate-700 truncate" title={col}>
{col}
</div>
<div className="h-10 px-2 flex items-center text-xs text-slate-400">
{col === '住院号' ? 'ZY001' : (col === '姓名' ? '张三' : '...')}
</div>
</div>
))
))}
{/* 辅表列 */}
{files.filter(f => f.id !== mainFileId).map(f => (
f.columns.filter(c => selectedCols[f.id]?.includes(c)).map(col => (
<div key={col} className="flex-shrink-0 w-32 border-r border-slate-200 last:border-0 bg-white">
<div className="h-8 px-2 flex items-center bg-purple-50 border-b border-slate-200 text-xs font-bold text-purple-800 truncate" title={`${f.name.split('.')[0]}_${col}`}>
{col}
</div>
<div className="h-10 px-2 flex items-center text-xs text-slate-400">
--
</div>
</div>
))
))}
</div>
<div className="mt-4 text-xs text-slate-400 text-center">
<ArrowRight className="w-4 h-4 inline-block mr-1" />
{Object.values(selectedCols).flat().length}
</div>
</div>
</div>
</div>
</div>
)}
{/* Step 4: Processing */}
{currentStep === 'processing' && (
<div className="flex flex-col items-center justify-center h-full animate-in fade-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 border-4 border-slate-100 rounded-full"></div>
<div className="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">...</h3>
<p className="text-slate-500 mb-8"> {files.length} 60,500 </p>
<div className="w-96 bg-slate-100 rounded-full h-2 overflow-hidden">
<div className="bg-blue-600 h-full transition-all duration-100" style={{ width: `${progress}%` }}></div>
</div>
<p className="text-xs text-slate-400 mt-2">{progress}% </p>
</div>
)}
{/* Step 5: Result (报告与出口) */}
{currentStep === 'result' && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 bg-emerald-50 rounded-xl border border-emerald-100 text-center">
<div className="text-2xl font-bold text-emerald-700">3,450</div>
<div className="text-xs text-emerald-600"> ()</div>
</div>
<div className="p-4 bg-orange-50 rounded-xl border border-orange-100 text-center">
<div className="text-2xl font-bold text-orange-700">12</div>
<div className="text-xs text-orange-600">ID </div>
</div>
<div className="p-4 bg-blue-50 rounded-xl border border-blue-100 text-center">
<div className="text-2xl font-bold text-blue-700">42</div>
<div className="text-xs text-blue-600"></div>
</div>
</div>
<div className="border border-slate-200 rounded-lg overflow-hidden mb-8">
<div className="bg-slate-50 px-4 py-2 text-xs font-bold text-slate-600 border-b border-slate-200">
5 (Top 5 Preview)
</div>
<table className="w-full text-left text-xs">
<thead className="bg-white text-slate-500 border-b border-slate-200">
<tr>
{selectedCols['f1'].slice(0,3).map(c => <th key={c} className="px-4 py-2">{c}</th>)}
<th className="px-4 py-2 text-purple-600"></th>
<th className="px-4 py-2 text-purple-600"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{[1,2,3,4,5].map(i => (
<tr key={i} className="hover:bg-slate-50">
<td className="px-4 py-2">ZY00{i}</td>
<td className="px-4 py-2">2023-01-0{i}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{4+i}.5</td>
<td className="px-4 py-2">{i%2===0 ? '结节' : '无异常'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-center gap-4">
<button className="flex items-center gap-2 px-6 py-3 bg-white border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 font-medium">
<Download className="w-4 h-4" />
</button>
<button className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium shadow-sm">
<Bot className="w-4 h-4" /> AI
</button>
<button className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium shadow-sm">
<Table2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Footer Navigation */}
{currentStep !== 'processing' && currentStep !== 'result' && (
<div className="px-8 py-4 border-t border-slate-100 flex justify-between bg-slate-50 rounded-b-2xl">
<button
className={`px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 ${currentStep === 'upload' ? 'invisible' : ''}`}
onClick={() => {
if(currentStep === 'anchor') setCurrentStep('upload');
if(currentStep === 'columns') setCurrentStep('anchor');
}}
>
</button>
<button
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium shadow-sm transition-transform active:scale-95"
onClick={() => {
if(currentStep === 'upload') setCurrentStep('anchor');
else if(currentStep === 'anchor') setCurrentStep('columns');
else if(currentStep === 'columns') setCurrentStep('processing');
}}
>
{currentStep === 'columns' ? '开始智能合并' : '下一步'} <ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
);
};
export default ToolA_SuperMerger;