feat: Sprint 2 — test taking + results
Backend:
- Models: TestAttempt, AttemptAnswer (migration 002)
- POST /api/attempts: start attempt, shuffle questions/answers,
hide is_correct, expose is_multiple for UI hints
- POST /api/attempts/{id}/submit: save answers, calculate score,
strict matching (selected == correct), return full result
- GET /api/attempts/{id}/result: fetch saved result
- Register attempts router in main.py
Frontend:
- api/attempts.ts: types + API functions
- TestTake page: one question at a time, progress bar, timer
with auto-submit, back navigation controlled by test setting,
radio/checkbox based on is_multiple
- AttemptResult page: score, pass/fail, per-question breakdown
with correct/selected/missed answer highlighting
- App.tsx: add /tests/:testId/take and /attempts/:id/result routes
- TestDetail: add "Пройти тест" button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Progress,
|
||||
Radio,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined, SendOutlined } from '@ant-design/icons'
|
||||
|
||||
import { AttemptStarted, QuestionAnswer, attemptsApi } from '../../api/attempts'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function TestTake() {
|
||||
const { testId } = useParams<{ testId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [attempt, setAttempt] = useState<AttemptStarted | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
// answers: questionId → выбранные answerId[]
|
||||
const [answers, setAnswers] = useState<Map<number, number[]>>(new Map())
|
||||
const [timeLeft, setTimeLeft] = useState<number | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Стартуем попытку при монтировании
|
||||
useEffect(() => {
|
||||
attemptsApi
|
||||
.start(Number(testId))
|
||||
.then((r) => {
|
||||
setAttempt(r.data)
|
||||
if (r.data.time_limit) {
|
||||
setTimeLeft(r.data.time_limit * 60)
|
||||
}
|
||||
})
|
||||
.catch(() => message.error('Не удалось загрузить тест'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [testId])
|
||||
|
||||
// Таймер
|
||||
useEffect(() => {
|
||||
if (timeLeft === null) return
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(timerRef.current!)
|
||||
handleSubmit()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timerRef.current!)
|
||||
}, [timeLeft !== null]) // запускаем один раз когда timeLeft появился
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!attempt || submitting) return
|
||||
clearInterval(timerRef.current!)
|
||||
setSubmitting(true)
|
||||
|
||||
const payload: QuestionAnswer[] = attempt.questions.map((q) => ({
|
||||
question_id: q.id,
|
||||
answer_ids: answers.get(q.id) ?? [],
|
||||
}))
|
||||
|
||||
try {
|
||||
await attemptsApi.submit(attempt.id, payload)
|
||||
navigate(`/attempts/${attempt.id}/result`)
|
||||
} catch {
|
||||
message.error('Ошибка при отправке теста')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnswer = (questionId: number, answerId: number, isMultiple: boolean) => {
|
||||
setAnswers((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (isMultiple) {
|
||||
const current = next.get(questionId) ?? []
|
||||
next.set(
|
||||
questionId,
|
||||
current.includes(answerId)
|
||||
? current.filter((id) => id !== answerId)
|
||||
: [...current, answerId],
|
||||
)
|
||||
} else {
|
||||
next.set(questionId, [answerId])
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (loading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
|
||||
if (!attempt) return null
|
||||
|
||||
const question = attempt.questions[currentIndex]
|
||||
const selectedIds = answers.get(question.id) ?? []
|
||||
const total = attempt.questions.length
|
||||
const isLast = currentIndex === total - 1
|
||||
const isTimeCritical = timeLeft !== null && timeLeft < 60
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', padding: 24 }}>
|
||||
|
||||
{/* Шапка */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
{attempt.test_title}
|
||||
</Title>
|
||||
{timeLeft !== null && (
|
||||
<Tag color={isTimeCritical ? 'red' : 'blue'} style={{ fontSize: 16, padding: '4px 12px' }}>
|
||||
⏱ {formatTime(timeLeft)}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* Прогресс */}
|
||||
<Progress
|
||||
percent={Math.round(((currentIndex + 1) / total) * 100)}
|
||||
format={() => `${currentIndex + 1} / ${total}`}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{isTimeCritical && (
|
||||
<Alert
|
||||
message="Осталось меньше минуты!"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Вопрос */}
|
||||
<Card
|
||||
title={
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
Вопрос {currentIndex + 1}
|
||||
</Text>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Text style={{ fontSize: 15, display: 'block', marginBottom: 20 }}>
|
||||
{question.text}
|
||||
</Text>
|
||||
|
||||
{question.is_multiple && (
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Выберите все правильные варианты
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{question.answers.map((answer) =>
|
||||
question.is_multiple ? (
|
||||
<Checkbox
|
||||
key={answer.id}
|
||||
checked={selectedIds.includes(answer.id)}
|
||||
onChange={() => handleAnswer(question.id, answer.id, true)}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
{answer.text}
|
||||
</Checkbox>
|
||||
) : (
|
||||
<Radio
|
||||
key={answer.id}
|
||||
checked={selectedIds.includes(answer.id)}
|
||||
onChange={() => handleAnswer(question.id, answer.id, false)}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
{answer.text}
|
||||
</Radio>
|
||||
),
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Навигация */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => setCurrentIndex((i) => i - 1)}
|
||||
disabled={currentIndex === 0 || !attempt.allow_navigation_back}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
{isLast ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Завершить тест
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ArrowRightOutlined />}
|
||||
onClick={() => setCurrentIndex((i) => i + 1)}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user