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
This commit is contained in:
484
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具A_超级合并器.html
Normal file
484
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具A_超级合并器.html
Normal file
@@ -0,0 +1,484 @@
|
||||
<!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>
|
||||
452
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具A_超级合并器_原型设计.tsx
Normal file
452
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具A_超级合并器_原型设计.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
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;
|
||||
489
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具B_病历结构化机器人V4.html
Normal file
489
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具B_病历结构化机器人V4.html
Normal file
@@ -0,0 +1,489 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>工具B - 病历结构化机器人 V4 (最终版)</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 -->
|
||||
<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) ---
|
||||
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 TEMPLATES = {
|
||||
'lung_cancer': {
|
||||
'pathology': [
|
||||
{ id: 'p1', name: '病理类型', desc: '如:浸润性腺癌', width: 'w-40' },
|
||||
{ id: 'p2', name: '分化程度', desc: '高/中/低分化', width: 'w-32' },
|
||||
{ id: 'p3', name: '肿瘤大小', desc: '最大径,单位cm', width: 'w-32' },
|
||||
{ id: 'p4', name: '淋巴结转移', desc: '有/无及具体组别', width: 'w-48' },
|
||||
{ id: 'p5', name: '免疫组化', desc: '关键指标', width: 'w-56' }
|
||||
],
|
||||
'admission': [
|
||||
{ id: 'a1', name: '主诉', desc: '患者入院的主要症状', width: 'w-48' },
|
||||
{ id: 'a2', name: '现病史', desc: '发病过程', width: 'w-64' },
|
||||
{ id: 'a3', name: '吸烟史', desc: '吸烟年支数', width: 'w-32' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const ToolB_V4 = () => {
|
||||
const [currentStep, setCurrentStep] = useState('upload');
|
||||
|
||||
// 1. 上传与体检状态
|
||||
const [fileName, setFileName] = useState('2023_肺癌病理报告_批量.xlsx');
|
||||
const [selectedColumn, setSelectedColumn] = useState('');
|
||||
const [columnHealth, setColumnHealth] = useState('unknown');
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
// 2. Schema 状态
|
||||
const [diseaseType, setDiseaseType] = useState('lung_cancer');
|
||||
const [reportType, setReportType] = useState('pathology');
|
||||
const [fields, setFields] = useState([]);
|
||||
|
||||
// 3. 处理状态
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [logs, setLogs] = useState([]);
|
||||
|
||||
// 4. 验证状态
|
||||
const [rows, setRows] = useState([]);
|
||||
const [selectedRowId, setSelectedRowId] = useState(null);
|
||||
|
||||
// 初始化模版
|
||||
useEffect(() => {
|
||||
if (diseaseType && reportType && TEMPLATES[diseaseType]?.[reportType]) {
|
||||
setFields(TEMPLATES[diseaseType][reportType]);
|
||||
} else {
|
||||
setFields([]);
|
||||
}
|
||||
}, [diseaseType, reportType]);
|
||||
|
||||
// 模拟健康检查
|
||||
const runHealthCheck = (col) => {
|
||||
if (!col) return;
|
||||
setIsChecking(true);
|
||||
setColumnHealth('unknown');
|
||||
setTimeout(() => {
|
||||
setIsChecking(false);
|
||||
setColumnHealth(col.includes('ID') || col.includes('时间') ? 'bad' : 'good');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 模拟处理过程
|
||||
useEffect(() => {
|
||||
if (currentStep === 'processing') {
|
||||
setLogs([]);
|
||||
const timer = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(timer);
|
||||
// 初始化 Mock 数据
|
||||
setRows([
|
||||
{
|
||||
id: 1,
|
||||
text: "病理诊断:(右肺上叶)浸润性腺癌,腺泡为主型(70%)...",
|
||||
fullText: "病理诊断:(右肺上叶)浸润性腺癌,腺泡为主型(70%),伴乳头状成分(30%)。肿瘤大小 3.2*2.5*2.0cm。支气管断端未见癌。第7组淋巴结(1/3)见转移。免疫组化:TTF-1(+), NapsinA(+)。",
|
||||
results: {
|
||||
'病理类型': { A: '浸润性腺癌', B: '浸润性腺癌', chosen: '浸润性腺癌' },
|
||||
'分化程度': { A: '未提及', B: '中分化', chosen: null }, // 冲突
|
||||
'肿瘤大小': { A: '3.2cm', B: '3.2*2.5*2.0cm', chosen: null }, // 冲突
|
||||
'淋巴结转移': { A: '第7组(1/3)转移', B: '有', chosen: null }, // 冲突
|
||||
'免疫组化': { A: 'TTF-1(+)', B: 'TTF-1(+), NapsinA(+)', chosen: null }
|
||||
},
|
||||
status: 'conflict'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: "送检(左肺下叶)组织,镜下见异型细胞巢状排列...",
|
||||
fullText: "送检(左肺下叶)组织,镜下见异型细胞巢状排列,角化珠形成,符合鳞状细胞癌。免疫组化:CK5/6(+), P40(+), TTF-1(-)。",
|
||||
results: {
|
||||
'病理类型': { A: '鳞状细胞癌', B: '鳞状细胞癌', chosen: '鳞状细胞癌' },
|
||||
'分化程度': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'肿瘤大小': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'淋巴结转移': { A: '无', B: '无', chosen: '无' },
|
||||
'免疫组化': { A: 'CK5/6(+), P40(+)', B: 'CK5/6(+), P40(+)', chosen: 'CK5/6(+), P40(+)' }
|
||||
},
|
||||
status: 'clean'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: "右肺中叶穿刺活检:腺癌。EGFR 19-del(+)...",
|
||||
fullText: "右肺中叶穿刺活检:腺癌。基因检测结果显示:EGFR 19-del(+), ALK(-), ROS1(-)。建议靶向治疗。",
|
||||
results: {
|
||||
'病理类型': { A: '腺癌', B: '腺癌', chosen: '腺癌' },
|
||||
'分化程度': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'肿瘤大小': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'淋巴结转移': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'免疫组化': { A: 'EGFR(+)', B: 'EGFR 19-del(+)', chosen: null } // 冲突
|
||||
},
|
||||
status: 'conflict'
|
||||
}
|
||||
]);
|
||||
setTimeout(() => setCurrentStep('verify'), 800);
|
||||
return 100;
|
||||
}
|
||||
if (prev === 5) setLogs(l => [...l, '初始化双模型引擎 (DeepSeek-V3 & Qwen-Max)...']);
|
||||
if (prev === 20) setLogs(l => [...l, 'PII 脱敏完成...']);
|
||||
if (prev === 40) setLogs(l => [...l, 'DeepSeek: 提取进度 45%']);
|
||||
if (prev === 45) setLogs(l => [...l, 'Qwen: 提取进度 50%']);
|
||||
if (prev === 80) setLogs(l => [...l, '正在进行交叉验证 (Cross-Validation)...']);
|
||||
return prev + 1;
|
||||
});
|
||||
}, 40);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 采纳逻辑
|
||||
const handleAdopt = (rowId, fieldName, value) => {
|
||||
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' };
|
||||
}));
|
||||
};
|
||||
|
||||
const conflictRowsCount = rows.filter(r => r.status === 'conflict').length;
|
||||
|
||||
// 渲染步骤条
|
||||
const renderSteps = () => (
|
||||
<div className="flex items-center justify-center mb-6 px-4">
|
||||
{[
|
||||
{ id: 'upload', label: '1. 选列与体检' },
|
||||
{ id: 'schema', label: '2. 智能模版' },
|
||||
{ id: 'processing', label: '3. 双盲提取' },
|
||||
{ id: 'verify', label: '4. 交叉验证' },
|
||||
{ id: 'result', label: '5. 完成' }
|
||||
].map((step, idx, arr) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className={`flex flex-col items-center z-10 ${currentStep === 'result' && 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-purple-600 text-white shadow-lg shadow-purple-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-purple-700' : 'text-slate-500'}`}>{step.label}</span>
|
||||
</div>
|
||||
{idx < arr.length - 1 && (
|
||||
<div className={`h-[2px] w-12 -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-6">
|
||||
<div className="max-w-7xl mx-auto bg-white rounded-2xl shadow-sm border border-slate-200 min-h-[800px] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-8 py-5 border-b border-slate-100 flex justify-between items-center bg-gradient-to-r from-purple-50 via-white to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center text-purple-600">
|
||||
<LucideIcon name="bot" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">病历结构化机器人 V4</h1>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1"><LucideIcon name="split" size={12} /> 双模型交叉验证</span>
|
||||
<span>•</span>
|
||||
<span>DeepSeek-V3 & Qwen-Max</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{currentStep === 'verify' && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-blue-50 text-blue-700 text-xs rounded-full border border-blue-100 font-medium">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500"></div> DeepSeek
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-orange-50 text-orange-700 text-xs rounded-full border border-orange-100 font-medium">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500"></div> Qwen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="pt-6 pb-2">
|
||||
{renderSteps()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-8 pb-8 relative overflow-hidden flex flex-col">
|
||||
|
||||
{/* Step 1 */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 animate-in mt-8">
|
||||
<div className="flex items-center p-5 border border-slate-200 rounded-xl bg-slate-50">
|
||||
<LucideIcon name="file-text" className="text-slate-400 mr-4" size={40} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-slate-900 text-lg">{fileName}</div>
|
||||
<div className="text-sm text-slate-500">12.5 MB • 1,200 行</div>
|
||||
</div>
|
||||
<button className="text-sm text-purple-600 hover:underline font-medium">更换文件</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold text-slate-700">请选择包含病历文本的列 (Input Source)</label>
|
||||
<select className="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 outline-none" value={selectedColumn} onChange={(e) => { setSelectedColumn(e.target.value); runHealthCheck(e.target.value); }}>
|
||||
<option value="">-- 请选择 --</option>
|
||||
<option value="summary_text">出院小结 (Summary_Text)</option>
|
||||
<option value="pathology_report">病理报告 (Pathology)</option>
|
||||
<option value="patient_id">错误示范:病人ID列</option>
|
||||
</select>
|
||||
{selectedColumn && (
|
||||
<div className={`p-5 rounded-xl border transition-all duration-300 ${isChecking ? 'bg-slate-50' : columnHealth === 'good' ? 'bg-emerald-50 border-emerald-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{isChecking ? <LucideIcon name="refresh-cw" className="text-slate-400 animate-spin" size={24} /> : columnHealth === 'good' ? <LucideIcon name="check-circle-2" className="text-emerald-600" size={24} /> : <LucideIcon name="alert-triangle" className="text-red-600" size={24} />}
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-base font-bold mb-1 ${isChecking ? 'text-slate-600' : columnHealth === 'good' ? 'text-emerald-800' : 'text-red-800'}`}>
|
||||
{isChecking ? '正在进行数据体检...' : columnHealth === 'good' ? '健康度优秀,适合提取' : '警告:该列包含大量空值或过短'}
|
||||
</h4>
|
||||
{!isChecking && columnHealth === 'good' && <div className="text-sm text-slate-600 mt-2 flex gap-6"><span>平均字符: <strong>358</strong></span><span>空值率: <strong>2%</strong></span><span>预计 Token: <strong className="text-purple-600">450k</strong></span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 */}
|
||||
{currentStep === 'schema' && (
|
||||
<div className="max-w-5xl mx-auto w-full space-y-6 animate-in mt-4">
|
||||
<div className="bg-purple-50 p-6 rounded-xl border border-purple-100 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-purple-900 mb-2 flex items-center gap-1"><LucideIcon name="stethoscope" size={12} /> 疾病类型</label>
|
||||
<select className="w-full p-2.5 bg-white border border-purple-200 rounded-lg text-sm outline-none" value={diseaseType} onChange={(e) => setDiseaseType(e.target.value)}>
|
||||
<option value="lung_cancer">肺癌 (Lung Cancer)</option>
|
||||
<option value="hypertension">高血压 (Hypertension)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-purple-900 mb-2 flex items-center gap-1"><LucideIcon name="layout-template" size={12} /> 报告类型</label>
|
||||
<select className="w-full p-2.5 bg-white border border-purple-200 rounded-lg text-sm outline-none" value={reportType} onChange={(e) => setReportType(e.target.value)}>
|
||||
<option value="pathology">病理报告 (Pathology)</option>
|
||||
<option value="admission">入院记录 (Admission Note)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3 bg-white border border-slate-200 rounded-xl p-4 h-[400px] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-2"><h4 className="font-bold text-slate-700">提取字段列表</h4><button className="text-xs text-purple-600 flex items-center gap-1 hover:underline"><LucideIcon name="plus" size={12} /> 添加字段</button></div>
|
||||
{fields.map((field) => (
|
||||
<div key={field.id} className="flex gap-2 items-start group p-2 hover:bg-slate-50 rounded-lg border border-transparent hover:border-slate-200 transition-all">
|
||||
<div className="flex-1 grid grid-cols-5 gap-2">
|
||||
<input defaultValue={field.name} className="col-span-2 bg-transparent text-sm font-medium text-slate-900 outline-none" />
|
||||
<input defaultValue={field.desc} className="col-span-3 bg-transparent text-sm text-slate-500 outline-none" />
|
||||
</div>
|
||||
<button className="text-slate-300 hover:text-red-500"><LucideIcon name="trash-2" size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-xl p-5 font-mono text-xs text-slate-300 shadow-lg flex flex-col h-[400px]">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-3 border-b border-slate-700 pb-2"><LucideIcon name="bot" size={12} /> System Prompt Preview</div>
|
||||
<div className="flex-1 overflow-y-auto text-slate-400 leading-relaxed pr-2">
|
||||
<p className="text-purple-400 mb-2">// Role Definition</p>
|
||||
<p>You are an expert in {diseaseType.replace('_', ' ')} pathology.</p>
|
||||
<p className="mb-2">Extract fields in JSON format:</p>
|
||||
<p className="text-yellow-500">{'{'}</p>
|
||||
{fields.map(f => (<p key={f.id} className="pl-4"><span className="text-blue-400">"{f.name}"</span>: <span className="text-green-400">"string"</span>, <span className="text-slate-600">// {f.desc}</span></p>))}
|
||||
<p className="text-yellow-500">{'}'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 */}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="flex flex-col items-center justify-center h-full animate-in mt-10">
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-slate-100"></div>
|
||||
<div className="absolute inset-0 w-24 h-24 rounded-full border-4 border-purple-600 border-t-transparent animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-1">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500 animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">双盲提取交叉验证中...</h3>
|
||||
<div className="w-96 bg-slate-100 rounded-full h-2 overflow-hidden mb-6"><div className="bg-purple-600 h-full transition-all duration-300 ease-out" style={{ width: `${progress}%` }}></div></div>
|
||||
<div className="w-full max-w-lg bg-slate-50 rounded-lg border border-slate-200 p-4 font-mono text-xs h-40 overflow-y-auto shadow-inner">
|
||||
{logs.map((log, i) => (<div key={i} className="mb-1 text-slate-600 flex gap-2"><span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span><span>{log}</span></div>))}
|
||||
<div className="animate-pulse text-purple-500">_</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Verify */}
|
||||
{currentStep === 'verify' && (
|
||||
<div className="flex-1 flex flex-col relative h-full animate-in">
|
||||
<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"><LucideIcon name="alert-triangle" size={16} /><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"><LucideIcon name="check-circle-2" size={16} /><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"><LucideIcon name="download" size={16} /> 导出当前结果</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={() => setCurrentStep('result')}>完成并入库 <LucideIcon name="arrow-right" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{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"><LucideIcon name="file-text" 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>
|
||||
{fields.map(f => {
|
||||
const cell = row.results[f.name];
|
||||
const data = cell || { A: '-', B: '-', chosen: '-' };
|
||||
const isConflict = data.A !== data.B && data.chosen === null;
|
||||
const isResolved = data.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, data.A); }}><span className="truncate max-w-[100px] text-slate-700" title={data.A}>{data.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, data.B); }}><span className="truncate max-w-[100px] text-slate-700" title={data.B}>{data.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 ? (
|
||||
<div className="flex items-center justify-between group"><span className="text-blue-700 font-medium">{data.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); }}><LucideIcon name="rotate-ccw" size={12} /></button></div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-slate-600"><LucideIcon name="check" className="text-emerald-400" size={12} />{data.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"><LucideIcon name="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>
|
||||
)}
|
||||
|
||||
{/* Step 5: Result */}
|
||||
{currentStep === 'result' && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center animate-in mt-10">
|
||||
<div className="w-20 h-20 bg-emerald-100 rounded-full flex items-center justify-center mb-6"><LucideIcon name="check-circle-2" className="text-emerald-600" size={40} /></div>
|
||||
<h2 className="text-3xl font-bold text-slate-900 mb-2">结构化处理完成</h2>
|
||||
<p className="text-slate-500 mb-10 text-center max-w-md">双模型交叉验证已完成。人工裁决修正了 1 条冲突数据。<br/>最终数据集包含 3 条高质量记录。</p>
|
||||
<div className="grid grid-cols-2 gap-6 w-full max-w-2xl mb-10">
|
||||
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200 text-center"><div className="text-sm text-slate-500 mb-1">隐私安全</div><div className="font-bold text-slate-800 flex items-center justify-center gap-2"><LucideIcon name="shield-check" className="text-emerald-500" size={16} /> PII 已脱敏</div></div>
|
||||
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200 text-center"><div className="text-sm text-slate-500 mb-1">Token 消耗</div><div className="font-bold text-slate-800 flex items-center justify-center gap-2"><LucideIcon name="zap" className="text-yellow-500" size={16} /> ~45k Tokens</div></div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button className="flex items-center gap-2 px-8 py-3 bg-white border border-slate-300 rounded-xl text-slate-700 hover:bg-slate-50 font-medium shadow-sm"><LucideIcon name="download" size={20} /> 下载结果 Excel</button>
|
||||
<button className="flex items-center gap-2 px-8 py-3 bg-emerald-600 text-white rounded-xl hover:bg-emerald-700 font-medium shadow-md shadow-emerald-200"><LucideIcon name="table-2" size={20} /> 去编辑器清洗</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{currentStep !== 'processing' && currentStep !== 'result' && currentStep !== 'verify' && (
|
||||
<div className="px-8 py-4 border-t border-slate-100 flex justify-between bg-slate-50">
|
||||
<button className={`px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 ${currentStep === 'upload' ? 'invisible' : ''}`} onClick={() => { if(currentStep === 'schema') setCurrentStep('upload'); }}>上一步</button>
|
||||
<button className={`flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium shadow-sm transition-transform active:scale-95 ${columnHealth === 'bad' ? 'opacity-50 cursor-not-allowed' : ''}`} disabled={columnHealth === 'bad'} onClick={() => { if(currentStep === 'upload') setCurrentStep('schema'); else if(currentStep === 'schema') setCurrentStep('processing'); }}>下一步 <LucideIcon name="arrow-right" size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<ToolB_V4 />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
640
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具B_病历结构化机器人_原型设计_V4.tsx
Normal file
640
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具B_病历结构化机器人_原型设计_V4.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
UploadCloud,
|
||||
FileText,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Settings2,
|
||||
Download,
|
||||
Table2,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
LayoutTemplate,
|
||||
Stethoscope,
|
||||
Split,
|
||||
AlertCircle,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
MoreHorizontal,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
|
||||
// --- 类型定义 ---
|
||||
type Step = 'upload' | 'schema' | 'processing' | 'verify' | 'result';
|
||||
|
||||
interface ExtractionField {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
width?: string; // 用于表格宽度
|
||||
}
|
||||
|
||||
interface VerifyRow {
|
||||
id: number;
|
||||
text: string; // 原文摘要
|
||||
fullText: string; // 原文全文
|
||||
results: Record<string, {
|
||||
A: string; // DeepSeek
|
||||
B: string; // Qwen
|
||||
chosen: string | null; // 用户采纳的值,null表示冲突未解决
|
||||
}>;
|
||||
status: 'clean' | 'conflict'; // 行状态
|
||||
}
|
||||
|
||||
// --- 模拟数据 ---
|
||||
const TEMPLATES: Record<string, Record<string, ExtractionField[]>> = {
|
||||
'lung_cancer': {
|
||||
'pathology': [
|
||||
{ id: 'p1', name: '病理类型', desc: '如:浸润性腺癌', width: 'w-40' },
|
||||
{ id: 'p2', name: '分化程度', desc: '高/中/低分化', width: 'w-32' },
|
||||
{ id: 'p3', name: '肿瘤大小', desc: '最大径,单位cm', width: 'w-32' },
|
||||
{ id: 'p4', name: '淋巴结转移', desc: '有/无及具体组别', width: 'w-48' },
|
||||
{ id: 'p5', name: '免疫组化', desc: '关键指标', width: 'w-56' }
|
||||
],
|
||||
'admission': [
|
||||
{ id: 'a1', name: '主诉', desc: '患者入院的主要症状', width: 'w-48' },
|
||||
{ id: 'a2', name: '现病史', desc: '发病过程', width: 'w-64' },
|
||||
{ id: 'a3', name: '吸烟史', desc: '吸烟年支数', width: 'w-32' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const ToolB_AIStructurerV4 = () => {
|
||||
const [currentStep, setCurrentStep] = useState<Step>('upload');
|
||||
|
||||
// --- Step 1: 上传与体检 ---
|
||||
const [fileName, setFileName] = useState<string>('2023_肺癌病理报告_批量.xlsx');
|
||||
const [selectedColumn, setSelectedColumn] = useState<string>('');
|
||||
const [columnHealth, setColumnHealth] = useState<'unknown' | 'good' | 'bad'>('unknown');
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
// --- Step 2: Schema ---
|
||||
const [diseaseType, setDiseaseType] = useState<string>('lung_cancer');
|
||||
const [reportType, setReportType] = useState<string>('pathology');
|
||||
const [fields, setFields] = useState<ExtractionField[]>([]);
|
||||
|
||||
// --- Step 3: 处理 ---
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
|
||||
// --- Step 4: 验证 (核心升级) ---
|
||||
const [rows, setRows] = useState<VerifyRow[]>([]);
|
||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null); // 侧边栏控制
|
||||
|
||||
// 初始化模版
|
||||
useEffect(() => {
|
||||
if (diseaseType && reportType && TEMPLATES[diseaseType]?.[reportType]) {
|
||||
setFields(TEMPLATES[diseaseType][reportType]);
|
||||
} else {
|
||||
setFields([]);
|
||||
}
|
||||
}, [diseaseType, reportType]);
|
||||
|
||||
// 模拟健康检查
|
||||
const runHealthCheck = (col: string) => {
|
||||
if (!col) return;
|
||||
setIsChecking(true);
|
||||
setColumnHealth('unknown');
|
||||
setTimeout(() => {
|
||||
setIsChecking(false);
|
||||
setColumnHealth(col.includes('ID') || col.includes('时间') ? 'bad' : 'good');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 模拟处理过程 + 生成验证数据
|
||||
useEffect(() => {
|
||||
if (currentStep === 'processing') {
|
||||
const timer = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(timer);
|
||||
// 生成模拟验证数据
|
||||
setRows([
|
||||
{
|
||||
id: 1,
|
||||
text: "病理诊断:(右肺上叶)浸润性腺癌,腺泡为主型(70%)...",
|
||||
fullText: "病理诊断:(右肺上叶)浸润性腺癌,腺泡为主型(70%),伴乳头状成分(30%)。肿瘤大小 3.2*2.5*2.0cm。支气管断端未见癌。第7组淋巴结(1/3)见转移。免疫组化:TTF-1(+), NapsinA(+)。",
|
||||
results: {
|
||||
'病理类型': { A: '浸润性腺癌', B: '浸润性腺癌', chosen: '浸润性腺癌' },
|
||||
'分化程度': { A: '未提及', B: '中分化', chosen: null }, // 冲突
|
||||
'肿瘤大小': { A: '3.2cm', B: '3.2*2.5*2.0cm', chosen: null }, // 冲突
|
||||
'淋巴结转移': { A: '第7组(1/3)转移', B: '有', chosen: null }, // 冲突
|
||||
'免疫组化': { A: 'TTF-1(+)', B: 'TTF-1(+), NapsinA(+)', chosen: null }
|
||||
},
|
||||
status: 'conflict'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: "送检(左肺下叶)组织,镜下见异型细胞巢状排列...",
|
||||
fullText: "送检(左肺下叶)组织,镜下见异型细胞巢状排列,角化珠形成,符合鳞状细胞癌。免疫组化:CK5/6(+), P40(+), TTF-1(-)。",
|
||||
results: {
|
||||
'病理类型': { A: '鳞状细胞癌', B: '鳞状细胞癌', chosen: '鳞状细胞癌' },
|
||||
'分化程度': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'肿瘤大小': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'淋巴结转移': { A: '无', B: '无', chosen: '无' },
|
||||
'免疫组化': { A: 'CK5/6(+), P40(+)', B: 'CK5/6(+), P40(+)', chosen: 'CK5/6(+), P40(+)' }
|
||||
},
|
||||
status: 'clean'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: "右肺中叶穿刺活检:腺癌。EGFR 19-del(+)...",
|
||||
fullText: "右肺中叶穿刺活检:腺癌。基因检测结果显示:EGFR 19-del(+), ALK(-), ROS1(-)。建议靶向治疗。",
|
||||
results: {
|
||||
'病理类型': { A: '腺癌', B: '腺癌', chosen: '腺癌' },
|
||||
'分化程度': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'肿瘤大小': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'淋巴结转移': { A: '未提及', B: '未提及', chosen: '未提及' },
|
||||
'免疫组化': { A: 'EGFR(+)', B: 'EGFR 19-del(+)', chosen: null } // 冲突
|
||||
},
|
||||
status: 'conflict'
|
||||
}
|
||||
]);
|
||||
setTimeout(() => setCurrentStep('verify'), 800);
|
||||
return 100;
|
||||
}
|
||||
// 模拟日志
|
||||
if (prev === 5) setLogs(l => [...l, '初始化双模型引擎 (DeepSeek-V3 & Qwen-Max)...']);
|
||||
if (prev === 20) setLogs(l => [...l, 'PII 脱敏完成...']);
|
||||
if (prev === 40) setLogs(l => [...l, 'DeepSeek: 提取进度 45%']);
|
||||
if (prev === 45) setLogs(l => [...l, 'Qwen: 提取进度 50%']);
|
||||
if (prev === 80) setLogs(l => [...l, '正在进行交叉验证 (Cross-Validation)...']);
|
||||
return prev + 1;
|
||||
});
|
||||
}, 40);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Step 4 逻辑: 采纳值
|
||||
const handleAdopt = (rowId: number, 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' };
|
||||
}));
|
||||
};
|
||||
|
||||
// 统计数据
|
||||
const conflictRowsCount = rows.filter(r => r.status === 'conflict').length;
|
||||
|
||||
// --- 渲染辅助 ---
|
||||
const renderSteps = () => (
|
||||
<div className="flex items-center justify-center mb-6 px-4">
|
||||
{[
|
||||
{ id: 'upload', label: '1. 选列与体检' },
|
||||
{ id: 'schema', label: '2. 智能模版' },
|
||||
{ id: 'processing', label: '3. 双盲提取' },
|
||||
{ id: 'verify', label: '4. 交叉验证' },
|
||||
{ id: 'result', label: '5. 完成' }
|
||||
].map((step, idx, arr) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className={`flex flex-col items-center z-10 ${currentStep === 'result' && 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-purple-600 text-white shadow-lg shadow-purple-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-purple-700' : 'text-slate-500'}`}>{step.label}</span>
|
||||
</div>
|
||||
{idx < arr.length - 1 && (
|
||||
<div className={`h-[2px] w-12 -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-6">
|
||||
<div className="max-w-7xl mx-auto bg-white rounded-2xl shadow-sm border border-slate-200 min-h-[800px] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-8 py-5 border-b border-slate-100 flex justify-between items-center bg-gradient-to-r from-purple-50 via-white to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center text-purple-600">
|
||||
<Bot className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">病历结构化机器人 V4</h1>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1"><Split className="w-3 h-3" /> 双模型交叉验证</span>
|
||||
<span>•</span>
|
||||
<span>DeepSeek-V3 & Qwen-Max</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态指示器 */}
|
||||
{currentStep === 'verify' && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-blue-50 text-blue-700 text-xs rounded-full border border-blue-100 font-medium">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500"></div> DeepSeek
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-orange-50 text-orange-700 text-xs rounded-full border border-orange-100 font-medium">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500"></div> Qwen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="pt-6 pb-2">
|
||||
{renderSteps()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-8 pb-8 relative overflow-hidden flex flex-col">
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 mt-8">
|
||||
<div className="flex items-center p-5 border border-slate-200 rounded-xl bg-slate-50">
|
||||
<FileText className="w-10 h-10 text-slate-400 mr-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-slate-900 text-lg">{fileName}</div>
|
||||
<div className="text-sm text-slate-500">12.5 MB • 1,200 行</div>
|
||||
</div>
|
||||
<button className="text-sm text-purple-600 hover:underline font-medium">更换文件</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold text-slate-700">请选择包含病历文本的列 (Input Source)</label>
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
className="flex-1 p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 outline-none transition-shadow"
|
||||
value={selectedColumn}
|
||||
onChange={(e) => {
|
||||
setSelectedColumn(e.target.value);
|
||||
runHealthCheck(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">-- 请选择 --</option>
|
||||
<option value="summary_text">出院小结 (Summary_Text)</option>
|
||||
<option value="pathology_report">病理报告 (Pathology)</option>
|
||||
<option value="patient_id">错误示范:病人ID列</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedColumn && (
|
||||
<div className={`p-5 rounded-xl border transition-all duration-300 ${isChecking ? 'bg-slate-50' : columnHealth === 'good' ? 'bg-emerald-50 border-emerald-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{isChecking ? <RefreshCw className="w-6 h-6 text-slate-400 animate-spin" /> : columnHealth === 'good' ? <CheckCircle2 className="w-6 h-6 text-emerald-600" /> : <AlertTriangle className="w-6 h-6 text-red-600" />}
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-base font-bold mb-1 ${isChecking ? 'text-slate-600' : columnHealth === 'good' ? 'text-emerald-800' : 'text-red-800'}`}>
|
||||
{isChecking ? '正在进行数据体检...' : columnHealth === 'good' ? '健康度优秀,适合提取' : '警告:该列包含大量空值或过短,不适合 AI 处理'}
|
||||
</h4>
|
||||
{!isChecking && columnHealth === 'good' && (
|
||||
<div className="text-sm text-slate-600 mt-2 flex gap-6">
|
||||
<span>平均字符: <strong>358</strong></span>
|
||||
<span>空值率: <strong>2%</strong></span>
|
||||
<span>预计 Token: <strong className="text-purple-600">450k</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Schema */}
|
||||
{currentStep === 'schema' && (
|
||||
<div className="max-w-5xl mx-auto w-full space-y-6 animate-in fade-in slide-in-from-right-4 duration-500 mt-4">
|
||||
<div className="bg-purple-50 p-6 rounded-xl border border-purple-100 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-purple-900 mb-2 flex items-center gap-1">
|
||||
<Stethoscope className="w-3 h-3" /> 疾病类型
|
||||
</label>
|
||||
<select className="w-full p-2.5 bg-white border border-purple-200 rounded-lg text-sm outline-none" value={diseaseType} onChange={(e) => setDiseaseType(e.target.value)}>
|
||||
<option value="lung_cancer">肺癌 (Lung Cancer)</option>
|
||||
<option value="hypertension">高血压 (Hypertension)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-purple-900 mb-2 flex items-center gap-1">
|
||||
<LayoutTemplate className="w-3 h-3" /> 报告类型
|
||||
</label>
|
||||
<select className="w-full p-2.5 bg-white border border-purple-200 rounded-lg text-sm outline-none" value={reportType} onChange={(e) => setReportType(e.target.value)}>
|
||||
<option value="pathology">病理报告 (Pathology)</option>
|
||||
<option value="admission">入院记录 (Admission Note)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3 bg-white border border-slate-200 rounded-xl p-4 h-[400px] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-bold text-slate-700">提取字段列表</h4>
|
||||
<button className="text-xs text-purple-600 flex items-center gap-1 hover:underline"><Plus className="w-3 h-3" /> 添加字段</button>
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<div key={field.id} className="flex gap-2 items-start group p-2 hover:bg-slate-50 rounded-lg border border-transparent hover:border-slate-200 transition-all">
|
||||
<div className="flex-1 grid grid-cols-5 gap-2">
|
||||
<input defaultValue={field.name} className="col-span-2 bg-transparent text-sm font-medium text-slate-900 outline-none" />
|
||||
<input defaultValue={field.desc} className="col-span-3 bg-transparent text-sm text-slate-500 outline-none" />
|
||||
</div>
|
||||
<button className="text-slate-300 hover:text-red-500"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 rounded-xl p-5 font-mono text-xs text-slate-300 shadow-lg flex flex-col h-[400px]">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-3 border-b border-slate-700 pb-2">
|
||||
<Bot className="w-3 h-3" /> System Prompt Preview
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto text-slate-400 leading-relaxed pr-2">
|
||||
<p className="text-purple-400 mb-2">// Role Definition</p>
|
||||
<p>You are an expert in {diseaseType.replace('_', ' ')} pathology.</p>
|
||||
<p className="mb-2">Extract fields in JSON format:</p>
|
||||
<p className="text-yellow-500">{'{'}</p>
|
||||
{fields.map(f => (
|
||||
<p key={f.id} className="pl-4">
|
||||
<span className="text-blue-400">"{f.name}"</span>: <span className="text-green-400">"string"</span>, <span className="text-slate-600">// {f.desc}</span>
|
||||
</p>
|
||||
))}
|
||||
<p className="text-yellow-500">{'}'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Processing */}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="flex flex-col items-center justify-center h-full animate-in fade-in duration-500 mt-10">
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 rounded-full border-4 border-slate-100"></div>
|
||||
<div className="absolute inset-0 w-24 h-24 rounded-full border-4 border-purple-600 border-t-transparent animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-1">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500 animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">双盲提取交叉验证中...</h3>
|
||||
<div className="w-96 bg-slate-100 rounded-full h-2 overflow-hidden mb-6">
|
||||
<div className="bg-purple-600 h-full transition-all duration-300 ease-out" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
<div className="w-full max-w-lg bg-slate-50 rounded-lg border border-slate-200 p-4 font-mono text-xs h-40 overflow-y-auto shadow-inner">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="mb-1 text-slate-600 flex gap-2">
|
||||
<span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span>
|
||||
<span>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="animate-pulse text-purple-500">_</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Verify (全景网格 + 侧边栏) */}
|
||||
{currentStep === 'verify' && (
|
||||
<div className="flex-1 flex flex-col relative h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* 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">
|
||||
<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={() => setCurrentStep('result')}
|
||||
>
|
||||
完成并入库 <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>
|
||||
{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>
|
||||
{/* 动态列 */}
|
||||
{fields.map(f => {
|
||||
const cell = row.results[f.name];
|
||||
// 简单的 Mock 数据映射逻辑 (真实场景中每个字段都有A/B)
|
||||
const data = cell || { A: '-', B: '-', chosen: '-' };
|
||||
const isConflict = data.A !== data.B && data.chosen === null;
|
||||
const isResolved = data.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, data.A); }}
|
||||
>
|
||||
<span className="truncate max-w-[100px] text-slate-700" title={data.A}>{data.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, data.B); }}
|
||||
>
|
||||
<span className="truncate max-w-[100px] text-slate-700" title={data.B}>{data.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 ? (
|
||||
<div className="flex items-center justify-between group">
|
||||
<span className="text-blue-700 font-medium">{data.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" />
|
||||
{data.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>
|
||||
)}
|
||||
|
||||
{/* Step 5: Result */}
|
||||
{currentStep === 'result' && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="w-20 h-20 bg-emerald-100 rounded-full flex items-center justify-center mb-6">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-slate-900 mb-2">结构化处理完成</h2>
|
||||
<p className="text-slate-500 mb-10 text-center max-w-md">
|
||||
双模型交叉验证已完成。人工裁决修正了 1 条冲突数据。<br/>
|
||||
最终数据集包含 3 条高质量记录。
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 w-full max-w-2xl mb-10">
|
||||
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200 text-center">
|
||||
<div className="text-sm text-slate-500 mb-1">隐私安全</div>
|
||||
<div className="font-bold text-slate-800 flex items-center justify-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500" /> PII 已脱敏
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200 text-center">
|
||||
<div className="text-sm text-slate-500 mb-1">Token 消耗</div>
|
||||
<div className="font-bold text-slate-800 flex items-center justify-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-500" /> ~45k Tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button className="flex items-center gap-2 px-8 py-3 bg-white border border-slate-300 rounded-xl text-slate-700 hover:bg-slate-50 font-medium shadow-sm">
|
||||
<Download className="w-5 h-5" /> 下载结果 Excel
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-8 py-3 bg-emerald-600 text-white rounded-xl hover:bg-emerald-700 font-medium shadow-md shadow-emerald-200">
|
||||
<Table2 className="w-5 h-5" /> 去编辑器清洗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation (Step 4 隐藏,因为有自己的工具栏) */}
|
||||
{currentStep !== 'processing' && currentStep !== 'result' && currentStep !== 'verify' && (
|
||||
<div className="px-8 py-4 border-t border-slate-100 flex justify-between bg-slate-50">
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 ${currentStep === 'upload' ? 'invisible' : ''}`}
|
||||
onClick={() => {
|
||||
if(currentStep === 'schema') setCurrentStep('upload');
|
||||
}}
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium shadow-sm transition-transform active:scale-95 ${columnHealth === 'bad' ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={columnHealth === 'bad'}
|
||||
onClick={() => {
|
||||
if(currentStep === 'upload') setCurrentStep('schema');
|
||||
else if(currentStep === 'schema') setCurrentStep('processing');
|
||||
}}
|
||||
>
|
||||
下一步 <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolB_AIStructurerV4;
|
||||
506
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具C_科研数据编辑器.tsx
Normal file
506
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具C_科研数据编辑器.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table2,
|
||||
Calculator,
|
||||
CalendarClock,
|
||||
Split,
|
||||
Filter,
|
||||
Download,
|
||||
ArrowRight,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Hash,
|
||||
Type,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Save,
|
||||
Wand2,
|
||||
Settings,
|
||||
FileUp,
|
||||
Search,
|
||||
ArrowLeftRight // 新增图标
|
||||
} from 'lucide-react';
|
||||
|
||||
// --- 模拟数据 ---
|
||||
const MOCK_DATA = [
|
||||
{ id: 'P001', age: 45, gender: 'Male', bmi: 24.5, admission_date: '2023-01-12', lab_val: '4.5' },
|
||||
{ id: 'P002', age: 62, gender: 'Female', bmi: 28.1, admission_date: '2023-01-15', lab_val: '5.1' },
|
||||
{ id: 'P003', age: 205, gender: 'Male', bmi: null, admission_date: '2023-02-01', lab_val: '<0.1' },
|
||||
{ id: 'P004', age: 58, gender: 'F', bmi: 22.4, admission_date: '2023-02-10', lab_val: '4.8' },
|
||||
{ id: 'P005', age: 34, gender: 'Male', bmi: 21.0, admission_date: '2023-03-05', lab_val: '5.2' },
|
||||
{ id: 'P006', age: 71, gender: 'Female', bmi: 30.5, admission_date: '2023-03-12', lab_val: '6.0' },
|
||||
{ id: 'P007', age: null, gender: 'Male', bmi: 25.3, admission_date: '2023-04-01', lab_val: '4.9' },
|
||||
{ id: 'P008', age: 49, gender: 'Male', bmi: 26.8, admission_date: '2023-04-05', lab_val: '5.5' },
|
||||
{ id: 'P009', age: 55, gender: 'Female', bmi: 23.9, admission_date: '2023-04-10', lab_val: '4.2' },
|
||||
{ id: 'P010', age: 66, gender: 'Male', bmi: 29.1, admission_date: '2023-04-12', lab_val: '5.8' },
|
||||
];
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'id', name: '病人ID', type: 'text', locked: true },
|
||||
{ id: 'age', name: '年龄', type: 'number' },
|
||||
{ id: 'gender', name: '性别', type: 'category' },
|
||||
{ id: 'bmi', name: 'BMI指数', type: 'number' },
|
||||
{ id: 'admission_date', name: '入院日期', type: 'date' },
|
||||
{ id: 'lab_val', name: '肌酐', type: 'text' },
|
||||
];
|
||||
|
||||
const ToolC_EditorV2 = () => {
|
||||
const [selectedColId, setSelectedColId] = useState<string | null>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [showModal, setShowModal] = useState<'calc' | 'recode' | 'pivot' | null>(null); // 新增 pivot 状态
|
||||
|
||||
const selectedCol = COLUMNS.find(c => c.id === selectedColId);
|
||||
|
||||
const handleColClick = (colId: string) => {
|
||||
if (selectedColId === colId) {
|
||||
setShowSidebar(!showSidebar);
|
||||
} else {
|
||||
setSelectedColId(colId);
|
||||
setShowSidebar(true);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 组件:顶部工具按钮 ---
|
||||
const ToolbarButton = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
desc,
|
||||
colorClass = "text-slate-600 bg-slate-50 hover:bg-slate-100",
|
||||
onClick
|
||||
}: { icon: any, label: string, desc: string, colorClass?: string, onClick?: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative group flex flex-col items-center justify-center w-24 h-16 rounded-lg transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className={`p-2 rounded-lg mb-1 ${colorClass}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-slate-600">{label}</span>
|
||||
|
||||
{/* Custom Tooltip */}
|
||||
<div className="absolute top-full mt-2 px-3 py-2 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
||||
{desc}
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-800 rotate-45"></div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-800 flex flex-col relative overflow-hidden">
|
||||
|
||||
{/* 1. Header (基础操作) */}
|
||||
<header className="bg-white border-b border-slate-200 h-14 flex items-center justify-between px-4 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center text-emerald-600">
|
||||
<Table2 className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">科研数据编辑器</span>
|
||||
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
|
||||
<span className="text-xs text-slate-500">肺癌数据集_2023.csv (未保存)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full" title="撤销"><Undo2 className="w-4 h-4" /></button>
|
||||
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full" title="重做"><Redo2 className="w-4 h-4" /></button>
|
||||
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200">
|
||||
<Save className="w-3 h-3" /> 保存快照
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 shadow-sm">
|
||||
<Download className="w-3 h-3" /> 导出分析
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 2. Top Toolbar (扁平化核心功能) */}
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-2 flex items-center gap-2 overflow-x-auto">
|
||||
|
||||
<ToolbarButton
|
||||
icon={Calculator}
|
||||
label="生成新变量"
|
||||
desc="使用公式计算生成新列 (如 BMI = 体重/身高²)"
|
||||
colorClass="bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100"
|
||||
onClick={() => setShowModal('calc')}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={CalendarClock}
|
||||
label="计算时间差"
|
||||
desc="计算两个日期之间的天数/年数 (如 年龄、住院天数)"
|
||||
colorClass="bg-blue-50 text-blue-600 group-hover:bg-blue-100"
|
||||
/>
|
||||
|
||||
{/* 新增:长宽转换按钮 */}
|
||||
<ToolbarButton
|
||||
icon={ArrowLeftRight}
|
||||
label="长宽转换"
|
||||
desc="将'一人多行'转换为'一人一行' (透视/Pivot)"
|
||||
colorClass="bg-blue-50 text-blue-600 group-hover:bg-blue-100"
|
||||
onClick={() => setShowModal('pivot')}
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
|
||||
|
||||
<ToolbarButton
|
||||
icon={FileUp}
|
||||
label="拆分数据集"
|
||||
desc="按某一列的值将数据拆分为多个文件或 Sheet"
|
||||
colorClass="bg-purple-50 text-purple-600 group-hover:bg-purple-100"
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
|
||||
|
||||
<ToolbarButton
|
||||
icon={AlertCircle}
|
||||
label="跨列规则检查"
|
||||
desc="自定义逻辑规则 (如: 男性不能有孕产史) 并标记错误行"
|
||||
colorClass="bg-orange-50 text-orange-600 group-hover:bg-orange-100"
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={Filter}
|
||||
label="构建入排标准"
|
||||
desc="组合多重筛选条件,生成最终分析集 (Cohort Selection)"
|
||||
colorClass="bg-indigo-50 text-indigo-600 group-hover:bg-indigo-100"
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
|
||||
|
||||
<div className="flex-1"></div> {/* Spacer */}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" placeholder="搜索值..." className="pl-9 pr-4 py-1.5 text-sm border border-slate-200 rounded-full focus:border-emerald-500 outline-none" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 3. Main Grid Area */}
|
||||
<div className="flex-1 relative overflow-hidden bg-slate-100 flex">
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex-1 overflow-auto p-6 transition-all duration-300" style={{ marginRight: showSidebar ? '320px' : '0' }}>
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-lg overflow-hidden min-w-[800px]">
|
||||
<div className="flex bg-slate-50 border-b border-slate-200 sticky top-0 z-10">
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`h-10 px-4 flex items-center justify-between border-r border-slate-200 text-xs font-bold text-slate-600 cursor-pointer hover:bg-slate-100 transition-colors ${selectedColId === col.id ? 'bg-emerald-50 text-emerald-700' : ''}`}
|
||||
style={{ width: col.id === 'id' ? '100px' : '150px' }}
|
||||
onClick={() => handleColClick(col.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{col.type === 'number' && <Hash className="w-3 h-3 text-slate-400" />}
|
||||
{col.type === 'text' && <Type className="w-3 h-3 text-slate-400" />}
|
||||
{col.type === 'category' && <Split className="w-3 h-3 text-slate-400" />}
|
||||
{col.type === 'date' && <Calendar className="w-3 h-3 text-slate-400" />}
|
||||
{col.name}
|
||||
</div>
|
||||
<ChevronDown className="w-3 h-3 text-slate-300" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100 text-sm text-slate-700">
|
||||
{MOCK_DATA.map((row, idx) => (
|
||||
<div key={idx} className="flex hover:bg-slate-50 transition-colors">
|
||||
{COLUMNS.map((col) => {
|
||||
const val = row[col.id as keyof typeof row];
|
||||
const isNull = val === null || val === '';
|
||||
const isOutlier = col.id === 'age' && (val as number) > 120;
|
||||
const isDirty = col.id === 'lab_val' && typeof val === 'string' && val.includes('<');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`h-10 px-4 flex items-center border-r border-slate-100
|
||||
${isNull ? 'bg-red-50' : ''}
|
||||
${isOutlier ? 'bg-orange-100 text-orange-700 font-bold' : ''}
|
||||
${isDirty ? 'text-purple-600 font-medium' : ''}
|
||||
${selectedColId === col.id ? 'bg-emerald-50/30' : ''}
|
||||
`}
|
||||
style={{ width: col.id === 'id' ? '100px' : '150px' }}
|
||||
>
|
||||
{isNull ? <span className="text-[10px] text-red-400 italic">NULL</span> : val}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Smart Sidebar (基于列类型的操作聚合) */}
|
||||
{showSidebar && selectedCol && (
|
||||
<div className="w-80 bg-white border-l border-slate-200 flex flex-col shadow-xl animate-in slide-in-from-right duration-300 absolute right-0 top-0 bottom-0 z-20">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-100 bg-slate-50 flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">当前选中列</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
{selectedCol.name}
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-600 text-[10px] rounded-full font-mono">{selectedCol.id}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowSidebar(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
|
||||
{/* A. 数值列操作 */}
|
||||
{selectedCol.type === 'number' && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-slate-500" /> 分布直方图
|
||||
</h4>
|
||||
{/* 图表 */}
|
||||
<div className="h-24 flex items-end gap-1 px-2 border-b border-slate-200 pb-1">
|
||||
{[10, 25, 45, 80, 50, 30, 15, 5, 2, 1].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-emerald-200 hover:bg-emerald-400 transition-colors rounded-t-sm" style={{ height: `${h}%` }}></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 px-1">
|
||||
<span>Min: 34</span>
|
||||
<span>Max: 205</span>
|
||||
</div>
|
||||
|
||||
{/* 异常值卡片 */}
|
||||
<div className="bg-orange-50 border border-orange-100 p-3 rounded-lg flex gap-3 items-start">
|
||||
<AlertCircle className="w-4 h-4 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs font-bold text-orange-700">发现异常值</div>
|
||||
<div className="text-[10px] text-orange-600 mt-1">值 <strong>205</strong> 显著偏离分布。</div>
|
||||
<button className="mt-2 text-xs bg-white border border-orange-200 text-orange-700 px-2 py-1 rounded hover:bg-orange-100">
|
||||
处理异常值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-bold text-slate-800">针对此列的操作</h4>
|
||||
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
|
||||
<Split className="w-4 h-4 text-emerald-600" />
|
||||
<div>
|
||||
<div className="font-medium">生成分类变量 (Binning)</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">例如: 将年龄分为 老年/中年/青年</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
|
||||
<Wand2 className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">填补缺失值</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">使用均值、中位数或特定值填补</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* B. 分类/文本列操作 */}
|
||||
{(selectedCol.type === 'category' || selectedCol.type === 'text') && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-slate-500" /> 频次统计
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{['Male (50%)', 'Female (40%)', 'F (10%)'].map((label, i) => (
|
||||
<div key={i} className="relative h-6 bg-slate-50 rounded overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-full bg-emerald-100" style={{ width: label.includes('50') ? '50%' : label.includes('40') ? '40%' : '10%' }}></div>
|
||||
<div className="absolute inset-0 flex items-center px-2 text-xs text-slate-700 justify-between">
|
||||
<span>{label.split(' ')[0]}</span>
|
||||
<span className="text-slate-400">{label.split(' ')[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-bold text-slate-800">针对此列的操作</h4>
|
||||
<button
|
||||
className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group"
|
||||
onClick={() => setShowModal('recode')}
|
||||
>
|
||||
<Settings className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">数值映射 (Recode)</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">例如: 将 Male->1, Female->0</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
|
||||
<Wand2 className="w-4 h-4 text-purple-600" />
|
||||
<div>
|
||||
<div className="font-medium">设为敏感字段 (脱敏)</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">隐藏或加密该列数据</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modals (Mock) */}
|
||||
{showModal === 'calc' && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in fade-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-900">生成新变量 (Formula)</h3>
|
||||
<button onClick={() => setShowModal(null)}><Trash2 className="w-4 h-4 text-slate-400 rotate-45" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1">新变量名称</label>
|
||||
<input type="text" className="w-full border p-2 rounded text-sm" placeholder="例如: BMI_Calc" defaultValue="New_Var" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1">计算公式</label>
|
||||
<div className="w-full h-24 border p-2 rounded bg-slate-50 font-mono text-sm text-slate-700">
|
||||
[体重] / ([身高] / 100) ^ 2
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">+ 加</button>
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">- 减</button>
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">* 乘</button>
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">/ 除</button>
|
||||
<button className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200">ln()</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">计算并生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recode Modal */}
|
||||
{showModal === 'recode' && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in fade-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[400px] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-900">数值映射 (Recode)</h3>
|
||||
<button onClick={() => setShowModal(null)}><Trash2 className="w-4 h-4 text-slate-400 rotate-45" /></button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-xs text-slate-500 mb-4">将源值映射为新的统计数值:</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-20 text-sm font-medium text-right">Male</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||
<input type="text" className="w-20 border p-1 rounded text-center" defaultValue="1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-20 text-sm font-medium text-right">Female</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||
<input type="text" className="w-20 border p-1 rounded text-center" defaultValue="0" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-20 text-sm font-medium text-right">F</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||
<input type="text" className="w-20 border p-1 rounded text-center" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">应用映射</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新增:Pivot Modal (长宽转换) */}
|
||||
{showModal === 'pivot' && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in fade-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[600px] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-blue-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900">长宽表转换 (Pivot)</h3>
|
||||
<p className="text-xs text-slate-500">将"一人多行"转换为"一人一行",适用于重复测量分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(null)}><Trash2 className="w-4 h-4 text-slate-400 rotate-45" /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-3 gap-6">
|
||||
{/* 列 1: 主键 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 block">1. 谁是唯一ID? (行)</label>
|
||||
<select className="w-full p-2 border border-slate-300 rounded text-sm bg-white">
|
||||
<option>病人ID</option>
|
||||
<option>姓名</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-400">转换后,每个ID只保留一行。</p>
|
||||
</div>
|
||||
|
||||
{/* 列 2: 区分列 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 block">2. 谁是区分次序? (列后缀)</label>
|
||||
<select className="w-full p-2 border border-slate-300 rounded text-sm bg-white">
|
||||
<option>入院日期</option>
|
||||
<option>就诊次序</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-400">生成列名如: 白细胞_20230101</p>
|
||||
</div>
|
||||
|
||||
{/* 列 3: 值列 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 block">3. 哪些值要铺平? (内容)</label>
|
||||
<div className="border border-slate-300 rounded p-2 h-32 overflow-y-auto bg-slate-50 text-sm space-y-1">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>白细胞</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>病理报告</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>B超结果</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" className="rounded text-blue-600" /> <span>年龄 (无需铺平)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览图示 */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="bg-slate-50 border border-slate-200 rounded p-3">
|
||||
<div className="text-xs font-bold text-slate-500 mb-2">转换预览:</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="border p-1 bg-white text-slate-400 rounded">
|
||||
ID | 时间 | 值<br/>
|
||||
P1 | T1 | A<br/>
|
||||
P1 | T2 | B
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-blue-500" />
|
||||
<div className="border p-1 bg-white text-blue-600 font-medium rounded shadow-sm">
|
||||
ID | 值_T1 | 值_T2<br/>
|
||||
P1 | A | B
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-sm">开始转换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolC_EditorV2;
|
||||
534
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具C_科研数据编辑器_原型设计_V2.html
Normal file
534
docs/03-业务模块/DC-数据清洗整理/03-UI设计/工具C_科研数据编辑器_原型设计_V2.html
Normal file
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>工具C - 科研数据编辑器 V2 (扁平化交互版)</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 -->
|
||||
<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); } }
|
||||
@keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.animate-in { animation: fadeIn 0.3s ease-out forwards; }
|
||||
.slide-in-right { animation: slideInRight 0.3s 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 overflow-hidden">
|
||||
|
||||
<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 MOCK_DATA = [
|
||||
{ id: 'P001', age: 45, gender: 'Male', bmi: 24.5, admission_date: '2023-01-12', lab_val: '4.5' },
|
||||
{ id: 'P002', age: 62, gender: 'Female', bmi: 28.1, admission_date: '2023-01-15', lab_val: '5.1' },
|
||||
{ id: 'P003', age: 205, gender: 'Male', bmi: null, admission_date: '2023-02-01', lab_val: '<0.1' },
|
||||
{ id: 'P004', age: 58, gender: 'F', bmi: 22.4, admission_date: '2023-02-10', lab_val: '4.8' },
|
||||
{ id: 'P005', age: 34, gender: 'Male', bmi: 21.0, admission_date: '2023-03-05', lab_val: '5.2' },
|
||||
{ id: 'P006', age: 71, gender: 'Female', bmi: 30.5, admission_date: '2023-03-12', lab_val: '6.0' },
|
||||
{ id: 'P007', age: null, gender: 'Male', bmi: 25.3, admission_date: '2023-04-01', lab_val: '4.9' },
|
||||
{ id: 'P008', age: 49, gender: 'Male', bmi: 26.8, admission_date: '2023-04-05', lab_val: '5.5' },
|
||||
{ id: 'P009', age: 55, gender: 'Female', bmi: 23.9, admission_date: '2023-04-10', lab_val: '4.2' },
|
||||
{ id: 'P010', age: 66, gender: 'Male', bmi: 29.1, admission_date: '2023-04-12', lab_val: '5.8' },
|
||||
];
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'id', name: '病人ID', type: 'text', locked: true },
|
||||
{ id: 'age', name: '年龄', type: 'number' },
|
||||
{ id: 'gender', name: '性别', type: 'category' },
|
||||
{ id: 'bmi', name: 'BMI指数', type: 'number' },
|
||||
{ id: 'admission_date', name: '入院日期', type: 'date' },
|
||||
{ id: 'lab_val', name: '肌酐', type: 'text' },
|
||||
];
|
||||
|
||||
const ToolC_EditorV2 = () => {
|
||||
const [selectedColId, setSelectedColId] = useState(null);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [showModal, setShowModal] = useState(null);
|
||||
|
||||
const selectedCol = COLUMNS.find(c => c.id === selectedColId);
|
||||
|
||||
const handleColClick = (colId) => {
|
||||
if (selectedColId === colId) {
|
||||
setShowSidebar(!showSidebar);
|
||||
} else {
|
||||
setSelectedColId(colId);
|
||||
setShowSidebar(true);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 顶部工具栏按钮组件 ---
|
||||
const ToolbarButton = ({ iconName, label, desc, colorClass = "text-slate-600 bg-slate-50 hover:bg-slate-100", onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative group flex flex-col items-center justify-center w-24 h-16 rounded-lg transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className={`p-2 rounded-lg mb-1 ${colorClass}`}>
|
||||
<LucideIcon name={iconName} size={20} />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-slate-600">{label}</span>
|
||||
|
||||
{/* Custom Tooltip */}
|
||||
<div className="absolute top-full mt-2 px-3 py-2 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
||||
{desc}
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-800 rotate-45"></div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
// --- 侧边栏渲染 ---
|
||||
const renderSidebar = () => {
|
||||
if (!showSidebar || !selectedCol) return null;
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 flex flex-col shadow-xl slide-in-right absolute right-0 top-0 bottom-0 z-20">
|
||||
|
||||
{/* Sidebar Header */}
|
||||
<div className="p-4 border-b border-slate-100 bg-slate-50 flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">当前选中列</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
{selectedCol.name}
|
||||
<span className="px-2 py-0.5 bg-slate-200 text-slate-600 text-[10px] rounded-full font-mono">{selectedCol.id}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowSidebar(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<LucideIcon name="arrow-right" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type Definition */}
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-2">变量类型定义</label>
|
||||
<div className="flex gap-2">
|
||||
<button className={`flex-1 py-1.5 text-xs rounded border flex items-center justify-center gap-1 ${selectedCol.type === 'number' ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}><LucideIcon name="hash" size={12} /> 数值</button>
|
||||
<button className={`flex-1 py-1.5 text-xs rounded border flex items-center justify-center gap-1 ${selectedCol.type === 'text' ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}><LucideIcon name="type" size={12} /> 文本</button>
|
||||
<button className={`flex-1 py-1.5 text-xs rounded border flex items-center justify-center gap-1 ${selectedCol.type === 'category' ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}><LucideIcon name="split" size={12} /> 分类</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
|
||||
{/* 数值型列操作 */}
|
||||
{selectedCol.type === 'number' && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<LucideIcon name="bar-chart-3" size={16} className="text-slate-500" /> 分布直方图
|
||||
</h4>
|
||||
<div className="h-24 flex items-end gap-1 px-2 border-b border-slate-200 pb-1">
|
||||
{[10, 25, 45, 80, 50, 30, 15, 5, 2, 1].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-emerald-200 hover:bg-emerald-400 transition-colors rounded-t-sm" style={{ height: `${h}%` }}></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 px-1">
|
||||
<span>Min: 34</span>
|
||||
<span>Max: 205</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-100 p-3 rounded-lg flex gap-3 items-start">
|
||||
<LucideIcon name="alert-circle" size={16} className="text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs font-bold text-orange-700">发现异常值</div>
|
||||
<div className="text-[10px] text-orange-600 mt-1">值 <strong>205</strong> 显著偏离分布。</div>
|
||||
<button className="mt-2 text-xs bg-white border border-orange-200 text-orange-700 px-2 py-1 rounded hover:bg-orange-100">
|
||||
处理异常值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-bold text-slate-800">针对此列的操作</h4>
|
||||
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
|
||||
<LucideIcon name="split" size={16} className="text-emerald-600" />
|
||||
<div>
|
||||
<div className="font-medium">生成分类变量</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">例如: 将年龄分为 老年/中年/青年</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
|
||||
<LucideIcon name="wand-2" size={16} className="text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">填补缺失值</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">使用均值、中位数或特定值填补</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 分类/文本型列操作 */}
|
||||
{(selectedCol.type === 'category' || selectedCol.type === 'text') && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<LucideIcon name="bar-chart-3" size={16} className="text-slate-500" /> 频次统计
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{['Male (50%)', 'Female (40%)', 'F (10%)'].map((label, i) => (
|
||||
<div key={i} className="relative h-6 bg-slate-50 rounded overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-full bg-emerald-100" style={{ width: label.includes('50') ? '50%' : label.includes('40') ? '40%' : '10%' }}></div>
|
||||
<div className="absolute inset-0 flex items-center px-2 text-xs text-slate-700 justify-between">
|
||||
<span>{label.split(' ')[0]}</span>
|
||||
<span className="text-slate-400">{label.split(' ')[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-bold text-slate-800">针对此列的操作</h4>
|
||||
<button
|
||||
className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group"
|
||||
onClick={() => setShowModal('recode')}
|
||||
>
|
||||
<LucideIcon name="settings" size={16} className="text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">数值映射 (Recode)</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">例如: 将 Male->1, Female->0</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 text-sm text-slate-700 flex items-center gap-3 border border-slate-200 hover:border-emerald-300 transition-all group">
|
||||
<LucideIcon name="shield-check" size={16} className="text-purple-600" />
|
||||
<div>
|
||||
<div className="font-medium">设为敏感字段 (脱敏)</div>
|
||||
<div className="text-[10px] text-slate-400 group-hover:text-slate-500">隐藏或加密该列数据</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-800 flex flex-col relative overflow-hidden">
|
||||
|
||||
{/* 1. Header */}
|
||||
<header className="bg-white border-b border-slate-200 h-14 flex items-center justify-between px-4 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center text-emerald-600">
|
||||
<LucideIcon name="table-2" size={20} />
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">科研数据编辑器</span>
|
||||
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
|
||||
<span className="text-xs text-slate-500">肺癌数据集_2023.csv (未保存)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full" title="撤销"><LucideIcon name="undo-2" size={16} /></button>
|
||||
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full" title="重做"><LucideIcon name="redo-2" size={16} /></button>
|
||||
<div className="h-4 w-[1px] bg-slate-300 mx-2"></div>
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200">
|
||||
<LucideIcon name="save" size={12} /> 保存快照
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 shadow-sm">
|
||||
<LucideIcon name="download" size={12} /> 导出分析
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 2. Top Toolbar (V2 扁平化 + 长宽转换) */}
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-2 flex items-center gap-2 overflow-x-auto">
|
||||
|
||||
<ToolbarButton
|
||||
iconName="calculator"
|
||||
label="生成新变量"
|
||||
desc="使用公式计算生成新列 (如 BMI = 体重/身高²)"
|
||||
colorClass="bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100"
|
||||
onClick={() => setShowModal('calc')}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
iconName="calendar-clock"
|
||||
label="计算时间差"
|
||||
desc="计算两个日期之间的天数/年数 (如 年龄、住院天数)"
|
||||
colorClass="bg-blue-50 text-blue-600 group-hover:bg-blue-100"
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
iconName="arrow-left-right"
|
||||
label="长宽转换"
|
||||
desc="将'一人多行'转换为'一人一行' (透视/Pivot)"
|
||||
colorClass="bg-blue-50 text-blue-600 group-hover:bg-blue-100"
|
||||
onClick={() => setShowModal('pivot')}
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
|
||||
|
||||
<ToolbarButton
|
||||
iconName="file-up"
|
||||
label="拆分数据集"
|
||||
desc="按某一列的值将数据拆分为多个文件或 Sheet"
|
||||
colorClass="bg-purple-50 text-purple-600 group-hover:bg-purple-100"
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
|
||||
|
||||
<ToolbarButton
|
||||
iconName="alert-circle"
|
||||
label="跨列规则检查"
|
||||
desc="自定义逻辑规则 (如: 男性不能有孕产史) 并标记错误行"
|
||||
colorClass="bg-orange-50 text-orange-600 group-hover:bg-orange-100"
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
iconName="filter"
|
||||
label="构建入排标准"
|
||||
desc="组合多重筛选条件,生成最终分析集 (Cohort Selection)"
|
||||
colorClass="bg-indigo-50 text-indigo-600 group-hover:bg-indigo-100"
|
||||
/>
|
||||
|
||||
<div className="w-[1px] h-10 bg-slate-200 mx-2"></div>
|
||||
|
||||
<div className="relative ml-auto">
|
||||
<LucideIcon name="search" size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" placeholder="搜索值..." className="pl-9 pr-4 py-1.5 text-sm border border-slate-200 rounded-full focus:border-emerald-500 outline-none" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 3. Main Grid Area */}
|
||||
<div className="flex-1 relative overflow-hidden bg-slate-100 flex">
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex-1 overflow-auto p-6 transition-all duration-300" style={{ marginRight: showSidebar ? '320px' : '0' }}>
|
||||
<div className="bg-white border border-slate-200 shadow-sm rounded-lg overflow-hidden min-w-[800px]">
|
||||
<div className="flex bg-slate-50 border-b border-slate-200 sticky top-0 z-10">
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`h-10 px-4 flex items-center justify-between border-r border-slate-200 text-xs font-bold text-slate-600 cursor-pointer hover:bg-slate-100 transition-colors ${selectedColId === col.id ? 'bg-emerald-50 text-emerald-700' : ''}`}
|
||||
style={{ width: col.id === 'id' ? '100px' : '150px' }}
|
||||
onClick={() => handleColClick(col.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{col.type === 'number' && <LucideIcon name="hash" size={12} className="text-slate-400" />}
|
||||
{col.type === 'text' && <LucideIcon name="type" size={12} className="text-slate-400" />}
|
||||
{col.type === 'category' && <LucideIcon name="split" size={12} className="text-slate-400" />}
|
||||
{col.type === 'date' && <LucideIcon name="calendar" size={12} className="text-slate-400" />}
|
||||
{col.name}
|
||||
</div>
|
||||
<LucideIcon name="chevron-down" size={12} className="text-slate-300" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100 text-sm text-slate-700">
|
||||
{MOCK_DATA.map((row, idx) => (
|
||||
<div key={idx} className="flex hover:bg-slate-50 transition-colors">
|
||||
{COLUMNS.map((col) => {
|
||||
const val = row[col.id];
|
||||
const isNull = val === null || val === '';
|
||||
const isOutlier = col.id === 'age' && val > 120;
|
||||
const isDirty = col.id === 'lab_val' && typeof val === 'string' && val.includes('<');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`h-10 px-4 flex items-center border-r border-slate-100
|
||||
${isNull ? 'bg-red-50' : ''}
|
||||
${isOutlier ? 'bg-orange-100 text-orange-700 font-bold' : ''}
|
||||
${isDirty ? 'text-purple-600 font-medium' : ''}
|
||||
${selectedColId === col.id ? 'bg-emerald-50/30' : ''}
|
||||
`}
|
||||
style={{ width: col.id === 'id' ? '100px' : '150px' }}
|
||||
>
|
||||
{isNull ? <span className="text-[10px] text-red-400 italic">NULL</span> : val}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showModal === 'calc' && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-900">生成新变量 (Formula)</h3>
|
||||
<button onClick={() => setShowModal(null)}><LucideIcon name="trash-2" size={16} className="text-slate-400 rotate-45" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1">新变量名称</label>
|
||||
<input type="text" className="w-full border p-2 rounded text-sm" placeholder="例如: BMI_Calc" defaultValue="New_Var" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 mb-1">计算公式</label>
|
||||
<div className="w-full h-24 border p-2 rounded bg-slate-50 font-mono text-sm text-slate-700">
|
||||
[体重] / ([身高] / 100) ^ 2
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">+ 加</button>
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">- 减</button>
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">* 乘</button>
|
||||
<button className="text-xs px-2 py-1 bg-slate-100 rounded hover:bg-slate-200">/ 除</button>
|
||||
<button className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200">ln()</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">计算并生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal === 'recode' && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[400px] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-900">数值映射 (Recode)</h3>
|
||||
<button onClick={() => setShowModal(null)}><LucideIcon name="trash-2" size={16} className="text-slate-400 rotate-45" /></button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-xs text-slate-500 mb-4">将源值映射为新的统计数值:</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4"><span className="w-20 text-sm font-medium text-right">Male</span><LucideIcon name="arrow-right" size={16} className="text-slate-300" /><input type="text" className="w-20 border p-1 rounded text-center" defaultValue="1" /></div>
|
||||
<div className="flex items-center gap-4"><span className="w-20 text-sm font-medium text-right">Female</span><LucideIcon name="arrow-right" size={16} className="text-slate-300" /><input type="text" className="w-20 border p-1 rounded text-center" defaultValue="0" /></div>
|
||||
<div className="flex items-center gap-4"><span className="w-20 text-sm font-medium text-right">F</span><LucideIcon name="arrow-right" size={16} className="text-slate-300" /><input type="text" className="w-20 border p-1 rounded text-center" defaultValue="0" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">应用映射</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pivot Modal (新增) */}
|
||||
{showModal === 'pivot' && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center animate-in">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[600px] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-blue-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<LucideIcon name="arrow-left-right" size={20} className="text-blue-600" />
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900">长宽表转换 (Pivot)</h3>
|
||||
<p className="text-xs text-slate-500">将"一人多行"转换为"一人一行",适用于重复测量分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(null)}><LucideIcon name="trash-2" size={16} className="text-slate-400 rotate-45" /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-3 gap-6">
|
||||
{/* 列 1: 主键 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 block">1. 谁是唯一ID? (行)</label>
|
||||
<select className="w-full p-2 border border-slate-300 rounded text-sm bg-white">
|
||||
<option>病人ID</option>
|
||||
<option>姓名</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-400">转换后,每个ID只保留一行。</p>
|
||||
</div>
|
||||
|
||||
{/* 列 2: 区分列 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 block">2. 谁是区分次序? (列后缀)</label>
|
||||
<select className="w-full p-2 border border-slate-300 rounded text-sm bg-white">
|
||||
<option>入院日期</option>
|
||||
<option>就诊次序</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-400">生成列名如: 白细胞_20230101</p>
|
||||
</div>
|
||||
|
||||
{/* 列 3: 值列 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 block">3. 哪些值要铺平? (内容)</label>
|
||||
<div className="border border-slate-300 rounded p-2 h-32 overflow-y-auto bg-slate-50 text-sm space-y-1">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>白细胞</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>病理报告</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked className="rounded text-blue-600" /> <span>B超结果</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" className="rounded text-blue-600" /> <span>年龄 (无需铺平)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<div className="bg-slate-50 border border-slate-200 rounded p-3">
|
||||
<div className="text-xs font-bold text-slate-500 mb-2">转换预览:</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="border p-1 bg-white text-slate-400 rounded">
|
||||
ID | 时间 | 值<br/>
|
||||
P1 | T1 | A<br/>
|
||||
P1 | T2 | B
|
||||
</div>
|
||||
<LucideIcon name="arrow-right" size={16} className="text-blue-500" />
|
||||
<div className="border p-1 bg-white text-blue-600 font-medium rounded shadow-sm">
|
||||
ID | 值_T1 | 值_T2<br/>
|
||||
P1 | A | B
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
|
||||
<button onClick={() => setShowModal(null)} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-sm">开始转换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<ToolC_EditorV2 />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
213
docs/03-业务模块/DC-数据清洗整理/03-UI设计/智能数据清洗工作台V2.html
Normal file
213
docs/03-业务模块/DC-数据清洗整理/03-UI设计/智能数据清洗工作台V2.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>智能数据清洗工作台 V2 - 数据资产库升级版</title>
|
||||
<!-- 引入 Tailwind CSS (CDN) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- 引入 Lucide Icons (CDN) -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<!-- 引入 Alpine.js 用于简单的交互逻辑 (Tab切换) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
.fade-in { animation: fadeIn 0.3s ease-in-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 font-sans text-slate-800">
|
||||
|
||||
<!-- 1. 顶部一级导航 -->
|
||||
<header class="bg-white border-b border-slate-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="database" class="text-white w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="font-bold text-xl text-slate-900">MedData AI</span>
|
||||
</div>
|
||||
<nav class="hidden md:ml-10 md:flex md:space-x-8">
|
||||
<a href="#" class="text-slate-500 hover:text-slate-900 px-1 pt-1 border-b-2 border-transparent text-sm font-medium">AI问答</a>
|
||||
<a href="#" class="text-slate-500 hover:text-slate-900 px-1 pt-1 border-b-2 border-transparent text-sm font-medium">智能数据分析</a>
|
||||
<a href="#" class="text-slate-500 hover:text-slate-900 px-1 pt-1 border-b-2 border-transparent text-sm font-medium">知识库</a>
|
||||
<a href="#" class="border-blue-500 text-slate-900 px-1 pt-1 border-b-2 text-sm font-medium">智能数据清洗</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-sm text-slate-500">张医生 (主治医师)</div>
|
||||
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-500 text-sm font-bold">ZH</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2. 主内容区 -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 fade-in">
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">数据清洗工作台</h1>
|
||||
<p class="text-slate-500 mt-1">从原始 Excel 到科研级数据集,只需三步。</p>
|
||||
</div>
|
||||
|
||||
<!-- 3. 功能启动区 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<!-- 卡片 A -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-blue-50 rounded-bl-full -mr-4 -mt-4 transition-transform group-hover:scale-110"></div>
|
||||
<div class="relative">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4 text-blue-600"><i data-lucide="file-spreadsheet" class="w-7 h-7"></i></div>
|
||||
<h3 class="text-lg font-bold text-slate-900 mb-2 group-hover:text-blue-600 transition-colors">超级合并器</h3>
|
||||
<p class="text-sm text-slate-500 mb-4 h-10">解决多源数据时间轴对齐难题。支持 HIS 导出数据按病人 ID 自动合并。</p>
|
||||
<div class="flex items-center text-sm font-medium text-blue-600">开始合并 <i data-lucide="arrow-right" class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片 B -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-purple-50 rounded-bl-full -mr-4 -mt-4 transition-transform group-hover:scale-110"></div>
|
||||
<div class="relative">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4 text-purple-600"><i data-lucide="bot" class="w-7 h-7"></i></div>
|
||||
<h3 class="text-lg font-bold text-slate-900 mb-2 group-hover:text-purple-600 transition-colors">病历结构化机器人</h3>
|
||||
<p class="text-sm text-slate-500 mb-4 h-10">利用大模型提取非结构化文本。支持自动脱敏、批量处理与抽检。</p>
|
||||
<div class="flex items-center text-sm font-medium text-purple-600">新建提取任务 <i data-lucide="arrow-right" class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片 C -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-emerald-50 rounded-bl-full -mr-4 -mt-4 transition-transform group-hover:scale-110"></div>
|
||||
<div class="relative">
|
||||
<div class="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center mb-4 text-emerald-600"><i data-lucide="table-2" class="w-7 h-7"></i></div>
|
||||
<h3 class="text-lg font-bold text-slate-900 mb-2 group-hover:text-emerald-600 transition-colors">科研数据编辑器</h3>
|
||||
<p class="text-sm text-slate-500 mb-4 h-10">Excel 风格的在线清洗工具。支持缺失值填补、值替换与分析集导出。</p>
|
||||
<div class="flex items-center text-sm font-medium text-emerald-600">打开编辑器 <i data-lucide="arrow-right" class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 任务与资产中心 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- 左侧:最近任务 (不变) -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2"><i data-lucide="history" class="w-5 h-5 text-slate-500"></i> 最近处理任务</h2>
|
||||
<button class="text-sm text-blue-600 hover:underline">查看全部</button>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden min-h-[400px]">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">任务名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">工具</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-slate-200">
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap"><div class="flex flex-col"><span class="text-sm font-medium text-slate-900">肺癌门诊数据_2023合并任务</span><span class="text-xs text-slate-500">10分钟前</span></div></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700"><i data-lucide="database" class="w-3 h-3"></i> 超级合并器</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><span class="inline-flex items-center text-xs text-emerald-600 font-medium"><i data-lucide="check-circle-2" class="w-4 h-4 mr-1.5"></i> 完成 (12,450 行)</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"><div class="flex justify-end gap-3"><button class="text-slate-500 hover:text-slate-900"><i data-lucide="download" class="w-4 h-4"></i></button><button class="text-purple-600 hover:text-purple-800 flex items-center gap-1">去 AI 提取 <i data-lucide="arrow-right" class="w-4 h-4"></i></button></div></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap"><div class="flex flex-col"><span class="text-sm font-medium text-slate-900">Q3出院小结_批量提取</span><span class="text-xs text-slate-500">正在运行</span></div></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700"><i data-lucide="bot" class="w-3 h-3"></i> AI 结构化</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><div class="w-32"><div class="flex justify-between text-xs mb-1"><span class="text-blue-600 font-medium">处理中</span><span class="text-slate-500">45%</span></div><div class="w-full bg-slate-200 rounded-full h-1.5"><div class="bg-blue-600 h-1.5 rounded-full" style="width: 45%"></div></div></div></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"><span class="text-slate-400 text-xs">等待完成...</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:数据资产库 (V2 核心修改) -->
|
||||
<div class="lg:col-span-1 flex flex-col h-full" x-data="{ activeTab: 'all' }">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<i data-lucide="database" class="w-5 h-5 text-slate-500"></i> 数据资产库
|
||||
</h2>
|
||||
<div class="flex gap-1">
|
||||
<button class="p-1 hover:bg-slate-100 rounded-full"><i data-lucide="search" class="w-4 h-4 text-slate-400"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col flex-1 min-h-[400px]">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-200">
|
||||
<button @click="activeTab = 'all'" :class="{ 'border-blue-500 text-blue-600': activeTab === 'all', 'border-transparent text-slate-500 hover:text-slate-700': activeTab !== 'all' }" class="flex-1 py-3 text-xs font-medium text-center border-b-2 transition-colors">全部</button>
|
||||
<button @click="activeTab = 'output'" :class="{ 'border-emerald-500 text-emerald-600': activeTab === 'output', 'border-transparent text-slate-500 hover:text-slate-700': activeTab !== 'output' }" class="flex-1 py-3 text-xs font-medium text-center border-b-2 transition-colors">处理结果</button>
|
||||
<button @click="activeTab = 'input'" :class="{ 'border-slate-400 text-slate-600': activeTab === 'input', 'border-transparent text-slate-500 hover:text-slate-700': activeTab !== 'input' }" class="flex-1 py-3 text-xs font-medium text-center border-b-2 transition-colors">原始上传</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<div class="p-4 space-y-3 flex-1 overflow-y-auto">
|
||||
|
||||
<!-- 结果文件 1 -->
|
||||
<div x-show="activeTab === 'all' || activeTab === 'output'" class="group p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-blue-50 transition-all cursor-pointer">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="file-spreadsheet" class="w-4 h-4 text-emerald-600"></i>
|
||||
<h4 class="text-sm font-bold text-slate-800 group-hover:text-blue-700 truncate max-w-[150px]">2024_肺癌分析最终版.csv</h4>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-slate-600"><i data-lucide="more-horizontal" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 border border-emerald-100 text-[10px] rounded">已清洗</span>
|
||||
<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 border border-emerald-100 text-[10px] rounded">已脱敏</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500"><span>12,405 行</span><span>2023-10-24</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 原始文件 2 -->
|
||||
<div x-show="activeTab === 'all' || activeTab === 'input'" class="group p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-blue-50 transition-all cursor-pointer">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="file-input" class="w-4 h-4 text-slate-400"></i>
|
||||
<h4 class="text-sm font-bold text-slate-800 group-hover:text-blue-700 truncate max-w-[150px]">心血管回顾性研究_raw.xlsx</h4>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-slate-600"><i data-lucide="more-horizontal" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-1.5 py-0.5 bg-slate-100 text-slate-500 border border-slate-200 text-[10px] rounded">原始底表</span>
|
||||
<span class="px-1.5 py-0.5 bg-slate-100 text-slate-500 border border-slate-200 text-[10px] rounded">未处理</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500"><span>45,200 行</span><span>2023-10-22</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 结果文件 3 -->
|
||||
<div x-show="activeTab === 'all' || activeTab === 'output'" class="group p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-blue-50 transition-all cursor-pointer">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i data-lucide="file-spreadsheet" class="w-4 h-4 text-emerald-600"></i>
|
||||
<h4 class="text-sm font-bold text-slate-800 group-hover:text-blue-700 truncate max-w-[150px]">药物临床试验组_提取结果.csv</h4>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-slate-600"><i data-lucide="more-horizontal" class="w-4 h-4"></i></button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 border border-emerald-100 text-[10px] rounded">AI提取</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500"><span>800 行</span><span>2023-10-20</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部上传 -->
|
||||
<div class="p-4 border-t border-slate-100 bg-slate-50 rounded-b-xl">
|
||||
<button class="w-full py-2 text-sm text-slate-600 border border-dashed border-slate-300 bg-white rounded-lg hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2">
|
||||
<i data-lucide="upload-cloud" class="w-4 h-4"></i> + 上传原始文件到库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 初始化 Icons -->
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user