342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
/**
|
|
* Прохождение теста: один вопрос на экран, прогресс сверху.
|
|
*/
|
|
(() => {
|
|
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]));
|
|
}
|
|
|
|
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 = '';
|
|
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();
|
|
})();
|