fd29acbbdd
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
556 lines
27 KiB
HTML
556 lines
27 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=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: #8898AA; 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: #8898AA;
|
||
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: #8898AA; 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: #8898AA; 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">
|
||
<div class="sb-brand">
|
||
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
|
||
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
|
||
</div>
|
||
<nav class="sb-nav">
|
||
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
|
||
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
|
||
<a href="/board" class="sb-link" id="btn-board"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
|
||
<a href="/classes" class="sb-link" id="btn-classes"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
|
||
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
|
||
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
|
||
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
|
||
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
|
||
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
|
||
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
|
||
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
|
||
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
|
||
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
|
||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
||
<div class="sb-divider"></div>
|
||
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||
<a href="/live-quiz" class="sb-link"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
|
||
<a href="/gradebook" class="sb-link nav-active"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
|
||
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
|
||
</nav>
|
||
<div style="padding: 4px 2px">
|
||
<div id="notif-wrap">
|
||
<button class="sb-link" id="notif-btn" onclick="toggleNotifDrop()">
|
||
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
|
||
<span class="sb-badge" id="notif-badge" style="display:none"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="sb-foot">
|
||
<a href="/profile" class="sb-user-row" style="text-decoration:none">
|
||
<div class="sb-avatar" id="nav-avatar">?</div>
|
||
<div class="sb-user-info">
|
||
<div class="sb-user-name" id="nav-user">—</div>
|
||
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</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>
|
||
if (!LS.requireAuth()) throw new Error();
|
||
|
||
const user = LS.getUser();
|
||
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
|
||
document.getElementById('nav-avatar').textContent =
|
||
(user?.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
|
||
|
||
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:#8898AA;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>
|