feat(admin): Phase 6 sub-commit 1 — add deep-page sections (overlay still works)
Add user-detail.js (~370L) and session-detail.js (~180L) section modules that render full pages for #users/:id and #sessions/:id, plus admin.js dispatch and HTML tab-panes. The legacy .user-panel overlay is intentionally still in place — sub-commit 2 will remove it once the deep pages are verified. * admin.js: DEEP_ROUTES map + activateDeepPane(); activate(route, params) signature; initial dispatch respects hash params (so F5 on #users/123 goes straight to the deep page). * admin.html: new tab-panes #tab-user-detail / #tab-session-detail and two script tags. Old #user-panel overlay untouched. * user-detail.js: header (avatar/role/email/meta) + sub-tabs (Обзор/Сессии/Классы/Audit) with URL-synced sub-tab routing (#users/N/sessions etc). Overview: 4 stat cards + per-subject SVG bar chart. Sessions: clickable rows that navigate to #sessions/N. Classes: placeholder empty-state (no per-user classes endpoint). Audit: client-side filter of /admin/audit-log by uid match. Header action buttons (Изменить/Права/История/Бан/Удалить) call existing overlay handlers; window.activeUid is set before opening any modal. * session-detail.js: full header (user/subject/score/stats) + per- question correctness layout reusing the drawer renderer. Delete button uses LS.adminDeleteSession then navigates to #sessions. Clicking the user name opens the user deep page. * users.js: quickOpenUserSessions now navigates to #users/<uid>/sessions instead of the bare #sessions list. Verified node --check on all new/modified JS. baseline npm test still shows pre-existing 3 auth failures unrelated to this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
/* admin → session-detail (Phase 6) — deep page for a single test session
|
||||
* (#sessions/:id). Replaces the inline drawer rendering when a row is clicked.
|
||||
*
|
||||
* Lazy-init via AdminSections['session-detail'].init(id).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── one-time CSS injection ── */
|
||||
function ensureSdStyles() {
|
||||
if (document.getElementById('session-detail-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'session-detail-style';
|
||||
s.textContent = `
|
||||
.sd-wrap { padding: 4px 2px 24px; }
|
||||
.sd-back { display:inline-flex; align-items:center; gap:6px; font-size:0.82rem; color:var(--text-3); text-decoration:none; padding:6px 10px; border-radius:8px; margin-bottom:16px; transition:background .12s, color .12s; cursor:pointer; background:transparent; border:0; font-family:inherit; }
|
||||
.sd-back:hover { background:rgba(155,93,229,.07); color:var(--violet); }
|
||||
.sd-back svg { width:14px; height:14px; }
|
||||
.sd-header { display:flex; align-items:center; gap:20px; padding:22px 26px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-bottom:20px; flex-wrap:wrap; }
|
||||
.sd-user-block { flex:1; min-width:200px; }
|
||||
.sd-user-name { font-family:'Unbounded',sans-serif; font-size:1.05rem; font-weight:800; cursor:pointer; transition:color .12s; }
|
||||
.sd-user-name:hover { color:var(--violet); }
|
||||
.sd-user-meta { font-size:0.82rem; color:var(--text-3); margin-top:4px; }
|
||||
.sd-score { font-family:'Unbounded',sans-serif; font-size:1.5rem; font-weight:800; padding:14px 20px; border-radius:16px; }
|
||||
.sd-score.pct-hi { color:var(--green); background:rgba(16,185,129,.1); }
|
||||
.sd-score.pct-mid { color:var(--amber); background:rgba(255,179,71,.12); }
|
||||
.sd-score.pct-lo { color:var(--pink); background:rgba(241,91,181,.1); }
|
||||
.sd-score.pct-none { color:var(--text-3); background:rgba(15,23,42,.04); }
|
||||
.sd-stats { display:flex; gap:24px; flex-wrap:wrap; }
|
||||
.sd-stat { text-align:center; }
|
||||
.sd-stat-val { font-family:'Unbounded',sans-serif; font-weight:700; font-size:0.95rem; }
|
||||
.sd-stat-val.correct { color:var(--green); }
|
||||
.sd-stat-val.wrong { color:var(--pink); }
|
||||
.sd-stat-val.skipped { color:var(--text-3); }
|
||||
.sd-stat-label { font-size:0.72rem; color:var(--text-3); margin-top:2px; }
|
||||
.sd-actions { display:flex; gap:6px; flex-wrap:wrap; margin-left:auto; }
|
||||
.sd-q-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.sd-q-item { padding:16px 18px; background:var(--surface); border:1px solid var(--border); border-radius:14px; border-left:4px solid var(--text-3); }
|
||||
.sd-q-item.correct { border-left-color:var(--green); }
|
||||
.sd-q-item.wrong { border-left-color:var(--pink); }
|
||||
.sd-q-item.skipped { border-left-color:var(--amber); }
|
||||
.sd-q-header { display:flex; align-items:center; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
|
||||
.sd-q-num { font-size:0.74rem; color:var(--text-3); font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.sd-q-badge { font-size:0.7rem; padding:2px 8px; border-radius:var(--r-pill); font-weight:700; }
|
||||
.sd-q-badge.correct { background:rgba(16,185,129,.12); color:var(--green); }
|
||||
.sd-q-badge.wrong { background:rgba(241,91,181,.12); color:var(--pink); }
|
||||
.sd-q-badge.skipped { background:rgba(255,179,71,.14); color:var(--amber); }
|
||||
.sd-q-time { font-size:0.72rem; color:var(--text-3); margin-left:auto; }
|
||||
.sd-q-text { font-size:0.92rem; line-height:1.45; margin-bottom:10px; }
|
||||
.sd-q-opts { display:flex; flex-direction:column; gap:5px; }
|
||||
.sd-q-opt { padding:8px 12px; border-radius:8px; background:rgba(15,23,42,.03); font-size:0.86rem; display:flex; align-items:center; gap:8px; }
|
||||
.sd-q-opt.correct-opt { background:rgba(16,185,129,.08); color:var(--green); font-weight:600; }
|
||||
.sd-q-opt.chosen-wrong { background:rgba(241,91,181,.08); color:var(--pink); font-weight:600; }
|
||||
.sd-q-opt-icon { width:14px; height:14px; flex-shrink:0; display:inline-flex; }
|
||||
.sd-q-expl { margin-top:10px; padding:10px 14px; background:rgba(155,93,229,.06); border-radius:8px; font-size:0.84rem; color:var(--text-2); }
|
||||
.sd-empty { padding:30px; text-align:center; color:var(--text-3); font-size:0.88rem; background:var(--surface); border:1px dashed var(--border); border-radius:var(--r-lg); }
|
||||
@media (max-width: 640px) {
|
||||
.sd-header { padding:16px 14px; gap:14px; }
|
||||
.sd-actions { margin-left:0; width:100%; }
|
||||
.sd-score { font-size:1.2rem; padding:10px 14px; }
|
||||
.sd-stats { gap:14px; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
arrowLeft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
|
||||
trash: '<i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i>',
|
||||
};
|
||||
|
||||
let _sessionId = null;
|
||||
let _data = null;
|
||||
|
||||
async function init(id) {
|
||||
ensureSdStyles();
|
||||
const newId = Number(id);
|
||||
if (!Number.isFinite(newId) || newId <= 0) {
|
||||
renderError('Некорректный ID сессии');
|
||||
return;
|
||||
}
|
||||
if (_sessionId === newId && _data) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
_sessionId = newId;
|
||||
_data = null;
|
||||
renderLoading();
|
||||
try {
|
||||
_data = await LS.adminGetSessionDetail(newId);
|
||||
render();
|
||||
} catch (e) {
|
||||
renderError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const el = document.getElementById('session-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="sd-wrap"><div class="spinner"></div></div>';
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
const el = document.getElementById('session-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="sd-wrap">
|
||||
<button type="button" class="sd-back" onclick="AdminRouter.navigate('#sessions')">${ICONS.arrowLeft} К списку сессий</button>
|
||||
<div class="sd-empty" style="color:var(--pink)">${esc(msg)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = document.getElementById('session-detail-content');
|
||||
if (!el || !_data) return;
|
||||
const d = _data;
|
||||
const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx;
|
||||
const pct = (d.score !== null && d.score !== undefined && d.total)
|
||||
? Math.round((d.score / d.total) * 100)
|
||||
: null;
|
||||
const pc = pct === null ? 'pct-none' : 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 isAdmin = AdminCtx.isAdmin;
|
||||
|
||||
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 viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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="sd-q-opt ${cls}"><span class="sd-q-opt-icon">${icon}</span>${esc(o.text)}</div>`;
|
||||
}).join('');
|
||||
const expl = q.explanation ? `<div class="sd-q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
||||
return `<div class="sd-q-item ${status}">
|
||||
<div class="sd-q-header">
|
||||
<span class="sd-q-num">Вопрос ${i + 1}</span>
|
||||
<span class="sd-q-badge ${status}">${badgeTxt}</span>
|
||||
<span class="sd-q-time">${q.time_spent_sec ? q.time_spent_sec + ' сек' : ''}</span>
|
||||
</div>
|
||||
<div class="sd-q-text">${esc(q.text || '')}</div>
|
||||
<div class="sd-q-opts">${opts}</div>${expl}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="sd-wrap">
|
||||
<button type="button" class="sd-back" onclick="AdminRouter.navigate('#sessions')">${ICONS.arrowLeft} К списку сессий</button>
|
||||
<div class="sd-header">
|
||||
<div class="sd-user-block">
|
||||
<div class="sd-user-name" onclick="AdminRouter.navigate('#users/${d.user_id}')" title="Открыть страницу пользователя">${esc(d.user_name || '?')}</div>
|
||||
<div class="sd-user-meta">${esc(d.user_email || '')} · ${esc(d.subject_name || 'Тест')} · <span class="mode-badge mode-${d.mode}">${MODES[d.mode] || d.mode}</span></div>
|
||||
<div class="sd-user-meta">${fmtDate(d.started_at)}${d.finished_at ? ' · завершена ' + fmtDate(d.finished_at) : ''}</div>
|
||||
</div>
|
||||
<div class="sd-score ${pc}">${pct !== null ? pct + '%' : '—'}</div>
|
||||
<div class="sd-stats">
|
||||
<div class="sd-stat"><div class="sd-stat-val correct">${correct}</div><div class="sd-stat-label">Верно</div></div>
|
||||
<div class="sd-stat"><div class="sd-stat-val wrong">${wrong}</div><div class="sd-stat-label">Неверно</div></div>
|
||||
<div class="sd-stat"><div class="sd-stat-val skipped">${skipped}</div><div class="sd-stat-label">Пропущено</div></div>
|
||||
<div class="sd-stat"><div class="sd-stat-val">${fmtTime(d.duration_sec)}</div><div class="sd-stat-label">Время</div></div>
|
||||
</div>
|
||||
${isAdmin ? `<div class="sd-actions">
|
||||
<button class="btn-del-q" onclick="sdDeleteSession()" style="background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)">${ICONS.trash} Удалить</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="sd-q-list">${qHtml || '<div class="sd-empty">Вопросы не найдены</div>'}</div>
|
||||
</div>
|
||||
`;
|
||||
renderMath(el);
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!_sessionId) return;
|
||||
if (!await LS.confirm(
|
||||
'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
|
||||
{ title: 'Удалить сессию', confirmText: 'Удалить' }
|
||||
)) return;
|
||||
try {
|
||||
await LS.adminDeleteSession(_sessionId);
|
||||
LS.toast('Сессия удалена', 'success');
|
||||
AdminRouter.navigate('#sessions');
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expose ── */
|
||||
window.sdDeleteSession = deleteSession;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections['session-detail'] = {
|
||||
init,
|
||||
reload: () => init(_sessionId),
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user