Files
Maxim Dolgolyov 5381679c68 chore: консолидация незакоммиченной работы (биохимия + System Health + lab/textbooks)
Зафиксирована накопленная незакоммиченная работа рабочего дерева, КРОМЕ файлов
учебника «Химия 7» (migration 046, chemistry_7_*.html, chem7_svg.js, тест —
оставлены незакоммиченными по запросу).

Включает: модуль биохимии (ядро BIO, 3D VSEPR, химдвижок, баланс, challenges,
пути из БД), System Health Level 1 (вердикт/мониторинг), а также frontend-
страницы и lab/textbooks-правки параллельной сессии.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:12:55 +03:00

510 lines
23 KiB
HTML
Raw Permalink 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=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; overflow-y: auto; }
/* ── page header ── */
.gb-header {
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
padding: 32px 28px 28px; position: relative; overflow: hidden;
}
.gb-header-dots {
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 22px 22px; pointer-events: none;
}
.gb-header-inner { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; }
.gb-header-back {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.78rem; font-weight: 700; color: rgba(255,255,255,0.45);
text-decoration: none; margin-bottom: 14px; transition: color 0.15s;
}
.gb-header-back:hover { color: rgba(255,255,255,0.8); }
.gb-title-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.gb-title {
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
color: #fff; flex: 1;
}
.gb-actions { display: flex; gap: 8px; flex-shrink: 0; }
.gb-action-btn {
padding: 8px 18px; border-radius: 999px; border: 1.5px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.7);
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.15s;
}
.gb-action-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
/* class selector */
.gb-class-select {
padding: 8px 14px; border: 1.5px solid rgba(255,255,255,0.15); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 600;
color: #fff; background: rgba(255,255,255,0.08); cursor: pointer;
}
.gb-class-select option { color: #0F172A; background: #fff; }
/* ── summary chips ── */
.gb-summary {
display: flex; gap: 12px; flex-wrap: wrap; max-width: 1200px;
margin: -20px auto 0; padding: 0 28px; position: relative; z-index: 2;
}
.gb-chip {
flex: 1; min-width: 150px; background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 16px; padding: 16px 18px; box-shadow: 0 2px 8px rgba(15,23,42,0.06);
}
.gb-chip-val {
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
}
.gb-chip-label { font-size: 0.72rem; color: var(--text-3); font-weight: 600; margin-top: 4px; }
/* ── table container ── */
.gb-container { max-width: 1200px; margin: 0 auto; padding: 24px 28px 80px; }
/* ── gradebook table ── */
.gb-table-wrap {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px; overflow: hidden;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.gb-table-scroll { overflow-x: auto; }
.gb-table {
width: 100%; border-collapse: collapse; font-size: 0.82rem;
min-width: 600px;
}
.gb-table thead th {
position: sticky; top: 0; z-index: 10;
background: #f8f9fc; border-bottom: 1.5px solid rgba(15,23,42,0.08);
padding: 12px 14px; text-align: left;
font-size: 0.7rem; font-weight: 700; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.04em;
white-space: nowrap;
}
.gb-table thead th.gb-th-student {
position: sticky; left: 0; z-index: 15; background: #f8f9fc;
min-width: 180px;
}
.gb-table thead th.gb-th-avg {
background: rgba(155,93,229,0.06); color: var(--violet);
}
.gb-table thead th.gb-th-rotate {
max-width: 80px; min-width: 60px;
}
.gb-th-text {
display: block; max-width: 90px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.gb-table tbody td {
padding: 10px 14px; border-bottom: 1px solid rgba(15,23,42,0.05);
text-align: center; font-weight: 600; color: #3D4F6B;
}
.gb-table tbody tr:hover td { background: rgba(155,93,229,0.02); }
.gb-table tbody td.gb-td-student {
position: sticky; left: 0; z-index: 5;
background: #fff; text-align: left; font-weight: 700; color: #0F172A;
}
.gb-table tbody tr:hover td.gb-td-student { background: rgba(155,93,229,0.02); }
.gb-table tbody td.gb-td-avg {
background: rgba(155,93,229,0.04); font-weight: 800;
}
/* grade cells */
.gb-grade {
display: inline-block; min-width: 40px; padding: 3px 8px;
border-radius: 8px; font-size: 0.76rem; font-weight: 700;
text-align: center;
}
.gb-grade-high { background: rgba(6,214,160,0.12); color: #059652; }
.gb-grade-mid { background: rgba(255,209,102,0.18); color: #B8860B; }
.gb-grade-low { background: rgba(239,71,111,0.12); color: #EF476F; }
.gb-grade-none { color: #CBD5E1; font-weight: 500; }
.gb-grade-full { background: rgba(6,214,160,0.15); color: #059652; font-weight: 800; }
/* student name row */
.gb-student-row { display: flex; align-items: center; gap: 10px; }
.gb-student-avatar {
width: 28px; height: 28px; border-radius: 8px;
background: rgba(155,93,229,0.1); color: var(--violet);
font-size: 0.65rem; font-weight: 800;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.gb-student-name { font-weight: 700; font-size: 0.82rem; }
.gb-student-email { font-size: 0.68rem; color: var(--text-3); font-weight: 500; }
/* ── footer row ── */
.gb-table tfoot td {
padding: 10px 14px; border-top: 1.5px solid rgba(15,23,42,0.08);
font-weight: 800; font-size: 0.78rem; color: #0F172A;
background: #f8f9fc; text-align: center;
}
.gb-table tfoot td.gb-td-student {
position: sticky; left: 0; z-index: 5;
text-align: left; background: #f8f9fc;
}
/* ── course progress section ── */
.gb-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #0F172A; margin: 28px 0 14px; display: flex; align-items: center; gap: 8px;
}
/* ── empty state ── */
.gb-empty {
text-align: center; padding: 60px 20px; color: var(--text-3); font-size: 0.88rem;
}
.gb-empty-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.3; }
/* ── nav ── */
.nav-active { background: rgba(155,93,229,0.08) !important; border-color: var(--violet) !important; color: var(--violet) !important; cursor: default; pointer-events: none; }
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
.notif-read-all { background: none; border: none; font-size: 0.74rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
.notif-item { display: flex; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--tr); text-decoration: none; color: inherit; }
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: rgba(155,93,229,0.04); }
.notif-item.unread { background: rgba(155,93,229,0.05); }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
@media (max-width: 768px) {
.gb-header { padding: 20px 16px 36px; }
.gb-title-row { gap: 10px; }
.gb-title { font-size: 1rem; }
.gb-class-select { width: 100%; }
.gb-summary { padding: 0 14px; gap: 8px; margin-top: -24px; }
.gb-chip { min-width: 0; flex: 1 1 calc(50% - 8px); padding: 12px 14px; }
.gb-chip-val { font-size: 1rem; }
.gb-container { padding: 18px 12px 80px; }
}
@media (max-width: 480px) {
.gb-chip { flex: 1 1 100%; }
.gb-action-btn { width: 100%; justify-content: center; }
.gb-title { font-size: 0.9rem; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<!-- Header -->
<div class="gb-header">
<div class="gb-header-dots"></div>
<div class="gb-header-inner">
<a href="/classes" class="gb-header-back"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Классы</a>
<div class="gb-title-row">
<div class="gb-title"><i data-lucide="table" style="width:22px;height:22px;opacity:0.4"></i> Журнал оценок</div>
<select class="gb-class-select" id="class-select" onchange="loadJournal()">
<option value="">Выберите класс…</option>
</select>
<div class="gb-actions">
<button class="gb-action-btn" id="btn-csv" onclick="exportCsv()" style="display:none">
<i data-lucide="download" style="width:14px;height:14px"></i> Экспорт CSV
</button>
</div>
</div>
</div>
</div>
<!-- Summary -->
<div class="gb-summary" id="gb-summary" style="display:none">
<div class="gb-chip">
<div class="gb-chip-val" id="gs-students" style="color:var(--violet)">0</div>
<div class="gb-chip-label">Учеников</div>
</div>
<div class="gb-chip">
<div class="gb-chip-val" id="gs-assignments" style="color:#06B6D4">0</div>
<div class="gb-chip-label">Заданий</div>
</div>
<div class="gb-chip">
<div class="gb-chip-val" id="gs-avg" style="color:#06D6A0"></div>
<div class="gb-chip-label">Средний балл</div>
</div>
<div class="gb-chip">
<div class="gb-chip-val" id="gs-completion" style="color:#FFD166"></div>
<div class="gb-chip-label">Выполнение</div>
</div>
</div>
<!-- Content -->
<div class="gb-container" id="gb-container">
<div class="gb-empty">
<div class="gb-empty-icon"><i data-lucide="table" style="width:48px;height:48px"></i></div>
Выберите класс, чтобы увидеть журнал оценок
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
const isTeacher = ['admin','teacher'].includes(user?.role);
if (!isTeacher) { location.href = '/dashboard'; throw new Error(); }
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
lucide.createIcons();
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
const collapsed = layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '');
lucide.createIcons();
}
if (localStorage.getItem('ls_sb_collapsed'))
document.querySelector('.app-layout').classList.add('sb-collapsed');
/* ── notif ── */
function toggleNotifDrop() {
const btn = document.getElementById('notif-btn');
const drop = document.getElementById('notif-drop');
const r = btn.getBoundingClientRect();
drop.style.left = (r.right + 8) + 'px';
drop.style.top = r.top + 'px';
if (drop.classList.toggle('open')) loadNotifs();
}
async function loadNotifs() {
const drop = document.getElementById('notif-drop');
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div><div class="notif-empty">Загрузка…</div>';
try {
const data = await LS.api('/api/notifications?limit=20');
const items = data.items || [];
const badge = document.getElementById('notif-badge');
const unread = items.filter(n => !n.is_read).length;
badge.textContent = unread; badge.style.display = unread ? '' : 'none';
if (!items.length) { drop.querySelector('.notif-empty').textContent = 'Нет уведомлений'; return; }
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div>' +
items.map(n => `<a class="notif-item${n.is_read ? '' : ' unread'}" href="${LS.safeHref(n.link)}" onclick="markRead(${n.id})">
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
<div><div class="notif-msg">${esc(n.message)}</div><div class="notif-time">${fmtTime(n.created_at)}</div></div>
</a>`).join('');
} catch {}
}
async function markRead(id) { try { await LS.api('/api/notifications/' + id + '/read', { method:'POST' }); } catch {} }
async function readAllNotifs() { try { await LS.api('/api/notifications/read-all', { method:'POST' }); loadNotifs(); } catch {} }
document.addEventListener('click', e => {
const drop = document.getElementById('notif-drop');
if (drop.classList.contains('open') && !drop.contains(e.target) && !document.getElementById('notif-btn').contains(e.target))
drop.classList.remove('open');
});
/* ── helpers ── */
function fmtTime(s) {
const d = new Date(s && s.includes('T') ? s : (s||'').replace(' ','T')+'Z');
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return Math.floor(diff/60000) + ' мин назад';
return d.toLocaleDateString('ru', { day:'numeric', month:'short' });
}
function initials(name) {
return (name || '??').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
}
function gradeClass(pct) {
if (pct >= 80) return 'gb-grade-high';
if (pct >= 50) return 'gb-grade-mid';
return 'gb-grade-low';
}
function gradeCell(pct) {
if (pct === null || pct === undefined || pct === '') return '<span class="gb-grade gb-grade-none">—</span>';
if (pct === 100) return `<span class="gb-grade gb-grade-full">${pct}%</span>`;
return `<span class="gb-grade ${gradeClass(pct)}">${pct}%</span>`;
}
/* ── load classes ── */
let currentClassId = null;
async function loadClasses() {
try {
const classes = await LS.api('/api/classes');
const sel = document.getElementById('class-select');
(classes || []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
// auto-select from URL
const urlClass = new URLSearchParams(location.search).get('classId');
if (urlClass) {
sel.value = urlClass;
loadJournal();
}
} catch {}
}
/* ── load journal ── */
async function loadJournal() {
const classId = document.getElementById('class-select').value;
if (!classId) {
document.getElementById('gb-summary').style.display = 'none';
document.getElementById('btn-csv').style.display = 'none';
document.getElementById('gb-container').innerHTML = `<div class="gb-empty"><div class="gb-empty-icon"><i data-lucide="table" style="width:48px;height:48px"></i></div>Выберите класс, чтобы увидеть журнал оценок</div>`;
lucide.createIcons();
return;
}
currentClassId = classId;
document.getElementById('gb-container').innerHTML = '<div class="spinner" style="margin:60px auto"></div>';
try {
const data = await LS.api('/api/classes/' + classId + '/journal');
renderJournal(data);
} catch (e) {
document.getElementById('gb-container').innerHTML = `<div class="gb-empty">${esc(e.message || 'Ошибка загрузки')}</div>`;
}
}
function renderJournal(data) {
const { members, assignments, results, courses, courseProgress, studentStats } = data;
const container = document.getElementById('gb-container');
const summary = document.getElementById('gb-summary');
if (!members.length) {
summary.style.display = 'none';
document.getElementById('btn-csv').style.display = 'none';
container.innerHTML = '<div class="gb-empty"><div class="gb-empty-icon"><i data-lucide="users" style="width:48px;height:48px"></i></div>В классе пока нет учеников</div>';
lucide.createIcons();
return;
}
// Build result map
const rmap = {};
results.forEach(r => { rmap[r.user_id + '_' + r.assignment_id] = r; });
// Summary stats
summary.style.display = '';
document.getElementById('btn-csv').style.display = '';
document.getElementById('gs-students').textContent = members.length;
document.getElementById('gs-assignments').textContent = assignments.length;
const allPcts = studentStats.filter(s => s.avgPct !== null);
const classAvg = allPcts.length > 0
? Math.round(allPcts.reduce((s, x) => s + x.avgPct, 0) / allPcts.length)
: 0;
document.getElementById('gs-avg').textContent = classAvg + '%';
const totalSlots = members.length * assignments.length;
const filledSlots = results.length;
const completionPct = totalSlots > 0 ? Math.round(filledSlots / totalSlots * 100) : 0;
document.getElementById('gs-completion').textContent = completionPct + '%';
// Assignments table
let html = '';
if (assignments.length) {
html += `<div class="gb-table-wrap"><div class="gb-table-scroll"><table class="gb-table">
<thead><tr>
<th class="gb-th-student">Ученик</th>
${assignments.map(a => `<th class="gb-th-rotate" title="${esc(a.title)}"><span class="gb-th-text">${esc(a.title)}</span></th>`).join('')}
<th class="gb-th-avg">Среднее</th>
</tr></thead>
<tbody>`;
members.forEach(m => {
const stats = studentStats.find(s => s.userId === m.id);
html += `<tr>
<td class="gb-td-student">
<div class="gb-student-row">
<div class="gb-student-avatar">${esc(initials(m.name))}</div>
<div>
<div class="gb-student-name">${esc(m.name)}</div>
<div class="gb-student-email">${esc(m.email)}</div>
</div>
</div>
</td>`;
assignments.forEach(a => {
const r = rmap[m.id + '_' + a.id];
html += `<td>${gradeCell(r ? r.percent : null)}</td>`;
});
html += `<td class="gb-td-avg">${stats?.avgPct !== null && stats?.avgPct !== undefined ? gradeCell(stats.avgPct) : gradeCell(null)}</td>`;
html += `</tr>`;
});
// Footer: averages per assignment
html += `</tbody><tfoot><tr><td class="gb-td-student" style="font-weight:800">Среднее по заданию</td>`;
assignments.forEach(a => {
const aResults = results.filter(r => r.assignment_id === a.id);
const avg = aResults.length > 0
? Math.round(aResults.reduce((s, r) => s + r.percent, 0) / aResults.length)
: null;
html += `<td>${gradeCell(avg)}</td>`;
});
html += `<td class="gb-td-avg">${gradeCell(classAvg)}</td></tr></tfoot>`;
html += `</table></div></div>`;
} else {
html += `<div style="background:#fff;border:1.5px solid rgba(15,23,42,0.07);border-radius:16px;padding:40px;text-align:center;color:var(--text-3);margin-bottom:20px">
Нет заданий в этом классе. <a href="/classes" style="color:var(--violet)">Создать задание <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
</div>`;
}
// Course progress section
if (courses && courses.length) {
html += `<div class="gb-section-title"><i data-lucide="brain" style="width:15px;height:15px;opacity:0.5"></i> Прогресс по курсам теории</div>`;
html += `<div class="gb-table-wrap"><div class="gb-table-scroll"><table class="gb-table">
<thead><tr>
<th class="gb-th-student">Ученик</th>
${courses.map(c => `<th class="gb-th-rotate" title="${esc(c.title)}"><span class="gb-th-text">${esc(c.title)}</span></th>`).join('')}
</tr></thead><tbody>`;
const cpMap = {};
(courseProgress || []).forEach(p => { cpMap[p.userId + '_' + p.courseId] = p; });
members.forEach(m => {
html += `<tr><td class="gb-td-student"><div class="gb-student-row"><div class="gb-student-avatar">${esc(initials(m.name))}</div><div class="gb-student-name">${esc(m.name)}</div></div></td>`;
courses.forEach(c => {
const p = cpMap[m.id + '_' + c.id];
html += `<td>${p ? gradeCell(p.percent) : gradeCell(null)}</td>`;
});
html += `</tr>`;
});
html += `</tbody></table></div></div>`;
}
container.innerHTML = html;
lucide.createIcons();
}
/* ── CSV export ── */
function exportCsv() {
if (!currentClassId) return;
const token = localStorage.getItem('ls_token');
const url = `/api/classes/${currentClassId}/journal/csv`;
// Use fetch with auth header then trigger download
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => {
if (!r.ok) throw new Error('Export failed');
return r.blob();
})
.then(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'gradebook.csv';
a.click();
URL.revokeObjectURL(a.href);
})
.catch(e => LS.toast(e.message || 'Ошибка экспорта', 'error'));
}
loadClasses();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>