feat(exam-prep F9): пробный экзамен — setup/active/result + таймер + балл по сетке + серверный чекер

This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:06:57 +03:00
parent b07da5ee6d
commit cfcb233b6c
5 changed files with 825 additions and 39 deletions
+97 -33
View File
@@ -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(),