8097672917
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|