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:
@@ -0,0 +1,140 @@
|
|||||||
|
# Предложение по редизайну страницы «Создание теста»
|
||||||
|
|
||||||
|
**Ветка:** `dev-new-design-page-createtest`
|
||||||
|
**Затронутые файлы:** `frontend/src/components/TestForm/index.tsx`, `frontend/src/pages/TestCreate/index.tsx` (через общий компонент), частично `frontend/src/api/llm.ts` (расширение сигнатуры `generate`).
|
||||||
|
**Бэкенд:** менять не обязательно — у нас уже есть `POST /api/llm/generate` (см. `backend/app/api/llm.py`); нужно лишь принять параметры `questions_count` и `answers_count`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель
|
||||||
|
|
||||||
|
Сделать форму создания теста читаемее: разбить «полотно» на три смысловые группы и чётко отделить редактируемое содержимое от служебных команд. Заодно — дать пользователю возможность сгенерировать тест сразу нужной формы, не нащёлкивая «+ вопрос» / «+ вариант» вручную.
|
||||||
|
|
||||||
|
## 2. Текущее состояние (что есть)
|
||||||
|
|
||||||
|
`TestForm/index.tsx` сейчас визуально устроен так:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ← Назад Заголовок │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Card «Основные настройки» │
|
||||||
|
│ • название │
|
||||||
|
│ • описание │
|
||||||
|
│ • порог зачёта │
|
||||||
|
│ • таймер │
|
||||||
|
│ • разрешить возврат │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ [Сгенерировать с AI] [Проверить тест] │ ← вне карточек, между мета и вопросами
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Card «Вопрос 1» │
|
||||||
|
│ ... │
|
||||||
|
│ Card «Вопрос N» │
|
||||||
|
│ [+ Добавить вопрос] │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ [Создать тест] [Отмена] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Замечания:
|
||||||
|
- AI-кнопки висят «в воздухе» — не видно, к какой части формы они относятся.
|
||||||
|
- «Сгенерировать с AI» жёстко создаёт **7 вопросов** (см. `TestForm/index.tsx:123` — `llmApi.generate(title.trim(), 7)`), без выбора структуры.
|
||||||
|
- В fallback-сообщении ошибки упоминается «API ключ в настройках» (`TestForm/index.tsx:244`).
|
||||||
|
|
||||||
|
## 3. Что меняем
|
||||||
|
|
||||||
|
### 3.1. Три смысловых блока
|
||||||
|
|
||||||
|
| Блок | Содержит | Визуально |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| **Метаинформация** | название, описание, порог, таймер, навигация назад | `Card title="Метаинформация"` |
|
||||||
|
| **Содержание** | кнопка «Сгенерировать с AI», список вопросов, «+ Добавить вопрос» | `Card title="Содержание"` (вложенные карточки вопросов остаются) |
|
||||||
|
| **Команды** | «Создать тест», «Отмена», «Проверить тест» | блок `Space` снизу страницы |
|
||||||
|
|
||||||
|
Кнопка **«Проверить тест»** логически — это команда над всем тестом, поэтому переезжает в нижнюю панель команд. Кнопка **«Сгенерировать с AI»** становится первым элементом блока «Содержание» — у неё там естественное место (она формирует именно содержание).
|
||||||
|
|
||||||
|
### 3.2. Wireframe после редизайна
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ← Назад Создание теста │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Card «Метаинформация» │
|
||||||
|
│ • название │
|
||||||
|
│ • описание │
|
||||||
|
│ • порог зачёта │
|
||||||
|
│ • таймер │
|
||||||
|
│ • разрешить возврат │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Card «Содержание» │
|
||||||
|
│ ┌─ AI-генерация ────────────────────┐ │
|
||||||
|
│ │ тема: [_________________] │ │
|
||||||
|
│ │ вопросов: [7] вариантов: [3] │ │
|
||||||
|
│ │ [🤖 Сгенерировать] │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Card «Вопрос 1» ... │
|
||||||
|
│ Card «Вопрос N» ... │
|
||||||
|
│ [+ Добавить вопрос] │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ [Создать тест] [Проверить тест] [Отмена] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3. Форма AI-генерации с тремя полями
|
||||||
|
|
||||||
|
Внутри блока «Содержание» — компактный мини-блок (не модалка) с тремя полями:
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Лимиты |
|
||||||
|
|------|-----|--------------|--------|
|
||||||
|
| Тема | `Input` | значение `title` из метаинформации (auto-fill) | непустая |
|
||||||
|
| Количество вопросов | `InputNumber` | 7 | 1…30 |
|
||||||
|
| Количество вариантов на вопрос | `InputNumber` | 3 | 2…8 |
|
||||||
|
|
||||||
|
Кнопка **«Сгенерировать»** запускает текущий поток `handleGenerate` (модалка-превью → «Применить все вопросы»), но передаёт оба числа в API. Превью показывает то же, что и сейчас.
|
||||||
|
|
||||||
|
Поведение существующих кнопок «Улучшить» и «Дистракторы» внутри карточки вопроса **не меняется** — они и так локальные.
|
||||||
|
|
||||||
|
### 3.4. Уход от текста про API-ключи
|
||||||
|
|
||||||
|
Единственное место в форме, где упоминается ключ, — fallback-сообщение об ошибке:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// TestForm/index.tsx:244
|
||||||
|
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
|
||||||
|
```
|
||||||
|
|
||||||
|
Заменяем на нейтральное:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.')
|
||||||
|
```
|
||||||
|
|
||||||
|
Сама страница `Settings` остаётся как есть — это её прямое назначение, и её редактируют только администраторы.
|
||||||
|
|
||||||
|
## 4. План работ (чек-лист для исполнителя)
|
||||||
|
|
||||||
|
- [ ] **Backend** (опционально, если ещё не принимает параметры): расширить схему `LLMGenerateRequest` в `backend/app/schemas/llm.py` полями `questions_count: int = 7`, `answers_count: int = 3`; передать их в промпт сервиса `app/services/llm.py`.
|
||||||
|
- [ ] **API-клиент**: в `frontend/src/api/llm.ts` обновить сигнатуру `generate(topic, questionsCount, answersCount)`.
|
||||||
|
- [ ] **TestForm**: обернуть текущий блок «Основные настройки» в `Card title="Метаинформация"`.
|
||||||
|
- [ ] **TestForm**: создать новый `Card title="Содержание"`, в него поместить:
|
||||||
|
- мини-блок AI-генерации (3 поля + кнопка),
|
||||||
|
- текущий `Form.List` вопросов с кнопкой «+ Добавить вопрос».
|
||||||
|
- [ ] **TestForm**: убрать текущий ряд из двух AI-кнопок между мета и вопросами; «Проверить тест» перенести в нижнюю панель команд рядом с «Создать»/«Отмена».
|
||||||
|
- [ ] **TestForm**: добавить локальный стейт `aiQuestionsCount`, `aiAnswersCount`, инициализация: 7 / 3.
|
||||||
|
- [ ] **TestForm**: в `handleGenerate` передавать оба числа; при `Применить` создавать соответствующее число пустых ответов в каждом вопросе (если бэк прислал меньше — добить пустыми, если больше — обрезать).
|
||||||
|
- [ ] **TestForm**: заменить fallback-текст про API-ключ.
|
||||||
|
- [ ] Прогнать `docker compose up`, проверить вручную: создание с дефолтами, генерация на 5 вопросов × 4 ответа, проверка теста, отмена.
|
||||||
|
- [ ] Документ-шаг в `DOC/ШАГИ/ШАГ_<дата>_<NNN>.md` по факту выполнения.
|
||||||
|
|
||||||
|
## 5. Что **не** делаем в этой ветке
|
||||||
|
|
||||||
|
- Не трогаем форму редактирования (`TestEdit`) — формально использует тот же `TestForm`, но эффект редизайна нужно отдельно проверить с уже заполненными вопросами и опциями версионирования.
|
||||||
|
- Не меняем поведение `Form.List` валидации (минимум 7 вопросов / 3 варианта) — это отдельная история.
|
||||||
|
- Не вводим drag-and-drop переупорядочивание вопросов.
|
||||||
|
|
||||||
|
## 6. Открытые вопросы для согласования
|
||||||
|
|
||||||
|
1. Нужно ли визуально подсвечивать мини-блок генерации (фон, бордер), чтобы он отличался от карточек вопросов?
|
||||||
|
2. После применения сгенерированных вопросов — нужно ли скрывать мини-блок, чтобы он не путал?
|
||||||
|
3. Лимиты «1…30 вопросов / 2…8 вариантов» — устраивают или нужны другие?
|
||||||
@@ -16,6 +16,7 @@ class CheckResponse(BaseModel):
|
|||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
topic: str
|
topic: str
|
||||||
count: int = 7
|
count: int = 7
|
||||||
|
answers_count: int = 3
|
||||||
|
|
||||||
|
|
||||||
class GenerateResponse(BaseModel):
|
class GenerateResponse(BaseModel):
|
||||||
@@ -73,7 +74,9 @@ async def check_connection(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.post("/api/llm/generate", response_model=GenerateResponse)
|
@router.post("/api/llm/generate", response_model=GenerateResponse)
|
||||||
async def generate_questions(req: GenerateRequest, db: AsyncSession = Depends(get_db)):
|
async def generate_questions(req: GenerateRequest, db: AsyncSession = Depends(get_db)):
|
||||||
try:
|
try:
|
||||||
questions = await llm_service.generate_questions(db, req.topic, req.count)
|
questions = await llm_service.generate_questions(
|
||||||
|
db, req.topic, req.count, req.answers_count
|
||||||
|
)
|
||||||
return {"questions": questions}
|
return {"questions": questions}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ async def check_connection(db: AsyncSession) -> str:
|
|||||||
return response.choices[0].message.content.strip()
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
|
||||||
async def generate_questions(db: AsyncSession, topic: str, count: int = 7) -> list[dict]:
|
async def generate_questions(
|
||||||
|
db: AsyncSession,
|
||||||
|
topic: str,
|
||||||
|
count: int = 7,
|
||||||
|
answers_count: int = 3,
|
||||||
|
) -> list[dict]:
|
||||||
api_key = await _get_api_key(db)
|
api_key = await _get_api_key(db)
|
||||||
client = _client(api_key)
|
client = _client(api_key)
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@ async def generate_questions(db: AsyncSession, topic: str, count: int = 7) -> li
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
Требования:
|
Требования:
|
||||||
- Минимум 3 варианта ответа на каждый вопрос
|
- Ровно {answers_count} вариантов ответа на каждый вопрос
|
||||||
- Ровно один правильный ответ на каждый вопрос
|
- Ровно один правильный ответ на каждый вопрос
|
||||||
- Вопросы должны проверять практические знания по теме
|
- Вопросы должны проверять практические знания по теме
|
||||||
- Варианты ответов должны быть правдоподобными"""
|
- Варианты ответов должны быть правдоподобными"""
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ const llmApi = {
|
|||||||
check: () =>
|
check: () =>
|
||||||
axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data),
|
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
|
axios
|
||||||
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', { topic, count })
|
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', {
|
||||||
|
topic,
|
||||||
|
count,
|
||||||
|
answers_count: answersCount,
|
||||||
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
improve: (question: string, answers: string[]) =>
|
improve: (question: string, answers: string[]) =>
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
notification,
|
notification,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import llmApi, { LLMQuestion } from '../../api/llm'
|
import llmApi, { LLMQuestion } from '../../api/llm'
|
||||||
|
import { TestListItem } from '../../api/tests'
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
@@ -46,6 +50,11 @@ interface TestFormProps {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
backLabel?: string
|
backLabel?: string
|
||||||
|
versions?: TestListItem[]
|
||||||
|
currentVersionId?: number
|
||||||
|
onActivateVersion?: (id: number) => void
|
||||||
|
isActivating?: boolean
|
||||||
|
onOpenVersion?: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TestForm({
|
export default function TestForm({
|
||||||
@@ -57,6 +66,11 @@ export default function TestForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onBack,
|
onBack,
|
||||||
backLabel = 'Назад',
|
backLabel = 'Назад',
|
||||||
|
versions,
|
||||||
|
currentVersionId,
|
||||||
|
onActivateVersion,
|
||||||
|
isActivating,
|
||||||
|
onOpenVersion,
|
||||||
}: TestFormProps) {
|
}: TestFormProps) {
|
||||||
const [form] = Form.useForm<TestFormValues>()
|
const [form] = Form.useForm<TestFormValues>()
|
||||||
const [notifApi, contextHolder] = notification.useNotification()
|
const [notifApi, contextHolder] = notification.useNotification()
|
||||||
@@ -65,6 +79,9 @@ export default function TestForm({
|
|||||||
const [generateLoading, setGenerateLoading] = useState(false)
|
const [generateLoading, setGenerateLoading] = useState(false)
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
|
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
|
||||||
|
const [aiTopic, setAiTopic] = useState('')
|
||||||
|
const [aiQuestionsCount, setAiQuestionsCount] = useState(7)
|
||||||
|
const [aiAnswersCount, setAiAnswersCount] = useState(3)
|
||||||
|
|
||||||
// Improve modal state
|
// Improve modal state
|
||||||
const [improveState, setImproveState] = useState<{
|
const [improveState, setImproveState] = useState<{
|
||||||
@@ -114,13 +131,20 @@ export default function TestForm({
|
|||||||
// ── AI: Генерация вопросов ──────────────────────────────────────────────
|
// ── AI: Генерация вопросов ──────────────────────────────────────────────
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
const title = form.getFieldValue('title') as string
|
const titleField = (form.getFieldValue('title') as string) || ''
|
||||||
if (!title?.trim()) return
|
const topic = (aiTopic.trim() || titleField.trim())
|
||||||
|
if (!topic) {
|
||||||
|
notifApi.warning({
|
||||||
|
message: 'Укажите тему',
|
||||||
|
description: 'Заполните поле «Тема» или название теста в метаинформации',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
setGenerateLoading(true)
|
setGenerateLoading(true)
|
||||||
setPreviewQuestions(null)
|
setPreviewQuestions(null)
|
||||||
setPreviewOpen(true)
|
setPreviewOpen(true)
|
||||||
try {
|
try {
|
||||||
const data = await llmApi.generate(title.trim(), 7)
|
const data = await llmApi.generate(topic, aiQuestionsCount, aiAnswersCount)
|
||||||
setPreviewQuestions(data.questions)
|
setPreviewQuestions(data.questions)
|
||||||
} catch {
|
} catch {
|
||||||
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
|
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
|
||||||
@@ -241,7 +265,7 @@ export default function TestForm({
|
|||||||
const data = await llmApi.review(values.title, values.questions || [])
|
const data = await llmApi.review(values.title, values.questions || [])
|
||||||
setReviewText(data.review)
|
setReviewText(data.review)
|
||||||
} catch {
|
} catch {
|
||||||
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
|
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.')
|
||||||
} finally {
|
} finally {
|
||||||
setReviewLoading(false)
|
setReviewLoading(false)
|
||||||
}
|
}
|
||||||
@@ -306,8 +330,8 @@ export default function TestForm({
|
|||||||
<Title level={2}>{heading}</Title>
|
<Title level={2}>{heading}</Title>
|
||||||
|
|
||||||
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}>
|
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}>
|
||||||
{/* ── Основные настройки ── */}
|
{/* ── Метаинформация ── */}
|
||||||
<Card title="Основные настройки" style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="title"
|
name="title"
|
||||||
label="Название теста"
|
label="Название теста"
|
||||||
@@ -363,33 +387,150 @@ export default function TestForm({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── AI: кнопки ── */}
|
{/* ── Версии теста ── */}
|
||||||
<Form.Item
|
{versions && versions.length > 0 && (
|
||||||
noStyle
|
<Card title="Версии теста" style={{ marginBottom: 16 }}>
|
||||||
shouldUpdate={(prev, cur) => prev.title !== cur.title}
|
<Table<TestListItem>
|
||||||
>
|
dataSource={versions}
|
||||||
{({ getFieldValue }) => (
|
rowKey="id"
|
||||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
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
|
<Button
|
||||||
|
type="primary"
|
||||||
icon={<RobotOutlined />}
|
icon={<RobotOutlined />}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
loading={generateLoading}
|
loading={generateLoading}
|
||||||
disabled={!getFieldValue('title')?.trim()}
|
|
||||||
>
|
>
|
||||||
Сгенерировать с AI
|
Сгенерировать
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</Space>
|
||||||
icon={<StarOutlined />}
|
</div>
|
||||||
onClick={handleReview}
|
|
||||||
>
|
|
||||||
Проверить тест
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* ── Вопросы ── */}
|
{/* ── Вопросы ── */}
|
||||||
<Form.List
|
<Form.List
|
||||||
name="questions"
|
name="questions"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
@@ -531,13 +672,18 @@ export default function TestForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Команды ── */}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit" loading={isPending}>
|
<Button type="primary" htmlType="submit" loading={isPending}>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button icon={<StarOutlined />} onClick={handleReview}>
|
||||||
|
Проверить тест
|
||||||
|
</Button>
|
||||||
<Button onClick={onCancel}>Отмена</Button>
|
<Button onClick={onCancel}>Отмена</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export default function TestEdit() {
|
|||||||
const { data: versions = [] } = useQuery({
|
const { data: versions = [] } = useQuery({
|
||||||
queryKey: ['tests', id, 'versions'],
|
queryKey: ['tests', id, 'versions'],
|
||||||
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data),
|
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data),
|
||||||
enabled: !editMode,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { mutate: activateVersion, isPending: isActivating } = useMutation({
|
const { mutate: activateVersion, isPending: isActivating } = useMutation({
|
||||||
@@ -105,6 +104,11 @@ export default function TestEdit() {
|
|||||||
onCancel={() => setEditMode(false)}
|
onCancel={() => setEditMode(false)}
|
||||||
onBack={() => setEditMode(false)}
|
onBack={() => setEditMode(false)}
|
||||||
backLabel="К просмотру теста"
|
backLabel="К просмотру теста"
|
||||||
|
versions={versions}
|
||||||
|
currentVersionId={test.id}
|
||||||
|
onActivateVersion={activateVersion}
|
||||||
|
isActivating={isActivating}
|
||||||
|
onOpenVersion={(vid) => navigate(`/tests/${vid}/edit`)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user