feat(exam-prep F3): интерактивный тренажёр — task-card + автопроверка ответа + retry + auto-open решения
This commit is contained in:
@@ -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