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;
|
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 ─────────────────────────────────────────────── */
|
/* ── Mobile tweaks ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.ep-wrap { padding: 20px 16px 60px; }
|
.ep-wrap { padding: 20px 16px 60px; }
|
||||||
@@ -326,7 +515,9 @@
|
|||||||
.ep-tab { padding: 9px 12px; font-size: .82rem; }
|
.ep-tab { padding: 9px 12px; font-size: .82rem; }
|
||||||
.ep-card { padding: 16px 18px; }
|
.ep-card { padding: 16px 18px; }
|
||||||
.ep-stat { padding: 14px 16px; }
|
.ep-stat { padding: 14px 16px; }
|
||||||
.vp-task-body { padding: 14px 18px; }
|
.vp-task-body, .tc-body { padding: 14px 18px; }
|
||||||
.vp-sol-wrap { padding: 0 18px 14px; }
|
.vp-sol-wrap, .tc-sol-wrap { padding: 0 18px 14px; }
|
||||||
.vp-sol-panel { padding: 14px 16px; }
|
.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/common.js"></script>
|
||||||
<script src="/js/exam-prep/api.js"></script>
|
<script src="/js/exam-prep/api.js"></script>
|
||||||
<script src="/js/exam-prep/katex.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>
|
<script src="/js/exam-prep/variants.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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';
|
'use strict';
|
||||||
/* ──────────────────────────────────────────────────────────────────
|
/* ──────────────────────────────────────────────────────────────────
|
||||||
Variants view — port of the old /exam9 browser onto API + DB.
|
Variants view — picks a variant, renders its tasks via TaskCard
|
||||||
Same UX as before: pick a variant from a grid overlay, then read
|
(interactive answer input + check + auto-logged attempts).
|
||||||
conditions + reveal solutions. Progress (which variants have all
|
|
||||||
solutions opened) is per-user via /api/exam-prep/attempts.
|
|
||||||
────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
await EP.boot();
|
await EP.boot();
|
||||||
const examKey = EP.examKey;
|
const examKey = EP.examKey;
|
||||||
|
|
||||||
// Optional ?v=N in URL: open that variant initially
|
|
||||||
const initialVariantFromQuery = (() => {
|
const initialVariantFromQuery = (() => {
|
||||||
const m = location.search.match(/[?&]v=(\d+)/);
|
const m = location.search.match(/[?&]v=(\d+)/);
|
||||||
return m ? Number(m[1]) : null;
|
return m ? Number(m[1]) : null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let variants = []; // [{ n, label, total, solved, viewed_sol }]
|
let variants = [];
|
||||||
let currentN = null;
|
let currentN = null;
|
||||||
let currentTasks = null; // cache: { [variantN]: tasks[] }
|
|
||||||
const tasksCache = new Map();
|
const tasksCache = new Map();
|
||||||
|
|
||||||
/* ── Load variants list ─────────────────────────────────────── */
|
/* ── Variants list ──────────────────────────────────────────── */
|
||||||
try {
|
try {
|
||||||
const r = await EP.api.listVariants(examKey);
|
const r = await EP.api.listVariants(examKey);
|
||||||
variants = r.variants || [];
|
variants = r.variants || [];
|
||||||
@@ -29,13 +25,9 @@
|
|||||||
showError(`Не удалось загрузить варианты: ${e.message || e}`);
|
showError(`Не удалось загрузить варианты: ${e.message || e}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!variants.length) { showError('Варианты не найдены'); return; }
|
||||||
|
|
||||||
if (!variants.length) {
|
/* ── DOM ────────────────────────────────────────────────────── */
|
||||||
showError('Варианты не найдены');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── DOM refs ───────────────────────────────────────────────── */
|
|
||||||
const main = document.getElementById('ep-main');
|
const main = document.getElementById('ep-main');
|
||||||
const pickerBtn = document.getElementById('vp-btn');
|
const pickerBtn = document.getElementById('vp-btn');
|
||||||
const pickerLabel = document.getElementById('vp-label');
|
const pickerLabel = document.getElementById('vp-label');
|
||||||
@@ -46,11 +38,13 @@
|
|||||||
function buildGrid() {
|
function buildGrid() {
|
||||||
pickerGrid.innerHTML = variants.map(v => {
|
pickerGrid.innerHTML = variants.map(v => {
|
||||||
let cls = '';
|
let cls = '';
|
||||||
if (v.total > 0 && v.viewed_sol === v.total) cls = ' done';
|
// 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';
|
else if (v.viewed_sol > 0) cls = ' partial';
|
||||||
const active = v.n === currentN ? ' active' : '';
|
const active = v.n === currentN ? ' active' : '';
|
||||||
const title = v.viewed_sol === v.total ? `${v.label} (все решения открыты)`
|
const title = `${v.label} · решено ${v.solved}/${v.total}` +
|
||||||
: `${v.label} (${v.viewed_sol}/${v.total} решений открыто)`;
|
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
|
||||||
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
|
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
|
||||||
@@ -72,8 +66,7 @@
|
|||||||
function onOverlayClick(e) { if (e.target === pickerOver) closePicker(); }
|
function onOverlayClick(e) { if (e.target === pickerOver) closePicker(); }
|
||||||
|
|
||||||
pickerBtn.onclick = () => {
|
pickerBtn.onclick = () => {
|
||||||
if (pickerOver.classList.contains('visible')) closePicker();
|
pickerOver.classList.contains('visible') ? closePicker() : openPicker();
|
||||||
else openPicker();
|
|
||||||
};
|
};
|
||||||
pickerOver.onclick = onOverlayClick;
|
pickerOver.onclick = onOverlayClick;
|
||||||
document.getElementById('vp-close').onclick = closePicker;
|
document.getElementById('vp-close').onclick = closePicker;
|
||||||
@@ -95,84 +88,37 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentTasks = tasksCache.get(n);
|
|
||||||
renderVariant(n, currentTasks);
|
renderVariant(n, tasksCache.get(n));
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVariant(n, tasks) {
|
function renderVariant(n, tasks) {
|
||||||
main.innerHTML =
|
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`;
|
||||||
`<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.querySelectorAll('.vp-sol-btn').forEach(btn => {
|
const variantMeta = variants.find(v => v.n === n);
|
||||||
btn.onclick = () => toggleSol(btn, n, Number(btn.dataset.i));
|
const solvedTracked = new Set(); // tasks already solved this session
|
||||||
});
|
const viewedTracked = new Set(); // tasks where solution opened this session
|
||||||
|
|
||||||
EP.katex.run(main);
|
tasks.forEach(task => {
|
||||||
}
|
EP.TaskCard.render(main, task, {
|
||||||
|
|
||||||
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',
|
mode: 'variant',
|
||||||
solution_viewed: 1,
|
autoCheck: true,
|
||||||
}).catch(() => {
|
showSolution: true,
|
||||||
// Silent: progress sync is best-effort
|
onAttempt: ({ taskId, isCorrect, solutionViewed }) => {
|
||||||
panel.dataset.logged = '';
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
@@ -188,7 +134,7 @@
|
|||||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Pick the initial variant ───────────────────────────────── */
|
/* ── Pick initial variant ───────────────────────────────────── */
|
||||||
let initial = variants[0].n;
|
let initial = variants[0].n;
|
||||||
if (initialVariantFromQuery && variants.some(v => v.n === initialVariantFromQuery)) {
|
if (initialVariantFromQuery && variants.some(v => v.n === initialVariantFromQuery)) {
|
||||||
initial = initialVariantFromQuery;
|
initial = initialVariantFromQuery;
|
||||||
|
|||||||
Reference in New Issue
Block a user