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

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

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

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

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

489 lines
41 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工具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>