Files
Learn_System/frontend/trainer.html
T
Maxim Dolgolyov d003a0e100 feat(trainer): P6 — учительская аналитика класса + общий прогресс
- GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ
- клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats
- лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress
- тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:24:05 +03:00

687 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Тренажёр — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"/>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ════════════════════ Страница «Тренажёр» (прототип) ════════════════════ */
.tr-wrap { max-width: 760px; margin: 0 auto; padding: 26px 20px 80px; }
.tr-head { margin-bottom: 18px; }
.tr-h1 { font-family: 'Manrope', sans-serif; font-weight: 800; font-size: 1.5rem; color: var(--text, #1e293b); margin: 0 0 4px; }
.tr-sub { color: var(--muted, #64748b); font-size: .92rem; }
.tr-pill {
display: inline-block; font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em;
padding: 3px 10px; border-radius: 99px; background: rgba(99,102,241,0.12); color: #6366f1; margin-left: 8px; vertical-align: middle;
}
/* ── выбор темы ── */
.tr-topics { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 20px; }
.tr-chip {
font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer;
padding: 8px 14px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32);
background: #fff; color: #475569; transition: .15s;
}
.tr-chip:hover { border-color: #818cf8; color: #4f46e5; }
.tr-chip.on { background: #6366f1; border-color: #6366f1; color: #fff; box-shadow: 0 6px 16px rgba(99,102,241,0.28); }
/* ── карточка задачи ── */
.tr-card {
background: #fff; border: 1px solid rgba(148,163,184,0.22); border-radius: 18px;
padding: 28px 26px; box-shadow: 0 14px 40px rgba(15,23,42,0.06);
}
.tr-skill { color: #64748b; font-size: .82rem; font-weight: 600; margin-bottom: 14px; }
.tr-eq {
font-family: 'Cambria Math', 'Times New Roman', Georgia, serif;
font-size: clamp(1.7rem, 5vw, 2.4rem); font-weight: 600; letter-spacing: .01em;
color: #0f172a; text-align: center; padding: 12px 0 22px; user-select: none;
}
.tr-inrow { display: flex; gap: 10px; align-items: stretch; max-width: 420px; margin: 0 auto; }
.tr-eqx { font-family: 'Cambria Math', serif; font-size: 1.4rem; color: #475569; align-self: center; }
.tr-input {
flex: 1; min-width: 0; font: inherit; font-size: 1.15rem; text-align: center;
padding: 11px 14px; border-radius: 12px; border: 2px solid rgba(148,163,184,0.4); outline: none; transition: .15s;
}
.tr-input:focus { border-color: #818cf8; box-shadow: 0 0 0 4px rgba(129,140,248,0.18); }
.tr-input:disabled { background: #f1f5f9; color: #64748b; }
.tr-btn {
font: inherit; font-weight: 700; cursor: pointer; border: none; border-radius: 12px;
padding: 11px 20px; transition: .15s; display: inline-flex; align-items: center; gap: 7px;
}
.tr-btn .ic { width: 17px; height: 17px; }
.tr-primary { background: #6366f1; color: #fff; box-shadow: 0 6px 16px rgba(99,102,241,0.3); }
.tr-primary:hover { background: #4f46e5; }
.tr-ghost { background: rgba(148,163,184,0.14); color: #475569; }
.tr-ghost:hover { background: rgba(148,163,184,0.24); }
.tr-feedback { text-align: center; min-height: 26px; margin: 18px 0 4px; font-weight: 700; font-size: 1rem; display: flex; align-items: center; justify-content: center; gap: 8px; }
.tr-feedback .ic { width: 19px; height: 19px; }
.tr-feedback.ok { color: #16a34a; }
.tr-feedback.bad { color: #dc2626; }
.tr-feedback.warn { color: #d97706; font-weight: 600; }
.tr-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 8px; }
.tr-solution {
margin-top: 20px; padding: 16px 18px; border-radius: 12px;
background: #f8fafc; border: 1px solid rgba(148,163,184,0.22);
}
.tr-solution h4 { margin: 0 0 10px; font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; color: #64748b; }
.tr-step { color: #334155; padding: 11px 0; }
.tr-step + .tr-step { border-top: 1px dashed rgba(148,163,184,0.28); }
.tr-step-note { display: block; color: #475569; font-family: 'Manrope', sans-serif; font-size: .92rem; line-height: 1.55; margin-bottom: 6px; }
.tr-step-math { display: block; font-family: 'Cambria Math', serif; font-size: 1.15rem; margin-left: 28px; }
.tr-step-n { display: inline-flex; align-items: center; justify-content: center; width: 21px; height: 21px; border-radius: 50%; background: #e0e7ff; color: #4f46e5; font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 800; margin-right: 8px; vertical-align: 1px; }
.tr-eq .katex-display { margin: 0; }
/* текстовый prompt (проценты) — компактнее уравнения */
.tr-eq.tr-eq-text { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: clamp(1.1rem, 3.4vw, 1.55rem); line-height: 1.4; color: #1e293b; }
/* выбор навыка внутри темы */
.tr-skills { display: flex; flex-wrap: wrap; gap: 7px; margin: -8px 0 22px; }
.tr-skill {
font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; font-family: 'Cambria Math', 'Times New Roman', serif;
padding: 6px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.3); background: #fff; color: #475569; transition: .15s;
display: inline-flex; align-items: center;
}
.tr-skill:hover { border-color: #818cf8; color: #4338ca; }
.tr-skill.on { background: #eef2ff; border-color: #818cf8; color: #4338ca; }
.tr-pool-info { font-size: .82rem; color: #64748b; align-self: center; margin-right: 4px; }
#tr-gen-btn { border-style: dashed; color: #4f46e5; }
/* бейджи прогресса на чипах */
.tr-badge { display: inline-flex; margin-left: 7px; color: #16a34a; vertical-align: middle; }
.tr-badge .ic { width: 14px; height: 14px; }
.tr-chip.on .tr-badge { color: #fff; }
.tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: #94a3b8; background: rgba(148,163,184,0.16); border-radius: 99px; padding: 1px 7px; }
.tr-chip.on .tr-badge-n { color: #e0e7ff; background: rgba(255,255,255,0.2); }
/* ── общий прогресс (лёгкая геймификация) ── */
.tr-overall { color: #6366f1; font-size: .84rem; font-weight: 600; margin: -2px 0 14px; }
.tr-overall:empty { display: none; }
/* ── модалка аналитики + тепловая карта ── */
.tr-modal { position: fixed; inset: 0; z-index: 50; background: rgba(15,23,42,0.5); display: flex; align-items: center; justify-content: center; padding: 20px; }
.tr-modal-card { background: #fff; border-radius: 16px; max-width: 920px; width: 100%; max-height: 86vh; overflow: auto; box-shadow: 0 24px 60px rgba(0,0,0,0.3); }
.tr-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid rgba(148,163,184,0.2); font-weight: 800; font-family: 'Manrope', sans-serif; font-size: 1.05rem; position: sticky; top: 0; background: #fff; }
.tr-modal-x { background: none; border: none; cursor: pointer; color: #64748b; padding: 4px; border-radius: 8px; }
.tr-modal-x:hover { background: rgba(148,163,184,0.15); color: #1e293b; }
.tr-modal-x .ic { width: 18px; height: 18px; }
#tr-an-body { padding: 18px 20px; }
.tr-an-picker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tr-an-cls { font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; padding: 7px 13px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32); background: #fff; color: #475569; }
.tr-an-cls:hover, .tr-an-cls.on { border-color: #818cf8; color: #4338ca; background: #eef2ff; }
.tr-an-empty { color: #94a3b8; padding: 20px; text-align: center; }
.tr-hm-wrap { overflow-x: auto; }
table.tr-hm { border-collapse: collapse; font-size: .8rem; }
table.tr-hm th, table.tr-hm td { border: 1px solid rgba(148,163,184,0.22); padding: 6px 8px; text-align: center; white-space: nowrap; }
table.tr-hm th { background: #f8fafc; color: #475569; font-weight: 700; position: sticky; top: 0; }
.tr-hm-name { text-align: left !important; font-weight: 600; color: #334155; background: #f8fafc; position: sticky; left: 0; }
.tr-hm-none { color: #cbd5e1; }
.tr-hm-cell { font-weight: 700; color: #334155; }
.tr-hm-cell .ic { width: 14px; height: 14px; color: #fff; }
.tr-hm-sum { font-weight: 800; color: #4f46e5; background: #eef2ff; }
/* ── режим (умная тренировка) ── */
.tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.tr-mode-btn {
font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32); background: #fff; color: #475569; transition: .15s;
}
.tr-mode-btn .ic { width: 16px; height: 16px; }
.tr-mode-btn:hover { border-color: #818cf8; color: #4f46e5; }
.tr-mode-btn.on { background: #6366f1; border-color: #6366f1; color: #fff; box-shadow: 0 6px 16px rgba(99,102,241,0.28); }
.tr-session { font-size: .85rem; font-weight: 700; color: #6366f1; }
/* ── итог сессии ── */
.tr-summary {
background: #fff; border: 1px solid rgba(148,163,184,0.22); border-radius: 18px;
padding: 26px; box-shadow: 0 14px 40px rgba(15,23,42,0.06); text-align: center;
}
.tr-sum-h { margin: 0 0 16px; font-family: 'Manrope', sans-serif; font-weight: 800; font-size: 1.3rem; color: #1e293b; }
.tr-sum-row { display: inline-flex; flex-direction: column; align-items: center; margin: 0 16px 10px; }
.tr-sum-row b { font-size: 1.7rem; font-weight: 800; color: #4f46e5; font-family: 'Manrope', sans-serif; line-height: 1.1; }
.tr-sum-row span { font-size: .74rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .04em; }
.tr-sum-weak { margin: 8px 0 20px; color: #d97706; font-weight: 600; font-size: .92rem; }
.tr-sum-weak.tr-sum-good { color: #16a34a; }
/* ── статистика ── */
.tr-stats { display: flex; gap: 20px; justify-content: center; margin: 22px 0 4px; }
.tr-stat { text-align: center; }
.tr-stat b { display: block; font-size: 1.5rem; font-weight: 800; color: #4f46e5; font-family: 'Manrope', sans-serif; line-height: 1.1; }
.tr-stat span { font-size: .74rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .04em; }
.tr-note { margin-top: 24px; text-align: center; color: #94a3b8; font-size: .78rem; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="tr-wrap">
<div class="tr-head">
<h1 class="tr-h1">Тренажёр<span class="tr-pill" id="tr-subject">Алгебра · 78 класс</span></h1>
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
</div>
<div class="tr-overall" id="tr-overall"></div>
<div class="tr-mode">
<button class="tr-mode-btn on" id="tr-smart-btn" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>
Умная тренировка
</button>
<span class="tr-session" id="tr-session"></span>
<button class="tr-mode-btn" id="tr-analytics-btn" type="button" style="display:none;margin-left:auto">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><rect x="7" y="10" width="3" height="7"/><rect x="13" y="6" width="3" height="11"/></svg>
Аналитика класса
</button>
</div>
<div class="tr-modal" id="tr-analytics" style="display:none">
<div class="tr-modal-card">
<div class="tr-modal-head">
<span>Аналитика класса</span>
<button class="tr-modal-x" id="tr-an-close" type="button" aria-label="Закрыть">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="tr-an-body"></div>
</div>
</div>
<div class="tr-topics" id="tr-topics"></div>
<div class="tr-skills" id="tr-skills"></div>
<div class="tr-card">
<div class="tr-skill" id="tr-skill"></div>
<div class="tr-eq" id="tr-eq"></div>
<div class="tr-inrow">
<span class="tr-eqx" id="tr-eqx">x =</span>
<input class="tr-input" id="tr-input" type="text" inputmode="text" autocomplete="off"
placeholder="ответ" aria-label="Ваш ответ"/>
<button class="tr-btn tr-primary" id="tr-check" type="button">Проверить</button>
</div>
<div class="tr-feedback" id="tr-feedback"></div>
<div class="tr-actions">
<button class="tr-btn tr-ghost" id="tr-hint" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1h6c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2Z"/></svg>
Подсказка
</button>
<button class="tr-btn tr-ghost" id="tr-solve" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Решение
</button>
<button class="tr-btn tr-ghost" id="tr-skip" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 12 8-12 8z"/><path d="M20 4v16"/></svg>
Другая
</button>
</div>
<div class="tr-solution" id="tr-solution" style="display:none"></div>
</div>
<div class="tr-summary" id="tr-summary" style="display:none"></div>
<div class="tr-stats">
<div class="tr-stat"><b id="tr-solved">0</b><span>решено</span></div>
<div class="tr-stat"><b id="tr-streak">0</b><span>серия</span></div>
</div>
<div class="tr-note" id="tr-note"></div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- безопасный вычислитель + ядро тренажёра (тот же путь, что lab/sim-builder) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/trainer/_trainer_engine.js"></script>
<script src="/js/trainer/generators.js"></script>
<script src="/js/trainer/adaptive.js"></script>
<!-- KaTeX для рендера уравнений и шагов решения -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script>
(function () {
'use strict';
// initPage редиректит на /login, если не авторизован
if (typeof LS === 'undefined') return;
var ip = LS.initPage();
if (!ip) return;
// Фича-гейт: «Тренажёр» можно отключить в админке (feature_trainer_enabled).
// Сайдбар/доступ скрываются через FEATURE_HREFS; здесь — редирект при прямом заходе.
// Админ имеет доступ всегда — он управляет модулями.
if (LS.loadFeatures && !ip.isAdmin) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.trainer === false) { if (LS.toast) LS.toast('Тренажёр отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
}
var TE = window.TrainerEngine, TG = window.TrainerGenerators, TA = window.TrainerAdaptive;
var gens = TG.list();
var ordered = gens; // прогрессия = порядок объявления (темы по order, навыки по order)
var ICON = {
ok: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
bad: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
star: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
};
function $(id) { return document.getElementById(id); }
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function randSeed() { return (Math.random() * 2147483647) | 0; }
function fmt(v) { return TE.prettyMath(String(v)); }
// KaTeX-рендер выражения → html-строка или null (мягкий фолбэк на текст)
function kat(latex, display) {
if (window.katex && latex) {
try { return window.katex.renderToString(latex, { displayMode: !!display, throwOnError: false }); }
catch (e) {}
}
return null;
}
function setMath(el, latex, fallbackText, display) {
var h = kat(latex, display);
if (h) el.innerHTML = h; else el.textContent = fallbackText;
}
var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
var isTeacher = !!(ip && ip.isTeacher);
function skillKey(g) { return g.skill || g.id; }
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
function isWord() { return curTopic === 'word'; }
function currentSkill() { return (cur && cur.kind === 'word') ? (cur.skill || 'word-linear') : skillKey(curGen); }
// ── пул текстовых задач (Уровень 1, LLM + серверная проверка) ──
var wordPool = [], wordIdx = 0, wordLoading = false;
function toWordProblem(p) {
return {
kind: 'word', skill: p.skill || 'word-linear', title: 'Текстовая задача',
display: p.story, latex: null,
lhsExpr: p.lhsExpr, rhsExpr: p.rhsExpr, answerVar: p.answerVar || 'x', answer: p.answer,
solution: (p.solution || []).map(function (st) {
var tex = st.tex || '';
return { note: st.note || '', tex: tex ? TE.prettyMath(tex) : '', latex: tex ? TE.exprToLatex(tex) : null };
})
};
}
function loadWordPool(done) {
if (!LS.practicePool) { wordPool = []; if (done) done(); return; }
wordLoading = true; renderSkills();
LS.practicePool('word-linear').then(function (r) {
wordPool = ((r && r.problems) || []).map(toWordProblem); wordIdx = 0;
}).catch(function () { wordPool = []; }).then(function () {
wordLoading = false; renderSkills(); if (done) done();
});
}
function serveWordProblem() {
var eq = $('tr-eq'); eq.classList.add('tr-eq-text');
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
if (!wordPool.length) {
cur = null;
$('tr-skill').textContent = 'Текстовые задачи';
eq.textContent = wordLoading ? 'Загрузка…' : (isTeacher ? 'Банк пуст. Нажмите «Сгенерировать задачу».' : 'Здесь появятся текстовые задачи.');
$('tr-input').disabled = true; setMode(false);
return;
}
cur = wordPool[wordIdx % wordPool.length]; wordIdx++;
$('tr-skill').textContent = cur.title;
setMath(eq, null, cur.display, true); // условие как текст
applyInputMode();
var inp = $('tr-input'); inp.value = ''; inp.disabled = false;
setMode(false); inp.focus();
}
function genWordProblem() {
var gb = $('tr-gen-btn'); if (gb) { gb.disabled = true; gb.textContent = 'Генерирую…'; }
LS.practiceGenerate('word-linear').then(function (r) {
if (r && r.ok && r.problem) {
wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0;
if (LS.toast) LS.toast('Задача добавлена (проверена за ' + r.attempts + ' попыт.)', 'success');
serveWordProblem();
}
renderSkills();
}).catch(function () {
if (LS.toast) LS.toast('Не удалось сгенерировать (LLM-провайдер не настроен?)', 'error');
renderSkills();
});
}
var curTopic = topics[0] ? topics[0].key : null;
var curGen = skillsOf(curTopic)[0] || gens[0];
var cur = null;
var solved = 0, streak = 0;
var answered = false; // задача решена (верно/неверно/показано решение) → «Проверить» становится «Дальше»
var prog = {}; // skill → строка прогресса с сервера
// адаптивная сессия
var smart = true, GOAL = 10;
var sessAnswered = 0, sessEvents = [], reviewQ = [], summaryShown = false;
function topicMastered(topicKey) {
var ss = skillsOf(topicKey);
return ss.length > 0 && ss.every(function (g) { var p = prog[skillKey(g)]; return p && p.mastered; });
}
function skillBadge(g) {
var p = prog[skillKey(g)];
if (p && p.mastered) return '<span class="tr-badge" title="Освоено">' + ICON.star + '</span>';
if (p && p.solved) return '<span class="tr-badge-n">' + p.solved + '</span>';
return '';
}
function renderTopics() {
$('tr-topics').innerHTML = topics.map(function (t, i) {
var done = topicMastered(t.key) ? '<span class="tr-badge" title="Тема освоена">' + ICON.star + '</span>' : '';
return '<button class="tr-chip' + (t.key === curTopic ? ' on' : '') + '" type="button" data-ti="' + i + '">' + esc(t.label) + done + '</button>';
}).join('');
}
function renderSkills() {
if (isWord()) {
var btn = isTeacher ? '<button class="tr-skill" id="tr-gen-btn" type="button">+ Сгенерировать задачу</button>' : '';
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + btn;
var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem);
return;
}
var ss = skillsOf(curTopic);
$('tr-skills').innerHTML = ss.map(function (g, i) {
return '<button class="tr-skill' + (g === curGen ? ' on' : '') + '" type="button" data-si="' + i + '">' + esc(g.title) + skillBadge(g) + '</button>';
}).join('');
}
function setMode(done) {
answered = done;
$('tr-check').textContent = done ? 'Дальше' : 'Проверить';
}
// Префикс «x =» и подсказка ввода зависят от типа задачи.
function applyInputMode() {
var k = cur && cur.kind;
var multi = (k === 'roots' || k === 'simplify');
var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : '';
$('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ';
}
// Текст ответа в фидбеке/раскрытии — по типу задачи.
function answerLabel() {
if (cur.kind === 'roots' && cur.answers) return 'Корни: ' + cur.answers.map(fmt).join('; ');
if (cur.kind === 'simplify') return '= ' + (cur.answerExpr ? fmt(cur.answerExpr) : '');
return 'x = ' + fmt(cur.answer);
}
function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; }
function stepHtml(st, n) {
if (!st) return '';
var num = '<span class="tr-step-n">' + n + '</span>';
var note = st.note ? '<span class="tr-step-note">' + num + esc(st.note) + '</span>'
: '<span class="tr-step-note">' + num + '</span>';
var math = '';
if (st.latex) { var h = kat(st.latex, false); math = '<span class="tr-step-math">' + (h || esc(st.tex || '')) + '</span>'; }
else if (st.tex) { math = '<span class="tr-step-math">' + esc(st.tex) + '</span>'; }
return '<div class="tr-step">' + note + math + '</div>';
}
function newProblem() {
if (isWord()) { serveWordProblem(); return; }
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
cur = null;
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
if (!cur) { $('tr-eq').textContent = 'Не удалось сгенерировать задачу'; return; }
$('tr-skill').textContent = curGen.title;
var eq = $('tr-eq');
eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты/упрощение) — другим шрифтом
setMath(eq, cur.latex, cur.display, true);
applyInputMode();
var inp = $('tr-input');
inp.value = ''; inp.disabled = false;
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
setMode(false);
inp.focus();
}
// фоновая отправка попытки на сервер (прогресс/мастерство)
function submitAttempt(correct) {
if (!LS.practiceSubmit) return;
LS.practiceSubmit(currentSkill(), correct).then(function (r) {
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); updateOverall(); }
}).catch(function () {});
}
function solutionHtml(title) {
var steps = (cur.solution || []).map(function (st, i) { return stepHtml(st, i + 1); }).join('');
return '<h4>' + title + '</h4>' + (steps || '<div class="tr-step"><span class="tr-step-math">x = ' + esc(fmt(cur.answer)) + '</span></div>');
}
function revealSolution() {
var s = $('tr-solution');
s.innerHTML = solutionHtml('Решение');
s.style.display = 'block';
}
// ── адаптивная сессия ──
function updateSession() {
$('tr-session').textContent = smart ? ('Сессия: ' + Math.min(sessAnswered, GOAL) + ' / ' + GOAL) : '';
}
function pickNext(lastSkill) {
if (!TA) return;
var last = (lastSkill !== undefined) ? lastSkill : (curGen ? skillKey(curGen) : null);
var id = TA.nextSkill({ ordered: ordered, progress: prog, queue: reviewQ, answered: sessAnswered, last: last });
var g = id ? gens.filter(function (x) { return skillKey(x) === id; })[0] : null;
if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); }
}
function recordAnswer(correct) {
var sk = currentSkill();
sessEvents.push({ skill: sk, correct: correct });
sessAnswered++;
if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered);
updateSession();
}
function advance() {
if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; }
if (isWord()) { serveWordProblem(); return; } // банк — без адаптивного подбора
if (smart) pickNext();
newProblem();
}
function showSummary() {
summaryShown = true;
var st = TA ? TA.sessionStats(sessEvents) : { total: sessAnswered, correct: solved, accuracy: 0, skills: [], weak: [] };
var weak = st.weak.map(function (s) { var g = gens.filter(function (x) { return skillKey(x) === s; })[0]; return g ? g.title : s; });
$('tr-summary').innerHTML =
'<h3 class="tr-sum-h">Итог сессии</h3>' +
'<div class="tr-sum-row"><b>' + st.correct + ' / ' + st.total + '</b><span>верно</span></div>' +
'<div class="tr-sum-row"><b>' + st.accuracy + '%</b><span>точность</span></div>' +
'<div class="tr-sum-row"><b>' + st.skills.length + '</b><span>навыков</span></div>' +
(weak.length ? '<div class="tr-sum-weak">Стоит повторить: ' + esc(weak.join(', ')) + '</div>'
: '<div class="tr-sum-weak tr-sum-good">Отличная сессия — без ошибок!</div>') +
'<button class="tr-btn tr-primary" id="tr-sum-go" type="button">Продолжить</button>';
$('tr-card').style.display = 'none';
$('tr-summary').style.display = 'block';
$('tr-sum-go').addEventListener('click', function () {
$('tr-summary').style.display = 'none';
$('tr-card').style.display = '';
sessAnswered = 0; sessEvents = []; summaryShown = false;
updateSession();
if (smart) pickNext();
newProblem();
});
}
// «Решение» до ответа = сдаться (засчитывается как неверно один раз)
function revealAnswer(giveUp) {
revealSolution();
if (giveUp && !answered) {
streak = 0;
$('tr-input').disabled = true;
var fb = $('tr-feedback'); fb.className = 'tr-feedback';
if (cur.kind === 'roots' || cur.kind === 'simplify') fb.textContent = 'Ответ: ' + answerLabel();
else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
setMode(true);
recordAnswer(false); submitAttempt(false);
updateStats();
}
}
function check() {
if (answered) { advance(); return; }
var r = TE.checkStudentAnswer(cur, $('tr-input').value);
var fb = $('tr-feedback');
if (r.reason === 'empty' || r.reason === 'parse' || r.reason === 'nan') {
fb.className = 'tr-feedback warn'; fb.textContent = r.message; return; // не решено, можно поправить ввод
}
$('tr-input').disabled = true;
setMode(true);
if (r.ok) {
solved++; streak++;
fb.className = 'tr-feedback ok';
var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel())
: (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
fb.innerHTML = ICON.ok + ' <span>Верно!</span>&nbsp;' + lbl;
recordAnswer(true); submitAttempt(true);
} else {
streak = 0;
fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Неверно. Разбери решение и реши похожую.';
recordAnswer(false); submitAttempt(false);
revealSolution();
}
updateStats();
}
// ── общий прогресс (лёгкая геймификация) ──
function updateOverall() {
var solvedTotal = 0, mastered = 0;
for (var k in prog) {
if (k === '__ms' || !Object.prototype.hasOwnProperty.call(prog, k)) continue;
var p = prog[k]; if (!p) continue;
solvedTotal += (p.solved || 0);
if (p.mastered) mastered++;
}
var el = $('tr-overall');
if (el) el.textContent = solvedTotal ? ('Освоено навыков: ' + mastered + ' из ' + gens.length + ' · решено всего: ' + solvedTotal) : '';
}
// ── учительская аналитика класса ──
var _anClasses = [], _anCur = null;
function skillTitle(id) { var g = TG.get ? TG.get(id) : null; return g ? g.title : id; }
function anPicker() {
if (_anClasses.length <= 1) return '';
return '<div class="tr-an-picker">' + _anClasses.map(function (c) {
return '<button class="tr-an-cls' + (c.id === _anCur ? ' on' : '') + '" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
}).join('') + '</div>';
}
function renderHeatmap(data) {
if (!data.skills || !data.skills.length) return '<div class="tr-an-empty">Пока нет данных — ученики ещё не решали задачи.</div>';
var head = '<tr><th>Ученик</th>' + data.skills.map(function (s) { return '<th title="' + esc(s) + '">' + esc(skillTitle(s)) + '</th>'; }).join('') + '</tr>';
var rows = data.students.map(function (st) {
var cells = data.skills.map(function (s) {
var c = st.perSkill[s];
if (!c) return '<td class="tr-hm-none">—</td>';
if (c.mastered) return '<td class="tr-hm-cell" style="background:#16a34a" title="освоено">' + ICON.star + '</td>';
var bg = c.accuracy >= 70 ? '#bbf7d0' : c.accuracy >= 40 ? '#fef9c3' : '#fecaca';
return '<td class="tr-hm-cell" style="background:' + bg + '" title="' + c.solved + ' из ' + c.attempts + '">' + c.accuracy + '%</td>';
}).join('');
return '<tr><td class="tr-hm-name">' + esc(st.name) + '</td>' + cells + '</tr>';
}).join('');
var sumCells = data.skills.map(function (s) {
var ps = data.perSkill.filter(function (x) { return x.skill === s; })[0];
return '<td class="tr-hm-sum">' + (ps ? ps.accuracy + '%' : '') + '</td>';
}).join('');
return '<div class="tr-hm-wrap"><table class="tr-hm">' + head + rows + '<tr><td class="tr-hm-name">Класс</td>' + sumCells + '</tr></table></div>';
}
function showStats(classId) {
_anCur = classId;
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Загрузка…</div>';
LS.practiceClassStats(classId).then(function (data) {
$('tr-an-body').innerHTML = anPicker() + renderHeatmap(data);
}).catch(function () {
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Не удалось загрузить аналитику.</div>';
});
}
function openAnalytics() {
$('tr-analytics').style.display = 'flex';
$('tr-an-body').innerHTML = '<div class="tr-an-empty">Загрузка…</div>';
(LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) {
var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || [];
_anClasses = list;
if (!list.length) { $('tr-an-body').innerHTML = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
showStats(list[0].id);
}).catch(function () { $('tr-an-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
}
// ── события ──
$('tr-analytics-btn').addEventListener('click', openAnalytics);
$('tr-an-close').addEventListener('click', function () { $('tr-analytics').style.display = 'none'; });
$('tr-analytics').addEventListener('click', function (e) { if (e.target === $('tr-analytics')) $('tr-analytics').style.display = 'none'; });
$('tr-an-body').addEventListener('click', function (e) {
var b = e.target.closest('.tr-an-cls'); if (!b) return;
showStats(+b.getAttribute('data-cid'));
});
$('tr-topics').addEventListener('click', function (e) {
var b = e.target.closest('.tr-chip'); if (!b) return;
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
curTopic = t.key;
renderTopics();
if (t.word) { renderSkills(); loadWordPool(function () { serveWordProblem(); }); return; }
var ss = skillsOf(curTopic);
// первый неосвоенный навык темы, иначе первый
curGen = ss[0] || curGen;
for (var i = 0; i < ss.length; i++) { var p = prog[skillKey(ss[i])]; if (!(p && p.mastered)) { curGen = ss[i]; break; } }
renderSkills(); newProblem();
});
$('tr-skills').addEventListener('click', function (e) {
var b = e.target.closest('.tr-skill'); if (!b) return;
var ss = skillsOf(curTopic);
curGen = ss[+b.getAttribute('data-si')] || curGen;
renderSkills(); newProblem();
});
$('tr-smart-btn').addEventListener('click', function () {
smart = !smart;
$('tr-smart-btn').classList.toggle('on', smart);
updateSession();
});
$('tr-check').addEventListener('click', check);
$('tr-skip').addEventListener('click', newProblem);
$('tr-hint').addEventListener('click', function () {
if (!cur) return;
var s = $('tr-solution');
s.innerHTML = '<h4>Подсказка</h4>' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null }, 1);
s.style.display = 'block';
});
$('tr-solve').addEventListener('click', function () { if (cur) revealAnswer(true); });
$('tr-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); check(); } });
$('tr-note').textContent = gens.length + ' навыков в ' + topics.length + ' темах · умная тренировка ведёт от простого к сложному и возвращает ошибки · прогресс сохраняется.';
// загрузка прогресса → старт (умный режим: адаптивный первый навык)
function boot() {
for (var ti = 0; ti < topics.length; ti++) {
if (!topicMastered(topics[ti].key)) { curTopic = topics[ti].key; break; }
}
var ss = skillsOf(curTopic);
curGen = ss[0] || gens[0];
for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } }
if (smart) pickNext(null); // адаптивный первый навык (last=null — можно взять текущий)
renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem();
if (isTeacher) $('tr-analytics-btn').style.display = '';
}
(LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null))
.then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); })
.catch(function () {})
.then(boot);
})();
</script>
</body>
</html>