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:
@@ -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[]) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user