mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10:14 +00:00
🎉 Initial commit: QQuiz - 智能刷题与题库管理平台
## 功能特性 ✅ **核心功能** - 多文件上传与智能去重(基于 content_hash) - 异步文档解析(支持 TXT/PDF/DOCX/XLSX) - AI 智能题目提取与评分(OpenAI/Anthropic/Qwen) - 断点续做与进度管理 - 自动错题本收集 ✅ **技术栈** - Backend: FastAPI + SQLAlchemy 2.0 + PostgreSQL - Frontend: React 18 + Vite + Tailwind CSS - Deployment: Docker Compose ✅ **项目结构** - 53 个文件 - 完整的前后端分离架构 - Docker/源码双模部署支持 🚀 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
397
frontend/src/pages/QuizPlayer.jsx
Normal file
397
frontend/src/pages/QuizPlayer.jsx
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Quiz Player Page - Core quiz functionality
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { examAPI, questionAPI, mistakeAPI } from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, X, Loader, BookmarkPlus, BookmarkX, AlertCircle
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getQuestionTypeText } from '../utils/helpers'
|
||||
|
||||
export const QuizPlayer = () => {
|
||||
const { examId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [exam, setExam] = useState(null)
|
||||
const [question, setQuestion] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [userAnswer, setUserAnswer] = useState('')
|
||||
const [multipleAnswers, setMultipleAnswers] = useState([])
|
||||
const [result, setResult] = useState(null)
|
||||
const [inMistakeBook, setInMistakeBook] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadQuiz()
|
||||
}, [examId])
|
||||
|
||||
const loadQuiz = async () => {
|
||||
try {
|
||||
// Check if reset flag is present
|
||||
const shouldReset = searchParams.get('reset') === 'true'
|
||||
if (shouldReset) {
|
||||
await examAPI.updateProgress(examId, 0)
|
||||
}
|
||||
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
await loadCurrentQuestion()
|
||||
} catch (error) {
|
||||
console.error('Failed to load quiz:', error)
|
||||
toast.error('加载题目失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCurrentQuestion = async () => {
|
||||
try {
|
||||
const response = await questionAPI.getCurrentQuestion(examId)
|
||||
setQuestion(response.data)
|
||||
setResult(null)
|
||||
setUserAnswer('')
|
||||
setMultipleAnswers([])
|
||||
await checkIfInMistakeBook(response.data.id)
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
toast.success('恭喜!所有题目已完成!')
|
||||
navigate(`/exams/${examId}`)
|
||||
} else {
|
||||
console.error('Failed to load question:', error)
|
||||
toast.error('加载题目失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfInMistakeBook = async (questionId) => {
|
||||
try {
|
||||
const response = await mistakeAPI.getList(0, 1000) // TODO: Optimize this
|
||||
const inBook = response.data.mistakes.some(m => m.question_id === questionId)
|
||||
setInMistakeBook(inBook)
|
||||
} catch (error) {
|
||||
console.error('Failed to check mistake book:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitAnswer = async () => {
|
||||
let answer = userAnswer
|
||||
|
||||
// For multiple choice, join selected options
|
||||
if (question.type === 'multiple') {
|
||||
if (multipleAnswers.length === 0) {
|
||||
toast.error('请至少选择一个选项')
|
||||
return
|
||||
}
|
||||
answer = multipleAnswers.sort().join('')
|
||||
}
|
||||
|
||||
if (!answer.trim()) {
|
||||
toast.error('请输入答案')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await questionAPI.checkAnswer(question.id, answer)
|
||||
setResult(response.data)
|
||||
|
||||
if (response.data.correct) {
|
||||
toast.success('回答正确!')
|
||||
} else {
|
||||
toast.error('回答错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check answer:', error)
|
||||
toast.error('提交答案失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
try {
|
||||
const newIndex = exam.current_index + 1
|
||||
await examAPI.updateProgress(examId, newIndex)
|
||||
|
||||
const examRes = await examAPI.getDetail(examId)
|
||||
setExam(examRes.data)
|
||||
|
||||
await loadCurrentQuestion()
|
||||
} catch (error) {
|
||||
console.error('Failed to move to next question:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleMistake = async () => {
|
||||
try {
|
||||
if (inMistakeBook) {
|
||||
await mistakeAPI.removeByQuestionId(question.id)
|
||||
setInMistakeBook(false)
|
||||
toast.success('已从错题本移除')
|
||||
} else {
|
||||
await mistakeAPI.add(question.id)
|
||||
setInMistakeBook(true)
|
||||
toast.success('已加入错题本')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle mistake:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMultipleChoice = (option) => {
|
||||
const letter = option.charAt(0)
|
||||
if (multipleAnswers.includes(letter)) {
|
||||
setMultipleAnswers(multipleAnswers.filter(a => a !== letter))
|
||||
} else {
|
||||
setMultipleAnswers([...multipleAnswers, letter])
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Loader className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!question) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<AlertCircle className="h-16 w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">没有更多题目了</p>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate(`/exams/${examId}`)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
进度: {exam.current_index + 1} / {exam.total_questions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Card */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 md:p-8 mb-6">
|
||||
{/* Question Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-primary-100 text-primary-600 rounded-full flex items-center justify-center font-bold">
|
||||
{exam.current_index + 1}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium">
|
||||
{getQuestionTypeText(question.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleToggleMistake}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||
inMistakeBook
|
||||
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{inMistakeBook ? (
|
||||
<>
|
||||
<BookmarkX className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">移出错题本</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookmarkPlus className="h-5 w-5" />
|
||||
<span className="hidden sm:inline">加入错题本</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Question Content */}
|
||||
<div className="mb-6">
|
||||
<p className="text-lg md:text-xl text-gray-900 leading-relaxed">
|
||||
{question.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Options (for choice questions) */}
|
||||
{question.options && question.options.length > 0 && (
|
||||
<div className="space-y-3 mb-6">
|
||||
{question.options.map((option, index) => {
|
||||
const letter = option.charAt(0)
|
||||
const isSelected = question.type === 'multiple'
|
||||
? multipleAnswers.includes(letter)
|
||||
: userAnswer === letter
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!result) {
|
||||
if (question.type === 'multiple') {
|
||||
handleMultipleChoice(option)
|
||||
} else {
|
||||
setUserAnswer(letter)
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!!result}
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
} ${result ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className="text-gray-900">{option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Short Answer Input */}
|
||||
{question.type === 'short' && !result && (
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:border-primary-500 focus:outline-none"
|
||||
placeholder="请输入你的答案..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Judge Input */}
|
||||
{question.type === 'judge' && !result && (
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={() => setUserAnswer('A')}
|
||||
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
|
||||
userAnswer === 'A'
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
正确
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUserAnswer('B')}
|
||||
className={`flex-1 py-3 rounded-lg border-2 transition-all ${
|
||||
userAnswer === 'B'
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
错误
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
{!result && (
|
||||
<button
|
||||
onClick={handleSubmitAnswer}
|
||||
disabled={submitting}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-5 w-5" />
|
||||
提交答案
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className={`rounded-xl p-6 mb-6 ${
|
||||
result.correct ? 'bg-green-50 border-2 border-green-200' : 'bg-red-50 border-2 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
{result.correct ? (
|
||||
<Check className="h-6 w-6 text-green-600 mt-0.5" />
|
||||
) : (
|
||||
<X className="h-6 w-6 text-red-600 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-bold text-lg mb-2 ${result.correct ? 'text-green-900' : 'text-red-900'}`}>
|
||||
{result.correct ? '回答正确!' : '回答错误'}
|
||||
</h3>
|
||||
|
||||
{!result.correct && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-gray-700">
|
||||
<span className="font-medium">你的答案:</span>{result.user_answer}
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<span className="font-medium">正确答案:</span>{result.correct_answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Score for short answers */}
|
||||
{result.ai_score !== null && result.ai_score !== undefined && (
|
||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">
|
||||
AI 评分:{(result.ai_score * 100).toFixed(0)}%
|
||||
</p>
|
||||
{result.ai_feedback && (
|
||||
<p className="text-sm text-gray-600">{result.ai_feedback}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
{result.analysis && (
|
||||
<div className="mt-3 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">解析:</p>
|
||||
<p className="text-sm text-gray-600">{result.analysis}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
下一题
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuizPlayer
|
||||
Reference in New Issue
Block a user