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
640 lines
35 KiB
TypeScript
640 lines
35 KiB
TypeScript
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; |