Files
Learn_System/frontend/js/exam-prep/mock.js
T
Maxim Dolgolyov 0b2e7c8880 fix(exam-prep): стилизованное окно завершения пробника вместо нативного confirm
Окно подтверждения завершения пробника использовало нативный confirm()
(и alert() при ошибке) — без стилей. Заменено на LS.confirm (стилизованный
модал) и LS.toast для ошибки завершения.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:47:12 +03:00

330 lines
13 KiB
JavaScript

'use strict';
/* ──────────────────────────────────────────────────────────────────
Mock exam view — three phases on the same page:
setup : pick source (variant / random N) + start
active : countdown timer + tasks (no auto-check) + finish
result : score + breakdown with solutions
URL: /exam-prep/:examKey/mock → setup
/exam-prep/:examKey/mock/:id → active | result (by session.status)
────────────────────────────────────────────────────────────────── */
(async function () {
await EP.boot();
const examKey = EP.examKey;
const main = document.getElementById('ep-main');
// Parse :id from path: /exam-prep/<key>/mock/<id>
const mockId = (() => {
const m = location.pathname.match(/\/mock\/(\d+)/);
return m ? Number(m[1]) : null;
})();
if (!mockId) {
renderSetup();
return;
}
// Load session + tasks
let payload;
try {
payload = await LS.api(`/api/exam-prep/mock/${mockId}`);
} catch (e) {
main.innerHTML = errorHtml('Не удалось загрузить пробник', e);
if (window.lucide) lucide.createIcons();
return;
}
if (payload.session.status === 'finished') {
renderResult(payload);
} else {
renderActive(payload);
}
/* ════════════════════════════════════════════════════════════
PHASE 1: SETUP
════════════════════════════════════════════════════════════ */
function renderSetup() {
const title = EP.info?.track?.title || 'Пробный экзамен';
const dur = EP.info?.track?.duration_min || 180;
const tpv = EP.info?.track?.tasks_per_variant || 10;
const vc = EP.info?.track?.variants_count || 80;
main.innerHTML = `
<div class="ep-card mk-setup">
<h3>Новый пробник</h3>
<p class="ep-card-hint">
${dur} минут · ${tpv} задач · в конце — балл по сетке и разбор каждого задания.
Во время прохождения ответы не проверяются и решения скрыты — как на реальном экзамене.
</p>
<div class="mk-source">
<div class="mk-source-card mk-source-active" data-src="variant">
<div class="mk-source-head">
<i data-lucide="layout-grid"></i>
<span>По варианту</span>
</div>
<div class="mk-source-body">
<label>Номер варианта:
<input type="number" min="1" max="${vc}" value="1" id="mk-variant-input" class="mk-input" />
</label>
<div class="mk-source-hint">Один из ${vc} реальных вариантов целиком.</div>
</div>
</div>
<div class="mk-source-card" data-src="random">
<div class="mk-source-head">
<i data-lucide="shuffle"></i>
<span>Случайные задачи</span>
</div>
<div class="mk-source-body">
<label>Количество:
<input type="number" min="5" max="30" value="${tpv}" id="mk-count-input" class="mk-input" />
</label>
<div class="mk-source-hint">Микс из всего банка (только mc + open).</div>
</div>
</div>
</div>
<div class="ep-cta-row mk-start-row">
<button class="ep-btn ep-btn-primary" id="mk-start">
<i data-lucide="play"></i> Начать пробник
</button>
</div>
</div>
`;
if (window.lucide) lucide.createIcons();
// Source selection
let source = 'variant';
main.querySelectorAll('.mk-source-card').forEach(c => {
c.onclick = () => {
source = c.dataset.src;
main.querySelectorAll('.mk-source-card').forEach(x =>
x.classList.toggle('mk-source-active', x === c));
};
});
document.getElementById('mk-start').onclick = startMock;
async function startMock() {
const btn = document.getElementById('mk-start');
btn.disabled = true; btn.textContent = 'Запуск…';
const body = { source };
if (source === 'variant') {
const v = Number(document.getElementById('mk-variant-input').value);
if (!Number.isInteger(v) || v < 1) {
btn.disabled = false; btn.innerHTML = '<i data-lucide="play"></i> Начать пробник';
if (window.lucide) lucide.createIcons();
return alert('Введите номер варианта');
}
body.variant = v;
} else {
body.count = Number(document.getElementById('mk-count-input').value) || 10;
}
try {
const r = await EP.api.startMock(examKey, body);
location.href = `/exam-prep/${examKey}/mock/${r.id}`;
} catch (e) {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="play"></i> Начать пробник';
if (window.lucide) lucide.createIcons();
alert(`Не удалось начать: ${e.message || e}`);
}
}
}
/* ════════════════════════════════════════════════════════════
PHASE 2: ACTIVE
════════════════════════════════════════════════════════════ */
function renderActive(payload) {
const { session, tasks } = payload;
const startMs = session.started_at;
const totalMs = session.duration_planned_min * 60 * 1000;
const sourceLabel = session.source === 'variant'
? `Вариант ${session.variant}`
: `Случайные ${tasks.length} задач`;
main.innerHTML = `
<div class="mk-bar">
<div class="mk-bar-info">
<span class="mk-bar-source">${sourceLabel}</span>
<span class="mk-bar-count" id="mk-answered">0/${tasks.length} отвечено</span>
</div>
<div class="mk-timer" id="mk-timer">--:--:--</div>
<button class="ep-btn ep-btn-primary mk-finish-btn" id="mk-finish">
<i data-lucide="flag"></i> Завершить
</button>
</div>
<div class="mk-tasks" id="mk-tasks"></div>
`;
const taskContainer = document.getElementById('mk-tasks');
const answeredSet = new Set();
tasks.forEach((task, i) => {
// Prefill if resuming
if (task.user_answer != null) answeredSet.add(task.id);
EP.TaskCard.render(taskContainer, task, {
mode: 'mock',
sessionId: session.id,
autoCheck: false,
showSolution: false,
numbering: i + 1,
prefillAnswer: task.user_answer ?? null,
onAnswerChange: (taskId, value) => {
// Save (best-effort). Empty → don't bother
if (value == null || value === '') return;
EP.api.mockAnswer(session.id, {
exam_task_id: taskId,
user_answer: value,
}).then(() => {
if (!answeredSet.has(taskId)) {
answeredSet.add(taskId);
updateAnsweredCount();
}
}).catch(() => { /* silent — user can finish anyway */ });
},
});
});
function updateAnsweredCount() {
const el = document.getElementById('mk-answered');
if (el) el.textContent = `${answeredSet.size}/${tasks.length} отвечено`;
}
updateAnsweredCount();
/* Timer */
let timerInterval = null;
function tick() {
const left = startMs + totalMs - Date.now();
const el = document.getElementById('mk-timer');
if (!el) { clearInterval(timerInterval); return; }
if (left <= 0) {
el.textContent = '00:00:00';
el.classList.add('mk-timer-zero');
clearInterval(timerInterval);
finish(true);
return;
}
const h = Math.floor(left / 3600000);
const m = Math.floor((left % 3600000) / 60000);
const s = Math.floor((left % 60000) / 1000);
el.textContent =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
if (left < 10 * 60 * 1000) el.classList.add('mk-timer-warn');
}
tick();
timerInterval = setInterval(tick, 1000);
/* Finish */
const finishBtn = document.getElementById('mk-finish');
finishBtn.onclick = async () => {
if (answeredSet.size < tasks.length) {
const left = tasks.length - answeredSet.size;
const ok = await LS.confirm(
`Не отвечено заданий: ${left}.\nЗавершить пробник сейчас?`,
{ title: 'Завершить пробник?', confirmText: 'Завершить', danger: true }
);
if (!ok) return;
}
finish(false);
};
async function finish(autoExpired) {
clearInterval(timerInterval);
finishBtn.disabled = true;
finishBtn.innerHTML = '<i data-lucide="loader-circle"></i> Подведение итогов…';
if (window.lucide) lucide.createIcons();
try {
await EP.api.mockFinish(session.id);
location.reload(); // will render result phase
} catch (e) {
LS.toast(`Не удалось завершить: ${e.message || e}`, 'error');
finishBtn.disabled = false;
finishBtn.innerHTML = '<i data-lucide="flag"></i> Завершить';
if (window.lucide) lucide.createIcons();
}
}
if (window.lucide) lucide.createIcons();
}
/* ════════════════════════════════════════════════════════════
PHASE 3: RESULT
════════════════════════════════════════════════════════════ */
function renderResult(payload) {
const { session, tasks } = payload;
const dur = (session.finished_at - session.started_at) / 1000;
const h = Math.floor(dur / 3600), m = Math.floor((dur % 3600) / 60);
const durStr = h ? `${h} ч ${m} мин` : `${m} мин`;
const acc = session.total_tasks
? Math.round((session.total_correct / session.total_tasks) * 100)
: 0;
main.innerHTML = `
<div class="ep-card mk-result">
<h3>Результат пробника</h3>
<div class="ep-stats mk-result-stats">
<div class="ep-stat">
<div class="ep-stat-label">Балл</div>
<div class="ep-stat-value ep-violet" style="font-size:2.2rem">${session.score != null ? session.score : '—'}</div>
<div class="ep-stat-sub">по сетке экзамена</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Верно</div>
<div class="ep-stat-value ${acc >= 70 ? 'ep-good' : 'ep-warn'}">${session.total_correct}/${session.total_tasks}</div>
<div class="ep-stat-sub">${acc}% точности</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Время</div>
<div class="ep-stat-value">${durStr}</div>
<div class="ep-stat-sub">из ${session.duration_planned_min} мин</div>
</div>
</div>
<div class="ep-cta-row">
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/mock">
<i data-lucide="rotate-cw"></i> Новый пробник
</a>
<a class="ep-btn" href="/exam-prep/${examKey}">
<i data-lucide="gauge"></i> На дашборд
</a>
</div>
</div>
<h3 class="mk-breakdown-title">Разбор задач</h3>
<div id="mk-breakdown"></div>
`;
const container = document.getElementById('mk-breakdown');
tasks.forEach((task, i) => {
EP.TaskCard.render(container, task, {
mode: 'mock',
sessionId: session.id,
autoCheck: false,
showSolution: true,
readonly: true,
numbering: i + 1,
prefillAnswer: task.user_answer ?? null,
forceVerdict: task.is_correct != null ? { isCorrect: task.is_correct } : null,
});
});
if (window.lucide) lucide.createIcons();
}
/* ── utils ──────────────────────────────────────────────────── */
function errorHtml(title, e) {
return `<div class="ep-empty">
<i data-lucide="alert-triangle"></i>
<h4>${escapeHtml(title)}</h4>
<p>${escapeHtml(e?.message || String(e))}</p>
</div>`;
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
})();