92030b462c
Replace ~3500L admin.js monolith with thin orchestrator (~700L) + 14 IIFE-wrapped per-section modules under /js/admin/sections/. Section modules expose AdminSections.<name>.init/reload (lazy init via switchTab/router) and re-expose onclick handlers via window.X for backward compat. Shared helpers (MODES/DIFFS, fmtDate, pctClass, renderMath, qTypeBadge, pagination) live in /js/admin/_shared.js exposed on window.AdminCtx. switchTab now dispatches to AdminSections via ROUTE_TO_SECTION map; non-extracted system tabs (topics/audit/errors/health/classroom/avatars) remain inline in admin.js. user-panel overlay markup untouched — Phase 6 will remove it.
160 lines
8.3 KiB
JavaScript
160 lines
8.3 KiB
JavaScript
'use strict';
|
|
/* admin → sessions section: sessions timeline + drawer detail */
|
|
(function () {
|
|
'use strict';
|
|
let inited = false;
|
|
|
|
let allSessions = [];
|
|
let openDrawerId = null;
|
|
|
|
async function load() {
|
|
const subject = document.getElementById('t-subject').value;
|
|
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
|
|
openDrawerId = null;
|
|
try {
|
|
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
|
|
renderSessions();
|
|
} catch (e) {
|
|
document.getElementById('t-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function sessPctRing(pct) {
|
|
const { pctClass } = AdminCtx;
|
|
const pc = pctClass(pct);
|
|
const colorMap = {'pct-hi':'var(--green)','pct-mid':'var(--amber)','pct-lo':'var(--pink)'};
|
|
const color = colorMap[pc] || 'var(--text-3)';
|
|
const circ = 106.8;
|
|
const dash = (pct / 100 * circ).toFixed(1);
|
|
return `<svg class="sess-tl-ring" width="48" height="48" viewBox="0 0 48 48">
|
|
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
|
|
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
|
|
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
|
|
transform="rotate(-90 24 24)"/>
|
|
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
|
|
</svg>`;
|
|
}
|
|
|
|
function renderSessions() {
|
|
const { MODES, fmtDate, fmtTime } = AdminCtx;
|
|
const modeF = document.getElementById('t-mode').value;
|
|
const searchF = document.getElementById('t-search').value.toLowerCase();
|
|
const filtered = allSessions.filter(s => {
|
|
if (modeF && s.mode !== modeF) return false;
|
|
if (searchF && !s.user_name.toLowerCase().includes(searchF) && !s.user_email.toLowerCase().includes(searchF)) return false;
|
|
return true;
|
|
});
|
|
document.getElementById('t-count').textContent = `${filtered.length} тестов`;
|
|
if (!filtered.length) {
|
|
document.getElementById('t-body').innerHTML = '<div class="empty">Нет тестов</div>';
|
|
return;
|
|
}
|
|
const groups = {};
|
|
filtered.forEach(s => {
|
|
const key = fmtDate(s.started_at);
|
|
(groups[key] = groups[key] || []).push(s);
|
|
});
|
|
document.getElementById('t-body').innerHTML = Object.entries(groups).map(([date, sessions]) =>
|
|
`<div class="sess-tl-day">${date}</div>
|
|
<div class="sess-tl-wrap">${sessions.map(s => {
|
|
const ring = s.percent !== null
|
|
? sessPctRing(s.percent)
|
|
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.85rem;font-weight:800;color:var(--text-3)">—</div>`;
|
|
return `<div class="sess-tl-item" id="trow-${s.id}" onclick="toggleDrawer(${s.id})">
|
|
${ring}
|
|
<div class="sess-tl-user">
|
|
<div class="sess-tl-name">${esc(s.user_name)}</div>
|
|
<div class="sess-tl-meta">${esc(s.subject_name||'?')} · <span class="mode-badge mode-${s.mode}">${MODES[s.mode]||s.mode}</span></div>
|
|
</div>
|
|
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
|
|
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div>
|
|
</div>
|
|
<div class="sess-tl-drawer" id="tdrawer-${s.id}">
|
|
<div class="sess-drawer" id="drawer-${s.id}">
|
|
<div class="sess-drawer-inner" id="drawer-inner-${s.id}"><div class="spinner"></div></div>
|
|
</div>
|
|
</div>`;
|
|
}).join('')}</div>`
|
|
).join('');
|
|
}
|
|
|
|
async function toggleDrawer(id) {
|
|
const drawerEl = document.getElementById('tdrawer-' + id);
|
|
const drawer = document.getElementById('drawer-' + id);
|
|
const trow = document.getElementById('trow-' + id);
|
|
if (openDrawerId && openDrawerId !== id) {
|
|
document.getElementById('tdrawer-' + openDrawerId)?.classList.remove('open');
|
|
document.getElementById('drawer-' + openDrawerId)?.classList.remove('open');
|
|
document.getElementById('trow-' + openDrawerId)?.classList.remove('open');
|
|
}
|
|
if (openDrawerId === id) {
|
|
drawerEl.classList.remove('open'); drawer.classList.remove('open'); trow.classList.remove('open');
|
|
openDrawerId = null; return;
|
|
}
|
|
openDrawerId = id; trow.classList.add('open');
|
|
drawerEl.classList.add('open');
|
|
requestAnimationFrame(() => drawer.classList.add('open'));
|
|
const inner = document.getElementById('drawer-inner-' + id);
|
|
if (inner.dataset.loaded) return;
|
|
inner.dataset.loaded = '1';
|
|
try {
|
|
const d = await LS.adminGetSessionDetail(id);
|
|
renderDrawer(inner, d);
|
|
} catch (e) { inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
|
|
}
|
|
|
|
function renderDrawer(el, d) {
|
|
const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx;
|
|
const pct = d.score !== null && d.total ? Math.round((d.score/d.total)*100) : null;
|
|
const pc = pctClass(pct);
|
|
const correct = d.questions.filter(q => q.is_correct).length;
|
|
const wrong = d.questions.filter(q => !q.is_correct && q.chosen_option_id).length;
|
|
const skipped = d.questions.filter(q => !q.chosen_option_id).length;
|
|
const qHtml = d.questions.map((q,i) => {
|
|
const status = !q.chosen_option_id ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
|
|
const badgeTxt = { correct:'Верно', wrong:'Неверно', skipped:'Пропущено' }[status];
|
|
const opts = q.options.map(o => {
|
|
const isCor = o.is_correct, isCho = o.id === q.chosen_option_id;
|
|
let cls='', icon='<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
|
|
if (isCor) { cls='correct-opt'; icon='<i data-lucide="check" style="width:13px;height:13px"></i>'; }
|
|
else if (isCho && !isCor) { cls='chosen-wrong'; icon='<i data-lucide="x" style="width:13px;height:13px"></i>'; }
|
|
return `<div class="qb-opt ${cls}"><span class="qb-opt-icon">${icon}</span>${esc(o.text)}</div>`;
|
|
}).join('');
|
|
const expl = q.explanation ? `<div class="qb-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
|
return `<div class="qb-item ${status}">
|
|
<div class="qb-header"><span class="qb-qnum">Вопрос ${i+1}</span><span class="qb-badge ${status}">${badgeTxt}</span><span class="qb-time">${q.time_spent_sec?q.time_spent_sec+' сек':''}</span></div>
|
|
<div class="qb-text">${esc(q.text)}</div>
|
|
<div class="qb-opts">${opts}</div>${expl}
|
|
</div>`;
|
|
}).join('');
|
|
el.innerHTML = `
|
|
<div class="drawer-header">
|
|
<div>
|
|
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:0.95rem">${esc(d.user_name)}</div>
|
|
<div class="drawer-meta">${esc(d.user_email)} · ${d.subject_name||'?'} · ${MODES[d.mode]||d.mode} · ${fmtDate(d.started_at)}</div>
|
|
</div>
|
|
<div class="drawer-score ${pc}">${pct !== null ? pct+'%' : '—'}</div>
|
|
<div style="display:flex;gap:20px;margin-left:auto;text-align:center">
|
|
<div><div style="font-family:'Unbounded',sans-serif;color:var(--green);font-weight:700">${correct}</div><div style="font-size:0.72rem;color:var(--text-3)">Верно</div></div>
|
|
<div><div style="font-family:'Unbounded',sans-serif;color:var(--pink);font-weight:700">${wrong}</div><div style="font-size:0.72rem;color:var(--text-3)">Неверно</div></div>
|
|
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-3);font-weight:700">${skipped}</div><div style="font-size:0.72rem;color:var(--text-3)">Пропущено</div></div>
|
|
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-2);font-weight:700">${fmtTime(d.duration_sec)}</div><div style="font-size:0.72rem;color:var(--text-3)">Время</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="qb-list">${qHtml||'<div class="empty">Вопросы не найдены</div>'}</div>`;
|
|
renderMath(el);
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
// Expose handlers
|
|
window.loadSessions = load;
|
|
window.renderSessions = renderSessions;
|
|
window.toggleDrawer = toggleDrawer;
|
|
|
|
window.AdminSections = window.AdminSections || {};
|
|
window.AdminSections.sessions = {
|
|
init: async () => { if (inited) return; inited = true; await load(); },
|
|
reload: load,
|
|
};
|
|
})();
|