feat: редизайн страницы создания/редактирования теста

- TestForm: смысловые блоки «Метаинформация» / «Версии теста» / «Содержание» / команды
- AI-генерация: мини-форма из 3 полей (тема, число вопросов, число вариантов)
- Кнопка «Проверить тест» переехала в нижнюю панель команд
- Backend: GenerateRequest принимает answers_count, передаётся в промпт
- Убрано упоминание API-ключа в fallback-сообщении формы

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aleksey Razorvin
2026-04-25 16:44:34 +05:00
parent f6fc92298a
commit 51df045220
6 changed files with 335 additions and 33 deletions
+6 -2
View File
@@ -9,9 +9,13 @@ const llmApi = {
check: () =>
axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data),
generate: (topic: string, count = 7) =>
generate: (topic: string, count = 7, answersCount = 3) =>
axios
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', { topic, count })
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', {
topic,
count,
answers_count: answersCount,
})
.then((r) => r.data),
improve: (question: string, answers: string[]) =>
+173 -27
View File
@@ -15,12 +15,16 @@ import {
Modal,
Space,
Switch,
Table,
Tag,
Typography,
notification,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import llmApi, { LLMQuestion } from '../../api/llm'
import { TestListItem } from '../../api/tests'
const { Title, Text, Paragraph } = Typography
@@ -46,6 +50,11 @@ interface TestFormProps {
onCancel: () => void
onBack?: () => void
backLabel?: string
versions?: TestListItem[]
currentVersionId?: number
onActivateVersion?: (id: number) => void
isActivating?: boolean
onOpenVersion?: (id: number) => void
}
export default function TestForm({
@@ -57,6 +66,11 @@ export default function TestForm({
onCancel,
onBack,
backLabel = 'Назад',
versions,
currentVersionId,
onActivateVersion,
isActivating,
onOpenVersion,
}: TestFormProps) {
const [form] = Form.useForm<TestFormValues>()
const [notifApi, contextHolder] = notification.useNotification()
@@ -65,6 +79,9 @@ export default function TestForm({
const [generateLoading, setGenerateLoading] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
const [aiTopic, setAiTopic] = useState('')
const [aiQuestionsCount, setAiQuestionsCount] = useState(7)
const [aiAnswersCount, setAiAnswersCount] = useState(3)
// Improve modal state
const [improveState, setImproveState] = useState<{
@@ -114,13 +131,20 @@ export default function TestForm({
// ── AI: Генерация вопросов ──────────────────────────────────────────────
const handleGenerate = async () => {
const title = form.getFieldValue('title') as string
if (!title?.trim()) return
const titleField = (form.getFieldValue('title') as string) || ''
const topic = (aiTopic.trim() || titleField.trim())
if (!topic) {
notifApi.warning({
message: 'Укажите тему',
description: 'Заполните поле «Тема» или название теста в метаинформации',
})
return
}
setGenerateLoading(true)
setPreviewQuestions(null)
setPreviewOpen(true)
try {
const data = await llmApi.generate(title.trim(), 7)
const data = await llmApi.generate(topic, aiQuestionsCount, aiAnswersCount)
setPreviewQuestions(data.questions)
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
@@ -241,7 +265,7 @@ export default function TestForm({
const data = await llmApi.review(values.title, values.questions || [])
setReviewText(data.review)
} catch {
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.')
} finally {
setReviewLoading(false)
}
@@ -306,8 +330,8 @@ export default function TestForm({
<Title level={2}>{heading}</Title>
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}>
{/* ── Основные настройки ── */}
<Card title="Основные настройки" style={{ marginBottom: 16 }}>
{/* ── Метаинформация ── */}
<Card style={{ marginBottom: 16 }}>
<Form.Item
name="title"
label="Название теста"
@@ -363,33 +387,150 @@ export default function TestForm({
</Form.Item>
</Card>
{/* ── AI: кнопки ── */}
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.title !== cur.title}
>
{({ getFieldValue }) => (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{/* ── Версии теста ── */}
{versions && versions.length > 0 && (
<Card title="Версии теста" style={{ marginBottom: 16 }}>
<Table<TestListItem>
dataSource={versions}
rowKey="id"
size="small"
pagination={false}
rowClassName={(record) =>
record.id === currentVersionId ? 'ant-table-row-selected' : ''
}
columns={
[
{
title: 'Версия',
dataIndex: 'version',
width: 80,
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: 90,
align: 'center' as const,
render: (s: number) => `${s}%`,
},
{
title: '',
key: 'action',
render: (_: unknown, record: TestListItem) => (
<Space>
{onOpenVersion && record.id !== currentVersionId && (
<Button size="small" onClick={() => onOpenVersion(record.id)}>
Открыть
</Button>
)}
{onActivateVersion &&
(record.id !== currentVersionId || !record.is_active) && (
<Button
size="small"
type={record.is_active ? 'default' : 'primary'}
loading={isActivating}
onClick={() => onActivateVersion(record.id)}
>
Сделать активной
</Button>
)}
</Space>
),
},
] as ColumnsType<TestListItem>
}
/>
</Card>
)}
{/* ── Содержание ── */}
<Card title="Содержание" style={{ marginBottom: 16 }}>
{/* AI мини-форма для генерации структуры теста */}
<div
style={{
padding: 12,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
marginBottom: 16,
}}
>
<Text strong>Сгенерировать вопросы с AI</Text>
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 12 }}>
Заполните поля и получите готовую структуру вопросов и вариантов без ручного «+ вопрос» / «+ вариант».
</Paragraph>
<Space wrap align="end">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>
Тема (если пусто берётся из названия)
</Text>
<Input
placeholder="Например: Пожарная безопасность"
value={aiTopic}
onChange={(e) => setAiTopic(e.target.value)}
style={{ width: 320 }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>
Вопросов
</Text>
<InputNumber
min={1}
max={30}
value={aiQuestionsCount}
onChange={(v) => setAiQuestionsCount(Number(v) || 7)}
style={{ width: 100 }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>
Вариантов
</Text>
<InputNumber
min={2}
max={8}
value={aiAnswersCount}
onChange={(v) => setAiAnswersCount(Number(v) || 3)}
style={{ width: 100 }}
/>
</div>
<Button
type="primary"
icon={<RobotOutlined />}
onClick={handleGenerate}
loading={generateLoading}
disabled={!getFieldValue('title')?.trim()}
>
Сгенерировать с AI
Сгенерировать
</Button>
<Button
icon={<StarOutlined />}
onClick={handleReview}
>
Проверить тест
</Button>
</div>
)}
</Form.Item>
</Space>
</div>
{/* ── Вопросы ── */}
<Form.List
{/* ── Вопросы ── */}
<Form.List
name="questions"
rules={[
{
@@ -531,13 +672,18 @@ export default function TestForm({
</Button>
</>
)}
</Form.List>
</Form.List>
</Card>
{/* ── Команды ── */}
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={isPending}>
{submitLabel}
</Button>
<Button icon={<StarOutlined />} onClick={handleReview}>
Проверить тест
</Button>
<Button onClick={onCancel}>Отмена</Button>
</Space>
</Form.Item>
+5 -1
View File
@@ -30,7 +30,6 @@ export default function TestEdit() {
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({
@@ -105,6 +104,11 @@ export default function TestEdit() {
onCancel={() => setEditMode(false)}
onBack={() => setEditMode(false)}
backLabel="К просмотру теста"
versions={versions}
currentVersionId={test.id}
onActivateVersion={activateVersion}
isActivating={isActivating}
onOpenVersion={(vid) => navigate(`/tests/${vid}/edit`)}
/>
)
}