Redesign test editor: meta, content, AI shape, command bar
- Split draft editor into AccSection Метаинформация and Содержание - AI generation: topic, question count (1–30), answers per question (2–8) - Move save and back-to-list to bottom command panel; remove AI from hero - Normalize generated options to requested count; sync ai topic on import draft - Add DOC/ШАГИ/ШАГ_2026-04-27_001.md and track design proposal doc Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
# Шаг 2026-04-27 — редизайн формы редактора теста (ветка `dev-redisign`)
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
|
||||||
|
- Создана ветка `dev-redisign` от `dev` в репозитории `TestingWebApp`.
|
||||||
|
- Страница автора `frontend/src/pages/TestDetail.jsx` приведена к структуре из `docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md` (адаптация под существующий React/JSX, без Ant Design):
|
||||||
|
- блок **«Метаинформация»** — название, описание, порог зачёта;
|
||||||
|
- блок **«Содержание»** — мини-панель ИИ (тема, число вопросов 1…30, число вариантов 2…8, кнопка генерации) и список вопросов с локальными кнопками ИИ;
|
||||||
|
- панель **«Команды»** — «Сохранить черновик» (основная), «К списку»; строка статуса черновика под панелью.
|
||||||
|
- Кнопка **«Сгенерировать тест (ИИ)»** убрана из шапки; генерация строит `shape` из введённых чисел, тема — из поля «Тема» с запасным вариантом на «Название»; после ответа API варианты в каждом вопросе нормализуются к выбранному числу (добор/обрезка, минимум один верный).
|
||||||
|
- Копирование темы при загрузке редактора и при применении импорта/черновика LLM (`setAiGenTopic` при `applyGeneratedDraft`).
|
||||||
|
|
||||||
|
## Бэкенд
|
||||||
|
|
||||||
|
- Менять не требовалось: `POST .../ai/generate-test` уже принимает `shape` с `optionsCount` (см. `backend/src/services/aiEditorService.js`).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `npm run lint` и `npm run build` в `TestingWebApp/frontend` — без ошибок.
|
||||||
|
- Ручной прогон `docker compose` по чек-листу из предложения — остаётся на стороне исполнителя.
|
||||||
@@ -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 вариантов» — устраивают или нужны другие?
|
||||||
+321
-214
@@ -85,6 +85,10 @@ export default function TestDetail() {
|
|||||||
const [importBusy, setImportBusy] = useState(false);
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
const [aiTestBusy, setAiTestBusy] = useState(false);
|
const [aiTestBusy, setAiTestBusy] = useState(false);
|
||||||
const [aiQBusy, setAiQBusy] = useState(null);
|
const [aiQBusy, setAiQBusy] = useState(null);
|
||||||
|
/** Параметры блока «Сгенерировать тест (ИИ)» (редизайн формы редактора) */
|
||||||
|
const [aiGenTopic, setAiGenTopic] = useState('');
|
||||||
|
const [aiQuestionsCount, setAiQuestionsCount] = useState(7);
|
||||||
|
const [aiAnswersCount, setAiAnswersCount] = useState(3);
|
||||||
const [assignSearch, setAssignSearch] = useState('');
|
const [assignSearch, setAssignSearch] = useState('');
|
||||||
const [assignSearchApplied, setAssignSearchApplied] = useState('');
|
const [assignSearchApplied, setAssignSearchApplied] = useState('');
|
||||||
const [assignDept, setAssignDept] = useState('__all__');
|
const [assignDept, setAssignDept] = useState('__all__');
|
||||||
@@ -119,6 +123,7 @@ export default function TestDetail() {
|
|||||||
setChain(c);
|
setChain(c);
|
||||||
if (ed?.test) {
|
if (ed?.test) {
|
||||||
setDraftTitle(ed.test.title || '');
|
setDraftTitle(ed.test.title || '');
|
||||||
|
setAiGenTopic((ed.test.title || '').trim());
|
||||||
setDraftDescription(ed.test.description || '');
|
setDraftDescription(ed.test.description || '');
|
||||||
const th = ed.test.passingThreshold;
|
const th = ed.test.passingThreshold;
|
||||||
setDraftPassing(
|
setDraftPassing(
|
||||||
@@ -300,21 +305,41 @@ export default function TestDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeGeneratedQuestionOptions(q, targetCount) {
|
||||||
|
const n = Math.min(12, Math.max(2, targetCount));
|
||||||
|
const raw = (q?.options || []).map((o) => ({
|
||||||
|
key: newKey(),
|
||||||
|
text: (o.text || '').trim() || 'Вариант',
|
||||||
|
isCorrect: !!o.isCorrect,
|
||||||
|
}));
|
||||||
|
const out = raw.slice(0, n);
|
||||||
|
while (out.length < n) {
|
||||||
|
out.push({ key: newKey(), text: '', isCorrect: false });
|
||||||
|
}
|
||||||
|
if (!out.some((o) => o.isCorrect)) {
|
||||||
|
out[0] = { ...out[0], isCorrect: true };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function runAiGenerateTest() {
|
async function runAiGenerateTest() {
|
||||||
if (aiTestBusy || !id) {
|
if (aiTestBusy || !id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nQ = Math.min(30, Math.max(1, Math.floor(Number(aiQuestionsCount)) || 7));
|
||||||
|
const nA = Math.min(8, Math.max(2, Math.floor(Number(aiAnswersCount)) || 3));
|
||||||
|
const topic = (aiGenTopic || draftTitle || '').trim() || 'Тест';
|
||||||
setDraftStatus('');
|
setDraftStatus('');
|
||||||
setAiTestBusy(true);
|
setAiTestBusy(true);
|
||||||
try {
|
try {
|
||||||
const shape = draftQuestions.map((q) => ({
|
const shape = Array.from({ length: nQ }, () => ({
|
||||||
optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)),
|
optionsCount: nA,
|
||||||
hasMultipleAnswers: q.hasMultipleAnswers,
|
hasMultipleAnswers: false,
|
||||||
}));
|
}));
|
||||||
const out = await api(`/api/tests/${id}/ai/generate-test`, {
|
const out = await api(`/api/tests/${id}/ai/generate-test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
testTitle: draftTitle,
|
testTitle: topic,
|
||||||
testDescription: draftDescription,
|
testDescription: draftDescription,
|
||||||
shape,
|
shape,
|
||||||
}),
|
}),
|
||||||
@@ -330,11 +355,7 @@ export default function TestDetail() {
|
|||||||
key: newKey(),
|
key: newKey(),
|
||||||
text: (q.text || '').trim() || 'Вопрос',
|
text: (q.text || '').trim() || 'Вопрос',
|
||||||
hasMultipleAnswers: !!q.hasMultipleAnswers,
|
hasMultipleAnswers: !!q.hasMultipleAnswers,
|
||||||
options: (q.options || []).map((o) => ({
|
options: normalizeGeneratedQuestionOptions(q, nA),
|
||||||
key: newKey(),
|
|
||||||
text: (o.text || '').trim() || 'Вариант',
|
|
||||||
isCorrect: !!o.isCorrect,
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
if (qs.length) {
|
if (qs.length) {
|
||||||
setDraftQuestions(qs);
|
setDraftQuestions(qs);
|
||||||
@@ -449,6 +470,7 @@ export default function TestDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraftTitle((d.title || '').trim() || 'Без названия');
|
setDraftTitle((d.title || '').trim() || 'Без названия');
|
||||||
|
setAiGenTopic((d.title || '').trim());
|
||||||
setDraftDescription(
|
setDraftDescription(
|
||||||
d.description != null && String(d.description).trim() ? String(d.description).trim() : ''
|
d.description != null && String(d.description).trim() ? String(d.description).trim() : ''
|
||||||
);
|
);
|
||||||
@@ -615,21 +637,6 @@ export default function TestDetail() {
|
|||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem', fontSize: 14 }}>
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem', fontSize: 14 }}>
|
||||||
{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}
|
{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-actions" style={{ marginBottom: '0.65rem', flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
disabled={aiTestBusy}
|
|
||||||
onClick={runAiGenerateTest}
|
|
||||||
>
|
|
||||||
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{test?.description && (
|
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
|
||||||
{test.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="muted" style={{ marginBottom: 0 }}>
|
<p className="muted" style={{ marginBottom: 0 }}>
|
||||||
Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)}
|
Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)}
|
||||||
{test?.chainActive === false && (
|
{test?.chainActive === false && (
|
||||||
@@ -657,6 +664,296 @@ export default function TestDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccSection title="Метаинформация" defaultOpen>
|
||||||
|
<div className="draft-block">
|
||||||
|
<label className="form-label" htmlFor="draft-title">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="draft-title"
|
||||||
|
className="form-input"
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(e) => setDraftTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="form-label" htmlFor="draft-desc" style={{ marginTop: '0.75rem' }}>
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="draft-desc"
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={draftDescription}
|
||||||
|
onChange={(e) => setDraftDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
||||||
|
Порог зачёта, %
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="draft-pass"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draftPassing}
|
||||||
|
onChange={(e) => setDraftPassing(e.target.value)}
|
||||||
|
style={{ maxWidth: 120 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccSection>
|
||||||
|
|
||||||
|
<AccSection title="Содержание" defaultOpen>
|
||||||
|
<div className="draft-block">
|
||||||
|
<div
|
||||||
|
className="surface-card"
|
||||||
|
style={{
|
||||||
|
padding: '1rem 1.1rem',
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
borderLeft: '3px solid color-mix(in srgb, var(--primary) 45%, transparent)',
|
||||||
|
}}
|
||||||
|
aria-label="Параметры генерации теста с ИИ"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="font-headline"
|
||||||
|
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
|
||||||
|
>
|
||||||
|
Генерация сетки вопросов (ИИ)
|
||||||
|
</p>
|
||||||
|
<label className="form-label" htmlFor="ai-gen-topic">
|
||||||
|
Тема
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-gen-topic"
|
||||||
|
className="form-input"
|
||||||
|
value={aiGenTopic}
|
||||||
|
onChange={(e) => setAiGenTopic(e.target.value)}
|
||||||
|
placeholder="По умолчанию — как в «Название»; при необходимости уточните"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="inline-actions"
|
||||||
|
style={{ marginTop: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end', gap: '0.9rem' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ai-n-questions" style={{ display: 'block' }}>
|
||||||
|
Вопросов
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-n-questions"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={aiQuestionsCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiQuestionsCount(Math.min(30, Math.max(1, n)));
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: 90 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ai-n-answers" style={{ display: 'block' }}>
|
||||||
|
Вариантов
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-n-answers"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={2}
|
||||||
|
max={8}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={aiAnswersCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiAnswersCount(Math.min(8, Math.max(2, n)));
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: 90 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ marginTop: '0.1rem' }}
|
||||||
|
disabled={aiTestBusy}
|
||||||
|
onClick={runAiGenerateTest}
|
||||||
|
>
|
||||||
|
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draftQuestions.map((q, qi) => (
|
||||||
|
<div
|
||||||
|
key={q.key}
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
|
||||||
|
marginTop: '1rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="inline-actions"
|
||||||
|
style={{ marginBottom: 6, flexWrap: 'wrap', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<label className="form-label" htmlFor={`qtext-${q.key}`} style={{ marginBottom: 0 }}>
|
||||||
|
Вопрос {qi + 1}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn--sm"
|
||||||
|
disabled={aiQBusy != null}
|
||||||
|
onClick={() => runAiGenerateQuestion(qi)}
|
||||||
|
>
|
||||||
|
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id={`qtext-${q.key}`}
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={q.text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setDraftQuestions((prev) =>
|
||||||
|
prev.map((row, i) => (i === qi ? { ...row, text: v } : row))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-label"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={q.hasMultipleAnswers}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setDraftQuestions((prev) =>
|
||||||
|
prev.map((row, i) => (i === qi ? { ...row, hasMultipleAnswers: v } : row))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Несколько верных ответов
|
||||||
|
</label>
|
||||||
|
<p className="muted" style={{ marginBottom: 6, fontSize: 13 }}>
|
||||||
|
Варианты
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{q.options.map((o, oi) => (
|
||||||
|
<li
|
||||||
|
key={o.key}
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={o.isCorrect}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setDraftQuestions((prev) =>
|
||||||
|
prev.map((row, i) => {
|
||||||
|
if (i !== qi) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
if (!row.hasMultipleAnswers && v) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
options: row.options.map((op, j) => ({
|
||||||
|
...op,
|
||||||
|
isCorrect: j === oi,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const options = row.options.map((op, j) =>
|
||||||
|
j === oi ? { ...op, isCorrect: v } : op
|
||||||
|
);
|
||||||
|
return { ...row, options };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
title="Верный ответ"
|
||||||
|
aria-label="Пометить как верный"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: '1 1 200px' }}
|
||||||
|
value={o.text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setDraftQuestions((prev) =>
|
||||||
|
prev.map((row, i) => {
|
||||||
|
if (i !== qi) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
options: row.options.map((op, j) => (j === oi ? { ...op, text: v } : op)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="Текст варианта"
|
||||||
|
/>
|
||||||
|
{q.options.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn--sm"
|
||||||
|
onClick={() => removeOption(qi, oi)}
|
||||||
|
>
|
||||||
|
убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="inline-actions" style={{ marginTop: 4 }}>
|
||||||
|
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}>
|
||||||
|
+ вариант
|
||||||
|
</button>
|
||||||
|
{draftQuestions.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn--sm"
|
||||||
|
onClick={() => removeQuestion(qi)}
|
||||||
|
>
|
||||||
|
Удалить вопрос
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={addQuestion}>
|
||||||
|
+ вопрос
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccSection>
|
||||||
|
|
||||||
|
<div className="cabinet-brick" style={{ marginTop: 0 }}>
|
||||||
|
<div
|
||||||
|
className="inline-actions"
|
||||||
|
style={{ flexWrap: 'wrap', gap: '0.5rem', marginBottom: draftStatus ? '0.35rem' : 0 }}
|
||||||
|
>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={saveDraft}>
|
||||||
|
Сохранить черновик
|
||||||
|
</button>
|
||||||
|
<Link to="/tests" className="btn btn-ghost">
|
||||||
|
К списку
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{draftStatus && (
|
||||||
|
<p className="muted" style={{ marginTop: 0, marginBottom: 0 }}>
|
||||||
|
{draftStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<AccSection title="История версий" defaultOpen={false}>
|
<AccSection title="История версий" defaultOpen={false}>
|
||||||
<div className="surface-card" style={{ padding: 0, overflow: 'hidden' }}>
|
<div className="surface-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
<table className="table-cabinet">
|
<table className="table-cabinet">
|
||||||
@@ -853,196 +1150,6 @@ export default function TestDetail() {
|
|||||||
)}
|
)}
|
||||||
</AccSection>
|
</AccSection>
|
||||||
|
|
||||||
<AccSection title="Содержание: название, порог, вопросы" defaultOpen>
|
|
||||||
<div className="draft-block">
|
|
||||||
<label className="form-label" htmlFor="draft-title">
|
|
||||||
Название
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="draft-title"
|
|
||||||
className="form-input"
|
|
||||||
value={draftTitle}
|
|
||||||
onChange={(e) => setDraftTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="form-label" htmlFor="draft-desc" style={{ marginTop: '0.75rem' }}>
|
|
||||||
Описание
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="draft-desc"
|
|
||||||
className="form-input"
|
|
||||||
rows={2}
|
|
||||||
value={draftDescription}
|
|
||||||
onChange={(e) => setDraftDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
|
||||||
Порог зачёта, %
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="draft-pass"
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
inputMode="numeric"
|
|
||||||
value={draftPassing}
|
|
||||||
onChange={(e) => setDraftPassing(e.target.value)}
|
|
||||||
style={{ maxWidth: 120 }}
|
|
||||||
/>
|
|
||||||
{draftQuestions.map((q, qi) => (
|
|
||||||
<div
|
|
||||||
key={q.key}
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
|
|
||||||
marginTop: '1rem',
|
|
||||||
paddingTop: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="inline-actions"
|
|
||||||
style={{ marginBottom: 6, flexWrap: 'wrap', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<label className="form-label" htmlFor={`qtext-${q.key}`} style={{ marginBottom: 0 }}>
|
|
||||||
Вопрос {qi + 1}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn--sm"
|
|
||||||
disabled={aiQBusy != null}
|
|
||||||
onClick={() => runAiGenerateQuestion(qi)}
|
|
||||||
>
|
|
||||||
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
id={`qtext-${q.key}`}
|
|
||||||
className="form-input"
|
|
||||||
rows={2}
|
|
||||||
value={q.text}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setDraftQuestions((prev) =>
|
|
||||||
prev.map((row, i) => (i === qi ? { ...row, text: v } : row))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
className="form-label"
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={q.hasMultipleAnswers}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.checked;
|
|
||||||
setDraftQuestions((prev) =>
|
|
||||||
prev.map((row, i) => (i === qi ? { ...row, hasMultipleAnswers: v } : row))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Несколько верных ответов
|
|
||||||
</label>
|
|
||||||
<p className="muted" style={{ marginBottom: 6, fontSize: 13 }}>
|
|
||||||
Варианты
|
|
||||||
</p>
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|
||||||
{q.options.map((o, oi) => (
|
|
||||||
<li
|
|
||||||
key={o.key}
|
|
||||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={o.isCorrect}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.checked;
|
|
||||||
setDraftQuestions((prev) =>
|
|
||||||
prev.map((row, i) => {
|
|
||||||
if (i !== qi) {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
if (!row.hasMultipleAnswers && v) {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
options: row.options.map((op, j) => ({
|
|
||||||
...op,
|
|
||||||
isCorrect: j === oi,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const options = row.options.map((op, j) =>
|
|
||||||
j === oi ? { ...op, isCorrect: v } : op
|
|
||||||
);
|
|
||||||
return { ...row, options };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
title="Верный ответ"
|
|
||||||
aria-label="Пометить как верный"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
style={{ flex: '1 1 200px' }}
|
|
||||||
value={o.text}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setDraftQuestions((prev) =>
|
|
||||||
prev.map((row, i) => {
|
|
||||||
if (i !== qi) {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
options: row.options.map((op, j) => (j === oi ? { ...op, text: v } : op)),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
placeholder="Текст варианта"
|
|
||||||
/>
|
|
||||||
{q.options.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn--sm"
|
|
||||||
onClick={() => removeOption(qi, oi)}
|
|
||||||
>
|
|
||||||
убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="inline-actions" style={{ marginTop: 4 }}>
|
|
||||||
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}>
|
|
||||||
+ вариант
|
|
||||||
</button>
|
|
||||||
{draftQuestions.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn--sm"
|
|
||||||
onClick={() => removeQuestion(qi)}
|
|
||||||
>
|
|
||||||
Удалить вопрос
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={addQuestion}>
|
|
||||||
+ вопрос
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={saveDraft}>
|
|
||||||
Сохранить черновик
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{draftStatus && (
|
|
||||||
<p className="muted" style={{ marginTop: '0.5rem' }}>
|
|
||||||
{draftStatus}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AccSection>
|
|
||||||
|
|
||||||
{assignmentUi && data && (
|
{assignmentUi && data && (
|
||||||
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
||||||
{assignErr && (
|
{assignErr && (
|
||||||
|
|||||||
Reference in New Issue
Block a user