UI bugfixes with boss

This commit is contained in:
Константин Лебединский
2026-04-30 19:53:49 +05:00
parent df6e770f90
commit b72b485fce
17 changed files with 469 additions and 250 deletions
+35 -13
View File
@@ -1234,6 +1234,41 @@ body.ui-legacy .attempts-card-list__action {
color: #b42318;
}
.attempt-feedback-panel {
margin-top: 1rem;
padding: 0.85rem 1rem;
border-radius: 0.85rem;
border: 1px solid color-mix(in srgb, var(--outline-variant) 55%, transparent);
background: color-mix(in srgb, var(--surface-container-low) 88%, var(--surface));
}
.attempt-feedback-verdict {
font-weight: 700;
margin: 0 0 0.35rem;
font-size: 0.95rem;
}
.attempt-feedback-verdict--ok {
color: var(--primary);
}
.attempt-feedback-verdict--bad {
color: #b42318;
}
.attempt-feedback-correct {
margin: 0 0 0.5rem;
font-size: 0.88rem;
color: var(--on-surface-variant);
}
.attempt-feedback-explanation {
margin: 0;
font-size: 0.9rem;
line-height: 1.45;
color: var(--on-surface);
}
.attempt-answer-actions {
margin-top: 1.25rem;
padding-top: 0.25rem;
@@ -1309,19 +1344,6 @@ body.ui-legacy .attempts-card-list__action {
color: #b42318;
}
.attempt-hint-verdict {
font-weight: 700;
margin-bottom: 0.35rem;
}
.attempt-hint-verdict--ok {
color: var(--primary);
}
.attempt-hint-verdict--bad {
color: #b42318;
}
body.ui-modern .attempt-flow {
min-height: min(75dvh, 880px);
}
+48 -31
View File
@@ -22,12 +22,6 @@
const btnFinish = document.getElementById('attempt-finish');
const resultEl = document.getElementById('attempt-result');
const hintModal = document.getElementById('hint-modal');
const hintVerdict = document.getElementById('hint-verdict');
const hintCorrect = document.getElementById('hint-correct');
const hintExplanation = document.getElementById('hint-explanation');
const hintCloseBtn = document.getElementById('hint-close-btn');
let playData = null;
const selections = {};
const checked = {};
@@ -65,6 +59,41 @@
return playData && playData.resultMode === 'immediate';
}
function formatAttemptMeta(d) {
const qs = d.questions || [];
const n = qs.length;
const tl = d.timeLimit;
const timestr = !tl || Number(tl) <= 0 ? 'без ограничения' : `${tl} мин`;
const res = d.resultMode === 'immediate' ? 'сразу' : 'в конце';
const hint = d.resultMode !== 'immediate'
? 'недоступны'
: (d.hintsEnabled ? 'вкл' : 'выкл');
const th = d.passingThreshold ?? 0;
return `Порог: ${th}% · Вопросов: ${n} · Время: ${timestr} · Результат: ${res} · Подсказки: ${hint}`;
}
function renderFeedbackPanel(data) {
const wrap = document.createElement('div');
wrap.className = 'attempt-feedback-panel';
const ok = data.isCorrect;
const verdict = document.createElement('p');
verdict.className = `attempt-feedback-verdict ${ok ? 'attempt-feedback-verdict--ok' : 'attempt-feedback-verdict--bad'}`;
verdict.textContent = ok ? 'Верно!' : 'Неверно.';
wrap.appendChild(verdict);
const correct = (data.correctOptionTexts || []).join('; ');
if (correct) {
const p = document.createElement('p');
p.className = 'attempt-feedback-correct';
p.textContent = `Правильный ответ: ${correct}`;
wrap.appendChild(p);
}
const exp = document.createElement('p');
exp.className = 'attempt-feedback-explanation';
exp.textContent = data.explanation || 'Объяснение недоступно.';
wrap.appendChild(exp);
return wrap;
}
function stepAnswered(q) {
const k = String(q.id);
if (isImmediate()) return !!checked[k];
@@ -151,6 +180,10 @@
}
card.appendChild(ul);
if (isChk && isImmediate() && playData.hintsEnabled) {
card.appendChild(renderFeedbackPanel(checked[qid]));
}
if (isImmediate() && !isChk) {
const wrap = document.createElement('div');
wrap.className = 'attempt-answer-actions';
@@ -204,29 +237,11 @@
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
checked[k] = data;
renderStep();
if (playData.hintsEnabled) {
showHint(data);
}
} catch (e) {
setErr(e.message);
}
}
function showHint(data) {
hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.';
hintVerdict.className = `attempt-hint-verdict ${data.isCorrect ? 'attempt-hint-verdict--ok' : 'attempt-hint-verdict--bad'}`;
const correct = (data.correctOptionTexts || []).join('; ');
hintCorrect.textContent = correct ? (`Правильный ответ: ${correct}`) : '';
hintExplanation.textContent = data.explanation || 'Объяснение недоступно.';
if (typeof hintModal.showModal === 'function') hintModal.showModal();
else hintModal.setAttribute('open', '');
}
hintCloseBtn.addEventListener('click', () => {
if (typeof hintModal.close === 'function') hintModal.close();
else hintModal.removeAttribute('open');
});
btnPrev.addEventListener('click', () => {
if (currentIdx <= 0) return;
currentIdx -= 1;
@@ -243,9 +258,14 @@
btnFinish.addEventListener('click', () => submit(false));
function startTimer(minutes) {
if (!minutes || minutes <= 0) return;
deadlineMs = Date.now() + minutes * 60 * 1000;
if (!timerEl) return;
timerEl.style.display = '';
if (!minutes || minutes <= 0) {
deadlineMs = null;
timerEl.textContent = 'без ограничения';
return;
}
deadlineMs = Date.now() + minutes * 60 * 1000;
const tick = () => {
const left = Math.max(0, deadlineMs - Date.now());
const m = Math.floor(left / 60000);
@@ -267,10 +287,7 @@
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
playData = data;
titleEl.textContent = data.testTitle || 'Прохождение теста';
const parts = [`Порог зачёта ${data.passingThreshold ?? 0}%`];
if (data.resultMode === 'immediate') parts.push('обратная связь после каждого ответа');
if (data.hintsEnabled) parts.push('с подсказками');
subEl.textContent = parts.join(' · ');
subEl.textContent = formatAttemptMeta(data);
if (!Array.isArray(data.questions) || !data.questions.length) {
setErr('В активной версии нет вопросов.');
btnNext.disabled = true;
@@ -279,7 +296,7 @@
}
currentIdx = 0;
renderStep();
if (data.timeLimit) startTimer(Number(data.timeLimit));
startTimer(Number(data.timeLimit));
} catch (e) {
setErr(e.message);
btnFinish.disabled = true;
+184 -97
View File
@@ -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([