feat(exam-prep F3): интерактивный тренажёр — task-card + автопроверка ответа + retry + auto-open решения

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:51:38 +03:00
parent 5f8fcbd964
commit da14b9cb68
5 changed files with 600 additions and 101 deletions
+194 -3
View File
@@ -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%; }
}
+2
View File
@@ -71,6 +71,8 @@
<script src="/js/exam-prep/common.js"></script>
<script src="/js/exam-prep/api.js"></script>
<script src="/js/exam-prep/katex.js"></script>
<script src="/js/exam-prep/answer-check.js"></script>
<script src="/js/exam-prep/task-card.js"></script>
<script src="/js/exam-prep/variants.js"></script>
</body>
</html>
+88
View File
@@ -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 };
})();
+272
View File
@@ -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 => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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 };
})();
+44 -98
View File
@@ -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 => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
/* ── Pick the initial variant ───────────────────────────────── */
/* ── Pick initial variant ───────────────────────────────────── */
let initial = variants[0].n;
if (initialVariantFromQuery && variants.some(v => v.n === initialVariantFromQuery)) {
initial = initialVariantFromQuery;