feat(rvw): Complete RVW module development Phase 1-3

Summary:
- Migrate backend to modules/rvw with v2 API routes (/api/v2/rvw)
- Add new database fields: selectedAgents, editorialScore, methodologyStatus, picoExtract, isArchived
- Create frontend module in frontend-v2/src/modules/rvw
- Implement Dashboard with task list, filtering, batch operations
- Implement ReportDetail with dual tabs (editorial/methodology)
- Implement AgentModal for intelligent agent selection
- Register RVW module in moduleRegistry.ts
- Add navigation entry in TopNavigation
- Update documentation for RVW module status (v3.0)
- Update system status document (v2.9)

Features:
- User can select agents: editorial, methodology, or both
- Support batch task execution
- Task status filtering
- Replace console.log with logger service
- Maintain v1 API backward compatibility

Tested: Frontend and backend verified locally
Status: 85% complete (Phase 1-3 done)
This commit is contained in:
2026-01-07 22:39:08 +08:00
parent 06028c6952
commit 179afa2c6b
226 changed files with 5860 additions and 21 deletions

View File

@@ -0,0 +1,252 @@
/**
* 任务表格组件
*/
import { FileText, FileType2, Loader2, Play, Eye } 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;
}
export default function TaskTable({
tasks,
selectedIds,
onSelectChange,
onViewReport,
onRunTask
}: 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 === 'completed') {
return (
<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>
);
}
if (task.status === 'pending') {
return (
<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"
>
<Play className="w-3 h-3" />
</button>
);
}
if (task.status === 'extracting' || task.status === 'reviewing') {
return (
<span className="text-xs text-slate-400 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
</span>
);
}
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>
);
}