Files
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

381 lines
21 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Результат теста — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
onload="window._katexReady=true; if(window._katexCb){window._katexCb(); window._katexCb=null;}"></script>
<style>
.container { max-width: 820px; margin: 0 auto; padding: 40px 20px 80px; }
/* ── Score card ── */
.score-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 40px; text-align: center; box-shadow: var(--shadow); margin-bottom: 32px; position: relative; overflow: hidden; }
.score-card::before { content: ''; position: absolute; inset: 0; background: linear-gradient(135deg, rgba(6,214,224,0.05), rgba(155,93,229,0.05)); pointer-events: none; }
.score-emoji { font-size: 3rem; margin-bottom: 12px; }
.score-title { font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 800; margin-bottom: 8px; }
.score-big { font-family: 'Unbounded', sans-serif; font-size: 3.5rem; font-weight: 900; background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1; margin: 16px 0; }
.score-meta { font-size: 0.9rem; color: var(--text-2); margin-bottom: 28px; }
.score-stats { display: flex; justify-content: center; gap: 32px; flex-wrap: wrap; margin-bottom: 28px; }
.score-stat { text-align: center; }
.score-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 700; color: var(--text); }
.score-stat-val.correct { color: var(--green); }
.score-stat-val.wrong { color: var(--pink); }
.score-stat-label { font-size: 0.75rem; color: var(--text-3); margin-top: 2px; }
.score-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
/* ── XP Animation ── */
.xp-anim-wrap { margin-top: 24px; text-align: center; position: relative; min-height: 80px; }
.xp-anim-total {
font-family: 'Unbounded', sans-serif; font-size: 1.8rem; font-weight: 900;
background: linear-gradient(135deg, #9B5DE5, #06D6E0); -webkit-background-clip: text;
-webkit-text-fill-color: transparent; background-clip: text;
opacity: 0; transform: scale(0.5); transition: all 0.5s cubic-bezier(0.34,1.56,0.64,1);
}
.xp-anim-total.show { opacity: 1; transform: scale(1); }
.xp-anim-breakdown {
display: flex; flex-wrap: wrap; justify-content: center; gap: 8px;
margin-top: 12px;
}
.xp-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 99px;
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800;
opacity: 0; transform: translateY(16px);
transition: all 0.4s cubic-bezier(0.34,1.56,0.64,1);
}
.xp-chip.show { opacity: 1; transform: translateY(0); }
.xp-chip.base { background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.2); color: #9B5DE5; }
.xp-chip.correct { background: rgba(6,214,100,0.08); border: 1px solid rgba(6,214,100,0.2); color: #059652; }
.xp-chip.bonus { background: rgba(6,214,224,0.08); border: 1px solid rgba(6,214,224,0.2); color: #06B6D4; }
.xp-chip.perfect { background: linear-gradient(135deg, rgba(255,215,0,0.12), rgba(255,165,0,0.08)); border: 1px solid rgba(255,215,0,0.3); color: #D97706; }
.xp-float {
position: absolute; left: 50%; font-family: 'Unbounded', sans-serif;
font-size: 1rem; font-weight: 900; color: #9B5DE5; pointer-events: none;
animation: xpFloat 1.2s ease-out forwards;
}
@keyframes xpFloat {
0% { opacity: 1; transform: translate(-50%, 0) scale(0.6); }
30% { opacity: 1; transform: translate(-50%, -20px) scale(1.1); }
100% { opacity: 0; transform: translate(-50%, -60px) scale(0.8); }
}
.xp-lvl-up {
display: block; text-align: center; margin-top: 10px;
font-size: 0.75rem; font-weight: 700; color: #06D6E0;
opacity: 0; transition: opacity 0.5s ease;
}
.xp-lvl-up.show { opacity: 1; animation: xpPulse 1.5s ease infinite; }
@keyframes xpPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.btn-primary { padding: 12px 28px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-block; transition: transform var(--tr), box-shadow var(--tr); }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(6,214,224,0.4); }
.btn-ghost { padding: 12px 28px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 600; color: var(--text); cursor: pointer; text-decoration: none; display: inline-block; transition: all var(--tr); }
.btn-ghost:hover { border-color: var(--violet); color: var(--violet); }
/* ── Review ── */
.review-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 700; margin-bottom: 20px; }
.review-item { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 24px 28px; margin-bottom: 16px; box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.review-item.correct { border-left: 3px solid var(--green); }
.review-item.wrong { border-left: 3px solid var(--pink); }
.review-item.skipped { border-left: 3px solid var(--text-3); }
.review-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.review-badge { padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.72rem; font-weight: 700; }
.review-badge.correct { background: rgba(6,214,100,0.1); color: var(--green); }
.review-badge.wrong { background: rgba(241,91,181,0.1); color: var(--pink); }
.review-badge.skipped { background: rgba(15,23,42,0.06); color: var(--text-3); }
.review-qnum { font-size: 0.78rem; color: var(--text-3); font-weight: 600; }
.review-text { font-size: 0.92rem; line-height: 1.6; margin-bottom: 14px; }
.review-opts { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.review-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 10px; font-size: 0.85rem; }
.review-opt.correct-opt { background: rgba(6,214,100,0.08); color: var(--green); font-weight: 600; }
.review-opt.chosen-wrong { background: rgba(241,91,181,0.08); color: var(--pink); }
.review-opt.chosen-correct { background: rgba(6,214,100,0.08); color: var(--green); font-weight: 600; }
.review-opt-icon { font-size: 0.9rem; width: 18px; text-align: center; flex-shrink: 0; }
.review-answer { padding: 10px 14px; border-radius: 10px; font-size: 0.85rem; margin-bottom: 14px; }
.review-answer.correct { background: rgba(6,214,100,0.08); color: var(--green); }
.review-answer.wrong { background: rgba(241,91,181,0.08); color: var(--pink); }
.review-answer.skipped { background: rgba(15,23,42,0.04); color: var(--text-3); }
.review-answer-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; margin-bottom: 4px; }
.review-match-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; margin-bottom: 14px; }
.review-match-table th { text-align: left; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); padding: 4px 8px; }
.review-match-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.review-match-table .match-correct { color: var(--green); }
.review-match-table .match-wrong { color: var(--pink); }
.review-explanation { font-size: 0.83rem; color: var(--text-2); background: rgba(155,93,229,0.05); border: 1px solid rgba(155,93,229,0.12); border-radius: 10px; padding: 10px 14px; line-height: 1.6; }
.review-explanation strong { color: var(--violet); }
.state-screen { min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.container { padding: 20px 14px 60px; }
.score-card { padding: 24px 18px; }
.score-big { font-size: 2.8rem; }
.score-stats { gap: 18px; }
.review-item { padding: 16px 14px; }
.score-actions { gap: 8px; }
.score-actions .btn-primary, .score-actions .btn-ghost { padding: 11px 20px; font-size: 0.85rem; }
}
@media (max-width: 480px) {
.container { padding: 12px 10px 60px; }
.score-card { padding: 20px 14px; }
.score-big { font-size: 2.2rem; }
.score-stats { gap: 12px; }
.score-stat-val { font-size: 1.1rem; }
.xp-anim-total { font-size: 1.3rem; }
.score-actions { flex-direction: column; align-items: stretch; }
.score-actions .btn-primary, .score-actions .btn-ghost { text-align: center; }
}
</style>
</head>
<body>
<nav class="nav">
<a href="/dashboard" class="nav-logo">Learn<span>Space</span></a>
</nav>
<div class="state-screen" id="screen-loading">
<div class="spinner"></div>
</div>
<div class="container" id="screen-result" style="display:none">
<!-- Score -->
<div class="score-card" id="score-card"></div>
<!-- Review -->
<div class="review-title">Разбор ответов</div>
<div id="review-list"></div>
</div>
<script src="/js/api.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const KATEX_OPTS = {
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
],
throwOnError: false,
};
function renderMath(el) {
if (!el) return;
const run = () => { if (window.renderMathInElement) renderMathInElement(el, KATEX_OPTS); };
if (window._katexReady) run(); else window._katexCb = run;
}
const session_id = Number(new URLSearchParams(location.search).get('session'));
if (!session_id) window.location.href = '/dashboard';
function emoji(pct) {
if (pct >= 90) return lsIcon('trophy', 36);
if (pct >= 75) return lsIcon('party', 36);
if (pct >= 60) return lsIcon('thumbs-up', 36);
if (pct >= 40) return lsIcon('muscle', 36);
return lsIcon('books', 36);
}
function fmt(sec) {
const m = Math.floor(sec / 60), s = sec % 60;
return m ? `${m} мин ${s} сек` : `${s} сек`;
}
async function load() {
try {
const d = await LS.getResult(session_id);
// Score card
const pct = d.percent;
const correct = d.review.filter(q => q.is_correct).length;
const skipped = d.review.filter(q => q.is_correct === null || q.is_correct === undefined).length;
const wrong = d.review.length - correct - skipped;
document.getElementById('score-card').innerHTML = `
<div class="score-emoji">${emoji(pct)}</div>
<div class="score-title">Тест завершён!</div>
<div class="score-big">${pct}%</div>
<div class="score-meta">${d.score} из ${d.total} правильных ответов</div>
<div class="score-stats">
<div class="score-stat"><div class="score-stat-val correct">${correct}</div><div class="score-stat-label">Правильно</div></div>
<div class="score-stat"><div class="score-stat-val wrong">${wrong}</div><div class="score-stat-label">Неверно</div></div>
<div class="score-stat"><div class="score-stat-val">${skipped}</div><div class="score-stat-label">Пропущено</div></div>
</div>
<div class="score-actions">
<a href="/library" class="btn-primary">К тестам</a>
<a href="/dashboard" class="btn-ghost">В кабинет</a>
</div>
<div id="xp-slot"></div>`;
// XP earned — animated breakdown
try {
const gam = await LS.getGamificationMe();
if (gam) {
const bonuses = [];
bonuses.push({ label: 'Прохождение', xp: 50, cls: 'base' });
if (correct > 0) bonuses.push({ label: `${correct} верных`, xp: correct * 10, cls: 'correct' });
if (pct >= 90) bonuses.push({ label: 'Отличный результат', xp: 100, cls: 'bonus' });
if (pct >= 100) bonuses.push({ label: 'Идеально!', xp: 200, cls: 'perfect' });
// Combo bonus
const mc = d.maxCombo || 0;
if (mc >= 10) bonuses.push({ label: `Комбо x${mc}`, xp: 75, cls: 'perfect' });
else if (mc >= 5) bonuses.push({ label: `Комбо x${mc}`, xp: 30, cls: 'bonus' });
else if (mc >= 3) bonuses.push({ label: `Комбо x${mc}`, xp: 15, cls: 'bonus' });
const totalXP = bonuses.reduce((s, b) => s + b.xp, 0);
const xpSlot = document.getElementById('xp-slot');
xpSlot.innerHTML = `
<div class="xp-anim-wrap" id="xp-anim-wrap">
<div class="xp-anim-total" id="xp-total">+0 XP</div>
<div class="xp-anim-breakdown" id="xp-chips">
${bonuses.map(b => `<div class="xp-chip ${b.cls}" data-xp="${b.xp}">${lsIcon('zap', 14)} ${b.label}: +${b.xp}</div>`).join('')}
</div>
<div class="xp-lvl-up" id="xp-lvl-info">Уровень ${gam.level} · ${gam.rank}</div>
</div>`;
// Animate chips one by one, then total
const chips = xpSlot.querySelectorAll('.xp-chip');
const wrap = document.getElementById('xp-anim-wrap');
const totalEl = document.getElementById('xp-total');
let runningXP = 0;
for (let i = 0; i < chips.length; i++) {
await new Promise(r => setTimeout(r, 350));
chips[i].classList.add('show');
const chipXP = Number(chips[i].dataset.xp);
// Float particle
const fl = document.createElement('div');
fl.className = 'xp-float';
fl.textContent = `+${chipXP}`;
fl.style.top = '0px';
fl.style.marginLeft = `${(Math.random() - 0.5) * 60}px`;
wrap.appendChild(fl);
setTimeout(() => fl.remove(), 1200);
// Count up total
const from = runningXP, to = runningXP + chipXP;
const start = performance.now();
totalEl.classList.add('show');
await new Promise(resolve => {
function tick(now) {
const t = Math.min(1, (now - start) / 400);
const ease = 1 - Math.pow(1 - t, 3);
const cur = Math.round(from + (to - from) * ease);
totalEl.textContent = `+${cur} XP`;
if (t < 1) requestAnimationFrame(tick); else resolve();
}
requestAnimationFrame(tick);
});
runningXP = to;
}
// Show level info
if (gam.level > 1) {
await new Promise(r => setTimeout(r, 200));
document.getElementById('xp-lvl-info').classList.add('show');
}
}
} catch {}
// Review
const list = document.getElementById('review-list');
d.review.forEach((q, i) => {
const type = q.type || 'single';
const hasAnswer = q.chosen_option_id || q.answer_text;
const status = !hasAnswer ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
const badgeText = { correct: `${lsIcon('check', 14)} Верно`, wrong: `${lsIcon('x', 14)} Неверно`, skipped: '— Пропущено' };
let bodyHtml = '';
if (type === 'short_answer') {
const userAns = q.answer_text || '';
const correctAns = q.correct_text || '';
bodyHtml = `<div class="review-answer ${status}">
<div class="review-answer-label">Ваш ответ</div>
${userAns ? esc(userAns) : '<em>Нет ответа</em>'}
</div>`;
if (!q.is_correct && correctAns) {
bodyHtml += `<div class="review-answer correct">
<div class="review-answer-label">Правильный ответ</div>
${esc(correctAns)}
</div>`;
}
} else if (type === 'multi') {
const chosen = (() => { try { return JSON.parse(q.answer_text || '[]'); } catch { return []; } })();
bodyHtml = '<div class="review-opts">' + q.options.map(o => {
const isCorrect = o.is_correct;
const isChosen = chosen.includes(o.id);
let cls = '', icon = '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>';
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>${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 {}; } })();
bodyHtml = `<table class="review-match-table"><thead><tr><th>Элемент</th><th>Ваш ответ</th><th>Правильно</th></tr></thead><tbody>` +
q.options.map(o => {
const userPair = pairs[String(o.id)] || '';
const isRight = userPair === o.match_pair;
return `<tr>
<td>${esc(o.text)}</td>
<td class="${isRight ? 'match-correct' : 'match-wrong'}">${userPair ? esc(userPair) : '<em>—</em>'}</td>
<td class="match-correct">${esc(o.match_pair || '')}</td>
</tr>`;
}).join('') + '</tbody></table>';
} else {
// single / true_false
bodyHtml = '<div class="review-opts">' + q.options.map(o => {
const isCorrect = o.is_correct;
const isChosen = o.id === q.chosen_option_id;
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>${q.allow_html ? o.text : esc(o.text)}</div>`;
}).join('') + '</div>';
}
const expl = q.explanation
? `<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}">
<div class="review-header">
<span class="review-qnum">Вопрос ${i + 1}</span>
<span class="review-badge ${status}">${badgeText[status]}</span>
</div>
<div class="review-text">${qText}</div>
${bodyHtml}
${expl}
</div>`;
});
renderMath(list);
// Show/hide review based on test setting
if (d.show_answers === 0) {
document.querySelector('.review-title').style.display = 'none';
list.style.display = 'none';
}
document.getElementById('screen-loading').style.display = 'none';
document.getElementById('screen-result').style.display = '';
} catch (err) {
document.getElementById('screen-loading').innerHTML =
`<div style="color:var(--pink);font-weight:600">${esc(err.message)}</div>
<a href="/dashboard" class="btn-primary" style="margin-top:12px">На главную</a>`;
}
}
load();
</script>
</body>
</html>