блоки 2 и 3 доработки интерфейса системы тестирования

This commit is contained in:
Константин Лебединский
2026-04-29 21:06:17 +05:00
parent eff3fda5b0
commit bba96f8f9f
37 changed files with 4440 additions and 1292 deletions
+127 -8
View File
@@ -6,6 +6,7 @@
<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>
@@ -15,6 +16,18 @@
</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>
@@ -24,12 +37,23 @@
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
@@ -51,38 +75,127 @@
}
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.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + '</p>';
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:pointer;">' +
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + (isSelected(q.id, o.id) ? 'checked' : '') + ' />' +
'<span>' + esc(o.text) + '</span>' +
'<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');
@@ -90,22 +203,26 @@
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
playData = data;
titleEl.textContent = data.testTitle || 'Прохождение теста';
subEl.textContent = 'Порог зачёта: ' + (data.passingThreshold ?? 0) + '%.';
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() {
async function submit(auto) {
submitBtn.disabled = true;
submitBtn.textContent = 'Отправка…';
submitBtn.textContent = auto ? 'Время вышло, отправка…' : 'Отправка…';
try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', {
method: 'POST',
@@ -114,6 +231,8 @@
});
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>' +
@@ -129,7 +248,7 @@
}
}
submitBtn.addEventListener('click', submit);
submitBtn.addEventListener('click', () => submit(false));
load();
})();
</script>