feat(trainer): P4 — авторинг задач учителем + раздача классу

- POST /api/practice/author: учитель пишет story/lhs/rhs/answer → та же проверка подстановкой (validateAndVerify) → пул; не сходится → 422
- POST /api/practice/assign: выдать тему классу → durable pushNotif каждому ученику (ссылка /trainer); владелец/админ, чужой → 403
- клиент: LS.practiceAuthor/Assign; в теме «Текстовые задачи» учителю кнопки «Своя задача» (модалка-форма) и «Выдать классу» (пикер классов)
- тесты: author (валид→пул, неверный→422, ученик→403), assign (владелец уведомляет, чужой→403) — practice 19/19 + practice-gen 16/16
- смоук страницы 27/27; план P4 → DONE (lean: ручной авторинг + раздача, без полного DSL-конструктора)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:30:02 +03:00
parent d003a0e100
commit cd7c75ff08
7 changed files with 185 additions and 7 deletions
+89 -2
View File
@@ -128,6 +128,15 @@
.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-form { display: flex; flex-direction: column; gap: 12px; }
.tr-form label { display: flex; flex-direction: column; gap: 4px; font-size: .85rem; font-weight: 600; color: #475569; }
.tr-form input, .tr-form textarea { font: inherit; padding: 9px 11px; border: 1px solid rgba(148,163,184,0.4); border-radius: 10px; outline: none; resize: vertical; }
.tr-form input:focus, .tr-form textarea:focus { border-color: #818cf8; box-shadow: 0 0 0 3px rgba(129,140,248,0.15); }
.tr-form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.tr-form-row label { flex: 1; min-width: 110px; }
.tr-form-hint { font-size: .8rem; color: #64748b; }
.tr-form-err { color: #dc2626; font-size: .85rem; font-weight: 600; min-height: 18px; }
/* ── режим (умная тренировка) ── */
.tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
@@ -197,6 +206,18 @@
</div>
</div>
<div class="tr-modal" id="tr-teacher" style="display:none">
<div class="tr-modal-card" style="max-width:560px">
<div class="tr-modal-head">
<span id="tr-tch-title">Своя задача</span>
<button class="tr-modal-x" id="tr-tch-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-tch-body"></div>
</div>
</div>
<div class="tr-topics" id="tr-topics"></div>
<div class="tr-skills" id="tr-skills"></div>
@@ -392,9 +413,15 @@
}
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 tb = isTeacher
? '<button class="tr-skill" id="tr-gen-btn" type="button">+ ИИ-задача</button>'
+ '<button class="tr-skill" id="tr-author-btn" type="button">Своя задача</button>'
+ '<button class="tr-skill" id="tr-assign-btn" type="button">Выдать классу</button>'
: '';
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + tb;
var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem);
var ab = $('tr-author-btn'); if (ab) ab.addEventListener('click', openAuthor);
var asg = $('tr-assign-btn'); if (asg) asg.addEventListener('click', openAssign);
return;
}
var ss = skillsOf(curTopic);
@@ -620,7 +647,67 @@
}).catch(function () { $('tr-an-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
}
// ── авторинг своей задачи (учитель) ──
function openAuthor() {
$('tr-tch-title').textContent = 'Своя задача';
$('tr-tch-body').innerHTML =
'<div class="tr-form">' +
'<label>Условие<textarea id="tr-f-story" rows="3" placeholder="Текст задачи словами"></textarea></label>' +
'<div class="tr-form-row">' +
'<label>Левая часть<input id="tr-f-lhs" placeholder="2*x + 1"></label>' +
'<label>Правая часть<input id="tr-f-rhs" placeholder="7"></label>' +
'<label>Ответ x<input id="tr-f-ans" placeholder="3"></label>' +
'</div>' +
'<div class="tr-form-hint">Сервер проверит подстановкой: при этом x левая часть должна равняться правой.</div>' +
'<div class="tr-form-err" id="tr-f-err"></div>' +
'<button class="tr-btn tr-primary" id="tr-f-save" type="button">Проверить и добавить</button>' +
'</div>';
$('tr-teacher').style.display = 'flex';
$('tr-f-save').addEventListener('click', submitAuthor);
}
function submitAuthor() {
var data = { topic: 'word-linear', story: $('tr-f-story').value, lhs: $('tr-f-lhs').value, rhs: $('tr-f-rhs').value, answer: Number($('tr-f-ans').value) };
var err = $('tr-f-err'); err.textContent = '';
var btn = $('tr-f-save'); btn.disabled = true; btn.textContent = 'Проверяю…';
LS.practiceAuthor(data).then(function (r) {
if (r && r.ok && r.problem) {
wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0;
if (LS.toast) LS.toast('Задача добавлена в банк', 'success');
$('tr-teacher').style.display = 'none';
if (isWord()) { renderSkills(); serveWordProblem(); }
} else { err.textContent = 'Не удалось добавить.'; btn.disabled = false; btn.textContent = 'Проверить и добавить'; }
}).catch(function () {
err.textContent = 'Проверка не прошла: при этом x левая часть не равна правой. Исправьте уравнение или ответ.';
btn.disabled = false; btn.textContent = 'Проверить и добавить';
});
}
// ── выдать тему классу (учитель) ──
function openAssign() {
$('tr-tch-title').textContent = 'Выдать классу';
$('tr-tch-body').innerHTML = '<div class="tr-an-empty">Загрузка классов…</div>';
$('tr-teacher').style.display = 'flex';
(LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) {
var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || [];
if (!list.length) { $('tr-tch-body').innerHTML = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
$('tr-tch-body').innerHTML = '<div class="tr-form-hint">Ученики выбранного класса получат уведомление со ссылкой на тренажёр.</div>' +
'<div class="tr-an-picker" id="tr-assign-list">' + list.map(function (c) {
return '<button class="tr-an-cls" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
}).join('') + '</div>';
$('tr-assign-list').addEventListener('click', function (e) {
var b = e.target.closest('.tr-an-cls'); if (!b) return;
b.disabled = true;
LS.practiceAssign(+b.getAttribute('data-cid'), 'word-linear', 'Текстовые задачи').then(function (res) {
if (LS.toast) LS.toast('Выдано классу (' + ((res && res.notified) || 0) + ' ученикам)', 'success');
$('tr-teacher').style.display = 'none';
}).catch(function () { if (LS.toast) LS.toast('Не удалось выдать классу', 'error'); b.disabled = false; });
});
}).catch(function () { $('tr-tch-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
}
// ── события ──
$('tr-tch-close').addEventListener('click', function () { $('tr-teacher').style.display = 'none'; });
$('tr-teacher').addEventListener('click', function (e) { if (e.target === $('tr-teacher')) $('tr-teacher').style.display = 'none'; });
$('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'; });