- 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
625 lines
21 KiB
TypeScript
625 lines
21 KiB
TypeScript
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;
|
||
|