Files
Learn_System/frontend/test-result.html
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

378 lines
21 KiB
HTML
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.
<!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 },
],
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>${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>${esc(o.text)}</div>`;
}).join('') + '</div>';
}
const expl = q.explanation
? `<div class="review-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`
: '';
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">${esc(q.text)}</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>