feat(dc): Complete Tool B frontend development with UI optimization
- Implement Tool B 5-step workflow (upload, schema, processing, verify, result) - Add back navigation button to Portal - Optimize Step 2 field list styling to match prototype - Fix step 3 label: 'dual-blind' to 'dual-model' - Create API service layer with 7 endpoints - Integrate Tool B route into DC module - Add comprehensive TypeScript types Components (~1100 lines): - index.tsx: Main Tool B entry with state management - Step1Upload.tsx: File upload and health check - Step2Schema.tsx: Smart template configuration - Step3Processing.tsx: Dual-model extraction progress - Step4Verify.tsx: Conflict verification workbench - Step5Result.tsx: Result display - StepIndicator.tsx: Step progress component - api/toolB.ts: API service layer Status: Frontend complete, ready for API integration
This commit is contained in:
259
frontend-v2/src/modules/dc/pages/tool-b/Step4Verify.tsx
Normal file
259
frontend-v2/src/modules/dc/pages/tool-b/Step4Verify.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Download, ArrowRight, FileText, Check, RotateCcw, X } from 'lucide-react';
|
||||
import { ToolBState } from './index';
|
||||
|
||||
interface Step4VerifyProps {
|
||||
state: ToolBState;
|
||||
updateState: (updates: Partial<ToolBState>) => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
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 Step4Verify: React.FC<Step4VerifyProps> = ({ state, updateState, onComplete }) => {
|
||||
const [rows, setRows] = useState<VerifyRow[]>([]);
|
||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||
|
||||
// 初始化Mock数据
|
||||
useEffect(() => {
|
||||
const mockRows: VerifyRow[] = [
|
||||
{
|
||||
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'
|
||||
}
|
||||
];
|
||||
setRows(mockRows);
|
||||
updateState({ rows: mockRows });
|
||||
}, [updateState]);
|
||||
|
||||
// 采纳值
|
||||
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;
|
||||
|
||||
return (
|
||||
<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={onComplete}
|
||||
>
|
||||
完成并入库 <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>
|
||||
{state.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>
|
||||
{/* 动态列 */}
|
||||
{state.fields.map(f => {
|
||||
const cell = row.results[f.name];
|
||||
if (!cell) return <td key={f.id} className="px-4 py-3 text-slate-400">-</td>;
|
||||
|
||||
const isConflict = cell.A !== cell.B && cell.chosen === null;
|
||||
const isResolved = cell.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, cell.A); }}
|
||||
>
|
||||
<span className="truncate max-w-[100px] text-slate-700" title={cell.A}>{cell.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, cell.B); }}
|
||||
>
|
||||
<span className="truncate max-w-[100px] text-slate-700" title={cell.B}>{cell.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 && cell.chosen !== cell.A && cell.chosen !== cell.B ? (
|
||||
<div className="flex items-center justify-between group">
|
||||
<span className="text-purple-700 font-medium">{cell.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" />
|
||||
{cell.chosen || cell.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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step4Verify;
|
||||
Reference in New Issue
Block a user