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
This commit is contained in:
2026-01-10 22:52:15 +08:00
parent 179afa2c6b
commit 440f75255e
237 changed files with 3942 additions and 657 deletions

View File

@@ -11,6 +11,7 @@ import {
BatchToolbar,
AgentModal,
ReportDetail,
TaskDetail,
} from './components';
import * as api from './api';
import type { ReviewTask, ReviewReport, TaskFilters, AgentType } from './types';
@@ -28,6 +29,10 @@ export default function Dashboard() {
// 报告详情
const [reportDetail, setReportDetail] = useState<ReviewReport | null>(null);
// 任务详情(支持进度显示)
const [viewingTask, setViewingTask] = useState<ReviewTask | null>(null);
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
// ==================== 数据加载 ====================
const loadTasks = useCallback(async () => {
@@ -114,12 +119,25 @@ export default function Dashboard() {
};
const handleConfirmRun = async (agents: AgentType[]) => {
// 🔥 保存到局部变量避免onClose后丢失
const taskToRun = pendingTaskForRun;
// 立即关闭弹窗
setAgentModalVisible(false);
setPendingTaskForRun(null);
try {
if (pendingTaskForRun) {
// 单个任务
if (taskToRun) {
// 单个任务 - 启动后跳转到详情页显示进度
message.loading({ content: '正在启动审查...', key: 'run' });
await api.runTask(pendingTaskForRun.id, agents);
const { jobId } = await api.runTask(taskToRun.id, agents);
message.success({ content: '审查已启动', key: 'run', duration: 2 });
// 更新任务状态后跳转到详情页传递jobId
const updatedTask = await api.getTask(taskToRun.id);
setCurrentJobId(jobId);
setViewingTask(updatedTask);
return;
} else {
// 批量任务
const pendingIds = selectedIds.filter(id => {
@@ -145,13 +163,22 @@ export default function Dashboard() {
};
const handleViewReport = async (task: ReviewTask) => {
// 直接使用TaskDetail视图支持进度和报告
setViewingTask(task);
};
const handleDeleteTask = async (task: ReviewTask) => {
if (!window.confirm(`确定要删除 "${task.fileName}" 吗?`)) {
return;
}
try {
message.loading({ content: '加载报告中...', key: 'report' });
const report = await api.getTaskReport(task.id);
setReportDetail(report);
message.destroy('report');
message.loading({ content: '正在删除...', key: 'delete' });
await api.deleteTask(task.id);
message.success({ content: '删除成功', key: 'delete', duration: 2 });
loadTasks();
} catch (error: any) {
message.error({ content: '加载报告失败', key: 'report', duration: 3 });
message.error({ content: error.message || '删除失败', key: 'delete', duration: 3 });
}
};
@@ -159,9 +186,29 @@ export default function Dashboard() {
setReportDetail(null);
};
// 返回列表并刷新
const handleBackFromDetail = () => {
setViewingTask(null);
setCurrentJobId(null);
loadTasks();
};
// ==================== 渲染 ====================
// 报告详情视图
// 任务详情视图(支持进度显示)
if (viewingTask) {
return (
<div className="h-full flex overflow-hidden bg-slate-50">
<Sidebar
currentView={currentView}
onViewChange={setCurrentView}
/>
<TaskDetail task={viewingTask} jobId={currentJobId} onBack={handleBackFromDetail} />
</div>
);
}
// 报告详情视图(旧版,保留兼容)
if (reportDetail) {
return (
<div className="h-full flex overflow-hidden bg-slate-50">
@@ -207,6 +254,7 @@ export default function Dashboard() {
onSelectChange={setSelectedIds}
onViewReport={handleViewReport}
onRunTask={handleRunTask}
onDeleteTask={handleDeleteTask}
/>
)}
</div>
@@ -234,3 +282,4 @@ export default function Dashboard() {
);
}

View File

@@ -44,17 +44,18 @@ export async function getTaskReport(taskId: string): Promise<ReviewReport> {
return response.data.data!;
}
// 运行审查任务
export async function runTask(taskId: string, agents: AgentType[]): Promise<void> {
const response = await axios.post<ApiResponse<void>>(`${API_BASE}/tasks/${taskId}/run`, { agents });
// 运行审查任务返回jobId供轮询
export async function runTask(taskId: string, agents: AgentType[]): Promise<{ taskId: string; jobId: string }> {
const response = await axios.post<ApiResponse<{ taskId: string; jobId: string }>>(`${API_BASE}/tasks/${taskId}/run`, { agents });
if (!response.data.success) {
throw new Error(response.data.error || '运行失败');
}
return response.data.data!;
}
// 批量运行审查任务
export async function batchRunTasks(taskIds: string[], agents: AgentType[]): Promise<void> {
const response = await axios.post<ApiResponse<void>>(`${API_BASE}/tasks/batch-run`, { taskIds, agents });
const response = await axios.post<ApiResponse<void>>(`${API_BASE}/tasks/batch/run`, { taskIds, agents });
if (!response.data.success) {
throw new Error(response.data.error || '批量运行失败');
}
@@ -127,3 +128,4 @@ export function formatTime(dateStr: string): string {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
}

View File

@@ -27,8 +27,8 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A
};
const handleConfirm = () => {
// 只调用onConfirm让调用方控制关闭时机
onConfirm(selectedAgents);
onClose();
};
if (!visible) return null;
@@ -121,3 +121,4 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A
);
}

View File

@@ -41,3 +41,5 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti
);
}

View File

@@ -106,3 +106,5 @@ export default function EditorialReport({ data }: EditorialReportProps) {
);
}

View File

@@ -64,3 +64,5 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC
);
}

View File

@@ -54,3 +54,5 @@ export default function Header({ onUpload }: HeaderProps) {
);
}

View File

@@ -108,3 +108,5 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
);
}

