mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10:14 +00:00
长文本拆分,前端反馈还未成功
This commit is contained in:
121
frontend/src/components/ParsingProgress.jsx
Normal file
121
frontend/src/components/ParsingProgress.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Parsing Progress Component
|
||||
* Displays real-time progress for document parsing
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Loader, CheckCircle, XCircle, FileText, Layers } from 'lucide-react'
|
||||
|
||||
export const ParsingProgress = ({ progress }) => {
|
||||
if (!progress) return null
|
||||
|
||||
const { status, message, progress: percentage, total_chunks, current_chunk, questions_extracted, questions_added, duplicates_removed } = progress
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-6 w-6 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />
|
||||
default:
|
||||
return <Loader className="h-6 w-6 text-primary-500 animate-spin" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-500'
|
||||
case 'failed':
|
||||
return 'bg-red-500'
|
||||
case 'processing_chunk':
|
||||
return 'bg-blue-500'
|
||||
default:
|
||||
return 'bg-primary-500'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Status Message */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{status === 'completed' ? '解析完成' : status === 'failed' ? '解析失败' : '正在解析文档'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">{message}</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{status !== 'completed' && status !== 'failed' && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>进度</span>
|
||||
<span>{percentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-3 ${getStatusColor()} transition-all duration-300 ease-out`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
{total_chunks > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Layers className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-xs text-blue-600 font-medium">文档拆分</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-blue-900">
|
||||
{current_chunk}/{total_chunks}
|
||||
</p>
|
||||
<p className="text-xs text-blue-600">部分</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questions_extracted > 0 && (
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-xs text-purple-600 font-medium">已提取</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-purple-900">{questions_extracted}</p>
|
||||
<p className="text-xs text-purple-600">题目</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questions_added > 0 && (
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-xs text-green-600 font-medium">已添加</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-900">{questions_added}</p>
|
||||
<p className="text-xs text-green-600">题目</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{duplicates_removed > 0 && (
|
||||
<div className="bg-orange-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<XCircle className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-xs text-orange-600 font-medium">已去重</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-orange-900">{duplicates_removed}</p>
|
||||
<p className="text-xs text-orange-600">题目</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParsingProgress
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Exam Detail Page - with append upload and status polling
|
||||
* Exam Detail Page - with real-time parsing progress via SSE
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { examAPI, questionAPI } from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import ParsingProgress from '../components/ParsingProgress'
|
||||
import {
|
||||
ArrowLeft, Upload, Play, Loader, FileText, AlertCircle, RefreshCw
|
||||
} from 'lucide-react'
|
||||
@@ -28,16 +29,20 @@ export const ExamDetail = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
|
||||
const eventSourceRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadExamDetail()
|
||||
|
||||
// Start polling if status is processing
|
||||
const interval = setInterval(() => {
|
||||
pollExamStatus()
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [examId])
|
||||
|
||||
const loadExamDetail = async () => {
|
||||
@@ -49,6 +54,11 @@ export const ExamDetail = () => {
|
||||
|
||||
setExam(examRes.data)
|
||||
setQuestions(questionsRes.data.questions)
|
||||
|
||||
// Connect to SSE if exam is processing
|
||||
if (examRes.data.status === 'processing') {
|
||||
connectSSE()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load exam:', error)
|
||||
toast.error('加载题库失败')
|
||||
@@ -57,22 +67,53 @@ export const ExamDetail = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const pollExamStatus = async () => {
|
||||
try {
|
||||
const response = await examAPI.getDetail(examId)
|
||||
const newExam = response.data
|
||||
const connectSSE = () => {
|
||||
// Close existing connection if any
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
// If status changed from processing to ready
|
||||
if (exam?.status === 'processing' && newExam.status === 'ready') {
|
||||
toast.success('文档解析完成!')
|
||||
await loadExamDetail() // Reload to get updated questions
|
||||
} else if (exam?.status === 'processing' && newExam.status === 'failed') {
|
||||
toast.error('文档解析失败')
|
||||
console.log('[SSE] Connecting to progress stream for exam', examId)
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/exams/${examId}/progress?token=${encodeURIComponent(token)}`
|
||||
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const progressData = JSON.parse(event.data)
|
||||
console.log('[SSE] Progress update:', progressData)
|
||||
|
||||
setProgress(progressData)
|
||||
|
||||
// Update exam status if completed or failed
|
||||
if (progressData.status === 'completed') {
|
||||
toast.success(progressData.message)
|
||||
setExam(prev => ({ ...prev, status: 'ready' }))
|
||||
loadExamDetail() // Reload to get updated questions
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
} else if (progressData.status === 'failed') {
|
||||
toast.error(progressData.message)
|
||||
setExam(prev => ({ ...prev, status: 'failed' }))
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE] Failed to parse progress data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setExam(newExam)
|
||||
} catch (error) {
|
||||
console.error('Failed to poll exam:', error)
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('[SSE] Connection error:', error)
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connection established')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +137,13 @@ export const ExamDetail = () => {
|
||||
toast.success('文档上传成功,正在解析并去重...')
|
||||
setShowUploadModal(false)
|
||||
setUploadFile(null)
|
||||
await loadExamDetail()
|
||||
setExam(prev => ({ ...prev, status: 'processing' }))
|
||||
|
||||
// Connect to SSE for real-time progress
|
||||
connectSSE()
|
||||
} catch (error) {
|
||||
console.error('Failed to append document:', error)
|
||||
toast.error('文档上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -138,7 +183,7 @@ export const ExamDetail = () => {
|
||||
const isProcessing = exam.status === 'processing'
|
||||
const isReady = exam.status === 'ready'
|
||||
const isFailed = exam.status === 'failed'
|
||||
const progress = calculateProgress(exam.current_index, exam.total_questions)
|
||||
const quizProgress = calculateProgress(exam.current_index, exam.total_questions)
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -152,6 +197,11 @@ export const ExamDetail = () => {
|
||||
返回题库列表
|
||||
</button>
|
||||
|
||||
{/* Parsing Progress (only shown when processing) */}
|
||||
{isProcessing && progress && (
|
||||
<ParsingProgress progress={progress} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4">
|
||||
@@ -223,7 +273,7 @@ export const ExamDetail = () => {
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
style={{ width: `${quizProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user