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,179 @@
import { useState, useEffect } from 'react'
import { message } from 'antd'
import chatApi, { type GeneralMessage } from '../api/chatApi'
import MessageList from '../components/chat/MessageList'
import MessageInput from '../components/chat/MessageInput'
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore'
const ChatPage = () => {
const { knowledgeBases, fetchKnowledgeBases } = useKnowledgeBaseStore()
const [messages, setMessages] = useState<GeneralMessage[]>([])
const [selectedModel, setSelectedModel] = useState<ModelType>('deepseek-v3')
const [sending, setSending] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [currentConversationId, setCurrentConversationId] = useState<string>()
// 加载知识库列表
useEffect(() => {
fetchKnowledgeBases()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 发送消息(流式)
const handleSendMessage = async (content: string, knowledgeBaseIds: string[]) => {
if (sending) return
console.log('🔵 [ChatPage] 发送消息', { content, knowledgeBaseIds, currentConversationId })
setSending(true)
setStreamingContent('')
// 添加用户消息到列表
const userMessage: GeneralMessage = {
id: `temp-${Date.now()}`,
conversationId: currentConversationId || 'temp',
role: 'user',
content,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, userMessage])
try {
let fullContent = ''
await chatApi.sendMessageStream(
{
content,
modelType: selectedModel,
knowledgeBaseIds,
conversationId: currentConversationId,
},
// onChunk
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
// onComplete
(conversationId) => {
console.log('✅ [ChatPage] 对话完成', { conversationId })
// 如果是新对话保存conversationId
if (!currentConversationId && conversationId) {
setCurrentConversationId(conversationId)
}
// 添加完整的助手消息
const assistantMessage: GeneralMessage = {
id: `temp-assistant-${Date.now()}`,
conversationId: conversationId || currentConversationId || 'temp',
role: 'assistant',
content: fullContent,
model: selectedModel,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, assistantMessage])
setStreamingContent('')
setSending(false)
},
// onError
(error) => {
console.error('❌ [ChatPage] 发送失败:', error)
message.error('发送消息失败:' + error.message)
setStreamingContent('')
setSending(false)
}
)
} catch (err) {
console.error('❌ [ChatPage] 发送消息异常:', err)
message.error('发送消息失败')
setStreamingContent('')
setSending(false)
}
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<div style={{
padding: '12px 24px',
background: '#fff',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
💬
</div>
{/* 模型选择器 */}
<ModelSelector
value={selectedModel}
onChange={setSelectedModel}
disabled={sending}
/>
</div>
{/* 聊天区域 */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
background: '#fff',
}}
>
{/* 消息列表区域 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}}>
{messages.length === 0 && !sending ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa'
}}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>💬</div>
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 8 }}>
AI自由对话
</div>
<div style={{ fontSize: 14 }}>
使@知识库引用文献
</div>
</div>
</div>
) : (
<MessageList
messages={messages}
loading={sending}
streamingContent={streamingContent}
/>
)}
</div>
{/* 消息输入 */}
<MessageInput
onSend={handleSendMessage}
loading={sending}
knowledgeBases={knowledgeBases}
placeholder="输入你的问题...Shift+Enter换行Enter发送"
/>
</div>
</div>
)
}
export default ChatPage

View File

@@ -0,0 +1,387 @@
/**
* Phase 2: 重构后的智能问答页面
* 支持:通用对话、全文阅读、逐篇精读三种模式
*/
import { useState, useEffect } from 'react'
import { message as antdMessage } from 'antd'
import chatApi, { type GeneralMessage } from '../api/chatApi'
import { documentSelectionApi, documentApi } from '../api/knowledgeBaseApi'
import { useChatMode } from '../hooks/useChatMode'
import { useDeepReadState } from '../hooks/useDeepReadState'
import { ModeSelector } from '../components/chat/ModeSelector'
import { FullTextMode } from '../components/chat/FullTextMode'
import { DeepReadMode } from '../components/chat/DeepReadMode'
import { DocumentSelector } from '../components/chat/DocumentSelector'
import MessageList from '../components/chat/MessageList'
import MessageInput from '../components/chat/MessageInput'
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore'
import { ChatMessage } from '../types/chat'
import type { Document } from '../types/index'
const ChatPage = () => {
const { knowledgeBases, fetchKnowledgeBases } = useKnowledgeBaseStore()
// 聊天模式状态
const {
state: modeState,
setBaseMode,
selectKnowledgeBase,
setKnowledgeBaseMode,
loadFullTextMode,
loadDeepReadMode,
} = useChatMode()
// 通用对话状态
const [generalMessages, setGeneralMessages] = useState<GeneralMessage[]>([])
const [selectedModel, setSelectedModel] = useState<ModelType>('deepseek-v3')
const [sending, setSending] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [currentConversationId, setCurrentConversationId] = useState<string>()
// 逐篇精读状态
const [deepReadState, setDeepReadState] = useState<ReturnType<typeof useDeepReadState> | null>(null)
const [showDocSelector, setShowDocSelector] = useState(false)
const [allDocuments, setAllDocuments] = useState<Document[]>([])
// 加载知识库列表
useEffect(() => {
fetchKnowledgeBases()
}, [fetchKnowledgeBases])
// 当选择知识库时,加载文档选择结果(全文阅读模式)
useEffect(() => {
if (modeState.selectedKbId && modeState.kbMode === 'full_text') {
loadFullTextData()
}
}, [modeState.selectedKbId, modeState.kbMode])
// 当切换到逐篇精读模式时,显示文献选择器
useEffect(() => {
if (modeState.selectedKbId && modeState.kbMode === 'deep_read' && !modeState.deepReadState) {
loadDocumentsForSelection()
}
}, [modeState.selectedKbId, modeState.kbMode])
// 加载全文阅读模式数据
const loadFullTextData = async () => {
if (!modeState.selectedKbId) return
try {
const result = await documentSelectionApi.getSelection(modeState.selectedKbId)
loadFullTextMode({
loadedDocs: result.selectedDocuments || [],
totalFiles: result.limits.maxFiles,
selectedFiles: result.selection.selectedCount,
totalTokens: result.limits.maxTokens,
usedTokens: result.selection.selectedTokens,
availableTokens: result.selection.availableTokens,
reason: result.selection.reason,
})
} catch (error) {
console.error('Failed to load document selection:', error)
antdMessage.error('加载文档选择失败')
}
}
// 加载文献列表供选择
const loadDocumentsForSelection = async () => {
if (!modeState.selectedKbId) return
try {
const result = await documentSelectionApi.getSelection(modeState.selectedKbId)
setAllDocuments(result.selectedDocuments || [])
setShowDocSelector(true)
} catch (error) {
console.error('Failed to load documents:', error)
antdMessage.error('加载文献列表失败')
}
}
// 确认文献选择(逐篇精读模式)
const handleConfirmDocSelection = (selectedDocs: Document[]) => {
const deepRead = useDeepReadState(selectedDocs)
setDeepReadState(deepRead as any)
loadDeepReadMode({
selectedDocs,
currentDocId: selectedDocs[0].id,
conversationPerDoc: new Map(selectedDocs.map(doc => [doc.id, []])),
totalTokens: selectedDocs.reduce((sum, doc) => sum + (doc.tokensCount || 0), 0),
})
setShowDocSelector(false)
}
// 发送消息(通用对话)
const handleSendGeneralMessage = async (content: string, knowledgeBaseIds: string[]) => {
if (sending) return
setSending(true)
setStreamingContent('')
const userMessage: GeneralMessage = {
id: `temp-${Date.now()}`,
conversationId: currentConversationId || 'temp',
role: 'user',
content,
createdAt: new Date().toISOString(),
}
setGeneralMessages(prev => [...prev, userMessage])
try {
let fullContent = ''
await chatApi.sendMessageStream(
{
content,
modelType: selectedModel,
knowledgeBaseIds,
conversationId: currentConversationId,
},
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
(conversationId) => {
if (!currentConversationId && conversationId) {
setCurrentConversationId(conversationId)
}
const assistantMessage: GeneralMessage = {
id: `temp-assistant-${Date.now()}`,
conversationId: conversationId || currentConversationId || 'temp',
role: 'assistant',
content: fullContent,
model: selectedModel,
createdAt: new Date().toISOString(),
}
setGeneralMessages(prev => [...prev, assistantMessage])
setStreamingContent('')
setSending(false)
},
(error) => {
console.error('Send failed:', error)
antdMessage.error('发送消息失败:' + error.message)
setStreamingContent('')
setSending(false)
}
)
} catch (err) {
console.error('Send message exception:', err)
antdMessage.error('发送消息失败')
setStreamingContent('')
setSending(false)
}
}
// 发送消息(逐篇精读模式)
const handleSendDeepReadMessage = async (content: string) => {
if (!deepReadState || !deepReadState.currentDoc) return
setSending(true)
// 添加用户消息
const userMsg: ChatMessage = {
id: `temp-${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
}
deepReadState.addMessage(userMsg)
try {
// TODO: 调用实际的API支持documentIds参数
// 当前先使用通用API
let fullContent = ''
await chatApi.sendMessageStream(
{
content: `[当前文献: ${deepReadState.currentDoc.filename}]\n\n${content}`,
modelType: selectedModel,
knowledgeBaseIds: modeState.selectedKbId ? [modeState.selectedKbId] : [],
},
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
() => {
const assistantMsg: ChatMessage = {
id: `temp-assistant-${Date.now()}`,
role: 'assistant',
content: fullContent,
timestamp: new Date(),
}
deepReadState.addMessage(assistantMsg)
setStreamingContent('')
setSending(false)
},
(error) => {
console.error('Send failed:', error)
antdMessage.error('发送消息失败')
setStreamingContent('')
setSending(false)
}
)
} catch (error) {
console.error('Send error:', error)
antdMessage.error('发送消息失败')
setSending(false)
}
}
// 渲染内容区域
const renderContent = () => {
const kb = knowledgeBases.find(k => k.id === modeState.selectedKbId)
const kbName = kb?.name || '未选择'
// 通用对话模式
if (modeState.baseMode === 'general') {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{generalMessages.length === 0 && !sending ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa'
}}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>💬</div>
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 8 }}>
AI自由对话
</div>
<div style={{ fontSize: 14 }}>
使@知识库引用文献
</div>
</div>
</div>
) : (
<MessageList
messages={generalMessages}
loading={sending}
streamingContent={streamingContent}
/>
)}
</div>
<MessageInput
onSend={handleSendGeneralMessage}
loading={sending}
knowledgeBases={knowledgeBases}
placeholder="输入你的问题...Shift+Enter换行Enter发送"
/>
</div>
)
}
// 全文阅读模式
if (modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'full_text') {
if (!modeState.fullTextState) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>...</div>
</div>
)
}
return (
<FullTextMode
state={modeState.fullTextState}
kbName={kbName}
kbId={modeState.selectedKbId!}
onSendMessage={(content) => handleSendGeneralMessage(content, [modeState.selectedKbId!])}
messages={generalMessages}
loading={sending}
streamingContent={streamingContent}
/>
)
}
// 逐篇精读模式
if (modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'deep_read') {
if (!modeState.deepReadState || !deepReadState) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>...</div>
</div>
)
}
return (
<DeepReadMode
state={{
selectedDocs: deepReadState.selectedDocs,
currentDoc: deepReadState.currentDoc,
currentConversation: deepReadState.currentConversation,
} as any}
kbName={kbName}
onSwitch={deepReadState.switchDocument}
onSendMessage={handleSendDeepReadMessage}
loading={sending}
streamingContent={streamingContent}
/>
)
}
return null
}
return (
<div style={{ height: '100%', display: 'flex' }}>
{/* 左侧模式选择器 */}
<ModeSelector
state={modeState}
onModeChange={setBaseMode}
onKbSelect={selectKnowledgeBase}
onKbModeChange={setKnowledgeBaseMode}
/>
{/* 主内容区 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<div style={{
padding: '12px 24px',
background: '#fff',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexShrink: 0,
}}>
{/* 模型选择器 */}
<ModelSelector
value={selectedModel}
onChange={setSelectedModel}
disabled={sending}
/>
</div>
{/* 内容区域 */}
{renderContent()}
</div>
{/* 文献选择器弹窗 */}
{showDocSelector && (
<DocumentSelector
documents={allDocuments}
maxSelection={5}
onConfirm={handleConfirmDocSelection}
onCancel={() => setShowDocSelector(false)}
/>
)}
</div>
)
}
export default ChatPage

View File

@@ -1,36 +1,146 @@
/**
* Phase 2: 重构后的智能问答页面
* 支持:通用对话、全文阅读、逐篇精读三种模式
*/
import { useState, useEffect } from 'react'
import { message } from 'antd'
import { message as antdMessage, Button, Tooltip } from 'antd'
import { InfoCircleOutlined } from '@ant-design/icons'
import chatApi, { type GeneralMessage } from '../api/chatApi'
import { documentSelectionApi } from '../api/knowledgeBaseApi'
import { useChatMode } from '../hooks/useChatMode'
import { useDeepReadState } from '../hooks/useDeepReadState'
import { ModeSelector } from '../components/chat/ModeSelector'
import { FullTextMode } from '../components/chat/FullTextMode'
import { DeepReadMode } from '../components/chat/DeepReadMode'
import { BatchMode } from '../components/chat/BatchMode'
import { DocumentSelector } from '../components/chat/DocumentSelector'
import { UsageInfoModal } from '../components/chat/UsageInfoModal'
import MessageList from '../components/chat/MessageList'
import MessageInput from '../components/chat/MessageInput'
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore'
import { ChatMessage } from '../types/chat'
import type { Document } from '../types/index'
const ChatPage = () => {
const { knowledgeBases, fetchKnowledgeBases } = useKnowledgeBaseStore()
const [messages, setMessages] = useState<GeneralMessage[]>([])
// 聊天模式状态
const {
state: modeState,
setBaseMode,
selectKnowledgeBase,
setKnowledgeBaseMode,
loadFullTextMode,
loadDeepReadMode,
} = useChatMode()
// 通用对话状态
const [generalMessages, setGeneralMessages] = useState<GeneralMessage[]>([])
const [selectedModel, setSelectedModel] = useState<ModelType>('deepseek-v3')
const [sending, setSending] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [currentConversationId, setCurrentConversationId] = useState<string>()
// 监听模式变化,自动切换默认模型
useEffect(() => {
// 全文阅读模式默认使用Qwen-Long需要1M上下文
if (modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'full_text') {
if (selectedModel !== 'qwen-long') {
setSelectedModel('qwen-long')
antdMessage.info('已自动切换到Qwen-Long模型支持1M上下文', 3)
}
}
}, [modeState.baseMode, modeState.kbMode, selectedModel])
// 逐篇精读状态 - 在组件顶层调用Hook
const deepReadHook = useDeepReadState([])
const [showDocSelector, setShowDocSelector] = useState(false)
const [allDocuments, setAllDocuments] = useState<Document[]>([])
// 用量说明模态框状态
const [showUsageModal, setShowUsageModal] = useState(false)
// 加载知识库列表
useEffect(() => {
fetchKnowledgeBases()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [fetchKnowledgeBases])
// 发送消息(流式)
const handleSendMessage = async (content: string, knowledgeBaseIds: string[]) => {
// 当选择知识库时,加载文档选择结果(全文阅读模式)
useEffect(() => {
if (modeState.selectedKbId && modeState.kbMode === 'full_text') {
loadFullTextData()
}
}, [modeState.selectedKbId, modeState.kbMode])
// 当切换到逐篇精读模式时,显示文献选择器
useEffect(() => {
if (modeState.selectedKbId && modeState.kbMode === 'deep_read' && !modeState.deepReadState) {
loadDocumentsForSelection()
}
}, [modeState.selectedKbId, modeState.kbMode])
// 加载全文阅读模式数据
const loadFullTextData = async () => {
if (!modeState.selectedKbId) return
try {
const result = await documentSelectionApi.getSelection(modeState.selectedKbId)
loadFullTextMode({
loadedDocs: result.selectedDocuments || [],
totalFiles: result.limits.maxFiles,
selectedFiles: result.selection.selectedCount,
totalTokens: result.limits.maxTokens,
usedTokens: result.selection.selectedTokens,
availableTokens: result.selection.availableTokens,
reason: result.selection.reason,
})
} catch (error) {
console.error('Failed to load document selection:', error)
antdMessage.error('加载文档选择失败')
}
}
// 加载文献列表供选择
const loadDocumentsForSelection = async () => {
if (!modeState.selectedKbId) return
try {
const result = await documentSelectionApi.getSelection(modeState.selectedKbId)
setAllDocuments(result.selectedDocuments || [])
setShowDocSelector(true)
} catch (error) {
console.error('Failed to load documents:', error)
antdMessage.error('加载文献列表失败')
}
}
// 确认文献选择(逐篇精读模式)
const handleConfirmDocSelection = (selectedDocs: Document[]) => {
// 使用hook的方法更新选中的文献
deepReadHook.updateSelectedDocs(selectedDocs)
loadDeepReadMode({
selectedDocs,
currentDocId: selectedDocs[0].id,
currentDoc: selectedDocs[0],
currentConversation: [],
conversationPerDoc: new Map(selectedDocs.map(doc => [doc.id, []])),
totalTokens: selectedDocs.reduce((sum, doc) => sum + (doc.tokensCount || 0), 0),
})
setShowDocSelector(false)
}
// 发送消息(通用对话)
const handleSendGeneralMessage = async (content: string, knowledgeBaseIds: string[]) => {
if (sending) return
console.log('🔵 [ChatPage] 发送消息', { content, knowledgeBaseIds, currentConversationId })
setSending(true)
setStreamingContent('')
// 添加用户消息到列表
const userMessage: GeneralMessage = {
id: `temp-${Date.now()}`,
conversationId: currentConversationId || 'temp',
@@ -38,33 +148,39 @@ const ChatPage = () => {
content,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, userMessage])
setGeneralMessages(prev => [...prev, userMessage])
try {
let fullContent = ''
// Phase 2: 判断是否是全文阅读模式
const isFullTextMode = modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'full_text'
const fullTextDocIds = isFullTextMode && modeState.fullTextState?.loadedDocs
? modeState.fullTextState.loadedDocs.map(doc => doc.id)
: undefined
console.log('📤 [ChatPage] 发送消息', {
mode: isFullTextMode ? '全文阅读' : '通用/RAG',
fullTextDocCount: fullTextDocIds?.length || 0,
})
await chatApi.sendMessageStream(
{
content,
modelType: selectedModel,
knowledgeBaseIds,
fullTextDocumentIds: fullTextDocIds, // 全文阅读模式传递文档ID列表
conversationId: currentConversationId,
},
// onChunk
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
// onComplete
(conversationId) => {
console.log('✅ [ChatPage] 对话完成', { conversationId })
// 如果是新对话保存conversationId
if (!currentConversationId && conversationId) {
setCurrentConversationId(conversationId)
}
// 添加完整的助手消息
const assistantMessage: GeneralMessage = {
id: `temp-assistant-${Date.now()}`,
conversationId: conversationId || currentConversationId || 'temp',
@@ -73,107 +189,266 @@ const ChatPage = () => {
model: selectedModel,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, assistantMessage])
setGeneralMessages(prev => [...prev, assistantMessage])
setStreamingContent('')
setSending(false)
},
// onError
(error) => {
console.error('❌ [ChatPage] 发送失败:', error)
message.error('发送消息失败:' + error.message)
console.error('Send failed:', error)
antdMessage.error('发送消息失败:' + error.message)
setStreamingContent('')
setSending(false)
}
)
} catch (err) {
console.error('❌ [ChatPage] 发送消息异常:', err)
message.error('发送消息失败')
console.error('Send message exception:', err)
antdMessage.error('发送消息失败')
setStreamingContent('')
setSending(false)
}
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<div style={{
padding: '12px 24px',
background: '#fff',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
💬
</div>
// 发送消息(逐篇精读模式)
const handleSendDeepReadMessage = async (content: string) => {
if (!deepReadHook || !deepReadHook.currentDoc) return
{/* 模型选择器 */}
<ModelSelector
value={selectedModel}
onChange={setSelectedModel}
disabled={sending}
/>
</div>
setSending(true)
// 添加用户消息
const userMsg: ChatMessage = {
id: `temp-${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
}
deepReadHook.addMessage(userMsg)
{/* 聊天区域 */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
background: '#fff',
}}
>
{/* 消息列表区域 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}}>
{messages.length === 0 && !sending ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa'
}}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>💬</div>
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 8 }}>
AI自由对话
</div>
<div style={{ fontSize: 14 }}>
使@知识库引用文献
try {
// Phase 2: 逐篇精读模式 - 限定文档范围
let fullContent = ''
await chatApi.sendMessageStream(
{
content: `[当前文献: ${deepReadHook.currentDoc.filename}]\n\n${content}`,
modelType: selectedModel,
knowledgeBaseIds: modeState.selectedKbId ? [modeState.selectedKbId] : [],
documentIds: [deepReadHook.currentDoc.id], // ✅ 只检索当前文档
},
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
() => {
const assistantMsg: ChatMessage = {
id: `temp-assistant-${Date.now()}`,
role: 'assistant',
content: fullContent,
timestamp: new Date(),
}
deepReadHook.addMessage(assistantMsg)
setStreamingContent('')
setSending(false)
},
(error) => {
console.error('Send failed:', error)
antdMessage.error('发送消息失败')
setStreamingContent('')
setSending(false)
}
)
} catch (error) {
console.error('Send error:', error)
antdMessage.error('发送消息失败')
setSending(false)
}
}
// 渲染内容区域
const renderContent = () => {
const kb = knowledgeBases.find(k => k.id === modeState.selectedKbId)
const kbName = kb?.name || '未选择'
// 通用对话模式
if (modeState.baseMode === 'general') {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{generalMessages.length === 0 && !sending ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa'
}}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>💬</div>
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 8 }}>
AI自由对话
</div>
<div style={{ fontSize: 14 }}>
使@知识库引用文献
</div>
</div>
</div>
</div>
) : (
<MessageList
messages={messages}
loading={sending}
streamingContent={streamingContent}
/>
) : (
<MessageList
messages={generalMessages}
loading={sending}
streamingContent={streamingContent}
/>
)}
</div>
<MessageInput
onSend={handleSendGeneralMessage}
loading={sending}
knowledgeBases={knowledgeBases}
placeholder="输入你的问题...Shift+Enter换行Enter发送"
/>
</div>
)
}
// 全文阅读模式
if (modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'full_text') {
if (!modeState.fullTextState) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>...</div>
</div>
)
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<FullTextMode
state={modeState.fullTextState}
onSendMessage={(content) => handleSendGeneralMessage(content, [modeState.selectedKbId!])}
messages={generalMessages}
loading={sending}
streamingContent={streamingContent}
/>
</div>
)
}
// 逐篇精读模式
if (modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'deep_read') {
if (!modeState.deepReadState || !deepReadHook.selectedDocs.length) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div>...</div>
</div>
)
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<DeepReadMode
state={{
selectedDocs: deepReadHook.selectedDocs,
currentDocId: deepReadHook.currentDocId,
currentDoc: deepReadHook.currentDoc,
currentConversation: deepReadHook.currentConversation,
conversationPerDoc: new Map(),
totalTokens: 0,
}}
kbName={kbName}
onSwitch={deepReadHook.switchDocument}
onSendMessage={handleSendDeepReadMessage}
loading={sending}
streamingContent={streamingContent}
/>
</div>
)
}
// Phase 3: 批处理模式
if (modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'batch') {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '20px' }}>
<BatchMode
kbId={modeState.selectedKbId || ''}
modelType={selectedModel}
/>
</div>
)
}
return null
}
return (
<div style={{ height: '100%', display: 'flex' }}>
{/* 左侧模式选择器 */}
<ModeSelector
state={modeState}
onModeChange={setBaseMode}
onKbSelect={selectKnowledgeBase}
onKbModeChange={setKnowledgeBaseMode}
/>
{/* 主内容区 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<div style={{
padding: '12px 24px',
background: '#fff',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '12px',
flexShrink: 0,
}}>
{/* 用量说明按钮(仅在全文阅读模式显示) */}
{modeState.baseMode === 'knowledge_base' && modeState.kbMode === 'full_text' && modeState.fullTextState && (
<Tooltip title="查看容量使用情况和已加载文献">
<Button
icon={<InfoCircleOutlined />}
onClick={() => setShowUsageModal(true)}
>
</Button>
</Tooltip>
)}
{/* 模型选择器 */}
<ModelSelector
value={selectedModel}
onChange={setSelectedModel}
disabled={sending}
/>
</div>
{/* 消息输入 */}
<MessageInput
onSend={handleSendMessage}
loading={sending}
knowledgeBases={knowledgeBases}
placeholder="输入你的问题...Shift+Enter换行Enter发送"
/>
{/* 内容区域 */}
{renderContent()}
</div>
{/* 文献选择器弹窗 */}
{showDocSelector && (
<DocumentSelector
documents={allDocuments}
maxSelection={5}
onConfirm={handleConfirmDocSelection}
onCancel={() => setShowDocSelector(false)}
/>
)}
{/* 用量说明模态框 */}
{showUsageModal && modeState.fullTextState && (
<UsageInfoModal
visible={showUsageModal}
onClose={() => setShowUsageModal(false)}
state={modeState.fullTextState}
kbName={knowledgeBases.find(k => k.id === modeState.selectedKbId)?.name || '未知知识库'}
/>
)}
</div>
)
}
export default ChatPage

View File

@@ -0,0 +1,121 @@
/* 打印样式优化 */
@media print {
/* 隐藏不需要打印的元素 */
.ant-layout-sider,
.ant-layout-header,
.ant-btn,
.ant-dropdown,
.ant-steps,
.no-print {
display: none !important;
}
/* 页面布局优化 */
body {
margin: 0;
padding: 20mm;
}
/* 卡片样式调整 */
.ant-card {
border: none !important;
box-shadow: none !important;
page-break-inside: avoid;
}
/* 标题优化 */
.ant-typography {
color: #000 !important;
}
/* 进度条隐藏 */
.ant-progress {
display: none !important;
}
/* 折叠面板展开 */
.ant-collapse-content {
display: block !important;
}
.ant-collapse-item {
page-break-inside: avoid;
}
/* Tabs切换隐藏显示所有内容 */
.ant-tabs-nav {
display: none !important;
}
.ant-tabs-content {
display: block !important;
}
.ant-tabs-tabpane {
display: block !important;
page-break-before: always;
}
.ant-tabs-tabpane:first-child {
page-break-before: auto;
}
/* 分页控制 */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
/* 背景色移除(节省墨水) */
* {
background: white !important;
color: black !important;
}
/* Tag样式调整 */
.ant-tag {
border: 1px solid #d9d9d9 !important;
}
/* Alert样式调整 */
.ant-alert {
border: 1px solid #d9d9d9 !important;
}
}
/* 屏幕显示样式 */
@media screen {
.print-only {
display: none;
}
}

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;