31a51956b6
Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов: - 400 questions с allow_html=1, source_type='экзамен 9', year=2025 - 540 options (single-choice) + correct_text (short_answer) - 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N" - exam9_variant_tests маппинг для назначения Назначение варианта как ДЗ на /exam9 (для учителей/админов): - Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть) - Модалка выбора классов + опциональный deadline - POST /api/assignments/bulk с test_id из exam9_variant_tests Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html: - Миграция 003: ALTER TABLE questions ADD COLUMN allow_html - sessionController: SELECT возвращают allow_html и image - test-run.html: рендер q.text и opt.text как HTML при allow_html=1 - test-result.html: то же для explanation и opt.text - KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт class_ids (array). Существующий вызов из classes.html был сломан; исправлено вместе. Команда: node backend/scripts/import-exam9.js (--all для всех 80)
309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
'use strict';
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Exam 9 — Math 2025 renderer
|
||
Variants loaded into window.VARIANTS by /js/exam9/variants/vNN.js
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
const STORAGE_KEY = 'exam9_progress_v1';
|
||
let currentVariant = null;
|
||
let katexLoaded = false;
|
||
let variantTests = {}; // { variantNum: testId } — populated by /api/exam9/variants
|
||
let userRole = null; // populated by LS.getUser()
|
||
let teacherClasses = null; // lazy-loaded from /api/classes
|
||
|
||
/* ── KaTeX bootstrap ────────────────────────────────────────────── */
|
||
function onKatexLoad() {
|
||
katexLoaded = true;
|
||
if (currentVariant !== null) runKatex(document.getElementById('ex-main'));
|
||
}
|
||
|
||
function runKatex(el) {
|
||
if (!katexLoaded || !el) return;
|
||
try {
|
||
renderMathInElement(el, {
|
||
delimiters: [
|
||
{ left: '$$', right: '$$', display: true },
|
||
{ left: '$', right: '$', display: false },
|
||
],
|
||
throwOnError: false,
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
/* ── Progress in localStorage ───────────────────────────────────── */
|
||
function loadProgress() {
|
||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
|
||
catch { return {}; }
|
||
}
|
||
function saveProgress(p) {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch {}
|
||
}
|
||
function markSolutionViewed(variantNum, taskIdx) {
|
||
const p = loadProgress();
|
||
p[variantNum] = p[variantNum] || [];
|
||
if (!p[variantNum].includes(taskIdx)) {
|
||
p[variantNum].push(taskIdx);
|
||
saveProgress(p);
|
||
}
|
||
}
|
||
|
||
/* ── Variant picker ─────────────────────────────────────────────── */
|
||
function buildGrid() {
|
||
const grid = document.getElementById('variant-grid');
|
||
const progress = loadProgress();
|
||
grid.innerHTML = '';
|
||
Object.keys(VARIANTS).sort((a, b) => Number(a) - Number(b)).forEach(n => {
|
||
const v = VARIANTS[n];
|
||
const total = (v.tasks || []).length;
|
||
const viewed = (progress[n] || []).length;
|
||
let cls = '';
|
||
if (viewed === total && total > 0) cls = ' done';
|
||
else if (viewed > 0) cls = ' partial';
|
||
const isActive = Number(n) === currentVariant ? ' active' : '';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'vg-btn' + cls + isActive;
|
||
btn.textContent = n;
|
||
btn.title = `${v.label}${viewed === total ? ' ✓' : viewed > 0 ? ` (${viewed}/${total})` : ''}`;
|
||
btn.onclick = () => { selectVariant(Number(n)); closePicker(); };
|
||
grid.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function togglePicker() {
|
||
const overlay = document.getElementById('picker-overlay');
|
||
const btn = document.getElementById('picker-btn');
|
||
if (overlay.classList.contains('visible')) closePicker();
|
||
else {
|
||
buildGrid();
|
||
overlay.classList.add('visible');
|
||
btn.classList.add('open');
|
||
document.addEventListener('keydown', onEsc);
|
||
}
|
||
}
|
||
|
||
function closePicker() {
|
||
document.getElementById('picker-overlay').classList.remove('visible');
|
||
document.getElementById('picker-btn').classList.remove('open');
|
||
document.removeEventListener('keydown', onEsc);
|
||
}
|
||
|
||
function onOverlayClick(e) {
|
||
if (e.target === document.getElementById('picker-overlay')) closePicker();
|
||
}
|
||
function onEsc(e) { if (e.key === 'Escape') closePicker(); }
|
||
|
||
/* ── Task rendering ─────────────────────────────────────────────── */
|
||
function buildOpts(opts) {
|
||
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
|
||
const cls = isLong ? 'opts-vertical' : 'opts';
|
||
return `<div class="${cls}">` +
|
||
opts.map(([l, t]) =>
|
||
`<span class="opt"><span class="opt-lbl">${l})</span><span>${t}</span></span>`
|
||
).join('') + `</div>`;
|
||
}
|
||
|
||
const SOL_ICON_CLOSED = `<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>`;
|
||
|
||
function renderVariant(num) {
|
||
const main = document.getElementById('ex-main');
|
||
const v = VARIANTS[num];
|
||
if (!v) {
|
||
main.innerHTML = '<div class="ex-empty">Вариант не найден</div>';
|
||
return;
|
||
}
|
||
|
||
const isTeacher = userRole === 'teacher' || userRole === 'admin';
|
||
const testId = variantTests[num];
|
||
const assignBtn = isTeacher
|
||
? `<div class="ex-assign-row">
|
||
<button class="ex-assign-btn" ${testId ? `onclick="openAssignModal(${num})"` : 'disabled'}>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
|
||
Назначить как ДЗ
|
||
</button>
|
||
${testId ? '' : '<span class="ex-assign-note">Этот вариант ещё не импортирован в банк (только нечётные)</span>'}
|
||
</div>`
|
||
: '';
|
||
|
||
main.innerHTML =
|
||
`<div class="variant-title">${v.label}<small>${v.tasks.length} заданий</small></div>` +
|
||
assignBtn +
|
||
v.tasks.map((t, i) => `
|
||
<div class="task-card">
|
||
<div class="task-header">
|
||
<div class="task-num">${i + 1}</div>
|
||
<div class="task-label">Задание ${i + 1}</div>
|
||
</div>
|
||
<div class="task-body">
|
||
<div class="task-text">${t.text}</div>
|
||
${t.figure ? `<div class="task-figure">${t.figure}</div>` : ''}
|
||
${t.opts ? buildOpts(t.opts) : ''}
|
||
</div>
|
||
${t.sol ? `<div class="sol-wrap">
|
||
<button class="sol-btn" data-task="${i}" onclick="toggleSol(this, ${num}, ${i})">
|
||
${SOL_ICON_CLOSED}<span>Показать решение</span>
|
||
</button>
|
||
<div class="sol-panel">${t.sol}</div>
|
||
</div>` : ''}
|
||
</div>`
|
||
).join('');
|
||
|
||
runKatex(main);
|
||
}
|
||
|
||
function toggleSol(btn, variantNum, taskIdx) {
|
||
const panel = btn.nextElementSibling;
|
||
const open = panel.classList.contains('visible');
|
||
panel.classList.toggle('visible', !open);
|
||
btn.classList.toggle('open', !open);
|
||
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
|
||
if (!open) {
|
||
if (!panel.dataset.k) { runKatex(panel); panel.dataset.k = '1'; }
|
||
markSolutionViewed(variantNum, taskIdx);
|
||
}
|
||
}
|
||
|
||
function selectVariant(num) {
|
||
currentVariant = num;
|
||
document.getElementById('picker-label').textContent = VARIANTS[num].label;
|
||
document.querySelectorAll('.vg-btn').forEach(b => {
|
||
b.classList.toggle('active', Number(b.textContent) === num);
|
||
});
|
||
renderVariant(num);
|
||
// Persist last opened variant
|
||
try { localStorage.setItem('exam9_last_variant', String(num)); } catch {}
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
/* ── Assignment modal ───────────────────────────────────────────── */
|
||
let assignVariantNum = null;
|
||
|
||
async function loadTeacherClasses() {
|
||
if (teacherClasses) return teacherClasses;
|
||
try {
|
||
const list = await LS.api('/api/classes');
|
||
teacherClasses = Array.isArray(list) ? list : [];
|
||
} catch {
|
||
teacherClasses = [];
|
||
}
|
||
return teacherClasses;
|
||
}
|
||
|
||
async function openAssignModal(variantNum) {
|
||
if (!variantTests[variantNum]) return;
|
||
assignVariantNum = variantNum;
|
||
document.getElementById('assign-title').textContent = `Назначить «Вариант ${variantNum}» как ДЗ`;
|
||
document.getElementById('ax-error').classList.remove('visible');
|
||
document.getElementById('ax-success').classList.remove('visible');
|
||
document.getElementById('ax-deadline').value = '';
|
||
document.getElementById('ax-submit').disabled = false;
|
||
document.getElementById('ax-submit').textContent = 'Назначить';
|
||
|
||
const listEl = document.getElementById('ax-classes-list');
|
||
listEl.textContent = 'Загрузка…';
|
||
const classes = await loadTeacherClasses();
|
||
if (!classes.length) {
|
||
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
|
||
} else {
|
||
listEl.innerHTML = classes.map(c => `
|
||
<label class="ax-class">
|
||
<input type="checkbox" name="cls" value="${c.id}" />
|
||
<span class="ax-cname">${escapeHtml(c.name)}</span>
|
||
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
|
||
</label>`).join('');
|
||
}
|
||
|
||
document.getElementById('assign-overlay').classList.add('visible');
|
||
document.addEventListener('keydown', onAssignEsc);
|
||
}
|
||
|
||
function closeAssignModal() {
|
||
document.getElementById('assign-overlay').classList.remove('visible');
|
||
document.removeEventListener('keydown', onAssignEsc);
|
||
assignVariantNum = null;
|
||
}
|
||
|
||
function onAssignOverlayClick(e) {
|
||
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
|
||
}
|
||
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
|
||
|
||
function escapeHtml(s) {
|
||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||
}
|
||
|
||
async function submitAssign() {
|
||
const errorEl = document.getElementById('ax-error');
|
||
const successEl = document.getElementById('ax-success');
|
||
const submitBtn = document.getElementById('ax-submit');
|
||
errorEl.classList.remove('visible');
|
||
successEl.classList.remove('visible');
|
||
|
||
const checked = Array.from(document.querySelectorAll('#ax-classes-list input[name="cls"]:checked'))
|
||
.map(el => Number(el.value));
|
||
if (!checked.length) {
|
||
errorEl.textContent = 'Выберите хотя бы один класс';
|
||
errorEl.classList.add('visible');
|
||
return;
|
||
}
|
||
|
||
const testId = variantTests[assignVariantNum];
|
||
const deadline = document.getElementById('ax-deadline').value || null;
|
||
if (!testId) { errorEl.textContent = 'Вариант не в банке вопросов'; errorEl.classList.add('visible'); return; }
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Назначаю…';
|
||
|
||
try {
|
||
const r = await LS.api('/api/assignments/bulk', {
|
||
method: 'POST',
|
||
body: {
|
||
title: `Экзамен 9 — Вариант ${assignVariantNum}`,
|
||
class_ids: checked,
|
||
mode: 'exam',
|
||
count: 10,
|
||
test_id: testId,
|
||
deadline: deadline,
|
||
is_homework: 1,
|
||
},
|
||
});
|
||
successEl.textContent = `Назначено в ${r.count || checked.length} классе(ах)`;
|
||
successEl.classList.add('visible');
|
||
submitBtn.textContent = 'Готово';
|
||
setTimeout(closeAssignModal, 1500);
|
||
} catch (e) {
|
||
errorEl.textContent = e.message || 'Не удалось создать задание';
|
||
errorEl.classList.add('visible');
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Назначить';
|
||
}
|
||
}
|
||
|
||
/* ── Boot ───────────────────────────────────────────────────────── */
|
||
(async function boot() {
|
||
const keys = Object.keys(VARIANTS);
|
||
if (!keys.length) {
|
||
document.getElementById('ex-main').innerHTML = '<div class="ex-empty">Варианты не загружены</div>';
|
||
return;
|
||
}
|
||
|
||
// Load user role + variant-to-test map (parallel)
|
||
const user = (typeof LS !== 'undefined') ? LS.getUser?.() : null;
|
||
userRole = user?.role || null;
|
||
|
||
if (userRole === 'teacher' || userRole === 'admin') {
|
||
try {
|
||
const r = await LS.api('/api/exam9/variants');
|
||
variantTests = r.variants || {};
|
||
} catch { variantTests = {}; }
|
||
}
|
||
|
||
// Resume last opened variant or open first one
|
||
let initial = Number(keys[0]);
|
||
try {
|
||
const last = Number(localStorage.getItem('exam9_last_variant'));
|
||
if (last && VARIANTS[last]) initial = last;
|
||
} catch {}
|
||
selectVariant(initial);
|
||
})();
|