8c4c9bf04c
- practiceVerify.js: грузит SimExpr в Node (require), verifyRoot подстановкой корня - practiceGenService.js: LLM (инъектируемый ask) → parse → validateAndVerify (SimExpr + подстановка + санитизация) → авторетрай по фидбэку; дефолт ask = assistantController.callLLMFailover - пул practice_problems (мигр.083); POST /api/practice/generate (учитель/админ) + GET /api/practice/pool - инвариант: невалидная/неверная задача в БД НЕ пишется → ученику не попадёт - клиент: LS.practicePool/Generate, тема «Текстовые задачи» (из пула; учителю кнопка «Сгенерировать») - тесты practice-gen.test.js 13/13 (verify, ретраи, off→503, 403 ученику, пул); смоуки страница 26/26; план P3 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
556 lines
29 KiB
HTML
556 lines
29 KiB
HTML
<!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-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">Алгебра · 7 класс</span></h1>
|
|
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
|
|
</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>
|
|
</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">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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
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); // условие как текст
|
|
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 ? 'Дальше' : 'Проверить';
|
|
}
|
|
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);
|
|
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(); }
|
|
}).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';
|
|
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';
|
|
fb.innerHTML = ICON.ok + ' <span>Верно!</span> ' + (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
|
|
recordAnswer(true); submitAttempt(true);
|
|
} else {
|
|
streak = 0;
|
|
fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Неверно. Разбери решение и реши похожую.';
|
|
recordAnswer(false); submitAttempt(false);
|
|
revealSolution();
|
|
}
|
|
updateStats();
|
|
}
|
|
|
|
// ── события ──
|
|
$('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(); newProblem();
|
|
}
|
|
(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>
|