Files
TestingWebApp/flask_app/app/static/js/editor.js
T
Константин Лебединский 1494b839f5 tset preview fix
2026-04-30 21:11:27 +05:00

1734 lines
72 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 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 syncEditorHeroExtra() {
const timeVal = document.getElementById('editor-hero-time-val');
const resVal = document.getElementById('editor-hero-result-val');
const hintsVal = document.getElementById('editor-hero-hints-val');
if (!timeVal || !resVal || !hintsVal) return;
const tlEl = document.getElementById('test-time-limit');
const raw = tlEl && tlEl.value !== '' ? Number(tlEl.value) : 0;
timeVal.textContent = (!raw || raw <= 0) ? 'без ограничения' : `${raw} мин`;
const mode = document.querySelector('input[name="result-mode"]:checked');
const imm = mode && mode.value === 'immediate';
resVal.textContent = imm ? 'сразу' : 'в конце';
const hintsCheckbox = document.getElementById('test-hints-enabled');
if (!imm) hintsVal.textContent = 'недоступны';
else if (hintsCheckbox && hintsCheckbox.checked) hintsVal.textContent = 'вкл';
else hintsVal.textContent = 'выкл';
}
function syncHeroMetaRow() {
syncThresholdMirror();
syncEditorHeroExtra();
}
function loadInitial() {
titleEl.value = initial.test.title || '';
descEl.value = initial.test.description || '';
autoResize(titleEl);
autoResize(descEl);
if (thresholdEl) {
thresholdEl.addEventListener('input', syncHeroMetaRow);
thresholdEl.addEventListener('change', syncHeroMetaRow);
}
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);
syncHeroMetaRow();
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();
syncEditorHeroExtra();
});
}
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 (hintsEl && !isImmediate) hintsEl.checked = false;
syncHeroMetaRow();
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';
syncHeroMetaRow();
scheduleDirtyCheck();
});
}
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : '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;
}
function applyServerTestState(test) {
if (!test || typeof test !== 'object') return;
if (typeof test.title === 'string' && titleEl) titleEl.value = test.title;
if (typeof test.description === 'string' && descEl) descEl.value = test.description;
if (thresholdEl && test.passingThreshold != null) {
thresholdEl.value = Number(test.passingThreshold);
}
const timeLimitEl = document.getElementById('test-time-limit');
if (timeLimitEl) timeLimitEl.value = test.timeLimit == null ? '' : Number(test.timeLimit);
const hintsEl = document.getElementById('test-hints-enabled');
const hintsRow = document.getElementById('test-hints-row');
const mode = (test.resultMode === 'immediate') ? 'immediate' : 'end';
const modeEl = document.querySelector(`input[name="result-mode"][value="${mode}"]`);
if (modeEl) modeEl.checked = true;
if (hintsRow) hintsRow.style.display = (mode === 'immediate') ? '' : 'none';
if (hintsEl) {
hintsEl.checked = !!test.hintsEnabled && mode === 'immediate';
}
autoResize(titleEl);
autoResize(descEl);
syncHeroMetaRow();
}
async function refreshMetaAfterSave() {
const [v, s, e] = await Promise.all([
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/editor`).then((r) => r.json()).catch(() => null),
]);
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 (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 (e && e.test) applyServerTestState(e.test);
updateForkBanner();
}
async function refreshHintsInForm() {
const r = await fetch(`/api/tests/${TEST_ID}/editor`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !Array.isArray(data.questions)) {
throw new Error(data.error || 'Не удалось обновить подсказки в форме.');
}
const byOrder = (data.questions || []).slice().sort(
(a, b) => Number(a.questionOrder || 0) - Number(b.questionOrder || 0)
);
// Надёжно подтягиваем подсказки: перерисовываем список вопросов с актуальными данными сервера.
questionsEl.innerHTML = '';
byOrder.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
}
// ─── 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;
}
await refreshMetaAfterSave();
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) {
const okGen = confirm(
`Подсказок пока нет или заполнены не все: не хватает ${st.missing} из ${st.total}.\n\n`
+ 'Сгенерировать недостающие подсказки через ИИ?',
);
if (okGen) {
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;
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех сохранения */
}
} else if (saveMsg) {
saveMsg.textContent = msg;
}
} 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) {
try {
await refreshHintsInForm();
} catch (_) {
// Не блокируем успех генерации вопросов.
}
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 = '';
/** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */
function buildImportPreviewQuestionHtml(q, index) {
const hint = (q.aiHint || '').trim();
const opts = Array.isArray(q.options) ? q.options : [];
const optionsHtml = opts.length
? `<ul class="attempt-review-options" role="list">${opts.map((o) => {
const correct = !!o.isCorrect;
const liCls = correct
? 'attempt-review-option attempt-review-option--correct'
: 'attempt-review-option';
const tag = correct ? '<span class="attempt-review-option__tag">верный ответ</span>' : '';
return `<li class="${liCls}">
<span class="attempt-review-option__text">
<span class="attempt-review-option__mark" aria-hidden="true">☐</span>
<span class="attempt-review-option__body">${escHtml(o.text || '')}${tag}</span>
</span>
</li>`;
}).join('')}</ul>`
: '';
const hintHtml = hint
? `<div class="attempt-review-hint">
<span class="attempt-review-hint__label">Подсказка</span>
<p class="attempt-review-hint__text">${escHtml(hint)}</p>
</div>`
: '';
return `<article class="attempt-card attempt-review-card import-modal-review__card">
<div class="attempt-review-card__head">
<span class="attempt-review-card__num">${index + 1}</span>
<span class="attempt-review-card__badge attempt-review-card__badge--preview">черновик</span>
</div>
<p class="attempt-review-card__question">${escHtml(q.text || '')}</p>
${hintHtml}
${optionsHtml}
</article>`;
}
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(
'Ошибка загрузки',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
[{ 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 недоступен',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--warn">${escHtml(g.message || 'AI недоступен — ключ не настроен.')}</p></div>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
return;
}
const draft = g.draft || {};
const qs = draft.questions || [];
const qCards = qs.map((q, i) => buildImportPreviewQuestionHtml(q, i)).join('');
const bodyHtml = `
<div class="import-modal-review">
${draft.title ? `<p class="import-modal-review__draft-title">${escHtml(draft.title)}</p>` : ''}
${draft.description ? `<p class="import-modal-review__desc attempt-review-page__params">${escHtml(draft.description)}</p>` : ''}
<p class="import-modal-review__stats attempt-review-page__params">Вопросов в черновике: <strong>${qs.length}</strong></p>
${qs.length ? `<div class="import-modal-review__list">${qCards}</div>` : ''}
<div class="import-modal-review__warn" role="status">Текущие вопросы теста будут <strong>заменены</strong> после «Применить».</div>
</div>`;
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(
'Ошибка генерации',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
[{ 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);
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);
}
});
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
function shuffleQuestionOptionsDom(qNode) {
const optsEl = $('.q-options', qNode);
if (!optsEl) return;
const rows = Array.from(optsEl.querySelectorAll('.opt-item'));
if (rows.length < 2) return;
for (let i = rows.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[rows[i], rows[j]] = [rows[j], rows[i]];
}
rows.forEach((el) => optsEl.appendChild(el));
}
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++;
}
});
shuffleQuestionOptionsDom(node);
}
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 = `
<div class="version-item__main">
<span class="version-item__label">Версия ${r.version}</span>
<span class="version-item__date">${fmtDt(r.created_at)}</span>
</div>
<div class="version-item__actions">
${r.is_active
? '<span class="version-item__badge">активная</span>'
: `<button class="btn btn-ghost btn--sm version-item__activate" type="button"
data-version-id="${escHtml(r.id)}">Сделать активной</button>`}
</div>`;
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 || 'Ошибка изменения видимости');
}
});
}
// ─── Автосоздание шаблона ──────────────────────────────────────
let templateRebuildTimer = null;
let lastAppliedTemplateKey = '';
function hasMeaningfulQuestions() {
return $$('#questions .q-item').some((node) => {
const qText = ($('.q-text', node)?.value || '').trim();
if (qText) return true;
return $$('.opt-text', node).some((o) => (o.value || '').trim());
});
}
function buildTemplateFromControls({ askConfirm } = { askConfirm: true }) {
const qCount = Math.min(30, Math.max(1, parseInt(aiQCountEl?.value || '7', 10)));
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt(aiOCountEl?.value || '3', 10)));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const range = getTemplateCorrectRange(oCount, globalMulti);
const key = JSON.stringify({ qCount, oCount, globalMulti, min: range.minCorrect, max: range.maxCorrect });
if (key === lastAppliedTemplateKey) return;
if (askConfirm && hasMeaningfulQuestions()) {
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();
lastAppliedTemplateKey = key;
}
function scheduleTemplateRebuild() {
if (templateRebuildTimer) clearTimeout(templateRebuildTimer);
templateRebuildTimer = setTimeout(() => buildTemplateFromControls({ askConfirm: true }), 200);
}
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (aiOCountEl) aiOCountEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (aiQCountEl) aiQCountEl.addEventListener('change', scheduleTemplateRebuild);
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);
})();