View File

@@ -36,3 +36,5 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }:
);
}

View File

@@ -71,3 +71,5 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }:
);
}

View File

@@ -0,0 +1,295 @@
/**
* 任务详情页组件
* 支持显示审稿进度和结果
*/
import { useState, useEffect } from 'react';
import { ArrowLeft, FileCheck, Clock, AlertCircle, CheckCircle, Loader2, FileText, Bot } from 'lucide-react';
import type { ReviewTask, ReviewReport, TaskStatus } from '../types';
import EditorialReport from './EditorialReport';
import MethodologyReport from './MethodologyReport';
import * as api from '../api';
import { message } from 'antd';
interface TaskDetailProps {
task: ReviewTask;
jobId?: string | null; // pg-boss 任务ID可选用于更精确的状态轮询
onBack: () => void;
}
// 状态信息映射
const STATUS_INFO: Record<TaskStatus, { label: string; color: string; icon: any }> = {
pending: { label: '等待开始', color: 'text-slate-500', icon: Clock },
extracting: { label: '正在提取文档', color: 'text-blue-500', icon: Loader2 },
reviewing: { label: '正在初始化审查', color: 'text-indigo-500', icon: Loader2 },
reviewing_editorial: { label: '正在审查稿约规范性', color: 'text-indigo-500', icon: Bot },
reviewing_methodology: { label: '正在审查方法学', color: 'text-purple-500', icon: Bot },
completed: { label: '审查完成', color: 'text-green-600', icon: CheckCircle },
failed: { label: '审查失败', color: 'text-red-500', icon: AlertCircle },
};
// 进度步骤
const PROGRESS_STEPS = [
{ key: 'upload', label: '上传文档', status: 'completed' as const },
{ key: 'extract', label: '文本提取', status: 'completed' as const },
{ key: 'editorial', label: '稿约规范性', status: 'pending' as const },
{ key: 'methodology', label: '方法学评估', status: 'pending' as const },
];
export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDetailProps) {
const [task, setTask] = useState<ReviewTask>(initialTask);
const [report, setReport] = useState<ReviewReport | null>(null);
const [activeTab, setActiveTab] = useState<'editorial' | 'methodology'>('editorial');
const [elapsedTime, setElapsedTime] = useState(0);
const isProcessing = ['extracting', 'reviewing', 'reviewing_editorial', 'reviewing_methodology'].includes(task.status);
const isCompleted = task.status === 'completed';
const isFailed = task.status === 'failed';
// 轮询任务状态
useEffect(() => {
if (!isProcessing) return;
const interval = setInterval(async () => {
try {
const updated = await api.getTask(task.id);
setTask(updated);
// 如果完成了,加载报告
if (updated.status === 'completed') {
const reportData = await api.getTaskReport(task.id);
setReport(reportData);
}
} catch (error) {
console.error('更新任务状态失败:', error);
}
}, 2000);
return () => clearInterval(interval);
}, [task.id, isProcessing]);
// 计时器
useEffect(() => {
if (!isProcessing) return;
const start = Date.now();
const interval = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - start) / 1000));
}, 1000);
return () => clearInterval(interval);
}, [isProcessing]);
// 完成时加载报告
useEffect(() => {
if (isCompleted && !report) {
api.getTaskReport(task.id).then(setReport).catch(() => {
message.error('加载报告失败');
});
}
}, [isCompleted, task.id, report]);
// 获取进度步骤状态
const getStepStatus = (stepKey: string): 'completed' | 'active' | 'pending' => {
if (task.status === 'pending') {
return stepKey === 'upload' ? 'completed' : 'pending';
}
if (task.status === 'extracting') {
if (stepKey === 'upload') return 'completed';
if (stepKey === 'extract') return 'active';
return 'pending';
}
if (task.status === 'reviewing') {
if (['upload', 'extract'].includes(stepKey)) return 'completed';
if (stepKey === 'editorial') return 'active';
return 'pending';
}
if (task.status === 'reviewing_editorial') {
if (['upload', 'extract'].includes(stepKey)) return 'completed';
if (stepKey === 'editorial') return 'active';
return 'pending';
}
if (task.status === 'reviewing_methodology') {
if (['upload', 'extract', 'editorial'].includes(stepKey)) return 'completed';
if (stepKey === 'methodology') return 'active';
return 'pending';
}
if (task.status === 'completed') return 'completed';
if (task.status === 'failed') {
if (['upload', 'extract'].includes(stepKey)) return 'completed';
return 'pending';
}
return 'pending';
};
// 格式化时间
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}${secs}` : `${secs}`;
};
const statusInfo = STATUS_INFO[task.status];
const StatusIcon = statusInfo.icon;
return (
<div className="flex-1 flex flex-col h-full bg-slate-50 relative fade-in">
{/* 顶部导航栏 */}
<header className="h-16 bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-20 shadow-sm">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 text-slate-500 hover:text-slate-800 transition-colors px-2 py-1 rounded hover:bg-slate-100"
>
<ArrowLeft className="w-5 h-5" />
<span className="text-sm font-medium"></span>
</button>
<div className="h-6 w-px bg-slate-200" />
<div>
<h1 className="text-base font-bold text-slate-800 flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-500" />
{task.fileName}
</h1>
</div>
</div>
<div className="flex items-center gap-3">
{isCompleted && (
<button className="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm font-medium hover:bg-indigo-700 transition shadow-sm flex items-center gap-2">
<FileCheck className="w-4 h-4" />
</button>
)}
</div>
</header>
{/* 内容区域 */}
<div className="flex-1 overflow-auto p-8">
<div className="max-w-4xl mx-auto">
{/* 进度显示(审查中) */}
{isProcessing && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 mb-8">
{/* 状态头部 */}
<div className="flex items-center justify-center gap-3 mb-8">
<StatusIcon className={`w-6 h-6 ${statusInfo.color} ${isProcessing ? 'animate-spin' : ''}`} />
<span className={`text-lg font-semibold ${statusInfo.color}`}>
{statusInfo.label}
</span>
<span className="text-slate-400 text-sm">
{formatTime(elapsedTime)}
</span>
</div>
{/* 进度条 */}
<div className="flex items-center justify-between mb-6 px-8">
{PROGRESS_STEPS.map((step, index) => {
const stepStatus = getStepStatus(step.key);
return (
<div key={step.key} className="flex items-center">
<div className="flex flex-col items-center">
<div className={`
w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold
${stepStatus === 'completed' ? 'bg-green-500 text-white' : ''}
${stepStatus === 'active' ? 'bg-indigo-500 text-white animate-pulse' : ''}
${stepStatus === 'pending' ? 'bg-slate-200 text-slate-400' : ''}
`}>
{stepStatus === 'completed' ? (
<CheckCircle className="w-5 h-5" />
) : stepStatus === 'active' ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
index + 1
)}
</div>
<span className={`text-xs mt-2 ${
stepStatus === 'active' ? 'text-indigo-600 font-medium' : 'text-slate-500'
}`}>
{step.label}
</span>
</div>
{index < PROGRESS_STEPS.length - 1 && (
<div className={`w-20 h-1 mx-2 rounded ${
getStepStatus(PROGRESS_STEPS[index + 1].key) === 'completed' || stepStatus === 'completed'
? 'bg-green-200'
: 'bg-slate-200'
}`} />
)}
</div>
);
})}
</div>
{/* 提示信息 */}
<div className="text-center text-slate-500 text-sm bg-slate-50 rounded-lg py-4">
<p>AI 稿 1-3 </p>
<p className="text-slate-400 mt-1"></p>
</div>
</div>
)}
{/* 失败状态 */}
{isFailed && (
<div className="bg-red-50 rounded-xl border border-red-200 p-8 text-center">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-red-700 mb-2"></h2>
<p className="text-red-600 text-sm">{task.errorMessage || '未知错误,请重试'}</p>
</div>
)}
{/* 完成状态 - 显示报告 */}
{isCompleted && report && (
<>
{/* 分数卡片 */}
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg p-6 mb-8 text-white">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold mb-1"></h2>
<p className="text-indigo-100 text-sm">
{report.durationSeconds ? formatTime(report.durationSeconds) : '-'}
</p>
</div>
<div className="text-5xl font-bold">{report.overallScore || '-'}</div>
</div>
</div>
{/* Tab切换 */}
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-lg mb-6 w-fit mx-auto">
{report.editorialReview && (
<button
onClick={() => setActiveTab('editorial')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
activeTab === 'editorial'
? 'font-bold bg-white text-indigo-600 shadow-sm'
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
稿 ({report.editorialReview.overall_score})
</button>
)}
{report.methodologyReview && (
<button
onClick={() => setActiveTab('methodology')}
className={`px-6 py-2 rounded-md text-sm transition-all ${
activeTab === 'methodology'
? 'font-bold bg-white text-indigo-600 shadow-sm'
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
({report.methodologyReview.overall_score})
</button>
)}
</div>
{/* 报告内容 */}
{activeTab === 'editorial' && report.editorialReview && (
<EditorialReport data={report.editorialReview} />
)}
{activeTab === 'methodology' && report.methodologyReview && (
<MethodologyReport data={report.methodologyReview} />
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
/**
* 任务表格组件
*/
import { FileText, FileType2, Loader2, Play, Eye } from 'lucide-react';
import { FileText, FileType2, Loader2, Play, Eye, RefreshCw, Trash2 } from 'lucide-react';
import type { ReviewTask } from '../types';
import { formatFileSize, formatTime } from '../api';
@@ -11,6 +11,7 @@ interface TaskTableProps {
onSelectChange: (ids: string[]) => void;
onViewReport: (task: ReviewTask) => void;
onRunTask: (task: ReviewTask) => void;
onDeleteTask: (task: ReviewTask) => void;
}
export default function TaskTable({
@@ -18,7 +19,8 @@ export default function TaskTable({
selectedIds,
onSelectChange,
onViewReport,
onRunTask
onRunTask,
onDeleteTask
}: TaskTableProps) {
const allSelected = tasks.length > 0 && selectedIds.length === tasks.length;
@@ -127,36 +129,89 @@ export default function TaskTable({
// 渲染操作按钮
const renderActions = (task: ReviewTask) => {
if (task.status === 'completed') {
// 待审稿:[开始审稿] [删除]
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 font-bold hover:bg-indigo-50 px-3 py-1.5 rounded-md transition-colors text-xs flex items-center gap-1"
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"
>
<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>
</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>
);
}
@@ -250,3 +305,5 @@ export default function TaskTable({
);
}

View File

@@ -11,4 +11,6 @@ export { default as ScoreRing } from './ScoreRing';
export { default as EditorialReport } from './EditorialReport';
export { default as MethodologyReport } from './MethodologyReport';
export { default as ReportDetail } from './ReportDetail';
export { default as TaskDetail } from './TaskDetail';

View File

@@ -5,3 +5,5 @@ export { default as RvwDashboard } from './Dashboard';
export * from './types';
export * from './api';

View File

@@ -231,3 +231,5 @@
}
}

View File

@@ -4,11 +4,13 @@
// 任务状态
export type TaskStatus =
| 'pending' // 待处理
| 'extracting' // 提取文本中
| 'reviewing' // 审查中
| 'completed' // 已完成
| 'failed'; // 失败
| 'pending' // 待处理
| 'extracting' // 提取文本中
| 'reviewing' // 审查中
| 'reviewing_editorial' // 正在审查稿约规范性
| 'reviewing_methodology' // 正在审查方法学
| 'completed' // 已完成
| 'failed'; // 失败
// 智能体类型
export type AgentType = 'editorial' | 'methodology';
@@ -90,3 +92,4 @@ export interface TaskFilters {
timeRange: 'all' | 'today' | 'week';
}