From cfcb233b6c92d443f5fb790715c3f10b7b4557ec Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 11:06:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(exam-prep=20F9):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=20=E2=80=94=20setup/active/result=20+=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BC=D0=B5=D1=80=20+=20=D0=B1=D0=B0=D0=BB=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=B5=D1=82=D0=BA=D0=B5=20+=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/exam-prep.js | 296 ++++++++++++++++++++++++++ frontend/css/exam-prep.css | 96 +++++++++ frontend/exam-prep-mock.html | 17 +- frontend/js/exam-prep/mock.js | 325 +++++++++++++++++++++++++++++ frontend/js/exam-prep/task-card.js | 130 +++++++++--- 5 files changed, 825 insertions(+), 39 deletions(-) create mode 100644 frontend/js/exam-prep/mock.js diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index d849fb3..0b1c5ef 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -110,6 +110,60 @@ const SQL = { mode, session_id, hint_used, solution_viewed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), + + /* ── Mock sessions ──────────────────────────────────────────── */ + getTasksByVariant: db.prepare(` + SELECT id FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx + `), + getRandomTaskIds: db.prepare(` + SELECT id FROM exam_tasks + WHERE exam_key = ? AND task_type IN ('mc','open') + ORDER BY RANDOM() LIMIT ? + `), + insertMockSession: db.prepare(` + INSERT INTO exam_mock_sessions + (user_id, exam_key, variant, source, task_ids_json, + started_at, duration_planned_min, total_tasks, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active') + `), + getMockSession: db.prepare(` + SELECT id, user_id, exam_key, variant, source, task_ids_json, + started_at, finished_at, duration_planned_min, + score, total_correct, total_tasks, status + FROM exam_mock_sessions + WHERE id = ? + `), + getTracksScoring: db.prepare(`SELECT duration_min, scoring_json FROM exam_tracks WHERE exam_key = ?`), + getTasksByIds: (ids) => { + if (!ids.length) return []; + const ph = ids.map(() => '?').join(','); + return db.prepare(` + SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic + FROM exam_tasks + WHERE id IN (${ph}) + `).all(...ids); + }, + /* Latest user-answer per task in a mock session (one row per task). + We upsert by deleting prior rows for (user, task, session, mode='mock'). */ + deleteMockAnswer: db.prepare(` + DELETE FROM exam_attempts + WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock' + `), + getMockAnswers: db.prepare(` + SELECT exam_task_id, user_answer, is_correct + FROM exam_attempts + WHERE user_id = ? AND session_id = ? AND mode = 'mock' + `), + updateMockAttemptCorrectness: db.prepare(` + UPDATE exam_attempts SET is_correct = ? + WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock' + `), + finalizeMockSession: db.prepare(` + UPDATE exam_mock_sessions + SET status = 'finished', finished_at = ?, score = ?, total_correct = ? + WHERE id = ? + `), }; /* ── GET /api/exam-prep/tracks ── @@ -290,4 +344,246 @@ router.post('/attempts', (req, res) => { res.json({ id: Number(result.lastInsertRowid) }); }); +/* ────────────────────────────────────────────────────────────────── + Mock exam (F9) + ────────────────────────────────────────────────────────────────── */ + +/* Convert {correct} count into a final score per the track's scoring grid. + Grid is JSON like [{correct:30,score:10},{correct:27,score:9},...]. + Sorted descending; we return the score for the largest threshold met. */ +function scoreFromGrid(correctCount, scoringJson) { + if (!scoringJson) return null; + let grid; + try { grid = JSON.parse(scoringJson); } catch { return null; } + if (!Array.isArray(grid) || !grid.length) return null; + grid = grid.slice().sort((a, b) => b.correct - a.correct); + for (const entry of grid) { + if (correctCount >= entry.correct) return entry.score; + } + return 0; +} + +/* ── POST /api/exam-prep/:examKey/mock/start ── + Body: { source: 'variant'|'random', variant?: number, count?: number } + Creates an active mock session and returns its id. */ +router.post('/:examKey/mock/start', (req, res) => { + const { examKey } = req.params; + const track = SQL.getTrack.get(examKey); + if (!track) return res.status(404).json({ error: 'Unknown exam track' }); + + const source = req.body?.source; + let taskIds = []; + let variant = null; + + if (source === 'variant') { + variant = Number(req.body?.variant); + if (!Number.isInteger(variant) || variant < 1) { + return res.status(400).json({ error: 'Variant number required' }); + } + const rows = SQL.getTasksByVariant.all(examKey, variant); + if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' }); + taskIds = rows.map(r => r.id); + } else if (source === 'random') { + let count = Number(req.body?.count) || track.tasks_per_variant; + count = Math.max(5, Math.min(count, 30)); + const rows = SQL.getRandomTaskIds.all(examKey, count); + taskIds = rows.map(r => r.id); + } else { + return res.status(400).json({ error: 'source must be variant|random' }); + } + + const r = SQL.insertMockSession.run( + req.user.id, examKey, variant, source, JSON.stringify(taskIds), + Date.now(), track.duration_min, taskIds.length + ); + res.json({ + id: Number(r.lastInsertRowid), + task_count: taskIds.length, + duration_min: track.duration_min, + }); +}); + +/* ── GET /api/exam-prep/mock/:id ── + Returns mock-session state + tasks. + - status='active' : tasks WITHOUT answer/solution; includes user_answers map + - status='finished' : tasks WITH answer/solution; includes is_correct per task */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/mock/:id', (req, res) => { + const id = Number(req.params.id); + const sess = SQL.getMockSession.get(id); + if (!sess) return res.status(404).json({ error: 'Mock session not found' }); + if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + + let ids = []; + try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} + const tasksRaw = SQL.getTasksByIds(ids); + const taskMap = new Map(tasksRaw.map(t => [t.id, t])); + const tasks = ids.map(i => taskMap.get(i)).filter(Boolean); + + const answers = SQL.getMockAnswers.all(req.user.id, id); + const answerByTask = new Map(answers.map(a => [a.exam_task_id, a])); + + const isActive = sess.status === 'active'; + const out = tasks.map((t, idx) => { + const ua = answerByTask.get(t.id); + return { + id: t.id, + idx: idx + 1, + variant: t.variant, + type: t.task_type, + text: t.text_html, + figure: t.figure_html, + opts: t.opts_json ? safeJson(t.opts_json) : null, + // Hide answer/solution while active + answer: isActive ? null : t.answer, + solution: isActive ? null : t.solution_html, + // User's stored answer + correctness (post-finish only) + user_answer: ua?.user_answer ?? null, + is_correct: !isActive ? (ua?.is_correct ?? null) : null, + }; + }); + + res.json({ + session: { + id: sess.id, + exam_key: sess.exam_key, + variant: sess.variant, + source: sess.source, + status: sess.status, + started_at: sess.started_at, + finished_at: sess.finished_at, + duration_planned_min: sess.duration_planned_min, + score: sess.score, + total_correct: sess.total_correct, + total_tasks: sess.total_tasks, + }, + tasks: out, + }); +}); + +/* ── POST /api/exam-prep/mock/:id/answer ── + Body: { exam_task_id, user_answer } + Upserts the user's draft answer (no correctness check while active). */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.post('/mock/:id/answer', (req, res) => { + const id = Number(req.params.id); + const sess = SQL.getMockSession.get(id); + if (!sess) return res.status(404).json({ error: 'Mock session not found' }); + if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + if (sess.status !== 'active') return res.status(409).json({ error: 'Session finished' }); + + const taskId = Number(req.body?.exam_task_id); + if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' }); + + let ids = []; + try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} + if (!ids.includes(taskId)) return res.status(400).json({ error: 'Task not in this session' }); + + const userAnswer = req.body?.user_answer != null ? String(req.body.user_answer).slice(0, 500) : null; + const timeMs = Number.isFinite(req.body?.time_ms) ? Math.max(0, Math.min(req.body.time_ms, 24 * 3600 * 1000)) : null; + + /* Upsert: delete any prior mock-attempt for this task, then insert fresh */ + db.transaction(() => { + SQL.deleteMockAnswer.run(req.user.id, taskId, id); + SQL.insertAttempt.run( + req.user.id, taskId, userAnswer, null /* is_correct unknown */, + timeMs, 'mock', id, 0, 0, Date.now() + ); + })(); + + res.json({ ok: true }); +}); + +/* ── POST /api/exam-prep/mock/:id/finish ── + Computes correctness for every stored answer + final score. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.post('/mock/:id/finish', (req, res) => { + const id = Number(req.params.id); + const sess = SQL.getMockSession.get(id); + if (!sess) return res.status(404).json({ error: 'Mock session not found' }); + if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + if (sess.status === 'finished') return res.json({ ok: true, already: true }); + + // Pull tasks + answers, compute correctness per task using same logic as client. + let ids = []; + try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} + const tasks = SQL.getTasksByIds(ids); + const taskMap = new Map(tasks.map(t => [t.id, t])); + const answers = SQL.getMockAnswers.all(req.user.id, id); + + let totalCorrect = 0; + db.transaction(() => { + for (const a of answers) { + const t = taskMap.get(a.exam_task_id); + if (!t || !t.answer) continue; + const correct = checkAnswerServer(a.user_answer, t.answer) ? 1 : 0; + if (correct) totalCorrect++; + SQL.updateMockAttemptCorrectness.run(correct, req.user.id, a.exam_task_id, id); + } + })(); + + const track = SQL.getTracksScoring.get(sess.exam_key); + const score = track ? scoreFromGrid(totalCorrect, track.scoring_json) : null; + + SQL.finalizeMockSession.run(Date.now(), score, totalCorrect, id); + + res.json({ + ok: true, + total_correct: totalCorrect, + total_tasks: sess.total_tasks, + score, + }); +}); + +/* ────────────────────────────────────────────────────────────────── + Server-side answer checker — mirror of frontend EP.answer.check. + Kept here (intentional code duplication) so the server is the source + of truth for mock-exam scoring and can never be bypassed by a + tampered client. + ────────────────────────────────────────────────────────────────── */ +const EPS = 1e-6; + +function srvToNumber(s) { + if (s == null) return NaN; + let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.'); + const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/); + if (f) { + const num = Number(f[1]); + const den = Number(f[2]); + if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN; + return num / den; + } + const n = Number(t); + return Number.isFinite(n) ? n : NaN; +} + +function srvToPair(s) { + if (s == null) return null; + const t = String(s).trim().replace(/\$/g, '').replace(/\s+и\s+/g, ';'); + const parts = t.split(/[;,]/).map(p => p.trim()).filter(Boolean); + if (parts.length !== 2) return null; + const a = srvToNumber(parts[0]); + const b = srvToNumber(parts[1]); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return a <= b ? [a, b] : [b, a]; +} + +function checkAnswerServer(userInput, canonical) { + if (userInput == null || canonical == null) return false; + const c = String(canonical).trim(); + if (/^[а-д]$/.test(c)) { + return String(userInput).trim().toLowerCase() === c.toLowerCase(); + } + if (/^[^;]+;[^;]+$/.test(c)) { + const cp = srvToPair(c); + const up = srvToPair(userInput); + if (!cp || !up) return false; + return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS; + } + const cn = srvToNumber(c); + const un = srvToNumber(userInput); + if (Number.isNaN(cn) || Number.isNaN(un)) return false; + return Math.abs(cn - un) < EPS; +} + module.exports = router; diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 8b4c03c..30ab80f 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -612,6 +612,102 @@ border-radius: 10px; } +/* ═══════════════════════════════════════════════════════════════ + Mock exam view (`mk-*`) — used by exam-prep-mock.html + ═══════════════════════════════════════════════════════════════ */ + +/* Setup */ +.mk-setup p { font-size: .92rem; line-height: 1.7; } +.mk-source { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + margin: 18px 0 4px; +} +.mk-source-card { + border: 1.5px solid var(--border-h); + border-radius: 12px; + padding: 14px 16px; + cursor: pointer; + transition: all .15s; +} +.mk-source-card:hover { border-color: var(--violet); } +.mk-source-card.mk-source-active { + border-color: var(--violet); + background: rgba(155,93,229,.06); +} +.mk-source-head { + display: flex; align-items: center; gap: 10px; + font-family: 'Manrope', sans-serif; font-size: .95rem; font-weight: 700; + color: var(--text); margin-bottom: 10px; +} +.mk-source-head svg { width: 18px; height: 18px; color: var(--violet); } +.mk-source-body label { + display: flex; align-items: center; gap: 8px; + font-size: .85rem; color: var(--text-2); +} +.mk-input { + width: 80px; + padding: 6px 10px; + border: 1.5px solid var(--border-h); + border-radius: 7px; + background: var(--surface); color: var(--text); + font-family: 'Manrope', sans-serif; font-size: .9rem; font-weight: 600; +} +.mk-input:focus { outline: none; border-color: var(--violet); } +.mk-source-hint { + font-size: .78rem; color: var(--text-3); margin-top: 6px; +} +.mk-start-row { margin-top: 16px; } + +/* Active phase: sticky bar */ +.mk-bar { + position: sticky; top: 0; z-index: 50; + display: flex; align-items: center; gap: 14px; + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 12px; + padding: 12px 18px; + margin-bottom: 18px; + flex-wrap: wrap; +} +.mk-bar-info { + display: flex; flex-direction: column; gap: 2px; + flex: 1; min-width: 180px; +} +.mk-bar-source { + font-family: 'Unbounded', sans-serif; font-size: .9rem; font-weight: 800; + letter-spacing: -.01em; +} +.mk-bar-count { + font-size: .78rem; color: var(--text-2); +} +.mk-timer { + font-family: 'Unbounded', sans-serif; + font-size: 1.6rem; font-weight: 800; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -.01em; +} +.mk-timer.mk-timer-warn { color: #F8961E; } +.mk-timer.mk-timer-zero { color: #E63946; animation: mk-pulse .8s infinite; } +@keyframes mk-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .4; } +} +.mk-finish-btn { white-space: nowrap; } + +.mk-tasks { } + +/* Result phase */ +.mk-result-stats { margin-bottom: 18px; } +.mk-breakdown-title { + font-family: 'Unbounded', sans-serif; + font-size: 1.05rem; font-weight: 800; + margin: 26px 0 12px; + letter-spacing: -.01em; +} + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/exam-prep-mock.html b/frontend/exam-prep-mock.html index 0916f8e..f18ea2d 100644 --- a/frontend/exam-prep-mock.html +++ b/frontend/exam-prep-mock.html @@ -8,6 +8,9 @@ + + +
@@ -24,9 +27,9 @@
-
+
 
-
 
+
 
@@ -34,9 +37,8 @@
- -

Пробный экзамен

-

В F9 здесь стартует таймер на 180 минут, 10 задач в реальных условиях (без проверки и решений), а в конце — балл по сетке и разбор каждого задания.

+ +

Загрузка…

@@ -52,6 +54,9 @@ - + + + + diff --git a/frontend/js/exam-prep/mock.js b/frontend/js/exam-prep/mock.js new file mode 100644 index 0000000..82f581e --- /dev/null +++ b/frontend/js/exam-prep/mock.js @@ -0,0 +1,325 @@ +'use strict'; +/* ────────────────────────────────────────────────────────────────── + Mock exam view — three phases on the same page: + setup : pick source (variant / random N) + start + active : countdown timer + tasks (no auto-check) + finish + result : score + breakdown with solutions + + URL: /exam-prep/:examKey/mock → setup + /exam-prep/:examKey/mock/:id → active | result (by session.status) + ────────────────────────────────────────────────────────────────── */ + +(async function () { + await EP.boot(); + const examKey = EP.examKey; + const main = document.getElementById('ep-main'); + + // Parse :id from path: /exam-prep//mock/ + const mockId = (() => { + const m = location.pathname.match(/\/mock\/(\d+)/); + return m ? Number(m[1]) : null; + })(); + + if (!mockId) { + renderSetup(); + return; + } + + // Load session + tasks + let payload; + try { + payload = await LS.api(`/api/exam-prep/mock/${mockId}`); + } catch (e) { + main.innerHTML = errorHtml('Не удалось загрузить пробник', e); + if (window.lucide) lucide.createIcons(); + return; + } + + if (payload.session.status === 'finished') { + renderResult(payload); + } else { + renderActive(payload); + } + + /* ════════════════════════════════════════════════════════════ + PHASE 1: SETUP + ════════════════════════════════════════════════════════════ */ + function renderSetup() { + const title = EP.info?.track?.title || 'Пробный экзамен'; + const dur = EP.info?.track?.duration_min || 180; + const tpv = EP.info?.track?.tasks_per_variant || 10; + const vc = EP.info?.track?.variants_count || 80; + + main.innerHTML = ` +
+

Новый пробник

+

+ ${dur} минут · ${tpv} задач · в конце — балл по сетке и разбор каждого задания. + Во время прохождения ответы не проверяются и решения скрыты — как на реальном экзамене. +

+ +
+
+
+ + По варианту +
+
+ +
Один из ${vc} реальных вариантов целиком.
+
+
+ +
+
+ + Случайные задачи +
+
+ +
Микс из всего банка (только mc + open).
+
+
+
+ +
+ +
+
+ `; + if (window.lucide) lucide.createIcons(); + + // Source selection + let source = 'variant'; + main.querySelectorAll('.mk-source-card').forEach(c => { + c.onclick = () => { + source = c.dataset.src; + main.querySelectorAll('.mk-source-card').forEach(x => + x.classList.toggle('mk-source-active', x === c)); + }; + }); + document.getElementById('mk-start').onclick = startMock; + + async function startMock() { + const btn = document.getElementById('mk-start'); + btn.disabled = true; btn.textContent = 'Запуск…'; + + const body = { source }; + if (source === 'variant') { + const v = Number(document.getElementById('mk-variant-input').value); + if (!Number.isInteger(v) || v < 1) { + btn.disabled = false; btn.innerHTML = ' Начать пробник'; + if (window.lucide) lucide.createIcons(); + return alert('Введите номер варианта'); + } + body.variant = v; + } else { + body.count = Number(document.getElementById('mk-count-input').value) || 10; + } + + try { + const r = await EP.api.startMock(examKey, body); + location.href = `/exam-prep/${examKey}/mock/${r.id}`; + } catch (e) { + btn.disabled = false; + btn.innerHTML = ' Начать пробник'; + if (window.lucide) lucide.createIcons(); + alert(`Не удалось начать: ${e.message || e}`); + } + } + } + + /* ════════════════════════════════════════════════════════════ + PHASE 2: ACTIVE + ════════════════════════════════════════════════════════════ */ + function renderActive(payload) { + const { session, tasks } = payload; + const startMs = session.started_at; + const totalMs = session.duration_planned_min * 60 * 1000; + + const sourceLabel = session.source === 'variant' + ? `Вариант ${session.variant}` + : `Случайные ${tasks.length} задач`; + + main.innerHTML = ` +
+
+ ${sourceLabel} + 0/${tasks.length} отвечено +
+
--:--:--
+ +
+
+ `; + + const taskContainer = document.getElementById('mk-tasks'); + const answeredSet = new Set(); + + tasks.forEach((task, i) => { + // Prefill if resuming + if (task.user_answer != null) answeredSet.add(task.id); + EP.TaskCard.render(taskContainer, task, { + mode: 'mock', + sessionId: session.id, + autoCheck: false, + showSolution: false, + numbering: i + 1, + prefillAnswer: task.user_answer ?? null, + onAnswerChange: (taskId, value) => { + // Save (best-effort). Empty → don't bother + if (value == null || value === '') return; + EP.api.mockAnswer(session.id, { + exam_task_id: taskId, + user_answer: value, + }).then(() => { + if (!answeredSet.has(taskId)) { + answeredSet.add(taskId); + updateAnsweredCount(); + } + }).catch(() => { /* silent — user can finish anyway */ }); + }, + }); + }); + + function updateAnsweredCount() { + const el = document.getElementById('mk-answered'); + if (el) el.textContent = `${answeredSet.size}/${tasks.length} отвечено`; + } + updateAnsweredCount(); + + /* Timer */ + let timerInterval = null; + function tick() { + const left = startMs + totalMs - Date.now(); + const el = document.getElementById('mk-timer'); + if (!el) { clearInterval(timerInterval); return; } + if (left <= 0) { + el.textContent = '00:00:00'; + el.classList.add('mk-timer-zero'); + clearInterval(timerInterval); + finish(true); + return; + } + const h = Math.floor(left / 3600000); + const m = Math.floor((left % 3600000) / 60000); + const s = Math.floor((left % 60000) / 1000); + el.textContent = + `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + if (left < 10 * 60 * 1000) el.classList.add('mk-timer-warn'); + } + tick(); + timerInterval = setInterval(tick, 1000); + + /* Finish */ + const finishBtn = document.getElementById('mk-finish'); + finishBtn.onclick = () => { + if (answeredSet.size < tasks.length) { + const left = tasks.length - answeredSet.size; + if (!confirm(`Не отвечено: ${left}. Завершить пробник сейчас?`)) return; + } + finish(false); + }; + + async function finish(autoExpired) { + clearInterval(timerInterval); + finishBtn.disabled = true; + finishBtn.innerHTML = ' Подведение итогов…'; + if (window.lucide) lucide.createIcons(); + try { + await EP.api.mockFinish(session.id); + location.reload(); // will render result phase + } catch (e) { + alert(`Не удалось завершить: ${e.message || e}`); + finishBtn.disabled = false; + finishBtn.innerHTML = ' Завершить'; + if (window.lucide) lucide.createIcons(); + } + } + + if (window.lucide) lucide.createIcons(); + } + + /* ════════════════════════════════════════════════════════════ + PHASE 3: RESULT + ════════════════════════════════════════════════════════════ */ + function renderResult(payload) { + const { session, tasks } = payload; + const dur = (session.finished_at - session.started_at) / 1000; + const h = Math.floor(dur / 3600), m = Math.floor((dur % 3600) / 60); + const durStr = h ? `${h} ч ${m} мин` : `${m} мин`; + const acc = session.total_tasks + ? Math.round((session.total_correct / session.total_tasks) * 100) + : 0; + + main.innerHTML = ` +
+

Результат пробника

+
+
+
Балл
+
${session.score != null ? session.score : '—'}
+
по сетке экзамена
+
+
+
Верно
+
${session.total_correct}/${session.total_tasks}
+
${acc}% точности
+
+
+
Время
+
${durStr}
+
из ${session.duration_planned_min} мин
+
+
+ +
+ +

Разбор задач

+
+ `; + + const container = document.getElementById('mk-breakdown'); + tasks.forEach((task, i) => { + EP.TaskCard.render(container, task, { + mode: 'mock', + sessionId: session.id, + autoCheck: false, + showSolution: true, + readonly: true, + numbering: i + 1, + prefillAnswer: task.user_answer ?? null, + forceVerdict: task.is_correct != null ? { isCorrect: task.is_correct } : null, + }); + }); + + if (window.lucide) lucide.createIcons(); + } + + /* ── utils ──────────────────────────────────────────────────── */ + function errorHtml(title, e) { + return `
+ +

${escapeHtml(title)}

+

${escapeHtml(e?.message || String(e))}

+
`; + } + function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); + } +})(); diff --git a/frontend/js/exam-prep/task-card.js b/frontend/js/exam-prep/task-card.js index 8f6af2a..444b474 100644 --- a/frontend/js/exam-prep/task-card.js +++ b/frontend/js/exam-prep/task-card.js @@ -11,8 +11,12 @@ opts : { mode : 'variant'|'practice'|'topic'|'mock' sessionId : number | null (groups attempts in a session) - autoCheck : true (whether check button is shown; mock=false) - showSolution : true (whether solution toggle exists; mock=false) + autoCheck : true (whether check button + verdict UI is shown) + showSolution : true (whether solution toggle exists) + readonly : false (inputs disabled; for finished mock review) + prefillAnswer : string | null (initial user_answer; for mock review) + forceVerdict : {isCorrect:0|1} | null (show verdict badge as if checked) + onAnswerChange : (taskId, value) => void (per-keystroke, debounced; mock auto-save) onAttempt : (result) => void (notify parent after check or solution-view) numbering : number | null (override task number badge) } @@ -46,46 +50,55 @@ /* Render one task into `container`. Returns a controller object with .destroy(). */ function render(container, task, opts = {}) { const mode = opts.mode || 'variant'; - const showAns = opts.autoCheck !== false; + const autoCheck = opts.autoCheck !== false; const showSol = opts.showSolution !== false; + const readonly = !!opts.readonly; const numbering = (opts.numbering != null) ? opts.numbering : task.idx; const sessionId = opts.sessionId || null; const onAttempt = opts.onAttempt || (() => {}); + const onAnswerChange = opts.onAnswerChange || null; const card = document.createElement('div'); card.className = 'tc-card'; card.dataset.taskId = String(task.id); card.dataset.taskType = task.type; - // ── Inner skeleton + // ── Build input area by task type × autoCheck combo let inputBlock = ''; - if (showAns) { - if (task.type === 'mc' && task.opts) { - inputBlock = ` - ${buildOptsBlock(task.id, task.opts)} -
- - -
`; - } else if (task.type === 'open') { - inputBlock = ` -
- - - + const verdictSlot = ``; + const checkBtnRow = autoCheck + ? `
+ + ${verdictSlot} +
` + : verdictSlot; + + if (task.type === 'mc' && task.opts) { + inputBlock = buildOptsBlock(task.id, task.opts) + checkBtnRow; + } else if (task.type === 'open') { + inputBlock = ` +
+ + +
` + checkBtnRow; + } else if (task.type === 'long' && autoCheck) { + inputBlock = ` +
+ Развёрнутый ответ — проверьте себя по решению, затем отметьте: +
+ +
- `; - } else if (task.type === 'long') { - inputBlock = ` -
- Развёрнутый ответ — проверьте себя по решению, затем отметьте: -
- - -
-
`; - } +
`; + } else if (task.type === 'long' && !autoCheck) { + // Mock + long: short free-text answer + inputBlock = ` +
+ + +
` + verdictSlot; } const solBlock = (showSol && task.solution) ? ` @@ -119,18 +132,57 @@ let attemptCount = 0; // how many CHECK attempts made let firstAttemptCorrect = null; // we report this in onAttempt - // ── Input enable on first interaction + // ── Prefill answer (mock review or resumed mock session) + if (opts.prefillAnswer != null) { + const text = card.querySelector('[data-tc-text]'); + if (text) text.value = String(opts.prefillAnswer); + const radios = card.querySelectorAll('input[type="radio"]'); + radios.forEach(r => { if (r.value === String(opts.prefillAnswer)) r.checked = true; }); + } + + // ── Force verdict (mock review: server has graded; show result without check button) + if (opts.forceVerdict && opts.forceVerdict.isCorrect != null) { + applyVerdictReadonly(opts.forceVerdict.isCorrect); + } + + // ── Readonly: disable all inputs (mock review) + if (readonly) { + card.querySelectorAll('input').forEach(el => el.disabled = true); + card.dataset.tcLocked = '1'; + } + + // ── Input enable on first interaction + auto-save (mock) const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]'); const checkBtn = card.querySelector('[data-tc-check]'); + let saveDebounce = null; inputs.forEach(inp => { - inp.addEventListener('input', () => updateCheckEnabled()); - inp.addEventListener('change', () => updateCheckEnabled()); + inp.addEventListener('input', () => { updateCheckEnabled(); maybeAutoSave(); }); + inp.addEventListener('change', () => { updateCheckEnabled(); maybeAutoSave(); }); }); function updateCheckEnabled() { if (!checkBtn) return; const has = readUserAnswer() !== null; checkBtn.disabled = !has || card.dataset.tcLocked === '1'; } + function maybeAutoSave() { + if (!onAnswerChange || readonly) return; + const v = readUserAnswer(); + // debounce per-keystroke text input; radio changes save immediately + clearTimeout(saveDebounce); + const delay = card.querySelector('input[type="radio"]:checked') ? 0 : 450; + saveDebounce = setTimeout(() => onAnswerChange(task.id, v), delay); + } + + function applyVerdictReadonly(isCorrect) { + card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong'); + const verdict = card.querySelector('[data-tc-verdict]'); + if (verdict) { + verdict.hidden = false; + verdict.innerHTML = isCorrect + ? `${ICONS.check} Правильно` + : `${ICONS.cross} Неправильно`; + } + } function readUserAnswer() { const mcGroup = card.querySelector('[data-tc-mc]'); @@ -255,6 +307,18 @@ EP.api.saveAttempt(body).catch(() => {}); } + // ── Review mode: auto-open the solution panel for instant context + if (opts.forceVerdict && showSol) { + const sb = card.querySelector('[data-tc-sol]'); + const sp = card.querySelector('[data-tc-sol-panel]'); + if (sb && sp && !sp.classList.contains('visible')) { + sp.classList.add('visible'); + sb.classList.add('open'); + sb.querySelector('span').textContent = 'Скрыть решение'; + EP.katex?.run(sp); + } + } + return { el: card, destroy: () => card.remove(),