- 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)
657 lines
26 KiB
TypeScript
657 lines
26 KiB
TypeScript
/**
|
||
* 完整的批处理模式组件
|
||
* 精细化设计,支持模板选择、文档选择、执行和结果展示
|
||
*/
|
||
|
||
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>
|
||
);
|
||
};
|