Files
AIclinicalresearch/frontend/src/pages/rvw/components/TaskTable.tsx
HaHafeng 440f75255e feat(rvw): Complete Phase 4-5 - Bug fixes and Word export
Summary:
- Fix methodology score display issue in task list (show score instead of 'warn')
- Add methodology_score field to database schema
- Fix report display when only methodology agent is selected
- Implement Word document export using docx library
- Update documentation to v3.0/v3.1

Backend changes:
- Add methodologyScore to Prisma schema and TaskSummary type
- Update reviewWorker to save methodologyScore
- Update getTaskList to return methodologyScore

Frontend changes:
- Install docx and file-saver libraries
- Implement handleExportReport with Word generation
- Fix activeTab auto-selection based on available data
- Add proper imports for docx components

Documentation:
- Update RVW module status to 90% (Phase 1-5 complete)
- Update system status document to v3.0

Tested: All review workflows verified, Word export functional
2026-01-10 22:52:15 +08:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { FileText, FileType2, Loader2, Play, Eye, RefreshCw, Trash2 } from 'lucide-react';
import type { ReviewTask } from '../types';
import { formatFileSize, formatTime } from '../api';
interface TaskTableProps {
tasks: ReviewTask[];
selectedIds: string[];
onSelectChange: (ids: string[]) => void;
onViewReport: (task: ReviewTask) => void;
onRunTask: (task: ReviewTask) => void;
onDeleteTask: (task: ReviewTask) => void;
}
export default function TaskTable({
tasks,
selectedIds,
onSelectChange,
onViewReport,
onRunTask,
onDeleteTask
}: TaskTableProps) {
const allSelected = tasks.length > 0 && selectedIds.length === tasks.length;
const toggleSelectAll = () => {
if (allSelected) {
onSelectChange([]);
} else {
onSelectChange(tasks.map(t => t.id));
}
};
const toggleSelect = (id: string) => {
if (selectedIds.includes(id)) {
onSelectChange(selectedIds.filter(i => i !== id));
} else {
onSelectChange([...selectedIds, id]);
}
};
// 获取文件图标
const getFileIcon = (fileName: string) => {
if (fileName.endsWith('.pdf')) {
return <FileText className="w-5 h-5" />;
}
return <FileType2 className="w-5 h-5" />;
};
// 获取文件图标容器样式
const getFileIconStyle = (fileName: string) => {
if (fileName.endsWith('.pdf')) {
return 'bg-red-50 text-red-600 border-red-100';
}
return 'bg-blue-50 text-blue-600 border-blue-100';
};
// 渲染智能体标签
const renderAgentTags = (task: ReviewTask) => {
if (!task.selectedAgents || task.selectedAgents.length === 0) {
return <span className="tag tag-gray"></span>;
}
return (
<div className="flex gap-1.5">
{task.selectedAgents.includes('editorial') && (
<span className="tag tag-blue"></span>
)}
{task.selectedAgents.includes('methodology') && (
<span className="tag tag-purple"></span>
)}
</div>
);
};
// 渲染结果摘要
const renderResultSummary = (task: ReviewTask) => {
if (task.status === 'pending') {
return <span className="text-xs text-slate-400 italic">...</span>;
}
if (task.status === 'extracting' || task.status === 'reviewing') {
return (
<div className="flex items-center gap-2 text-xs text-indigo-600">
<Loader2 className="w-3 h-3 animate-spin" />
<span>{task.status === 'extracting' ? '提取文本中...' : '审查中...'}</span>
</div>
);
}
if (task.status === 'failed') {
return <span className="text-xs text-red-500"></span>;
}
if (task.status === 'completed') {
return (
<div className="flex flex-col gap-1.5">
{task.editorialScore !== undefined && (
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full ${task.editorialScore >= 80 ? 'bg-green-500' : task.editorialScore >= 60 ? 'bg-amber-500' : 'bg-red-500'}`} />
<span className="text-slate-600">:</span>
<span className={`font-bold ${task.editorialScore >= 80 ? 'text-green-700' : task.editorialScore >= 60 ? 'text-amber-700' : 'text-red-700'}`}>
{task.editorialScore}
</span>
</div>
)}
{task.methodologyStatus && (
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full ${
task.methodologyStatus === '通过' ? 'bg-green-500' :
task.methodologyStatus === '存疑' ? 'bg-amber-500' : 'bg-red-500'
}`} />
<span className="text-slate-600">:</span>
<span className={`font-bold ${
task.methodologyStatus === '通过' ? 'text-green-700' :
task.methodologyStatus === '存疑' ? 'text-amber-700' : 'text-red-700'
}`}>
{task.methodologyStatus}
</span>
</div>
)}
</div>
);
}
return null;
};
// 渲染操作按钮
const renderActions = (task: ReviewTask) => {
// 待审稿:[开始审稿] [删除]
if (task.status === 'pending') {
return (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onRunTask(task)}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1.5 rounded-md transition-colors text-xs font-medium flex items-center gap-1"
>
<Play className="w-3 h-3" />
稿
</button>
<button
onClick={() => onDeleteTask(task)}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1.5 rounded-md transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
}
// 处理中:[查看进度]
if (['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(task.status)) {
return (
<button
onClick={() => onViewReport(task)}
className="text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs font-medium flex items-center gap-1"
>
<Loader2 className="w-3 h-3 animate-spin" />
</button>
);
}
// 已完成:[查看报告] [重新审稿] [删除]
if (task.status === 'completed') {
return (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onViewReport(task)}
className="text-indigo-600 font-bold hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs flex items-center gap-1"
>
<Eye className="w-3 h-3" />
</button>
<button
onClick={() => onRunTask(task)}
className="text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 p-1.5 rounded-md transition-colors"
title="重新审稿"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => onDeleteTask(task)}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1.5 rounded-md transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
}
// 失败:[重新审稿] [删除]
if (task.status === 'failed') {
return (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onRunTask(task)}
className="border border-indigo-200 text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs font-medium flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
稿
</button>
<button
onClick={() => onDeleteTask(task)}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1.5 rounded-md transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
}
return null;
};
if (tasks.length === 0) {
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm p-12 text-center">
<FileText className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500">稿稿</p>
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500 font-semibold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4 w-12">
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
/>
</th>
<th className="px-6 py-4 w-1/3"> / </th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4">稿</th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tasks.map(task => (
<tr
key={task.id}
className={`hover:bg-slate-50 group transition-colors ${selectedIds.includes(task.id) ? 'bg-blue-50' : ''}`}
>
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedIds.includes(task.id)}
onChange={() => toggleSelect(task.id)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
/>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg border ${getFileIconStyle(task.fileName)}`}>
{getFileIcon(task.fileName)}
</div>
<div>
<div
className={`font-bold text-slate-800 text-base mb-0.5 ${task.status === 'completed' ? 'cursor-pointer hover:text-indigo-600' : ''}`}
onClick={() => task.status === 'completed' && onViewReport(task)}
>
{task.fileName}
</div>
<div className="text-xs text-slate-400 flex items-center gap-2">
<span className="bg-slate-100 px-1.5 rounded">{formatFileSize(task.fileSize)}</span>
{task.wordCount && (
<>
<span></span>
<span>{task.wordCount.toLocaleString()} </span>
</>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-slate-500 font-mono text-xs">
{formatTime(task.createdAt)}
</td>
<td className="px-6 py-4">
{renderAgentTags(task)}
</td>
<td className="px-6 py-4">
{renderResultSummary(task)}
</td>
<td className="px-6 py-4 text-right">
{renderActions(task)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}