69113ab35e
Hover-only action buttons (right-aligned, opacity transition, hidden on mobile). - users.js: 4 actions (ban/unban, award coins, sessions, delete) — replaces `>` glyph cell, falls back to glyph for non-admin / self - sessions.js: 2 actions (view, delete) - DELETE /api/admin/sessions/:id (NEW): transactional (assignment_sessions=NULL, user_answers, session_questions, test_sessions), audit-logged, admin-only - event.stopPropagation defence-in-depth (each button + parent .row-actions) - LS.confirm for destructive ops; LS.modal for award-coins amount/reason - CSS injected once via #row-actions-style id-dedup (same content in both sections) Existing user-panel overlay + session toggle-drawer flows untouched (Phase 6 removes overlay). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
12 KiB
JavaScript
214 lines
12 KiB
JavaScript
'use strict';
|
|
/* admin → sessions section: sessions timeline + drawer detail */
|
|
(function () {
|
|
'use strict';
|
|
let inited = false;
|
|
|
|
let allSessions = [];
|
|
let openDrawerId = null;
|
|
|
|
/* SVG icons (Lucide-style) — kept local to mirror users.js without coupling */
|
|
const SESS_ICONS = {
|
|
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
|
|
};
|
|
|
|
/* Inject .row-actions / .row-action-btn styles only if users.js hasn't (sessions can render first). */
|
|
function ensureRowActionsStyles() {
|
|
if (document.getElementById('row-actions-style')) return;
|
|
const s = document.createElement('style');
|
|
s.id = 'row-actions-style';
|
|
s.textContent = `
|
|
.row-actions { opacity: 0; transition: opacity .15s ease; display: inline-flex; gap: 4px; vertical-align: middle; }
|
|
tr:hover .row-actions, .sess-tl-item:hover .row-actions { opacity: 1; }
|
|
tr.selected .row-actions, .sess-tl-item.open .row-actions { opacity: 1; }
|
|
.row-action-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); transition: background .12s ease, border-color .12s ease, color .12s ease; padding: 0; }
|
|
.row-action-btn:hover { background: rgba(155,93,229,.08); border-color: var(--violet); color: var(--violet); }
|
|
.row-action-btn:focus-visible { outline: 2px solid var(--violet); outline-offset: 1px; }
|
|
.row-action-btn.danger:hover { background: rgba(239,68,68,.08); border-color: var(--red, #EF4444); color: var(--red, #EF4444); }
|
|
.row-action-btn svg { width: 14px; height: 14px; pointer-events: none; }
|
|
.row-action-btn:disabled { opacity: .5; cursor: wait; }
|
|
.row-actions-cell { text-align: right; white-space: nowrap; padding-right: 12px; }
|
|
@media (max-width: 768px) {
|
|
.row-actions { display: none; }
|
|
}
|
|
`;
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
async function load() {
|
|
const subject = document.getElementById('t-subject').value;
|
|
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
|
|
openDrawerId = null;
|
|
ensureRowActionsStyles();
|
|
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 class="row-actions" onclick="event.stopPropagation()">
|
|
<button type="button" class="row-action-btn" title="Открыть детали"
|
|
onclick="event.stopPropagation();toggleDrawer(${s.id})">${SESS_ICONS.eye}</button>
|
|
<button type="button" class="row-action-btn danger" title="Удалить сессию"
|
|
onclick="event.stopPropagation();quickDeleteSession(${s.id},this)">${SESS_ICONS.trash}</button>
|
|
</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();
|
|
}
|
|
|
|
async function quickDeleteSession(id, btn) {
|
|
if (!await LS.confirm(
|
|
'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
|
|
{ title: 'Удалить сессию', confirmText: 'Удалить' }
|
|
)) return;
|
|
btn.disabled = true;
|
|
try {
|
|
await LS.adminDeleteSession(id);
|
|
LS.toast('Сессия удалена', 'success');
|
|
// Refresh from server — keeps grouped layout consistent.
|
|
await load();
|
|
} catch (e) {
|
|
LS.toast('Ошибка: ' + e.message, 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Expose handlers
|
|
window.loadSessions = load;
|
|
window.renderSessions = renderSessions;
|
|
window.toggleDrawer = toggleDrawer;
|
|
window.quickDeleteSession = quickDeleteSession;
|
|
|
|
window.AdminSections = window.AdminSections || {};
|
|
window.AdminSections.sessions = {
|
|
init: async () => { if (inited) return; inited = true; await load(); },
|
|
reload: load,
|
|
};
|
|
})();
|