feat(exam-prep F3): интерактивный тренажёр — task-card + автопроверка ответа + retry + auto-open решения
This commit is contained in:
+194
-3
@@ -318,6 +318,195 @@
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 800; color: #06D6A0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TaskCard component (`tc-*`) — reusable across views (F3+)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.tc-card {
|
||||
background: var(--surface); border: 1.5px solid var(--border);
|
||||
border-radius: 14px; margin-bottom: 14px; overflow: hidden;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.tc-card:hover { border-color: var(--border-h); }
|
||||
.tc-card.tc-correct {
|
||||
border-color: #06D6A0;
|
||||
box-shadow: 0 0 0 1.5px rgba(6,214,160,.25);
|
||||
}
|
||||
.tc-card.tc-wrong {
|
||||
border-color: #E63946;
|
||||
box-shadow: 0 0 0 1.5px rgba(230,57,70,.22);
|
||||
}
|
||||
|
||||
.tc-head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 22px; background: rgba(155,93,229,.04);
|
||||
border-bottom: 1.5px solid var(--border);
|
||||
}
|
||||
.tc-num {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--violet); color: #fff;
|
||||
font-family: 'Unbounded', sans-serif; font-size: .82rem; font-weight: 800;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.tc-num-label {
|
||||
font-family: 'Unbounded', sans-serif; font-size: .82rem; font-weight: 700;
|
||||
color: var(--text-2); letter-spacing: .02em;
|
||||
}
|
||||
.tc-type-badge {
|
||||
margin-left: auto;
|
||||
font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
background: rgba(155,93,229,.10); color: var(--violet);
|
||||
}
|
||||
.tc-type-open { background: rgba(6,214,160,.12); color: #059669; }
|
||||
.tc-type-long { background: rgba(248,150,30,.12); color: #B45309; }
|
||||
|
||||
.tc-body { padding: 18px 24px; font-size: .98rem; line-height: 1.8; }
|
||||
.tc-text .katex-display { margin: 12px 0 6px; overflow-x: auto; }
|
||||
.tc-figure { margin: 14px 0 4px; }
|
||||
.tc-figure svg, .tc-figure img { max-width: 100%; height: auto; }
|
||||
|
||||
/* MC: radio options as clickable rows */
|
||||
.tc-opts {
|
||||
display: flex; flex-wrap: wrap; gap: 10px 32px;
|
||||
margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.tc-opts-vertical {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.tc-opt {
|
||||
display: inline-flex; align-items: flex-start; gap: 8px;
|
||||
padding: 6px 10px; border-radius: 8px;
|
||||
cursor: pointer; transition: background .12s;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tc-opt:hover { background: rgba(155,93,229,.06); }
|
||||
.tc-opt input[type="radio"] {
|
||||
accent-color: var(--violet);
|
||||
margin-top: 4px; flex-shrink: 0;
|
||||
}
|
||||
.tc-opt-lbl {
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 800;
|
||||
color: var(--violet); font-size: .9rem; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Open: short text answer */
|
||||
.tc-input-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tc-ans-label {
|
||||
font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.tc-ans-input {
|
||||
flex: 1; min-width: 140px; max-width: 260px;
|
||||
padding: 9px 14px;
|
||||
border: 1.5px solid var(--border-h);
|
||||
border-radius: 9px;
|
||||
background: #fff; color: var(--text);
|
||||
font-family: 'Manrope', sans-serif; font-size: .95rem; font-weight: 600;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.tc-ans-input:focus { outline: none; border-color: var(--violet); }
|
||||
.tc-ans-input::placeholder { color: var(--text-3); font-weight: 500; }
|
||||
.tc-card.tc-correct .tc-ans-input { border-color: #06D6A0; background: rgba(6,214,160,.08); }
|
||||
.tc-card.tc-wrong .tc-ans-input { border-color: #E63946; background: rgba(230,57,70,.06); }
|
||||
|
||||
/* Check button + verdict */
|
||||
.tc-action-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin-top: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.tc-check-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 22px;
|
||||
border: none; border-radius: 9px;
|
||||
background: var(--violet); color: #fff;
|
||||
font-family: 'Manrope', sans-serif; font-size: .88rem; font-weight: 700;
|
||||
cursor: pointer; transition: filter .12s, opacity .12s;
|
||||
}
|
||||
.tc-check-btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.tc-check-btn:disabled { opacity: .38; cursor: not-allowed; }
|
||||
|
||||
.tc-verdict {
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
font-family: 'Manrope', sans-serif; font-size: .9rem; font-weight: 700;
|
||||
}
|
||||
.tc-verdict-ok, .tc-verdict-bad {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.tc-verdict-ok { color: #06D6A0; }
|
||||
.tc-verdict-bad { color: #E63946; }
|
||||
.tc-verdict-ok svg, .tc-verdict-bad svg { width: 14px; height: 14px; }
|
||||
|
||||
.tc-retry-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 5px 12px;
|
||||
border: 1.5px solid var(--border-h); border-radius: 7px;
|
||||
background: transparent; color: var(--text-2);
|
||||
font-family: 'Manrope', sans-serif; font-size: .8rem; font-weight: 700;
|
||||
cursor: pointer; transition: all .12s;
|
||||
}
|
||||
.tc-retry-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.tc-retry-btn svg { width: 12px; height: 12px; }
|
||||
|
||||
/* Long: self-mark */
|
||||
.tc-self-mark {
|
||||
margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.tc-self-mark-label {
|
||||
display: block; font-size: .85rem; color: var(--text-2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tc-self-mark-btns { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tc-self-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: 1.5px solid var(--border-h); border-radius: 9px;
|
||||
background: transparent;
|
||||
font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.tc-self-btn:disabled { opacity: .42; cursor: not-allowed; }
|
||||
.tc-self-btn svg { width: 13px; height: 13px; }
|
||||
.tc-self-yes { color: #06D6A0; border-color: #06D6A0; }
|
||||
.tc-self-yes:hover:not(:disabled) { background: rgba(6,214,160,.10); }
|
||||
.tc-self-no { color: #E63946; border-color: #E63946; }
|
||||
.tc-self-no:hover:not(:disabled) { background: rgba(230,57,70,.10); }
|
||||
|
||||
/* Solution toggle within task card */
|
||||
.tc-sol-wrap { padding: 0 22px 16px; }
|
||||
.tc-sol-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 14px; border: 1.5px solid #06D6A0; border-radius: 8px;
|
||||
background: transparent; color: #06D6A0;
|
||||
font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.tc-sol-btn:hover { background: rgba(6,214,160,.12); }
|
||||
.tc-sol-btn.open { background: #06D6A0; border-color: #06D6A0; color: #fff; }
|
||||
.tc-sol-btn svg { width: 13px; height: 13px; transition: transform .2s; }
|
||||
.tc-sol-btn.open svg { transform: rotate(90deg); }
|
||||
|
||||
.tc-sol-panel {
|
||||
display: none; margin-top: 14px; padding: 16px 20px;
|
||||
background: rgba(6,214,160,.06); border-radius: 10px;
|
||||
border-left: 3px solid #06D6A0;
|
||||
line-height: 1.85; font-size: .94rem;
|
||||
}
|
||||
.tc-sol-panel.visible { display: block; }
|
||||
.tc-sol-panel .katex-display { margin: 10px 0 6px; overflow-x: auto; }
|
||||
.tc-sol-panel ul { margin: 6px 0 6px 22px; }
|
||||
.tc-sol-panel li { margin: 3px 0; }
|
||||
.tc-sol-panel .sol-ans {
|
||||
display: inline-block; margin-top: 12px; padding: 4px 14px;
|
||||
background: rgba(6,214,160,.2); border-radius: 6px;
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 800; color: #06D6A0;
|
||||
}
|
||||
|
||||
/* ── Mobile tweaks ─────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.ep-wrap { padding: 20px 16px 60px; }
|
||||
@@ -326,7 +515,9 @@
|
||||
.ep-tab { padding: 9px 12px; font-size: .82rem; }
|
||||
.ep-card { padding: 16px 18px; }
|
||||
.ep-stat { padding: 14px 16px; }
|
||||
.vp-task-body { padding: 14px 18px; }
|
||||
.vp-sol-wrap { padding: 0 18px 14px; }
|
||||
.vp-sol-panel { padding: 14px 16px; }
|
||||
.vp-task-body, .tc-body { padding: 14px 18px; }
|
||||
.vp-sol-wrap, .tc-sol-wrap { padding: 0 18px 14px; }
|
||||
.vp-sol-panel, .tc-sol-panel { padding: 14px 16px; }
|
||||
.tc-input-row { gap: 8px; }
|
||||
.tc-ans-input { max-width: 100%; }
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
<script src="/js/exam-prep/common.js"></script>
|
||||
<script src="/js/exam-prep/api.js"></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/variants.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Answer normalization + correctness check.
|
||||
Same algorithm runs on user input and stored canonical answer
|
||||
(from /tasks endpoint), then compared.
|
||||
|
||||
Canonical answer forms (produced by backend/scripts/import-exam-tasks.js):
|
||||
MC: single Cyrillic letter: "а" | "б" | "в" | "г" | "д"
|
||||
open NUM: decimal: "-2" "7500" "1.5" "0.25"
|
||||
open FRAC: fraction: "9/4" "-104/9" (sign on numerator)
|
||||
open PAIR: two values, ";" sep: "-2;4"
|
||||
The user can type the answer in many equivalent forms — the normalizer
|
||||
accepts any form and decides equivalence numerically.
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(function () {
|
||||
const EPS = 1e-6;
|
||||
|
||||
// Try to coerce a string to a number; returns NaN on failure.
|
||||
function toNumber(s) {
|
||||
if (s == null) return NaN;
|
||||
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
|
||||
// Fraction "a/b" → a/b
|
||||
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;
|
||||
}
|
||||
|
||||
// Parse a "pair" answer "A;B" — accepts ";" or "," or " и " or whitespace
|
||||
function toPair(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 = toNumber(parts[0]);
|
||||
const b = toNumber(parts[1]);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
||||
// Order-insensitive comparison: return sorted pair
|
||||
return a <= b ? [a, b] : [b, a];
|
||||
}
|
||||
|
||||
/* Detect form of the canonical answer.
|
||||
Returns: 'mc' | 'pair' | 'numeric' */
|
||||
function detectForm(canonical) {
|
||||
if (canonical == null) return 'numeric';
|
||||
const t = String(canonical).trim();
|
||||
if (/^[а-д]$/.test(t)) return 'mc';
|
||||
if (/^[^;]+;[^;]+$/.test(t)) return 'pair';
|
||||
return 'numeric';
|
||||
}
|
||||
|
||||
/* Main check. Returns true iff user input matches canonical answer.
|
||||
False if either side can't be normalized.
|
||||
|
||||
Both inputs are strings as returned by the form or stored in DB. */
|
||||
function check(userInput, canonical) {
|
||||
if (userInput == null || canonical == null) return false;
|
||||
|
||||
const form = detectForm(canonical);
|
||||
|
||||
if (form === 'mc') {
|
||||
const u = String(userInput).trim().toLowerCase();
|
||||
return u === String(canonical).trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (form === 'pair') {
|
||||
const cp = toPair(canonical);
|
||||
const up = toPair(userInput);
|
||||
if (!cp || !up) return false;
|
||||
return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS;
|
||||
}
|
||||
|
||||
// Numeric (integer / decimal / fraction)
|
||||
const c = toNumber(canonical);
|
||||
const u = toNumber(userInput);
|
||||
if (Number.isNaN(c) || Number.isNaN(u)) return false;
|
||||
return Math.abs(c - u) < EPS;
|
||||
}
|
||||
|
||||
window.EP = window.EP || {};
|
||||
window.EP.answer = { check, detectForm, toNumber, toPair };
|
||||
})();
|
||||
@@ -0,0 +1,272 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
TaskCard component — reusable across variants / practice / topic /
|
||||
mock views. Renders ONE task with answer input, check button, and
|
||||
solution toggle.
|
||||
|
||||
Public API:
|
||||
EP.TaskCard.render(container, task, opts)
|
||||
container : HTMLElement
|
||||
task : { id, idx, type, text, figure, opts, answer, solution }
|
||||
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)
|
||||
onAttempt : (result) => void (notify parent after check or solution-view)
|
||||
numbering : number | null (override task number badge)
|
||||
}
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(function () {
|
||||
const ICONS = {
|
||||
chev: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
|
||||
cross: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||
rotate: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/></svg>',
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||
}
|
||||
|
||||
function buildOptsBlock(taskId, opts) {
|
||||
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
|
||||
const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts';
|
||||
const name = `tc-opt-${taskId}`;
|
||||
return `<div class="${cls}" data-tc-mc>` + opts.map(([l, t]) => `
|
||||
<label class="tc-opt">
|
||||
<input type="radio" name="${name}" value="${escapeHtml(l)}" />
|
||||
<span class="tc-opt-lbl">${escapeHtml(l)})</span>
|
||||
<span class="tc-opt-text">${t}</span>
|
||||
</label>
|
||||
`).join('') + `</div>`;
|
||||
}
|
||||
|
||||
/* 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 showSol = opts.showSolution !== false;
|
||||
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
|
||||
const sessionId = opts.sessionId || null;
|
||||
const onAttempt = opts.onAttempt || (() => {});
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'tc-card';
|
||||
card.dataset.taskId = String(task.id);
|
||||
card.dataset.taskType = task.type;
|
||||
|
||||
// ── Inner skeleton
|
||||
let inputBlock = '';
|
||||
if (showAns) {
|
||||
if (task.type === 'mc' && task.opts) {
|
||||
inputBlock = `
|
||||
${buildOptsBlock(task.id, task.opts)}
|
||||
<div class="tc-action-row">
|
||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
||||
<div class="tc-verdict" data-tc-verdict hidden></div>
|
||||
</div>`;
|
||||
} else if (task.type === 'open') {
|
||||
inputBlock = `
|
||||
<div class="tc-input-row">
|
||||
<label class="tc-ans-label">Ответ:</label>
|
||||
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
|
||||
placeholder="например, 9/4 или -2" data-tc-text />
|
||||
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
|
||||
</div>
|
||||
<div class="tc-verdict" data-tc-verdict hidden></div>`;
|
||||
} else if (task.type === 'long') {
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
const solBlock = (showSol && task.solution) ? `
|
||||
<div class="tc-sol-wrap">
|
||||
<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>
|
||||
<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>
|
||||
</div>` : '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="tc-head">
|
||||
<div class="tc-num">${numbering}</div>
|
||||
<div class="tc-num-label">Задание ${numbering}</div>
|
||||
<span class="tc-type-badge tc-type-${task.type}">${typeLabel(task.type)}</span>
|
||||
</div>
|
||||
<div class="tc-body">
|
||||
<div class="tc-text">${task.text}</div>
|
||||
${task.figure ? `<div class="tc-figure">${task.figure}</div>` : ''}
|
||||
${inputBlock}
|
||||
</div>
|
||||
${solBlock}
|
||||
`;
|
||||
container.appendChild(card);
|
||||
|
||||
// ── KaTeX render
|
||||
EP.katex?.run(card);
|
||||
|
||||
// ── State
|
||||
let startedAt = Date.now();
|
||||
let solutionLogged = false;
|
||||
let solutionRendered = false;
|
||||
let attemptCount = 0; // how many CHECK attempts made
|
||||
let firstAttemptCorrect = null; // we report this in onAttempt
|
||||
|
||||
// ── Input enable on first interaction
|
||||
const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]');
|
||||
const checkBtn = card.querySelector('[data-tc-check]');
|
||||
inputs.forEach(inp => {
|
||||
inp.addEventListener('input', () => updateCheckEnabled());
|
||||
inp.addEventListener('change', () => updateCheckEnabled());
|
||||
});
|
||||
function updateCheckEnabled() {
|
||||
if (!checkBtn) return;
|
||||
const has = readUserAnswer() !== null;
|
||||
checkBtn.disabled = !has || card.dataset.tcLocked === '1';
|
||||
}
|
||||
|
||||
function readUserAnswer() {
|
||||
const mcGroup = card.querySelector('[data-tc-mc]');
|
||||
if (mcGroup) {
|
||||
const picked = mcGroup.querySelector('input[type="radio"]:checked');
|
||||
return picked ? picked.value : null;
|
||||
}
|
||||
const text = card.querySelector('[data-tc-text]');
|
||||
if (text) {
|
||||
const v = text.value.trim();
|
||||
return v || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Check action
|
||||
if (checkBtn) {
|
||||
checkBtn.addEventListener('click', () => {
|
||||
const userAnswer = readUserAnswer();
|
||||
if (userAnswer == null) return;
|
||||
const isCorrect = EP.answer.check(userAnswer, task.answer) ? 1 : 0;
|
||||
attemptCount++;
|
||||
if (firstAttemptCorrect === null) firstAttemptCorrect = isCorrect;
|
||||
applyVerdict(isCorrect, userAnswer);
|
||||
sendAttempt({ user_answer: userAnswer, is_correct: isCorrect });
|
||||
onAttempt({ taskId: task.id, isCorrect, userAnswer, attempt: attemptCount });
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerdict(isCorrect, userAnswer) {
|
||||
const verdict = card.querySelector('[data-tc-verdict]');
|
||||
card.classList.remove('tc-correct', 'tc-wrong');
|
||||
card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong');
|
||||
|
||||
if (verdict) {
|
||||
verdict.hidden = false;
|
||||
verdict.innerHTML = isCorrect
|
||||
? `<span class="tc-verdict-ok">${ICONS.check} Правильно</span>`
|
||||
: `<span class="tc-verdict-bad">${ICONS.cross} Неправильно</span>
|
||||
<button class="tc-retry-btn" data-tc-retry>${ICONS.rotate} Попробовать ещё</button>`;
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
// Lock inputs + auto-open solution
|
||||
card.querySelectorAll('input').forEach(el => el.disabled = true);
|
||||
checkBtn.disabled = true;
|
||||
card.dataset.tcLocked = '1';
|
||||
autoOpenSolution();
|
||||
} else {
|
||||
// Allow retry
|
||||
const retry = card.querySelector('[data-tc-retry]');
|
||||
if (retry) retry.addEventListener('click', () => resetForRetry());
|
||||
}
|
||||
}
|
||||
|
||||
function resetForRetry() {
|
||||
const verdict = card.querySelector('[data-tc-verdict]');
|
||||
card.classList.remove('tc-wrong');
|
||||
if (verdict) { verdict.hidden = true; verdict.innerHTML = ''; }
|
||||
startedAt = Date.now();
|
||||
}
|
||||
|
||||
function autoOpenSolution() {
|
||||
const btn = card.querySelector('[data-tc-sol]');
|
||||
const panel = card.querySelector('[data-tc-sol-panel]');
|
||||
if (btn && panel && !panel.classList.contains('visible')) {
|
||||
toggleSolution(btn, panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Solution toggle (manual)
|
||||
const solBtn = card.querySelector('[data-tc-sol]');
|
||||
const solPanel = card.querySelector('[data-tc-sol-panel]');
|
||||
if (solBtn && solPanel) {
|
||||
solBtn.addEventListener('click', () => toggleSolution(solBtn, solPanel));
|
||||
}
|
||||
|
||||
function toggleSolution(btn, panel) {
|
||||
const open = panel.classList.contains('visible');
|
||||
panel.classList.toggle('visible', !open);
|
||||
btn.classList.toggle('open', !open);
|
||||
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
|
||||
if (!open) {
|
||||
if (!solutionRendered) {
|
||||
EP.katex?.run(panel);
|
||||
solutionRendered = true;
|
||||
}
|
||||
if (!solutionLogged) {
|
||||
solutionLogged = true;
|
||||
sendAttempt({ solution_viewed: 1 });
|
||||
onAttempt({ taskId: task.id, solutionViewed: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Long: self-mark buttons
|
||||
card.querySelectorAll('[data-tc-self]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const val = btn.dataset.tcSelf === '1' ? 1 : 0;
|
||||
attemptCount++;
|
||||
if (firstAttemptCorrect === null) firstAttemptCorrect = val;
|
||||
card.classList.remove('tc-correct', 'tc-wrong');
|
||||
card.classList.add(val ? 'tc-correct' : 'tc-wrong');
|
||||
card.querySelectorAll('[data-tc-self]').forEach(b => b.disabled = true);
|
||||
sendAttempt({ is_correct: val });
|
||||
onAttempt({ taskId: task.id, isCorrect: val, attempt: attemptCount });
|
||||
});
|
||||
});
|
||||
|
||||
function sendAttempt(partial) {
|
||||
const body = Object.assign({
|
||||
exam_task_id: task.id,
|
||||
mode,
|
||||
session_id: sessionId,
|
||||
time_ms: Math.min(Date.now() - startedAt, 24 * 3600 * 1000),
|
||||
user_answer: null,
|
||||
is_correct: null,
|
||||
hint_used: 0,
|
||||
solution_viewed:0,
|
||||
}, partial);
|
||||
// Best-effort — UI doesn't block on this
|
||||
EP.api.saveAttempt(body).catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
el: card,
|
||||
destroy: () => card.remove(),
|
||||
};
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
if (type === 'mc') return 'выбор';
|
||||
if (type === 'open') return 'кр. ответ';
|
||||
return 'развёрнут.';
|
||||
}
|
||||
|
||||
window.EP = window.EP || {};
|
||||
window.EP.TaskCard = { render };
|
||||
})();
|
||||
@@ -1,27 +1,23 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Variants view — port of the old /exam9 browser onto API + DB.
|
||||
Same UX as before: pick a variant from a grid overlay, then read
|
||||
conditions + reveal solutions. Progress (which variants have all
|
||||
solutions opened) is per-user via /api/exam-prep/attempts.
|
||||
Variants view — picks a variant, renders its tasks via TaskCard
|
||||
(interactive answer input + check + auto-logged attempts).
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(async function () {
|
||||
await EP.boot();
|
||||
const examKey = EP.examKey;
|
||||
|
||||
// Optional ?v=N in URL: open that variant initially
|
||||
const initialVariantFromQuery = (() => {
|
||||
const m = location.search.match(/[?&]v=(\d+)/);
|
||||
return m ? Number(m[1]) : null;
|
||||
})();
|
||||
|
||||
let variants = []; // [{ n, label, total, solved, viewed_sol }]
|
||||
let currentN = null;
|
||||
let currentTasks = null; // cache: { [variantN]: tasks[] }
|
||||
let variants = [];
|
||||
let currentN = null;
|
||||
const tasksCache = new Map();
|
||||
|
||||
/* ── Load variants list ─────────────────────────────────────── */
|
||||
/* ── Variants list ──────────────────────────────────────────── */
|
||||
try {
|
||||
const r = await EP.api.listVariants(examKey);
|
||||
variants = r.variants || [];
|
||||
@@ -29,28 +25,26 @@
|
||||
showError(`Не удалось загрузить варианты: ${e.message || e}`);
|
||||
return;
|
||||
}
|
||||
if (!variants.length) { showError('Варианты не найдены'); return; }
|
||||
|
||||
if (!variants.length) {
|
||||
showError('Варианты не найдены');
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── DOM refs ───────────────────────────────────────────────── */
|
||||
const main = document.getElementById('ep-main');
|
||||
const pickerBtn = document.getElementById('vp-btn');
|
||||
const pickerLabel = document.getElementById('vp-label');
|
||||
const pickerOver = document.getElementById('vp-overlay');
|
||||
const pickerGrid = document.getElementById('vp-grid');
|
||||
/* ── DOM ────────────────────────────────────────────────────── */
|
||||
const main = document.getElementById('ep-main');
|
||||
const pickerBtn = document.getElementById('vp-btn');
|
||||
const pickerLabel = document.getElementById('vp-label');
|
||||
const pickerOver = document.getElementById('vp-overlay');
|
||||
const pickerGrid = document.getElementById('vp-grid');
|
||||
|
||||
/* ── Picker overlay ─────────────────────────────────────────── */
|
||||
function buildGrid() {
|
||||
pickerGrid.innerHTML = variants.map(v => {
|
||||
let cls = '';
|
||||
if (v.total > 0 && v.viewed_sol === v.total) cls = ' done';
|
||||
else if (v.viewed_sol > 0) cls = ' partial';
|
||||
// Prefer solved-based highlight; fall back to viewed-sol when nothing solved yet.
|
||||
if (v.total > 0 && v.solved === v.total) cls = ' done';
|
||||
else if (v.solved > 0) cls = ' partial';
|
||||
else if (v.viewed_sol > 0) cls = ' partial';
|
||||
const active = v.n === currentN ? ' active' : '';
|
||||
const title = v.viewed_sol === v.total ? `${v.label} (все решения открыты)`
|
||||
: `${v.label} (${v.viewed_sol}/${v.total} решений открыто)`;
|
||||
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
||||
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
|
||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
|
||||
}).join('');
|
||||
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
||||
@@ -72,8 +66,7 @@
|
||||
function onOverlayClick(e) { if (e.target === pickerOver) closePicker(); }
|
||||
|
||||
pickerBtn.onclick = () => {
|
||||
if (pickerOver.classList.contains('visible')) closePicker();
|
||||
else openPicker();
|
||||
pickerOver.classList.contains('visible') ? closePicker() : openPicker();
|
||||
};
|
||||
pickerOver.onclick = onOverlayClick;
|
||||
document.getElementById('vp-close').onclick = closePicker;
|
||||
@@ -95,84 +88,37 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
currentTasks = tasksCache.get(n);
|
||||
renderVariant(n, currentTasks);
|
||||
|
||||
renderVariant(n, tasksCache.get(n));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function renderVariant(n, tasks) {
|
||||
main.innerHTML =
|
||||
`<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>` +
|
||||
tasks.map((t, i) => `
|
||||
<div class="vp-task" data-task-id="${t.id}">
|
||||
<div class="vp-task-header">
|
||||
<div class="vp-task-num">${t.idx}</div>
|
||||
<div class="vp-task-label">Задание ${t.idx}</div>
|
||||
</div>
|
||||
<div class="vp-task-body">
|
||||
<div class="vp-task-text">${t.text}</div>
|
||||
${t.figure ? `<div class="vp-task-figure">${t.figure}</div>` : ''}
|
||||
${t.opts ? buildOpts(t.opts) : ''}
|
||||
</div>
|
||||
${t.solution ? `
|
||||
<div class="vp-sol-wrap">
|
||||
<button class="vp-sol-btn" data-i="${i}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<span>Показать решение</span>
|
||||
</button>
|
||||
<div class="vp-sol-panel">${t.solution}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
|
||||
|
||||
main.querySelectorAll('.vp-sol-btn').forEach(btn => {
|
||||
btn.onclick = () => toggleSol(btn, n, Number(btn.dataset.i));
|
||||
});
|
||||
const variantMeta = variants.find(v => v.n === n);
|
||||
const solvedTracked = new Set(); // tasks already solved this session
|
||||
const viewedTracked = new Set(); // tasks where solution opened this session
|
||||
|
||||
EP.katex.run(main);
|
||||
}
|
||||
|
||||
function buildOpts(opts) {
|
||||
const isLong = opts.some(([, txt]) => txt.length > 40 && !txt.startsWith('$'));
|
||||
const cls = isLong ? 'vp-opts vp-opts-vertical' : 'vp-opts';
|
||||
return `<div class="${cls}">` + opts.map(([l, t]) =>
|
||||
`<span class="vp-opt"><span class="vp-opt-lbl">${l})</span><span>${t}</span></span>`
|
||||
).join('') + '</div>';
|
||||
}
|
||||
|
||||
async function toggleSol(btn, n, i) {
|
||||
const panel = btn.nextElementSibling;
|
||||
const wasOpen = panel.classList.contains('visible');
|
||||
panel.classList.toggle('visible', !wasOpen);
|
||||
btn.classList.toggle('open', !wasOpen);
|
||||
btn.querySelector('span').textContent = wasOpen ? 'Показать решение' : 'Скрыть решение';
|
||||
|
||||
if (!wasOpen) {
|
||||
if (!panel.dataset.k) { EP.katex.run(panel); panel.dataset.k = '1'; }
|
||||
// Persist "solution viewed" exactly once per (user, task)
|
||||
if (!panel.dataset.logged) {
|
||||
panel.dataset.logged = '1';
|
||||
const taskId = currentTasks[i]?.id;
|
||||
if (taskId) {
|
||||
EP.api.saveAttempt({
|
||||
exam_task_id: taskId,
|
||||
user_answer: null,
|
||||
is_correct: null,
|
||||
mode: 'variant',
|
||||
solution_viewed: 1,
|
||||
}).catch(() => {
|
||||
// Silent: progress sync is best-effort
|
||||
panel.dataset.logged = '';
|
||||
});
|
||||
// Local optimistic update of picker grid
|
||||
const v = variants.find(v => v.n === n);
|
||||
if (v && panel.dataset.firstView !== '1') {
|
||||
panel.dataset.firstView = '1';
|
||||
v.viewed_sol = Math.min(v.viewed_sol + 1, v.total);
|
||||
tasks.forEach(task => {
|
||||
EP.TaskCard.render(main, task, {
|
||||
mode: 'variant',
|
||||
autoCheck: true,
|
||||
showSolution: true,
|
||||
onAttempt: ({ taskId, isCorrect, solutionViewed }) => {
|
||||
// Optimistic update of picker counters (best-effort; backend is source of truth)
|
||||
if (!variantMeta) return;
|
||||
if (isCorrect === 1 && !solvedTracked.has(taskId)) {
|
||||
solvedTracked.add(taskId);
|
||||
variantMeta.solved = Math.min(variantMeta.solved + 1, variantMeta.total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (solutionViewed && !viewedTracked.has(taskId)) {
|
||||
viewedTracked.add(taskId);
|
||||
variantMeta.viewed_sol = Math.min(variantMeta.viewed_sol + 1, variantMeta.total);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
@@ -188,7 +134,7 @@
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||
}
|
||||
|
||||
/* ── Pick the initial variant ───────────────────────────────── */
|
||||
/* ── Pick initial variant ───────────────────────────────────── */
|
||||
let initial = variants[0].n;
|
||||
if (initialVariantFromQuery && variants.some(v => v.n === initialVariantFromQuery)) {
|
||||
initial = initialVariantFromQuery;
|
||||
|
||||
Reference in New Issue
Block a user