Files
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

484 lines
34 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工具A - 超级合并器 (原型演示)</title>
<!-- 1. 引入 Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 2. 引入 React 和 ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- 3. 引入 Babel (用于解析 JSX) -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- 4. 引入 Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.animate-in { animation: fadeIn 0.5s ease-out forwards; }
/* 滚动条美化 */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #f1f5f9; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// --- 图标组件适配 (Safe Shim) ---
// 使用 ref 容器隔离 React 和 DOM 操作,避免 removeChild 错误
const LucideIcon = ({ name, className, size = 16, ...props }) => {
const containerRef = useRef(null);
useEffect(() => {
if (containerRef.current) {
containerRef.current.innerHTML = '';
const i = document.createElement('i');
i.setAttribute('data-lucide', name);
containerRef.current.appendChild(i);
lucide.createIcons({
root: containerRef.current,
nameAttr: 'data-lucide',
attrs: { class: className, width: size, height: size, ...props }
});
}
}, [name, className, size]);
return <span ref={containerRef} style={{ display: 'inline-flex', verticalAlign: 'middle' }}></span>;
};
// --- 业务逻辑代码 ---
const ToolA_SuperMerger = () => {
const [currentStep, setCurrentStep] = useState('upload');
// 模拟已上传的文件
const [files, setFiles] = useState([
{ 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('f1');
const [idColumn, setIdColumn] = useState('住院号');
const [timeColumn, setTimeColumn] = useState('入院日期');
const [timeWindow, setTimeWindow] = useState('window');
// 模拟列选择状态 (默认全选)
const [selectedCols, setSelectedCols] = useState({
'f1': ['住院号', '入院日期', '主诊断'],
'f2': ['白细胞', '红细胞'],
'f3': ['超声提示']
});
// 处理列勾选切换
const toggleColumn = (fileId, col) => {
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 ? <LucideIcon name="check-circle-2" size={20} /> : 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">
<LucideIcon name="file-spreadsheet" size={24} />
</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">
<LucideIcon name="info" size={20} />
</button>
</div>
{/* Body */}
<div className="flex-1 p-8">
{renderSteps()}
{/* Step 1: Upload */}
{currentStep === 'upload' && (
<div className="space-y-6 animate-in">
<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">
<LucideIcon name="upload-cloud" size={32} />
</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">
<LucideIcon name="file-spreadsheet" className="text-emerald-600" size={20} />
<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"><LucideIcon name="trash-2" size={16} /></button>
</div>
</div>
))}
</div>
</div>
)}
{/* Step 2: Anchor */}
{currentStep === 'anchor' && (
<div className="space-y-8 animate-in">
<div className="bg-blue-50 border border-blue-100 p-4 rounded-lg flex items-start gap-3">
<LucideIcon name="info" className="text-blue-600 mt-0.5" size={20} />
<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">
<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>
<LucideIcon name="chevron-down" className="text-slate-400" size={16} />
</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>
{/* 右侧:结果预览 */}
<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">
<LucideIcon name="arrow-right" className="inline-block mr-1" size={16} />
横向扩展共选中 {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">
<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">
<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">
<LucideIcon name="download" size={16} /> 下载合并结果
</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">
<LucideIcon name="bot" size={16} /> 发送到 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">
<LucideIcon name="table-2" size={16} /> 去编辑器清洗
</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' ? '开始智能合并' : '下一步'} <LucideIcon name="arrow-right" size={16} />
</button>
</div>
)}
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<ToolA_SuperMerger />);
</script>
</body>
</html>