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.
255 lines
11 KiB
255 lines
11 KiB
{% extends "base.html" %} |
|
{% block title %}Прохождение теста{% endblock %} |
|
|
|
{% block content %} |
|
<div class="test-detail-page" id="attempt-root" data-test-id="{{ test_id }}" data-attempt-id="{{ attempt_id }}"> |
|
<p class="link-back"><a href="/tests">← к списку тестов</a></p> |
|
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;" id="attempt-title">Загрузка…</h1> |
|
<p class="text-muted" style="margin-top:0;" id="attempt-subtitle"></p> |
|
<p class="text-muted" id="attempt-timer" style="margin-top:0;display:none;font-weight:600;"></p> |
|
<p class="error-text" id="attempt-error" style="display:none;"></p> |
|
|
|
<ol id="questions-list" style="padding-left:1.25rem;"></ol> |
|
|
|
<div class="inline-actions" style="margin-top:1rem;"> |
|
<button type="button" class="btn btn-primary" id="submit-attempt-btn">Завершить тест</button> |
|
</div> |
|
|
|
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div> |
|
|
|
<dialog id="hint-modal" style="border:none;border-radius:14px;padding:0;max-width:480px;width:90%;box-shadow:0 12px 40px rgba(0,0,0,0.18);"> |
|
<div style="padding:1rem 1.25rem;"> |
|
<h3 id="hint-title" style="margin:0 0 0.5rem 0;font-size:1.05rem;">Подсказка</h3> |
|
<p id="hint-verdict" style="margin:0 0 0.5rem 0;font-weight:600;"></p> |
|
<p id="hint-correct" style="margin:0 0 0.5rem 0;font-size:0.9rem;color:#506965;"></p> |
|
<p id="hint-explanation" style="margin:0;font-size:0.95rem;line-height:1.4;"></p> |
|
<div class="inline-actions" style="margin-top:0.85rem;justify-content:flex-end;"> |
|
<button type="button" class="btn btn-primary btn--sm" id="hint-close-btn">Понятно</button> |
|
</div> |
|
</div> |
|
</dialog> |
|
</div> |
|
|
|
<script> |
|
(() => { |
|
const root = document.getElementById('attempt-root'); |
|
const testId = root.dataset.testId; |
|
const attemptId = root.dataset.attemptId; |
|
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 listEl = document.getElementById('questions-list'); |
|
const resultEl = document.getElementById('attempt-result'); |
|
const submitBtn = document.getElementById('submit-attempt-btn'); |
|
const hintModal = document.getElementById('hint-modal'); |
|
const hintTitle = document.getElementById('hint-title'); |
|
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; |
|
|
|
function esc(s) { |
|
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); |
|
} |
|
function setErr(msg) { |
|
errEl.textContent = msg || 'Ошибка.'; |
|
errEl.style.display = ''; |
|
} |
|
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 renderQuestions() { |
|
listEl.innerHTML = ''; |
|
for (const q of (playData.questions || [])) { |
|
const qid = String(q.id); |
|
const isChecked = !!checked[qid]; |
|
const li = document.createElement('li'); |
|
li.style.marginBottom = '1.5rem'; |
|
li.dataset.qid = qid; |
|
let badge = ''; |
|
if (isChecked) { |
|
const ok = checked[qid].isCorrect; |
|
badge = '<span style="margin-left:8px;font-size:0.8rem;font-weight:600;color:' + |
|
(ok ? '#1a7a4a' : '#b32d2d') + ';">' + (ok ? '✓ верно' : '✗ неверно') + '</span>'; |
|
} |
|
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + badge + '</p>'; |
|
const ul = document.createElement('ul'); |
|
ul.style.listStyle = 'none'; |
|
ul.style.padding = '0'; |
|
ul.style.margin = '0'; |
|
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.style.marginBottom = '6px'; |
|
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio'; |
|
const name = 'q-' + q.id; |
|
let mark = ''; |
|
if (isChecked) { |
|
if (correctSet.has(oid)) mark = ' <span style="color:#1a7a4a;font-weight:600;">(верно)</span>'; |
|
else if (isSelected(q.id, o.id)) mark = ' <span style="color:#b32d2d;">(ваш ответ)</span>'; |
|
} |
|
row.innerHTML = |
|
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:' + (isChecked ? 'default' : 'pointer') + ';' + |
|
(isChecked ? 'opacity:0.85;' : '') + '">' + |
|
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + |
|
(isSelected(q.id, o.id) ? 'checked' : '') + ' ' + (isChecked ? 'disabled' : '') + ' />' + |
|
'<span>' + esc(o.text) + mark + '</span>' + |
|
'</label>'; |
|
const input = row.querySelector('input'); |
|
input.addEventListener('change', () => { |
|
if (checked[qid]) return; |
|
toggle(q.id, o.id, q.hasMultipleAnswers); |
|
renderQuestions(); |
|
}); |
|
ul.appendChild(row); |
|
} |
|
li.appendChild(ul); |
|
|
|
if (isImmediate() && !isChecked) { |
|
const btn = document.createElement('button'); |
|
btn.type = 'button'; |
|
btn.className = 'btn btn-ghost btn--sm'; |
|
btn.textContent = 'Ответить'; |
|
btn.style.marginTop = '0.4rem'; |
|
const sel = selections[qid] || []; |
|
btn.disabled = sel.length === 0; |
|
btn.addEventListener('click', () => checkOne(q.id)); |
|
li.appendChild(btn); |
|
} |
|
listEl.appendChild(li); |
|
} |
|
} |
|
|
|
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; |
|
renderQuestions(); |
|
if (playData.hintsEnabled) { |
|
showHint(data); |
|
} |
|
} catch (e) { |
|
setErr(e.message); |
|
} |
|
} |
|
|
|
function showHint(data) { |
|
hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.'; |
|
hintVerdict.style.color = data.isCorrect ? '#1a7a4a' : '#b32d2d'; |
|
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'); |
|
}); |
|
|
|
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('В активной версии нет вопросов.'); |
|
submitBtn.disabled = true; |
|
return; |
|
} |
|
renderQuestions(); |
|
if (data.timeLimit) startTimer(Number(data.timeLimit)); |
|
} catch (e) { |
|
setErr(e.message); |
|
submitBtn.disabled = true; |
|
} |
|
} |
|
|
|
async function submit(auto) { |
|
submitBtn.disabled = true; |
|
submitBtn.textContent = auto ? 'Время вышло, отправка…' : 'Отправка…'; |
|
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'; |
|
resultEl.style.display = ''; |
|
resultEl.innerHTML = |
|
'<h3 style="margin-top:0;">Результат</h3>' + |
|
'<p>Правильно: <strong>' + data.correctCount + '</strong> из ' + data.totalQuestions + |
|
' (' + data.percent + '%). Порог: ' + data.passingThreshold + '%.</p>' + |
|
'<p class="' + (data.passed ? 'text-muted' : 'error-text') + '">' + (data.passed ? 'Зачёт.' : 'Незачёт.') + '</p>' + |
|
'<p><a href="/tests/' + testId + '/attempts/' + data.attemptId + '/review">Разбор попытки</a></p>'; |
|
submitBtn.style.display = 'none'; |
|
} catch (e) { |
|
setErr(e.message); |
|
submitBtn.disabled = false; |
|
submitBtn.textContent = 'Завершить тест'; |
|
} |
|
} |
|
|
|
submitBtn.addEventListener('click', () => submit(false)); |
|
load(); |
|
})(); |
|
</script> |
|
{% endblock %}
|
|
|