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:
Maxim Dolgolyov
2026-05-16 13:13:06 +03:00
parent 6cff327e88
commit 31a51956b6
10 changed files with 461 additions and 13 deletions
+97
View File
@@ -181,6 +181,76 @@
}
.ex-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
/* ── Assign button + modal ── */
.ex-assign-row {
display:flex; align-items:center; gap:12px; margin-bottom:22px; flex-wrap:wrap;
}
.ex-assign-btn {
display:inline-flex; align-items:center; gap:7px;
padding:8px 16px; border:1.5px solid var(--violet); border-radius:10px;
background:rgba(155,93,229,.08); color:var(--violet);
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
cursor:pointer; transition:all .15s;
}
.ex-assign-btn:hover { background:var(--violet); color:#fff; }
.ex-assign-btn:disabled {
opacity:.5; cursor:not-allowed; background:transparent; color:var(--text-3); border-color:var(--border);
}
.ex-assign-btn svg { width:14px; height:14px; }
.ex-assign-note {
font-size:.78rem; color:var(--text-3);
}
.ax-form { display:flex; flex-direction:column; gap:14px; }
.ax-field label {
display:block; font-size:.78rem; font-weight:700; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
}
.ax-classes {
display:flex; flex-direction:column; gap:6px; max-height:240px; overflow-y:auto;
border:1.5px solid var(--border); border-radius:10px; padding:8px;
}
.ax-class {
display:flex; align-items:center; gap:10px; padding:8px 10px;
border-radius:8px; cursor:pointer; transition:background .12s;
font-size:.9rem;
}
.ax-class:hover { background:var(--border); }
.ax-class input { accent-color:var(--violet); flex-shrink:0; }
.ax-class .ax-cname { font-weight:600; }
.ax-class .ax-cmeta { font-size:.78rem; color:var(--text-3); margin-left:auto; }
.ax-input {
width:100%; padding:9px 12px; border:1.5px solid var(--border-h);
border-radius:9px; background:var(--surface); color:var(--text);
font-family:'Manrope',sans-serif; font-size:.9rem;
}
.ax-input:focus { outline:none; border-color:var(--violet); }
.ax-actions {
display:flex; gap:10px; justify-content:flex-end; margin-top:6px;
}
.ax-btn {
padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
cursor:pointer; transition:all .15s;
}
.ax-btn:hover { border-color:var(--text-2); }
.ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
.ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
.ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
.ax-error {
padding:9px 12px; border-radius:8px; background:rgba(241,91,68,.1);
border:1px solid rgba(241,91,68,.3); color:#F94144;
font-size:.84rem; display:none;
}
.ax-error.visible { display:block; }
.ax-success {
padding:9px 12px; border-radius:8px; background:rgba(6,214,160,.1);
border:1px solid rgba(6,214,160,.3); color:#06D6A0;
font-size:.84rem; display:none;
}
.ax-success.visible { display:block; }
@media (max-width: 600px) {
.ex-wrap { padding:20px 16px 60px; }
.ex-title { font-size:1.15rem; }
@@ -241,6 +311,33 @@
</div>
</div>
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
<div class="ex-panel" onclick="event.stopPropagation()" style="width:min(520px,94vw)">
<div class="ex-panel-head">
<h2 id="assign-title">Назначить вариант</h2>
<button class="ex-panel-close" onclick="closeAssignModal()" title="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
<div class="ax-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
</div>
<div class="ax-field">
<label>Срок сдачи (опционально)</label>
<input type="datetime-local" class="ax-input" id="ax-deadline" />
</div>
<div class="ax-error" id="ax-error"></div>
<div class="ax-success" id="ax-success"></div>
<div class="ax-actions">
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
+133 -1
View File
@@ -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 => ({ '&':'&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 ───────────────────────────────────────────────────────── */
(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 {
+7 -4
View File
@@ -160,6 +160,8 @@
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
],
throwOnError: false,
};
@@ -312,7 +314,7 @@
if (isCorrect && isChosen) { cls = 'chosen-correct'; icon = lsIcon('check', 14); }
else if (isCorrect) { cls = 'correct-opt'; icon = lsIcon('check', 14); }
else if (isChosen) { cls = 'chosen-wrong'; icon = lsIcon('x', 14); }
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${esc(o.text)}</div>`;
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${q.allow_html ? o.text : esc(o.text)}</div>`;
}).join('') + '</div>';
} else if (type === 'matching') {
const pairs = (() => { try { return JSON.parse(q.answer_text || '{}'); } catch { return {}; } })();
@@ -334,13 +336,14 @@
let cls = '', icon = '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
if (isCorrect) { cls = 'correct-opt'; icon = lsIcon('check', 14); }
else if (isChosen && !isCorrect) { cls = 'chosen-wrong'; icon = lsIcon('x', 14); }
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${esc(o.text)}</div>`;
return `<div class="review-opt ${cls}"><span class="review-opt-icon">${icon}</span>${q.allow_html ? o.text : esc(o.text)}</div>`;
}).join('') + '</div>';
}
const expl = q.explanation
? `<div class="review-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`
? `<div class="review-explanation"><strong>Пояснение:</strong> ${q.allow_html ? q.explanation : esc(q.explanation)}</div>`
: '';
const qText = q.allow_html ? q.text : esc(q.text);
list.innerHTML += `
<div class="review-item ${status}">
@@ -348,7 +351,7 @@
<span class="review-qnum">Вопрос ${i + 1}</span>
<span class="review-badge ${status}">${badgeText[status]}</span>
</div>
<div class="review-text">${esc(q.text)}</div>
<div class="review-text">${qText}</div>
${bodyHtml}
${expl}
</div>`;
+4 -2
View File
@@ -342,6 +342,8 @@
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
],
throwOnError: false,
});
@@ -409,7 +411,7 @@
tabindex="0"
data-opt-id="${opt.id}" data-i="${i}">
<div class="q-opt-key" aria-hidden="true">${keyLabel}</div>
<div class="q-opt-text">${esc(opt.text)}</div>
<div class="q-opt-text">${q.allow_html ? opt.text : esc(opt.text)}</div>
</div>`;
}).join('');
bodyHtml = `<div class="q-options" id="opts" role="${isMulti ? 'group' : 'radiogroup'}" aria-label="Варианты ответа">${optHtml}</div>`;
@@ -434,7 +436,7 @@
${flags[q.id] ? '<span style="font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px">отмечен</span>' : ''}
</div>
${q.image ? `<img src="${esc(q.image)}" alt="" style="max-width:100%;max-height:260px;border-radius:10px;margin-bottom:12px;display:block" />` : ''}
<p class="q-text">${esc(q.text)}</p>
${q.allow_html ? `<div class="q-text">${q.text}</div>` : `<p class="q-text">${esc(q.text)}</p>`}
${bodyHtml}
<div class="q-nav">
<button class="btn-nav" id="btn-prev" ${idx === 0 ? 'disabled' : ''}><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Назад</button>