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>
This commit is contained in:
+115
-2
@@ -103,6 +103,32 @@
|
||||
.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 {
|
||||
@@ -145,12 +171,30 @@
|
||||
<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>
|
||||
@@ -413,7 +457,7 @@
|
||||
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(); }
|
||||
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); updateOverall(); }
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
@@ -515,7 +559,75 @@
|
||||
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;
|
||||
@@ -561,7 +673,8 @@
|
||||
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();
|
||||
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; }); })
|
||||
|
||||
Reference in New Issue
Block a user