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)
This commit is contained in:
+133
-1
@@ -7,6 +7,9 @@
|
||||
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() {
|
||||
@@ -110,8 +113,21 @@ function renderVariant(num) {
|
||||
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">
|
||||
@@ -159,13 +175,129 @@ function selectVariant(num) {
|
||||
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 ───────────────────────────────────────────────────────── */
|
||||
(function 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 {
|
||||
|
||||
Reference in New Issue
Block a user