feat(admin): phase 5 — per-row quick actions for users + sessions
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>
This commit is contained in:
@@ -7,6 +7,39 @@
|
||||
let _usersPage = 1;
|
||||
const _USERS_PER_PAGE = 50;
|
||||
|
||||
/* ── one-time CSS injection for hover row-actions (shared with sessions) ── */
|
||||
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);
|
||||
}
|
||||
|
||||
/* SVG icons (Lucide-style, 24x24 viewBox) */
|
||||
const ICONS = {
|
||||
ban: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>',
|
||||
unlock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>',
|
||||
coins: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg>',
|
||||
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></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>',
|
||||
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>',
|
||||
};
|
||||
|
||||
// user-panel + edit modal + perms modal state
|
||||
let activeTr = null;
|
||||
let activeUid = null;
|
||||
@@ -19,6 +52,7 @@
|
||||
const isAdmin = AdminCtx.isAdmin;
|
||||
const user = AdminCtx.user;
|
||||
if (page) _usersPage = page;
|
||||
ensureRowActionsStyles();
|
||||
try {
|
||||
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
|
||||
const users = r.users || [];
|
||||
@@ -58,7 +92,7 @@
|
||||
</td>
|
||||
<td style="color:var(--text-3);font-size:0.8rem">${fmtDate(u.created_at)}</td>
|
||||
<td style="color:var(--text-3);font-size:0.8rem">${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'}</td>
|
||||
<td style="text-align:right;color:var(--text-3);font-size:0.85rem;opacity:0.4">›</td>
|
||||
<td class="row-actions-cell">${renderUserRowActions(u, isAdmin && u.id !== user.id)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
|
||||
@@ -68,6 +102,106 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Per-row hover actions (Phase 5) ─── */
|
||||
function renderUserRowActions(u, canAct) {
|
||||
if (!canAct) {
|
||||
// Hide actions for non-admins or current user; keep arrow indicator as before
|
||||
return '<span style="color:var(--text-3);font-size:0.85rem;opacity:0.4">›</span>';
|
||||
}
|
||||
const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban;
|
||||
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
|
||||
return `<div class="row-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="row-action-btn" title="${banLabel}"
|
||||
onclick="event.stopPropagation();quickToggleBan(${u.id},${u.is_banned?1:0},this)">${banIcon}</button>
|
||||
<button type="button" class="row-action-btn" title="Начислить монеты"
|
||||
onclick="event.stopPropagation();quickAwardCoins(${u.id},'${esc(u.name).replace(/'/g, "\\'")}')">${ICONS.coins}</button>
|
||||
<button type="button" class="row-action-btn" title="История сессий"
|
||||
onclick="event.stopPropagation();quickOpenUserSessions(${u.id})">${ICONS.history}</button>
|
||||
<button type="button" class="row-action-btn danger" title="Удалить пользователя"
|
||||
onclick="event.stopPropagation();quickDeleteUser(${u.id},'${esc(u.name).replace(/'/g, "\\'")}',this)">${ICONS.trash}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function quickToggleBan(uid, isBanned, btn) {
|
||||
const action = isBanned ? 'Разблокировать' : 'Заблокировать';
|
||||
const msg = isBanned
|
||||
? 'Разблокировать пользователя? Он снова сможет войти в систему.'
|
||||
: 'Заблокировать пользователя? Он не сможет войти в систему.';
|
||||
if (!await LS.confirm(msg, { title: action, confirmText: action })) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await LS.adminBanUser(uid, !isBanned);
|
||||
LS.toast(isBanned ? 'Пользователь разблокирован' : 'Пользователь заблокирован', isBanned ? 'success' : 'warning');
|
||||
await load();
|
||||
if (activeUid === uid) await reloadUserPanel(uid);
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function quickAwardCoins(uid, name) {
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = `
|
||||
<p style="margin:0 0 14px;font-size:0.88rem;color:var(--text-2)">Начислить монеты пользователю <strong>${esc(name)}</strong>:</p>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<label style="font-size:0.78rem;font-weight:600;color:var(--text-3)">Количество монет
|
||||
<input id="qa-coins-amt" type="number" min="1" max="100000" value="100"
|
||||
style="display:block;margin-top:4px;width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.15);border-radius:10px;font-family:inherit;font-size:0.92rem">
|
||||
</label>
|
||||
<label style="font-size:0.78rem;font-weight:600;color:var(--text-3)">Причина (необязательно)
|
||||
<input id="qa-coins-reason" type="text" maxlength="200" placeholder="напр. награда за активность"
|
||||
style="display:block;margin-top:4px;width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.15);border-radius:10px;font-family:inherit;font-size:0.88rem">
|
||||
</label>
|
||||
</div>`;
|
||||
const m = LS.modal({
|
||||
title: 'Начислить монеты',
|
||||
content: body,
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{ label: 'Отмена', onClick: ({ close }) => close() },
|
||||
{ label: 'Начислить', primary: true, onClick: async ({ close, setError }) => {
|
||||
const amt = parseInt(body.querySelector('#qa-coins-amt').value, 10);
|
||||
const reason = body.querySelector('#qa-coins-reason').value.trim();
|
||||
if (!Number.isFinite(amt) || amt <= 0) { setError('Введите положительное количество монет'); return; }
|
||||
try {
|
||||
const r = await LS.adminShopAwardCoins({ userId: uid, amount: amt, reason });
|
||||
LS.toast(`Начислено ${amt} монет. Баланс: ${r.coins ?? '?'}`, 'success');
|
||||
close();
|
||||
} catch (e) { setError('Ошибка: ' + e.message); }
|
||||
} },
|
||||
],
|
||||
});
|
||||
setTimeout(() => body.querySelector('#qa-coins-amt')?.focus(), 80);
|
||||
}
|
||||
|
||||
function quickOpenUserSessions(uid) {
|
||||
// Phase 6 may extend to `#sessions?user=${uid}` (deep-link with prefilter);
|
||||
// for now just navigate to sessions tab.
|
||||
if (window.AdminRouter) AdminRouter.navigate('#sessions');
|
||||
else if (typeof window.switchTab === 'function') {
|
||||
const btn = document.querySelector('.admin-nav-item[onclick*="sessions"]');
|
||||
if (btn) window.switchTab(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function quickDeleteUser(uid, name, btn) {
|
||||
if (!await LS.confirm(
|
||||
`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
|
||||
{ title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }
|
||||
)) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await LS.adminDeleteUser(uid);
|
||||
LS.toast('Пользователь удалён', 'success');
|
||||
if (activeUid === uid) closeUserPanel();
|
||||
await load();
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gotoUsersPage(n) {
|
||||
_usersPage = n;
|
||||
load();
|
||||
@@ -334,6 +468,11 @@
|
||||
window.doSetUserPerm = doSetUserPerm;
|
||||
window.doResetOneUserPerm = doResetOneUserPerm;
|
||||
window.doResetAllUserPerms = doResetAllUserPerms;
|
||||
// Phase 5 quick actions
|
||||
window.quickToggleBan = quickToggleBan;
|
||||
window.quickAwardCoins = quickAwardCoins;
|
||||
window.quickOpenUserSessions = quickOpenUserSessions;
|
||||
window.quickDeleteUser = quickDeleteUser;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.users = {
|
||||
|
||||
Reference in New Issue
Block a user