Files
Константин Лебединский 9511fcb555 fix test editor UI and test completition UI
2026-04-30 20:54:37 +05:00

406 lines
15 KiB
JavaScript
Raw Permalink 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.
/**
* Прохождение теста: один вопрос на экран, прогресс сверху.
*/
(() => {
const root = document.getElementById('attempt-root');
if (!root) return;
const testId = root.dataset.testId;
const attemptId = root.dataset.attemptId;
const flowEl = document.getElementById('attempt-flow');
const titleEl = document.getElementById('attempt-title');
const subEl = document.getElementById('attempt-subtitle');
const timerEl = document.getElementById('attempt-timer');
const errEl = document.getElementById('attempt-error');
const stageEl = document.getElementById('attempt-stage');
const progressLabel = document.getElementById('attempt-progress-label');
const progressFill = document.getElementById('attempt-progress-fill');
const progressTrack = document.querySelector('.attempt-progress-track');
const btnPrev = document.getElementById('attempt-prev');
const btnNext = document.getElementById('attempt-next');
const btnFinish = document.getElementById('attempt-finish');
const resultEl = document.getElementById('attempt-result');
let playData = null;
const selections = {};
const checked = {};
let timerHandle = null;
let deadlineMs = null;
let currentIdx = 0;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (m) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[m]));
}
/** Доля круга 0..1 от верха по часовой; путь для stroke с round caps (viewBox 120). */
function attemptResultArcD(cx, cy, r, t0, t1) {
if (t1 <= t0 + 1e-9) return '';
if (t1 - t0 > 0.5 + 1e-6) {
const a = attemptResultArcD(cx, cy, r, t0, t0 + 0.5);
const b = attemptResultArcD(cx, cy, r, t0 + 0.5, t1);
return `${a} ${b.replace(/^M [\d.]+\s+[\d.]+\s+/, '')}`;
}
const angle = (t) => (-Math.PI / 2) + 2 * Math.PI * t;
const x0 = cx + r * Math.cos(angle(t0));
const y0 = cy + r * Math.sin(angle(t0));
const x1 = cx + r * Math.cos(angle(t1));
const y1 = cy + r * Math.sin(angle(t1));
const spanDeg = (t1 - t0) * 360;
const largeArc = spanDeg > 180 ? 1 : 0;
return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${largeArc} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
function setErr(msg) {
errEl.textContent = msg || 'Ошибка.';
errEl.style.display = msg ? 'block' : 'none';
}
function isSelected(qid, oid) {
return (selections[String(qid)] || []).includes(String(oid));
}
function toggle(qid, oid, multi) {
const k = String(qid);
const cur = selections[k] || [];
const id = String(oid);
if (multi) {
selections[k] = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
return;
}
selections[k] = [id];
}
function isImmediate() {
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];
return (selections[k] || []).length > 0;
}
function allAnswered() {
return (playData.questions || []).every((q) => stepAnswered(q));
}
function updateProgress() {
const qs = playData.questions || [];
const total = qs.length;
const n = Math.min(currentIdx + 1, Math.max(total, 1));
if (progressLabel) progressLabel.textContent = `${n} из ${total}`;
const pct = total <= 1 ? 100 : ((currentIdx + 1) / total) * 100;
if (progressFill) progressFill.style.width = `${pct}%`;
if (progressTrack) {
progressTrack.setAttribute('aria-valuenow', String(n));
progressTrack.setAttribute('aria-valuemax', String(total));
}
}
function renderStep() {
const qs = playData.questions || [];
const total = qs.length;
const q = qs[currentIdx];
stageEl.innerHTML = '';
if (!q) return;
updateProgress();
const card = document.createElement('article');
card.className = 'attempt-q-card surface-card';
card.dataset.qid = String(q.id);
const qid = String(q.id);
const isChk = !!checked[qid];
const meta = document.createElement('div');
meta.className = 'attempt-q-card__meta';
meta.innerHTML = `<span class="attempt-q-num">Вопрос ${currentIdx + 1}</span>`;
card.appendChild(meta);
const textP = document.createElement('div');
textP.className = 'attempt-q-text';
let badge = '';
if (isChk) {
const ok = checked[qid].isCorrect;
badge = `<span class="attempt-q-badge ${ok ? 'attempt-q-badge--ok' : 'attempt-q-badge--bad'}">${ok ? '✓ верно' : '✗ неверно'}</span>`;
}
textP.innerHTML = esc(q.text) + badge;
card.appendChild(textP);
const ul = document.createElement('ul');
ul.className = 'attempt-q-options';
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
for (const o of (q.options || [])) {
const oid = String(o.id);
const row = document.createElement('li');
row.className = 'attempt-opt-row';
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
const name = `q-${q.id}`;
let mark = '';
if (isChk) {
if (correctSet.has(oid)) mark = ' <span class="attempt-mark attempt-mark--ok">верно</span>';
else if (isSelected(q.id, o.id)) mark = ' <span class="attempt-mark attempt-mark--bad">ваш ответ</span>';
}
row.innerHTML =
`<label class="attempt-opt-label ${isChk ? 'attempt-opt-label--locked' : ''}">`
+ `<input type="${type}" ${q.hasMultipleAnswers ? '' : `name="${esc(name)}"`} class="attempt-opt-input" />`
+ `<span class="attempt-opt-text">${esc(o.text)}${mark}</span>`
+ '</label>';
const input = row.querySelector('input');
if (isSelected(q.id, o.id)) input.checked = true;
if (isChk) input.disabled = true;
input.addEventListener('change', () => {
if (checked[qid]) return;
toggle(q.id, o.id, q.hasMultipleAnswers);
renderStep();
});
ul.appendChild(row);
}
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';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-primary attempt-reply-btn';
btn.textContent = 'Ответить';
const sel = selections[qid] || [];
btn.disabled = sel.length === 0;
btn.addEventListener('click', () => checkOne(q.id));
wrap.appendChild(btn);
card.appendChild(wrap);
}
stageEl.appendChild(card);
updateNav();
}
function updateNav() {
const qs = playData.questions || [];
const total = qs.length;
const q = qs[currentIdx];
if (!q) return;
const ok = stepAnswered(q);
btnPrev.disabled = currentIdx <= 0;
btnPrev.toggleAttribute('hidden', currentIdx <= 0);
const last = currentIdx >= total - 1;
btnNext.style.display = last ? 'none' : '';
btnFinish.style.display = last ? '' : 'none';
if (last) {
btnFinish.disabled = !allAnswered();
} else {
btnNext.disabled = !ok;
}
}
async function checkOne(qid) {
const k = String(qid);
const sel = selections[k] || [];
if (!sel.length) return;
try {
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
checked[k] = data;
renderStep();
} catch (e) {
setErr(e.message);
}
}
btnPrev.addEventListener('click', () => {
if (currentIdx <= 0) return;
currentIdx -= 1;
renderStep();
});
btnNext.addEventListener('click', () => {
const total = (playData.questions || []).length;
if (currentIdx >= total - 1) return;
currentIdx += 1;
renderStep();
});
btnFinish.addEventListener('click', () => submit(false));
function startTimer(minutes) {
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);
const s = Math.floor((left % 60000) / 1000);
timerEl.textContent = `Осталось ${m}:${String(s).padStart(2, '0')}`;
if (left <= 0) {
clearInterval(timerHandle);
submit(true);
}
};
tick();
timerHandle = setInterval(tick, 500);
}
async function load() {
try {
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/play`);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
playData = data;
titleEl.textContent = data.testTitle || 'Прохождение теста';
subEl.textContent = formatAttemptMeta(data);
if (!Array.isArray(data.questions) || !data.questions.length) {
setErr('В активной версии нет вопросов.');
btnNext.disabled = true;
btnFinish.disabled = true;
return;
}
currentIdx = 0;
renderStep();
startTimer(Number(data.timeLimit));
} catch (e) {
setErr(e.message);
btnFinish.disabled = true;
}
}
async function submit(auto) {
btnFinish.disabled = true;
btnNext.disabled = true;
btnPrev.disabled = true;
const label = auto ? 'Время вышло…' : 'Отправка…';
btnFinish.textContent = label;
try {
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: selections }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.');
if (timerHandle) clearInterval(timerHandle);
timerEl.style.display = 'none';
if (flowEl) flowEl.style.display = 'none';
resultEl.style.display = '';
const pct = Math.min(100, Math.max(0, Number(data.percent) || 0));
const thr = Math.min(100, Math.max(0, Number(data.passingThreshold) || 0));
const passed = !!data.passed;
const thrArc = passed ? pct : Math.min(100, Math.max(pct, thr));
const centerIcon = passed ? 'check' : 'close';
const verdictLabel = passed ? 'Зачёт' : 'Незачёт';
const ariaRing = passed
? `Зачёт: ${pct} процентов верных ответов`
: `Незачёт: ${pct} процентов, порог ${thr} процентов`;
const CX = 60;
const CY = 60;
const RAD = 48;
const tScore = pct / 100;
const tThr = thrArc / 100;
const trackCircle = `<circle class="attempt-result-svg__track" cx="${CX}" cy="${CY}" r="${RAD}" fill="none" />`;
let arcPaths = '';
if (passed) {
const dPass = attemptResultArcD(CX, CY, RAD, 0, tScore);
if (dPass) {
arcPaths += `<path class="attempt-result-svg__pass" d="${esc(dPass)}" fill="none" />`;
}
} else {
const dGap = tThr > tScore + 1e-9 ? attemptResultArcD(CX, CY, RAD, tScore, tThr) : '';
const dScore = tScore > 1e-9 ? attemptResultArcD(CX, CY, RAD, 0, tScore) : '';
if (dGap) arcPaths += `<path class="attempt-result-svg__fail-gap" d="${esc(dGap)}" fill="none" />`;
if (dScore) arcPaths += `<path class="attempt-result-svg__fail-score" d="${esc(dScore)}" fill="none" />`;
}
const donutSvg = `<svg class="attempt-result-donut__svg" viewBox="0 0 120 120" aria-hidden="true">${trackCircle}${arcPaths}</svg>`;
resultEl.innerHTML = `
<div class="attempt-result-card__inner">
<h3 class="attempt-result-title font-headline">Результат</h3>
<div class="attempt-result-visual" aria-label="${ariaRing.replace(/"/g, '&quot;')}">
<div class="attempt-result-donut">
${donutSvg}
<div class="attempt-result-donut__disk" aria-hidden="true"></div>
<div class="attempt-result-donut__center" data-passed="${passed ? '1' : '0'}">
<span class="material-symbols-outlined attempt-result-donut__icon" aria-hidden="true">${centerIcon}</span>
</div>
</div>
<p class="attempt-result-verdict" data-passed="${passed ? '1' : '0'}">
<span class="attempt-result-verdict__label">${verdictLabel}</span>
</p>
</div>
<ul class="attempt-result-stats">
<li><span class="attempt-result-stats__k">Верно</span> <strong>${data.correctCount}</strong> из ${data.totalQuestions} (${pct}%)</li>
<li><span class="attempt-result-stats__k">Порог зачёта</span> <strong>${thr}%</strong></li>
</ul>
<p class="attempt-result-actions">
<a class="btn btn-primary attempt-result-review-link"
href="/tests/${testId}/attempts/${data.attemptId}/review">Разбор попытки</a>
</p>
</div>`;
} catch (e) {
setErr(e.message);
btnFinish.disabled = false;
btnFinish.textContent = 'Завершить тест';
btnPrev.disabled = false;
btnNext.disabled = false;
if (playData) updateNav();
}
}
load();
})();