Files
Learn_System/frontend/js/exam9/app.js
T
Maxim Dolgolyov 31a51956b6 feat: exam9 — назначение варианта как ДЗ + импорт нечётных в банк
Импорт 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)
2026-05-16 13:13:06 +03:00

309 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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);
})();