Files
qa_test_app/frontend/src/pages/TestEdit/index.tsx
T
2026-03-21 13:54:00 +05:00

286 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeftOutlined,
CheckCircleTwoTone,
CloseCircleTwoTone,
EditOutlined,
PlayCircleOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, List, Space, Spin, Table, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Answer, CreateTestDto, TestListItem, testsApi } from '../../api/tests'
import TestForm, { TestFormValues } from '../../components/TestForm'
const { Title, Text } = Typography
export default function TestEdit() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [editMode, setEditMode] = useState(false)
const { data: test, isLoading } = useQuery({
queryKey: ['tests', id],
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
})
const { data: versions = [] } = useQuery({
queryKey: ['tests', id, 'versions'],
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data),
enabled: !editMode,
})
const { mutate: activateVersion, isPending: isActivating } = useMutation({
mutationFn: (versionId: number) => testsApi.activate(versionId).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
queryClient.invalidateQueries({ queryKey: ['tests', id, 'versions'] })
queryClient.invalidateQueries({ queryKey: ['tests', id] })
message.success('Активная версия изменена')
},
onError: () => message.error('Не удалось изменить активную версию'),
})
const { mutate: updateTest, isPending } = useMutation({
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
if (result.is_new_version) {
message.success(`Создана новая версия теста (v${result.test.version})`)
navigate(`/tests/${result.test.id}/edit`)
} else {
message.success('Тест обновлён')
queryClient.invalidateQueries({ queryKey: ['tests', id] })
setEditMode(false)
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || 'Ошибка при сохранении теста')
},
})
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
if (!test) return null
// Режим редактирования — показываем форму с предзаполненными данными
if (editMode) {
const initialValues: TestFormValues = {
title: test.title,
description: test.description ?? undefined,
passing_score: test.passing_score,
has_timer: test.time_limit !== null,
time_limit: test.time_limit ?? undefined,
allow_navigation_back: test.allow_navigation_back,
questions: test.questions.map((q) => ({
text: q.text,
answers: q.answers.map((a) => ({ text: a.text, is_correct: a.is_correct })),
})),
}
const onSubmit = (values: TestFormValues) => {
updateTest({
title: values.title,
description: values.description,
passing_score: values.passing_score,
time_limit: values.has_timer ? values.time_limit : undefined,
allow_navigation_back: values.allow_navigation_back ?? true,
questions: values.questions,
})
}
return (
<TestForm
heading={`Редактирование теста — v${test.version}`}
initialValues={initialValues}
onSubmit={onSubmit}
isPending={isPending}
submitLabel="Сохранить"
onCancel={() => setEditMode(false)}
onBack={() => setEditMode(false)}
backLabel="К просмотру теста"
/>
)
}
// Режим просмотра (вид автора)
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
К списку тестов
</Button>
<Space>
{versions.length > 1 && (!test.is_active || versions.some(v => v.id !== test.id && v.is_active)) && (
<Button
loading={isActivating}
onClick={() => activateVersion(test.id)}
>
Сделать активной
</Button>
)}
<Button
icon={<EditOutlined />}
onClick={() => setEditMode(true)}
>
Редактировать
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => navigate(`/tests/${test.id}/take`)}
>
Пройти тест
</Button>
</Space>
</Space>
<Alert
type="warning"
showIcon
message="Вид автора — правильные ответы отмечены"
style={{ marginBottom: 16 }}
/>
<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>
{versions.length > 1 && (
<>
<Title level={3}>История версий</Title>
<Table<TestListItem>
dataSource={versions}
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 24 }}
rowClassName={(record) => (record.id === test.id ? 'ant-table-row-selected' : '')}
columns={
[
{
title: 'Версия',
dataIndex: 'version',
width: 70,
render: (v: number) => <Tag color="default">v{v}</Tag>,
},
{
title: 'Статус',
dataIndex: 'is_active',
width: 130,
render: (active: boolean) =>
active ? (
<Tag color="green">Активная</Tag>
) : (
<Tag color="default">Неактивная</Tag>
),
},
{
title: 'Дата',
dataIndex: 'created_at',
width: 120,
render: (d: string) => new Date(d).toLocaleDateString('ru-RU'),
},
{
title: 'Вопросов',
dataIndex: 'questions_count',
width: 100,
align: 'center' as const,
},
{
title: 'Порог зачёта',
dataIndex: 'passing_score',
width: 120,
align: 'center' as const,
render: (s: number) => `${s}%`,
},
{
title: '',
key: 'action',
render: (_: unknown, record: TestListItem) => (
<Space>
{record.id !== test.id && (
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}>
Открыть
</Button>
)}
{(record.id !== test.id || !record.is_active) && (
<Button
size="small"
type={record.is_active ? 'default' : 'primary'}
loading={isActivating}
onClick={() => activateVersion(record.id)}
>
Сделать активной
</Button>
)}
</Space>
),
},
] as ColumnsType<TestListItem>
}
/>
</>
)}
<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>
)
}