feat(exam-prep F9): пробный экзамен — setup/active/result + таймер + балл по сетке + серверный чекер
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
'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 = () => {
|
||||
if (answeredSet.size < tasks.length) {
|
||||
const left = tasks.length - answeredSet.size;
|
||||
if (!confirm(`Не отвечено: ${left}. Завершить пробник сейчас?`)) 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) {
|
||||
alert(`Не удалось завершить: ${e.message || e}`);
|
||||
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 => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||
}
|
||||
})();
|
||||
@@ -11,8 +11,12 @@
|
||||
opts : {
|
||||
mode : 'variant'|'practice'|'topic'|'mock'
|
||||
sessionId : number | null (groups attempts in a session)
|
||||
autoCheck : true (whether check button is shown; mock=false)
|
||||
showSolution : true (whether solution toggle exists; mock=false)
|
||||
autoCheck : true (whether check button + verdict UI is shown)
|
||||
showSolution : true (whether solution toggle exists)
|
||||
readonly : false (inputs disabled; for finished mock review)
|
||||
prefillAnswer : string | null (initial user_answer; for mock review)
|
||||
forceVerdict : {isCorrect:0|1} | null (show verdict badge as if checked)
|
||||
onAnswerChange : (taskId, value) => void (per-keystroke, debounced; mock auto-save)
|
||||
onAttempt : (result) => void (notify parent after check or solution-view)
|
||||
numbering : number | null (override task number badge)
|
||||
}
|
||||
@@ -46,46 +50,55 @@
|
||||
/* Render one task into `container`. Returns a controller object with .destroy(). */
|
||||
function render(container, task, opts = {}) {
|
||||
const mode = opts.mode || 'variant';
|
||||
const showAns = opts.autoCheck !== false;
|
||||
const autoCheck = opts.autoCheck !== false;
|
||||
const showSol = opts.showSolution !== false;
|
||||
const readonly = !!opts.readonly;
|
||||
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
|
||||
const sessionId = opts.sessionId || null;
|
||||
const onAttempt = opts.onAttempt || (() => {});
|
||||
const onAnswerChange = opts.onAnswerChange || null;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'tc-card';
|
||||
card.dataset.taskId = String(task.id);
|
||||
card.dataset.taskType = task.type;
|
||||
|
||||
// ── Inner skeleton
|
||||
// ── Build input area by task type × autoCheck combo
|
||||
let inputBlock = '';
|
||||
if (showAns) {
|
||||
if (task.type === 'mc' && task.opts) {
|
||||
inputBlock = `
|
||||
${buildOptsBlock(task.id, task.opts)}
|
||||
<div class="tc-action-row">
|
||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
||||
<div class="tc-verdict" data-tc-verdict hidden></div>
|
||||
</div>`;
|
||||
} else if (task.type === 'open') {
|
||||
inputBlock = `
|
||||
<div class="tc-input-row">
|
||||
<label class="tc-ans-label">Ответ:</label>
|
||||
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
||||
placeholder="например, 9/4 или -2" data-tc-text />
|
||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
||||
const verdictSlot = `<div class="tc-verdict" data-tc-verdict hidden></div>`;
|
||||
const checkBtnRow = autoCheck
|
||||
? `<div class="tc-action-row">
|
||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
||||
${verdictSlot}
|
||||
</div>`
|
||||
: verdictSlot;
|
||||
|
||||
if (task.type === 'mc' && task.opts) {
|
||||
inputBlock = buildOptsBlock(task.id, task.opts) + checkBtnRow;
|
||||
} else if (task.type === 'open') {
|
||||
inputBlock = `
|
||||
<div class="tc-input-row">
|
||||
<label class="tc-ans-label">Ответ:</label>
|
||||
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
||||
placeholder="например, 9/4 или -2" data-tc-text />
|
||||
</div>` + checkBtnRow;
|
||||
} else if (task.type === 'long' && autoCheck) {
|
||||
inputBlock = `
|
||||
<div class="tc-self-mark">
|
||||
<span class="tc-self-mark-label">Развёрнутый ответ — проверьте себя по решению, затем отметьте:</span>
|
||||
<div class="tc-self-mark-btns">
|
||||
<button class="tc-self-btn tc-self-yes" data-tc-self="1">${ICONS.check} Я решил</button>
|
||||
<button class="tc-self-btn tc-self-no" data-tc-self="0">${ICONS.cross} Не решил</button>
|
||||
</div>
|
||||
<div class="tc-verdict" data-tc-verdict hidden></div>`;
|
||||
} else if (task.type === 'long') {
|
||||
inputBlock = `
|
||||
<div class="tc-self-mark">
|
||||
<span class="tc-self-mark-label">Развёрнутый ответ — проверьте себя по решению, затем отметьте:</span>
|
||||
<div class="tc-self-mark-btns">
|
||||
<button class="tc-self-btn tc-self-yes" data-tc-self="1">${ICONS.check} Я решил</button>
|
||||
<button class="tc-self-btn tc-self-no" data-tc-self="0">${ICONS.cross} Не решил</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
</div>`;
|
||||
} else if (task.type === 'long' && !autoCheck) {
|
||||
// Mock + long: short free-text answer
|
||||
inputBlock = `
|
||||
<div class="tc-input-row">
|
||||
<label class="tc-ans-label">Ответ:</label>
|
||||
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
||||
placeholder="кратко ваш ответ" data-tc-text />
|
||||
</div>` + verdictSlot;
|
||||
}
|
||||
|
||||
const solBlock = (showSol && task.solution) ? `
|
||||
@@ -119,18 +132,57 @@
|
||||
let attemptCount = 0; // how many CHECK attempts made
|
||||
let firstAttemptCorrect = null; // we report this in onAttempt
|
||||
|
||||
// ── Input enable on first interaction
|
||||
// ── Prefill answer (mock review or resumed mock session)
|
||||
if (opts.prefillAnswer != null) {
|
||||
const text = card.querySelector('[data-tc-text]');
|
||||
if (text) text.value = String(opts.prefillAnswer);
|
||||
const radios = card.querySelectorAll('input[type="radio"]');
|
||||
radios.forEach(r => { if (r.value === String(opts.prefillAnswer)) r.checked = true; });
|
||||
}
|
||||
|
||||
// ── Force verdict (mock review: server has graded; show result without check button)
|
||||
if (opts.forceVerdict && opts.forceVerdict.isCorrect != null) {
|
||||
applyVerdictReadonly(opts.forceVerdict.isCorrect);
|
||||
}
|
||||
|
||||
// ── Readonly: disable all inputs (mock review)
|
||||
if (readonly) {
|
||||
card.querySelectorAll('input').forEach(el => el.disabled = true);
|
||||
card.dataset.tcLocked = '1';
|
||||
}
|
||||
|
||||
// ── Input enable on first interaction + auto-save (mock)
|
||||
const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]');
|
||||
const checkBtn = card.querySelector('[data-tc-check]');
|
||||
let saveDebounce = null;
|
||||
inputs.forEach(inp => {
|
||||
inp.addEventListener('input', () => updateCheckEnabled());
|
||||
inp.addEventListener('change', () => updateCheckEnabled());
|
||||
inp.addEventListener('input', () => { updateCheckEnabled(); maybeAutoSave(); });
|
||||
inp.addEventListener('change', () => { updateCheckEnabled(); maybeAutoSave(); });
|
||||
});
|
||||
function updateCheckEnabled() {
|
||||
if (!checkBtn) return;
|
||||
const has = readUserAnswer() !== null;
|
||||
checkBtn.disabled = !has || card.dataset.tcLocked === '1';
|
||||
}
|
||||
function maybeAutoSave() {
|
||||
if (!onAnswerChange || readonly) return;
|
||||
const v = readUserAnswer();
|
||||
// debounce per-keystroke text input; radio changes save immediately
|
||||
clearTimeout(saveDebounce);
|
||||
const delay = card.querySelector('input[type="radio"]:checked') ? 0 : 450;
|
||||
saveDebounce = setTimeout(() => onAnswerChange(task.id, v), delay);
|
||||
}
|
||||
|
||||
function applyVerdictReadonly(isCorrect) {
|
||||
card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong');
|
||||
const verdict = card.querySelector('[data-tc-verdict]');
|
||||
if (verdict) {
|
||||
verdict.hidden = false;
|
||||
verdict.innerHTML = isCorrect
|
||||
? `<span class="tc-verdict-ok">${ICONS.check} Правильно</span>`
|
||||
: `<span class="tc-verdict-bad">${ICONS.cross} Неправильно</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function readUserAnswer() {
|
||||
const mcGroup = card.querySelector('[data-tc-mc]');
|
||||
@@ -255,6 +307,18 @@
|
||||
EP.api.saveAttempt(body).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Review mode: auto-open the solution panel for instant context
|
||||
if (opts.forceVerdict && showSol) {
|
||||
const sb = card.querySelector('[data-tc-sol]');
|
||||
const sp = card.querySelector('[data-tc-sol-panel]');
|
||||
if (sb && sp && !sp.classList.contains('visible')) {
|
||||
sp.classList.add('visible');
|
||||
sb.classList.add('open');
|
||||
sb.querySelector('span').textContent = 'Скрыть решение';
|
||||
EP.katex?.run(sp);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
el: card,
|
||||
destroy: () => card.remove(),
|
||||
|
||||
Reference in New Issue
Block a user