feat(exam-prep F9): пробный экзамен — setup/active/result + таймер + балл по сетке + серверный чекер
This commit is contained in:
@@ -110,6 +110,60 @@ const SQL = {
|
|||||||
mode, session_id, hint_used, solution_viewed, created_at)
|
mode, session_id, hint_used, solution_viewed, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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 ──
|
/* ── GET /api/exam-prep/tracks ──
|
||||||
@@ -290,4 +344,246 @@ router.post('/attempts', (req, res) => {
|
|||||||
res.json({ id: Number(result.lastInsertRowid) });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -612,6 +612,102 @@
|
|||||||
border-radius: 10px;
|
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 ─────────────────────────────────────────────── */
|
/* ── Mobile tweaks ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.ep-wrap { padding: 20px 16px 60px; }
|
.ep-wrap { padding: 20px 16px 60px; }
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<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 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="/css/ls.css" />
|
||||||
<link rel="stylesheet" href="/css/exam-prep.css" />
|
<link rel="stylesheet" href="/css/exam-prep.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" onload="onKatexLoad()"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
@@ -24,9 +27,9 @@
|
|||||||
<path d="m9 14 2 2 4-4"/>
|
<path d="m9 14 2 2 4-4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style="flex:1">
|
||||||
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em"> </span></div>
|
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em"> </span></div>
|
||||||
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em"> </span></div>
|
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em"> </span></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -34,9 +37,8 @@
|
|||||||
|
|
||||||
<main id="ep-main">
|
<main id="ep-main">
|
||||||
<div class="ep-empty">
|
<div class="ep-empty">
|
||||||
<i data-lucide="timer"></i>
|
<i data-lucide="loader-circle"></i>
|
||||||
<h4>Пробный экзамен</h4>
|
<h4>Загрузка…</h4>
|
||||||
<p>В F9 здесь стартует таймер на 180 минут, 10 задач в реальных условиях (без проверки и решений), а в конце — балл по сетке и разбор каждого задания.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -52,6 +54,9 @@
|
|||||||
<script src="/js/mobile.js"></script>
|
<script src="/js/mobile.js"></script>
|
||||||
<script src="/js/exam-prep/common.js"></script>
|
<script src="/js/exam-prep/common.js"></script>
|
||||||
<script src="/js/exam-prep/api.js"></script>
|
<script src="/js/exam-prep/api.js"></script>
|
||||||
<script>(async () => { await EP.boot(); if (window.lucide) lucide.createIcons(); })();</script>
|
<script src="/js/exam-prep/katex.js"></script>
|
||||||
|
<script src="/js/exam-prep/answer-check.js"></script>
|
||||||
|
<script src="/js/exam-prep/task-card.js"></script>
|
||||||
|
<script src="/js/exam-prep/mock.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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/<key>/mock/<id>
|
||||||
|
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 = `
|
||||||
|
<div class="ep-card mk-setup">
|
||||||
|
<h3>Новый пробник</h3>
|
||||||
|
<p class="ep-card-hint">
|
||||||
|
${dur} минут · ${tpv} задач · в конце — балл по сетке и разбор каждого задания.
|
||||||
|
Во время прохождения ответы не проверяются и решения скрыты — как на реальном экзамене.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mk-source">
|
||||||
|
<div class="mk-source-card mk-source-active" data-src="variant">
|
||||||
|
<div class="mk-source-head">
|
||||||
|
<i data-lucide="layout-grid"></i>
|
||||||
|
<span>По варианту</span>
|
||||||
|
</div>
|
||||||
|
<div class="mk-source-body">
|
||||||
|
<label>Номер варианта:
|
||||||
|
<input type="number" min="1" max="${vc}" value="1" id="mk-variant-input" class="mk-input" />
|
||||||
|
</label>
|
||||||
|
<div class="mk-source-hint">Один из ${vc} реальных вариантов целиком.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mk-source-card" data-src="random">
|
||||||
|
<div class="mk-source-head">
|
||||||
|
<i data-lucide="shuffle"></i>
|
||||||
|
<span>Случайные задачи</span>
|
||||||
|
</div>
|
||||||
|
<div class="mk-source-body">
|
||||||
|
<label>Количество:
|
||||||
|
<input type="number" min="5" max="30" value="${tpv}" id="mk-count-input" class="mk-input" />
|
||||||
|
</label>
|
||||||
|
<div class="mk-source-hint">Микс из всего банка (только mc + open).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-cta-row mk-start-row">
|
||||||
|
<button class="ep-btn ep-btn-primary" id="mk-start">
|
||||||
|
<i data-lucide="play"></i> Начать пробник
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = '<i data-lucide="play"></i> Начать пробник';
|
||||||
|
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 = '<i data-lucide="play"></i> Начать пробник';
|
||||||
|
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 = `
|
||||||
|
<div class="mk-bar">
|
||||||
|
<div class="mk-bar-info">
|
||||||
|
<span class="mk-bar-source">${sourceLabel}</span>
|
||||||
|
<span class="mk-bar-count" id="mk-answered">0/${tasks.length} отвечено</span>
|
||||||
|
</div>
|
||||||
|
<div class="mk-timer" id="mk-timer">--:--:--</div>
|
||||||
|
<button class="ep-btn ep-btn-primary mk-finish-btn" id="mk-finish">
|
||||||
|
<i data-lucide="flag"></i> Завершить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mk-tasks" id="mk-tasks"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<i data-lucide="loader-circle"></i> Подведение итогов…';
|
||||||
|
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 = '<i data-lucide="flag"></i> Завершить';
|
||||||
|
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 = `
|
||||||
|
<div class="ep-card mk-result">
|
||||||
|
<h3>Результат пробника</h3>
|
||||||
|
<div class="ep-stats mk-result-stats">
|
||||||
|
<div class="ep-stat">
|
||||||
|
<div class="ep-stat-label">Балл</div>
|
||||||
|
<div class="ep-stat-value ep-violet" style="font-size:2.2rem">${session.score != null ? session.score : '—'}</div>
|
||||||
|
<div class="ep-stat-sub">по сетке экзамена</div>
|
||||||
|
</div>
|
||||||
|
<div class="ep-stat">
|
||||||
|
<div class="ep-stat-label">Верно</div>
|
||||||
|
<div class="ep-stat-value ${acc >= 70 ? 'ep-good' : 'ep-warn'}">${session.total_correct}/${session.total_tasks}</div>
|
||||||
|
<div class="ep-stat-sub">${acc}% точности</div>
|
||||||
|
</div>
|
||||||
|
<div class="ep-stat">
|
||||||
|
<div class="ep-stat-label">Время</div>
|
||||||
|
<div class="ep-stat-value">${durStr}</div>
|
||||||
|
<div class="ep-stat-sub">из ${session.duration_planned_min} мин</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ep-cta-row">
|
||||||
|
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/mock">
|
||||||
|
<i data-lucide="rotate-cw"></i> Новый пробник
|
||||||
|
</a>
|
||||||
|
<a class="ep-btn" href="/exam-prep/${examKey}">
|
||||||
|
<i data-lucide="gauge"></i> На дашборд
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mk-breakdown-title">Разбор задач</h3>
|
||||||
|
<div id="mk-breakdown"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `<div class="ep-empty">
|
||||||
|
<i data-lucide="alert-triangle"></i>
|
||||||
|
<h4>${escapeHtml(title)}</h4>
|
||||||
|
<p>${escapeHtml(e?.message || String(e))}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -11,8 +11,12 @@
|
|||||||
opts : {
|
opts : {
|
||||||
mode : 'variant'|'practice'|'topic'|'mock'
|
mode : 'variant'|'practice'|'topic'|'mock'
|
||||||
sessionId : number | null (groups attempts in a session)
|
sessionId : number | null (groups attempts in a session)
|
||||||
autoCheck : true (whether check button is shown; mock=false)
|
autoCheck : true (whether check button + verdict UI is shown)
|
||||||
showSolution : true (whether solution toggle exists; mock=false)
|
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)
|
onAttempt : (result) => void (notify parent after check or solution-view)
|
||||||
numbering : number | null (override task number badge)
|
numbering : number | null (override task number badge)
|
||||||
}
|
}
|
||||||
@@ -46,46 +50,55 @@
|
|||||||
/* Render one task into `container`. Returns a controller object with .destroy(). */
|
/* Render one task into `container`. Returns a controller object with .destroy(). */
|
||||||
function render(container, task, opts = {}) {
|
function render(container, task, opts = {}) {
|
||||||
const mode = opts.mode || 'variant';
|
const mode = opts.mode || 'variant';
|
||||||
const showAns = opts.autoCheck !== false;
|
const autoCheck = opts.autoCheck !== false;
|
||||||
const showSol = opts.showSolution !== false;
|
const showSol = opts.showSolution !== false;
|
||||||
|
const readonly = !!opts.readonly;
|
||||||
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
|
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
|
||||||
const sessionId = opts.sessionId || null;
|
const sessionId = opts.sessionId || null;
|
||||||
const onAttempt = opts.onAttempt || (() => {});
|
const onAttempt = opts.onAttempt || (() => {});
|
||||||
|
const onAnswerChange = opts.onAnswerChange || null;
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'tc-card';
|
card.className = 'tc-card';
|
||||||
card.dataset.taskId = String(task.id);
|
card.dataset.taskId = String(task.id);
|
||||||
card.dataset.taskType = task.type;
|
card.dataset.taskType = task.type;
|
||||||
|
|
||||||
// ── Inner skeleton
|
// ── Build input area by task type × autoCheck combo
|
||||||
let inputBlock = '';
|
let inputBlock = '';
|
||||||
if (showAns) {
|
const verdictSlot = `<div class="tc-verdict" data-tc-verdict hidden></div>`;
|
||||||
if (task.type === 'mc' && task.opts) {
|
const checkBtnRow = autoCheck
|
||||||
inputBlock = `
|
? `<div class="tc-action-row">
|
||||||
${buildOptsBlock(task.id, task.opts)}
|
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
||||||
<div class="tc-action-row">
|
${verdictSlot}
|
||||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
</div>`
|
||||||
<div class="tc-verdict" data-tc-verdict hidden></div>
|
: verdictSlot;
|
||||||
</div>`;
|
|
||||||
} else if (task.type === 'open') {
|
if (task.type === 'mc' && task.opts) {
|
||||||
inputBlock = `
|
inputBlock = buildOptsBlock(task.id, task.opts) + checkBtnRow;
|
||||||
<div class="tc-input-row">
|
} else if (task.type === 'open') {
|
||||||
<label class="tc-ans-label">Ответ:</label>
|
inputBlock = `
|
||||||
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
<div class="tc-input-row">
|
||||||
placeholder="например, 9/4 или -2" data-tc-text />
|
<label class="tc-ans-label">Ответ:</label>
|
||||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
||||||
|
placeholder="например, 9/4 или -2" data-tc-text />
|
||||||
|
</div>` + checkBtnRow;
|
||||||
|
} else if (task.type === 'long' && autoCheck) {
|
||||||
|
inputBlock = `
|
||||||
|
<div class="tc-self-mark">
|
||||||
|
<span class="tc-self-mark-label">Развёрнутый ответ — проверьте себя по решению, затем отметьте:</span>
|
||||||
|
<div class="tc-self-mark-btns">
|
||||||
|
<button class="tc-self-btn tc-self-yes" data-tc-self="1">${ICONS.check} Я решил</button>
|
||||||
|
<button class="tc-self-btn tc-self-no" data-tc-self="0">${ICONS.cross} Не решил</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tc-verdict" data-tc-verdict hidden></div>`;
|
</div>`;
|
||||||
} else if (task.type === 'long') {
|
} else if (task.type === 'long' && !autoCheck) {
|
||||||
inputBlock = `
|
// Mock + long: short free-text answer
|
||||||
<div class="tc-self-mark">
|
inputBlock = `
|
||||||
<span class="tc-self-mark-label">Развёрнутый ответ — проверьте себя по решению, затем отметьте:</span>
|
<div class="tc-input-row">
|
||||||
<div class="tc-self-mark-btns">
|
<label class="tc-ans-label">Ответ:</label>
|
||||||
<button class="tc-self-btn tc-self-yes" data-tc-self="1">${ICONS.check} Я решил</button>
|
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
||||||
<button class="tc-self-btn tc-self-no" data-tc-self="0">${ICONS.cross} Не решил</button>
|
placeholder="кратко ваш ответ" data-tc-text />
|
||||||
</div>
|
</div>` + verdictSlot;
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const solBlock = (showSol && task.solution) ? `
|
const solBlock = (showSol && task.solution) ? `
|
||||||
@@ -119,18 +132,57 @@
|
|||||||
let attemptCount = 0; // how many CHECK attempts made
|
let attemptCount = 0; // how many CHECK attempts made
|
||||||
let firstAttemptCorrect = null; // we report this in onAttempt
|
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 inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]');
|
||||||
const checkBtn = card.querySelector('[data-tc-check]');
|
const checkBtn = card.querySelector('[data-tc-check]');
|
||||||
|
let saveDebounce = null;
|
||||||
inputs.forEach(inp => {
|
inputs.forEach(inp => {
|
||||||
inp.addEventListener('input', () => updateCheckEnabled());
|
inp.addEventListener('input', () => { updateCheckEnabled(); maybeAutoSave(); });
|
||||||
inp.addEventListener('change', () => updateCheckEnabled());
|
inp.addEventListener('change', () => { updateCheckEnabled(); maybeAutoSave(); });
|
||||||
});
|
});
|
||||||
function updateCheckEnabled() {
|
function updateCheckEnabled() {
|
||||||
if (!checkBtn) return;
|
if (!checkBtn) return;
|
||||||
const has = readUserAnswer() !== null;
|
const has = readUserAnswer() !== null;
|
||||||
checkBtn.disabled = !has || card.dataset.tcLocked === '1';
|
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
|
||||||
|
? `<span class="tc-verdict-ok">${ICONS.check} Правильно</span>`
|
||||||
|
: `<span class="tc-verdict-bad">${ICONS.cross} Неправильно</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readUserAnswer() {
|
function readUserAnswer() {
|
||||||
const mcGroup = card.querySelector('[data-tc-mc]');
|
const mcGroup = card.querySelector('[data-tc-mc]');
|
||||||
@@ -255,6 +307,18 @@
|
|||||||
EP.api.saveAttempt(body).catch(() => {});
|
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 {
|
return {
|
||||||
el: card,
|
el: card,
|
||||||
destroy: () => card.remove(),
|
destroy: () => card.remove(),
|
||||||
|
|||||||
Reference in New Issue
Block a user