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:
2025-12-03 09:36:35 +08:00
parent 33db2687b9
commit 5f1e7af92c
47 changed files with 2757 additions and 10 deletions

View File

@@ -510,3 +510,4 @@ export default FulltextDetailDrawer;

View File

@@ -109,3 +109,4 @@ export function useFulltextResults({

View File

@@ -72,3 +72,4 @@ export function useFulltextTask({

View File

@@ -463,3 +463,4 @@ export default FulltextResults;

View File

@@ -0,0 +1,178 @@
/**
* Tool B API 服务层
* 病历结构化机器人 API调用
*/
const API_BASE = '/api/v1/dc/tool-b';
export interface HealthCheckRequest {
fileKey: string;
columnName: string;
}
export interface HealthCheckResponse {
status: 'good' | 'bad';
emptyRate: number;
avgLength: number;
totalRows: number;
estimatedTokens: number;
message: string;
}
export interface Template {
diseaseType: string;
reportType: string;
displayName: string;
fields: Array<{
name: string;
desc: string;
width?: string;
}>;
}
export interface CreateTaskRequest {
projectName: string;
fileKey: string;
textColumn: string;
diseaseType: string;
reportType: string;
targetFields: Array<{
name: string;
desc: string;
}>;
}
export interface CreateTaskResponse {
taskId: string;
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
totalRows: number;
processedRows: number;
conflictCount?: number;
estimatedTime?: string;
logs: string[];
}
export interface ExtractionItem {
id: string;
rowIndex: number;
originalText: string;
status: 'clean' | 'conflict';
extractedData: Record<string, {
modelA: string;
modelB: string;
chosen: string | null;
}>;
}
/**
* 健康检查API
*/
export async function healthCheck(request: HealthCheckRequest): Promise<HealthCheckResponse> {
const response = await fetch(`${API_BASE}/health-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Health check failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 获取模板列表
*/
export async function getTemplates(): Promise<{ templates: Template[] }> {
const response = await fetch(`${API_BASE}/templates`);
if (!response.ok) {
throw new Error(`Get templates failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 创建提取任务
*/
export async function createTask(request: CreateTaskRequest): Promise<CreateTaskResponse> {
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Create task failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 查询任务进度
*/
export async function getTaskProgress(taskId: string): Promise<TaskProgress> {
const response = await fetch(`${API_BASE}/tasks/${taskId}/progress`);
if (!response.ok) {
throw new Error(`Get task progress failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 获取验证网格数据
*/
export async function getTaskItems(taskId: string): Promise<{ items: ExtractionItem[] }> {
const response = await fetch(`${API_BASE}/tasks/${taskId}/items`);
if (!response.ok) {
throw new Error(`Get task items failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 裁决冲突
*/
export async function resolveConflict(
itemId: string,
fieldName: string,
value: string
): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/items/${itemId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fieldName, chosenValue: value }),
});
if (!response.ok) {
throw new Error(`Resolve conflict failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 导出结果
*/
export async function exportResults(taskId: string): Promise<Blob> {
const response = await fetch(`${API_BASE}/tasks/${taskId}/export`);
if (!response.ok) {
throw new Error(`Export results failed: ${response.statusText}`);
}
return await response.blob();
}

View File

@@ -103,3 +103,4 @@ export const useAssets = (activeTab: AssetTabType) => {
};
};

View File

@@ -93,3 +93,4 @@ export const useRecentTasks = () => {
};
};

View File

@@ -16,6 +16,7 @@ import Placeholder from '@/shared/components/Placeholder';
// 懒加载组件
const Portal = lazy(() => import('./pages/Portal'));
const ToolBModule = lazy(() => import('./pages/tool-b/index'));
const DCModule = () => {
return (
@@ -43,16 +44,7 @@ const DCModule = () => {
/>
{/* Tool B - 病历结构化机器人(开发中) */}
<Route
path="tool-b/*"
element={
<Placeholder
title="Tool B - 病历结构化机器人"
description="该工具正在开发中,即将上线"
moduleName="AI驱动的医疗文本结构化提取"
/>
}
/>
<Route path="tool-b/*" element={<ToolBModule />} />
{/* Tool C - 科研数据编辑器(暂未开发) */}
<Route

View File

@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { FileText, RefreshCw, CheckCircle2, AlertTriangle, UploadCloud } from 'lucide-react';
import { ToolBState } from './index';
interface Step1UploadProps {
state: ToolBState;
updateState: (updates: Partial<ToolBState>) => void;
onNext: () => void;
}
const Step1Upload: React.FC<Step1UploadProps> = ({ state, updateState, onNext }) => {
const [isChecking, setIsChecking] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
// 处理文件上传
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadedFile(file);
// TODO: 上传文件到服务器获取fileKey
updateState({
fileName: file.name,
fileKey: `uploads/temp/${file.name}`, // Mock路径
});
};
// 健康检查
const runHealthCheck = async (columnName: string) => {
if (!columnName || !state.fileKey) return;
setIsChecking(true);
updateState({
selectedColumn: columnName,
healthCheckResult: { status: 'unknown' }
});
try {
// TODO: 调用真实API
// const response = await fetch('/api/v1/dc/tool-b/health-check', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ fileKey: state.fileKey, columnName })
// });
// const data = await response.json();
// Mock响应
await new Promise(resolve => setTimeout(resolve, 1000));
const mockResult = columnName.includes('ID') || columnName.includes('时间')
? {
status: 'bad' as const,
emptyRate: 0.85,
avgLength: 15.2,
totalRows: 500,
estimatedTokens: 0,
message: '空值率过高85.0%),该列不适合提取'
}
: {
status: 'good' as const,
emptyRate: 0.02,
avgLength: 358.4,
totalRows: 500,
estimatedTokens: 450000,
message: '健康度优秀,预计消耗约 450.0k Token'
};
updateState({ healthCheckResult: mockResult });
} catch (error) {
console.error('Health check failed:', error);
updateState({
healthCheckResult: {
status: 'bad',
message: '健康检查失败,请重试'
}
});
} finally {
setIsChecking(false);
}
};
const canProceed = state.healthCheckResult.status === 'good';
return (
<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">
{/* 文件上传区域 */}
{!state.fileName ? (
<div className="border-2 border-dashed border-slate-300 rounded-xl p-12 text-center hover:border-purple-400 hover:bg-purple-50/30 transition-all cursor-pointer">
<label className="cursor-pointer flex flex-col items-center">
<UploadCloud className="w-16 h-16 text-slate-400 mb-4" />
<div className="text-lg font-medium text-slate-700 mb-2">
Excel
</div>
<div className="text-sm text-slate-500">
.xlsx, .xls 50MB
</div>
<input
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
) : (
<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">{state.fileName}</div>
<div className="text-sm text-slate-500">
{uploadedFile ? `${(uploadedFile.size / 1024 / 1024).toFixed(2)} MB` : '12.5 MB'} 1,200
</div>
</div>
<label className="text-sm text-purple-600 hover:underline font-medium cursor-pointer">
<input
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
)}
{/* 列选择 */}
{state.fileName && (
<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={state.selectedColumn}
onChange={(e) => 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>
{/* 健康检查结果 */}
{state.selectedColumn && (
<div className={`p-5 rounded-xl border transition-all duration-300 ${
isChecking
? 'bg-slate-50'
: state.healthCheckResult.status === '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" />
) : state.healthCheckResult.status === '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'
: state.healthCheckResult.status === 'good'
? 'text-emerald-800'
: 'text-red-800'
}`}>
{isChecking
? '正在进行数据体检...'
: state.healthCheckResult.message || '健康度检查完成'}
</h4>
{!isChecking && state.healthCheckResult.status === 'good' && (
<div className="text-sm text-slate-600 mt-2 flex gap-6">
<span>: <strong>{state.healthCheckResult.avgLength?.toFixed(0)}</strong></span>
<span>: <strong>{((state.healthCheckResult.emptyRate || 0) * 100).toFixed(1)}%</strong></span>
<span> Token: <strong className="text-purple-600">
{state.healthCheckResult.estimatedTokens ? `${(state.healthCheckResult.estimatedTokens / 1000).toFixed(0)}k` : 'N/A'}
</strong></span>
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
{/* 底部按钮 */}
{state.fileName && (
<div className="flex justify-end pt-6 border-t border-slate-100 mt-6">
<button
className={`flex items-center gap-2 px-8 py-2.5 bg-purple-600 text-white rounded-lg font-medium shadow-lg shadow-purple-200 transition-all ${
canProceed
? 'hover:bg-purple-700 hover:shadow-xl hover:shadow-purple-300 active:scale-95'
: 'opacity-50 cursor-not-allowed'
}`}
disabled={!canProceed}
onClick={onNext}
>
<span></span>
<span></span>
</button>
</div>
)}
</div>
);
};
export default Step1Upload;

View File

@@ -0,0 +1,199 @@
import React, { useEffect } from 'react';
import { Plus, Trash2, Bot, Stethoscope, LayoutTemplate, ArrowRight } from 'lucide-react';
import { ToolBState, ExtractionField } from './index';
interface Step2SchemaProps {
state: ToolBState;
updateState: (updates: Partial<ToolBState>) => void;
onNext: () => void;
onPrev: () => void;
}
// 预设模板
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' }
]
},
diabetes: {
admission: [
{ id: 'd1', name: '糖化血红蛋白', desc: 'HbA1c值', width: 'w-40' },
{ id: 'd2', name: '空腹血糖', desc: 'FPG值', width: 'w-32' },
{ id: 'd3', name: '糖尿病类型', desc: '1型/2型', width: 'w-32' },
{ id: 'd4', name: '并发症', desc: '视网膜病变等', width: 'w-48' }
]
}
};
const Step2Schema: React.FC<Step2SchemaProps> = ({ state, updateState, onNext, onPrev }) => {
// 加载模板
useEffect(() => {
const template = TEMPLATES[state.diseaseType]?.[state.reportType];
if (template) {
updateState({ fields: template });
}
}, [state.diseaseType, state.reportType, updateState]);
// 添加字段
const addField = () => {
const newField: ExtractionField = {
id: `custom_${Date.now()}`,
name: '新字段',
desc: '描述...',
width: 'w-40'
};
updateState({ fields: [...state.fields, newField] });
};
// 删除字段
const removeField = (id: string) => {
updateState({ fields: state.fields.filter(f => f.id !== id) });
};
// 更新字段
const updateField = (id: string, updates: Partial<ExtractionField>) => {
updateState({
fields: state.fields.map(f => f.id === id ? { ...f, ...updates } : f)
});
};
return (
<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={state.diseaseType}
onChange={(e) => updateState({ diseaseType: e.target.value })}
>
<option value="lung_cancer"> (Lung Cancer)</option>
<option value="diabetes">尿 (Diabetes)</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={state.reportType}
onChange={(e) => updateState({ reportType: e.target.value })}
>
<option value="pathology"> (Pathology)</option>
<option value="admission"> (Admission Note)</option>
</select>
</div>
</div>
{/* 字段配置与Prompt预览 */}
<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"
onClick={addField}
>
<Plus className="w-3 h-3" />
</button>
</div>
{state.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
value={field.name}
onChange={(e) => updateField(field.id, { name: e.target.value })}
className="col-span-2 bg-transparent text-sm font-medium text-slate-900 outline-none border-none focus:ring-0"
placeholder="字段名"
/>
<input
value={field.desc}
onChange={(e) => updateField(field.id, { desc: e.target.value })}
className="col-span-3 bg-transparent text-sm text-slate-500 outline-none border-none focus:ring-0"
placeholder="字段描述"
/>
</div>
<button
className="text-slate-300 hover:text-red-500 transition-colors"
onClick={() => removeField(field.id)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{state.fields.length === 0 && (
<div className="text-center py-12 text-slate-400">
<Bot className="w-12 h-12 mx-auto mb-2 opacity-30" />
<p className="text-sm"></p>
</div>
)}
</div>
{/* 右侧Prompt预览 */}
<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 {state.diseaseType.replace('_', ' ')} medical records.</p>
<p className="mb-2">Extract fields in JSON format:</p>
<p className="text-yellow-500">{'{'}</p>
{state.fields.map(f => (
<p key={f.id} className="pl-4">
<span className="text-blue-400">&quot;{f.name}&quot;</span>: <span className="text-green-400">&quot;string&quot;</span>, <span className="text-slate-600">// {f.desc}</span>
</p>
))}
<p className="text-yellow-500">{'}'}</p>
<p className="mt-4 text-slate-600">// Instructions</p>
<p>- Extract ALL fields from the medical text</p>
<p>- If a field is not mentioned, return &quot;&quot;</p>
<p>- Preserve exact values (e.g., sizes, dates)</p>
<p>- Output MUST be valid JSON</p>
</div>
</div>
</div>
{/* 底部按钮 */}
<div className="flex justify-between pt-4">
<button
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900"
onClick={onPrev}
>
</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 ${
state.fields.length === 0 ? 'opacity-50 cursor-not-allowed' : ''
}`}
disabled={state.fields.length === 0}
onClick={onNext}
>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default Step2Schema;

View File

@@ -0,0 +1,75 @@
import React, { useEffect } from 'react';
import { ToolBState } from './index';
interface Step3ProcessingProps {
state: ToolBState;
updateState: (updates: Partial<ToolBState>) => void;
onComplete: () => void;
}
const Step3Processing: React.FC<Step3ProcessingProps> = ({ state, updateState, onComplete }) => {
useEffect(() => {
// 模拟处理进度
const timer = setInterval(() => {
updateState({ progress: Math.min(state.progress + 2, 100) });
if (state.progress >= 100) {
clearInterval(timer);
setTimeout(onComplete, 800);
}
}, 100);
return () => clearInterval(timer);
}, [state.progress, updateState, onComplete]);
return (
<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: `${state.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">
<div className="mb-1 text-slate-600 flex gap-2">
<span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span>
<span> (DeepSeek-V3 & Qwen-Max)...</span>
</div>
<div className="mb-1 text-slate-600 flex gap-2">
<span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span>
<span>PII ...</span>
</div>
{state.progress > 40 && (
<div className="mb-1 text-slate-600 flex gap-2">
<span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span>
<span>DeepSeek: 提取进度 {state.progress}%</span>
</div>
)}
{state.progress > 45 && (
<div className="mb-1 text-slate-600 flex gap-2">
<span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span>
<span>Qwen: 提取进度 {state.progress}%</span>
</div>
)}
{state.progress > 80 && (
<div className="mb-1 text-slate-600 flex gap-2">
<span className="text-slate-400">[{new Date().toLocaleTimeString()}]</span>
<span> (Cross-Validation)...</span>
</div>
)}
<div className="animate-pulse text-purple-500">_</div>
</div>
</div>
);
};
export default Step3Processing;

View 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;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { CheckCircle2, Download, Table2, ShieldCheck, Zap } from 'lucide-react';
import { ToolBState } from './index';
interface Step5ResultProps {
state: ToolBState;
}
const Step5Result: React.FC<Step5ResultProps> = ({ state }) => {
return (
<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/>
{state.rows.length || 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" /> ~{state.healthCheckResult.estimatedTokens ? `${(state.healthCheckResult.estimatedTokens / 1000).toFixed(0)}k` : '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>
);
};
export default Step5Result;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { CheckCircle2 } from 'lucide-react';
import { Step } from '../index';
interface StepIndicatorProps {
currentStep: Step;
}
const steps = [
{ id: 'upload', label: '1. 选列与体检' },
{ id: 'schema', label: '2. 智能模版' },
{ id: 'processing', label: '3. 双模型提取' },
{ id: 'verify', label: '4. 交叉验证' },
{ id: 'result', label: '5. 完成' }
];
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep }) => {
return (
<div className="flex items-center justify-center mb-6 px-4">
{steps.map((step, idx) => (
<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'
: (steps.findIndex(s => s.id === currentStep) > idx || currentStep === 'result')
? 'bg-emerald-500 text-white'
: 'bg-slate-200 text-slate-500'}`}>
{(steps.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 < steps.length - 1 && (
<div className={`h-[2px] w-12 -mt-6 mx-2 ${
steps.findIndex(s => s.id === currentStep) > idx
? 'bg-emerald-500'
: 'bg-slate-200'}`}
></div>
)}
</React.Fragment>
))}
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,167 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Step1Upload from './Step1Upload';
import Step2Schema from './Step2Schema';
import Step3Processing from './Step3Processing';
import Step4Verify from './Step4Verify';
import Step5Result from './Step5Result';
import StepIndicator from './components/StepIndicator';
import { Bot, Split, ArrowLeft } from 'lucide-react';
export type Step = 'upload' | 'schema' | 'processing' | 'verify' | 'result';
export interface ExtractionField {
id: string;
name: string;
desc: string;
width?: string;
}
export interface ToolBState {
// Step 1
fileName: string;
fileKey: string;
selectedColumn: string;
healthCheckResult: {
status: 'unknown' | 'good' | 'bad';
emptyRate?: number;
avgLength?: number;
totalRows?: number;
estimatedTokens?: number;
message?: string;
};
// Step 2
diseaseType: string;
reportType: string;
fields: ExtractionField[];
// Step 3
taskId?: string;
progress: number;
// Step 4
rows: any[];
// Step 5
resultFileUrl?: string;
}
const ToolBModule: React.FC = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<Step>('upload');
const [state, setState] = useState<ToolBState>({
fileName: '',
fileKey: '',
selectedColumn: '',
healthCheckResult: { status: 'unknown' },
diseaseType: 'lung_cancer',
reportType: 'pathology',
fields: [],
progress: 0,
rows: [],
});
const updateState = (updates: Partial<ToolBState>) => {
setState(prev => ({ ...prev, ...updates }));
};
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-4">
<button
onClick={() => navigate('/data-cleaning')}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white border border-slate-200 hover:border-slate-300 text-slate-600 hover:text-slate-900 transition-all shadow-sm hover:shadow"
title="返回数据清洗工作台"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</button>
<div className="h-8 w-px bg-slate-200"></div>
<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"></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>
</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>
{/* Step Indicator */}
<div className="pt-6 pb-2">
<StepIndicator currentStep={currentStep} />
</div>
{/* Main Content */}
<div className="flex-1 px-8 pb-8 relative overflow-hidden flex flex-col">
{currentStep === 'upload' && (
<Step1Upload
state={state}
updateState={updateState}
onNext={() => setCurrentStep('schema')}
/>
)}
{currentStep === 'schema' && (
<Step2Schema
state={state}
updateState={updateState}
onNext={() => setCurrentStep('processing')}
onPrev={() => setCurrentStep('upload')}
/>
)}
{currentStep === 'processing' && (
<Step3Processing
state={state}
updateState={updateState}
onComplete={() => setCurrentStep('verify')}
/>
)}
{currentStep === 'verify' && (
<Step4Verify
state={state}
updateState={updateState}
onComplete={() => setCurrentStep('result')}
/>
)}
{currentStep === 'result' && (
<Step5Result
state={state}
/>
)}
</div>
</div>
</div>
);
};
export default ToolBModule;

View File

@@ -51,3 +51,4 @@ export interface Asset {
// 资产库Tab类型
export type AssetTabType = 'all' | 'processed' | 'raw';