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,153 @@
|
||||
import {
|
||||
CheckCircleTwoTone,
|
||||
CloseCircleTwoTone,
|
||||
MinusCircleOutlined,
|
||||
TrophyOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button, Card, Col, Divider, List, Result, Row, Space, Spin, Tag, Typography } from 'antd'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { AnswerResult, attemptsApi } from '../../api/attempts'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function AttemptResult() {
|
||||
const { attemptId } = useParams<{ attemptId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: result, isLoading } = useQuery({
|
||||
queryKey: ['attempts', attemptId, 'result'],
|
||||
queryFn: () => attemptsApi.getResult(Number(attemptId)).then((r) => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
|
||||
if (!result) return null
|
||||
|
||||
const duration = Math.round(
|
||||
(new Date(result.finished_at).getTime() - new Date(result.started_at).getTime()) / 1000,
|
||||
)
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = duration % 60
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
|
||||
|
||||
{/* Итог */}
|
||||
<Result
|
||||
icon={
|
||||
result.passed ? (
|
||||
<TrophyOutlined style={{ color: '#52c41a' }} />
|
||||
) : (
|
||||
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
|
||||
)
|
||||
}
|
||||
status={result.passed ? 'success' : 'error'}
|
||||
title={result.passed ? 'Тест сдан!' : 'Тест не сдан'}
|
||||
subTitle={result.test_title}
|
||||
/>
|
||||
|
||||
{/* Статистика */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Row gutter={32} justify="center" style={{ textAlign: 'center' }}>
|
||||
<Col>
|
||||
<Title level={1} style={{ margin: 0, color: result.passed ? '#52c41a' : '#ff4d4f' }}>
|
||||
{result.score}%
|
||||
</Title>
|
||||
<Text type="secondary">Результат</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Title level={1} style={{ margin: 0 }}>
|
||||
{result.correct_count}/{result.total_count}
|
||||
</Title>
|
||||
<Text type="secondary">Правильных ответов</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Title level={1} style={{ margin: 0 }}>
|
||||
{result.passing_score}%
|
||||
</Title>
|
||||
<Text type="secondary">Порог зачёта</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Title level={1} style={{ margin: 0 }}>
|
||||
{minutes > 0 ? `${minutes}м ` : ''}{seconds}с
|
||||
</Title>
|
||||
<Text type="secondary">Время</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Разбор ошибок */}
|
||||
<Title level={3}>Разбор ответов</Title>
|
||||
|
||||
{result.questions.map((question, index) => (
|
||||
<Card
|
||||
key={question.id}
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
borderColor: question.is_answered_correctly ? '#b7eb8f' : '#ffccc7',
|
||||
}}
|
||||
title={
|
||||
<Space>
|
||||
{question.is_answered_correctly ? (
|
||||
<CheckCircleTwoTone twoToneColor="#52c41a" />
|
||||
) : (
|
||||
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
|
||||
)}
|
||||
<Text strong>
|
||||
{index + 1}. {question.text}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<List
|
||||
dataSource={question.answers}
|
||||
renderItem={(answer: AnswerResult) => {
|
||||
const icon = answer.is_correct ? (
|
||||
<CheckCircleTwoTone twoToneColor="#52c41a" />
|
||||
) : answer.is_selected ? (
|
||||
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
|
||||
) : (
|
||||
<MinusCircleOutlined style={{ color: '#d9d9d9' }} />
|
||||
)
|
||||
|
||||
return (
|
||||
<List.Item style={{ padding: '4px 0' }}>
|
||||
<Space>
|
||||
{icon}
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: answer.is_correct ? 600 : 400,
|
||||
color: answer.is_selected && !answer.is_correct ? '#ff4d4f' : undefined,
|
||||
}}
|
||||
>
|
||||
{answer.text}
|
||||
</Text>
|
||||
{answer.is_selected && answer.is_correct && (
|
||||
<Tag color="green">ваш ответ ✓</Tag>
|
||||
)}
|
||||
{answer.is_selected && !answer.is_correct && (
|
||||
<Tag color="red">ваш ответ ✗</Tag>
|
||||
)}
|
||||
{!answer.is_selected && answer.is_correct && (
|
||||
<Tag color="green">правильный ответ</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Space>
|
||||
<Button onClick={() => navigate('/')}>К списку тестов</Button>
|
||||
<Button type="primary" onClick={() => navigate(`/tests/${result.test_id}`)}>
|
||||
Страница теста
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeftOutlined, CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'
|
||||
import { ArrowLeftOutlined, CheckCircleTwoTone, CloseCircleTwoTone, PlayCircleOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@@ -24,10 +24,17 @@ export default function TestDetail() {
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
|
||||
К списку тестов
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => navigate(`/tests/${test.id}/take`)}
|
||||
>
|
||||
Пройти тест
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Title level={2}>{test.title}</Title>
|
||||
|
||||
@@ -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