Files
AIclinicalresearch/frontend/src/pages/ReviewPage.tsx
HaHafeng 0fe6821a89 feat(frontend): add batch processing and review features
- Add batch processing API and mode
- Add deep read mode for full-text analysis
- Add document selector and switcher components
- Add review page with editorial and methodology assessment
- Add capacity indicator and usage info modal
- Add custom hooks for batch tasks and chat modes
- Update layouts and routing
- Add TypeScript types for chat features
2025-11-16 15:43:39 +08:00

625 lines
21 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 { useState } from 'react';
import {
Card,
Upload,
Button,
Select,
Steps,
Progress,
message,
Typography,
Space,
Alert,
Spin,
Empty,
Tag,
Divider,
Tabs,
Dropdown,
} from 'antd';
import type { MenuProps } from 'antd';
import './ReviewPage.css';
import {
UploadOutlined,
FileTextOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
RocketOutlined,
DownloadOutlined,
PrinterOutlined,
CopyOutlined,
} from '@ant-design/icons';
import type { UploadProps } from 'antd';
import {
uploadManuscript,
pollTaskUntilComplete,
getTaskReport,
getStatusText,
getStatusColor,
formatFileSize,
formatDuration,
type ReviewTask,
type ReviewReport,
type ReviewTaskStatus,
} from '../api/reviewApi';
import ScoreCard from '../components/review/ScoreCard';
import EditorialReview from '../components/review/EditorialReview';
import MethodologyReview from '../components/review/MethodologyReview';
const { Title, Paragraph, Text } = Typography;
const { Dragger } = Upload;
/**
* 稿件审查页面
*/
const ReviewPage = () => {
// ==================== State ====================
const [modelType, setModelType] = useState<'deepseek-v3' | 'qwen3-72b' | 'qwen-long'>('deepseek-v3');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [currentTask, setCurrentTask] = useState<ReviewTask | null>(null);
const [report, setReport] = useState<ReviewReport | null>(null);
const [error, setError] = useState<string | null>(null);
// ==================== 上传配置 ====================
const uploadProps: UploadProps = {
name: 'file',
multiple: false,
maxCount: 1,
accept: '.doc,.docx',
beforeUpload: (file) => {
// 验证文件类型
const isDoc = file.name.endsWith('.doc') || file.name.endsWith('.docx');
if (!isDoc) {
message.error('只支持Word文档.doc或.docx');
return Upload.LIST_IGNORE;
}
// 验证文件大小5MB
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
message.error('文件大小不能超过5MB');
return Upload.LIST_IGNORE;
}
setSelectedFile(file);
return false; // 阻止自动上传
},
onRemove: () => {
setSelectedFile(null);
},
};
// ==================== 开始审查 ====================
const handleStartReview = async () => {
if (!selectedFile) {
message.warning('请先选择稿件文件!');
return;
}
setUploading(true);
setError(null);
setReport(null);
try {
// 1. 上传稿件
message.loading({ content: '正在上传稿件...', key: 'upload' });
const result = await uploadManuscript({
file: selectedFile,
modelType,
});
message.success({ content: '上传成功!开始审查...', key: 'upload', duration: 2 });
// 2. 轮询任务状态
const task = await pollTaskUntilComplete(
result.taskId,
(updatedTask) => {
setCurrentTask(updatedTask);
console.log('任务状态:', updatedTask.status);
},
60 // 最多5分钟
);
// 3. 任务完成,获取报告
if (task.status === 'completed') {
const fullReport = await getTaskReport(task.id);
setReport(fullReport);
message.success('审查完成!');
} else if (task.status === 'failed') {
setError(task.errorMessage || '审查失败');
message.error('审查失败:' + (task.errorMessage || '未知错误'));
}
} catch (err: any) {
console.error('审查失败:', err);
setError(err.message || '审查过程出错');
message.error('审查失败:' + (err.message || '未知错误'));
} finally {
setUploading(false);
}
};
// ==================== 重新审查 ====================
const handleReset = () => {
setSelectedFile(null);
setCurrentTask(null);
setReport(null);
setError(null);
};
// ==================== 导出报告 ====================
const handleExportPDF = async () => {
if (!report) return;
try {
message.loading({ content: '正在生成PDF...', key: 'pdf', duration: 0 });
// 动态导入依赖
const html2canvas = (await import('html2canvas')).default;
const jsPDF = (await import('jspdf')).default;
// 获取报告内容的DOM元素
const reportElement = document.getElementById('report-content');
if (!reportElement) {
message.error('无法找到报告内容');
return;
}
// 1. 确保所有Tabs内容都显示
const tabPanes = reportElement.querySelectorAll('.ant-tabs-tabpane');
const originalTabDisplay: string[] = [];
tabPanes.forEach((pane, index) => {
const htmlPane = pane as HTMLElement;
originalTabDisplay[index] = htmlPane.style.display;
htmlPane.style.display = 'block'; // 强制显示所有Tab内容
});
// 2. 隐藏不需要导出的元素
const elementsToHide = reportElement.querySelectorAll('.no-print, .ant-btn, .ant-dropdown, .ant-tabs-nav');
elementsToHide.forEach((el) => {
(el as HTMLElement).style.display = 'none';
});
// 3. 等待DOM更新和渲染
await new Promise(resolve => setTimeout(resolve, 300));
// 4. 将HTML转换为Canvas
const canvas = await html2canvas(reportElement, {
scale: 2, // 提高清晰度
useCORS: true,
logging: false,
backgroundColor: '#ffffff',
windowWidth: reportElement.scrollWidth,
windowHeight: reportElement.scrollHeight,
});
// 5. 恢复原始状态
elementsToHide.forEach((el) => {
(el as HTMLElement).style.display = '';
});
tabPanes.forEach((pane, index) => {
(pane as HTMLElement).style.display = originalTabDisplay[index] || '';
});
// 6. 创建PDF
const imgWidth = 210; // A4纸宽度mm
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const pageHeight = 297; // A4纸高度mm
const pdf = new jsPDF('p', 'mm', 'a4');
const imgData = canvas.toDataURL('image/png');
let heightLeft = imgHeight;
let position = 0;
// 添加第一页
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
// 如果内容超过一页,添加更多页
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
// 7. 下载PDF
const fileName = `稿件审查报告-${report.fileName.replace('.docx', '').replace('.doc', '')}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '-')}.pdf`;
pdf.save(fileName);
message.success({ content: 'PDF已生成并下载', key: 'pdf', duration: 2 });
} catch (error) {
console.error('PDF生成失败:', error);
message.error({ content: 'PDF生成失败请重试', key: 'pdf', duration: 3 });
}
};
const handleCopyReport = () => {
if (!report) return;
// 构建文本格式的报告
let reportText = `稿件审查报告\n`;
reportText += `${'='.repeat(60)}\n\n`;
reportText += `文件名: ${report.fileName}\n`;
reportText += `字数: ${report.wordCount} 字符\n`;
reportText += `使用模型: ${report.modelUsed}\n`;
reportText += `评估时间: ${report.completedAt ? new Date(report.completedAt).toLocaleString('zh-CN') : 'N/A'}\n`;
reportText += `耗时: ${report.durationSeconds ? formatDuration(report.durationSeconds) : 'N/A'}\n\n`;
reportText += `总体评分: ${report.overallScore?.toFixed(1) || 'N/A'} / 100\n`;
reportText += `${'='.repeat(60)}\n\n`;
// 稿约规范性评估
if (report.editorialReview) {
reportText += `一、稿约规范性评估\n`;
reportText += `-`.repeat(60) + '\n\n';
reportText += `总分: ${report.editorialReview.overall_score} / 100\n\n`;
reportText += `总结: ${report.editorialReview.summary}\n\n`;
report.editorialReview.items.forEach((item, index) => {
reportText += `${index + 1}. ${item.criterion}\n`;
reportText += ` 状态: ${item.status === 'pass' ? '✓ 通过' : item.status === 'warning' ? '⚠ 警告' : '✗ 不通过'}\n`;
reportText += ` 评分: ${item.score} / 100\n`;
if (item.issues && item.issues.length > 0) {
reportText += ` 问题:\n`;
item.issues.forEach(issue => {
reportText += ` - ${issue}\n`;
});
}
if (item.suggestions && item.suggestions.length > 0) {
reportText += ` 建议:\n`;
item.suggestions.forEach(suggestion => {
reportText += ` - ${suggestion}\n`;
});
}
reportText += '\n';
});
}
// 方法学评估
if (report.methodologyReview) {
reportText += `\n二、方法学评估\n`;
reportText += `-`.repeat(60) + '\n\n';
reportText += `总分: ${report.methodologyReview.overall_score} / 100\n\n`;
reportText += `总结: ${report.methodologyReview.summary}\n\n`;
report.methodologyReview.parts.forEach((part, partIndex) => {
reportText += `${partIndex + 1}. ${part.part}\n`;
reportText += ` 评分: ${part.score} / 100\n`;
if (part.issues && part.issues.length > 0) {
reportText += ` 发现的问题:\n`;
part.issues.forEach((issue, issueIndex) => {
reportText += ` ${issueIndex + 1}) ${issue.type} [${issue.severity === 'major' ? '严重' : '轻微'}]\n`;
reportText += ` 描述: ${issue.description}\n`;
reportText += ` 位置: ${issue.location}\n`;
reportText += ` 建议: ${issue.suggestion}\n`;
});
} else {
reportText += ` ✓ 未发现问题\n`;
}
reportText += '\n';
});
}
reportText += `${'='.repeat(60)}\n`;
reportText += `报告生成时间: ${new Date().toLocaleString('zh-CN')}\n`;
// 复制到剪贴板
navigator.clipboard.writeText(reportText).then(() => {
message.success('报告已复制到剪贴板可粘贴到Word或其他文档中');
}).catch(() => {
message.error('复制失败,请手动选择文本复制');
});
};
// ==================== 获取当前步骤 ====================
const getCurrentStep = (status: ReviewTaskStatus): number => {
const stepMap: Record<ReviewTaskStatus, number> = {
pending: 0,
extracting: 1,
reviewing_editorial: 2,
reviewing_methodology: 3,
completed: 4,
failed: 4,
};
return stepMap[status] || 0;
};
// ==================== 渲染 ====================
return (
<div style={{
height: '100%',
overflow: 'auto',
padding: '24px',
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* 标题 */}
<Card style={{ marginBottom: 24, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<div style={{ color: 'white', padding: '20px 0' }}>
<Title level={2} style={{ color: 'white', marginBottom: 16 }}>
<FileTextOutlined /> 稿
</Title>
<Paragraph style={{ color: 'white', fontSize: 16, marginBottom: 0 }}>
稿
</Paragraph>
</div>
</Card>
{/* 主内容 */}
{!currentTask && !report && !error && (
<>
{/* 上传区域 */}
<Card title="1. 上传稿件" style={{ marginBottom: 24 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Dragger {...uploadProps} style={{ padding: '20px' }}>
<p className="ant-upload-drag-icon">
<FileTextOutlined style={{ fontSize: 48, color: '#1890ff' }} />
</p>
<p className="ant-upload-text">Word文档到此区域</p>
<p className="ant-upload-hint">
.doc .docx 5MB
</p>
</Dragger>
{selectedFile && (
<Alert
message="已选择文件"
description={
<Space direction="vertical">
<Text>{selectedFile.name}</Text>
<Text>{formatFileSize(selectedFile.size)}</Text>
</Space>
}
type="success"
showIcon
/>
)}
</Space>
</Card>
{/* 模型选择 */}
<Card title="2. 选择评估模型" style={{ marginBottom: 24 }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Select
value={modelType}
onChange={setModelType}
style={{ width: '100%' }}
size="large"
options={[
{
value: 'deepseek-v3',
label: (
<Space>
<RocketOutlined />
<span>DeepSeek-V3- </span>
</Space>
),
},
{
value: 'qwen3-72b',
label: (
<Space>
<span>Qwen3-72B - </span>
</Space>
),
},
{
value: 'qwen-long',
label: (
<Space>
<span>Qwen-Long - 1M tokens</span>
</Space>
),
},
]}
/>
<Alert
message="模型说明"
description={
<div>
<p><strong>DeepSeek-V3</strong>使</p>
<p><strong>Qwen3-72B</strong></p>
<p><strong>Qwen-Long</strong>稿</p>
</div>
}
type="info"
/>
</Space>
</Card>
{/* 开始按钮 */}
<Card>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={handleStartReview}
disabled={!selectedFile || uploading}
loading={uploading}
block
>
{uploading ? '审查中...' : '开始审查'}
</Button>
</Card>
</>
)}
{/* 进度展示 */}
{currentTask && !report && !error && (
<Card>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Spin size="large" indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />} />
<Title level={4} style={{ marginTop: 16 }}>
...
</Title>
<Tag color={getStatusColor(currentTask.status)} style={{ fontSize: 14, padding: '4px 12px' }}>
{getStatusText(currentTask.status)}
</Tag>
</div>
<Steps
current={getCurrentStep(currentTask.status)}
items={[
{ title: '上传完成', icon: <CheckCircleOutlined /> },
{ title: '提取文本', icon: getCurrentStep(currentTask.status) >= 1 ? <CheckCircleOutlined /> : <LoadingOutlined /> },
{ title: '稿约评估', icon: getCurrentStep(currentTask.status) >= 2 ? <CheckCircleOutlined /> : <LoadingOutlined /> },
{ title: '方法学评估', icon: getCurrentStep(currentTask.status) >= 3 ? <CheckCircleOutlined /> : <LoadingOutlined /> },
{ title: '生成报告', icon: getCurrentStep(currentTask.status) >= 4 ? <CheckCircleOutlined /> : <LoadingOutlined /> },
]}
/>
{currentTask.wordCount && (
<Alert
message="文档信息"
description={`已提取 ${currentTask.wordCount} 个字符`}
type="info"
showIcon
/>
)}
<Progress percent={getCurrentStep(currentTask.status) * 20} status="active" />
</Space>
</Card>
)}
{/* 报告展示(完整版) */}
{report && (
<Space direction="vertical" size="large" style={{ width: '100%' }} id="report-content">
{/* 成功提示 */}
<Card>
<div style={{ textAlign: 'center' }}>
<CheckCircleOutlined style={{ fontSize: 72, color: '#52c41a' }} />
<Title level={3} style={{ marginTop: 16 }}>
</Title>
<Text type="secondary">
稿
</Text>
</div>
</Card>
{/* 总体评分 */}
<ScoreCard
title="总体评分"
score={report.overallScore || 0}
description={`稿约规范性40%+ 方法学60%)的综合评分`}
size="large"
/>
{/* 基本信息 */}
<Card title="审查信息">
<Space direction="vertical">
<Text><strong></strong>{report.fileName}</Text>
<Text><strong></strong>{report.wordCount} </Text>
<Text><strong>使</strong>{report.modelUsed}</Text>
<Text><strong></strong>{report.durationSeconds ? formatDuration(report.durationSeconds) : 'N/A'}</Text>
</Space>
</Card>
{/* 详细评估报告Tabs */}
<Tabs
defaultActiveKey="editorial"
size="large"
items={[
{
key: 'editorial',
label: `稿约规范性评估(${report.editorialReview?.overall_score || 'N/A'}分)`,
children: report.editorialReview ? (
<EditorialReview data={report.editorialReview} />
) : (
<Empty description="暂无稿约规范性评估数据" />
),
},
{
key: 'methodology',
label: `方法学评估(${report.methodologyReview?.overall_score || 'N/A'}分)`,
children: report.methodologyReview ? (
<MethodologyReview data={report.methodologyReview} />
) : (
<Empty description="暂无方法学评估数据" />
),
},
]}
/>
{/* 操作按钮 */}
<Card className="no-print">
<Space size="middle" style={{ width: '100%' }}>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={handleReset}
block
>
稿
</Button>
<Dropdown
menu={{
items: [
{
key: 'pdf',
label: '导出为PDF',
icon: <PrinterOutlined />,
onClick: handleExportPDF,
},
{
key: 'copy',
label: '复制报告内容',
icon: <CopyOutlined />,
onClick: handleCopyReport,
},
],
}}
placement="bottomLeft"
>
<Button
size="large"
icon={<DownloadOutlined />}
block
>
</Button>
</Dropdown>
</Space>
</Card>
</Space>
)}
{/* 错误展示 */}
{error && (
<Card>
<div style={{ textAlign: 'center' }}>
<CloseCircleOutlined style={{ fontSize: 72, color: '#f5222d' }} />
<Title level={3} style={{ marginTop: 16, color: '#f5222d' }}>
</Title>
<Alert
message="错误信息"
description={error}
type="error"
showIcon
style={{ marginTop: 24, textAlign: 'left' }}
/>
<Button type="primary" size="large" onClick={handleReset} style={{ marginTop: 24 }}>
</Button>
</div>
</Card>
)}
</div>
</div>
);
};
export default ReviewPage;