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
This commit is contained in:
2025-11-16 15:43:39 +08:00
parent 11325f88a7
commit 0fe6821a89
52 changed files with 7324 additions and 109 deletions

View File

@@ -0,0 +1,624 @@
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;