Files
TestingWebApp/flask_app/app/static/js/editor.js
T
Константин Лебединский db9851eeda bugfix
2026-04-30 14:11:15 +05:00

1590 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Редактор теста: рабочий минимум.
* Работает с эндпоинтами /api/tests/<id>/{draft, ai/generate-test, ai/generate-question}
* и /api/tests/<id> (PATCH chainActive).
*
* Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop)
* запланирована отдельным спринтом E1.7.
*/
(() => {
'use strict';
const root = document.getElementById('editor-root');
if (!root) return;
const TEST_ID = root.dataset.testId;
const initial = JSON.parse(root.dataset.initial);
const $ = (sel, parent = document) => parent.querySelector(sel);
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
const MAX_OPTIONS = 8;
const titleEl = $('#test-title');
const descEl = $('#test-description');
const thresholdEl = $('#test-threshold');
const questionsEl = $('#questions');
const qCountEl = $('#q-count');
const saveStatusEl = $('#save-status');
const aiStatusEl = $('#ai-status');
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
const chainActiveDisplay = $('#chain-active-display');
const aiTopicEl = $('#ai-topic');
const aiQCountEl = $('#ai-q-count');
const aiOCountEl = $('#ai-o-count');
const templateGlobalMultiEl = $('#template-global-multi');
const templateMinCorrectEl = $('#template-min-correct');
const templateMaxCorrectEl = $('#template-max-correct');
const hintsStatusEl = $('#hints-status');
const hintsActionsEl = $('#test-hints-actions');
const generateHintsBtn = $('#btn-generate-hints');
const docProgressEl = $('#doc-progress');
const introUpdatedEl = $('#intro-updated');
const introForkBannerEl = $('#intro-fork-banner');
const versionsListEl = $('#versions-list');
const attemptsListEl = $('#attempts-list');
const visibilityBtn = $('#btn-toggle-visibility');
const assignSearchEl = $('#assign-search');
const assignDeptEl = $('#assign-dept');
const assignClinicEl = $('#assign-clinic');
const assignListEl = $('#assign-list');
const assignSelectAllBtn = $('#assign-select-all');
const assignSubmitBtn = $('#assign-submit');
const assignStatusEl = $('#assign-status');
const tplQ = $('#tpl-question');
const tplO = $('#tpl-option');
let chainActive = true;
let assignPeople = [];
let assignSelected = new Set();
let hasAnyAttempts = false;
let hasForkRisk = Boolean(initial?.test?.hasForkRisk)
|| (introForkBannerEl && introForkBannerEl.dataset.forkRisk === '1');
let baselineDraftKey = '';
let dirtyCheckQueued = false;
function getTemplateCorrectRange(optionsCount, hasMultipleAnswers) {
const maxOpt = Math.max(2, Number(optionsCount || 2));
const rawMin = Math.max(1, Number(templateMinCorrectEl?.value || 1) || 1);
const rawMax = Math.max(1, Number(templateMaxCorrectEl?.value || 1) || 1);
if (!hasMultipleAnswers) return { minCorrect: 1, maxCorrect: 1 };
const minCorrect = Math.min(maxOpt, rawMin);
const maxCorrect = Math.max(minCorrect, Math.min(maxOpt, rawMax));
return { minCorrect, maxCorrect };
}
function syncTemplateRangeUi() {
const hasMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const maxOpt = Math.min(MAX_OPTIONS, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const range = getTemplateCorrectRange(maxOpt, hasMulti);
if (templateMinCorrectEl) {
templateMinCorrectEl.min = '1';
templateMinCorrectEl.max = String(maxOpt);
templateMinCorrectEl.value = String(range.minCorrect);
templateMinCorrectEl.disabled = !hasMulti;
}
if (templateMaxCorrectEl) {
templateMaxCorrectEl.min = '1';
templateMaxCorrectEl.max = String(maxOpt);
templateMaxCorrectEl.value = String(range.maxCorrect);
templateMaxCorrectEl.disabled = !hasMulti;
}
}
function currentDraftKey() {
return JSON.stringify(collectPayload());
}
function isDirty() {
return baselineDraftKey !== '' && baselineDraftKey !== currentDraftKey();
}
function updateForkBanner() {
if (!introForkBannerEl) return;
introForkBannerEl.style.display = (hasForkRisk && isDirty()) ? '' : 'none';
}
function scheduleDirtyCheck() {
if (dirtyCheckQueued) return;
dirtyCheckQueued = true;
requestAnimationFrame(() => {
dirtyCheckQueued = false;
updateForkBanner();
});
}
function resetBaselineDraft() {
baselineDraftKey = currentDraftKey();
updateForkBanner();
}
// ─── render ─────────────────────────────────────────────────────────
function syncOptionInputTypes(qNode) {
const isMulti = $('.q-multi', qNode).checked;
const qName = `q-correct-${Math.random().toString(36).slice(2)}`;
$$('.opt-correct', qNode).forEach((input) => {
input.type = isMulti ? 'checkbox' : 'radio';
if (isMulti) input.removeAttribute('name');
else input.setAttribute('name', qName);
input.classList.add('question-option-row__mark');
});
}
function renderQuestion(q) {
const node = tplQ.content.firstElementChild.cloneNode(true);
node._q = { id: q.id || null };
$('.q-text', node).value = q.text || '';
$('.q-multi', node).checked = !!q.hasMultipleAnswers;
const optsEl = $('.q-options', node);
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o, node)));
bindQuestionEvents(node);
syncOptionInputTypes(node);
updateOptionsCounter(node);
updateAiButtonLabel(node);
const hintEl = $('.q-hint', node);
if (hintEl) {
hintEl.value = q.aiHint || '';
const rh = () => autoResize(hintEl);
hintEl.addEventListener('input', () => { rh(); scheduleDirtyCheck(); });
requestAnimationFrame(rh);
}
return node;
}
function renderOption(o, qNode) {
const node = tplO.content.firstElementChild.cloneNode(true);
const textEl = $('.opt-text', node);
textEl.value = o.text || '';
$('.opt-correct', node).checked = !!o.isCorrect;
if (textEl && textEl.tagName === 'TEXTAREA') {
const resize = () => autoResize(textEl);
textEl.addEventListener('input', resize);
requestAnimationFrame(resize);
}
$('.opt-delete', node).addEventListener('click', () => {
node.remove();
if (qNode) updateOptionsCounter(qNode);
scheduleDirtyCheck();
});
return node;
}
let dragSrc = null;
function bindDragEvents(node) {
const handle = $('.q-drag', node);
if (handle) {
handle.addEventListener('mousedown', () => { node.draggable = true; });
handle.addEventListener('mouseup', () => { node.draggable = true; });
}
node.addEventListener('dragstart', (e) => {
dragSrc = node;
node.classList.add('q-dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); } catch {}
});
node.addEventListener('dragend', () => {
node.classList.remove('q-dragging');
$$('#questions .q-item').forEach((li) => li.classList.remove('q-drop-before', 'q-drop-after'));
dragSrc = null;
renumber();
scheduleDirtyCheck();
});
node.addEventListener('dragover', (e) => {
if (!dragSrc || dragSrc === node) return;
e.preventDefault();
const rect = node.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
node.classList.toggle('q-drop-before', before);
node.classList.toggle('q-drop-after', !before);
});
node.addEventListener('dragleave', () => {
node.classList.remove('q-drop-before', 'q-drop-after');
});
node.addEventListener('drop', (e) => {
if (!dragSrc || dragSrc === node) return;
e.preventDefault();
const rect = node.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
node.classList.remove('q-drop-before', 'q-drop-after');
node.parentNode.insertBefore(dragSrc, before ? node : node.nextSibling);
});
}
function bindQuestionEvents(node) {
bindDragEvents(node);
$('.q-delete', node).addEventListener('click', () => {
markQuestionRemoved(node);
});
$('.q-up', node).addEventListener('click', () => {
if (node.previousElementSibling) {
node.parentNode.insertBefore(node, node.previousElementSibling);
renumber();
scheduleDirtyCheck();
}
});
$('.q-down', node).addEventListener('click', () => {
if (node.nextElementSibling) {
node.parentNode.insertBefore(node.nextElementSibling, node);
renumber();
scheduleDirtyCheck();
}
});
const addOptBtn = $('.q-add-option', node);
addOptBtn.addEventListener('click', () => {
const optsEl = $('.q-options', node);
const count = $$('.opt-item', node).length;
if (count >= MAX_OPTIONS) return;
optsEl.appendChild(renderOption({ text: '', isCorrect: false }, node));
syncOptionInputTypes(node);
updateOptionsCounter(node);
scheduleDirtyCheck();
});
// Кнопка очистки вопроса
const clearBtn = $('.q-clear', node);
if (clearBtn) {
clearBtn.addEventListener('click', () => {
const qTextEl = $('.q-text', node);
qTextEl.value = '';
autoResize(qTextEl);
const qh = $('.q-hint', node);
if (qh) { qh.value = ''; autoResize(qh); }
$$('.opt-text', node).forEach((t) => { t.value = ''; autoResize(t); });
$$('.opt-correct', node).forEach((c) => { c.checked = false; });
updateAiButtonLabel(node);
scheduleDirtyCheck();
});
}
// Умная кнопка AI — label зависит от наличия текста
const qTextEl2 = $('.q-text', node);
if (qTextEl2) {
qTextEl2.addEventListener('input', () => updateAiButtonLabel(node));
}
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
$('.q-multi', node).addEventListener('change', () => {
syncOptionInputTypes(node);
scheduleDirtyCheck();
});
// Счётчик символов у textarea вопроса
const qTextEl = $('.q-text', node);
const qCounter = $('.q-text-counter', node);
if (qTextEl && qCounter) {
const updateCounter = () => {
const len = qTextEl.value.length;
const max = parseInt(qTextEl.getAttribute('maxlength') || '500', 10);
qCounter.textContent = len > 200 ? `${len}/${max}` : '';
qCounter.style.color = len > 450 ? '#ef4444' : len > 350 ? '#f59e0b' : '';
autoResize(qTextEl);
};
qTextEl.addEventListener('input', () => { updateCounter(); scheduleDirtyCheck(); });
requestAnimationFrame(updateCounter);
}
}
function updateAiButtonLabel(node) {
const qText = $('.q-text', node);
const label = $('.q-ai-label', node);
if (!qText || !label) return;
const hasText = qText.value.trim().length > 0;
label.textContent = hasText ? 'Улучшить' : 'Сгенерировать';
}
function updateOptionsCounter(node) {
const count = $$('.opt-item', node).length;
const countEl = $('.q-options-count', node);
const addBtn = $('.q-add-option', node);
const labelEl = $('.q-add-option-label', node);
if (countEl) countEl.textContent = count > 0 ? `${count}/${MAX_OPTIONS}` : '';
if (addBtn) {
const atMax = count >= MAX_OPTIONS;
addBtn.disabled = atMax;
addBtn.style.opacity = atMax ? '0.4' : '';
if (labelEl) labelEl.textContent = atMax ? 'Лимит вариантов' : 'Добавить вариант';
}
}
function renumber() {
let i = 0;
$$('#questions .q-item').forEach((li) => {
const removed = li.classList.contains('q-removed');
if (removed) {
$$('.q-num', li).forEach((el) => { el.textContent = 'Удалён'; });
return;
}
i += 1;
$$('.q-num', li).forEach((el) => { el.textContent = `Вопрос #${i}`; });
});
if (qCountEl) qCountEl.textContent = i;
const mirror = document.getElementById('q-count-mirror');
if (mirror) mirror.textContent = i;
}
function markQuestionRemoved(node) {
if (node.classList.contains('q-removed')) return;
node.classList.add('q-removed');
node.draggable = false;
let banner = $('.q-removed-banner', node);
if (!banner) {
banner = document.createElement('div');
banner.className = 'q-removed-banner';
banner.innerHTML =
'<span>Вопрос будет удалён при сохранении</span>' +
'<button type="button" class="q-restore btn btn-ghost btn--sm">Отменить</button>';
node.prepend(banner);
$('.q-restore', banner).addEventListener('click', () => restoreQuestion(node));
}
renumber();
scheduleDirtyCheck();
}
function restoreQuestion(node) {
node.classList.remove('q-removed');
node.draggable = true;
const banner = $('.q-removed-banner', node);
if (banner) banner.remove();
renumber();
scheduleDirtyCheck();
}
function autoResize(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
function syncThresholdMirror() {
const m = document.getElementById('threshold-mirror');
if (!m) return;
const v = (thresholdEl && thresholdEl.value !== '') ? thresholdEl.value : '—';
m.textContent = v;
}
function loadInitial() {
titleEl.value = initial.test.title || '';
descEl.value = initial.test.description || '';
autoResize(titleEl);
autoResize(descEl);
if (thresholdEl) {
thresholdEl.addEventListener('input', syncThresholdMirror);
thresholdEl.addEventListener('change', syncThresholdMirror);
}
if (titleEl && titleEl.tagName === 'TEXTAREA') {
titleEl.addEventListener('input', () => {
autoResize(titleEl);
// Синхронизируем поле темы, только если оно не было изменено вручную
if (aiTopicEl && aiTopicEl.dataset.userEdited !== '1') {
aiTopicEl.value = titleEl.value;
}
});
titleEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') e.preventDefault();
});
}
if (aiTopicEl) {
aiTopicEl.addEventListener('input', () => {
aiTopicEl.dataset.userEdited = '1';
autoResize(aiTopicEl);
});
}
if (descEl) descEl.addEventListener('input', () => autoResize(descEl));
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
thresholdEl.value =
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
syncThresholdMirror();
const timeLimitEl = document.getElementById('test-time-limit');
const hintsEl = document.getElementById('test-hints-enabled');
const hintsRow = document.getElementById('test-hints-row');
const resultModeRadios = document.querySelectorAll('input[name="result-mode"]');
if (timeLimitEl) {
timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit);
timeLimitEl.addEventListener('input', scheduleDirtyCheck);
}
const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end';
resultModeRadios.forEach((r) => {
r.checked = (r.value === initMode);
r.addEventListener('change', () => {
const mode = document.querySelector('input[name="result-mode"]:checked');
const isImmediate = mode && mode.value === 'immediate';
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl && hintsEl.checked) ? '' : 'none';
if (hintsEl && !isImmediate) hintsEl.checked = false;
scheduleDirtyCheck();
});
});
if (hintsEl) {
hintsEl.checked = !!initial.test.hintsEnabled;
hintsEl.addEventListener('change', () => {
const mode = document.querySelector('input[name="result-mode"]:checked');
const isImmediate = mode && mode.value === 'immediate';
if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl.checked) ? '' : 'none';
scheduleDirtyCheck();
});
}
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
if (hintsActionsEl) hintsActionsEl.style.display = (initMode === 'immediate' && hintsEl && hintsEl.checked) ? '' : 'none';
questionsEl.innerHTML = '';
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
if (aiTopicEl && !aiTopicEl.value.trim()) {
aiTopicEl.value = initial.test.title || '';
}
if (aiTopicEl) requestAnimationFrame(() => autoResize(aiTopicEl));
}
function fmtDt(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return '—';
}
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ─── collect ───────────────────────────────────────────────────────
function collectPayload() {
const questions = $$('#questions .q-item:not(.q-removed)').map((li, i) => {
const hintVal = ($('.q-hint', li) && $('.q-hint', li).value.trim()) || '';
return {
text: $('.q-text', li).value.trim(),
question_order: i + 1,
hasMultipleAnswers: $('.q-multi', li).checked,
aiHint: hintVal || null,
options: $$('.opt-item', li).map((op, j) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
option_order: j + 1,
})),
};
});
const payload = {
title: titleEl.value.trim() || null,
description: descEl.value.trim() || null,
questions,
};
const t = thresholdEl.value;
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t);
const timeLimitEl = document.getElementById('test-time-limit');
if (timeLimitEl) {
const tl = timeLimitEl.value;
payload.timeLimit = (tl === '' ? null : Math.max(0, Number(tl) || 0));
}
const modeEl = document.querySelector('input[name="result-mode"]:checked');
payload.resultMode = (modeEl && modeEl.value === 'immediate') ? 'immediate' : 'end';
const hintsEl = document.getElementById('test-hints-enabled');
payload.hintsEnabled = !!(hintsEl && hintsEl.checked && payload.resultMode === 'immediate');
return payload;
}
function collectShape() {
return $$('#questions .q-item').map((li) => {
const optionsCount = Math.max(2, $$('.opt-item', li).length || 4);
const hasMultipleAnswers = $('.q-multi', li).checked;
const range = getTemplateCorrectRange(optionsCount, hasMultipleAnswers);
return {
optionsCount,
hasMultipleAnswers,
minCorrect: hasMultipleAnswers ? range.minCorrect : 1,
maxCorrect: hasMultipleAnswers ? range.maxCorrect : 1,
};
});
}
async function saveCurrentDraftQuietly() {
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload()),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить черновик перед генерацией подсказок.');
resetBaselineDraft();
return data;
}
// ─── actions ───────────────────────────────────────────────────────
$('#add-question').addEventListener('click', () => {
questionsEl.appendChild(
renderQuestion({
text: '',
hasMultipleAnswers: false,
options: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
],
}),
);
renumber();
scheduleDirtyCheck();
});
$('#save-draft').addEventListener('click', async () => {
saveStatusEl.textContent = 'Сохраняем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload()),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.');
if (chainActiveEl.checked !== chainActive) {
const r2 = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainActive: chainActiveEl.checked }),
});
if (r2.ok) chainActive = chainActiveEl.checked;
}
resetBaselineDraft();
const msg = data.forked
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
: 'Изменения сохранены.';
const saveModal = document.getElementById('save-modal');
const saveMsg = document.getElementById('save-modal-msg');
const hintsEl = document.getElementById('test-hints-enabled');
const modeEl = document.querySelector('input[name="result-mode"]:checked');
const wantsHints = !!(hintsEl && hintsEl.checked) && modeEl && modeEl.value === 'immediate';
if (wantsHints) {
try {
const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`);
const st = await sr.json().catch(() => ({}));
if (sr.ok && Number(st.missing) > 0) {
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
const gd = await gr.json().catch(() => ({}));
if (!gr.ok) {
saveStatusEl.textContent = '';
alert(gd.error || 'Не удалось сгенерировать подсказки.');
if (saveMsg) saveMsg.textContent = msg + ' (часть подсказок не создана)';
if (saveModal) saveModal.showModal();
return;
}
const skipped = Number(gd.skipped || 0);
const tail = gd.failed
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
if (saveMsg) saveMsg.textContent = msg + tail;
} else {
if (saveMsg) saveMsg.textContent = msg;
}
} catch (err) {
if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)';
}
} else {
if (saveMsg) saveMsg.textContent = msg;
}
saveStatusEl.textContent = '';
if (saveModal) {
saveModal.showModal();
}
} catch (e) {
saveStatusEl.textContent = '';
alert(e.message || 'Не удалось сохранить.');
}
});
$('#ai-generate-test').addEventListener('click', async () => {
const topic = (aiTopicEl?.value || titleEl.value || '').trim();
if (!topic) {
alert('Укажите тему.');
return;
}
// Предупреждение, если в тесте уже есть вопросы или заполненное название/описание
const hasContent = questionsEl.children.length > 0
|| titleEl.value.trim()
|| descEl.value.trim();
if (hasContent) {
const ok = confirm(
'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?'
);
if (!ok) return;
}
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const globalRange = getTemplateCorrectRange(nO, globalMulti);
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nO,
hasMultipleAnswers: globalMulti,
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
aiStatusEl.textContent = 'Генерируем структуру и вопросы…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: topic,
testDescription: descEl.value,
shape,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
const draft = data.draft;
if (draft.title) {
titleEl.value = draft.title;
if (aiTopicEl) aiTopicEl.value = draft.title;
}
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
const hintsEl = document.getElementById('test-hints-enabled');
const modeEl = document.querySelector('input[name="result-mode"]:checked');
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
aiStatusEl.textContent = 'Сохраняем черновик…';
try {
await saveCurrentDraftQuietly();
aiStatusEl.textContent = 'Генерируем вопросы… затем подсказки…';
const hr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
const hd = await hr.json().catch(() => ({}));
if (hr.ok) {
const skipped = Number(hd.skipped || 0);
aiStatusEl.textContent = skipped
? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).`
: `Готово: вопросы + подсказки (${hd.generated}).`;
}
} catch (_) {
// Оставляем базовый статус готовности вопросов.
}
}
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.');
}
});
// ─── импорт документа с drag-and-drop (E1.3) ──────────────────
const importDropzone = $('#ai-import-dropzone');
const importDropzoneLabel = $('#ai-import-dropzone-label');
const docUserHint = $('#doc-user-hint');
const docGenerateBtn = $('#doc-generate-btn');
const importModal = $('#import-modal');
const importModalTitle = $('#import-modal-title');
const importModalBody = $('#import-modal-body');
const importModalActions = $('#import-modal-actions');
let _extractedText = '';
let _extractedFileName = '';
function openImportModal(title, bodyHtml, actions) {
importModalTitle.textContent = title;
importModalBody.innerHTML = bodyHtml;
importModalActions.innerHTML = '';
actions.forEach(({ label, onClick, primary }) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.className = primary
? 'px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium'
: 'px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-50 text-ink-700 text-sm';
btn.addEventListener('click', onClick);
importModalActions.appendChild(btn);
});
importModal.showModal();
}
// Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны
async function handleImportFile(file) {
if (!file) return;
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
importDropzone.classList.add('import-dropzone--loading');
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
_extractedText = data.extractedText || '';
_extractedFileName = file.name;
aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`;
if (importDropzoneLabel) importDropzoneLabel.textContent = `${file.name}`;
importDropzone.classList.add('import-dropzone--done');
} catch (e) {
aiStatusEl.textContent = '';
openImportModal(
'Ошибка загрузки',
`<p class="text-red-700">${escHtml(e.message || 'Не удалось загрузить файл.')}</p>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
} finally {
importDropzone.classList.remove('import-dropzone--loading');
}
}
// Фаза 2: сгенерировать тест из извлечённого текста + подсказки
async function handleGenerateFromDoc() {
if (!_extractedText) return;
const userHint = docUserHint ? docUserHint.value.trim() : '';
docGenerateBtn.disabled = true;
docGenerateBtn.textContent = 'Генерируем…';
aiStatusEl.textContent = 'Генерируем тест из документа…';
if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…';
try {
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const globalRange = getTemplateCorrectRange(nO, globalMulti);
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nO,
hasMultipleAnswers: globalMulti,
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
if (docProgressEl) docProgressEl.textContent = 'Шаг 2/3: генерация вопросов…';
const r = await fetch('/api/tests/generate-from-extracted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extractedText: _extractedText, userHint, shape }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
const g = data.generation || {};
aiStatusEl.textContent = '';
if (docProgressEl) docProgressEl.textContent = 'Шаг 3/3: подготовка к применению…';
if (!g.available) {
openImportModal(
'AI недоступен',
`<p class="mb-2 text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 text-xs">
${escHtml(g.message || 'AI недоступен — ключ не настроен.')}
</p>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
return;
}
const draft = g.draft || {};
const qs = draft.questions || [];
const qPreview = qs.slice(0, 4).map((q, i) =>
`<li class="text-xs text-ink-600">${i + 1}. ${escHtml((q.text || '').slice(0, 80))}${(q.text || '').length > 80 ? '…' : ''}</li>`
).join('');
const moreCount = qs.length > 4 ? qs.length - 4 : 0;
const bodyHtml = `
${draft.title ? `<p class="font-medium text-ink-800 mb-1">${escHtml(draft.title)}</p>` : ''}
${draft.description ? `<p class="text-xs text-ink-500 mb-2">${escHtml(draft.description)}</p>` : ''}
<p class="text-xs text-ink-500 mb-1">Вопросов: <b>${qs.length}</b></p>
${qs.length ? `<ul class="space-y-0.5 mb-1">${qPreview}</ul>
${moreCount ? `<p class="text-xs text-ink-400">…и ещё ${moreCount}</p>` : ''}` : ''}
<p class="mt-3 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
Текущие вопросы теста будут <b>заменены</b>.
</p>`;
openImportModal(
`Черновик из «${escHtml(_extractedFileName)}»`,
bodyHtml,
[
{
label: 'Применить',
primary: true,
onClick: () => {
importModal.close();
if (draft.title) { titleEl.value = draft.title; autoResize(titleEl); }
if (draft.description) { descEl.value = draft.description; autoResize(descEl); }
questionsEl.innerHTML = '';
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
// Сброс зоны загрузки
_extractedText = '';
_extractedFileName = '';
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите';
importDropzone.classList.remove('import-dropzone--done');
if (docUserHint) docUserHint.value = '';
aiStatusEl.textContent = '';
},
},
{ label: 'Отмена', onClick: () => importModal.close() },
],
);
} catch (e) {
aiStatusEl.textContent = '';
openImportModal(
'Ошибка генерации',
`<p class="text-red-700">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
} finally {
if (docGenerateBtn) {
docGenerateBtn.disabled = false;
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
}
if (docProgressEl) setTimeout(() => { docProgressEl.textContent = ''; }, 2500);
}
}
if (docGenerateBtn) docGenerateBtn.addEventListener('click', () => {
if (!_extractedText) {
// Файл ещё не выбран — открываем picker, генерация запустится после загрузки
const fileInput = $('#ai-import-file');
if (fileInput) {
const onchange = async (ev) => {
fileInput.removeEventListener('change', onchange);
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
await handleImportFile(f);
if (_extractedText) handleGenerateFromDoc();
};
fileInput.addEventListener('change', onchange);
fileInput.click();
}
} else {
handleGenerateFromDoc();
}
});
$('#ai-import-file').addEventListener('change', (ev) => {
const file = ev.target.files && ev.target.files[0];
ev.target.value = '';
handleImportFile(file);
});
// Drag-and-drop на зону загрузки
if (importDropzone) {
importDropzone.addEventListener('dragenter', (e) => {
e.preventDefault();
importDropzone.classList.add('import-dropzone--over');
});
importDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
importDropzone.classList.add('import-dropzone--over');
});
importDropzone.addEventListener('dragleave', (e) => {
if (!importDropzone.contains(e.relatedTarget)) {
importDropzone.classList.remove('import-dropzone--over');
}
});
importDropzone.addEventListener('drop', (e) => {
e.preventDefault();
importDropzone.classList.remove('import-dropzone--over');
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) {
aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`;
setTimeout(() => (aiStatusEl.textContent = ''), 3000);
return;
}
handleImportFile(file);
});
}
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
document.addEventListener('dragover', (e) => { e.preventDefault(); });
document.addEventListener('drop', (e) => {
if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) return;
// Подсвечиваем зону и обрабатываем
importDropzone?.classList.add('import-dropzone--over');
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
handleImportFile(file);
});
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
function aiAlert(data, fallback) {
const msg = (data && data.error) || fallback || 'AI: ошибка.';
if (data && data.settingsUrl) {
if (confirm(msg + '\n\nОткрыть Настройки?')) {
window.location.href = data.settingsUrl;
}
return;
}
alert(msg);
}
const modal = $('#ai-modal');
const modalTitle = $('#ai-modal-title');
const modalBody = $('#ai-modal-body');
const modalActions = $('#ai-modal-actions');
$('#ai-modal-close').addEventListener('click', () => modal.close());
const saveModalEl = document.getElementById('save-modal');
const saveStayBtn = document.getElementById('save-modal-stay');
const saveGoBtn = document.getElementById('save-modal-go');
if (saveStayBtn) saveStayBtn.addEventListener('click', () => saveModalEl.close());
if (saveGoBtn) saveGoBtn.addEventListener('click', () => { window.location.href = '/tests'; });
function doCancel() {
if (isDirty()) {
if (!confirm('Есть несохранённые изменения. Уйти без сохранения?')) return;
}
window.location.href = '/tests';
}
const cancelBtn = document.getElementById('btn-cancel');
if (cancelBtn) cancelBtn.addEventListener('click', doCancel);
const cancelBtnInline = document.getElementById('btn-cancel-inline');
if (cancelBtnInline) cancelBtnInline.addEventListener('click', doCancel);
// Кнопка «Сохранить» под вопросами — дублирует основную
const saveDraftInlineBtn = document.getElementById('save-draft-inline');
const saveStatusInlineEl = document.getElementById('save-status-inline');
if (saveDraftInlineBtn) {
saveDraftInlineBtn.addEventListener('click', () => {
document.getElementById('save-draft')?.click();
});
}
function openModal(title, bodyHtml, actions) {
modalTitle.textContent = title;
modalBody.innerHTML = bodyHtml;
modalActions.innerHTML = '';
(actions || []).forEach((a) => {
const b = document.createElement('button');
b.textContent = a.label;
b.className = a.className
|| 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm';
b.addEventListener('click', () => a.onClick(b));
modalActions.appendChild(b);
});
modal.showModal();
}
const aiGenerateByTitleBtn = $('#ai-generate-by-title');
if (aiGenerateByTitleBtn) aiGenerateByTitleBtn.addEventListener('click', async () => {
const title = titleEl.value.trim();
if (!title) {
alert('Сначала заполните название теста.');
titleEl.focus();
return;
}
const nQRaw = prompt('Сколько вопросов сгенерировать?', '10');
if (nQRaw == null) return;
const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10));
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
if (nORaw == null) return;
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
aiStatusEl.textContent = 'Генерируем по названию…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: title,
testDescription: descEl.value,
questionsCount: nQ,
optionsCount: nO,
}),
});
const data = await r.json();
if (!r.ok) {
aiStatusEl.textContent = '';
return aiAlert(data);
}
const draft = data.draft;
const ok = confirm(
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
'Применить как черновик? Текущие вопросы будут заменены.',
);
if (!ok) {
aiStatusEl.textContent = '';
return;
}
if (draft.title) titleEl.value = draft.title;
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
aiAlert(null, e.message);
}
});
const aiCheckBtn = $('#ai-check');
if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего проверять.');
return;
}
aiStatusEl.textContent = 'Анализируем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
if (!r.ok) return aiAlert(data);
const rev = data.review || {};
const verdict = rev.verdict || 'warn';
const verdictMap = {
ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'],
warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'],
bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'],
};
const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn;
let html = `<div class="rounded-lg border ${verdictCls} p-3 text-sm">
<div class="font-semibold">${verdictText}</div>
<div class="mt-1">${escHtml(rev.summary || '')}</div></div>`;
if (Array.isArray(rev.sections) && rev.sections.length) {
html += rev.sections.map((s) => `
<div class="mt-4">
<div class="font-semibold">${escHtml(s.title)}</div>
<ul class="mt-1 list-disc pl-5 text-sm space-y-1">
${s.items.map((it) => `<li>${escHtml(it)}</li>`).join('')}
</ul>
</div>`).join('');
} else {
html += '<p class="mt-4 text-sm text-ink-500">Замечаний нет.</p>';
}
openModal('Проверка теста', html, [
{ label: 'Закрыть', onClick: () => modal.close(),
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
]);
} catch (e) {
aiStatusEl.textContent = '';
aiAlert(null, e.message);
}
});
const aiImproveBtn = $('#ai-improve');
if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего улучшать.');
return;
}
aiStatusEl.textContent = 'Улучшаем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
if (!r.ok) return aiAlert(data);
const items = data.items || [];
if (!items.length) {
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
{ label: 'Закрыть', onClick: () => modal.close() },
]);
return;
}
const changed = items.filter((i) => i.changed);
if (!changed.length) {
openModal('Улучшение теста', '<p>AI не предложил изменений.</p>', [
{ label: 'Закрыть', onClick: () => modal.close() },
]);
return;
}
let html = `<p class="text-sm text-ink-500 mb-3">
Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.</p>`;
html += changed.map((it) => `
<div class="rounded-xl border border-ink-300/60 p-3 mb-3" data-idx="${it.index}">
<label class="inline-flex items-center gap-2 text-sm font-medium">
<input type="checkbox" class="apply-q rounded border-ink-300 text-brand-600 focus:ring-brand-500" checked />
<span>Вопрос #${it.index + 1}</span>
</label>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs uppercase text-ink-500">Было</div>
<div class="mt-1 ${it.textChanged ? 'line-through text-ink-500' : ''}">
${escHtml(it.original.text)}
</div>
<ul class="mt-1 list-disc pl-5">
${it.original.options.map((o) =>
`<li class="${it.optionsChanged ? 'text-ink-500' : ''}">
${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
</ul>
</div>
<div>
<div class="text-xs uppercase text-brand-700">Стало</div>
<div class="mt-1 ${it.textChanged ? 'font-medium' : ''}">
${escHtml(it.suggested.text)}
</div>
<ul class="mt-1 list-disc pl-5">
${it.suggested.options.map((o) =>
`<li>${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
</ul>
</div>
</div>
</div>`).join('');
openModal('Улучшение теста', html, [
{ label: 'Отмена', onClick: () => modal.close() },
{
label: 'Применить выбранное',
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm',
onClick: () => {
const qs = $$('#questions .q-item');
modalBody.querySelectorAll('[data-idx]').forEach((row) => {
if (!$('.apply-q', row).checked) return;
const idx = parseInt(row.dataset.idx, 10);
const it = items.find((x) => x.index === idx);
if (!it || !qs[idx]) return;
const node = qs[idx];
$('.q-text', node).value = it.suggested.text;
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
const optsEl = $('.q-options', node);
optsEl.innerHTML = '';
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
});
modal.close();
scheduleDirtyCheck();
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
},
},
]);
} catch (e) {
aiStatusEl.textContent = '';
aiAlert(null, e.message);
}
});
async function aiGenerateQuestion(node) {
const qTextEl = $('.q-text', node);
const qText = qTextEl.value.trim();
const existingOpts = $$('.opt-item', node);
const optsCount = Math.max(2, existingOpts.length || 4);
const multi = $('.q-multi', node).checked;
const overlay = $('.q-ai-overlay', node);
// Показываем оверлей
overlay?.classList.remove('hidden');
node.style.pointerEvents = 'none';
try {
// Собираем варианты с их состоянием
const existingOptions = existingOpts.map((op) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
}));
const emptySlots = existingOptions.filter((o) => !o.text).length;
const filledSlots = existingOptions.filter((o) => o.text).length;
// Выбираем режим:
// - нет текста вопроса → full
// - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors
// - есть вопрос, все варианты заполнены или вариантов нет → rephrase
let requestMode;
if (!qText) {
requestMode = 'full';
} else if (emptySlots > 0 && filledSlots > 0) {
requestMode = 'distractors';
} else {
requestMode = 'rephrase';
}
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questionText: qText,
optionsCount: optsCount,
hasMultipleAnswers: multi,
mode: requestMode,
existingOptions: qText ? existingOptions : undefined,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
// Обновляем текст вопроса (кроме режима дистракторов — текст не меняем)
if (data.mode !== 'distractors') {
qTextEl.value = data.text || qText;
autoResize(qTextEl);
}
const optsEl = $('.q-options', node);
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
// Полная замена вариантов
optsEl.innerHTML = '';
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
// Заполняем только пустые слоты
let dIdx = 0;
existingOpts.forEach((op) => {
const t = $('.opt-text', op);
if (!t.value.trim() && dIdx < data.options.length) {
t.value = data.options[dIdx].text || '';
autoResize(t);
dIdx++;
}
});
}
syncOptionInputTypes(node);
updateOptionsCounter(node);
updateAiButtonLabel(node);
scheduleDirtyCheck();
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.');
} finally {
overlay?.classList.add('hidden');
node.style.pointerEvents = '';
}
}
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
function updateChainActiveDisplay(active) {
chainActive = !!active;
chainActiveEl.checked = chainActive;
if (chainActiveDisplay) {
chainActiveDisplay.textContent = chainActive ? '✓ Активна в каталоге' : 'Скрыта из каталога';
chainActiveDisplay.style.color = chainActive ? '' : 'var(--ink-500, #6b7280)';
}
}
fetch(`/api/tests/${TEST_ID}/summary`)
.then((r) => r.json())
.then((data) => {
if (data && data.test && typeof data.test.chainActive === 'boolean') {
updateChainActiveDisplay(data.test.chainActive);
} else {
updateChainActiveDisplay(true);
}
})
.catch(() => { updateChainActiveDisplay(true); });
function renderVersions(rows) {
if (!versionsListEl) return;
versionsListEl.innerHTML = '';
if (!(rows || []).length) {
versionsListEl.innerHTML = '<li class="muted" style="font-size:.85rem;">Нет версий.</li>';
return;
}
(rows || []).forEach((r) => {
const li = document.createElement('li');
li.className = 'version-item';
li.dataset.versionId = r.id;
li.dataset.active = r.is_active ? '1' : '0';
li.innerHTML = `
<span class="version-item__label">
Версия ${r.version}
${r.is_active ? '<span class="version-item__badge">активная</span>' : ''}
</span>
<span class="version-item__date muted">${fmtDt(r.created_at)}</span>
${!r.is_active
? `<button class="btn btn-ghost btn--sm version-item__activate" type="button"
data-version-id="${escHtml(r.id)}">Сделать активной</button>`
: '<span class="version-item__spacer"></span>'}`;
versionsListEl.appendChild(li);
});
versionsListEl.querySelectorAll('.version-item__activate').forEach((btn) => {
btn.addEventListener('click', async () => {
const vid = btn.dataset.versionId;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/versions/${vid}/activate`, { method: 'POST' });
if (!r.ok) throw new Error('Не удалось активировать');
// обновить список
const v = await fetch(`/api/tests/${TEST_ID}/versions`).then((x) => x.json()).catch(() => null);
if (v && Array.isArray(v.versions)) renderVersions(v.versions);
} catch (e) {
btn.disabled = false;
btn.textContent = 'Сделать активной';
alert(e.message);
}
});
});
}
function renderAttempts(rows) {
if (!attemptsListEl) return;
attemptsListEl.innerHTML = '';
if (!(rows || []).length) {
attemptsListEl.innerHTML = '<li class="muted" style="padding:.5rem 0; font-size:.85rem;">Прохождений ещё нет.</li>';
return;
}
const statusLabel = {
completed: null, // handled by score
in_progress: 'Идёт',
expired: 'Истекло',
};
(rows || []).forEach((a) => {
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
let result;
if (a.status === 'completed' && a.totalQuestions != null) {
const verdict = a.passed ? '✓ Сдано' : '✗ Не сдано';
const score = `${a.correctCount} из ${a.totalQuestions}`;
result = `${verdict} · ${score}`;
} else {
result = statusLabel[a.status] || a.status;
}
const passedCls = a.status === 'completed'
? (a.passed ? 'color:#166534;' : 'color:#991b1b;')
: 'color:#6b7280;';
const li = document.createElement('li');
li.className = 'surface-card attempts-card-list__item';
li.innerHTML = `
<div class="attempts-card-list__row">
<div class="attempts-card-list__main">
<p class="muted" style="margin:0; font-size:.8rem;">${when}</p>
<p style="margin:.3rem 0 0; font-weight:600;">${escHtml(a.attempterName || a.attempterLogin || '—')}</p>
<p style="margin:.2rem 0 0; font-size:.85rem; ${passedCls}">${escHtml(result)}</p>
</div>
${a.status === 'completed'
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>`
: `<span class="muted" style="font-size:.8rem;">${statusLabel[a.status] || ''}</span>`}
</div>`;
attemptsListEl.appendChild(li);
});
}
function renderAssignList() {
if (!assignListEl) return;
assignListEl.innerHTML = '';
assignPeople.forEach((p) => {
const row = document.createElement('label');
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
row.innerHTML = `
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
<span class="assign-row__text">
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
<span class="assign-row__meta">${escHtml(p.department || '—')}</span>
</span>`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => {
const k = String(p.staffId);
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
row.classList.toggle('assign-row--selected', cb.checked);
});
assignListEl.appendChild(row);
});
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
}
async function loadDirectory() {
if (!assignListEl) return;
assignStatusEl.textContent = 'Загружаем…';
try {
const params = new URLSearchParams();
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
params.set('clinic', assignClinicEl.value || 'all');
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
assignPeople = data.people || [];
const depts = data.departments || [];
if (assignDeptEl.options.length <= 1) {
depts.forEach((d) => {
const o = document.createElement('option');
o.value = d;
o.textContent = d;
assignDeptEl.appendChild(o);
});
}
assignSelected = new Set();
renderAssignList();
assignStatusEl.textContent = '';
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
}
}
if (assignSearchEl) {
let t = null;
assignSearchEl.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(loadDirectory, 350);
});
assignDeptEl.addEventListener('change', loadDirectory);
assignClinicEl.addEventListener('change', loadDirectory);
assignSelectAllBtn.addEventListener('click', () => {
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
renderAssignList();
});
assignSubmitBtn.addEventListener('click', async () => {
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
const userIds = selectedRows.filter((p) => p.clinicUserId).map((p) => p.clinicUserId);
const staffIds = selectedRows.filter((p) => !p.clinicUserId && p.staffId != null).map((p) => p.staffId);
if (!userIds.length && !staffIds.length) return;
assignStatusEl.textContent = 'Назначаем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds, staffIds }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка назначения';
}
});
loadDirectory();
}
if (visibilityBtn) {
visibilityBtn.addEventListener('click', async () => {
const next = !chainActive;
try {
const r = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainActive: next }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
updateChainActiveDisplay(next);
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
} catch (e) {
alert(e.message || 'Ошибка изменения видимости');
}
});
}
// ─── Создать шаблон ────────────────────────────────────────────
const createTemplateBtn = $('#create-template');
if (createTemplateBtn) {
createTemplateBtn.addEventListener('click', () => {
const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10)));
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10)));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const range = getTemplateCorrectRange(oCount, globalMulti);
const existing = $$('#questions .q-item').length;
if (existing > 0) {
const ok = confirm(
`Создать шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` +
'Текущие вопросы будут заменены.'
);
if (!ok) return;
}
questionsEl.innerHTML = '';
for (let qi = 0; qi < qCount; qi++) {
const opts = [];
for (let oi = 0; oi < oCount; oi++) {
opts.push({ text: '', isCorrect: oi < range.minCorrect });
}
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts }));
}
renumber();
scheduleDirtyCheck();
// Прокручиваем к первому вопросу
questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
async function generateHintsForCurrentTest() {
if (!generateHintsBtn) return;
generateHintsBtn.disabled = true;
if (hintsStatusEl) hintsStatusEl.textContent = 'Сохраняем текущие изменения…';
try {
await saveCurrentDraftQuietly();
if (hintsStatusEl) hintsStatusEl.textContent = 'Генерируем подсказки…';
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось сгенерировать подсказки.');
const skipped = Number(data.skipped || 0);
if (hintsStatusEl) {
hintsStatusEl.textContent = data.failed
? `Создано ${data.generated}, ошибок ${data.failed}${skipped ? `, пропущено ${skipped}` : ''}.`
: `Подсказки созданы: ${data.generated}${skipped ? `, пропущено ${skipped}` : ''}.`;
}
} catch (e) {
if (hintsStatusEl) hintsStatusEl.textContent = e.message || 'Ошибка генерации подсказок.';
} finally {
generateHintsBtn.disabled = false;
}
}
if (generateHintsBtn) {
generateHintsBtn.addEventListener('click', generateHintsForCurrentTest);
}
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', syncTemplateRangeUi);
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('input', syncTemplateRangeUi);
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('input', syncTemplateRangeUi);
if (aiOCountEl) aiOCountEl.addEventListener('input', syncTemplateRangeUi);
syncTemplateRangeUi();
Promise.all([
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
]).then(([v, a, s]) => {
if (v && Array.isArray(v.versions)) {
renderVersions(v.versions);
hasForkRisk = hasForkRisk || (v.versions.length > 1);
if (typeof v.hasAttempts === 'boolean') {
hasAnyAttempts = hasAnyAttempts || v.hasAttempts;
hasForkRisk = hasForkRisk || v.hasAttempts;
}
}
if (a && Array.isArray(a.attempts)) {
renderAttempts(a.attempts);
hasAnyAttempts = a.attempts.length > 0;
}
if (s && s.test) {
if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt);
const versionEl = document.getElementById('intro-version');
if (versionEl && s.test.version != null) versionEl.textContent = s.test.version;
if (typeof s.test.hasAttempts === 'boolean') {
hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts;
hasForkRisk = hasForkRisk || s.test.hasAttempts;
}
if (typeof s.test.versionCount === 'number') {
hasForkRisk = hasForkRisk || s.test.versionCount > 1;
}
}
updateForkBanner();
});
loadInitial();
resetBaselineDraft();
root.addEventListener('input', scheduleDirtyCheck);
root.addEventListener('change', scheduleDirtyCheck);
})();