31a51956b6
Импорт 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)
838 lines
45 KiB
HTML
838 lines
45 KiB
HTML
<!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._katexReadyCb) window._katexReadyCb()"></script>
|
||
<style>
|
||
.nav-timer { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 700; color: var(--text); }
|
||
.nav-timer.warning { color: var(--pink); }
|
||
|
||
/* ── layout ── */
|
||
.layout { display: grid; grid-template-columns: 260px 1fr; gap: 24px; max-width: 1100px; margin: 0 auto; padding: 28px 20px 60px; }
|
||
@media (max-width: 768px) {
|
||
.layout { grid-template-columns: 1fr; padding: 16px 14px 60px; }
|
||
.sidebar { order: 2; }
|
||
.q-card { padding: 20px 18px; }
|
||
.q-text { font-size: 0.95rem; }
|
||
.confirm-box { padding: 28px 20px; }
|
||
.confirm-actions { flex-wrap: wrap; }
|
||
.confirm-cancel, .confirm-ok { flex: 1; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.layout { padding: 12px 10px 60px; gap: 14px; }
|
||
.q-card { padding: 16px 14px; }
|
||
.dot { width: 24px; height: 24px; font-size: 0.64rem; border-radius: 6px; }
|
||
.q-opt { padding: 10px 12px; gap: 10px; }
|
||
.q-text { font-size: 0.9rem; }
|
||
.q-match-row { flex-direction: column; gap: 8px; }
|
||
.q-match-sel { width: 100%; }
|
||
}
|
||
|
||
/* ── sidebar ── */
|
||
.sidebar { display: flex; flex-direction: column; gap: 16px; }
|
||
.card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 20px; box-shadow: var(--shadow); }
|
||
.card-label { font-size: 0.75rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
|
||
.progress-bar { height: 6px; background: var(--border); border-radius: 99px; overflow: hidden; margin-bottom: 8px; }
|
||
.progress-fill { height: 100%; background: var(--grad-1); border-radius: 99px; transition: width 0.4s ease; }
|
||
.progress-text { font-size: 0.82rem; color: var(--text-2); }
|
||
.dots { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||
.dot { width: 28px; height: 28px; border-radius: 8px; border: 1.5px solid var(--border-h); background: transparent; font-size: 0.7rem; font-weight: 700; color: var(--text-3); cursor: pointer; transition: all var(--tr); display: flex; align-items: center; justify-content: center; }
|
||
.dot.answered { background: rgba(6,214,224,0.12); border-color: var(--cyan); color: var(--text); }
|
||
.dot.current { background: var(--violet); border-color: var(--violet); color: #fff; }
|
||
.btn-finish { width: 100%; padding: 13px; 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; transition: transform var(--tr), box-shadow var(--tr); }
|
||
.btn-finish:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(6,214,224,0.4); }
|
||
.btn-finish:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||
|
||
/* ── question ── */
|
||
.q-area { display: flex; flex-direction: column; gap: 0; }
|
||
.q-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px 32px; box-shadow: var(--shadow); display: none; }
|
||
.q-card.active { display: block; }
|
||
.q-num-badge { display: inline-flex; align-items: center; gap: 8px; font-size: 0.78rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 16px; }
|
||
.q-num-badge span { background: var(--grad-1); color: #fff; padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.72rem; }
|
||
.q-type-hint { display: inline-block; font-size: 0.72rem; color: var(--text-3); background: rgba(15,23,42,0.05); padding: 2px 8px; border-radius: var(--r-pill); margin-left: 8px; }
|
||
.q-text { font-size: 1rem; line-height: 1.7; color: var(--text); margin-bottom: 24px; }
|
||
.q-options { display: flex; flex-direction: column; gap: 10px; }
|
||
.q-opt { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border: 1.5px solid var(--border-h); border-radius: 14px; cursor: pointer; transition: all var(--tr); user-select: none; }
|
||
.q-opt:hover { border-color: var(--violet); background: rgba(155,93,229,0.04); }
|
||
.q-opt.selected { border-color: var(--cyan); background: rgba(6,214,224,0.06); }
|
||
.q-opt-key { width: 28px; height: 28px; border-radius: 8px; background: rgba(15,23,42,0.06); display: flex; align-items: center; justify-content: center; font-size: 0.78rem; font-weight: 700; color: var(--text-3); flex-shrink: 0; transition: all var(--tr); }
|
||
.q-opt.selected .q-opt-key { background: var(--cyan); color: #fff; }
|
||
.q-opt-text { font-size: 0.9rem; color: var(--text); }
|
||
|
||
/* ── short answer ── */
|
||
.q-short-wrap { padding: 4px 0 16px; }
|
||
.q-text-input { width: 100%; padding: 13px 16px; border: 1.5px solid var(--border-h); border-radius: 14px; font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: var(--text); background: #f8f9ff; outline: none; transition: border-color 0.2s, background 0.2s; }
|
||
.q-text-input:focus { border-color: var(--violet); background: #fff; }
|
||
.q-hint { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
|
||
.q-match-wrap { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 16px; }
|
||
.q-match-hint { font-size: 0.78rem; color: var(--text-3); margin-bottom: 4px; }
|
||
.q-match-row { display: flex; align-items: center; gap: 14px; padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 12px; transition: border-color var(--tr); }
|
||
.q-match-row:has(.q-match-sel:not([value=""])) { border-color: rgba(6,214,224,0.5); background: rgba(6,214,224,0.04); }
|
||
.q-match-left { flex: 1; font-size: 0.9rem; font-weight: 600; min-width: 100px; }
|
||
.q-match-sel { flex: 1; padding: 8px 12px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text); background: #f8f9ff; outline: none; transition: border-color 0.2s; cursor: pointer; }
|
||
.q-match-sel:focus { border-color: var(--cyan); }
|
||
|
||
/* ── navigation ── */
|
||
.q-nav { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; }
|
||
.btn-nav { padding: 10px 22px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; color: var(--text); cursor: pointer; transition: all var(--tr); }
|
||
.btn-nav:hover { border-color: var(--violet); color: var(--violet); }
|
||
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||
|
||
/* ── loading/error ── */
|
||
.state-screen { min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; padding: 40px; }
|
||
.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); } }
|
||
.state-title { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 700; }
|
||
.state-desc { font-size: 0.9rem; color: var(--text-2); }
|
||
.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; }
|
||
|
||
/* ── auto-advance progress ring on selected option ── */
|
||
.q-opt.auto-advancing { border-color: var(--cyan); background: rgba(6,214,224,0.08); }
|
||
.q-opt.auto-advancing .q-opt-key { background: var(--cyan); color: #fff; }
|
||
@keyframes ring-fill { from { stroke-dashoffset: 56.5; } to { stroke-dashoffset: 0; } }
|
||
.advance-ring { position: absolute; top: 50%; right: 18px; transform: translateY(-50%); width: 22px; height: 22px; }
|
||
.advance-ring circle { fill: none; stroke: var(--cyan); stroke-width: 2.5; stroke-dasharray: 56.5; stroke-dashoffset: 56.5; stroke-linecap: round; animation: ring-fill 0.65s linear forwards; transform-origin: center; transform: rotate(-90deg); }
|
||
.q-opt { position: relative; }
|
||
|
||
/* ── confirm modal ── */
|
||
.confirm-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(6px); z-index: 300; display: none; align-items: center; justify-content: center; padding: 20px; }
|
||
.confirm-overlay.open { display: flex; }
|
||
.confirm-box { background: #fff; border-radius: 20px; padding: 36px 32px; width: 100%; max-width: 400px; box-shadow: 0 24px 80px rgba(15,23,42,0.2); text-align: center; }
|
||
.confirm-icon { font-size: 2.4rem; margin-bottom: 12px; }
|
||
.confirm-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 10px; }
|
||
.confirm-desc { font-size: 0.88rem; color: var(--text-2); line-height: 1.6; margin-bottom: 24px; }
|
||
.confirm-skipped { display: inline-block; margin: 8px 0 4px; padding: 6px 16px; border-radius: var(--r-pill); background: rgba(255,179,71,0.12); color: #c47f00; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700; }
|
||
.confirm-actions { display: flex; gap: 10px; justify-content: center; }
|
||
.confirm-cancel { padding: 11px 26px; min-height: 44px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||
.confirm-cancel:hover { border-color: var(--violet); color: var(--violet); }
|
||
.confirm-ok { padding: 11px 26px; min-height: 44px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: transform var(--tr); }
|
||
.confirm-ok:hover { transform: translateY(-1px); }
|
||
|
||
/* ── btn-finish counter ── */
|
||
.btn-finish-count { font-size: 0.72rem; opacity: 0.75; font-weight: 600; display: block; margin-top: 2px; }
|
||
|
||
/* ── flag button ── */
|
||
.q-card { position: relative; }
|
||
.btn-flag { position: absolute; top: 16px; right: 20px; width: 34px; height: 34px; border-radius: 10px; border: 1.5px solid var(--border-h); background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--tr); color: var(--text-3); }
|
||
.btn-flag:hover { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.08); }
|
||
.btn-flag.flagged { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.12); }
|
||
.dot.flagged { border-color: #f59e0b !important; }
|
||
.dot.flagged:not(.answered) { color: #f59e0b; }
|
||
.dot.current.flagged { background: #f59e0b; border-color: #f59e0b; color: #fff; }
|
||
.dot.answered.flagged { background: rgba(245,158,11,0.18); }
|
||
|
||
/* ── Combo system ── */
|
||
.combo-toast {
|
||
position: fixed; top: 18px; left: 50%; transform: translateX(-50%) scale(0.7);
|
||
z-index: 500; pointer-events: none;
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 22px; border-radius: 99px;
|
||
font-family: 'Unbounded', sans-serif; font-weight: 900;
|
||
opacity: 0; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
||
}
|
||
.combo-toast.show { opacity: 1; transform: translateX(-50%) scale(1); }
|
||
.combo-toast.hide { opacity: 0; transform: translateX(-50%) scale(0.7) translateY(-10px); }
|
||
.combo-toast.c3 { background: linear-gradient(135deg, rgba(6,214,224,0.15), rgba(6,214,100,0.1)); border: 2px solid rgba(6,214,224,0.3); color: #06B6D4; font-size: 0.82rem; }
|
||
.combo-toast.c5 { background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.1)); border: 2px solid rgba(155,93,229,0.35); color: #9B5DE5; font-size: 0.92rem; }
|
||
.combo-toast.c10 { background: linear-gradient(135deg, rgba(255,215,0,0.18), rgba(255,165,0,0.12)); border: 2px solid rgba(255,215,0,0.4); color: #D97706; font-size: 1.05rem; }
|
||
.combo-icon { font-size: 1.3em; }
|
||
.combo-break {
|
||
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
|
||
z-index: 500; pointer-events: none;
|
||
padding: 6px 16px; border-radius: 99px;
|
||
background: rgba(241,91,181,0.1); border: 1.5px solid rgba(241,91,181,0.25);
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: #F15BB5;
|
||
opacity: 0; transition: all 0.3s ease;
|
||
}
|
||
.combo-break.show { opacity: 1; }
|
||
.combo-break.hide { opacity: 0; transform: translateX(-50%) translateY(-8px); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="nav">
|
||
<a href="/dashboard" class="nav-logo">Learn<span>Space</span></a>
|
||
<div class="nav-right">
|
||
<span id="nav-subject">Тест</span>
|
||
<span id="nav-mode" style="font-size:0.68rem;padding:3px 10px;border-radius:99px;font-weight:700;display:none"></span>
|
||
<span class="nav-timer" id="nav-timer">—</span>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Загрузка -->
|
||
<div class="state-screen" id="screen-loading">
|
||
<div class="spinner"></div>
|
||
<div class="state-title">Загружаем тест...</div>
|
||
</div>
|
||
|
||
<!-- Ошибка -->
|
||
<div class="state-screen" id="screen-error" style="display:none">
|
||
<div class="state-title">Ошибка загрузки</div>
|
||
<div class="state-desc" id="error-msg"></div>
|
||
<a href="/dashboard" class="btn-primary">На главную</a>
|
||
</div>
|
||
|
||
<!-- Тест -->
|
||
<div class="layout" id="screen-test" style="display:none">
|
||
<aside class="sidebar">
|
||
<div class="card">
|
||
<div class="card-label" id="sidebar-label">Прогресс</div>
|
||
<div class="progress-bar"><div class="progress-fill" id="prog-fill" style="width:0%"></div></div>
|
||
<div class="progress-text"><span id="prog-answered">0</span> / <span id="prog-total">0</span> отвечено</div>
|
||
<div class="dots" id="dots"></div>
|
||
</div>
|
||
<div class="card">
|
||
<button class="btn-finish" id="btn-finish">Завершить тест</button>
|
||
</div>
|
||
<label style="display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:0.74rem;color:var(--text-2);cursor:pointer;user-select:none">
|
||
<input type="checkbox" id="auto-adv-toggle" style="accent-color:var(--violet)" /> Автопереход
|
||
</label>
|
||
</aside>
|
||
|
||
<div class="q-area" id="q-area"></div>
|
||
</div>
|
||
|
||
<!-- Confirm finish modal -->
|
||
<div class="confirm-overlay" id="confirm-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-dlg-title" onclick="if(event.target===this)closeConfirm()">
|
||
<div class="confirm-box">
|
||
<div class="confirm-icon" id="confirm-icon"></div>
|
||
<div class="confirm-title" id="confirm-dlg-title">Завершить тест?</div>
|
||
<div class="confirm-desc" id="confirm-desc"></div>
|
||
<div class="confirm-actions">
|
||
<button class="confirm-cancel" id="confirm-cancel-btn" onclick="closeConfirm()">Вернуться</button>
|
||
<button class="confirm-ok" onclick="doFinish()">Завершить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script>
|
||
if (!LS.requireAuth()) throw new Error('Not logged in');
|
||
|
||
// Set confirm modal icon via lsIcon
|
||
document.getElementById('confirm-icon').innerHTML = lsIcon('clipboard', 36);
|
||
|
||
const params = new URLSearchParams(location.search);
|
||
const subject = params.get('subject') || 'bio';
|
||
const mode = params.get('mode') || 'exam';
|
||
const count = Number(params.get('count') || 25);
|
||
const topic_id = params.get('topic') || null;
|
||
const test_id = params.get('test') || null;
|
||
const assignmentMode = params.get('assignment_mode') || null; // 'exam' | 'repeat' | 'ct'
|
||
|
||
// Whether timer should run: exam mode or ct mode
|
||
const useTimer = !assignmentMode || assignmentMode === 'exam' || assignmentMode === 'ct';
|
||
// ct mode: split questions into Part A (single/true_false) and Part B (multi/short_answer)
|
||
const isCTMode = assignmentMode === 'ct';
|
||
|
||
let session_id, questions, currentIdx = 0;
|
||
let sessionMode = mode;
|
||
// answers[q.id] = option_id (single/true_false) | [opt_id,...] (multi) | "text" (short_answer)
|
||
let answers = {};
|
||
let flags = {}; // flags[q.id] = true → marked for review
|
||
let startTimes = {};
|
||
let timerInterval, secondsLeft;
|
||
let ctPartBStart = 0; // index where Part B begins in ct mode
|
||
const _matchShuffles = {}; // cache shuffled right-side values per question id
|
||
|
||
/* ── SessionStorage persistence ──────────────────────────────────── */
|
||
function _ssKey() { return `ls_test_${session_id}`; }
|
||
function saveToSession() {
|
||
if (!session_id) return;
|
||
try { sessionStorage.setItem(_ssKey(), JSON.stringify({ answers, flags, currentIdx })); } catch {}
|
||
}
|
||
function restoreFromSession() {
|
||
try {
|
||
const raw = sessionStorage.getItem(_ssKey());
|
||
if (!raw) return;
|
||
const d = JSON.parse(raw);
|
||
if (d.answers) answers = d.answers;
|
||
if (d.flags) flags = d.flags;
|
||
if (typeof d.currentIdx === 'number') currentIdx = d.currentIdx;
|
||
} catch {}
|
||
}
|
||
function clearSession() {
|
||
try { sessionStorage.removeItem(_ssKey()); } catch {}
|
||
}
|
||
|
||
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика' };
|
||
|
||
|
||
/* ── Инициализация ──────────────────────────────────────────────── */
|
||
async function init() {
|
||
try {
|
||
const existingSession = params.get('session');
|
||
const data = existingSession
|
||
? await LS.getSessionQuestions(existingSession)
|
||
: await LS.startSession(subject, mode, count, topic_id, test_id);
|
||
sessionMode = data.mode || mode;
|
||
document.getElementById('nav-subject').textContent =
|
||
SUBJECT_NAMES[data.subject_slug] || SUBJECT_NAMES[subject] || 'Тест';
|
||
// Show mode indicator
|
||
const modeBadge = document.getElementById('nav-mode');
|
||
const modeInfo = { practice: { label: 'Практика', bg: 'rgba(6,214,100,0.15)', color: '#059652' }, exam: { label: 'Экзамен', bg: 'rgba(241,91,181,0.12)', color: '#F15BB5' }, ct: { label: 'ЦТ/ЦЭ', bg: 'rgba(155,93,229,0.15)', color: '#9B5DE5' }, repeat: { label: 'Повтор', bg: 'rgba(6,214,224,0.15)', color: '#06B6D4' } };
|
||
const mi = modeInfo[sessionMode] || modeInfo[assignmentMode];
|
||
if (mi && modeBadge) { modeBadge.textContent = mi.label; modeBadge.style.background = mi.bg; modeBadge.style.color = mi.color; modeBadge.style.display = ''; }
|
||
session_id = data.session_id;
|
||
questions = data.questions;
|
||
restoreFromSession();
|
||
|
||
document.getElementById('prog-total').textContent = questions.length;
|
||
|
||
// For ct mode: count Part A / Part B boundary
|
||
if (isCTMode) {
|
||
ctPartBStart = questions.findIndex(q => !['single','true_false'].includes(q.type || 'single'));
|
||
if (ctPartBStart === -1) ctPartBStart = questions.length; // all Part A
|
||
const lbl = document.getElementById('sidebar-label');
|
||
if (lbl) lbl.innerHTML = `ЦТ/ЦЭ <span style="font-size:0.65rem;color:var(--violet)">А: ${ctPartBStart} / Б: ${questions.length - ctPartBStart}</span>`;
|
||
}
|
||
|
||
renderDots();
|
||
renderQuestion(currentIdx);
|
||
|
||
if (useTimer) {
|
||
let timerSec = data.time_limit_sec || questions.length * 90;
|
||
if (data.started_at && data.time_limit_sec) {
|
||
const elapsed = Math.round((Date.now() - new Date(data.started_at).getTime()) / 1000);
|
||
timerSec = Math.max(1, data.time_limit_sec - elapsed);
|
||
}
|
||
startTimer(timerSec);
|
||
}
|
||
|
||
show('screen-test');
|
||
} catch (err) {
|
||
document.getElementById('error-msg').textContent = err.message;
|
||
show('screen-error');
|
||
}
|
||
hide('screen-loading');
|
||
}
|
||
|
||
/* ── Таймер ─────────────────────────────────────────────────────── */
|
||
function startTimer(seconds) {
|
||
secondsLeft = seconds;
|
||
updateTimerDisplay();
|
||
timerInterval = setInterval(() => {
|
||
secondsLeft--;
|
||
updateTimerDisplay();
|
||
if (secondsLeft <= 0) { clearInterval(timerInterval); doFinish(); }
|
||
if (secondsLeft <= 120) document.getElementById('nav-timer').classList.add('warning');
|
||
}, 1000);
|
||
}
|
||
|
||
function updateTimerDisplay() {
|
||
const m = String(Math.floor(secondsLeft / 60)).padStart(2, '0');
|
||
const s = String(secondsLeft % 60).padStart(2, '0');
|
||
document.getElementById('nav-timer').textContent = `${m}:${s}`;
|
||
}
|
||
|
||
/* ── KaTeX ──────────────────────────────────────────────────────── */
|
||
function renderMath(el) {
|
||
if (!el) return;
|
||
const run = () => {
|
||
if (window.renderMathInElement) {
|
||
renderMathInElement(el, {
|
||
delimiters: [
|
||
{ left: '\\(', right: '\\)', display: false },
|
||
{ left: '\\[', right: '\\]', display: true },
|
||
{ left: '$$', right: '$$', display: true },
|
||
{ left: '$', right: '$', display: false },
|
||
],
|
||
throwOnError: false,
|
||
});
|
||
}
|
||
};
|
||
if (window._katexReady) run(); else window._katexReadyCb = run;
|
||
}
|
||
|
||
/* ── Проверка: есть ли ответ на вопрос ─────────────────────────── */
|
||
function isAnswered(q) {
|
||
const a = answers[q.id];
|
||
if (q.type === 'short_answer') return !!(a && String(a).trim());
|
||
if (q.type === 'multi') return !!(Array.isArray(a) && a.length > 0);
|
||
if (q.type === 'matching') {
|
||
if (!a || typeof a !== 'object') return false;
|
||
return q.options.every(opt => a[opt.id] && a[opt.id] !== '');
|
||
}
|
||
return !!a;
|
||
}
|
||
|
||
/* ── Рендер вопроса ─────────────────────────────────────────────── */
|
||
function renderQuestion(idx) {
|
||
currentIdx = idx;
|
||
const q = questions[idx];
|
||
startTimes[q.id] = startTimes[q.id] || Date.now();
|
||
const type = q.type || 'single';
|
||
|
||
// Build options HTML
|
||
let bodyHtml = '';
|
||
if (type === 'matching') {
|
||
// Shuffle right-side values once per question (cached)
|
||
if (!_matchShuffles[q.id]) _matchShuffles[q.id] = q.options.map(o => o.match_pair).sort(() => Math.random() - 0.5);
|
||
const rightVals = _matchShuffles[q.id];
|
||
const curPairs = answers[q.id] || {};
|
||
bodyHtml = `
|
||
<div class="q-match-wrap">
|
||
<div class="q-match-hint">Выберите пару для каждого элемента</div>
|
||
${q.options.map(opt => `
|
||
<div class="q-match-row">
|
||
<div class="q-match-left">${esc(opt.text)}</div>
|
||
<select class="q-match-sel" data-opt-id="${opt.id}" onchange="onMatchChange(${q.id},${opt.id},this.value)">
|
||
<option value="">— выбрать —</option>
|
||
${rightVals.map(v => `<option value="${esc(v)}"${curPairs[opt.id]===v?' selected':''}>${esc(v)}</option>`).join('')}
|
||
</select>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
} else if (type === 'short_answer') {
|
||
const curVal = typeof answers[q.id] === 'string' ? esc(answers[q.id]) : '';
|
||
bodyHtml = `
|
||
<div class="q-short-wrap">
|
||
<input type="text" class="q-text-input" id="q-input-${q.id}"
|
||
placeholder="Введите ваш ответ…" value="${curVal}" autocomplete="off" />
|
||
<div class="q-hint">Сравнение без учёта регистра и пробелов по краям</div>
|
||
</div>`;
|
||
} else {
|
||
const isMulti = type === 'multi';
|
||
const optHtml = q.options.map((opt, i) => {
|
||
const sel = isMulti
|
||
? (Array.isArray(answers[q.id]) && answers[q.id].includes(opt.id))
|
||
: answers[q.id] === opt.id;
|
||
const keyLabel = isMulti ? (sel ? lsIcon('check', 14) : lsIcon('square', 14)) : String.fromCharCode(65 + i);
|
||
return `<div class="q-opt${sel ? ' selected' : ''}"
|
||
role="${isMulti ? 'checkbox' : 'radio'}"
|
||
aria-checked="${sel}"
|
||
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">${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>`;
|
||
if (isMulti) {
|
||
bodyHtml += `<div class="q-hint" style="margin-top:10px">Можно выбрать несколько вариантов</div>`;
|
||
}
|
||
}
|
||
|
||
const typeLabel = { single:'Один ответ', multi:'Несколько ответов', true_false:'Верно / Неверно', short_answer:'Краткий ответ', matching:'Сопоставление' }[type] || '';
|
||
const ctSection = isCTMode
|
||
? (idx < ctPartBStart ? '<span style="font-size:0.75rem;font-weight:800;color:var(--violet);margin-right:6px">Часть А</span>' : '<span style="font-size:0.75rem;font-weight:800;color:var(--amber);margin-right:6px">Часть Б</span>')
|
||
: '';
|
||
|
||
document.getElementById('q-area').innerHTML = `
|
||
<div class="q-card active">
|
||
<button class="btn-flag${flags[q.id] ? ' flagged' : ''}" id="btn-flag-${q.id}" aria-label="${flags[q.id] ? 'Снять отметку' : 'Отметить для проверки'}" aria-pressed="${flags[q.id] ? 'true' : 'false'}" onclick="toggleFlag(${q.id})">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="${flags[q.id] ? '#f59e0b' : 'none'}" stroke="${flags[q.id] ? '#f59e0b' : 'currentColor'}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
|
||
</button>
|
||
<div class="q-num-badge">
|
||
${ctSection}Вопрос <span>${idx + 1} / ${questions.length}</span>
|
||
${typeLabel ? `<span class="q-type-hint">${typeLabel}</span>` : ''}
|
||
${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" />` : ''}
|
||
${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>
|
||
<button class="btn-nav" id="btn-next">${idx === questions.length - 1 ? 'Завершить' : 'Вперёд <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>'}</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Wire events
|
||
if (type === 'short_answer') {
|
||
const inp = document.getElementById(`q-input-${q.id}`);
|
||
inp.addEventListener('input', () => {
|
||
answers[q.id] = inp.value;
|
||
updateDots();
|
||
updateProgress();
|
||
});
|
||
inp.addEventListener('blur', () => {
|
||
if (String(answers[q.id] || '').trim()) sendAnswerAsync(q);
|
||
});
|
||
inp.focus();
|
||
} else if (type === 'matching') {
|
||
// matching: handled via onMatchChange inline (see function below)
|
||
} else {
|
||
const isMulti = type === 'multi';
|
||
document.querySelectorAll('.q-opt').forEach(el => {
|
||
const handleSelect = () => {
|
||
const optId = Number(el.dataset.optId);
|
||
if (isMulti) toggleMultiOpt(q, optId);
|
||
else selectSingleOpt(q, optId);
|
||
};
|
||
el.addEventListener('click', handleSelect);
|
||
el.addEventListener('keydown', e => {
|
||
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); handleSelect(); }
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('btn-prev').addEventListener('click', () => {
|
||
cancelAutoAdvance();
|
||
flushShortAnswer(q);
|
||
renderQuestion(idx - 1);
|
||
});
|
||
document.getElementById('btn-next').addEventListener('click', () => {
|
||
cancelAutoAdvance();
|
||
flushShortAnswer(q);
|
||
if (idx === questions.length - 1) tryFinish();
|
||
else renderQuestion(idx + 1);
|
||
});
|
||
|
||
updateDots();
|
||
renderMath(document.getElementById('q-area'));
|
||
saveToSession();
|
||
}
|
||
|
||
/* ── Matching ── */
|
||
function onMatchChange(qid, optId, val) {
|
||
const q = questions.find(q => q.id === qid);
|
||
if (!q) return;
|
||
if (!answers[qid] || typeof answers[qid] !== 'object') answers[qid] = {};
|
||
answers[qid][optId] = val;
|
||
updateDots();
|
||
updateProgress();
|
||
if (isAnswered(q)) sendAnswerAsync(q);
|
||
}
|
||
|
||
/* send short_answer to server if not yet sent */
|
||
const _sentAnswers = new Set();
|
||
function flushShortAnswer(q) {
|
||
if ((q.type || 'single') !== 'short_answer') return;
|
||
const text = String(answers[q.id] || '').trim();
|
||
if (text && !_sentAnswers.has(q.id)) sendAnswerAsync(q);
|
||
}
|
||
|
||
/* ── Авто-переход ────────────────────────────────────────────────── */
|
||
let _autoAdvanceTimer = null;
|
||
|
||
function cancelAutoAdvance() {
|
||
if (_autoAdvanceTimer) { clearTimeout(_autoAdvanceTimer); _autoAdvanceTimer = null; }
|
||
// remove ring from all options
|
||
document.querySelectorAll('.advance-ring').forEach(r => r.remove());
|
||
document.querySelectorAll('.q-opt.auto-advancing').forEach(el => el.classList.remove('auto-advancing'));
|
||
}
|
||
|
||
/* ── Выбор ответа — одиночный ───────────────────────────────────── */
|
||
function selectSingleOpt(q, option_id) {
|
||
cancelAutoAdvance();
|
||
const timeSpent = Math.round((Date.now() - (startTimes[q.id] || Date.now())) / 1000);
|
||
answers[q.id] = option_id;
|
||
|
||
document.querySelectorAll('.q-opt').forEach((el, i) => {
|
||
const sel = q.options[i].id === option_id;
|
||
el.classList.toggle('selected', sel);
|
||
el.setAttribute('aria-checked', sel ? 'true' : 'false');
|
||
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : String.fromCharCode(65 + i);
|
||
if (sel) {
|
||
// attach progress ring svg
|
||
el.classList.add('auto-advancing');
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||
svg.setAttribute('class','advance-ring');
|
||
svg.setAttribute('viewBox','0 0 22 22');
|
||
const circle = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||
circle.setAttribute('cx','11'); circle.setAttribute('cy','11'); circle.setAttribute('r','9');
|
||
svg.appendChild(circle);
|
||
el.appendChild(svg);
|
||
}
|
||
});
|
||
|
||
updateDots();
|
||
updateProgress();
|
||
LS.sendAnswer(session_id, q.id, option_id, timeSpent)
|
||
.then(r => { if (r && typeof r.is_correct === 'boolean' && sessionMode === 'practice') _handleAnswerResult(r.is_correct); })
|
||
.catch(console.error);
|
||
|
||
// auto-advance to next question (not on last question, can be disabled)
|
||
if (currentIdx < questions.length - 1 && localStorage.getItem('ls_auto_advance') !== '0') {
|
||
_autoAdvanceTimer = setTimeout(() => {
|
||
_autoAdvanceTimer = null;
|
||
if (answers[q.id] === option_id) renderQuestion(currentIdx + 1);
|
||
}, 1200);
|
||
}
|
||
}
|
||
|
||
/* ── Выбор ответа — множественный ───────────────────────────────── */
|
||
function toggleMultiOpt(q, option_id) {
|
||
if (!Array.isArray(answers[q.id])) answers[q.id] = [];
|
||
const arr = answers[q.id];
|
||
const idx = arr.indexOf(option_id);
|
||
if (idx === -1) arr.push(option_id); else arr.splice(idx, 1);
|
||
|
||
document.querySelectorAll('.q-opt').forEach(el => {
|
||
const id = Number(el.dataset.optId);
|
||
const sel = arr.includes(id);
|
||
el.classList.toggle('selected', sel);
|
||
el.setAttribute('aria-checked', sel ? 'true' : 'false');
|
||
el.querySelector('.q-opt-key').innerHTML = sel ? lsIcon('check', 14) : lsIcon('square', 14);
|
||
});
|
||
|
||
updateDots();
|
||
updateProgress();
|
||
|
||
// debounce multi send (avoid hammering on rapid toggles)
|
||
clearTimeout(toggleMultiOpt._t);
|
||
toggleMultiOpt._t = setTimeout(() => sendAnswerAsync(q), 500);
|
||
}
|
||
|
||
/* ── Отправка ответа ─────────────────────────────────────────────── */
|
||
function sendAnswerAsync(q) {
|
||
const timeSpent = Math.round((Date.now() - (startTimes[q.id] || Date.now())) / 1000);
|
||
const type = q.type || 'single';
|
||
let option_id = null, answer_text = null, chosen_options = null;
|
||
if (type === 'short_answer') {
|
||
answer_text = String(answers[q.id] || '').trim();
|
||
_sentAnswers.add(q.id);
|
||
} else if (type === 'multi') {
|
||
chosen_options = answers[q.id] || [];
|
||
} else if (type === 'matching') {
|
||
answer_text = JSON.stringify(answers[q.id] || {});
|
||
} else {
|
||
option_id = answers[q.id];
|
||
}
|
||
LS.sendAnswer(session_id, q.id, option_id, timeSpent, answer_text, chosen_options)
|
||
.then(r => { if (r && typeof r.is_correct === 'boolean' && sessionMode === 'practice') _handleAnswerResult(r.is_correct); })
|
||
.catch(() => { LS.toast('Ответ не сохранён — нет связи с сервером', 'error'); });
|
||
}
|
||
|
||
/* ── Завершить ──────────────────────────────────────────────────── */
|
||
function tryFinish() {
|
||
cancelAutoAdvance();
|
||
if (questions[currentIdx]) flushShortAnswer(questions[currentIdx]);
|
||
|
||
const answered = questions.filter(isAnswered).length;
|
||
const total = questions.length;
|
||
const skipped = total - answered;
|
||
|
||
if (skipped > 0) {
|
||
const desc = document.getElementById('confirm-desc');
|
||
desc.innerHTML = skipped === total
|
||
? `Вы не ответили ни на один вопрос.<br>Результат будет нулевым.`
|
||
: `<span class="confirm-skipped">${lsIcon('warning', 16)} ${skipped} ${skipped === 1 ? 'вопрос без ответа' : skipped < 5 ? 'вопроса без ответа' : 'вопросов без ответа'}</span><br>Пропущенные вопросы будут засчитаны как неверные.`;
|
||
const ov = document.getElementById('confirm-overlay');
|
||
ov.classList.add('open');
|
||
setTimeout(() => document.getElementById('confirm-cancel-btn')?.focus(), 50);
|
||
return;
|
||
}
|
||
|
||
doFinish();
|
||
}
|
||
|
||
function closeConfirm() {
|
||
document.getElementById('confirm-overlay').classList.remove('open');
|
||
}
|
||
|
||
async function doFinish() {
|
||
closeConfirm();
|
||
clearInterval(timerInterval);
|
||
clearSession();
|
||
_finishing = true;
|
||
const btn = document.getElementById('btn-finish');
|
||
btn.disabled = true;
|
||
btn.innerHTML = 'Завершаем…';
|
||
try {
|
||
await LS.finishSession(session_id);
|
||
window.location.href = `/test-result?session=${session_id}`;
|
||
} catch (err) {
|
||
LS.toast('Ошибка: ' + err.message, 'error');
|
||
btn.disabled = false;
|
||
updateFinishBtn();
|
||
}
|
||
}
|
||
|
||
function updateFinishBtn() {
|
||
const answered = questions.filter(isAnswered).length;
|
||
const total = questions.length;
|
||
const btn = document.getElementById('btn-finish');
|
||
if (!btn) return;
|
||
if (answered === total) {
|
||
btn.innerHTML = 'Завершить тест ' + lsIcon('check', 14);
|
||
} else {
|
||
btn.innerHTML = `Завершить тест<span class="btn-finish-count">${answered} / ${total} отвечено</span>`;
|
||
}
|
||
}
|
||
|
||
/* ── Combo system ────────────────────────────────────────────────── */
|
||
let _combo = 0, _maxCombo = 0, _comboEl = null, _comboTimer = null;
|
||
|
||
function _showCombo(count) {
|
||
_clearComboEl();
|
||
const el = document.createElement('div');
|
||
let cls = '', icon = '', text = '';
|
||
if (count >= 10) { cls = 'c10'; icon = lsIcon('flame', 20); text = `COMBO x${count}! +бонус XP`; }
|
||
else if (count >= 5) { cls = 'c5'; icon = lsIcon('zap', 20); text = `Combo x${count}!`; }
|
||
else if (count >= 3) { cls = 'c3'; icon = lsIcon('sparkles', 20); text = `Combo x${count}`; }
|
||
else return;
|
||
el.className = `combo-toast ${cls}`;
|
||
el.innerHTML = `<span class="combo-icon">${icon}</span>${text}`;
|
||
document.body.appendChild(el);
|
||
_comboEl = el;
|
||
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
|
||
_comboTimer = setTimeout(() => {
|
||
el.classList.remove('show');
|
||
el.classList.add('hide');
|
||
setTimeout(() => el.remove(), 350);
|
||
if (_comboEl === el) _comboEl = null;
|
||
}, 1800);
|
||
}
|
||
|
||
function _showComboBreak(was) {
|
||
if (was < 3) return;
|
||
_clearComboEl();
|
||
const el = document.createElement('div');
|
||
el.className = 'combo-break';
|
||
el.textContent = `Комбо x${was} прервано`;
|
||
document.body.appendChild(el);
|
||
_comboEl = el;
|
||
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
|
||
setTimeout(() => {
|
||
el.classList.remove('show');
|
||
el.classList.add('hide');
|
||
setTimeout(() => el.remove(), 300);
|
||
if (_comboEl === el) _comboEl = null;
|
||
}, 1200);
|
||
}
|
||
|
||
function _clearComboEl() {
|
||
if (_comboTimer) { clearTimeout(_comboTimer); _comboTimer = null; }
|
||
if (_comboEl) { _comboEl.remove(); _comboEl = null; }
|
||
}
|
||
|
||
function _handleAnswerResult(isCorrect) {
|
||
if (isCorrect) {
|
||
_combo++;
|
||
if (_combo > _maxCombo) _maxCombo = _combo;
|
||
_showCombo(_combo);
|
||
} else {
|
||
const was = _combo;
|
||
_combo = 0;
|
||
_showComboBreak(was);
|
||
}
|
||
}
|
||
|
||
/* ── Навигационные точки ────────────────────────────────────────── */
|
||
function renderDots() {
|
||
const el = document.getElementById('dots');
|
||
el.innerHTML = '';
|
||
questions.forEach((q, i) => {
|
||
if (isCTMode && i === 0 && ctPartBStart > 0) {
|
||
const lbl = document.createElement('div');
|
||
lbl.style.cssText = 'width:100%;font-size:0.65rem;font-weight:800;color:var(--text-3);text-transform:uppercase;letter-spacing:0.07em;margin-top:4px';
|
||
lbl.textContent = 'Часть А';
|
||
el.appendChild(lbl);
|
||
}
|
||
if (isCTMode && i === ctPartBStart && ctPartBStart > 0 && ctPartBStart < questions.length) {
|
||
const lbl = document.createElement('div');
|
||
lbl.style.cssText = 'width:100%;font-size:0.65rem;font-weight:800;color:var(--amber);text-transform:uppercase;letter-spacing:0.07em;margin-top:8px';
|
||
lbl.textContent = 'Часть Б';
|
||
el.appendChild(lbl);
|
||
}
|
||
const dot = document.createElement('button');
|
||
dot.className = 'dot';
|
||
dot.textContent = i + 1;
|
||
dot.setAttribute('aria-label', `Вопрос ${i + 1}`);
|
||
dot.addEventListener('click', () => { cancelAutoAdvance(); renderQuestion(i); });
|
||
el.appendChild(dot);
|
||
});
|
||
}
|
||
|
||
function updateDots() {
|
||
document.querySelectorAll('.dot').forEach((dot, i) => {
|
||
const isCurrent = i === currentIdx;
|
||
dot.classList.toggle('current', isCurrent);
|
||
dot.classList.toggle('answered', isAnswered(questions[i]));
|
||
dot.classList.toggle('flagged', !!flags[questions[i].id]);
|
||
dot.setAttribute('aria-current', isCurrent ? 'step' : 'false');
|
||
});
|
||
}
|
||
|
||
/* ── Flag toggle ─────────────────────────────────────────────────── */
|
||
function toggleFlag(qid) {
|
||
flags[qid] = !flags[qid];
|
||
// Update flag button
|
||
const btn = document.getElementById(`btn-flag-${qid}`);
|
||
if (btn) {
|
||
btn.classList.toggle('flagged', flags[qid]);
|
||
btn.setAttribute('aria-pressed', flags[qid] ? 'true' : 'false');
|
||
btn.setAttribute('aria-label', flags[qid] ? 'Снять отметку' : 'Отметить для проверки');
|
||
btn.querySelector('svg').setAttribute('fill', flags[qid] ? '#f59e0b' : 'none');
|
||
btn.querySelector('svg').setAttribute('stroke', flags[qid] ? '#f59e0b' : 'currentColor');
|
||
}
|
||
// Update badge label
|
||
const badge = document.querySelector('.q-num-badge');
|
||
if (badge) {
|
||
const existing = badge.querySelector('.flag-label');
|
||
if (existing) existing.remove();
|
||
if (flags[qid]) {
|
||
const lbl = document.createElement('span');
|
||
lbl.className = 'flag-label';
|
||
lbl.style.cssText = 'font-size:0.7rem;color:#f59e0b;font-weight:700;margin-left:4px';
|
||
lbl.textContent = 'отмечен';
|
||
badge.appendChild(lbl);
|
||
}
|
||
}
|
||
updateDots();
|
||
saveToSession();
|
||
}
|
||
|
||
function updateProgress() {
|
||
const n = questions.filter(isAnswered).length;
|
||
document.getElementById('prog-answered').textContent = n;
|
||
document.getElementById('prog-fill').style.width = `${(n / questions.length) * 100}%`;
|
||
updateFinishBtn();
|
||
saveToSession();
|
||
}
|
||
|
||
/* ── utils ──────────────────────────────────────────────────────── */
|
||
function show(id) { document.getElementById(id).style.display = ''; }
|
||
function hide(id) { document.getElementById(id).style.display = 'none'; }
|
||
|
||
document.getElementById('btn-finish')?.addEventListener('click', tryFinish);
|
||
|
||
// Auto-advance toggle
|
||
const _aaToggle = document.getElementById('auto-adv-toggle');
|
||
_aaToggle.checked = localStorage.getItem('ls_auto_advance') !== '0';
|
||
_aaToggle.addEventListener('change', () => {
|
||
localStorage.setItem('ls_auto_advance', _aaToggle.checked ? '1' : '0');
|
||
if (!_aaToggle.checked) cancelAutoAdvance();
|
||
});
|
||
|
||
/* ── Leave-page warning ──────────────────────────────────────────── */
|
||
let _finishing = false;
|
||
window.addEventListener('beforeunload', e => {
|
||
if (_finishing) return;
|
||
if (questions && questions.some(isAnswered)) {
|
||
e.preventDefault();
|
||
e.returnValue = 'Тест не завершён. Ваши ответы сохранены. Выйти?';
|
||
}
|
||
});
|
||
|
||
// Keyboard shortcuts: 1-4 / A-D to select answer options
|
||
document.addEventListener('keydown', e => {
|
||
if (!questions.length || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
const q = questions[currentIdx];
|
||
if (!q || (q.type && q.type !== 'single' && q.type !== 'true_false')) return;
|
||
let idx = -1;
|
||
if (e.key >= '1' && e.key <= '4') idx = parseInt(e.key) - 1;
|
||
else if (e.key.toLowerCase() >= 'a' && e.key.toLowerCase() <= 'd') idx = e.key.toLowerCase().charCodeAt(0) - 97;
|
||
if (idx === -1) return;
|
||
const opts = document.querySelectorAll('#q-area .q-opt');
|
||
if (opts[idx]) opts[idx].click();
|
||
});
|
||
|
||
// Prevent bfcache from restoring a completed test when user presses Back
|
||
window.addEventListener('pageshow', e => {
|
||
if (e.persisted) window.location.replace('/dashboard');
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|