feat: Sprint 1 — infrastructure + test creation
Backend:
- FastAPI + SQLAlchemy 2.0 async + Alembic
- Models: Test, Question, Answer
- API: GET /api/tests, GET /api/tests/{id}, POST /api/tests
- Pydantic validation: min 7 questions, min 3 answers, ≥1 correct
Frontend:
- React 18 + TypeScript + Vite + Ant Design + TanStack Query
- Pages: TestList, TestCreate (nested Form.List), TestDetail
Infrastructure:
- Docker Compose: db (postgres:16), backend, frontend, nginx
- Nginx: /api/ → FastAPI, / → Vite dev server with HMR
- Alembic migration 001_init: tests, questions, answers tables
- entrypoint.sh: wait for db, migrate, start uvicorn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { ArrowLeftOutlined, CheckCircleTwoTone, CloseCircleTwoTone } 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'
|
||||
|
||||
import { Answer, testsApi } from '../../api/tests'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function TestDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: test, isLoading } = useQuery({
|
||||
queryKey: ['tests', id],
|
||||
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
|
||||
}
|
||||
|
||||
if (!test) return null
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
|
||||
К списку тестов
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Title level={2}>{test.title}</Title>
|
||||
|
||||
{test.description && (
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
{test.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Descriptions column={3}>
|
||||
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item>
|
||||
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item>
|
||||
<Descriptions.Item label="Таймер">
|
||||
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Возврат к вопросу">
|
||||
{test.allow_navigation_back ? (
|
||||
<Tag color="green">Разрешён</Tag>
|
||||
) : (
|
||||
<Tag color="red">Запрещён</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="Создан">
|
||||
{new Date(test.created_at).toLocaleDateString('ru-RU')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Title level={3}>Вопросы ({test.questions.length})</Title>
|
||||
|
||||
{test.questions.map((question, index) => (
|
||||
<Card key={question.id} style={{ marginBottom: 12 }}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.text}
|
||||
</Text>
|
||||
<List
|
||||
style={{ marginTop: 10 }}
|
||||
dataSource={question.answers}
|
||||
renderItem={(answer: Answer) => (
|
||||
<List.Item style={{ padding: '4px 0' }}>
|
||||
<Space>
|
||||
{answer.is_correct ? (
|
||||
<CheckCircleTwoTone twoToneColor="#52c41a" />
|
||||
) : (
|
||||
<CloseCircleTwoTone twoToneColor="#d9d9d9" />
|
||||
)}
|
||||
<Text>{answer.text}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user