You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
405 lines
15 KiB
405 lines
15 KiB
/** |
|
* Прохождение теста: один вопрос на экран, прогресс сверху. |
|
*/ |
|
(() => { |
|
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) => ({ |
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', |
|
}[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, '"')}"> |
|
<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(); |
|
})();
|
|
|