Files
Learn_System/frontend/js/exam9/app.js
T
Maxim Dolgolyov bc22715734 feat: LS.modal — общий компонент модалок + миграция /exam9 + /my-students
Новый общий компонент LS.modal (api.js) — companion к LS.confirm.
Универсальная form/content-модалка с консистентным поведением:

  LS.modal({
    title, content, size: 'sm'|'md'|'lg',
    actions: [{label, primary, danger, onClick}],
    onClose,
  });
  // Returns { close, root, body, setBody, setActions, setError }

Стандартное поведение:
  - ESC и backdrop-click закрывают (опциональный dismissible:false)
  - z-index 9000 (тот же что LS.confirm — без конфликтов)
  - Auto-focus первого input/select/textarea/button в body
  - prevFocus restore при закрытии
  - Анимация scale+translateY .22s
  - Адаптив: на мобилках padding уменьшается

CSS-классы .ls-mov / .ls-mod / .ls-mod-hdr / .ls-mod-body / .ls-mod-act
впрыскиваются один раз из api.js (id=ls-modal-style), как и стили
toast/confirm.

Миграция exam9 «Назначить вариант»:
  - Убран inline <div class="ex-overlay" id="assign-overlay">…</div>
  - Убраны .ax-actions, .ax-btn, .ax-btn-primary, .ax-error, .ax-success
    CSS (теперь в общих .ls-mod-* стилях)
  - openAssignModal → LS.modal({ title, content: form, actions: [...] })
  - Удалены closeAssignModal/onAssignOverlayClick/onAssignEsc — теперь
    handle'ит LS.modal
  - Удалена unused переменная assignVariantNum (closure теперь над varNum)

  exam9.html:  −53 строк (CSS + HTML модалки)
  app.js:      переписан 90 строк → 70 строк

Миграция my-students «Убрать ученика»:
  - native confirm() → LS.confirm() с danger-стилизацией
  - alert() → LS.toast() для согласованности

Сохранён классroom-овский «ex-overlay»/«ex-panel» CSS (используется
для picker'а вариантов в exam9). Не трогаем classroom.html — у него
своя ecosystem cr-*-overlay.

Дальше — postupенная миграция модалок в textbooks/classes/admin
по мере касания этих страниц. Шаблон установлен.
2026-05-16 18:41:27 +03:00

290 lines
11 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 ───────────────────────────────────────────── */
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
try {
const list = await LS.api('/api/classes');
teacherClasses = Array.isArray(list) ? list : [];
} catch {
teacherClasses = [];
}
return teacherClasses;
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
async function openAssignModal(variantNum) {
if (!variantTests[variantNum]) return;
const testId = variantTests[variantNum];
// Build body with shared LS.modal helper
const classes = await loadTeacherClasses();
const classesHtml = classes.length
? 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('')
: '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
const body = `
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault()">
<div class="ax-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">${classesHtml}</div>
</div>
<div class="ax-field">
<label>Срок сдачи (опционально)</label>
<input type="datetime-local" class="ax-input" id="ax-deadline" />
</div>
</form>`;
const m = LS.modal({
title: `Назначить «Вариант ${variantNum}» как ДЗ`,
content: body,
size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => m.close() },
{
label: 'Назначить', primary: true,
onClick: async () => {
const checked = Array.from(m.body.querySelectorAll('input[name="cls"]:checked')).map(el => Number(el.value));
if (!checked.length) { m.setError('Выберите хотя бы один класс'); return; }
const btns = m.root.querySelectorAll('.ls-mod-btn');
btns.forEach(b => b.disabled = true);
btns[1].textContent = 'Назначаю…';
try {
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Экзамен 9 — Вариант ${variantNum}`,
class_ids: checked,
mode: 'exam', count: 10,
test_id: testId,
deadline: m.body.querySelector('#ax-deadline').value || null,
is_homework: 1,
},
});
LS.toast(`Назначено в ${r.count || checked.length} класс(ах)`, 'success');
m.close();
} catch (e) {
m.setError(e.message || 'Не удалось создать задание');
btns.forEach(b => b.disabled = false);
btns[1].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);
})();