3 changed files with 673 additions and 239 deletions
@ -0,0 +1,324 @@
|
||||
/** |
||||
* Прохождение теста: один вопрос на экран, прогресс сверху. |
||||
*/ |
||||
(() => { |
||||
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'); |
||||
|
||||
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 = {}; |
||||
let timerHandle = null; |
||||
let deadlineMs = null; |
||||
let currentIdx = 0; |
||||
|
||||
function esc(s) { |
||||
return String(s ?? '').replace(/[&<>"']/g, (m) => ({ |
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', |
||||
}[m])); |
||||
} |
||||
|
||||
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 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 (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(); |
||||
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; |
||||
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 (!minutes || minutes <= 0) return; |
||||
deadlineMs = Date.now() + minutes * 60 * 1000; |
||||
timerEl.style.display = ''; |
||||
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 || 'Прохождение теста'; |
||||
const parts = [`Порог зачёта ${data.passingThreshold ?? 0}%`]; |
||||
if (data.resultMode === 'immediate') parts.push('обратная связь после каждого ответа'); |
||||
if (data.hintsEnabled) parts.push('с подсказками'); |
||||
subEl.textContent = parts.join(' · '); |
||||
if (!Array.isArray(data.questions) || !data.questions.length) { |
||||
setErr('В активной версии нет вопросов.'); |
||||
btnNext.disabled = true; |
||||
btnFinish.disabled = true; |
||||
return; |
||||
} |
||||
currentIdx = 0; |
||||
renderStep(); |
||||
if (data.timeLimit) 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 = ''; |
||||
resultEl.innerHTML = |
||||
`<h3 class="attempt-result-title font-headline">Результат</h3>` |
||||
+ `<p>Правильно: <strong>${data.correctCount}</strong> из ${data.totalQuestions}` |
||||
+ ` (${data.percent}%). Порог: ${data.passingThreshold}%.</p>` |
||||
+ `<p class="${data.passed ? 'attempt-passed' : 'attempt-failed'}">${data.passed ? 'Зачёт' : 'Незачёт'}</p>` |
||||
+ `<p><a class="link-back" href="/tests/${testId}/attempts/${data.attemptId}/review">Разбор попытки</a></p>`; |
||||
} catch (e) { |
||||
setErr(e.message); |
||||
btnFinish.disabled = false; |
||||
btnFinish.textContent = 'Завершить тест'; |
||||
btnPrev.disabled = false; |
||||
btnNext.disabled = false; |
||||
if (playData) updateNav(); |
||||
} |
||||
} |
||||
|
||||
load(); |
||||
})(); |
||||
Loading…
Reference in new issue