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,97 @@
|
||||
# ШАГ 007 — Спринт 2: Прохождение теста + результат
|
||||
|
||||
**Дата:** 2026-03-21
|
||||
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
|
||||
|
||||
---
|
||||
|
||||
## Запрос
|
||||
|
||||
> запускай реализацию спринта 2
|
||||
|
||||
---
|
||||
|
||||
## Ответ
|
||||
|
||||
Реализован Спринт 2: прохождение теста с таймером и навигацией, результат с разбором ошибок.
|
||||
|
||||
---
|
||||
|
||||
## Новые файлы
|
||||
|
||||
```
|
||||
backend/app/models/attempt.py ← TestAttempt, AttemptAnswer
|
||||
backend/app/schemas/attempt.py ← схемы для старта, сдачи и результата
|
||||
backend/app/api/attempts.py ← 3 эндпоинта
|
||||
backend/alembic/versions/002_attempts.py ← миграция
|
||||
|
||||
frontend/src/api/attempts.ts ← типы и запросы
|
||||
frontend/src/pages/TestTake/ ← страница прохождения теста
|
||||
frontend/src/pages/AttemptResult/ ← страница результата
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API эндпоинты (новые)
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|-------|-----|----------|
|
||||
| POST | `/api/attempts` | Начать попытку → возвращает вопросы перемешанные, без правильных ответов |
|
||||
| POST | `/api/attempts/{id}/submit` | Сдать тест → подсчитать и вернуть результат |
|
||||
| GET | `/api/attempts/{id}/result` | Получить результат сохранённой попытки |
|
||||
|
||||
---
|
||||
|
||||
## Схема БД (добавлено)
|
||||
|
||||
```
|
||||
test_attempts
|
||||
id, test_id → tests.id, started_at, finished_at,
|
||||
score, passed, correct_count, total_count, status
|
||||
|
||||
attempt_answers
|
||||
id, attempt_id → test_attempts.id,
|
||||
question_id → questions.id, answer_id → answers.id
|
||||
```
|
||||
|
||||
Одна строка `attempt_answers` = один выбранный вариант ответа.
|
||||
Для вопросов с несколькими правильными ответами — несколько строк.
|
||||
|
||||
---
|
||||
|
||||
## Логика прохождения теста
|
||||
|
||||
**Старт попытки:**
|
||||
- Создаётся запись `TestAttempt` со статусом `in_progress`
|
||||
- Вопросы и ответы внутри каждого вопроса перемешиваются случайно
|
||||
- Поле `is_correct` **не передаётся** на фронт — нельзя смошенничать через DevTools
|
||||
- Поле `is_multiple: bool` говорит фронту: показывать радио-кнопки или чекбоксы
|
||||
|
||||
**Сдача теста:**
|
||||
- Фронт отправляет `[{ question_id, answer_ids[] }]` для каждого вопроса
|
||||
- Вопрос засчитывается правильным только если `selected_ids == correct_ids` (точное совпадение)
|
||||
- Балл = (правильных / всего) × 100
|
||||
- Зачёт: балл ≥ порогу из теста
|
||||
|
||||
**Разбор ошибок:**
|
||||
- Для каждого вопроса: `is_answered_correctly`
|
||||
- Для каждого варианта: `is_correct` + `is_selected` → фронт показывает что выбрал и что было правильно
|
||||
|
||||
---
|
||||
|
||||
## UX прохождения теста
|
||||
|
||||
- Вопросы по одному, прогресс-бар сверху
|
||||
- Таймер: если задан — обратный отсчёт, при `< 60 сек` — предупреждение, при `0` — автосабмит
|
||||
- Кнопка «Назад» заблокирована если `allow_navigation_back = false`
|
||||
- Чекбоксы для `is_multiple`, радио-кнопки для одиночного ответа
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
- [x] Спринт 1: Инфраструктура + Создание тестов
|
||||
- [x] Спринт 2: Прохождение теста + результат
|
||||
- [ ] Спринт 3: Трекер результатов
|
||||
- [ ] Спринт 4: Авторизация и роли
|
||||
- [ ] Спринт 5: Уведомления в MAX
|
||||
@@ -15,7 +15,7 @@ if config.config_file_name is not None:
|
||||
# Берём DATABASE_URL из настроек приложения
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
from app.models import test # noqa: F401 — импортируем модели, чтобы Alembic их видел
|
||||
from app.models import attempt, test # noqa: F401 — импортируем модели, чтобы Alembic их видел
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""attempts
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-03-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"test_attempts",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False),
|
||||
sa.Column(
|
||||
"started_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("score", sa.Float(), nullable=True),
|
||||
sa.Column("passed", sa.Boolean(), nullable=True),
|
||||
sa.Column("correct_count", sa.Integer(), nullable=True),
|
||||
sa.Column("total_count", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="in_progress"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"attempt_answers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"attempt_id", sa.Integer(), sa.ForeignKey("test_attempts.id"), nullable=False
|
||||
),
|
||||
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False),
|
||||
sa.Column("answer_id", sa.Integer(), sa.ForeignKey("answers.id"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("attempt_answers")
|
||||
op.drop_table("test_attempts")
|
||||
@@ -0,0 +1,223 @@
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.attempt import AttemptAnswer, TestAttempt
|
||||
from app.models.test import Question, Test
|
||||
from app.schemas.attempt import (
|
||||
AttemptResult,
|
||||
AttemptStart,
|
||||
AttemptStarted,
|
||||
AttemptSubmitDto,
|
||||
AnswerForTest,
|
||||
AnswerResult,
|
||||
QuestionForTest,
|
||||
QuestionResult,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
|
||||
|
||||
|
||||
@router.post("", response_model=AttemptStarted, status_code=201)
|
||||
async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
|
||||
"""Начать попытку прохождения теста. Возвращает вопросы в случайном порядке без правильных ответов."""
|
||||
|
||||
result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||
.where(Test.id == data.test_id, Test.is_active == True)
|
||||
)
|
||||
test = result.scalar_one_or_none()
|
||||
if not test:
|
||||
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||
|
||||
attempt = TestAttempt(test_id=test.id, status="in_progress")
|
||||
db.add(attempt)
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
|
||||
# Перемешиваем вопросы и ответы случайно
|
||||
questions_shuffled = list(test.questions)
|
||||
random.shuffle(questions_shuffled)
|
||||
|
||||
questions_for_test = []
|
||||
for q in questions_shuffled:
|
||||
correct_count = sum(1 for a in q.answers if a.is_correct)
|
||||
answers_shuffled = list(q.answers)
|
||||
random.shuffle(answers_shuffled)
|
||||
questions_for_test.append(
|
||||
QuestionForTest(
|
||||
id=q.id,
|
||||
text=q.text,
|
||||
is_multiple=correct_count > 1,
|
||||
answers=[AnswerForTest(id=a.id, text=a.text) for a in answers_shuffled],
|
||||
)
|
||||
)
|
||||
|
||||
return AttemptStarted(
|
||||
id=attempt.id,
|
||||
test_id=test.id,
|
||||
test_title=test.title,
|
||||
test_description=test.description,
|
||||
started_at=attempt.started_at,
|
||||
time_limit=test.time_limit,
|
||||
allow_navigation_back=test.allow_navigation_back,
|
||||
questions=questions_for_test,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{attempt_id}/submit", response_model=AttemptResult)
|
||||
async def submit_attempt(
|
||||
attempt_id: int, data: AttemptSubmitDto, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Завершить попытку: сохранить ответы, подсчитать результат."""
|
||||
|
||||
result = await db.execute(
|
||||
select(TestAttempt)
|
||||
.where(TestAttempt.id == attempt_id, TestAttempt.status == "in_progress")
|
||||
)
|
||||
attempt = result.scalar_one_or_none()
|
||||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Попытка не найдена или уже завершена")
|
||||
|
||||
# Загружаем тест с вопросами и ответами
|
||||
test_result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||
.where(Test.id == attempt.test_id)
|
||||
)
|
||||
test = test_result.scalar_one()
|
||||
|
||||
# Сохраняем выбранные ответы
|
||||
submitted = {qa.question_id: set(qa.answer_ids) for qa in data.answers}
|
||||
|
||||
for qa in data.answers:
|
||||
for answer_id in qa.answer_ids:
|
||||
db.add(AttemptAnswer(
|
||||
attempt_id=attempt.id,
|
||||
question_id=qa.question_id,
|
||||
answer_id=answer_id,
|
||||
))
|
||||
|
||||
# Подсчёт результата
|
||||
correct_count = 0
|
||||
question_results = []
|
||||
|
||||
for question in test.questions:
|
||||
correct_ids = {a.id for a in question.answers if a.is_correct}
|
||||
selected_ids = submitted.get(question.id, set())
|
||||
is_correct = selected_ids == correct_ids
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
|
||||
question_results.append(
|
||||
QuestionResult(
|
||||
id=question.id,
|
||||
text=question.text,
|
||||
is_answered_correctly=is_correct,
|
||||
answers=[
|
||||
AnswerResult(
|
||||
id=a.id,
|
||||
text=a.text,
|
||||
is_correct=a.is_correct,
|
||||
is_selected=a.id in selected_ids,
|
||||
)
|
||||
for a in question.answers
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
total = len(test.questions)
|
||||
score = round((correct_count / total) * 100, 1) if total > 0 else 0.0
|
||||
passed = score >= test.passing_score
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
|
||||
attempt.finished_at = finished_at
|
||||
attempt.score = score
|
||||
attempt.passed = passed
|
||||
attempt.correct_count = correct_count
|
||||
attempt.total_count = total
|
||||
attempt.status = "finished"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return AttemptResult(
|
||||
id=attempt.id,
|
||||
test_id=test.id,
|
||||
test_title=test.title,
|
||||
started_at=attempt.started_at,
|
||||
finished_at=finished_at,
|
||||
score=score,
|
||||
passed=passed,
|
||||
passing_score=test.passing_score,
|
||||
correct_count=correct_count,
|
||||
total_count=total,
|
||||
questions=question_results,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{attempt_id}/result", response_model=AttemptResult)
|
||||
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Получить результат завершённой попытки."""
|
||||
|
||||
result = await db.execute(
|
||||
select(TestAttempt)
|
||||
.options(selectinload(TestAttempt.answers).selectinload(AttemptAnswer.answer))
|
||||
.where(TestAttempt.id == attempt_id, TestAttempt.status == "finished")
|
||||
)
|
||||
attempt = result.scalar_one_or_none()
|
||||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Результат не найден")
|
||||
|
||||
test_result = await db.execute(
|
||||
select(Test)
|
||||
.options(selectinload(Test.questions).selectinload(Question.answers))
|
||||
.where(Test.id == attempt.test_id)
|
||||
)
|
||||
test = test_result.scalar_one()
|
||||
|
||||
# Восстанавливаем выбранные ответы из БД
|
||||
selected_by_question: dict[int, set[int]] = {}
|
||||
for aa in attempt.answers:
|
||||
selected_by_question.setdefault(aa.question_id, set()).add(aa.answer_id)
|
||||
|
||||
question_results = []
|
||||
for question in test.questions:
|
||||
correct_ids = {a.id for a in question.answers if a.is_correct}
|
||||
selected_ids = selected_by_question.get(question.id, set())
|
||||
question_results.append(
|
||||
QuestionResult(
|
||||
id=question.id,
|
||||
text=question.text,
|
||||
is_answered_correctly=selected_ids == correct_ids,
|
||||
answers=[
|
||||
AnswerResult(
|
||||
id=a.id,
|
||||
text=a.text,
|
||||
is_correct=a.is_correct,
|
||||
is_selected=a.id in selected_ids,
|
||||
)
|
||||
for a in question.answers
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return AttemptResult(
|
||||
id=attempt.id,
|
||||
test_id=test.id,
|
||||
test_title=test.title,
|
||||
started_at=attempt.started_at,
|
||||
finished_at=attempt.finished_at,
|
||||
score=attempt.score,
|
||||
passed=attempt.passed,
|
||||
passing_score=test.passing_score,
|
||||
correct_count=attempt.correct_count,
|
||||
total_count=attempt.total_count,
|
||||
questions=question_results,
|
||||
)
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import tests
|
||||
from app.api import attempts, tests
|
||||
|
||||
app = FastAPI(
|
||||
title="QA Test App API",
|
||||
@@ -19,6 +19,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(tests.router)
|
||||
app.include_router(attempts.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TestAttempt(Base):
|
||||
__tablename__ = "test_attempts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
score: Mapped[float | None] = mapped_column(Float, nullable=True) # процент 0–100
|
||||
passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
correct_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
total_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# in_progress | finished
|
||||
status: Mapped[str] = mapped_column(String(20), default="in_progress", nullable=False)
|
||||
|
||||
test: Mapped["Test"] = relationship("Test") # type: ignore[name-defined]
|
||||
answers: Mapped[list["AttemptAnswer"]] = relationship(
|
||||
"AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class AttemptAnswer(Base):
|
||||
"""Одна запись = один выбранный вариант ответа сотрудника."""
|
||||
|
||||
__tablename__ = "attempt_answers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
attempt_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("test_attempts.id"), nullable=False
|
||||
)
|
||||
question_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("questions.id"), nullable=False
|
||||
)
|
||||
answer_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("answers.id"), nullable=False
|
||||
)
|
||||
|
||||
attempt: Mapped["TestAttempt"] = relationship("TestAttempt", back_populates="answers")
|
||||
answer: Mapped["Answer"] = relationship("Answer") # type: ignore[name-defined]
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ── Начало попытки ──────────────────────────────────────────
|
||||
|
||||
class AttemptStart(BaseModel):
|
||||
test_id: int
|
||||
|
||||
|
||||
class AnswerForTest(BaseModel):
|
||||
"""Вариант ответа без поля is_correct — не раскрываем правильные ответы."""
|
||||
id: int
|
||||
text: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class QuestionForTest(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
is_multiple: bool # True = несколько правильных ответов → показываем чекбоксы
|
||||
answers: list[AnswerForTest] # перемешаны случайно
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AttemptStarted(BaseModel):
|
||||
"""Возвращается после старта попытки."""
|
||||
id: int
|
||||
test_id: int
|
||||
test_title: str
|
||||
test_description: Optional[str]
|
||||
started_at: datetime
|
||||
time_limit: Optional[int] # минуты, из теста
|
||||
allow_navigation_back: bool # из теста
|
||||
questions: list[QuestionForTest] # перемешаны случайно
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Сдача попытки ────────────────────────────────────────────
|
||||
|
||||
class QuestionAnswer(BaseModel):
|
||||
"""Ответы сотрудника на один вопрос."""
|
||||
question_id: int
|
||||
answer_ids: list[int] # выбранные варианты (может быть пустым)
|
||||
|
||||
|
||||
class AttemptSubmitDto(BaseModel):
|
||||
answers: list[QuestionAnswer]
|
||||
|
||||
|
||||
# ── Результат ────────────────────────────────────────────────
|
||||
|
||||
class AnswerResult(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
is_correct: bool # правильный ли ответ вообще
|
||||
is_selected: bool # выбрал ли его сотрудник
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class QuestionResult(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
is_answered_correctly: bool # вся комбинация ответов верна
|
||||
answers: list[AnswerResult]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AttemptResult(BaseModel):
|
||||
id: int
|
||||
test_id: int
|
||||
test_title: str
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
score: float # процент правильных ответов
|
||||
passed: bool # преодолён ли порог зачёта
|
||||
passing_score: int # порог из теста
|
||||
correct_count: int
|
||||
total_count: int
|
||||
questions: list[QuestionResult]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -3,9 +3,11 @@ import { ConfigProvider } from 'antd'
|
||||
import ruRU from 'antd/locale/ru_RU'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import AttemptResult from './pages/AttemptResult'
|
||||
import TestCreate from './pages/TestCreate'
|
||||
import TestDetail from './pages/TestDetail'
|
||||
import TestList from './pages/TestList'
|
||||
import TestTake from './pages/TestTake'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
@@ -18,6 +20,8 @@ export default function App() {
|
||||
<Route path="/" element={<TestList />} />
|
||||
<Route path="/tests/create" element={<TestCreate />} />
|
||||
<Route path="/tests/:id" element={<TestDetail />} />
|
||||
<Route path="/tests/:testId/take" element={<TestTake />} />
|
||||
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import client from './client'
|
||||
|
||||
export interface AnswerForTest {
|
||||
id: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface QuestionForTest {
|
||||
id: number
|
||||
text: string
|
||||
is_multiple: boolean // true → показываем чекбоксы, false → радио-кнопки
|
||||
answers: AnswerForTest[]
|
||||
}
|
||||
|
||||
export interface AttemptStarted {
|
||||
id: number
|
||||
test_id: number
|
||||
test_title: string
|
||||
test_description: string | null
|
||||
started_at: string
|
||||
time_limit: number | null // минуты
|
||||
allow_navigation_back: boolean
|
||||
questions: QuestionForTest[]
|
||||
}
|
||||
|
||||
export interface QuestionAnswer {
|
||||
question_id: number
|
||||
answer_ids: number[]
|
||||
}
|
||||
|
||||
export interface AnswerResult {
|
||||
id: number
|
||||
text: string
|
||||
is_correct: boolean
|
||||
is_selected: boolean
|
||||
}
|
||||
|
||||
export interface QuestionResult {
|
||||
id: number
|
||||
text: string
|
||||
is_answered_correctly: boolean
|
||||
answers: AnswerResult[]
|
||||
}
|
||||
|
||||
export interface AttemptResult {
|
||||
id: number
|
||||
test_id: number
|
||||
test_title: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
score: number
|
||||
passed: boolean
|
||||
passing_score: number
|
||||
correct_count: number
|
||||
total_count: number
|
||||
questions: QuestionResult[]
|
||||
}
|
||||
|
||||
export const attemptsApi = {
|
||||
start: (test_id: number) =>
|
||||
client.post<AttemptStarted>('/attempts', { test_id }),
|
||||
|
||||
submit: (attempt_id: number, answers: QuestionAnswer[]) =>
|
||||
client.post<AttemptResult>(`/attempts/${attempt_id}/submit`, { answers }),
|
||||
|
||||
getResult: (attempt_id: number) =>
|
||||
client.get<AttemptResult>(`/attempts/${attempt_id}/result`),
|
||||
}
|
||||
@@ -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