Files
Learn_System/frontend/js/exam-prep/variants.js
T

203 lines
8.4 KiB
JavaScript

'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.
────────────────────────────────────────────────────────────────── */
(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[] }
const tasksCache = new Map();
/* ── Load variants list ─────────────────────────────────────── */
try {
const r = await EP.api.listVariants(examKey);
variants = r.variants || [];
} catch (e) {
showError(`Не удалось загрузить варианты: ${e.message || e}`);
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');
/* ── 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';
const active = v.n === currentN ? ' active' : '';
const title = v.viewed_sol === v.total ? `${v.label} (все решения открыты)`
: `${v.label} (${v.viewed_sol}/${v.total} решений открыто)`;
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
}).join('');
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
});
}
function openPicker() {
buildGrid();
pickerOver.classList.add('visible');
pickerBtn.classList.add('open');
document.addEventListener('keydown', onEsc);
}
function closePicker() {
pickerOver.classList.remove('visible');
pickerBtn.classList.remove('open');
document.removeEventListener('keydown', onEsc);
}
function onEsc(e) { if (e.key === 'Escape') closePicker(); }
function onOverlayClick(e) { if (e.target === pickerOver) closePicker(); }
pickerBtn.onclick = () => {
if (pickerOver.classList.contains('visible')) closePicker();
else openPicker();
};
pickerOver.onclick = onOverlayClick;
document.getElementById('vp-close').onclick = closePicker;
/* ── Variant rendering ──────────────────────────────────────── */
async function selectVariant(n) {
currentN = n;
pickerLabel.textContent = `Вариант ${n}`;
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
if (!tasksCache.has(n)) {
main.innerHTML = `<div class="ep-empty"><i data-lucide="loader-circle"></i><h4>Загрузка варианта ${n}…</h4></div>`;
if (window.lucide) lucide.createIcons();
try {
const r = await EP.api.getVariant(examKey, n);
tasksCache.set(n, r.tasks || []);
} catch (e) {
showError(`Не удалось загрузить вариант ${n}: ${e.message || e}`);
return;
}
}
currentTasks = tasksCache.get(n);
renderVariant(n, currentTasks);
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.querySelectorAll('.vp-sol-btn').forEach(btn => {
btn.onclick = () => toggleSol(btn, n, Number(btn.dataset.i));
});
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);
}
}
}
}
}
function showError(msg) {
document.getElementById('ep-main').innerHTML = `
<div class="ep-empty">
<i data-lucide="alert-triangle"></i>
<h4>${escapeHtml(msg)}</h4>
</div>`;
if (window.lucide) lucide.createIcons();
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
/* ── Pick the initial variant ───────────────────────────────── */
let initial = variants[0].n;
if (initialVariantFromQuery && variants.some(v => v.n === initialVariantFromQuery)) {
initial = initialVariantFromQuery;
} else {
try {
const last = Number(localStorage.getItem(`exam_prep_${examKey}_last_variant`));
if (last && variants.some(v => v.n === last)) initial = last;
} catch {}
}
selectVariant(initial);
})();