feat(exam-prep F3): интерактивный тренажёр — task-card + автопроверка ответа + retry + auto-open решения
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Answer normalization + correctness check.
|
||||
Same algorithm runs on user input and stored canonical answer
|
||||
(from /tasks endpoint), then compared.
|
||||
|
||||
Canonical answer forms (produced by backend/scripts/import-exam-tasks.js):
|
||||
MC: single Cyrillic letter: "а" | "б" | "в" | "г" | "д"
|
||||
open NUM: decimal: "-2" "7500" "1.5" "0.25"
|
||||
open FRAC: fraction: "9/4" "-104/9" (sign on numerator)
|
||||
open PAIR: two values, ";" sep: "-2;4"
|
||||
The user can type the answer in many equivalent forms — the normalizer
|
||||
accepts any form and decides equivalence numerically.
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(function () {
|
||||
const EPS = 1e-6;
|
||||
|
||||
// Try to coerce a string to a number; returns NaN on failure.
|
||||
function toNumber(s) {
|
||||
if (s == null) return NaN;
|
||||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||||
// Fraction "a/b" → a/b
|
||||
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
|
||||
if (f) {
|
||||
const num = Number(f[1]);
|
||||
const den = Number(f[2]);
|
||||
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN;
|
||||
return num / den;
|
||||
}
|
||||
const n = Number(t);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
|
||||
// Parse a "pair" answer "A;B" — accepts ";" or "," or " и " or whitespace
|
||||
function toPair(s) {
|
||||
if (s == null) return null;
|
||||
const t = String(s).trim().replace(/\$/g, '').replace(/\s+и\s+/g, ';');
|
||||
const parts = t.split(/[;,]/).map(p => p.trim()).filter(Boolean);
|
||||
if (parts.length !== 2) return null;
|
||||
const a = toNumber(parts[0]);
|
||||
const b = toNumber(parts[1]);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
||||
// Order-insensitive comparison: return sorted pair
|
||||
return a <= b ? [a, b] : [b, a];
|
||||
}
|
||||
|
||||
/* Detect form of the canonical answer.
|
||||
Returns: 'mc' | 'pair' | 'numeric' */
|
||||
function detectForm(canonical) {
|
||||
if (canonical == null) return 'numeric';
|
||||
const t = String(canonical).trim();
|
||||
if (/^[а-д]$/.test(t)) return 'mc';
|
||||
if (/^[^;]+;[^;]+$/.test(t)) return 'pair';
|
||||
return 'numeric';
|
||||
}
|
||||
|
||||
/* Main check. Returns true iff user input matches canonical answer.
|
||||
False if either side can't be normalized.
|
||||
|
||||
Both inputs are strings as returned by the form or stored in DB. */
|
||||
function check(userInput, canonical) {
|
||||
if (userInput == null || canonical == null) return false;
|
||||
|
||||
const form = detectForm(canonical);
|
||||
|
||||
if (form === 'mc') {
|
||||
const u = String(userInput).trim().toLowerCase();
|
||||
return u === String(canonical).trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (form === 'pair') {
|
||||
const cp = toPair(canonical);
|
||||
const up = toPair(userInput);
|
||||
if (!cp || !up) return false;
|
||||
return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS;
|
||||
}
|
||||
|
||||
// Numeric (integer / decimal / fraction)
|
||||
const c = toNumber(canonical);
|
||||
const u = toNumber(userInput);
|
||||
if (Number.isNaN(c) || Number.isNaN(u)) return false;
|
||||
return Math.abs(c - u) < EPS;
|
||||
}
|
||||
|
||||
window.EP = window.EP || {};
|
||||
window.EP.answer = { check, detectForm, toNumber, toPair };
|
||||
})();
|
||||
@@ -0,0 +1,272 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
TaskCard component — reusable across variants / practice / topic /
|
||||
mock views. Renders ONE task with answer input, check button, and
|
||||
solution toggle.
|
||||
|
||||
Public API:
|
||||
EP.TaskCard.render(container, task, opts)
|
||||
container : HTMLElement
|
||||
task : { id, idx, type, text, figure, opts, answer, solution }
|
||||
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)
|
||||
onAttempt : (result) => void (notify parent after check or solution-view)
|
||||
numbering : number | null (override task number badge)
|
||||
}
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(function () {
|
||||
const ICONS = {
|
||||
chev: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
|
||||
cross: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||
rotate: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/></svg>',
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||
}
|
||||
|
||||
function buildOptsBlock(taskId, opts) {
|
||||
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
|
||||
const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts';
|
||||
const name = `tc-opt-${taskId}`;
|
||||
return `<div class="${cls}" data-tc-mc>` + opts.map(([l, t]) => `
|
||||
<label class="tc-opt">
|
||||
<input type="radio" name="${name}" value="${escapeHtml(l)}" />
|
||||
<span class="tc-opt-lbl">${escapeHtml(l)})</span>
|
||||
<span class="tc-opt-text">${t}</span>
|
||||
</label>
|
||||
`).join('') + `</div>`;
|
||||
}
|
||||
|
||||
/* 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 showSol = opts.showSolution !== false;
|
||||
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
|
||||
const sessionId = opts.sessionId || null;
|
||||
const onAttempt = opts.onAttempt || (() => {});
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'tc-card';
|
||||
card.dataset.taskId = String(task.id);
|
||||
card.dataset.taskType = task.type;
|
||||
|
||||
// ── Inner skeleton
|
||||
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>
|
||||
</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>`;
|
||||
}
|
||||
}
|
||||
|
||||
const solBlock = (showSol && task.solution) ? `
|
||||
<div class="tc-sol-wrap">
|
||||
<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>
|
||||
<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>
|
||||
</div>` : '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="tc-head">
|
||||
<div class="tc-num">${numbering}</div>
|
||||
<div class="tc-num-label">Задание ${numbering}</div>
|
||||
<span class="tc-type-badge tc-type-${task.type}">${typeLabel(task.type)}</span>
|
||||
</div>
|
||||
<div class="tc-body">
|
||||
<div class="tc-text">${task.text}</div>
|
||||
${task.figure ? `<div class="tc-figure">${task.figure}</div>` : ''}
|
||||
${inputBlock}
|
||||
</div>
|
||||
${solBlock}
|
||||
`;
|
||||
container.appendChild(card);
|
||||
|
||||
// ── KaTeX render
|
||||
EP.katex?.run(card);
|
||||
|
||||
// ── State
|
||||
let startedAt = Date.now();
|
||||
let solutionLogged = false;
|
||||
let solutionRendered = false;
|
||||
let attemptCount = 0; // how many CHECK attempts made
|
||||
let firstAttemptCorrect = null; // we report this in onAttempt
|
||||
|
||||
// ── Input enable on first interaction
|
||||
const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]');
|
||||
const checkBtn = card.querySelector('[data-tc-check]');
|
||||
inputs.forEach(inp => {
|
||||
inp.addEventListener('input', () => updateCheckEnabled());
|
||||
inp.addEventListener('change', () => updateCheckEnabled());
|
||||
});
|
||||
function updateCheckEnabled() {
|
||||
if (!checkBtn) return;
|
||||
const has = readUserAnswer() !== null;
|
||||
checkBtn.disabled = !has || card.dataset.tcLocked === '1';
|
||||
}
|
||||
|
||||
function readUserAnswer() {
|
||||
const mcGroup = card.querySelector('[data-tc-mc]');
|
||||
if (mcGroup) {
|
||||
const picked = mcGroup.querySelector('input[type="radio"]:checked');
|
||||
return picked ? picked.value : null;
|
||||
}
|
||||
const text = card.querySelector('[data-tc-text]');
|
||||
if (text) {
|
||||
const v = text.value.trim();
|
||||
return v || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Check action
|
||||
if (checkBtn) {
|
||||
checkBtn.addEventListener('click', () => {
|
||||
const userAnswer = readUserAnswer();
|
||||
if (userAnswer == null) return;
|
||||
const isCorrect = EP.answer.check(userAnswer, task.answer) ? 1 : 0;
|
||||
attemptCount++;
|
||||
if (firstAttemptCorrect === null) firstAttemptCorrect = isCorrect;
|
||||
applyVerdict(isCorrect, userAnswer);
|
||||
sendAttempt({ user_answer: userAnswer, is_correct: isCorrect });
|
||||
onAttempt({ taskId: task.id, isCorrect, userAnswer, attempt: attemptCount });
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerdict(isCorrect, userAnswer) {
|
||||
const verdict = card.querySelector('[data-tc-verdict]');
|
||||
card.classList.remove('tc-correct', 'tc-wrong');
|
||||
card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong');
|
||||
|
||||
if (verdict) {
|
||||
verdict.hidden = false;
|
||||
verdict.innerHTML = isCorrect
|
||||
? `<span class="tc-verdict-ok">${ICONS.check} Правильно</span>`
|
||||
: `<span class="tc-verdict-bad">${ICONS.cross} Неправильно</span>
|
||||
<button class="tc-retry-btn" data-tc-retry>${ICONS.rotate} Попробовать ещё</button>`;
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
// Lock inputs + auto-open solution
|
||||
card.querySelectorAll('input').forEach(el => el.disabled = true);
|
||||
checkBtn.disabled = true;
|
||||
card.dataset.tcLocked = '1';
|
||||
autoOpenSolution();
|
||||
} else {
|
||||
// Allow retry
|
||||
const retry = card.querySelector('[data-tc-retry]');
|
||||
if (retry) retry.addEventListener('click', () => resetForRetry());
|
||||
}
|
||||
}
|
||||
|
||||
function resetForRetry() {
|
||||
const verdict = card.querySelector('[data-tc-verdict]');
|
||||
card.classList.remove('tc-wrong');
|
||||
if (verdict) { verdict.hidden = true; verdict.innerHTML = ''; }
|
||||
startedAt = Date.now();
|
||||
}
|
||||
|
||||
function autoOpenSolution() {
|
||||
const btn = card.querySelector('[data-tc-sol]');
|
||||
const panel = card.querySelector('[data-tc-sol-panel]');
|
||||
if (btn && panel && !panel.classList.contains('visible')) {
|
||||
toggleSolution(btn, panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Solution toggle (manual)
|
||||
const solBtn = card.querySelector('[data-tc-sol]');
|
||||
const solPanel = card.querySelector('[data-tc-sol-panel]');
|
||||
if (solBtn && solPanel) {
|
||||
solBtn.addEventListener('click', () => toggleSolution(solBtn, solPanel));
|
||||
}
|
||||
|
||||
function toggleSolution(btn, panel) {
|
||||
const open = panel.classList.contains('visible');
|
||||
panel.classList.toggle('visible', !open);
|
||||
btn.classList.toggle('open', !open);
|
||||
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
|
||||
if (!open) {
|
||||
if (!solutionRendered) {
|
||||
EP.katex?.run(panel);
|
||||
solutionRendered = true;
|
||||
}
|
||||
if (!solutionLogged) {
|
||||
solutionLogged = true;
|
||||
sendAttempt({ solution_viewed: 1 });
|
||||
onAttempt({ taskId: task.id, solutionViewed: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Long: self-mark buttons
|
||||
card.querySelectorAll('[data-tc-self]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const val = btn.dataset.tcSelf === '1' ? 1 : 0;
|
||||
attemptCount++;
|
||||
if (firstAttemptCorrect === null) firstAttemptCorrect = val;
|
||||
card.classList.remove('tc-correct', 'tc-wrong');
|
||||
card.classList.add(val ? 'tc-correct' : 'tc-wrong');
|
||||
card.querySelectorAll('[data-tc-self]').forEach(b => b.disabled = true);
|
||||
sendAttempt({ is_correct: val });
|
||||
onAttempt({ taskId: task.id, isCorrect: val, attempt: attemptCount });
|
||||
});
|
||||
});
|
||||
|
||||
function sendAttempt(partial) {
|
||||
const body = Object.assign({
|
||||
exam_task_id: task.id,
|
||||
mode,
|
||||
session_id: sessionId,
|
||||
time_ms: Math.min(Date.now() - startedAt, 24 * 3600 * 1000),
|
||||
user_answer: null,
|
||||
is_correct: null,
|
||||
hint_used: 0,
|
||||
solution_viewed:0,
|
||||
}, partial);
|
||||
// Best-effort — UI doesn't block on this
|
||||
EP.api.saveAttempt(body).catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
el: card,
|
||||
destroy: () => card.remove(),
|
||||
};
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
if (type === 'mc') return 'выбор';
|
||||
if (type === 'open') return 'кр. ответ';
|
||||
return 'развёрнут.';
|
||||
}
|
||||
|
||||
window.EP = window.EP || {};
|
||||
window.EP.TaskCard = { render };
|
||||
})();
|
||||
@@ -1,27 +1,23 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Variants view — port of the old /exam9 browser onto API + DB.
|
||||
Same UX as before: pick a variant from a grid overlay, then read
|
||||
conditions + reveal solutions. Progress (which variants have all
|
||||
solutions opened) is per-user via /api/exam-prep/attempts.
|
||||
Variants view — picks a variant, renders its tasks via TaskCard
|
||||
(interactive answer input + check + auto-logged attempts).
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(async function () {
|
||||
await EP.boot();
|
||||
const examKey = EP.examKey;
|
||||
|
||||
// Optional ?v=N in URL: open that variant initially
|
||||
const initialVariantFromQuery = (() => {
|
||||
const m = location.search.match(/[?&]v=(\d+)/);
|
||||
return m ? Number(m[1]) : null;
|
||||
})();
|
||||
|
||||
let variants = []; // [{ n, label, total, solved, viewed_sol }]
|
||||
let currentN = null;
|
||||
let currentTasks = null; // cache: { [variantN]: tasks[] }
|
||||
let variants = [];
|
||||
let currentN = null;
|
||||
const tasksCache = new Map();
|
||||
|
||||
/* ── Load variants list ─────────────────────────────────────── */
|
||||
/* ── Variants list ──────────────────────────────────────────── */
|
||||
try {
|
||||
const r = await EP.api.listVariants(examKey);
|
||||
variants = r.variants || [];
|
||||
@@ -29,28 +25,26 @@
|
||||
showError(`Не удалось загрузить варианты: ${e.message || e}`);
|
||||
return;
|
||||
}
|
||||
if (!variants.length) { showError('Варианты не найдены'); return; }
|
||||
|
||||
if (!variants.length) {
|
||||
showError('Варианты не найдены');
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── DOM refs ───────────────────────────────────────────────── */
|
||||
const main = document.getElementById('ep-main');
|
||||
const pickerBtn = document.getElementById('vp-btn');
|
||||
const pickerLabel = document.getElementById('vp-label');
|
||||
const pickerOver = document.getElementById('vp-overlay');
|
||||
const pickerGrid = document.getElementById('vp-grid');
|
||||
/* ── DOM ────────────────────────────────────────────────────── */
|
||||
const main = document.getElementById('ep-main');
|
||||
const pickerBtn = document.getElementById('vp-btn');
|
||||
const pickerLabel = document.getElementById('vp-label');
|
||||
const pickerOver = document.getElementById('vp-overlay');
|
||||
const pickerGrid = document.getElementById('vp-grid');
|
||||
|
||||
/* ── Picker overlay ─────────────────────────────────────────── */
|
||||
function buildGrid() {
|
||||
pickerGrid.innerHTML = variants.map(v => {
|
||||
let cls = '';
|
||||
if (v.total > 0 && v.viewed_sol === v.total) cls = ' done';
|
||||
else if (v.viewed_sol > 0) cls = ' partial';
|
||||
// Prefer solved-based highlight; fall back to viewed-sol when nothing solved yet.
|
||||
if (v.total > 0 && v.solved === v.total) cls = ' done';
|
||||
else if (v.solved > 0) cls = ' partial';
|
||||
else if (v.viewed_sol > 0) cls = ' partial';
|
||||
const active = v.n === currentN ? ' active' : '';
|
||||
const title = v.viewed_sol === v.total ? `${v.label} (все решения открыты)`
|
||||
: `${v.label} (${v.viewed_sol}/${v.total} решений открыто)`;
|
||||
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
||||
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
|
||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
|
||||
}).join('');
|
||||
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
||||
@@ -72,8 +66,7 @@
|
||||
function onOverlayClick(e) { if (e.target === pickerOver) closePicker(); }
|
||||
|
||||
pickerBtn.onclick = () => {
|
||||
if (pickerOver.classList.contains('visible')) closePicker();
|
||||
else openPicker();
|
||||
pickerOver.classList.contains('visible') ? closePicker() : openPicker();
|
||||
};
|
||||
pickerOver.onclick = onOverlayClick;
|
||||
document.getElementById('vp-close').onclick = closePicker;
|
||||
@@ -95,84 +88,37 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
currentTasks = tasksCache.get(n);
|
||||
renderVariant(n, currentTasks);
|
||||
|
||||
renderVariant(n, tasksCache.get(n));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function renderVariant(n, tasks) {
|
||||
main.innerHTML =
|
||||
`<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>` +
|
||||
tasks.map((t, i) => `
|
||||
<div class="vp-task" data-task-id="${t.id}">
|
||||
<div class="vp-task-header">
|
||||
<div class="vp-task-num">${t.idx}</div>
|
||||
<div class="vp-task-label">Задание ${t.idx}</div>
|
||||
</div>
|
||||
<div class="vp-task-body">
|
||||
<div class="vp-task-text">${t.text}</div>
|
||||
${t.figure ? `<div class="vp-task-figure">${t.figure}</div>` : ''}
|
||||
${t.opts ? buildOpts(t.opts) : ''}
|
||||
</div>
|
||||
${t.solution ? `
|
||||
<div class="vp-sol-wrap">
|
||||
<button class="vp-sol-btn" data-i="${i}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<span>Показать решение</span>
|
||||
</button>
|
||||
<div class="vp-sol-panel">${t.solution}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
|
||||
|
||||
main.querySelectorAll('.vp-sol-btn').forEach(btn => {
|
||||
btn.onclick = () => toggleSol(btn, n, Number(btn.dataset.i));
|
||||
});
|
||||
const variantMeta = variants.find(v => v.n === n);
|
||||
const solvedTracked = new Set(); // tasks already solved this session
|
||||
const viewedTracked = new Set(); // tasks where solution opened this session
|
||||
|
||||
EP.katex.run(main);
|
||||
}
|
||||
|
||||
function buildOpts(opts) {
|
||||
const isLong = opts.some(([, txt]) => txt.length > 40 && !txt.startsWith('$'));
|
||||
const cls = isLong ? 'vp-opts vp-opts-vertical' : 'vp-opts';
|
||||
return `<div class="${cls}">` + opts.map(([l, t]) =>
|
||||
`<span class="vp-opt"><span class="vp-opt-lbl">${l})</span><span>${t}</span></span>`
|
||||
).join('') + '</div>';
|
||||
}
|
||||
|
||||
async function toggleSol(btn, n, i) {
|
||||
const panel = btn.nextElementSibling;
|
||||
const wasOpen = panel.classList.contains('visible');
|
||||
panel.classList.toggle('visible', !wasOpen);
|
||||
btn.classList.toggle('open', !wasOpen);
|
||||
btn.querySelector('span').textContent = wasOpen ? 'Показать решение' : 'Скрыть решение';
|
||||
|
||||
if (!wasOpen) {
|
||||
if (!panel.dataset.k) { EP.katex.run(panel); panel.dataset.k = '1'; }
|
||||
// Persist "solution viewed" exactly once per (user, task)
|
||||
if (!panel.dataset.logged) {
|
||||
panel.dataset.logged = '1';
|
||||
const taskId = currentTasks[i]?.id;
|
||||
if (taskId) {
|
||||
EP.api.saveAttempt({
|
||||
exam_task_id: taskId,
|
||||
user_answer: null,
|
||||
is_correct: null,
|
||||
mode: 'variant',
|
||||
solution_viewed: 1,
|
||||
}).catch(() => {
|
||||
// Silent: progress sync is best-effort
|
||||
panel.dataset.logged = '';
|
||||
});
|
||||
// Local optimistic update of picker grid
|
||||
const v = variants.find(v => v.n === n);
|
||||
if (v && panel.dataset.firstView !== '1') {
|
||||
panel.dataset.firstView = '1';
|
||||
v.viewed_sol = Math.min(v.viewed_sol + 1, v.total);
|
||||
tasks.forEach(task => {
|
||||
EP.TaskCard.render(main, task, {
|
||||
mode: 'variant',
|
||||
autoCheck: true,
|
||||
showSolution: true,
|
||||
onAttempt: ({ taskId, isCorrect, solutionViewed }) => {
|
||||
// Optimistic update of picker counters (best-effort; backend is source of truth)
|
||||
if (!variantMeta) return;
|
||||
if (isCorrect === 1 && !solvedTracked.has(taskId)) {
|
||||
solvedTracked.add(taskId);
|
||||
variantMeta.solved = Math.min(variantMeta.solved + 1, variantMeta.total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (solutionViewed && !viewedTracked.has(taskId)) {
|
||||
viewedTracked.add(taskId);
|
||||
variantMeta.viewed_sol = Math.min(variantMeta.viewed_sol + 1, variantMeta.total);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
@@ -188,7 +134,7 @@
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||
}
|
||||
|
||||
/* ── Pick the initial variant ───────────────────────────────── */
|
||||
/* ── Pick initial variant ───────────────────────────────────── */
|
||||
let initial = variants[0].n;
|
||||
if (initialVariantFromQuery && variants.some(v => v.n === initialVariantFromQuery)) {
|
||||
initial = initialVariantFromQuery;
|
||||
|
||||
Reference in New Issue
Block a user