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:
179
frontend/src/pages/ChatPage.backup.tsx
Normal file
179
frontend/src/pages/ChatPage.backup.tsx
Normal 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
|
||||
|
||||
387
frontend/src/pages/ChatPage.new.tsx
Normal file
387
frontend/src/pages/ChatPage.new.tsx
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
121
frontend/src/pages/ReviewPage.css
Normal file
121
frontend/src/pages/ReviewPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
624
frontend/src/pages/ReviewPage.tsx
Normal file
624
frontend/src/pages/ReviewPage.tsx
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user