dbfcfa41ec
Селект «Вариант» использовал .mk-input (узкий, под число) → подпись «РТ-2024/25 · этап I» обрезалась. Задал width:auto/min-width:14rem/max-width:100%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
335 lines
14 KiB
JavaScript
335 lines
14 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
|
|
════════════════════════════════════════════════════════════ */
|
|
async 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;
|
|
// Реальный список вариантов-пробников (бэкенд уже отфильтровал год-пачки):
|
|
// номера вариантов могут быть не подряд (ctmath: 101, 102, …), поэтому
|
|
// показываем выпадающий список реальных вариантов, а не диапазон 1..N.
|
|
let vlist = [];
|
|
try { vlist = (await EP.api.listVariants(examKey)).variants || []; } catch {}
|
|
const vOpts = vlist.map(v => `<option value="${v.n}">${v.label}</option>`).join('');
|
|
|
|
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>Вариант:
|
|
<select id="mk-variant-input" class="mk-input" style="width:auto;min-width:14rem;max-width:100%">${vOpts || '<option value="">—</option>'}</select>
|
|
</label>
|
|
<div class="mk-source-hint">Один из ${vlist.length} готовых вариантов целиком.</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_label || `Вариант ${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 => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
|
}
|
|
})();
|