UI bugfixes with boss
This commit is contained in:
@@ -34,9 +34,6 @@
|
||||
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');
|
||||
@@ -365,14 +362,36 @@
|
||||
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', syncThresholdMirror);
|
||||
thresholdEl.addEventListener('change', syncThresholdMirror);
|
||||
thresholdEl.addEventListener('input', syncHeroMetaRow);
|
||||
thresholdEl.addEventListener('change', syncHeroMetaRow);
|
||||
}
|
||||
if (titleEl && titleEl.tagName === 'TEXTAREA') {
|
||||
titleEl.addEventListener('input', () => {
|
||||
@@ -396,7 +415,7 @@
|
||||
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
|
||||
thresholdEl.value =
|
||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||
syncThresholdMirror();
|
||||
syncHeroMetaRow();
|
||||
|
||||
const timeLimitEl = document.getElementById('test-time-limit');
|
||||
const hintsEl = document.getElementById('test-hints-enabled');
|
||||
@@ -405,7 +424,10 @@
|
||||
|
||||
if (timeLimitEl) {
|
||||
timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit);
|
||||
timeLimitEl.addEventListener('input', scheduleDirtyCheck);
|
||||
timeLimitEl.addEventListener('input', () => {
|
||||
scheduleDirtyCheck();
|
||||
syncEditorHeroExtra();
|
||||
});
|
||||
}
|
||||
const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end';
|
||||
resultModeRadios.forEach((r) => {
|
||||
@@ -414,8 +436,8 @@
|
||||
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;
|
||||
syncHeroMetaRow();
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
});
|
||||
@@ -424,12 +446,11 @@
|
||||
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';
|
||||
syncHeroMetaRow();
|
||||
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)));
|
||||
@@ -519,6 +540,60 @@
|
||||
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(() => ({}));
|
||||
@@ -572,6 +647,7 @@
|
||||
});
|
||||
if (r2.ok) chainActive = chainActiveEl.checked;
|
||||
}
|
||||
await refreshMetaAfterSave();
|
||||
resetBaselineDraft();
|
||||
const msg = data.forked
|
||||
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
|
||||
@@ -587,23 +663,36 @@
|
||||
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 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;
|
||||
}
|
||||
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;
|
||||
} else if (saveMsg) {
|
||||
saveMsg.textContent = msg;
|
||||
}
|
||||
} catch (err) {
|
||||
if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)';
|
||||
@@ -972,18 +1061,6 @@
|
||||
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;
|
||||
@@ -1205,6 +1282,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
/** Перемешивает строки вариантов в 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();
|
||||
@@ -1278,6 +1368,7 @@
|
||||
dIdx++;
|
||||
}
|
||||
});
|
||||
shuffleQuestionOptionsDom(node);
|
||||
}
|
||||
|
||||
syncOptionInputTypes(node);
|
||||
@@ -1507,73 +1598,69 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Создать шаблон ────────────────────────────────────────────
|
||||
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' });
|
||||
// ─── Автосоздание шаблона ──────────────────────────────────────
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
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 || 'Не удалось сгенерировать подсказки.');
|
||||
try {
|
||||
await refreshHintsInForm();
|
||||
} catch (_) {
|
||||
// Статус покажем как успешный; пользователь может перезагрузить страницу.
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
if (generateHintsBtn) {
|
||||
generateHintsBtn.addEventListener('click', generateHintsForCurrentTest);
|
||||
function scheduleTemplateRebuild() {
|
||||
if (templateRebuildTimer) clearTimeout(templateRebuildTimer);
|
||||
templateRebuildTimer = setTimeout(() => buildTemplateFromControls({ askConfirm: true }), 200);
|
||||
}
|
||||
|
||||
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', syncTemplateRangeUi);
|
||||
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('input', syncTemplateRangeUi);
|
||||
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('input', syncTemplateRangeUi);
|
||||
if (aiOCountEl) aiOCountEl.addEventListener('input', syncTemplateRangeUi);
|
||||
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([
|
||||
|
||||
Reference in New Issue
Block a user