41acbdd0d0
GET /api/admin/overview returns 24h digest (~0.08ms/call).
- adminController.getOverview: 7 prepared statements (users 24h, sessions 24h, active users, classes count, failed sessions, banned this week, top-5 sessions)
- new section frontend/js/admin/sections/overview.js (~205L): bento-grid cards, alerts (only when >0), top-5 table, quick-links
- nav-item + tab-pane reordered: #overview is now default; #stats remains routable
Auth: admin-only (inside requireRole('admin') block, sibling of /stats).
Backward compat: all 13 existing routes unchanged.
Known follow-ups (post-merge polish):
- activeClasses counts all (label could be 'Всего классов')
- failedSessions24h includes in_progress (could tighten to abandoned only)
- topSessions24h drops NULL-score completed rows
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
703 lines
38 KiB
JavaScript
703 lines
38 KiB
JavaScript
'use strict';
|
|
// admin.html — main orchestrator (thin shell after Phase 2 section split).
|
|
// Section modules live in /js/admin/sections/*.js — admin.js wires them
|
|
// to the router + handles tabs not yet extracted (topics/audit/errors/health/classroom/avatars).
|
|
// Order of operation preserved: loads after api.js + sidebar.js + router.js + _shared.js + sections/*.js
|
|
|
|
const { user, isTeacher, isAdmin } = LS.initPage();
|
|
if (!isTeacher) { window.location.href = '/dashboard'; throw new Error(); }
|
|
document.getElementById('page-sub').textContent =
|
|
isAdmin ? 'Администратор · полный доступ' : 'Учитель · просмотр статистики';
|
|
|
|
/* Populate shared context for section modules */
|
|
AdminCtx.user = user;
|
|
AdminCtx.isTeacher = isTeacher;
|
|
AdminCtx.isAdmin = isAdmin;
|
|
|
|
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
|
|
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'];
|
|
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
|
ADMIN_ONLY_TABS.forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.style.display = ''; // always visible now
|
|
if (!isAdmin) {
|
|
el.classList.add('locked');
|
|
el.title = 'Только для администраторов';
|
|
el.insertAdjacentHTML('beforeend', lockSvg);
|
|
}
|
|
});
|
|
// Система group: visible to everyone too
|
|
const sysGroup = document.getElementById('admin-nav-system-group');
|
|
if (sysGroup) sysGroup.style.display = '';
|
|
|
|
/* Collapsible nav groups — state persisted in localStorage */
|
|
window.toggleAdminGroup = function (slug) {
|
|
const g = document.querySelector(`.admin-nav-group[data-ng="${slug}"]`);
|
|
if (!g) return;
|
|
const collapsed = g.classList.toggle('collapsed');
|
|
try { localStorage.setItem('ls_adm_g_' + slug, collapsed ? '1' : '0'); } catch {}
|
|
};
|
|
// Restore collapsed state on page load
|
|
document.querySelectorAll('.admin-nav-group').forEach(g => {
|
|
const slug = g.dataset.ng;
|
|
try {
|
|
if (localStorage.getItem('ls_adm_g_' + slug) === '1') g.classList.add('collapsed');
|
|
} catch {}
|
|
});
|
|
LS.showBoardIfAllowed();
|
|
LS.hideDisabledFeatures?.();
|
|
LS.notif?.init();
|
|
|
|
/* ─── Tabs → section bridge ─── */
|
|
// Routes that map 1:1 to a section module (Phase 2-extracted).
|
|
const ROUTE_TO_SECTION = {
|
|
overview: 'overview',
|
|
stats: 'stats',
|
|
questions: 'questions',
|
|
tests: 'tests',
|
|
assignments: 'assignments',
|
|
subjects: 'subjects',
|
|
users: 'users',
|
|
sessions: 'sessions',
|
|
permissions: 'permissions',
|
|
shop: 'shop',
|
|
gam: 'gam',
|
|
tpl: 'tpl',
|
|
sims: 'sims',
|
|
games: 'games',
|
|
sublog: 'sublog',
|
|
};
|
|
|
|
function switchTab(btn, opts) {
|
|
if (btn.classList.contains('locked')) {
|
|
LS.toast('Этот раздел доступен только администраторам', 'warn');
|
|
return;
|
|
}
|
|
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
|
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
|
|
const name = btn.dataset.tab;
|
|
document.getElementById('tab-' + name).classList.add('active');
|
|
btn.classList.add('active');
|
|
// Dispatch to section module for the 13 Phase-2 tabs
|
|
const secName = ROUTE_TO_SECTION[name];
|
|
if (secName && AdminSections[secName]) {
|
|
AdminSections[secName].init();
|
|
}
|
|
// System tabs (not yet extracted in Phase 2) — load inline
|
|
if (name === 'topics') loadTopics();
|
|
else if (name === 'audit') loadAuditLog();
|
|
else if (name === 'errors') loadErrorLog();
|
|
else if (name === 'health') loadHealth();
|
|
else if (name === 'classroom'){ loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
|
else if (name === 'avatars') loadAvatarRequests();
|
|
|
|
// Sync URL hash via router (silent so we don't recurse).
|
|
if (!(opts && opts.fromRouter) && window.AdminRouter) {
|
|
AdminRouter.navigate('#' + name, { silent: true });
|
|
}
|
|
}
|
|
window.switchTab = switchTab;
|
|
|
|
/* Cross-section orchestrator: navigate to Questions tab + open Q-modal pre-filled */
|
|
async function goAddQuestion(slug) {
|
|
const qBtn = document.querySelector('[data-tab="questions"]');
|
|
switchTab(qBtn);
|
|
document.getElementById('q-subject').value = slug;
|
|
await AdminSections.questions.reload();
|
|
AdminSections.questions.openModal();
|
|
document.getElementById('qf-subject').value = slug;
|
|
await AdminSections.questions.loadModalTopics();
|
|
}
|
|
window.goAddQuestion = goAddQuestion;
|
|
|
|
/* ═══ TOPICS ═══════════════════════════════════════════════════════ */
|
|
let _topicsSubjects = [];
|
|
async function loadTopicSubjects() {
|
|
if (_topicsSubjects.length) return;
|
|
try {
|
|
_topicsSubjects = await LS.getSubjects();
|
|
const sel = document.getElementById('topics-subj-filter');
|
|
sel.innerHTML = _topicsSubjects.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
|
|
} catch {}
|
|
}
|
|
async function loadTopics() {
|
|
await loadTopicSubjects();
|
|
const subjId = document.getElementById('topics-subj-filter').value;
|
|
const el = document.getElementById('topics-list');
|
|
el.innerHTML = LS.skeleton(4, 'row');
|
|
try {
|
|
const rows = await LS.api(`/api/admin/topics?subject_id=${subjId}`);
|
|
document.getElementById('topics-count').textContent = rows.length + ' тем';
|
|
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Тем нет</div>'; return; }
|
|
el.innerHTML = '<div style="display:flex;flex-direction:column;gap:6px">' + rows.map(t => `
|
|
<div class="adm-panel" style="padding:12px 18px;margin:0;display:flex;align-items:center;gap:14px">
|
|
<span style="font-size:0.75rem;color:var(--text-3);font-weight:700;min-width:28px">#${t.order_index}</span>
|
|
<span style="flex:1;font-weight:600">${esc(t.name)}</span>
|
|
<span style="font-size:0.78rem;color:var(--text-3)">${t.question_count} вопр.</span>
|
|
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-2);padding:5px 12px" onclick="renameTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}')">
|
|
<i data-lucide="pencil" style="width:12px;height:12px;vertical-align:-1px"></i>
|
|
</button>
|
|
<button class="adm-btn adm-btn-small" style="background:rgba(241,91,181,0.1);color:var(--pink);padding:5px 12px" onclick="deleteTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}',${t.question_count})">
|
|
<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:-1px"></i>
|
|
</button>
|
|
</div>`).join('') + '</div>';
|
|
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
|
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
|
}
|
|
function showAddTopic() { document.getElementById('topics-add-row').style.display = ''; document.getElementById('topics-new-name').focus(); }
|
|
async function createTopic() {
|
|
const name = document.getElementById('topics-new-name').value.trim();
|
|
if (!name) return;
|
|
const subjId = document.getElementById('topics-subj-filter').value;
|
|
try {
|
|
await LS.api('/api/admin/topics', { method:'POST', body: JSON.stringify({ subject_id: subjId, name }) });
|
|
document.getElementById('topics-new-name').value = '';
|
|
document.getElementById('topics-add-row').style.display = 'none';
|
|
LS.toast('Тема создана', 'success');
|
|
loadTopics();
|
|
} catch (e) { LS.toast(e.message, 'error'); }
|
|
}
|
|
async function renameTopic(id, oldName) {
|
|
const name = prompt('Новое название темы:', oldName);
|
|
if (!name || name === oldName) return;
|
|
try {
|
|
await LS.api(`/api/admin/topics/${id}`, { method:'PATCH', body: JSON.stringify({ name }) });
|
|
LS.toast('Тема переименована', 'success');
|
|
loadTopics();
|
|
} catch (e) { LS.toast(e.message, 'error'); }
|
|
}
|
|
async function deleteTopic(id, name, qcount) {
|
|
if (qcount > 0) { LS.toast(`Нельзя удалить тему с ${qcount} вопросами`, 'warn'); return; }
|
|
if (!await LS.confirm(`Удалить тему "${name}"?`, { danger: true })) return;
|
|
try {
|
|
await LS.api(`/api/admin/topics/${id}`, { method:'DELETE' });
|
|
LS.toast('Тема удалена', 'success');
|
|
loadTopics();
|
|
} catch (e) { LS.toast(e.message, 'error'); }
|
|
}
|
|
window.showAddTopic = showAddTopic;
|
|
window.createTopic = createTopic;
|
|
window.renameTopic = renameTopic;
|
|
window.deleteTopic = deleteTopic;
|
|
|
|
/* ═══ BROADCAST ═════════════════════════════════════════════════════ */
|
|
async function sendBroadcast() {
|
|
const message = document.getElementById('bc-message').value.trim();
|
|
if (!message) { LS.toast('Введите сообщение', 'warn'); return; }
|
|
const role = document.getElementById('bc-role').value;
|
|
const link = document.getElementById('bc-link').value.trim() || null;
|
|
try {
|
|
const r = await LS.api('/api/admin/broadcast', { method:'POST', body: JSON.stringify({ message, role, link }) });
|
|
document.getElementById('bc-result').textContent = `Отправлено ${r.sent} пользователям`;
|
|
document.getElementById('bc-message').value = '';
|
|
LS.toast(`Уведомление отправлено ${r.sent} пользователям`, 'success');
|
|
} catch (e) { LS.toast(e.message, 'error'); }
|
|
}
|
|
window.sendBroadcast = sendBroadcast;
|
|
|
|
/* ═══ AUDIT LOG ════════════════════════════════════════════════════ */
|
|
async function loadAuditLog() {
|
|
const el = document.getElementById('audit-list');
|
|
el.innerHTML = LS.skeleton(5, 'row');
|
|
try {
|
|
const rows = await LS.api('/api/admin/audit-log?limit=200');
|
|
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал пуст</div>'; return; }
|
|
const ACTION_LABELS = {
|
|
'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка',
|
|
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
|
|
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
|
|
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
|
|
};
|
|
const ACTION_COLORS = {
|
|
'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)',
|
|
};
|
|
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
|
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
|
|
<tbody>${rows.map(r => {
|
|
const dt = new Date(r.created_at);
|
|
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
|
const acol = ACTION_COLORS[r.action] || 'var(--violet)';
|
|
return `<tr>
|
|
<td><span class="sl-date">${ds}</span></td>
|
|
<td>${esc(r.admin_name || '—')}</td>
|
|
<td><span style="color:${acol};font-weight:700;font-size:0.82rem">${ACTION_LABELS[r.action] || r.action}</span></td>
|
|
<td style="font-size:0.82rem;color:var(--text-3)">${esc(r.target || '')}</td>
|
|
<td style="font-size:0.82rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.detail || '')}">${esc(r.detail || '')}</td>
|
|
<td style="font-size:0.78rem;color:var(--text-3);font-family:monospace">${esc(r.ip || '')}</td>
|
|
</tr>`;
|
|
}).join('')}</tbody></table></div>`;
|
|
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
|
}
|
|
async function clearAuditLog() {
|
|
if (!await LS.confirm('Очистить весь аудит-лог?', { danger: true })) return;
|
|
try {
|
|
await LS.api('/api/admin/audit-log', { method:'DELETE' });
|
|
document.getElementById('audit-list').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал очищен</div>';
|
|
LS.toast('Журнал очищен', 'success');
|
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
|
}
|
|
window.clearAuditLog = clearAuditLog;
|
|
|
|
/* ═══ ERROR LOG ════════════════════════════════════════════════════ */
|
|
async function loadErrorLog() {
|
|
const el = document.getElementById('errors-list');
|
|
el.innerHTML = LS.skeleton(3, 'row');
|
|
try {
|
|
const rows = await LS.api('/api/admin/error-log?limit=200');
|
|
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3);font-size:0.88rem">Ошибок нет</div>'; return; }
|
|
el.innerHTML = rows.map(r => {
|
|
const dt = new Date(r.created_at);
|
|
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
|
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
|
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
|
|
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
|
|
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
|
|
</div>
|
|
<div style="font-size:0.88rem;font-weight:600;color:var(--text);margin-bottom:4px">${esc(r.message)}</div>
|
|
${r.stack ? `<details><summary style="font-size:0.75rem;color:var(--text-3);cursor:pointer">Stack trace</summary><pre style="font-size:0.72rem;color:var(--text-3);white-space:pre-wrap;max-height:200px;overflow:auto;margin-top:6px;padding:8px;background:rgba(0,0,0,0.02);border-radius:8px">${esc(r.stack)}</pre></details>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
|
}
|
|
async function clearErrorLog() {
|
|
if (!await LS.confirm('Очистить журнал ошибок?', { danger: true })) return;
|
|
try {
|
|
await LS.api('/api/admin/error-log', { method:'DELETE' });
|
|
document.getElementById('errors-list').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал очищен</div>';
|
|
LS.toast('Журнал очищен', 'success');
|
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
|
}
|
|
window.clearErrorLog = clearErrorLog;
|
|
|
|
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
|
async function loadHealth() {
|
|
const el = document.getElementById('health-content');
|
|
el.innerHTML = LS.skeleton(3, 'row');
|
|
try {
|
|
const h = await LS.api('/api/admin/health');
|
|
const fmtBytes = b => b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB';
|
|
const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; };
|
|
el.innerHTML = `
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:24px">
|
|
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
|
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--green)">${fmtUp(h.uptime)}</div>
|
|
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Uptime</div>
|
|
</div>
|
|
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
|
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--violet)">${fmtBytes(h.db.sizeBytes)}</div>
|
|
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">База данных</div>
|
|
</div>
|
|
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
|
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif">${fmtBytes(h.uploads.sizeBytes)}</div>
|
|
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Файлы</div>
|
|
</div>
|
|
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
|
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:${h.recentErrors>0?'var(--pink)':'var(--green)'}">${h.recentErrors}</div>
|
|
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Ошибок за 24ч</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
|
<div class="adm-panel" style="margin:0">
|
|
<div class="adm-panel-title">Платформа</div>
|
|
<table style="width:100%;font-size:0.88rem">
|
|
<tr><td style="color:var(--text-3);padding:4px 0">Node.js</td><td style="font-weight:600">${h.node}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">OS</td><td style="font-weight:600">${h.platform}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">CPU ядра</td><td style="font-weight:600">${h.cpus}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">RAM использовано</td><td style="font-weight:600">${fmtBytes(h.memory.rss)}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">RAM heap</td><td style="font-weight:600">${fmtBytes(h.memory.heapUsed)}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">RAM свободно</td><td style="font-weight:600">${fmtBytes(h.freeMem)} / ${fmtBytes(h.totalMem)}</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="adm-panel" style="margin:0">
|
|
<div class="adm-panel-title">Данные</div>
|
|
<table style="width:100%;font-size:0.88rem">
|
|
<tr><td style="color:var(--text-3);padding:4px 0">Пользователей</td><td style="font-weight:600">${h.db.totalUsers}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">Всего сессий</td><td style="font-weight:600">${h.db.totalSessions}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">Сессий сегодня</td><td style="font-weight:600;color:var(--violet)">${h.db.todaySessions}</td></tr>
|
|
<tr><td style="color:var(--text-3);padding:4px 0">Вопросов в базе</td><td style="font-weight:600">${h.db.totalQuestions}</td></tr>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
ОНЛАЙН-УРОКИ (classroom admin)
|
|
════════════════════════════════════════════════ */
|
|
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
|
|
let _crOpenDetailId = null, _crHistDebTimer = null;
|
|
|
|
async function loadCrModuleState() {
|
|
try {
|
|
const features = await LS.api('/api/admin/features');
|
|
const chk = document.getElementById('cr-master-chk');
|
|
if (chk) chk.checked = features.classroom !== false;
|
|
} catch(e) { /* silent */ }
|
|
}
|
|
|
|
async function crMasterToggle(enabled) {
|
|
try {
|
|
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
|
|
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
|
|
} catch(e) {
|
|
LS.toast('Ошибка: ' + e.message, 'error');
|
|
const chk = document.getElementById('cr-master-chk');
|
|
if (chk) chk.checked = !enabled;
|
|
}
|
|
}
|
|
window.crMasterToggle = crMasterToggle;
|
|
|
|
function fmtLiveDuration(createdAt) {
|
|
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
|
|
return AdminCtx.fmtDuration(sec);
|
|
}
|
|
|
|
async function loadCrActiveSessions() {
|
|
const el = document.getElementById('cr-live-list');
|
|
try {
|
|
const { sessions } = await LS.api('/api/classroom/admin/active');
|
|
if (!sessions.length) {
|
|
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = sessions.map(s => {
|
|
const dur = fmtLiveDuration(s.created_at);
|
|
const title = s.title || `Урок #${s.id}`;
|
|
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
|
|
return `<div class="cr-live-card">
|
|
<div class="cr-live-pulse"></div>
|
|
<div class="cr-live-info">
|
|
<div class="cr-live-title">${esc(title)}</div>
|
|
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
|
|
</div>
|
|
<div class="cr-live-badges">
|
|
<span class="cr-badge cr-badge-online">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
${s.online_count}
|
|
</span>
|
|
<span class="cr-badge cr-badge-msgs">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
${s.message_count}
|
|
</span>
|
|
<span class="cr-badge cr-badge-dur">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
${dur}
|
|
</span>
|
|
</div>
|
|
<div class="cr-live-actions">
|
|
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
} catch(e) {
|
|
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
|
}
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
async function adminEndSession(id) {
|
|
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
|
|
try {
|
|
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
|
|
LS.toast('Урок завершён', 'success', 2500);
|
|
loadCrActiveSessions();
|
|
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
|
}
|
|
window.adminEndSession = adminEndSession;
|
|
|
|
function crHistDebounce() {
|
|
clearTimeout(_crHistDebTimer);
|
|
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
|
|
}
|
|
window.crHistDebounce = crHistDebounce;
|
|
|
|
async function loadCrHistory(page) {
|
|
const { fmtDate, fmtDuration } = AdminCtx;
|
|
if (page) _crHistPage = page;
|
|
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
|
|
const el = document.getElementById('cr-hist-list');
|
|
el.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
|
|
if (_crHistSearch) params.set('search', _crHistSearch);
|
|
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
|
|
_crHistTotal = total; _crHistPages = pages;
|
|
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
|
|
if (!sessions.length) {
|
|
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
|
|
renderCrPagination();
|
|
return;
|
|
}
|
|
el.innerHTML = sessions.map(s => {
|
|
const title = s.title || `Урок #${s.id}`;
|
|
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
|
|
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
|
|
return `<div>
|
|
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
|
|
<div class="cr-hist-icon">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
</div>
|
|
<div class="cr-hist-main">
|
|
<div class="cr-hist-title">${esc(title)}</div>
|
|
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
|
|
</div>
|
|
<div class="cr-hist-chips">
|
|
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
|
|
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
|
|
<span class="cr-badge cr-badge-dur">${dur}</span>
|
|
</div>
|
|
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
|
|
</div>
|
|
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
|
|
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
if (_crOpenDetailId) {
|
|
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
|
|
if (dr) loadCrSessionDetail(_crOpenDetailId);
|
|
}
|
|
renderCrPagination();
|
|
} catch(e) {
|
|
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
|
}
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
window.loadCrHistory = loadCrHistory;
|
|
|
|
function renderCrPagination() {
|
|
const el = document.getElementById('cr-hist-pagination');
|
|
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
|
|
const p = _crHistPage, total = _crHistPages;
|
|
let html = '<div class="cr-pagination">';
|
|
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
|
|
</button>`;
|
|
const range = [];
|
|
for (let i=1;i<=total;i++) {
|
|
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
|
|
else if (range[range.length-1]!=='…') range.push('…');
|
|
}
|
|
range.forEach(r => {
|
|
if (r==='…') html += `<span class="cr-page-info">…</span>`;
|
|
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
|
|
});
|
|
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
|
|
</button></div>`;
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
async function toggleCrDetail(id, rowEl) {
|
|
const wasOpen = _crOpenDetailId === id;
|
|
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
|
|
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
|
|
_crOpenDetailId = null;
|
|
if (wasOpen) return;
|
|
rowEl.classList.add('open');
|
|
const dr = document.getElementById(`cr-detail-${id}`);
|
|
if (dr) { dr.classList.add('open'); }
|
|
_crOpenDetailId = id;
|
|
await loadCrSessionDetail(id);
|
|
}
|
|
window.toggleCrDetail = toggleCrDetail;
|
|
|
|
async function loadCrSessionDetail(id) {
|
|
const { fmtDuration } = AdminCtx;
|
|
const inner = document.getElementById(`cr-detail-inner-${id}`);
|
|
if (!inner) return;
|
|
inner.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
|
|
const dur = fmtDuration(stats.duration_sec);
|
|
inner.innerHTML = `
|
|
<div class="cr-detail-grid">
|
|
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
|
|
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
|
|
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
|
|
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
|
|
</div>
|
|
${attendance.length ? `
|
|
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
|
|
<div class="cr-attend-list">
|
|
${attendance.map(a => `
|
|
<div class="cr-attend-row">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
<span class="cr-attend-name">${esc(a.user_name)}</span>
|
|
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
|
|
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
${pages.length > 1 ? `
|
|
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
|
|
<div class="cr-pages-list">
|
|
${pages.map(p => `
|
|
<div class="cr-page-chip">
|
|
<span class="cr-page-num">Стр. ${p.page_num}</span>
|
|
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
<div class="cr-detail-actions">
|
|
<button class="btn-cr-export" onclick="adminExportChat(${id})">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
Экспорт чата
|
|
</button>
|
|
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
|
Удалить запись
|
|
</button>
|
|
</div>`;
|
|
} catch(e) {
|
|
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function adminExportChat(id) {
|
|
window.open(`/api/classroom/${id}/chat/export`, '_blank');
|
|
}
|
|
window.adminExportChat = adminExportChat;
|
|
|
|
async function adminDeleteSession(id) {
|
|
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
|
|
try {
|
|
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
|
|
LS.toast('Урок удалён', 'success', 2500);
|
|
_crOpenDetailId = null;
|
|
loadCrHistory();
|
|
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
|
}
|
|
window.adminDeleteSession = adminDeleteSession;
|
|
|
|
/* ── Avatar moderation ─────────────────────────────────────────────── */
|
|
async function loadAvatarRequests() {
|
|
const list = document.getElementById('av-list');
|
|
list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>';
|
|
try {
|
|
const rows = await LS.get('/api/avatar/pending');
|
|
const badge = document.getElementById('av-badge');
|
|
if (rows.length) {
|
|
badge.textContent = rows.length;
|
|
badge.style.display = 'inline-flex';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
}
|
|
if (!rows.length) {
|
|
list.innerHTML = '<div class="av-empty"><i data-lucide="check-circle" style="width:36px;height:36px;opacity:.3;display:block;margin:0 auto 10px"></i>Нет заявок на модерацию</div>';
|
|
if (window.lucide) lucide.createIcons();
|
|
return;
|
|
}
|
|
list.innerHTML = `<div class="av-grid">${rows.map(r => {
|
|
const initials = (r.user_name||'LS').split(' ').slice(0,2).map(w=>(w[0]||'').toUpperCase()).join('') || 'LS';
|
|
const curAvatar = r.current_avatar
|
|
? `<img src="/avatars/${esc(r.current_avatar)}" alt="">`
|
|
: initials;
|
|
const newAvatar = `<img src="/avatars/${esc(r.filename)}" alt="" onerror="this.parentElement.textContent='?'">`;
|
|
const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
|
|
return `<div class="av-card" id="av-card-${r.id}">
|
|
<div class="av-card-top">
|
|
<div class="av-imgs">
|
|
<div class="av-img-wrap">
|
|
<span>Сейчас</span>
|
|
<div class="av-img">${curAvatar}</div>
|
|
</div>
|
|
<svg class="av-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
|
<div class="av-img-wrap">
|
|
<span>Новый</span>
|
|
<div class="av-img">${newAvatar}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="av-user-name">${esc(r.user_name||r.user_email)}</div>
|
|
<div class="av-date">${esc(r.user_email)} · ${d}</div>
|
|
</div>
|
|
<div class="av-actions">
|
|
<button class="av-approve" onclick="avatarApprove(${r.id})">Одобрить</button>
|
|
<button class="av-reject" onclick="avatarRejectPrompt(${r.id})">Отклонить</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('')}</div>`;
|
|
if (window.lucide) lucide.createIcons();
|
|
} catch {
|
|
list.innerHTML = '<div class="av-empty">Ошибка загрузки</div>';
|
|
}
|
|
}
|
|
|
|
async function avatarApprove(id) {
|
|
const card = document.getElementById('av-card-' + id);
|
|
if (card) card.style.opacity = '0.5';
|
|
try {
|
|
await LS.post('/api/avatar/' + id + '/approve', {});
|
|
LS.toast('Аватар одобрен', 'success');
|
|
loadAvatarRequests();
|
|
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
|
|
}
|
|
window.avatarApprove = avatarApprove;
|
|
|
|
function avatarRejectPrompt(id) {
|
|
const reason = prompt('Причина отклонения (необязательно):') ?? null;
|
|
if (reason === null) return;
|
|
avatarReject(id, reason);
|
|
}
|
|
window.avatarRejectPrompt = avatarRejectPrompt;
|
|
|
|
async function avatarReject(id, reason) {
|
|
const card = document.getElementById('av-card-' + id);
|
|
if (card) card.style.opacity = '0.5';
|
|
try {
|
|
await LS.patch('/api/avatar/' + id + '/reject', { reason });
|
|
LS.toast('Аватар отклонён', 'info');
|
|
loadAvatarRequests();
|
|
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
|
|
}
|
|
window.avatarReject = avatarReject;
|
|
|
|
/* ─── init ─── */
|
|
// Initial #overview tab is .active in markup — section module will lazy-load on first switchTab.
|
|
AdminSections.overview.init();
|
|
loadAvatarRequests(); // load badge count on page open
|
|
if (window.lucide) lucide.createIcons();
|
|
|
|
/* ─── Hash router wiring ─── */
|
|
(function initAdminRouter() {
|
|
if (!window.AdminRouter) return;
|
|
|
|
function activate(route) {
|
|
const name = route || 'overview';
|
|
const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]');
|
|
if (!btn) {
|
|
console.warn('AdminRouter: unknown route', name);
|
|
AdminRouter.navigate('#overview', { replace: true, silent: true });
|
|
const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
|
|
if (fallback) switchTab(fallback, { fromRouter: true });
|
|
return;
|
|
}
|
|
if (btn.classList.contains('locked')) {
|
|
LS.toast('Этот раздел доступен только администраторам', 'warn');
|
|
AdminRouter.navigate('#overview', { replace: true, silent: true });
|
|
const fallback = document.querySelector('.admin-nav-item[data-tab="overview"]');
|
|
if (fallback) switchTab(fallback, { fromRouter: true });
|
|
return;
|
|
}
|
|
switchTab(btn, { fromRouter: true });
|
|
}
|
|
|
|
AdminRouter.on('change', (r) => activate(r.route));
|
|
|
|
// Initial dispatch: respect existing hash, else default to #overview.
|
|
const initial = AdminRouter.current();
|
|
if (!initial.route) {
|
|
AdminRouter.navigate('#overview', { replace: true, silent: true });
|
|
} else if (initial.route !== 'overview') {
|
|
activate(initial.route);
|
|
}
|
|
})();
|