Files
AIclinicalresearch/frontend-v2/src/modules/pkb/components/Workspace/BatchModeComplete.tsx
HaHafeng 06028c6952 feat(pkb): implement complete batch processing workflow and frontend optimization
- Frontend V3 architecture migration to modules/pkb
- Implement three work modes: full-text reading, deep reading, batch processing
- Complete batch processing: template selection, progress display, result export (CSV)
- Integrate Ant Design X Chat component with streaming support
- Add document upload modal with drag-and-drop support
- Optimize UI: multi-line table display, citation formatting, auto-scroll
- Fix 10+ technical issues: API mapping, state sync, form clearing
- Update documentation: development records and module status

Performance: 3 docs batch processing ~17-28s
Status: PKB module now production-ready (90% complete)
2026-01-07 18:23:43 +08:00

657 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 完整的批处理模式组件
* 精细化设计,支持模板选择、文档选择、执行和结果展示
*/
import React, { useState, useEffect } from 'react';
import { Button, Table, Progress, message, Card, Steps, Checkbox, Radio, Tooltip } from 'antd';
import { Play, Download, RotateCw, FileText, CheckCircle2, Zap } from 'lucide-react';
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
interface BatchModeCompleteProps {
kbId: string;
kbInfo: KnowledgeBase;
documents: Document[];
template: string;
}
interface BatchTemplate {
id: string;
name: string;
description: string;
fields: { key: string; label: string }[];
}
interface BatchResult {
documentId: string;
documentName: string;
status: 'pending' | 'processing' | 'completed' | 'error';
progress: number;
result?: Record<string, string>;
error?: string;
}
// 模板配置ID必须与后端 PRESET_TEMPLATES 匹配)
const TEMPLATES: BatchTemplate[] = [
{
id: 'clinical_research', // ✅ 必须与后端模板ID匹配
name: '临床研究信息提取',
description: '提取研究目的、设计、对象、样本量、干预、对照、结果、证据等级',
fields: [
{ key: 'research_purpose', label: '研究目的' },
{ key: 'research_design', label: '研究设计' },
{ key: 'research_subjects', label: '研究对象' },
{ key: 'sample_size', label: '样本量' },
{ key: 'intervention_group', label: '干预组' },
{ key: 'control_group', label: '对照组' },
{ key: 'results_data', label: '结果及数据' },
{ key: 'oxford_level', label: '牛津评级' },
],
},
];
export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
kbId,
documents,
template: initialTemplate,
}) => {
const [step, setStep] = useState(0); // 0: 配置, 1: 执行中, 2: 结果
const [selectedTemplate, setSelectedTemplate] = useState<BatchTemplate | null>(TEMPLATES[0]);
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
const [results, setResults] = useState<BatchResult[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
// 过滤出已完成解析的文档,并去重(确保唯一性)
const completedDocs = React.useMemo(() => {
const seen = new Set<string>();
return documents.filter(doc => {
if (doc.status === 'completed' && !seen.has(doc.id)) {
seen.add(doc.id);
return true;
}
return false;
});
}, [documents]);
useEffect(() => {
// 初始化模板(如果有传入则使用,否则默认第一个)
if (initialTemplate) {
const template = TEMPLATES.find(t => t.id === initialTemplate);
if (template) setSelectedTemplate(template);
}
}, [initialTemplate]);
// 切换文档选择状态toggle模式参考旧版实现
const handleToggleDocument = (docId: string) => {
if (selectedDocs.includes(docId)) {
// 已选中 -> 取消选择
setSelectedDocs(prev => prev.filter(id => id !== docId));
} else {
// 未选中 -> 添加选择
if (selectedDocs.length >= 50) {
message.warning('最多选择50篇文档');
return;
}
setSelectedDocs(prev => [...prev, docId]);
}
};
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = completedDocs.slice(0, 50).map(d => d.id);
setSelectedDocs(allIds);
} else {
setSelectedDocs([]);
}
};
// 执行批处理
const handleExecute = async () => {
if (selectedDocs.length < 3) {
message.warning('请至少选择3篇文档');
return;
}
if (!selectedTemplate) {
message.error('请选择批处理模板');
return;
}
setStep(1);
setIsExecuting(true);
// 初始化结果 - 使用唯一索引确保key唯一
const initialResults: BatchResult[] = selectedDocs.map((docId, index) => {
const doc = completedDocs.find(d => d.id === docId);
return {
documentId: `${docId}-${index}`, // 确保唯一性
documentName: doc?.filename || '未知文档',
status: 'pending',
progress: 0,
};
});
setResults(initialResults);
try {
// 调用批处理API使用v2新版API
// 完整路径: /api/v2/pkb/batch-tasks/batch/execute
// 请求体格式必须匹配后端 ExecuteBatchBody 接口
const response = await fetch('/api/v2/pkb/batch-tasks/batch/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kb_id: kbId, // 后端期望 kb_id
document_ids: selectedDocs, // 后端期望 document_ids
template_type: 'preset', // 使用预设模板
template_id: selectedTemplate.id, // 后端期望 template_id
model_type: 'qwen-long', // 使用qwen-long模型
task_name: `${selectedTemplate.name}_${new Date().toLocaleString('zh-CN')}`,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || '批处理执行失败');
}
const responseData = await response.json();
console.log('[BatchMode] API响应:', responseData);
// 后端返回格式: { success: true, data: { task_id: xxx } }
const taskId = responseData.data?.task_id || responseData.taskId || responseData.task_id;
if (!taskId) {
console.error('[BatchMode] 未获取到taskId:', responseData);
message.error('批处理任务创建失败未获取到任务ID');
setIsExecuting(false);
setStep(0);
return;
}
console.log('[BatchMode] 任务已创建,开始轮询:', taskId);
// 轮询任务状态
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/v2/pkb/batch-tasks/batch/tasks/${taskId}`);
if (!statusRes.ok) {
console.error('[BatchMode] 获取任务状态失败:', statusRes.status);
return;
}
const statusJson = await statusRes.json();
const taskData = statusJson.data || statusJson;
console.log('[BatchMode] 任务状态:', taskData);
// 计算进度 - 基于后端返回的completed_count
const completedCount = taskData.completed_count || 0;
const failedCount = taskData.failed_count || 0;
const processedCount = completedCount + failedCount;
// 更新每个文档的状态(根据处理进度模拟)
setResults(prev => prev.map((r, idx) => {
if (idx < completedCount) {
// 已完成
return { ...r, status: 'completed' as const, progress: 100 };
} else if (idx < processedCount) {
// 失败
return { ...r, status: 'error' as const, progress: 100 };
} else if (idx === processedCount && taskData.status === 'processing') {
// 正在处理
return { ...r, status: 'processing' as const, progress: 50 };
}
return r;
}));
// 检查是否全部完成
if (taskData.status === 'completed' || taskData.status === 'failed') {
clearInterval(pollInterval);
setIsExecuting(false);
// 获取最终结果
try {
const resultsRes = await fetch(`/api/v2/pkb/batch-tasks/batch/tasks/${taskId}/results`);
console.log('[BatchMode] 获取结果响应状态:', resultsRes.status);
if (resultsRes.ok) {
const resultsJson = await resultsRes.json();
console.log('[BatchMode] 结果数据:', JSON.stringify(resultsJson, null, 2));
const resultsData = resultsJson.data?.results || [];
console.log('[BatchMode] 解析到的结果数量:', resultsData.length);
if (resultsData.length > 0) {
// 构建新的结果数组 - 后端返回的提取数据在 data 字段中
const newResults: BatchResult[] = resultsData.map((docResult: any, idx: number) => {
console.log(`[BatchMode] 文档 ${idx}:`, {
id: docResult.document_id,
name: docResult.document_name,
status: docResult.status,
hasData: !!docResult.data,
dataKeys: docResult.data ? Object.keys(docResult.data) : [],
});
// 🔑 后端返回的状态是 "success" 或 "failed",需要映射为前端的 "completed" 或 "error"
const isSuccess = docResult.status === 'success' || docResult.status === 'completed';
return {
documentId: docResult.document_id || `doc-${idx}`,
documentName: docResult.document_name || `文档${idx + 1}`,
status: isSuccess ? 'completed' as const : 'error' as const,
progress: 100,
result: docResult.data, // 后端返回的提取数据
error: docResult.error_message,
};
});
console.log('[BatchMode] 更新结果:', newResults);
setResults(newResults);
} else {
console.warn('[BatchMode] 没有结果数据');
}
} else {
console.error('[BatchMode] 获取结果失败,状态码:', resultsRes.status);
}
} catch (e) {
console.error('[BatchMode] 获取结果异常:', e);
}
// 延迟设置step确保状态更新完成
setTimeout(() => {
setStep(2);
}, 100);
if (taskData.status === 'completed') {
message.success(`批处理完成!成功 ${completedCount} 篇,失败 ${failedCount}`);
} else {
message.error('批处理失败: ' + (taskData.error || '未知错误'));
}
}
} catch (error) {
console.error('[BatchMode] 轮询任务状态失败:', error);
}
}, 2000);
// 设置超时保护5分钟
setTimeout(() => {
clearInterval(pollInterval);
if (isExecuting) {
setIsExecuting(false);
setStep(2);
message.warning('任务执行超时,请稍后查看结果');
}
}, 5 * 60 * 1000);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '执行失败';
message.error(errorMessage);
setIsExecuting(false);
setStep(0); // 返回配置步骤
}
};
// 导出Excel (CSV格式)
const handleExport = () => {
if (!selectedTemplate) return;
const completedResults = results.filter(r => r.status === 'completed' && r.result);
if (completedResults.length === 0) {
message.warning('没有可导出的结果');
return;
}
// 构建CSV数据 - 使用模板定义的字段
const headers = ['文档名称', ...selectedTemplate.fields.map(f => f.label)];
const rows = completedResults.map(r => [
r.documentName,
...selectedTemplate.fields.map(f => {
const value = r.result?.[f.key];
if (!value) return '-';
// 处理换行符和引号
return String(value).replace(/"/g, '""').replace(/\n/g, ' ');
}),
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(',')),
].join('\n');
// 下载
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().split('T')[0];
link.download = `批处理结果_${selectedTemplate.name}_${timestamp}.csv`;
link.click();
message.success(`已导出 ${completedResults.length} 条结果`);
};
// 重置
const handleReset = () => {
setStep(0);
setSelectedDocs([]);
setResults([]);
};
return (
<div className="h-full flex flex-col overflow-hidden bg-white">
{/* 步骤指示器 */}
<div className="flex-shrink-0 px-8 py-5 border-b border-gray-100 bg-gray-50/50">
<Steps
current={step}
size="small"
items={[
{ title: '配置任务', subTitle: '选择模板和文档' },
{ title: '执行中', subTitle: '正在处理文档' },
{ title: '查看结果', subTitle: '导出数据' },
]}
/>
</div>
{/* 步骤1: 配置 */}
{step === 0 && (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* 模板选择 */}
<Card
title={
<div className="flex items-center">
<Zap className="w-4 h-4 mr-2 text-orange-500" />
<span></span>
</div>
}
size="small"
className="shadow-sm"
>
<Radio.Group
value={selectedTemplate?.id}
onChange={(e) => {
const template = TEMPLATES.find(t => t.id === e.target.value);
setSelectedTemplate(template || null);
}}
className="w-full"
>
<div className="space-y-3">
{TEMPLATES.map(t => (
<div
key={t.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedTemplate?.id === t.id
? 'border-blue-400 bg-blue-50/50'
: 'border-gray-200 hover:border-blue-200 hover:bg-gray-50'
}`}
onClick={() => setSelectedTemplate(t)}
>
<Radio value={t.id} className="w-full">
<div className="ml-2">
<div className="font-medium text-slate-800">{t.name}</div>
<div className="text-xs text-slate-500 mt-1">{t.description}</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{t.fields.slice(0, 5).map(f => (
<span key={f.key} className="text-xs bg-gray-100 text-slate-600 px-2 py-0.5 rounded">
{f.label}
</span>
))}
{t.fields.length > 5 && (
<span className="text-xs text-slate-400">+{t.fields.length - 5}</span>
)}
</div>
</div>
</Radio>
</div>
))}
</div>
</Radio.Group>
</Card>
{/* 文档选择 */}
<Card
title={
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<FileText className="w-4 h-4 mr-2 text-blue-500" />
<span></span>
<span className="ml-2 text-xs text-slate-400 font-normal">
({completedDocs.length} )
</span>
</div>
<Checkbox
checked={selectedDocs.length === completedDocs.length && completedDocs.length > 0}
indeterminate={selectedDocs.length > 0 && selectedDocs.length < completedDocs.length}
onChange={(e) => handleSelectAll(e.target.checked)}
>
</Checkbox>
</div>
}
size="small"
className="shadow-sm"
>
<div className="space-y-1 max-h-[280px] overflow-y-auto">
{completedDocs.length === 0 ? (
<div className="text-center py-8 text-slate-400">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p></p>
</div>
) : (
completedDocs.map(doc => (
<div
key={doc.id}
className={`flex items-center p-3 rounded-lg cursor-pointer transition-all ${
selectedDocs.includes(doc.id)
? 'bg-blue-50 border border-blue-200'
: 'hover:bg-gray-50 border border-transparent'
}`}
onClick={() => handleToggleDocument(doc.id)}
>
<Checkbox
checked={selectedDocs.includes(doc.id)}
onChange={() => handleToggleDocument(doc.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="w-8 h-8 bg-red-50 text-red-500 rounded flex items-center justify-center ml-3 mr-3 flex-shrink-0">
<FileText className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-700 truncate">{doc.filename}</div>
<div className="text-xs text-slate-400">
{doc.tokensCount ? `${(doc.tokensCount / 1000).toFixed(0)}k tokens` : ''}
</div>
</div>
{selectedDocs.includes(doc.id) && (
<CheckCircle2 className="w-4 h-4 text-blue-500 flex-shrink-0" />
)}
</div>
))
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-100">
<div className={`flex items-center p-3 rounded-lg ${
selectedDocs.length >= 3
? 'bg-green-50 border border-green-200'
: 'bg-yellow-50 border border-yellow-200'
}`}>
<CheckCircle2 className={`w-4 h-4 mr-2 ${
selectedDocs.length >= 3 ? 'text-green-500' : 'text-yellow-500'
}`} />
<span className="text-sm">
<strong className="text-blue-600">{selectedDocs.length}</strong>
{selectedDocs.length < 3 && <span className="text-orange-600 ml-2">3</span>}
</span>
</div>
</div>
</Card>
{/* 开始按钮 - 显示实际选择的文档数量 */}
<Button
type="primary"
size="large"
icon={<Play className="w-4 h-4" />}
onClick={handleExecute}
disabled={selectedDocs.length < 3 || selectedDocs.length > 50 || !selectedTemplate}
className="w-full h-12 text-base font-medium shadow-lg"
>
🚀 ({selectedDocs.length} )
</Button>
</div>
</div>
)}
{/* 步骤2: 执行中 */}
{step === 1 && (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto">
<Card
title={
<div className="flex items-center">
<Zap className="w-4 h-4 mr-2 text-orange-500 animate-pulse" />
<span>...</span>
</div>
}
size="small"
className="shadow-sm"
>
<div className="space-y-4">
{results.map((result) => (
<div key={result.documentId} className="border-b border-gray-100 pb-4 last:border-b-0 last:pb-0">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center min-w-0 flex-1">
<FileText className="w-4 h-4 text-red-400 mr-2 flex-shrink-0" />
<span className="font-medium text-sm text-slate-700 truncate">
{result.documentName}
</span>
</div>
<span className="text-xs ml-2 flex-shrink-0">
{result.status === 'completed' && <span className="text-green-600"> </span>}
{result.status === 'processing' && <span className="text-blue-600"> </span>}
{result.status === 'error' && <span className="text-red-600"> </span>}
{result.status === 'pending' && <span className="text-slate-400"> </span>}
</span>
</div>
<Progress
percent={result.progress}
status={result.status === 'error' ? 'exception' : result.status === 'completed' ? 'success' : 'active'}
size="small"
strokeColor={result.status === 'completed' ? '#22c55e' : '#3b82f6'}
/>
</div>
))}
</div>
</Card>
</div>
</div>
)}
{/* 步骤3: 结果 */}
{step === 2 && selectedTemplate && (
<div className="flex-1 overflow-hidden flex flex-col p-6">
<div className="flex-shrink-0 mb-4 flex justify-between items-center">
<div className="flex items-center text-sm text-slate-600">
<CheckCircle2 className="w-4 h-4 text-green-500 mr-2" />
{results.filter(r => r.status === 'completed').length} / {results.length}
</div>
<div className="flex space-x-3">
<Button icon={<RotateCw className="w-4 h-4" />} onClick={handleReset}>
</Button>
<Button
type="primary"
icon={<Download className="w-4 h-4" />}
onClick={handleExport}
>
Excel
</Button>
</div>
</div>
<div className="flex-1 overflow-auto bg-white border border-gray-200 rounded-lg shadow-sm">
{results.filter(r => r.status === 'completed' && r.result).length === 0 ? (
<div className="flex items-center justify-center h-full text-slate-400">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p></p>
<p className="text-xs mt-1"></p>
</div>
</div>
) : (
<Table
dataSource={results.filter(r => r.status === 'completed' && r.result)}
rowKey="documentId"
pagination={false}
scroll={{ x: 1800, y: 'calc(100vh - 400px)' }}
size="small"
className="batch-results-table"
columns={[
{
title: '文档名称',
dataIndex: 'documentName',
key: 'documentName',
fixed: 'left',
width: 220,
render: (text: string) => (
<Tooltip title={text} placement="topLeft">
<div className="flex items-center">
<FileText className="w-4 h-4 text-red-400 mr-2 flex-shrink-0" />
<span
className="font-medium text-slate-800"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
lineHeight: '1.4',
}}
>
{text}
</span>
</div>
</Tooltip>
),
},
...selectedTemplate.fields.map(field => ({
title: field.label,
key: field.key,
width: field.key === 'results_data' ? 280 : 180, // 结果数据列更宽
render: (_: unknown, record: BatchResult) => {
const value = record.result?.[field.key];
if (!value) return <span className="text-slate-300">-</span>;
const textValue = String(value);
return (
<Tooltip
title={<div style={{ maxWidth: 400, maxHeight: 300, overflow: 'auto' }}>{textValue}</div>}
placement="topLeft"
overlayStyle={{ maxWidth: 450 }}
>
<div
className="text-slate-700 text-sm leading-relaxed cursor-help"
style={{
display: '-webkit-box',
WebkitLineClamp: 3, // 最多显示3行
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
lineHeight: '1.5',
}}
>
{textValue}
</div>
</Tooltip>
);
},
})),
]}
/>
)}
</div>
</div>
)}
</div>
);
};