From da14b9cb6811ad89dfab9549f3357604606760b4 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 10:51:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(exam-prep=20F3):=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D1=91=D1=80=20=E2=80=94=20?= =?UTF-8?q?task-card=20+=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20+=20retry=20+=20auto-open=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/exam-prep.css | 197 ++++++++++++++++++- frontend/exam-prep-variants.html | 2 + frontend/js/exam-prep/answer-check.js | 88 +++++++++ frontend/js/exam-prep/task-card.js | 272 ++++++++++++++++++++++++++ frontend/js/exam-prep/variants.js | 142 +++++--------- 5 files changed, 600 insertions(+), 101 deletions(-) create mode 100644 frontend/js/exam-prep/answer-check.js create mode 100644 frontend/js/exam-prep/task-card.js diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index b025564..6ebf1ee 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -318,6 +318,195 @@ font-family: 'Unbounded', sans-serif; font-weight: 800; color: #06D6A0; } +/* ═══════════════════════════════════════════════════════════════ + TaskCard component (`tc-*`) — reusable across views (F3+) + ═══════════════════════════════════════════════════════════════ */ + +.tc-card { + background: var(--surface); border: 1.5px solid var(--border); + border-radius: 14px; margin-bottom: 14px; overflow: hidden; + transition: border-color .2s, box-shadow .2s; +} +.tc-card:hover { border-color: var(--border-h); } +.tc-card.tc-correct { + border-color: #06D6A0; + box-shadow: 0 0 0 1.5px rgba(6,214,160,.25); +} +.tc-card.tc-wrong { + border-color: #E63946; + box-shadow: 0 0 0 1.5px rgba(230,57,70,.22); +} + +.tc-head { + display: flex; align-items: center; gap: 12px; + padding: 11px 22px; background: rgba(155,93,229,.04); + border-bottom: 1.5px solid var(--border); +} +.tc-num { + width: 28px; height: 28px; border-radius: 50%; + background: var(--violet); color: #fff; + font-family: 'Unbounded', sans-serif; font-size: .82rem; font-weight: 800; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.tc-num-label { + font-family: 'Unbounded', sans-serif; font-size: .82rem; font-weight: 700; + color: var(--text-2); letter-spacing: .02em; +} +.tc-type-badge { + margin-left: auto; + font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; + padding: 3px 8px; border-radius: 6px; + background: rgba(155,93,229,.10); color: var(--violet); +} +.tc-type-open { background: rgba(6,214,160,.12); color: #059669; } +.tc-type-long { background: rgba(248,150,30,.12); color: #B45309; } + +.tc-body { padding: 18px 24px; font-size: .98rem; line-height: 1.8; } +.tc-text .katex-display { margin: 12px 0 6px; overflow-x: auto; } +.tc-figure { margin: 14px 0 4px; } +.tc-figure svg, .tc-figure img { max-width: 100%; height: auto; } + +/* MC: radio options as clickable rows */ +.tc-opts { + display: flex; flex-wrap: wrap; gap: 10px 32px; + margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); +} +.tc-opts-vertical { + display: flex; flex-direction: column; gap: 6px; + margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); +} +.tc-opt { + display: inline-flex; align-items: flex-start; gap: 8px; + padding: 6px 10px; border-radius: 8px; + cursor: pointer; transition: background .12s; + line-height: 1.5; +} +.tc-opt:hover { background: rgba(155,93,229,.06); } +.tc-opt input[type="radio"] { + accent-color: var(--violet); + margin-top: 4px; flex-shrink: 0; +} +.tc-opt-lbl { + font-family: 'Unbounded', sans-serif; font-weight: 800; + color: var(--violet); font-size: .9rem; white-space: nowrap; +} + +/* Open: short text answer */ +.tc-input-row { + display: flex; align-items: center; gap: 10px; + margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); + flex-wrap: wrap; +} +.tc-ans-label { + font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700; + color: var(--text-2); +} +.tc-ans-input { + flex: 1; min-width: 140px; max-width: 260px; + padding: 9px 14px; + border: 1.5px solid var(--border-h); + border-radius: 9px; + background: #fff; color: var(--text); + font-family: 'Manrope', sans-serif; font-size: .95rem; font-weight: 600; + transition: border-color .15s; +} +.tc-ans-input:focus { outline: none; border-color: var(--violet); } +.tc-ans-input::placeholder { color: var(--text-3); font-weight: 500; } +.tc-card.tc-correct .tc-ans-input { border-color: #06D6A0; background: rgba(6,214,160,.08); } +.tc-card.tc-wrong .tc-ans-input { border-color: #E63946; background: rgba(230,57,70,.06); } + +/* Check button + verdict */ +.tc-action-row { + display: flex; align-items: center; gap: 14px; + margin-top: 14px; flex-wrap: wrap; +} +.tc-check-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 9px 22px; + border: none; border-radius: 9px; + background: var(--violet); color: #fff; + font-family: 'Manrope', sans-serif; font-size: .88rem; font-weight: 700; + cursor: pointer; transition: filter .12s, opacity .12s; +} +.tc-check-btn:hover:not(:disabled) { filter: brightness(1.08); } +.tc-check-btn:disabled { opacity: .38; cursor: not-allowed; } + +.tc-verdict { + display: inline-flex; align-items: center; gap: 12px; + font-family: 'Manrope', sans-serif; font-size: .9rem; font-weight: 700; +} +.tc-verdict-ok, .tc-verdict-bad { + display: inline-flex; align-items: center; gap: 6px; +} +.tc-verdict-ok { color: #06D6A0; } +.tc-verdict-bad { color: #E63946; } +.tc-verdict-ok svg, .tc-verdict-bad svg { width: 14px; height: 14px; } + +.tc-retry-btn { + display: inline-flex; align-items: center; gap: 5px; + padding: 5px 12px; + border: 1.5px solid var(--border-h); border-radius: 7px; + background: transparent; color: var(--text-2); + font-family: 'Manrope', sans-serif; font-size: .8rem; font-weight: 700; + cursor: pointer; transition: all .12s; +} +.tc-retry-btn:hover { border-color: var(--violet); color: var(--violet); } +.tc-retry-btn svg { width: 12px; height: 12px; } + +/* Long: self-mark */ +.tc-self-mark { + margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); +} +.tc-self-mark-label { + display: block; font-size: .85rem; color: var(--text-2); + margin-bottom: 10px; +} +.tc-self-mark-btns { display: flex; flex-wrap: wrap; gap: 8px; } +.tc-self-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 16px; + border: 1.5px solid var(--border-h); border-radius: 9px; + background: transparent; + font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700; + cursor: pointer; transition: all .15s; +} +.tc-self-btn:disabled { opacity: .42; cursor: not-allowed; } +.tc-self-btn svg { width: 13px; height: 13px; } +.tc-self-yes { color: #06D6A0; border-color: #06D6A0; } +.tc-self-yes:hover:not(:disabled) { background: rgba(6,214,160,.10); } +.tc-self-no { color: #E63946; border-color: #E63946; } +.tc-self-no:hover:not(:disabled) { background: rgba(230,57,70,.10); } + +/* Solution toggle within task card */ +.tc-sol-wrap { padding: 0 22px 16px; } +.tc-sol-btn { + display: inline-flex; align-items: center; gap: 7px; + padding: 6px 14px; border: 1.5px solid #06D6A0; border-radius: 8px; + background: transparent; color: #06D6A0; + font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700; + cursor: pointer; transition: all .15s; +} +.tc-sol-btn:hover { background: rgba(6,214,160,.12); } +.tc-sol-btn.open { background: #06D6A0; border-color: #06D6A0; color: #fff; } +.tc-sol-btn svg { width: 13px; height: 13px; transition: transform .2s; } +.tc-sol-btn.open svg { transform: rotate(90deg); } + +.tc-sol-panel { + display: none; margin-top: 14px; padding: 16px 20px; + background: rgba(6,214,160,.06); border-radius: 10px; + border-left: 3px solid #06D6A0; + line-height: 1.85; font-size: .94rem; +} +.tc-sol-panel.visible { display: block; } +.tc-sol-panel .katex-display { margin: 10px 0 6px; overflow-x: auto; } +.tc-sol-panel ul { margin: 6px 0 6px 22px; } +.tc-sol-panel li { margin: 3px 0; } +.tc-sol-panel .sol-ans { + display: inline-block; margin-top: 12px; padding: 4px 14px; + background: rgba(6,214,160,.2); border-radius: 6px; + font-family: 'Unbounded', sans-serif; font-weight: 800; color: #06D6A0; +} + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } @@ -326,7 +515,9 @@ .ep-tab { padding: 9px 12px; font-size: .82rem; } .ep-card { padding: 16px 18px; } .ep-stat { padding: 14px 16px; } - .vp-task-body { padding: 14px 18px; } - .vp-sol-wrap { padding: 0 18px 14px; } - .vp-sol-panel { padding: 14px 16px; } + .vp-task-body, .tc-body { padding: 14px 18px; } + .vp-sol-wrap, .tc-sol-wrap { padding: 0 18px 14px; } + .vp-sol-panel, .tc-sol-panel { padding: 14px 16px; } + .tc-input-row { gap: 8px; } + .tc-ans-input { max-width: 100%; } } diff --git a/frontend/exam-prep-variants.html b/frontend/exam-prep-variants.html index 3500f29..1f764b5 100644 --- a/frontend/exam-prep-variants.html +++ b/frontend/exam-prep-variants.html @@ -71,6 +71,8 @@ + + diff --git a/frontend/js/exam-prep/answer-check.js b/frontend/js/exam-prep/answer-check.js new file mode 100644 index 0000000..bc46e63 --- /dev/null +++ b/frontend/js/exam-prep/answer-check.js @@ -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 }; +})(); diff --git a/frontend/js/exam-prep/task-card.js b/frontend/js/exam-prep/task-card.js new file mode 100644 index 0000000..8f6af2a --- /dev/null +++ b/frontend/js/exam-prep/task-card.js @@ -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: '', + check: '', + cross: '', + rotate: '', + }; + + 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 `
` + opts.map(([l, t]) => ` + + `).join('') + `
`; + } + + /* 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)} +
+ + +
`; + } else if (task.type === 'open') { + inputBlock = ` +
+ + + +
+ `; + } else if (task.type === 'long') { + inputBlock = ` +
+ Развёрнутый ответ — проверьте себя по решению, затем отметьте: +
+ + +
+
`; + } + } + + const solBlock = (showSol && task.solution) ? ` +
+ +
${task.solution}
+
` : ''; + + card.innerHTML = ` +
+
${numbering}
+
Задание ${numbering}
+ ${typeLabel(task.type)} +
+
+
${task.text}
+ ${task.figure ? `
${task.figure}
` : ''} + ${inputBlock} +
+ ${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 + ? `${ICONS.check} Правильно` + : `${ICONS.cross} Неправильно + `; + } + + 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 }; +})(); diff --git a/frontend/js/exam-prep/variants.js b/frontend/js/exam-prep/variants.js index 3518346..c492acc 100644 --- a/frontend/js/exam-prep/variants.js +++ b/frontend/js/exam-prep/variants.js @@ -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 ``; }).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 = - `
Вариант ${n}${tasks.length} заданий
` + - tasks.map((t, i) => ` -
-
-
${t.idx}
-
Задание ${t.idx}
-
-
-
${t.text}
- ${t.figure ? `
${t.figure}
` : ''} - ${t.opts ? buildOpts(t.opts) : ''} -
- ${t.solution ? ` -
- -
${t.solution}
-
` : ''} -
- `).join(''); + main.innerHTML = `
Вариант ${n}${tasks.length} заданий
`; - 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 `
` + opts.map(([l, t]) => - `${l})${t}` - ).join('') + '
'; - } - - 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;