Merge feature/admin-redesign: admin SPA redesign (6 phases) + security fixes
Hash-router, 14 per-section modules, dashboard #overview, Cmd+K palette, per-row quick actions, deep entity pages. admin.js: 3500L → ~700L. Includes stored-XSS fix in user-name onclick interpolation, and SVG-as-text rendering fixes in 6 lab simulations. Squashed phase commits preserved (--no-ff merge).
This commit is contained in:
@@ -30,6 +30,103 @@ function getStats(_req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Overview (Phase 3 dashboard) — prepared statements ───────────────── */
|
||||
const overviewStmts = {
|
||||
newUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at >= datetime('now', '-24 hours')"),
|
||||
newSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours')"),
|
||||
activeUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= datetime('now', '-24 hours')"),
|
||||
failedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status != 'completed'"),
|
||||
activeClasses: db.prepare('SELECT COUNT(*) AS n FROM classes'),
|
||||
// No banned_at column — fall back to audit log for recent bans (last 7 days)
|
||||
bannedThisWeek: db.prepare(`
|
||||
SELECT u.id, u.name, u.email, al.created_at AS banned_at
|
||||
FROM admin_audit_log al
|
||||
JOIN users u ON u.id = CAST(SUBSTR(al.target, 6) AS INTEGER)
|
||||
WHERE al.action = 'user.ban'
|
||||
AND al.created_at >= datetime('now', '-7 days')
|
||||
AND u.is_banned = 1
|
||||
GROUP BY u.id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT 10
|
||||
`),
|
||||
topSessions24h: db.prepare(`
|
||||
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
|
||||
ts.score, ts.total,
|
||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
|
||||
ts.finished_at
|
||||
FROM test_sessions ts
|
||||
JOIN users u ON u.id = ts.user_id
|
||||
LEFT JOIN subjects s ON s.id = ts.subject_id
|
||||
WHERE ts.status = 'completed'
|
||||
AND ts.finished_at >= datetime('now', '-24 hours')
|
||||
AND ts.total > 0
|
||||
ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC
|
||||
LIMIT 5
|
||||
`),
|
||||
};
|
||||
|
||||
/* ── GET /api/admin/overview ──────────────────────────────────────────── */
|
||||
function getOverview(_req, res) {
|
||||
try {
|
||||
res.json({
|
||||
newUsers24h: overviewStmts.newUsers24h.get().n,
|
||||
newSessions24h: overviewStmts.newSessions24h.get().n,
|
||||
activeUsers24h: overviewStmts.activeUsers24h.get().n,
|
||||
activeClasses: overviewStmts.activeClasses.get().n,
|
||||
failedSessions24h: overviewStmts.failedSessions24h.get().n,
|
||||
bannedThisWeek: overviewStmts.bannedThisWeek.all(),
|
||||
topSessions24h: overviewStmts.topSessions24h.all(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global search (Phase 4 command palette) — prepared statements ────── */
|
||||
const searchStmts = {
|
||||
users: db.prepare(`
|
||||
SELECT id, name, email, role
|
||||
FROM users
|
||||
WHERE name LIKE ? OR email LIKE ?
|
||||
ORDER BY (CASE WHEN name LIKE ? THEN 0 ELSE 1 END), id DESC
|
||||
LIMIT 5
|
||||
`),
|
||||
tests: db.prepare(`
|
||||
SELECT id, title AS name, subject_slug
|
||||
FROM tests
|
||||
WHERE title LIKE ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 3
|
||||
`),
|
||||
classes: db.prepare(`
|
||||
SELECT id, name, invite_code AS code
|
||||
FROM classes
|
||||
WHERE name LIKE ? OR invite_code LIKE ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 3
|
||||
`),
|
||||
};
|
||||
|
||||
/* ── GET /api/admin/search?q=X ────────────────────────────────────────── */
|
||||
function globalSearch(req, res) {
|
||||
const q = (req.query.q || '').trim();
|
||||
if (q.length < 2) {
|
||||
return res.json({ users: [], tests: [], classes: [] });
|
||||
}
|
||||
try {
|
||||
const like = `%${q}%`;
|
||||
const prefix = `${q}%`;
|
||||
res.json({
|
||||
users: searchStmts.users.all(like, like, prefix),
|
||||
tests: searchStmts.tests.all(like),
|
||||
classes: searchStmts.classes.all(like, like),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[admin.search]', err.message);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */
|
||||
function getUsers(req, res) {
|
||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
|
||||
@@ -196,6 +293,33 @@ function getSessionDetail(req, res) {
|
||||
res.json(session);
|
||||
}
|
||||
|
||||
/* ── DELETE /api/admin/sessions/:id ──────────────────────────────────── */
|
||||
const _deleteSessionTx = db.transaction((sid) => {
|
||||
// assignment_sessions references test_sessions with ON DELETE SET NULL,
|
||||
// but we explicitly null it so the assignment slot stays usable.
|
||||
db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid);
|
||||
// user_answers / session_questions cascade via ON DELETE CASCADE,
|
||||
// but delete explicitly for visibility and to mirror clearUserSessions().
|
||||
db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid);
|
||||
db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid);
|
||||
db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid);
|
||||
});
|
||||
|
||||
function deleteSession(req, res, next) {
|
||||
const sid = Number(req.params.id);
|
||||
if (!Number.isInteger(sid) || sid <= 0)
|
||||
return res.status(400).json({ error: 'Invalid session id' });
|
||||
try {
|
||||
const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid);
|
||||
if (!sess) return res.status(404).json({ error: 'Session not found' });
|
||||
_deleteSessionTx(sid);
|
||||
audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */
|
||||
function clearUserSessions(req, res, next) {
|
||||
const uid = Number(req.params.id);
|
||||
@@ -539,8 +663,9 @@ function broadcast(req, res) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStats, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, updateUser, banUser, deleteUser,
|
||||
getStats, getOverview, globalSearch,
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
|
||||
@@ -14,6 +14,8 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
|
||||
router.use(requireRole('admin'));
|
||||
|
||||
router.get('/stats', ctrl.getStats);
|
||||
router.get('/overview', ctrl.getOverview);
|
||||
router.get('/search', ctrl.globalSearch);
|
||||
router.get('/users', ctrl.getUsers);
|
||||
router.patch('/users/:id/role', ctrl.updateRole);
|
||||
router.get('/users/:id/sessions', ctrl.getUserSessions);
|
||||
@@ -24,6 +26,7 @@ router.patch('/users/:id/ban', ctrl.banUser);
|
||||
router.delete('/users/:id', ctrl.deleteUser);
|
||||
router.get('/sessions', ctrl.getAllSessions);
|
||||
router.get('/sessions/:id', ctrl.getSessionDetail);
|
||||
router.delete('/sessions/:id', ctrl.deleteSession);
|
||||
|
||||
/* Audit log */
|
||||
router.get('/audit-log', ctrl.getAuditLog);
|
||||
|
||||
+43
-27
@@ -159,12 +159,8 @@
|
||||
.pct-mid { color: var(--amber); }
|
||||
.pct-lo { color: var(--pink); }
|
||||
|
||||
/* user panel */
|
||||
.user-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 32px; box-shadow: var(--shadow); display: none; }
|
||||
.user-panel.visible { display: block; }
|
||||
.user-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
|
||||
.user-panel-name { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; }
|
||||
.user-panel-email { font-size: 0.92rem; color: var(--text-3); margin-top: 3px; }
|
||||
/* Legacy .user-panel overlay was removed in Phase 6 — the deep page
|
||||
(#users/:id) replaces it. .btn-close kept for use elsewhere if any. */
|
||||
.btn-close { padding: 8px 18px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
|
||||
.btn-close:hover { border-color: var(--pink); color: var(--pink); }
|
||||
.sess-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
@@ -579,10 +575,6 @@
|
||||
.q-modal-title { font-size: 0.9rem; margin-bottom: 20px; }
|
||||
.form-row-2, .form-row-3 { grid-template-columns: 1fr; }
|
||||
|
||||
/* User panel */
|
||||
.user-panel { padding: 18px 14px; }
|
||||
.user-panel-header { flex-wrap: wrap; gap: 10px; }
|
||||
|
||||
/* Session drawer */
|
||||
.sess-drawer-inner { padding: 16px 12px; }
|
||||
.drawer-header { gap: 10px; }
|
||||
@@ -919,7 +911,10 @@
|
||||
<svg class="adm-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="admin-nav-body">
|
||||
<button class="admin-nav-item active" data-tab="stats" onclick="switchTab(this)">
|
||||
<button class="admin-nav-item active" data-tab="overview" onclick="switchTab(this)">
|
||||
<i data-lucide="layout-dashboard" style="width:15px;height:15px"></i> Обзор
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="stats" onclick="switchTab(this)">
|
||||
<i data-lucide="bar-chart-2" style="width:15px;height:15px"></i> Статистика
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
|
||||
@@ -1016,8 +1011,13 @@
|
||||
</nav>
|
||||
<div class="admin-main">
|
||||
|
||||
<!-- ── Обзор (Phase 3) ── -->
|
||||
<div class="tab-pane active" id="tab-overview">
|
||||
<div id="overview-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Статистика ── -->
|
||||
<div class="tab-pane active" id="tab-stats">
|
||||
<div class="tab-pane" id="tab-stats">
|
||||
<div class="section-title">Общая статистика</div>
|
||||
<div class="stats-grid" id="stats-grid"><div class="spinner"></div></div>
|
||||
<div class="section-title">По предметам</div>
|
||||
@@ -1118,21 +1118,17 @@
|
||||
</table>
|
||||
</div>
|
||||
<div id="users-pagination" class="pgn-bar" style="display:none"></div>
|
||||
<div class="user-panel" id="user-panel">
|
||||
<div class="user-panel-header">
|
||||
<div><div class="user-panel-name" id="up-name"></div><div class="user-panel-email" id="up-email"></div></div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn-edit-q" id="up-edit-btn" onclick="openEditUserModal()" style="display:none"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
|
||||
<button class="btn-edit-q" id="up-perms-btn" onclick="openUserPermsModal()" style="display:none"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>
|
||||
<button class="btn-del-q" id="up-clear-btn" onclick="clearUserHistory()" style="display:none"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
|
||||
<button class="btn-del-q" id="up-ban-btn" onclick="toggleBanUser()" style="display:none"><i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i> <span id="up-ban-label">Заблокировать</span></button>
|
||||
<button class="btn-del-q" id="up-delete-btn" onclick="confirmDeleteUser()" style="display:none;background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)"><i data-lucide="user-x" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button>
|
||||
<button class="btn-close" onclick="closeUserPanel()"><i data-lucide="x" style="width:13px;height:13px;vertical-align:-2px"></i> Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">История тестов</div>
|
||||
<div id="up-sessions"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<!-- Phase 6: legacy .user-panel overlay removed; deep page renders into #tab-user-detail above. -->
|
||||
</div>
|
||||
|
||||
<!-- ── Deep page: user detail (#users/:id) — populated by user-detail.js ── -->
|
||||
<div class="tab-pane" id="tab-user-detail">
|
||||
<div id="user-detail-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Deep page: session detail (#sessions/:id) — populated by session-detail.js ── -->
|
||||
<div class="tab-pane" id="tab-session-detail">
|
||||
<div id="session-detail-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Тесты (сессии) ── -->
|
||||
@@ -1981,6 +1977,26 @@
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/admin/router.js"></script>
|
||||
<script src="/js/admin/_shared.js"></script>
|
||||
<script src="/js/admin/sections/overview.js"></script>
|
||||
<script src="/js/admin/sections/stats.js"></script>
|
||||
<script src="/js/admin/sections/sublog.js"></script>
|
||||
<script src="/js/admin/sections/sims.js"></script>
|
||||
<script src="/js/admin/sections/games.js"></script>
|
||||
<script src="/js/admin/sections/tpl.js"></script>
|
||||
<script src="/js/admin/sections/subjects.js"></script>
|
||||
<script src="/js/admin/sections/permissions.js"></script>
|
||||
<script src="/js/admin/sections/shop.js"></script>
|
||||
<script src="/js/admin/sections/gam.js"></script>
|
||||
<script src="/js/admin/sections/assignments.js"></script>
|
||||
<script src="/js/admin/sections/tests.js"></script>
|
||||
<script src="/js/admin/sections/questions.js"></script>
|
||||
<script src="/js/admin/sections/users.js"></script>
|
||||
<script src="/js/admin/sections/sessions.js"></script>
|
||||
<script src="/js/admin/sections/user-detail.js"></script>
|
||||
<script src="/js/admin/sections/session-detail.js"></script>
|
||||
<script src="/js/admin/palette.js"></script>
|
||||
<script src="/js/admin/admin.js"></script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
/* Admin shared helpers — referenced by admin.js orchestrator + every section module.
|
||||
* Exposed on window.AdminCtx (filled by admin.js after LS.initPage()) and
|
||||
* on window directly for utility functions used by HTML onclicks.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ─── Constants ─── */
|
||||
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
|
||||
const DIFFS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
|
||||
const DIFF_LABELS = DIFFS;
|
||||
const TYPE_LABELS = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Краткий', matching:'Сопоставление' };
|
||||
|
||||
/* ─── Generic formatters ─── */
|
||||
function pctClass(p) { return p === null ? '' : p >= 75 ? 'pct-hi' : p >= 50 ? 'pct-mid' : 'pct-lo'; }
|
||||
function fmtDate(d) { return new Date(d).toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }); }
|
||||
function fmtTime(sec) {
|
||||
if (!sec || sec < 0) return '—';
|
||||
const m = Math.floor(sec / 60), s = sec % 60;
|
||||
return m ? `${m} мин ${s} сек` : `${s} сек`;
|
||||
}
|
||||
function fmtDuration(sec) {
|
||||
if (!sec || sec < 0) return '—';
|
||||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||||
if (h) return `${h}ч ${m}м`;
|
||||
if (m) return `${m} мин ${s} сек`;
|
||||
return `${s} сек`;
|
||||
}
|
||||
|
||||
/* ─── KaTeX rendering ─── */
|
||||
const KATEX_OPTS = {
|
||||
delimiters: [
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
],
|
||||
throwOnError: false,
|
||||
};
|
||||
function renderMath(el) {
|
||||
if (!el) return;
|
||||
const run = () => { if (window.renderMathInElement) renderMathInElement(el, KATEX_OPTS); };
|
||||
if (window._katexReady) run(); else window._katexCb = run;
|
||||
}
|
||||
|
||||
/* ─── Question type badges (used by tests + subjects sections) ─── */
|
||||
function qTypeBadge(type) {
|
||||
const MAP = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Ответ', matching:'Сопост.' };
|
||||
const CLR = { single:'rgba(155,93,229,0.12)', multi:'rgba(6,214,224,0.12)', true_false:'rgba(255,179,71,0.14)', short_answer:'rgba(6,214,100,0.12)', matching:'rgba(241,91,181,0.10)' };
|
||||
const TXT = { single:'var(--violet)', multi:'#05aab3', true_false:'var(--amber)', short_answer:'var(--green)', matching:'var(--pink)' };
|
||||
return `<span class="tst-q-badge" style="background:${CLR[type]||'rgba(15,23,42,0.06)'};color:${TXT[type]||'var(--text-3)'}">${MAP[type]||type}</span>`;
|
||||
}
|
||||
|
||||
function qOptsPreview(q) {
|
||||
if (q.type === 'short_answer') return q.correct_text ? `<span class="tst-q-opts">Ответ: ${esc(q.correct_text)}</span>` : '';
|
||||
if (!q.options?.length) return '';
|
||||
const correct = q.options.filter(o => o.is_correct).map(o => esc(o.text)).join(', ');
|
||||
return `<span class="tst-q-opts"><i data-lucide="check" style="width:12px;height:12px;vertical-align:-2px"></i> ${correct}</span>`;
|
||||
}
|
||||
|
||||
/* ─── Pagination controls (users + future tables) ─── */
|
||||
function ensurePgnStyles() {
|
||||
if (document.getElementById('pgn-bar-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'pgn-bar-style';
|
||||
s.textContent = `
|
||||
.pgn-bar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:14px 4px 4px; font-size:0.85rem; color:var(--text-3); }
|
||||
.pgn-info { font-weight:600; }
|
||||
.pgn-ctrls { display:flex; align-items:center; gap:4px; }
|
||||
.pgn-btn { min-width:32px; height:32px; padding:0 10px; border:1px solid var(--border); background:var(--surface); border-radius:8px; cursor:pointer; font-weight:600; font-family:inherit; font-size:0.85rem; color:var(--text-2); transition:background .12s, color .12s, border-color .12s; }
|
||||
.pgn-btn:hover:not(:disabled) { background:rgba(155,93,229,.08); color:var(--violet); border-color:rgba(155,93,229,.3); }
|
||||
.pgn-btn.active { background:var(--violet); color:#fff; border-color:var(--violet); }
|
||||
.pgn-btn:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.pgn-ellip { padding:0 6px; color:var(--text-3); }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function renderPgnControls(elId, page, total, perPage, gotoFn) {
|
||||
const bar = document.getElementById(elId);
|
||||
if (!bar) return;
|
||||
const pages = Math.max(1, Math.ceil(total / perPage));
|
||||
if (pages <= 1) { bar.style.display = 'none'; return; }
|
||||
ensurePgnStyles();
|
||||
const from = (page - 1) * perPage + 1;
|
||||
const to = Math.min(page * perPage, total);
|
||||
const nums = new Set([1, pages, page, page - 1, page + 1, page - 2, page + 2]);
|
||||
const sorted = [...nums].filter(n => n >= 1 && n <= pages).sort((a, b) => a - b);
|
||||
const numHtml = sorted.map((n, i) => {
|
||||
const prev = sorted[i - 1];
|
||||
const gap = prev && n - prev > 1 ? '<span class="pgn-ellip">…</span>' : '';
|
||||
return `${gap}<button class="pgn-btn${n === page ? ' active' : ''}" onclick="${gotoFn}(${n})">${n}</button>`;
|
||||
}).join('');
|
||||
bar.innerHTML = `
|
||||
<div class="pgn-info">${from}–${to} из ${total}</div>
|
||||
<div class="pgn-ctrls">
|
||||
<button class="pgn-btn" onclick="${gotoFn}(${page - 1})" ${page <= 1 ? 'disabled' : ''}>←</button>
|
||||
${numHtml}
|
||||
<button class="pgn-btn" onclick="${gotoFn}(${page + 1})" ${page >= pages ? 'disabled' : ''}>→</button>
|
||||
</div>`;
|
||||
bar.style.display = '';
|
||||
}
|
||||
|
||||
/* ─── Export ─── */
|
||||
window.AdminCtx = window.AdminCtx || {
|
||||
// filled by admin.js after LS.initPage():
|
||||
user: null,
|
||||
isTeacher: false,
|
||||
isAdmin: false,
|
||||
// constants:
|
||||
MODES,
|
||||
DIFFS,
|
||||
DIFF_LABELS,
|
||||
TYPE_LABELS,
|
||||
// formatters:
|
||||
pctClass,
|
||||
fmtDate,
|
||||
fmtTime,
|
||||
fmtDuration,
|
||||
// rendering:
|
||||
renderMath,
|
||||
qTypeBadge,
|
||||
qOptsPreview,
|
||||
// pagination:
|
||||
renderPgnControls,
|
||||
ensurePgnStyles,
|
||||
};
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
})();
|
||||
+704
-3515
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
'use strict';
|
||||
/* admin → Cmd+K / Ctrl+K command palette (Phase 4).
|
||||
* Self-initialized on DOMContentLoaded. Not a section — it's a global widget.
|
||||
* Overrides the generic /js/search.js Ctrl+K handler on admin pages by binding
|
||||
* in capture phase and calling stopImmediatePropagation.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── Hardcoded actions ────────────────────────────────────────────────── */
|
||||
const ACTIONS = [
|
||||
{ id: 'award_coins', name: 'Выдать монеты', hint: 'shop', icon: 'coins', go: () => navigateTo('#shop') },
|
||||
{ id: 'award_xp', name: 'Выдать XP', hint: 'gam', icon: 'zap', go: () => navigateTo('#gam') },
|
||||
{ id: 'new_class', name: 'Создать класс', hint: 'classes', icon: 'plus-circle', go: () => { window.location.href = '/classes'; } },
|
||||
{ id: 'new_test', name: 'Создать тест', hint: 'tests', icon: 'file-plus', go: () => navigateTo('#tests') },
|
||||
{ id: 'view_users', name: 'Все пользователи', hint: 'users', icon: 'users', go: () => navigateTo('#users') },
|
||||
{ id: 'view_sessions', name: 'Все сессии', hint: 'sessions', icon: 'history', go: () => navigateTo('#sessions') },
|
||||
{ id: 'view_audit', name: 'Audit log', hint: 'sublog', icon: 'shield', go: () => navigateTo('#sublog') },
|
||||
{ id: 'view_overview', name: 'Главная', hint: 'overview', icon: 'layout-dashboard', go: () => navigateTo('#overview') },
|
||||
];
|
||||
|
||||
/* ── State ────────────────────────────────────────────────────────────── */
|
||||
let _overlay = null;
|
||||
let _input = null;
|
||||
let _results = null;
|
||||
let _timer = null;
|
||||
let _items = []; // flat list of result items in display order
|
||||
let _activeIdx = 0;
|
||||
let _lastQuery = '';
|
||||
let _reqSeq = 0; // race-guard for async fetches
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
||||
function navigateTo(hash) {
|
||||
if (window.AdminRouter) AdminRouter.navigate(hash);
|
||||
else window.location.hash = hash;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return (window.LS && LS.esc) ? LS.esc(s) : String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!(_overlay && _overlay.classList.contains('open'));
|
||||
}
|
||||
|
||||
/* ── Styles (lazy injection) ──────────────────────────────────────────── */
|
||||
function ensureStyles() {
|
||||
if (document.getElementById('akp-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'akp-style';
|
||||
s.textContent = `
|
||||
.akp-ov { position: fixed; inset: 0; z-index: 9500;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding: 96px 20px 20px;
|
||||
background: rgba(15,23,42,0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .15s ease; }
|
||||
.akp-ov.open { opacity: 1; pointer-events: auto; }
|
||||
.akp-box { width: 100%; max-width: 600px;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border, rgba(15,23,42,.08));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 80px rgba(15,23,42,0.32);
|
||||
display: flex; flex-direction: column;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow: hidden;
|
||||
transform: translateY(-8px) scale(.98);
|
||||
transition: transform .18s ease; }
|
||||
.akp-ov.open .akp-box { transform: translateY(0) scale(1); }
|
||||
.akp-input-wrap { display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border, rgba(15,23,42,.08)); }
|
||||
.akp-input-wrap svg.akp-search-icon { width: 18px; height: 18px;
|
||||
color: var(--text-3, #64748b); flex-shrink: 0; }
|
||||
.akp-input { flex: 1; border: none; outline: none; background: transparent;
|
||||
font-family: inherit; font-size: 1.05rem; color: var(--text, #0F172A);
|
||||
padding: 4px 0; }
|
||||
.akp-input::placeholder { color: var(--text-3, #94a3b8); }
|
||||
.akp-kbd { font-family: ui-monospace, monospace; font-size: .7rem;
|
||||
background: rgba(15,23,42,.06); color: var(--text-3, #64748b);
|
||||
padding: 2px 6px; border-radius: 5px; border: 1px solid var(--border, rgba(15,23,42,.08));
|
||||
flex-shrink: 0; }
|
||||
.akp-results { flex: 1; overflow-y: auto; padding: 6px 0; }
|
||||
.akp-group-label { font-family: 'Unbounded', sans-serif;
|
||||
font-size: .68rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; color: var(--text-3, #64748b);
|
||||
padding: 10px 18px 6px; }
|
||||
.akp-item { display: flex; align-items: center; gap: 12px;
|
||||
padding: 9px 18px; cursor: pointer; user-select: none;
|
||||
border-left: 3px solid transparent; }
|
||||
.akp-item:hover, .akp-item.active {
|
||||
background: rgba(155,93,229,.08); border-left-color: var(--violet, #9B5DE5); }
|
||||
.akp-icon { width: 30px; height: 30px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(155,93,229,.1); color: var(--violet, #9B5DE5);
|
||||
flex-shrink: 0; }
|
||||
.akp-icon svg { width: 16px; height: 16px; }
|
||||
.akp-body { flex: 1; min-width: 0; }
|
||||
.akp-title { font-size: .92rem; font-weight: 600; color: var(--text, #0F172A);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.akp-sub { font-size: .76rem; color: var(--text-3, #64748b);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
.akp-badge { font-size: .68rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .04em; color: var(--text-3, #64748b);
|
||||
padding: 2px 7px; border-radius: 5px;
|
||||
background: rgba(15,23,42,.06); border: 1px solid var(--border, rgba(15,23,42,.08));
|
||||
flex-shrink: 0; }
|
||||
.akp-badge.role-admin { background: rgba(241,91,181,.1); color: var(--pink, #F15BB5); border-color: rgba(241,91,181,.25); }
|
||||
.akp-badge.role-teacher { background: rgba(6,214,224,.1); color: var(--cyan, #06D6E0); border-color: rgba(6,214,224,.25); }
|
||||
.akp-badge.role-student { background: rgba(155,93,229,.1); color: var(--violet,#9B5DE5); border-color: rgba(155,93,229,.25); }
|
||||
.akp-empty { padding: 22px 18px; text-align: center;
|
||||
color: var(--text-3, #64748b); font-size: .88rem; }
|
||||
.akp-footer { display: flex; align-items: center; gap: 14px;
|
||||
padding: 9px 18px; font-size: .72rem; color: var(--text-3, #64748b);
|
||||
border-top: 1px solid var(--border, rgba(15,23,42,.08));
|
||||
background: rgba(15,23,42,.02); flex-shrink: 0; }
|
||||
.akp-footer span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.akp-footer kbd { font-family: ui-monospace, monospace; font-size: .7rem;
|
||||
padding: 1px 5px; border-radius: 4px;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border, rgba(15,23,42,.12)); }
|
||||
@media (max-width: 540px) {
|
||||
.akp-ov { padding: 40px 12px 12px; }
|
||||
.akp-box { max-height: calc(100vh - 60px); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
/* ── Lucide icon helper (inline SVG fallback if Lucide missing) ───────── */
|
||||
function iconHtml(name) {
|
||||
return `<i data-lucide="${esc(name)}"></i>`;
|
||||
}
|
||||
|
||||
/* ── Build DOM ────────────────────────────────────────────────────────── */
|
||||
function build() {
|
||||
if (_overlay) return;
|
||||
ensureStyles();
|
||||
_overlay = document.createElement('div');
|
||||
_overlay.className = 'akp-ov';
|
||||
_overlay.setAttribute('role', 'dialog');
|
||||
_overlay.setAttribute('aria-modal', 'true');
|
||||
_overlay.setAttribute('aria-label', 'Командная палитра');
|
||||
_overlay.innerHTML = `
|
||||
<div class="akp-box" role="presentation">
|
||||
<div class="akp-input-wrap">
|
||||
<svg class="akp-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input class="akp-input" type="text" autocomplete="off" spellcheck="false"
|
||||
placeholder="Поиск: пользователь, тест, класс, действие…" />
|
||||
<span class="akp-kbd">esc</span>
|
||||
</div>
|
||||
<div class="akp-results"></div>
|
||||
<div class="akp-footer">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> навигация</span>
|
||||
<span><kbd>↵</kbd> выбрать</span>
|
||||
<span><kbd>esc</kbd> закрыть</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(_overlay);
|
||||
|
||||
_input = _overlay.querySelector('.akp-input');
|
||||
_results = _overlay.querySelector('.akp-results');
|
||||
|
||||
// Click outside (backdrop) closes
|
||||
_overlay.addEventListener('mousedown', (e) => {
|
||||
if (e.target === _overlay) close();
|
||||
});
|
||||
|
||||
// Box click does not close (handled by stopPropagation on the box)
|
||||
_overlay.querySelector('.akp-box').addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Input handling
|
||||
_input.addEventListener('input', () => {
|
||||
clearTimeout(_timer);
|
||||
_timer = setTimeout(runSearch, 150);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
_input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const it = _items[_activeIdx];
|
||||
if (it) fire(it);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Click on result
|
||||
_results.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('.akp-item');
|
||||
if (!row) return;
|
||||
const idx = Number(row.dataset.idx);
|
||||
const it = _items[idx];
|
||||
if (it) fire(it);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Matching / filtering ─────────────────────────────────────────────── */
|
||||
function filterActions(q) {
|
||||
if (!q) return ACTIONS.slice();
|
||||
const lq = q.toLowerCase();
|
||||
return ACTIONS.filter(a =>
|
||||
a.name.toLowerCase().includes(lq) ||
|
||||
(a.hint && a.hint.toLowerCase().includes(lq))
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Fire (handle result selection) ───────────────────────────────────── */
|
||||
function fire(item) {
|
||||
close();
|
||||
try {
|
||||
if (item.kind === 'action' && typeof item.go === 'function') {
|
||||
item.go();
|
||||
} else if (item.kind === 'user') {
|
||||
navigateTo('#users/' + item.id);
|
||||
} else if (item.kind === 'test') {
|
||||
navigateTo('#tests');
|
||||
} else if (item.kind === 'class') {
|
||||
window.location.href = '/classes#' + item.id;
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.LS && LS.toast) LS.toast('Не удалось открыть: ' + (err && err.message || err), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────────────────── */
|
||||
function render(groups) {
|
||||
_items = [];
|
||||
let html = '';
|
||||
let idx = 0;
|
||||
|
||||
function pushGroup(label, arr, makeItem) {
|
||||
if (!arr || !arr.length) return;
|
||||
html += `<div class="akp-group-label">${esc(label)}</div>`;
|
||||
for (const x of arr) {
|
||||
const item = makeItem(x);
|
||||
_items.push(item);
|
||||
const isActive = idx === _activeIdx ? ' active' : '';
|
||||
html += `<div class="akp-item${isActive}" data-idx="${idx}">
|
||||
<div class="akp-icon">${iconHtml(item.icon)}</div>
|
||||
<div class="akp-body">
|
||||
<div class="akp-title">${esc(item.title)}</div>
|
||||
${item.subtitle ? `<div class="akp-sub">${esc(item.subtitle)}</div>` : ''}
|
||||
</div>
|
||||
${item.badge ? `<span class="akp-badge ${item.badgeClass || ''}">${esc(item.badge)}</span>` : ''}
|
||||
</div>`;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
pushGroup('Действия', groups.actions, a => ({
|
||||
kind: 'action', id: a.id, title: a.name, icon: a.icon, go: a.go,
|
||||
}));
|
||||
pushGroup('Пользователи', groups.users, u => ({
|
||||
kind: 'user', id: u.id, title: u.name || '(без имени)',
|
||||
subtitle: u.email || '', icon: 'user',
|
||||
badge: u.role || '', badgeClass: u.role ? ('role-' + u.role) : '',
|
||||
}));
|
||||
pushGroup('Тесты', groups.tests, t => ({
|
||||
kind: 'test', id: t.id, title: t.name || '(без названия)',
|
||||
subtitle: t.subject_slug ? ('предмет: ' + t.subject_slug) : '',
|
||||
icon: 'clipboard-list',
|
||||
}));
|
||||
pushGroup('Классы', groups.classes, c => ({
|
||||
kind: 'class', id: c.id, title: c.name || '(без названия)',
|
||||
subtitle: c.code ? ('код: ' + c.code) : '',
|
||||
icon: 'graduation-cap',
|
||||
}));
|
||||
|
||||
if (!_items.length) {
|
||||
_results.innerHTML = '<div class="akp-empty">Ничего не найдено</div>';
|
||||
_activeIdx = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeIdx >= _items.length) _activeIdx = 0;
|
||||
_results.innerHTML = html;
|
||||
|
||||
if (window.lucide) {
|
||||
try { lucide.createIcons({ nodes: _results.querySelectorAll('[data-lucide]') }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function moveActive(dir) {
|
||||
const total = _items.length;
|
||||
if (!total) return;
|
||||
_activeIdx = (_activeIdx + dir + total) % total;
|
||||
// Re-paint active class without rebuilding html
|
||||
const rows = _results.querySelectorAll('.akp-item');
|
||||
rows.forEach((r, i) => r.classList.toggle('active', i === _activeIdx));
|
||||
const cur = rows[_activeIdx];
|
||||
if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
/* ── Search executor ──────────────────────────────────────────────────── */
|
||||
async function runSearch() {
|
||||
const q = _input.value.trim();
|
||||
_lastQuery = q;
|
||||
_activeIdx = 0;
|
||||
|
||||
// No query: actions only
|
||||
if (q.length < 2) {
|
||||
render({ actions: ACTIONS, users: [], tests: [], classes: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const localActions = filterActions(q);
|
||||
// Show actions immediately, then update with server results
|
||||
render({ actions: localActions, users: [], tests: [], classes: [] });
|
||||
|
||||
const seq = ++_reqSeq;
|
||||
try {
|
||||
const data = (window.LS && LS.adminGlobalSearch)
|
||||
? await LS.adminGlobalSearch(q)
|
||||
: { users: [], tests: [], classes: [] };
|
||||
// Stale response: ignore
|
||||
if (seq !== _reqSeq || q !== _lastQuery) return;
|
||||
render({
|
||||
actions: localActions,
|
||||
users: data.users || [],
|
||||
tests: data.tests || [],
|
||||
classes: data.classes || [],
|
||||
});
|
||||
} catch (err) {
|
||||
if (seq !== _reqSeq) return;
|
||||
_results.innerHTML = '<div class="akp-empty">Ошибка поиска</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Open / close ─────────────────────────────────────────────────────── */
|
||||
function open() {
|
||||
build();
|
||||
_activeIdx = 0;
|
||||
_items = [];
|
||||
_input.value = '';
|
||||
render({ actions: ACTIONS, users: [], tests: [], classes: [] });
|
||||
_overlay.classList.add('open');
|
||||
setTimeout(() => { try { _input.focus(); _input.select(); } catch {} }, 30);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!_overlay) return;
|
||||
_overlay.classList.remove('open');
|
||||
}
|
||||
|
||||
/* ── Global shortcut: Ctrl+K / Cmd+K ──────────────────────────────────── */
|
||||
// Capture phase + stopImmediatePropagation prevents the generic /js/search.js
|
||||
// handler (also on Ctrl+K) from firing on admin pages.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (isOpen()) close(); else open();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Expose for debugging / future cross-section calls
|
||||
window.AdminPalette = { open, close, isOpen };
|
||||
})();
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
/* AdminRouter — hash-based router for admin panel.
|
||||
* Wraps the existing switchTab() flow without replacing it.
|
||||
*
|
||||
* Hash format: #<route>[/<param1>[/<param2>...]]
|
||||
* #stats → { route: 'stats', params: [] }
|
||||
* #users → { route: 'users', params: [] }
|
||||
* #users/123 → { route: 'users', params: ['123'] }
|
||||
* #sessions/456/foo → { route: 'sessions', params: ['456','foo'] }
|
||||
*
|
||||
* Public API: window.AdminRouter
|
||||
* parse(hash) → route object
|
||||
* current() → route object for location.hash
|
||||
* navigate(hash, { replace, silent })
|
||||
* on(event, fn) / off(event, fn) — 'change' event only
|
||||
*
|
||||
* Recursion guard: programmatic navigate() sets a flag so the hashchange
|
||||
* listener does not re-emit 'change' for our own writes.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const listeners = { change: new Set() };
|
||||
let _navigating = false;
|
||||
|
||||
function parse(hash) {
|
||||
const raw = String(hash || '');
|
||||
const stripped = raw.charAt(0) === '#' ? raw.slice(1) : raw;
|
||||
const parts = stripped.split('/').filter(Boolean);
|
||||
const route = parts.length ? decodeURIComponent(parts[0]) : '';
|
||||
const params = parts.slice(1).map(p => {
|
||||
try { return decodeURIComponent(p); } catch { return p; }
|
||||
});
|
||||
return { route, params, raw: raw || '' };
|
||||
}
|
||||
|
||||
function current() {
|
||||
return parse(location.hash);
|
||||
}
|
||||
|
||||
function normalizeHash(input) {
|
||||
const s = String(input || '');
|
||||
if (!s) return '';
|
||||
return s.charAt(0) === '#' ? s : '#' + s;
|
||||
}
|
||||
|
||||
function emit(event, payload) {
|
||||
const set = listeners[event];
|
||||
if (!set) return;
|
||||
set.forEach(fn => {
|
||||
try { fn(payload); } catch (e) { console.error('AdminRouter listener error', e); }
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(routeOrHash, opts) {
|
||||
const options = opts || {};
|
||||
const target = normalizeHash(routeOrHash);
|
||||
const currentHash = location.hash || '';
|
||||
|
||||
// Same hash → no-op (avoids spurious listener fires).
|
||||
if (target === currentHash) {
|
||||
if (!options.silent) emit('change', { ...parse(target), silent: false });
|
||||
return;
|
||||
}
|
||||
|
||||
_navigating = true;
|
||||
try {
|
||||
if (options.replace && typeof history !== 'undefined' && history.replaceState) {
|
||||
// Preserve current path/query, swap hash without adding history entry.
|
||||
const url = location.pathname + location.search + target;
|
||||
history.replaceState(history.state, '', url);
|
||||
} else {
|
||||
location.hash = target;
|
||||
}
|
||||
} finally {
|
||||
// hashchange fires async; clear flag after dispatch.
|
||||
setTimeout(() => { _navigating = false; }, 0);
|
||||
}
|
||||
|
||||
if (!options.silent) {
|
||||
emit('change', { ...parse(target), silent: false });
|
||||
}
|
||||
}
|
||||
|
||||
function on(event, fn) {
|
||||
if (!listeners[event] || typeof fn !== 'function') return;
|
||||
listeners[event].add(fn);
|
||||
}
|
||||
|
||||
function off(event, fn) {
|
||||
if (!listeners[event]) return;
|
||||
listeners[event].delete(fn);
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (_navigating) return;
|
||||
emit('change', { ...current(), silent: false });
|
||||
});
|
||||
|
||||
window.AdminRouter = { parse, current, navigate, on, off };
|
||||
})();
|
||||
@@ -0,0 +1,477 @@
|
||||
'use strict';
|
||||
/* admin → assignments section (классные/индивидуальные задания) */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
let allAssignments = [];
|
||||
let editingAId = null;
|
||||
const SUBJ_NAMES = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||||
const SUBJ_COLORS_A = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
|
||||
const SUBJ_ICONS_A = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
|
||||
let _aFilter = 'all';
|
||||
|
||||
// create-modal state
|
||||
let _afSrc = 'random';
|
||||
let _afLoadedTests = [];
|
||||
let _acSrc = 'random';
|
||||
let _acTarget = 'class';
|
||||
let _acFileId = null, _acAllFiles = null;
|
||||
let _acStudentId = null, _acAllStudents = null;
|
||||
|
||||
async function load() {
|
||||
document.getElementById('a-body').innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
allAssignments = await LS.teacherAssignments();
|
||||
renderAssignments();
|
||||
} catch (e) {
|
||||
document.getElementById('a-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setAFilter(f) {
|
||||
_aFilter = f;
|
||||
document.querySelectorAll('.a-f-chip').forEach(c =>
|
||||
c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',overdue:'Просрочены',done:'Завершены'}[f])
|
||||
);
|
||||
renderAssignments();
|
||||
}
|
||||
|
||||
function aClassify(a) {
|
||||
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
|
||||
if (pct === 100) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < new Date()) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function renderAssignments() {
|
||||
const { MODES, pctClass } = AdminCtx;
|
||||
const subjF = document.getElementById('a-subject').value;
|
||||
const searchF = document.getElementById('a-search').value.toLowerCase();
|
||||
const sortF = document.getElementById('a-sort')?.value || 'date';
|
||||
|
||||
let list = allAssignments.filter(a => {
|
||||
if (subjF && a.subject_slug !== subjF) return false;
|
||||
if (searchF && !a.title.toLowerCase().includes(searchF)) return false;
|
||||
if (_aFilter === 'active' && aClassify(a) !== 'active') return false;
|
||||
if (_aFilter === 'overdue' && aClassify(a) !== 'overdue') return false;
|
||||
if (_aFilter === 'done' && aClassify(a) !== 'done') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
list = [...list].sort((a, b) => {
|
||||
if (sortF === 'deadline') {
|
||||
const da = a.deadline ? new Date(a.deadline) : new Date(9e15);
|
||||
const db = b.deadline ? new Date(b.deadline) : new Date(9e15);
|
||||
return da - db;
|
||||
}
|
||||
if (sortF === 'progress_asc') {
|
||||
const pa = a.total_members ? a.completed_count / a.total_members : 0;
|
||||
const pb = b.total_members ? b.completed_count / b.total_members : 0;
|
||||
return pa - pb;
|
||||
}
|
||||
if (sortF === 'progress_desc') {
|
||||
const pa = a.total_members ? a.completed_count / a.total_members : 0;
|
||||
const pb = b.total_members ? b.completed_count / b.total_members : 0;
|
||||
return pb - pa;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const all = allAssignments;
|
||||
const nActive = all.filter(a => aClassify(a) === 'active').length;
|
||||
const nOverdue = all.filter(a => aClassify(a) === 'overdue').length;
|
||||
const nDone = all.filter(a => aClassify(a) === 'done').length;
|
||||
document.getElementById('a-summary').innerHTML = [
|
||||
`<span class="a-sum-chip s-all">Всего: ${all.length}</span>`,
|
||||
nActive ? `<span class="a-sum-chip s-active">Активных: ${nActive}</span>` : '',
|
||||
nOverdue ? `<span class="a-sum-chip s-overdue">Просрочено: ${nOverdue}</span>` : '',
|
||||
nDone ? `<span class="a-sum-chip s-done">Завершено: ${nDone}</span>` : '',
|
||||
].join('');
|
||||
|
||||
document.getElementById('a-count').textContent = `${list.length} заданий`;
|
||||
const container = document.getElementById('a-body');
|
||||
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<div class="empty">Заданий нет</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
container.innerHTML = list.map(a => {
|
||||
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
|
||||
const cls = aClassify(a);
|
||||
const rowCls = cls === 'overdue' ? 'a-overdue' : cls === 'done' ? 'a-done' : '';
|
||||
const sColor = SUBJ_COLORS_A[a.subject_slug] || '#9B5DE5';
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - now : Infinity;
|
||||
const isUrgent = cls === 'active' && dlMs > 0 && dlMs < 24 * 3600 * 1000;
|
||||
|
||||
const dl = a.deadline
|
||||
? new Date(a.deadline).toLocaleDateString('ru', {day:'numeric', month:'short'})
|
||||
: null;
|
||||
|
||||
const targetStr = a.target_user_id
|
||||
? esc(a.target_user_name || 'Ученик')
|
||||
: esc(a.class_name || '—');
|
||||
|
||||
const metaParts = [
|
||||
targetStr,
|
||||
SUBJ_NAMES[a.subject_slug] || a.subject_slug,
|
||||
`<span class="mode-badge mode-${a.mode}">${MODES[a.mode]||a.mode}</span>`,
|
||||
a.count + ' вопр.',
|
||||
dl ? `до ${dl}` : null,
|
||||
isUrgent ? `<span class="a-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> срочно</span>` : null,
|
||||
cls === 'overdue' ? `<span class="a-tag-over">просрочено</span>` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const barColor = pct >= 75 ? '#06D6A0' : pct >= 40 ? '#F59E0B' : '#F15BB5';
|
||||
const pctLabel = pct !== null ? `${pct}%` : '—';
|
||||
|
||||
return `<div class="a-row ${rowCls}${isUrgent ? ' a-urgent' : ''}" style="--ac:${sColor}">
|
||||
<div class="a-icon" style="background:${sColor}18;color:${sColor}"><i data-lucide="${SUBJ_ICONS_A[a.subject_slug]||'file-text'}" style="width:18px;height:18px"></i></div>
|
||||
<div class="a-main">
|
||||
<div class="a-title">${esc(a.title)}</div>
|
||||
<div class="a-meta">${metaParts.join(' · ')}</div>
|
||||
</div>
|
||||
<div class="a-prog">
|
||||
<div class="a-prog-nums">
|
||||
<span>${a.completed_count} / ${a.total_members} сдали</span>
|
||||
<span class="a-prog-pct ${pctClass(pct)}">${pctLabel}</span>
|
||||
</div>
|
||||
<div class="a-prog-bar">
|
||||
<div class="a-prog-fill" style="width:${pct||0}%;background:${barColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-actions">
|
||||
<button class="btn-edit-q" onclick="openAModal(${a.id})">Изменить</button>
|
||||
<button class="btn-del-q" onclick="deleteAsgn(${a.id})"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function setAfSrc(src) {
|
||||
_afSrc = src;
|
||||
document.querySelectorAll('[data-afsrc]').forEach(b => b.classList.toggle('active', b.dataset.afsrc === src));
|
||||
document.getElementById('af-random-fields').style.display = src === 'random' ? '' : 'none';
|
||||
document.getElementById('af-test-fields').style.display = src === 'test' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function openAModal(id) {
|
||||
const a = allAssignments.find(x => x.id === id);
|
||||
if (!a) return;
|
||||
editingAId = id;
|
||||
document.getElementById('a-modal-title').textContent = `Редактировать: ${a.title}`;
|
||||
document.getElementById('af-title').value = a.title;
|
||||
document.getElementById('af-deadline').value = a.deadline ? a.deadline.split('T')[0] : '';
|
||||
document.getElementById('af-error').textContent = '';
|
||||
|
||||
const testSel = document.getElementById('af-test');
|
||||
testSel.innerHTML = '<option value="">Загрузка…</option>';
|
||||
try {
|
||||
_afLoadedTests = await LS.getTests();
|
||||
testSel.innerHTML = _afLoadedTests.length
|
||||
? '<option value="">— выберите тест —</option>' + _afLoadedTests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
|
||||
: '<option value="">Нет тестов</option>';
|
||||
} catch {
|
||||
testSel.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
_afLoadedTests = [];
|
||||
}
|
||||
|
||||
if (a.test_id) {
|
||||
setAfSrc('test');
|
||||
testSel.value = a.test_id;
|
||||
document.getElementById('af-mode-test').value = a.mode;
|
||||
} else {
|
||||
setAfSrc('random');
|
||||
document.getElementById('af-subject').value = a.subject_slug;
|
||||
document.getElementById('af-mode').value = a.mode;
|
||||
document.getElementById('af-count').value = a.count;
|
||||
}
|
||||
|
||||
document.getElementById('a-modal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('af-title').focus(), 80);
|
||||
}
|
||||
|
||||
function closeAModal() {
|
||||
document.getElementById('a-modal').classList.remove('open');
|
||||
editingAId = null;
|
||||
}
|
||||
|
||||
async function saveAssignment() {
|
||||
const title = document.getElementById('af-title').value.trim();
|
||||
const deadline = document.getElementById('af-deadline').value || null;
|
||||
const errEl = document.getElementById('af-error');
|
||||
errEl.textContent = '';
|
||||
if (!title) { errEl.textContent = 'Введите название'; return; }
|
||||
|
||||
let payload = { title, deadline };
|
||||
|
||||
if (_afSrc === 'test') {
|
||||
const test_id = document.getElementById('af-test').value;
|
||||
const mode = document.getElementById('af-mode-test').value;
|
||||
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
|
||||
const testObj = _afLoadedTests.find(t => t.id === Number(test_id));
|
||||
if (testObj && testObj.question_count === 0) { errEl.textContent = 'В выбранном тесте нет вопросов'; return; }
|
||||
payload = { ...payload, test_id: Number(test_id), mode };
|
||||
} else {
|
||||
const subject_slug = document.getElementById('af-subject').value;
|
||||
const mode = document.getElementById('af-mode').value;
|
||||
const count = Number(document.getElementById('af-count').value);
|
||||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||||
if (!count || count < 1) { errEl.textContent = 'Введите количество вопросов'; return; }
|
||||
payload = { ...payload, subject_slug, mode, count, test_id: null };
|
||||
}
|
||||
|
||||
const btn = document.getElementById('af-save');
|
||||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||||
try {
|
||||
await LS.updateAssignment(editingAId, payload);
|
||||
const idx = allAssignments.findIndex(x => x.id === editingAId);
|
||||
if (idx !== -1) Object.assign(allAssignments[idx], payload);
|
||||
closeAModal();
|
||||
renderAssignments();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Create assignment modal ─── */
|
||||
function setAcTarget(t) {
|
||||
_acTarget = t;
|
||||
document.querySelectorAll('[data-actgt]').forEach(b => b.classList.toggle('active', b.dataset.actgt === t));
|
||||
document.getElementById('acf-class-field').style.display = t === 'class' ? '' : 'none';
|
||||
document.getElementById('acf-user-field').style.display = t === 'user' ? '' : 'none';
|
||||
if (t === 'user' && !_acAllStudents) loadAcStudents();
|
||||
}
|
||||
|
||||
async function loadAcStudents() {
|
||||
const drop = document.getElementById('acf-student-drop');
|
||||
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Загрузка…</div>';
|
||||
drop.style.display = '';
|
||||
try {
|
||||
_acAllStudents = await LS.getStudentsList();
|
||||
openAcStudentDrop();
|
||||
} catch(e) {
|
||||
_acAllStudents = [];
|
||||
drop.innerHTML = `<div style="padding:8px 12px;font-size:13px;color:#ef4444">Ошибка загрузки: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAcStudents(q) {
|
||||
openAcStudentDrop(q);
|
||||
}
|
||||
|
||||
function openAcStudentDrop(q) {
|
||||
const drop = document.getElementById('acf-student-drop');
|
||||
if (_acAllStudents === null) { loadAcStudents(); return; }
|
||||
const list = _acAllStudents;
|
||||
const term = (q !== undefined ? q : document.getElementById('acf-student-search').value).toLowerCase().trim();
|
||||
const filtered = term ? list.filter(s => s.name.toLowerCase().includes(term) || s.email.toLowerCase().includes(term)) : list;
|
||||
if (!filtered.length) {
|
||||
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Нет учеников</div>';
|
||||
drop.style.display = '';
|
||||
return;
|
||||
}
|
||||
drop.innerHTML = filtered.slice(0, 50).map(s =>
|
||||
`<div style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #f3f4f6;font-size:13px" data-id="${s.id}" data-name="${esc(s.name)}" data-email="${esc(s.email)}" onmousedown="selectAcStudent(+this.dataset.id,this.dataset.name,this.dataset.email)" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background=''">${esc(s.name)} <span style="color:#9ca3af">${esc(s.email)}</span></div>`
|
||||
).join('');
|
||||
drop.style.display = '';
|
||||
}
|
||||
|
||||
function closeAcStudentDrop() {
|
||||
document.getElementById('acf-student-drop').style.display = 'none';
|
||||
}
|
||||
|
||||
function selectAcStudent(id, name, email) {
|
||||
_acStudentId = id;
|
||||
document.getElementById('acf-student-search').value = name;
|
||||
document.getElementById('acf-student-selected').textContent = `${name} (${email})`;
|
||||
document.getElementById('acf-student-selected').style.display = '';
|
||||
closeAcStudentDrop();
|
||||
}
|
||||
|
||||
function setAcSrc(src) {
|
||||
_acSrc = src;
|
||||
document.querySelectorAll('[data-src]').forEach(b => b.classList.toggle('active', b.dataset.src === src));
|
||||
document.getElementById('acf-random-fields').style.display = src === 'random' ? '' : 'none';
|
||||
document.getElementById('acf-test-fields').style.display = src === 'test' ? '' : 'none';
|
||||
document.getElementById('acf-file-fields').style.display = src === 'file' ? '' : 'none';
|
||||
if (src === 'file' && !_acAllFiles) loadAcFiles();
|
||||
}
|
||||
|
||||
async function loadAcFiles() {
|
||||
try {
|
||||
_acAllFiles = await LS.getFiles();
|
||||
renderAcFiles('');
|
||||
} catch { _acAllFiles = []; }
|
||||
}
|
||||
|
||||
function renderAcFiles(q) {
|
||||
const el = document.getElementById('acf-file-list');
|
||||
if (!_acAllFiles) { el.innerHTML = '<div style="padding:10px;color:var(--text-3);font-size:.82rem;text-align:center">Загрузка…</div>'; return; }
|
||||
const lq = q.toLowerCase();
|
||||
const items = q ? _acAllFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _acAllFiles;
|
||||
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||||
if (!items.length) { el.innerHTML = '<div style="padding:10px;color:var(--text-3);font-size:.82rem;text-align:center">Нет файлов</div>'; return; }
|
||||
el.innerHTML = items.map(f => `
|
||||
<div onclick="selectAcFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')"
|
||||
style="padding:9px 12px;cursor:pointer;border-bottom:1px solid rgba(15,23,42,0.07);display:flex;align-items:center;gap:8px;${_acFileId===f.id?'background:rgba(155,93,229,0.08);':''} transition:background .15s">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:.84rem;font-weight:600">${esc(f.title||'Файл')}</div>
|
||||
<div style="font-size:.74rem;color:var(--text-3)">${SUBJ[f.subject_slug]||f.subject_slug||''}</div>
|
||||
</div>
|
||||
${_acFileId===f.id ? '<span style="color:var(--violet)"><i data-lucide="check" style="width:15px;height:15px"></i></span>' : ''}
|
||||
</div>`).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function filterAcFiles(q) { renderAcFiles(q); }
|
||||
|
||||
function selectAcFile(id, title, subject_slug) {
|
||||
_acFileId = id;
|
||||
renderAcFiles(document.getElementById('acf-file-search').value);
|
||||
const sel = document.getElementById('acf-file-selected');
|
||||
sel.textContent = 'Выбран: ' + title;
|
||||
sel.style.display = '';
|
||||
}
|
||||
|
||||
async function openCreateAModal() {
|
||||
_acSrc = 'random'; _acTarget = 'class'; _acFileId = null; _acStudentId = null; _acAllStudents = null;
|
||||
setAcSrc('random');
|
||||
setAcTarget('class');
|
||||
loadAcStudents();
|
||||
document.getElementById('acf-title').value = '';
|
||||
document.getElementById('acf-subject').value = '';
|
||||
document.getElementById('acf-mode').value = 'exam';
|
||||
document.getElementById('acf-mode-test').value = 'exam';
|
||||
document.getElementById('acf-count').value = '25';
|
||||
document.getElementById('acf-deadline').value = '';
|
||||
document.getElementById('acf-student-search').value = '';
|
||||
document.getElementById('acf-student-selected').style.display = 'none';
|
||||
_acStudentId = null;
|
||||
document.getElementById('acf-error').textContent = '';
|
||||
document.getElementById('acf-file-search').value = '';
|
||||
document.getElementById('acf-file-selected').style.display = 'none';
|
||||
|
||||
const [clsSel, testSel] = [document.getElementById('acf-class'), document.getElementById('acf-test')];
|
||||
clsSel.innerHTML = '<option value="">Загрузка…</option>';
|
||||
testSel.innerHTML = '<option value="">Загрузка…</option>';
|
||||
|
||||
const [classesP, testsP] = await Promise.allSettled([LS.getClasses(), LS.getTests()]);
|
||||
|
||||
if (classesP.status === 'fulfilled') {
|
||||
const classes = classesP.value;
|
||||
clsSel.innerHTML = classes.length
|
||||
? '<option value="">— выберите класс —</option>' + classes.map(c => `<option value="${c.id}">${esc(c.name)} (${c.member_count} уч.)</option>`).join('')
|
||||
: '<option value="">Нет классов — создайте класс</option>';
|
||||
} else {
|
||||
clsSel.innerHTML = `<option value="">Ошибка загрузки классов</option>`;
|
||||
}
|
||||
|
||||
if (testsP.status === 'fulfilled') {
|
||||
const tests = testsP.value;
|
||||
testSel.innerHTML = tests.length
|
||||
? '<option value="">— выберите тест —</option>' + tests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
|
||||
: '<option value="">Нет тестов — создайте тест</option>';
|
||||
} else {
|
||||
testSel.innerHTML = `<option value="">Ошибка загрузки тестов</option>`;
|
||||
}
|
||||
|
||||
document.getElementById('ac-modal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('acf-title').focus(), 80);
|
||||
}
|
||||
|
||||
function closeCreateAModal() {
|
||||
document.getElementById('ac-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function saveNewAssignment() {
|
||||
const title = document.getElementById('acf-title').value.trim();
|
||||
const deadline = document.getElementById('acf-deadline').value || null;
|
||||
const errEl = document.getElementById('acf-error');
|
||||
errEl.textContent = '';
|
||||
if (!title) { errEl.textContent = 'Введите название'; return; }
|
||||
|
||||
let payload = { title, deadline };
|
||||
|
||||
if (_acSrc === 'file') {
|
||||
if (!_acFileId) { errEl.textContent = 'Выберите файл из библиотеки'; return; }
|
||||
const f = _acAllFiles.find(x => x.id === _acFileId);
|
||||
payload = { ...payload, file_id: _acFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1 };
|
||||
} else if (_acSrc === 'test') {
|
||||
const test_id = document.getElementById('acf-test').value;
|
||||
const mode = document.getElementById('acf-mode-test').value;
|
||||
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
|
||||
const selOpt = document.querySelector(`#acf-test option[value="${test_id}"]`);
|
||||
if (selOpt && selOpt.textContent.includes('(0 вопр.)')) { errEl.textContent = 'В выбранном тесте нет вопросов. Добавьте вопросы во вкладке «Тесты».'; return; }
|
||||
payload = { ...payload, test_id: Number(test_id), mode };
|
||||
} else {
|
||||
const subject_slug = document.getElementById('acf-subject').value;
|
||||
const mode = document.getElementById('acf-mode').value;
|
||||
const count = Number(document.getElementById('acf-count').value);
|
||||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||||
if (!count || count < 1) { errEl.textContent = 'Укажите количество вопросов'; return; }
|
||||
payload = { ...payload, subject_slug, mode, count };
|
||||
}
|
||||
|
||||
const btn = document.getElementById('acf-save');
|
||||
btn.disabled = true; btn.textContent = 'Создание…';
|
||||
try {
|
||||
if (_acTarget === 'user') {
|
||||
if (!_acStudentId) { errEl.textContent = 'Выберите ученика из списка'; btn.disabled=false; btn.textContent='Создать'; return; }
|
||||
await LS.createDirectAssignment({ ...payload, student_id: _acStudentId });
|
||||
} else {
|
||||
const class_id = document.getElementById('acf-class').value;
|
||||
if (!class_id) { errEl.textContent = 'Выберите класс'; btn.disabled=false; btn.textContent='Создать'; return; }
|
||||
await LS.createAssignment(class_id, payload);
|
||||
}
|
||||
closeCreateAModal();
|
||||
await load();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Создать';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsgn(id) {
|
||||
const a = allAssignments.find(x => x.id === id);
|
||||
if (!await LS.confirm(`Удалить задание «${a?.title}»?\nВсе связанные сессии будут удалены.`, { title: 'Удалить задание', confirmText: 'Удалить' })) return;
|
||||
try {
|
||||
await LS.deleteAssignment(id);
|
||||
allAssignments = allAssignments.filter(x => x.id !== id);
|
||||
renderAssignments();
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Expose handlers used by HTML/inline onclicks
|
||||
window.loadAssignments = load;
|
||||
window.renderAssignments = renderAssignments;
|
||||
window.setAFilter = setAFilter;
|
||||
window.setAfSrc = setAfSrc;
|
||||
window.openAModal = openAModal;
|
||||
window.closeAModal = closeAModal;
|
||||
window.saveAssignment = saveAssignment;
|
||||
window.setAcTarget = setAcTarget;
|
||||
window.filterAcStudents = filterAcStudents;
|
||||
window.openAcStudentDrop = openAcStudentDrop;
|
||||
window.closeAcStudentDrop = closeAcStudentDrop;
|
||||
window.selectAcStudent = selectAcStudent;
|
||||
window.setAcSrc = setAcSrc;
|
||||
window.filterAcFiles = filterAcFiles;
|
||||
window.selectAcFile = selectAcFile;
|
||||
window.openCreateAModal = openCreateAModal;
|
||||
window.closeCreateAModal = closeCreateAModal;
|
||||
window.saveNewAssignment = saveNewAssignment;
|
||||
window.deleteAsgn = deleteAsgn;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.assignments = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,185 @@
|
||||
'use strict';
|
||||
/* admin → gam (gamification) section: stats + top + recent XP + purchases + award XP */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
let _gamSearchTimer = null;
|
||||
let _gamAwarding = false;
|
||||
|
||||
const XP_REASONS = {
|
||||
'daily_activity': ['sun', '#F59E0B', 'Ежедневная активность'],
|
||||
'correct_answers':['check-circle', '#10B981', 'Правильные ответы'],
|
||||
'test_complete': ['file-text', '#06B6D4', 'Тест завершён'],
|
||||
'test_90+': ['zap', '#9B5DE5', 'Тест на 90%+'],
|
||||
'test_perfect': ['trophy', '#F59E0B', 'Идеальный тест (100%)'],
|
||||
'lab_experiment': ['atom', '#06D6A0', 'Лабораторный эксперимент'],
|
||||
'daily_goal': ['target', '#EF476F', 'Ежедневная цель выполнена'],
|
||||
'Admin award': ['crown', '#9B5DE5', 'Начисление администратором'],
|
||||
};
|
||||
|
||||
function fmtXPReason(reason) {
|
||||
if (!reason) return '—';
|
||||
const entry = XP_REASONS[reason];
|
||||
if (entry) {
|
||||
const [icon, color, label] = entry;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:${color};display:inline-flex">${lsIcon(icon,14)}</span>${label}</span>`;
|
||||
}
|
||||
if (reason.startsWith('achievement:')) {
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#F59E0B;display:inline-flex">${lsIcon('award',14)}</span>Достижение: ${esc(reason.slice(12))}</span>`;
|
||||
}
|
||||
if (reason.startsWith('Испытание:')) {
|
||||
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#EF476F;display:inline-flex">${lsIcon('swords',14)}</span>${esc(reason)}</span>`;
|
||||
}
|
||||
return esc(reason);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { fmtDate } = AdminCtx;
|
||||
try {
|
||||
const stats = await LS.adminGamStats();
|
||||
document.getElementById('gam-stats-grid').innerHTML = `
|
||||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="zap" class="stat-icon"></i></div>
|
||||
<div class="stat-val violet">${stats.totalXP}</div>
|
||||
<div class="stat-label">Суммарный XP</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--cyan)">
|
||||
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
|
||||
<div class="stat-val cyan">${stats.totalCoins}</div>
|
||||
<div class="stat-label">Суммарные монеты</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--green)">
|
||||
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="bar-chart-3" class="stat-icon"></i></div>
|
||||
<div class="stat-val green">${(stats.avgLevel ?? 0).toFixed(1)}</div>
|
||||
<div class="stat-label">Средний уровень</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
|
||||
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="trophy" class="stat-icon"></i></div>
|
||||
<div class="stat-val" style="color:var(--amber, #FFB347)">${stats.achievementCount}</div>
|
||||
<div class="stat-label">Достижений выдано</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:#FF9F1C">
|
||||
<div class="stat-card-icon" style="background:rgba(255,159,28,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
|
||||
<div class="stat-val" style="color:#FF9F1C">${stats.totalPurchases || 0}</div>
|
||||
<div class="stat-label">Покупок</div>
|
||||
</div>`;
|
||||
|
||||
// Top-10
|
||||
const topBody = document.getElementById('gam-top-body');
|
||||
if (stats.topByXP?.length) {
|
||||
topBody.innerHTML = stats.topByXP.slice(0, 10).map((u, i) => `<tr>
|
||||
<td><strong>${i + 1}</strong></td>
|
||||
<td>${esc(u.name || u.email || 'ID:' + (u.id || u.user_id))}</td>
|
||||
<td><span style="color:var(--violet);font-weight:700">${u.xp}</span></td>
|
||||
<td>${u.level}</td>
|
||||
<td>${u.coins} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
|
||||
</tr>`).join('');
|
||||
} else {
|
||||
topBody.innerHTML = '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
|
||||
}
|
||||
|
||||
// Recent XP
|
||||
const logBody = document.getElementById('gam-log-body');
|
||||
if (stats.recentXP?.length) {
|
||||
logBody.innerHTML = stats.recentXP.slice(0, 20).map(e => `<tr>
|
||||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(e.created_at || e.date)}</td>
|
||||
<td>${esc(e.name || e.user_name || '—')}</td>
|
||||
<td><span style="color:var(--violet);font-weight:700">+${e.amount}</span></td>
|
||||
<td style="font-size:0.82rem;color:var(--text-2)">${fmtXPReason(e.reason)}</td>
|
||||
</tr>`).join('');
|
||||
} else {
|
||||
logBody.innerHTML = '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
|
||||
}
|
||||
|
||||
// Purchases
|
||||
const purchBody = document.getElementById('gam-purchases-body');
|
||||
if (stats.recentPurchases?.length) {
|
||||
purchBody.innerHTML = stats.recentPurchases.slice(0, 20).map(p => `<tr>
|
||||
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(p.purchased_at)}</td>
|
||||
<td>${esc(p.user_name || '—')}</td>
|
||||
<td style="font-weight:600">${esc(p.item_name || '—')}</td>
|
||||
<td><span class="badge" style="font-size:0.7rem">${esc(p.type || '—')}</span></td>
|
||||
<td style="color:var(--amber,#FFB347);font-weight:700">${p.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px"></i></td>
|
||||
</tr>`).join('');
|
||||
} else {
|
||||
purchBody.innerHTML = '<tr><td colspan="5" class="empty">Нет покупок</td></tr>';
|
||||
}
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('gam-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function gamSearchUser(q, prefix) {
|
||||
clearTimeout(_gamSearchTimer);
|
||||
const box = document.getElementById(prefix + '-results');
|
||||
if (q.length < 2) { box.classList.remove('open'); return; }
|
||||
_gamSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await LS.adminGetUsers({ q, limit: 8 });
|
||||
const label = u => u.name || u.email;
|
||||
box.innerHTML = (r.users || []).map(u => `<div class="us-item" data-uid="${u.id}" data-name="${esc(label(u))}" data-prefix="${esc(prefix)}" onclick="gamPickUser(this)">
|
||||
<span>${esc(label(u))}</span><span class="us-role">${esc(u.role)}</span>
|
||||
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
|
||||
box.classList.add('open');
|
||||
} catch(e) { box.classList.remove('open'); }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function gamPickUser(el) {
|
||||
const prefix = el.dataset.prefix;
|
||||
document.getElementById(prefix + '-uid').value = el.dataset.uid;
|
||||
document.getElementById(prefix + '-user').value = el.dataset.name || '';
|
||||
document.getElementById(prefix + '-results').classList.remove('open');
|
||||
}
|
||||
|
||||
async function gamAdminAward() {
|
||||
if (_gamAwarding) return;
|
||||
const userId = parseInt(document.getElementById('gam-award-uid').value);
|
||||
const xp = parseInt(document.getElementById('gam-award-xp').value) || 0;
|
||||
const coins = parseInt(document.getElementById('gam-award-coins').value) || 0;
|
||||
const reason = document.getElementById('gam-award-reason').value.trim();
|
||||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||||
if (!xp && !coins) { LS.toast('Введите XP или монеты', 'error'); return; }
|
||||
_gamAwarding = true;
|
||||
try {
|
||||
const r = await LS.adminGamAward({ userId, xp, coins, reason });
|
||||
LS.toast(`Начислено! XP: ${r.xp}, Уровень: ${r.level}, Монеты: ${r.coins}`, 'success');
|
||||
document.getElementById('gam-award-uid').value = '';
|
||||
document.getElementById('gam-award-user').value = '';
|
||||
document.getElementById('gam-award-reason').value = '';
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
finally { _gamAwarding = false; }
|
||||
}
|
||||
|
||||
async function gamAdminReset() {
|
||||
const userId = parseInt(document.getElementById('gam-reset-uid').value);
|
||||
const userName = document.getElementById('gam-reset-user').value;
|
||||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||||
if (!await LS.confirm(`ВСЕ XP, монеты и достижения «${userName}» будут удалены безвозвратно.`, { title: 'Сбросить прогресс?', confirmText: 'Сбросить', danger: true })) return;
|
||||
try {
|
||||
await LS.adminGamReset({ userId });
|
||||
LS.toast('Прогресс сброшен', 'success');
|
||||
document.getElementById('gam-reset-uid').value = '';
|
||||
document.getElementById('gam-reset-user').value = '';
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
window.gamSearchUser = gamSearchUser;
|
||||
window.gamPickUser = gamPickUser;
|
||||
window.gamAdminAward = gamAdminAward;
|
||||
window.gamAdminReset = gamAdminReset;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.gam = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
/* admin → games (game features + free-student features) section */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
const GAME_FEATURES = [
|
||||
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
||||
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
|
||||
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
||||
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, пищевые сети, квесты', icon: 'leaf' },
|
||||
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и достижений — игровой прогресс ученика', icon: 'layers' },
|
||||
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
|
||||
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
|
||||
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
|
||||
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
||||
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
||||
];
|
||||
|
||||
const FS_FEATURES = [
|
||||
{ key: 'gamification', label: 'Геймификация', desc: 'XP, уровни, достижения, монеты, стрики, магазин', icon: 'trophy' },
|
||||
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
|
||||
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически', icon: 'grid-3x3' },
|
||||
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
|
||||
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, квесты', icon: 'leaf' },
|
||||
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и игровой прогресс ученика', icon: 'layers' },
|
||||
{ key: 'lab', label: 'Лаборатория', desc: 'Виртуальные симуляции и интерактивные опыты', icon: 'flask-conical' },
|
||||
{ key: 'knowledge_map',label: 'Карта знаний', desc: 'Визуальная карта тем и связей между понятиями', icon: 'map' },
|
||||
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для повторения терминов и понятий', icon: 'square-stack' },
|
||||
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями и постами', icon: 'layout-dashboard' },
|
||||
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
||||
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
||||
];
|
||||
|
||||
async function loadGamesAdmin() {
|
||||
const grid = document.getElementById('games-features-grid');
|
||||
try {
|
||||
const features = await LS.api('/api/admin/features');
|
||||
grid.innerHTML = '';
|
||||
for (const f of GAME_FEATURES) {
|
||||
const enabled = features[f.key] !== false;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'perm-card' + (enabled ? ' enabled' : '');
|
||||
card.innerHTML = `
|
||||
<div class="perm-info">
|
||||
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
|
||||
<div class="perm-desc">${f.desc}</div>
|
||||
</div>
|
||||
<label class="perm-toggle">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleGameFeature('${f.key}', this.checked, this)" />
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>`;
|
||||
grid.appendChild(card);
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch(e) {
|
||||
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGameFeature(key, enabled, checkbox) {
|
||||
try {
|
||||
await LS.api('/api/admin/features', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ [key]: enabled }),
|
||||
});
|
||||
const card = checkbox.closest('.perm-card');
|
||||
if (card) card.classList.toggle('enabled', enabled);
|
||||
LS.toast(enabled ? 'Функция включена' : 'Функция отключена', 'success');
|
||||
} catch(e) {
|
||||
checkbox.checked = !enabled;
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFsFeatures() {
|
||||
const grid = document.getElementById('fs-features-grid');
|
||||
try {
|
||||
const features = await LS.api('/api/admin/free-student-features');
|
||||
grid.innerHTML = '';
|
||||
for (const f of FS_FEATURES) {
|
||||
const enabled = features[f.key] !== false;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'perm-card' + (enabled ? ' enabled' : '');
|
||||
card.innerHTML = `
|
||||
<div class="perm-info">
|
||||
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
|
||||
<div class="perm-desc">${f.desc}</div>
|
||||
</div>
|
||||
<label class="perm-toggle">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleFsFeature('${f.key}', this.checked, this)" />
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>`;
|
||||
grid.appendChild(card);
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch(e) {
|
||||
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFsFeature(key, enabled, checkbox) {
|
||||
try {
|
||||
await LS.api('/api/admin/free-student-features', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ [key]: enabled }),
|
||||
});
|
||||
const card = checkbox.closest('.perm-card');
|
||||
if (card) card.classList.toggle('enabled', enabled);
|
||||
LS.toast(enabled ? 'Модуль включён' : 'Модуль отключён', 'success');
|
||||
} catch(e) {
|
||||
checkbox.checked = !enabled;
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await loadGamesAdmin();
|
||||
await loadFsFeatures();
|
||||
}
|
||||
|
||||
window.toggleGameFeature = toggleGameFeature;
|
||||
window.toggleFsFeature = toggleFsFeature;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.games = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
/* admin → overview (Phase 3 dashboard) — landing page "что требует внимания".
|
||||
* Lazy-init via AdminSections.overview.init(); reloads via .reload().
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
/* ── one-time CSS injection (overview-specific bento layout) ────────── */
|
||||
function ensureOvStyles() {
|
||||
if (document.getElementById('ov-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'ov-style';
|
||||
s.textContent = `
|
||||
.ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.ov-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 22px 20px; position: relative; overflow: hidden; }
|
||||
.ov-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--ov-top, var(--violet)); opacity: 0.7; }
|
||||
.ov-card-icon { width: 38px; height: 38px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; background: rgba(155,93,229,0.1); color: var(--violet); }
|
||||
.ov-card-val { font-family: 'Unbounded', sans-serif; font-size: 1.9rem; font-weight: 800; line-height: 1.1; margin-bottom: 4px; }
|
||||
.ov-card-label { font-size: 0.82rem; color: var(--text-3); font-weight: 600; }
|
||||
.ov-card.warn { border-color: rgba(255,179,71,0.4); }
|
||||
.ov-card.warn::before { background: var(--amber); }
|
||||
.ov-card.warn .ov-card-icon { background: rgba(255,179,71,0.12); color: var(--amber); }
|
||||
.ov-card.danger { border-color: rgba(241,91,181,0.35); }
|
||||
.ov-card.danger::before { background: var(--pink); }
|
||||
.ov-card.danger .ov-card-icon { background: rgba(241,91,181,0.1); color: var(--pink); }
|
||||
.ov-section-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); margin: 28px 0 12px; }
|
||||
.ov-banned-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ov-banned-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: rgba(241,91,181,0.06); border: 1px solid rgba(241,91,181,0.18); border-radius: 10px; font-size: 0.86rem; }
|
||||
.ov-banned-row .ov-bn-name { font-weight: 600; }
|
||||
.ov-banned-row .ov-bn-email { color: var(--text-3); font-size: 0.78rem; }
|
||||
.ov-banned-row .ov-bn-date { margin-left: auto; color: var(--text-3); font-size: 0.76rem; }
|
||||
.ov-top-table { width: 100%; border-collapse: collapse; }
|
||||
.ov-top-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); font-weight: 700; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.ov-top-table td { padding: 10px; font-size: 0.86rem; border-bottom: 1px solid var(--border); }
|
||||
.ov-top-table tr:last-child td { border-bottom: none; }
|
||||
.ov-pct { font-family: 'Unbounded', sans-serif; font-weight: 700; }
|
||||
.ov-pct.hi { color: var(--green); }
|
||||
.ov-pct.mid { color: var(--amber); }
|
||||
.ov-pct.lo { color: var(--pink); }
|
||||
.ov-quick-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.ov-quick-btn { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 0.88rem; font-weight: 600; color: var(--text); text-align: left; transition: background .12s, border-color .12s, transform .12s; }
|
||||
.ov-quick-btn:hover { background: rgba(155,93,229,0.06); border-color: rgba(155,93,229,0.3); color: var(--violet); transform: translateY(-1px); }
|
||||
.ov-quick-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.ov-empty { padding: 18px; text-align: center; color: var(--text-3); font-size: 0.85rem; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function pctClassNum(p) {
|
||||
if (p === null || p === undefined) return '';
|
||||
return p >= 75 ? 'hi' : p >= 50 ? 'mid' : 'lo';
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
return (n === 0 || n === null || n === undefined) ? '—' : String(n);
|
||||
}
|
||||
|
||||
function fmtBannedDate(s) {
|
||||
if (!s) return '';
|
||||
try {
|
||||
const d = new Date(s.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function fmtFinished(s) {
|
||||
if (!s) return '—';
|
||||
try {
|
||||
const d = new Date(s.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleString('ru', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return s; }
|
||||
}
|
||||
|
||||
function navigateTo(hash) {
|
||||
if (window.AdminRouter) AdminRouter.navigate(hash);
|
||||
else window.location.hash = hash;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const el = document.getElementById('overview-content');
|
||||
if (!el) return;
|
||||
ensureOvStyles();
|
||||
|
||||
const e = LS.esc;
|
||||
const failedCls = data.failedSessions24h > 0 ? 'warn' : '';
|
||||
const bannedCount = Array.isArray(data.bannedThisWeek) ? data.bannedThisWeek.length : 0;
|
||||
const top = Array.isArray(data.topSessions24h) ? data.topSessions24h : [];
|
||||
|
||||
let alertsHtml = '';
|
||||
if (bannedCount > 0 || data.failedSessions24h > 0) {
|
||||
const banned = bannedCount > 0 ? `
|
||||
<div class="ov-card danger" style="grid-column: span 2; padding-bottom: 14px">
|
||||
<div class="ov-card-icon"><i data-lucide="user-x" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val">${bannedCount}</div>
|
||||
<div class="ov-card-label" style="margin-bottom: 10px">Заблокированы за неделю</div>
|
||||
<div class="ov-banned-list">
|
||||
${data.bannedThisWeek.map(u => `
|
||||
<div class="ov-banned-row">
|
||||
<span class="ov-bn-name">${e(u.name || '—')}</span>
|
||||
<span class="ov-bn-email">${e(u.email || '')}</span>
|
||||
<span class="ov-bn-date">${fmtBannedDate(u.banned_at)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const failed = data.failedSessions24h > 0 ? `
|
||||
<div class="ov-card ${failedCls}">
|
||||
<div class="ov-card-icon"><i data-lucide="alert-triangle" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val">${data.failedSessions24h}</div>
|
||||
<div class="ov-card-label">Незавершённых сессий за 24ч</div>
|
||||
</div>` : '';
|
||||
|
||||
alertsHtml = `
|
||||
<div class="ov-section-title">Требует внимания</div>
|
||||
<div class="ov-grid">${banned}${failed}</div>`;
|
||||
}
|
||||
|
||||
const topRowsHtml = top.length ? `
|
||||
<table class="ov-top-table">
|
||||
<thead><tr><th>Ученик</th><th>Предмет</th><th>Счёт</th><th>%</th><th>Завершён</th></tr></thead>
|
||||
<tbody>
|
||||
${top.map(s => `
|
||||
<tr>
|
||||
<td>${e(s.user_name || '—')}</td>
|
||||
<td>${e(s.subject_name || '—')}</td>
|
||||
<td>${s.score ?? 0} / ${s.total ?? 0}</td>
|
||||
<td><span class="ov-pct ${pctClassNum(s.percent)}">${s.percent ?? '—'}%</span></td>
|
||||
<td style="color:var(--text-3);font-size:0.8rem">${fmtFinished(s.finished_at)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>` : '<div class="ov-empty">Нет завершённых сессий за последние 24 часа</div>';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ov-section-title">Активность за 24 часа</div>
|
||||
<div class="ov-grid">
|
||||
<div class="ov-card" style="--ov-top:var(--violet)">
|
||||
<div class="ov-card-icon"><i data-lucide="user-plus" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--violet)">${fmtNum(data.newUsers24h)}</div>
|
||||
<div class="ov-card-label">Новых регистраций</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--cyan)">
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,224,0.1);color:var(--cyan)"><i data-lucide="play-circle" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--cyan)">${fmtNum(data.newSessions24h)}</div>
|
||||
<div class="ov-card-label">Сессий запущено</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--green)">
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,100,0.1);color:var(--green)"><i data-lucide="activity" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--green)">${fmtNum(data.activeUsers24h)}</div>
|
||||
<div class="ov-card-label">Активных юзеров</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--amber)">
|
||||
<div class="ov-card-icon" style="background:rgba(255,179,71,0.12);color:var(--amber)"><i data-lucide="users" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--amber)">${fmtNum(data.activeClasses)}</div>
|
||||
<div class="ov-card-label">Активных классов</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${alertsHtml}
|
||||
|
||||
<div class="ov-section-title">Топ-5 сессий за день</div>
|
||||
${topRowsHtml}
|
||||
|
||||
<div class="ov-section-title">Быстрый переход</div>
|
||||
<div class="ov-quick-grid">
|
||||
<button class="ov-quick-btn" data-go="#users">
|
||||
<i data-lucide="users"></i> Все пользователи
|
||||
</button>
|
||||
<button class="ov-quick-btn" data-go="#sessions">
|
||||
<i data-lucide="clock"></i> Все сессии
|
||||
</button>
|
||||
<button class="ov-quick-btn" data-go="#tests">
|
||||
<i data-lucide="clipboard-list"></i> Создать тест
|
||||
</button>
|
||||
<button class="ov-quick-btn" data-go="#sublog">
|
||||
<i data-lucide="file-text"></i> Audit log
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire quick-links via event delegation
|
||||
el.querySelectorAll('.ov-quick-btn[data-go]').forEach(btn => {
|
||||
btn.addEventListener('click', () => navigateTo(btn.dataset.go));
|
||||
});
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const el = document.getElementById('overview-content');
|
||||
if (!el) return;
|
||||
LS.state.loading(el, 'Загружаю обзор…');
|
||||
try {
|
||||
const data = await LS.adminGetOverview();
|
||||
render(data);
|
||||
} catch (e) {
|
||||
LS.state.error(el, e, () => load());
|
||||
}
|
||||
}
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.overview = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
/* admin → permissions section (role-based teacher/student permissions) */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
let _permData = null;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
_permData = await LS.getPermissions();
|
||||
renderPermissions();
|
||||
} catch(e) {
|
||||
document.getElementById('perm-teacher').innerHTML =
|
||||
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPermissions() {
|
||||
if (!_permData) return;
|
||||
const { permissions, definitions } = _permData;
|
||||
['teacher', 'student'].forEach(role => {
|
||||
const container = document.getElementById('perm-' + role);
|
||||
const defs = definitions.filter(d => d.role === role);
|
||||
container.innerHTML = defs.map(def => {
|
||||
const enabled = permissions[role]?.[def.key] ?? def.default;
|
||||
return `
|
||||
<div class="perm-card${enabled ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}">
|
||||
<div class="perm-info">
|
||||
<div class="perm-label">${esc(def.label)}</div>
|
||||
<div class="perm-desc">${esc(def.desc)}</div>
|
||||
</div>
|
||||
<label class="perm-toggle" title="${enabled ? 'Выключить' : 'Включить'}">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''}
|
||||
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>
|
||||
</div>`;
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
async function togglePermission(role, key, enabled, checkbox) {
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
await LS.setPermission(role, key, enabled);
|
||||
if (!_permData.permissions[role]) _permData.permissions[role] = {};
|
||||
_permData.permissions[role][key] = enabled;
|
||||
const safeKey = key.replace('.', '_');
|
||||
const card = document.getElementById(`perm-card-${role}-${safeKey}`);
|
||||
if (card) card.classList.toggle('enabled', enabled);
|
||||
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
|
||||
} catch(e) {
|
||||
checkbox.checked = !enabled;
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
} finally {
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.togglePermission = togglePermission;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.permissions = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,536 @@
|
||||
'use strict';
|
||||
/* admin → questions section (the biggest — список + Q-modal + CSV) */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
let allQuestions = [];
|
||||
let editingQId = null;
|
||||
let openQId = null;
|
||||
let _topicMap = {};
|
||||
let _currentType = 'single';
|
||||
// window._matchPairs is exposed on window because HTML oninput uses bare `_matchPairs[i].left=this.value`
|
||||
window._matchPairs = window._matchPairs || [];
|
||||
let _opts = [];
|
||||
let _focusedInput = null;
|
||||
let _prevTimer = null;
|
||||
const OPT_LETTERS = 'АБВГДЕ';
|
||||
|
||||
function updateCharCounter(el, cntId, max) {
|
||||
const n = el.value.length;
|
||||
const cnt = document.getElementById(cntId);
|
||||
if (!cnt) return;
|
||||
cnt.textContent = `${n} / ${max}`;
|
||||
cnt.className = 'char-counter' + (n > max * 0.9 ? ' warn' : '') + (n >= max ? ' over' : '');
|
||||
}
|
||||
|
||||
async function onQSubjectChange() {
|
||||
const slug = document.getElementById('q-subject').value;
|
||||
const sel = document.getElementById('q-topic');
|
||||
sel.innerHTML = '<option value="">Все темы</option>';
|
||||
if (slug) {
|
||||
try {
|
||||
const topics = await LS.getTopics(slug);
|
||||
topics.forEach(t => sel.appendChild(new Option(t.name, t.id)));
|
||||
} catch {}
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const subject = document.getElementById('q-subject').value;
|
||||
const topic_id = document.getElementById('q-topic').value;
|
||||
const sort = document.getElementById('q-sort').value;
|
||||
const wrap = document.getElementById('q-list-wrap');
|
||||
wrap.innerHTML = LS.skeleton(5);
|
||||
try {
|
||||
allQuestions = await LS.getQuestions(subject || null, topic_id || null, sort);
|
||||
renderQuestions();
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<div class="error">Ошибка загрузки: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuestions() {
|
||||
const { DIFFS } = AdminCtx;
|
||||
const search = document.getElementById('q-search').value.toLowerCase();
|
||||
const filtered = search
|
||||
? allQuestions.filter(q => q.text.toLowerCase().includes(search) || (q.topic||'').toLowerCase().includes(search))
|
||||
: allQuestions;
|
||||
|
||||
document.getElementById('q-count').textContent = `${filtered.length} вопросов`;
|
||||
|
||||
if (!filtered.length) {
|
||||
document.getElementById('q-list-wrap').innerHTML = '<div class="empty">Вопросов не найдено</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = document.getElementById('q-list-wrap');
|
||||
wrap.innerHTML =
|
||||
`<div class="q-list">${filtered.map(q => {
|
||||
const diffCls = `diff-${q.difficulty}`;
|
||||
const optsHtml = (q.options || []).map(o =>
|
||||
`<div class="q-opt-row ${o.is_correct ? 'correct' : ''}">
|
||||
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${esc(o.text)}
|
||||
</div>`).join('');
|
||||
const explHtml = q.explanation
|
||||
? `<div class="q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
|
||||
return `<div class="q-card" id="qcard-${q.id}">
|
||||
<div class="q-card-head">
|
||||
<span class="q-card-num">#${q.id}</span>
|
||||
<div class="q-card-body" onclick="toggleQDetail(${q.id})">
|
||||
<div class="q-card-text">${esc(q.text)}</div>
|
||||
<div class="q-card-meta">
|
||||
${q.subject_name ? `<span class="q-badge q-badge-subj">${esc(q.subject_name)}</span>` : ''}
|
||||
${q.topic ? `<span class="q-badge q-badge-topic">${esc(q.topic)}</span>` : ''}
|
||||
<span class="q-badge ${diffCls}">${DIFFS[q.difficulty]||q.difficulty}</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-3);background:rgba(15,23,42,0.05);padding:2px 7px;border-radius:999px">${{single:'Один',multi:'Несколько',true_false:'Верно/Неверно',short_answer:'Краткий',matching:'Сопост.'}[q.type]||q.type||'Один'}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-3)">${q.options?.length||0} вар.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-card-actions">
|
||||
<button class="btn-edit-q" onclick="editQ(${q.id})">Изменить</button>
|
||||
<button class="btn-dup-q" onclick="dupQ(${q.id})" title="Дублировать">⧉</button>
|
||||
<button class="btn-del-q" onclick="deleteQ(${q.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-card-detail" id="qdetail-${q.id}">
|
||||
${optsHtml}${explHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
AdminCtx.renderMath(wrap);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function toggleQDetail(id) {
|
||||
if (openQId === id) {
|
||||
document.getElementById('qdetail-' + id)?.classList.remove('open');
|
||||
openQId = null; return;
|
||||
}
|
||||
if (openQId) document.getElementById('qdetail-' + openQId)?.classList.remove('open');
|
||||
document.getElementById('qdetail-' + id)?.classList.add('open');
|
||||
openQId = id;
|
||||
}
|
||||
|
||||
async function dupQ(id) {
|
||||
try {
|
||||
const { id: newId } = await LS.duplicateQuestion(id);
|
||||
await load();
|
||||
setTimeout(() => document.getElementById('qcard-' + newId)?.scrollIntoView({ behavior:'smooth', block:'center' }), 300);
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteQ(id) {
|
||||
if (!await LS.confirm(`Удалить вопрос #${id}?`, { title: 'Удалить вопрос', confirmText: 'Удалить' })) return;
|
||||
try {
|
||||
await LS.deleteQuestion(id);
|
||||
allQuestions = allQuestions.filter(q => q.id !== id);
|
||||
renderQuestions();
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ─── Question type ─── */
|
||||
function setQType(type) {
|
||||
_currentType = type;
|
||||
document.querySelectorAll('[data-type]').forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
||||
const isMatching = type === 'matching';
|
||||
const isShort = type === 'short_answer';
|
||||
const showOpts = !isShort && !isMatching;
|
||||
const optsHeader = document.getElementById('qf-opts-header');
|
||||
if (optsHeader) optsHeader.style.display = showOpts ? '' : 'none';
|
||||
document.getElementById('qf-opts').style.display = showOpts ? '' : 'none';
|
||||
document.getElementById('qf-short-wrap').style.display = isShort ? '' : 'none';
|
||||
document.getElementById('qf-match-wrap').style.display = isMatching ? '' : 'none';
|
||||
document.getElementById('btn-add-opt').style.display = showOpts && type !== 'true_false' ? '' : 'none';
|
||||
|
||||
if (type === 'true_false') {
|
||||
initOpts([{ text:'Верно', is_correct:false }, { text:'Неверно', is_correct:false }]);
|
||||
} else if (isShort) {
|
||||
_opts = [];
|
||||
} else if (isMatching) {
|
||||
_opts = [];
|
||||
if (window._matchPairs.length === 0) window._matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
|
||||
renderMatchRows();
|
||||
} else {
|
||||
if (_opts.length === 0 || _opts[0]?.text === 'Верно') initOpts([{},{},{},{}]);
|
||||
else renderOptRows(_opts);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMatchRows() {
|
||||
const cont = document.getElementById('qf-match-rows');
|
||||
cont.innerHTML = window._matchPairs.map((p, i) => `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:8px" data-mi="${i}">
|
||||
<input type="text" class="form-ctrl" placeholder="Элемент…" value="${esc(p.left)}"
|
||||
oninput="window._matchPairs[${i}].left=this.value" style="margin:0" />
|
||||
<input type="text" class="form-ctrl" placeholder="Пара к нему…" value="${esc(p.right)}"
|
||||
oninput="window._matchPairs[${i}].right=this.value" style="margin:0" />
|
||||
<button type="button" onclick="removeMatchPair(${i})" style="border:none;background:none;color:var(--text-3);cursor:pointer;padding:0 6px;display:flex;align-items:center" title="Удалить"><i data-lucide="x" style="width:15px;height:15px"></i></button>
|
||||
</div>`).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function addMatchPair() {
|
||||
window._matchPairs.push({left:'',right:''});
|
||||
renderMatchRows();
|
||||
}
|
||||
|
||||
function removeMatchPair(i) {
|
||||
window._matchPairs.splice(i, 1);
|
||||
renderMatchRows();
|
||||
}
|
||||
|
||||
/* ─── Formula bar ─── */
|
||||
document.addEventListener('focusin', e => {
|
||||
if (e.target.closest && e.target.closest('#q-modal') &&
|
||||
(e.target.tagName === 'TEXTAREA' || (e.target.tagName === 'INPUT' && e.target.type === 'text'))) {
|
||||
_focusedInput = e.target;
|
||||
}
|
||||
});
|
||||
|
||||
function ins(latex) {
|
||||
const el = _focusedInput || document.getElementById('qf-text');
|
||||
if (!el) return;
|
||||
const s = el.selectionStart ?? el.value.length;
|
||||
const e2= el.selectionEnd ?? el.value.length;
|
||||
const before = el.value.slice(0, s), after = el.value.slice(e2);
|
||||
const opens = (before.match(/\\\(/g)||[]).length;
|
||||
const closes = (before.match(/\\\)/g)||[]).length;
|
||||
const insert = opens > closes ? latex : `\\(${latex}\\)`;
|
||||
el.value = before + insert + after;
|
||||
el.setSelectionRange(s + insert.length, s + insert.length);
|
||||
el.focus();
|
||||
updateQPreview();
|
||||
}
|
||||
|
||||
function wrapMath() {
|
||||
const el = _focusedInput || document.getElementById('qf-text');
|
||||
if (!el) return;
|
||||
const s = el.selectionStart, e2 = el.selectionEnd;
|
||||
const sel = el.value.slice(s, e2) || 'x';
|
||||
el.value = el.value.slice(0, s) + `\\(${sel}\\)` + el.value.slice(e2);
|
||||
el.focus();
|
||||
updateQPreview();
|
||||
}
|
||||
|
||||
/* ─── Live preview ─── */
|
||||
function updateQPreview() {
|
||||
clearTimeout(_prevTimer);
|
||||
_prevTimer = setTimeout(() => {
|
||||
const text = (document.getElementById('qf-text').value || '').trim();
|
||||
const el = document.getElementById('q-preview-text');
|
||||
const wrap = document.getElementById('q-preview-wrap');
|
||||
wrap.classList.toggle('hidden', !text);
|
||||
if (!text) return;
|
||||
el.textContent = text;
|
||||
AdminCtx.renderMath(el);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Formula bar toggle (default collapsed)
|
||||
window.toggleFormulaBar = function () {
|
||||
const bar = document.getElementById('formula-bar');
|
||||
const btn = document.getElementById('qf-fml-toggle');
|
||||
const open = bar.classList.toggle('visible');
|
||||
btn.classList.toggle('open', open);
|
||||
};
|
||||
|
||||
// Wire textarea input to preview
|
||||
setTimeout(() => {
|
||||
const ta = document.getElementById('qf-text');
|
||||
if (ta) ta.addEventListener('input', updateQPreview);
|
||||
}, 0);
|
||||
|
||||
/* ─── Dynamic options ─── */
|
||||
function renderOptRows(opts) {
|
||||
const grid = document.getElementById('qf-opts');
|
||||
const isMulti = _currentType === 'multi';
|
||||
grid.innerHTML = opts.map((o, i) => `
|
||||
<div class="opt-row${o.is_correct ? ' opt-correct' : ''}" data-i="${i}">
|
||||
<span class="opt-letter">${OPT_LETTERS[i]}</span>
|
||||
${isMulti
|
||||
? `<input type="checkbox" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
|
||||
onchange="onCheckChange(${i}, this.checked)" />`
|
||||
: `<input type="radio" name="qf-correct" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
|
||||
onchange="onRadioChange(${i})" />`}
|
||||
<input type="text" class="opt-input" placeholder="Вариант ${OPT_LETTERS[i]}"
|
||||
value="${esc(o.text||'')}" oninput="syncOptText(${i}, this.value)" />
|
||||
${opts.length > 2
|
||||
? `<button type="button" class="btn-rem-opt" onclick="removeOpt(${i})" title="Удалить">−</button>`
|
||||
: '<span style="width:24px;flex-shrink:0"></span>'}
|
||||
</div>`).join('');
|
||||
document.getElementById('btn-add-opt').style.display = opts.length >= 6 ? 'none' : '';
|
||||
}
|
||||
|
||||
function onCheckChange(idx, checked) {
|
||||
_opts[idx].is_correct = checked;
|
||||
document.querySelector(`#qf-opts .opt-row[data-i="${idx}"]`)?.classList.toggle('opt-correct', checked);
|
||||
}
|
||||
|
||||
function initOpts(opts) {
|
||||
_opts = opts.length ? opts.map(o => ({ text: o.text||'', is_correct: !!o.is_correct }))
|
||||
: [{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false}];
|
||||
renderOptRows(_opts);
|
||||
}
|
||||
|
||||
function onRadioChange(idx) {
|
||||
_opts.forEach((o, i) => o.is_correct = (i === idx));
|
||||
renderOptRows(_opts);
|
||||
}
|
||||
|
||||
function syncOptText(idx, val) { _opts[idx].text = val; }
|
||||
|
||||
function addOpt() {
|
||||
if (_opts.length >= 6) return;
|
||||
_opts.push({ text: '', is_correct: false });
|
||||
renderOptRows(_opts);
|
||||
const rows = document.querySelectorAll('#qf-opts .opt-row');
|
||||
rows[rows.length - 1]?.querySelector('input[type=text]')?.focus();
|
||||
}
|
||||
|
||||
function removeOpt(idx) {
|
||||
if (_opts.length <= 2) return;
|
||||
const wasCorrect = _opts[idx].is_correct;
|
||||
_opts.splice(idx, 1);
|
||||
if (wasCorrect && _opts.length > 0) _opts[0].is_correct = true;
|
||||
renderOptRows(_opts);
|
||||
}
|
||||
|
||||
/* ─── Modal ─── */
|
||||
function openQModal(q = null) {
|
||||
editingQId = q ? q.id : null;
|
||||
document.getElementById('q-modal-title').textContent = q ? `Редактировать вопрос #${q.id}` : 'Добавить вопрос';
|
||||
const textEl = document.getElementById('qf-text');
|
||||
textEl.value = q?.text || '';
|
||||
updateCharCounter(textEl, 'qf-text-cnt', 500);
|
||||
document.getElementById('qf-explanation').value = q?.explanation || '';
|
||||
document.getElementById('qf-difficulty').value = q?.difficulty ?? 2;
|
||||
document.getElementById('qf-subject').value = q?.subject_slug || '';
|
||||
document.getElementById('qf-topic-text').value = q?.topic || '';
|
||||
document.getElementById('qf-correct-text').value = q?.correct_text || '';
|
||||
document.getElementById('qf-error').textContent = '';
|
||||
const imgVal = q?.image || '';
|
||||
document.getElementById('qf-image').value = imgVal;
|
||||
updateImagePreview(imgVal);
|
||||
|
||||
if (q?.type === 'matching') {
|
||||
window._matchPairs = (q.options || []).map(o => ({ left: o.text, right: o.match_pair || '' }));
|
||||
if (!window._matchPairs.length) window._matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
|
||||
} else {
|
||||
window._matchPairs = [];
|
||||
}
|
||||
setQType(q?.type || 'single');
|
||||
if (q?.type !== 'matching') initOpts(q?.options || []);
|
||||
updateQPreview();
|
||||
loadQModalTopics();
|
||||
document.getElementById('q-modal').classList.add('open');
|
||||
setTimeout(() => textEl.focus(), 80);
|
||||
}
|
||||
|
||||
function editQ(id) {
|
||||
const q = allQuestions.find(x => x.id === id);
|
||||
if (q) openQModal(q);
|
||||
}
|
||||
|
||||
function closeQModal() {
|
||||
document.getElementById('q-modal').classList.remove('open');
|
||||
editingQId = null;
|
||||
}
|
||||
|
||||
async function loadQModalTopics() {
|
||||
const slug = document.getElementById('qf-subject').value;
|
||||
const dl = document.getElementById('qf-topic-list');
|
||||
dl.innerHTML = '';
|
||||
_topicMap = {};
|
||||
if (!slug) return;
|
||||
try {
|
||||
const topics = await LS.getTopics(slug);
|
||||
topics.forEach(t => {
|
||||
dl.appendChild(new Option(t.name));
|
||||
_topicMap[t.name.toLowerCase()] = t.id;
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveQuestion() {
|
||||
const text = document.getElementById('qf-text').value.trim();
|
||||
const explanation = document.getElementById('qf-explanation').value.trim();
|
||||
const difficulty = Number(document.getElementById('qf-difficulty').value);
|
||||
const subject_slug = document.getElementById('qf-subject').value;
|
||||
const topicText = document.getElementById('qf-topic-text').value.trim();
|
||||
const type = _currentType;
|
||||
const errEl = document.getElementById('qf-error');
|
||||
errEl.textContent = '';
|
||||
|
||||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||||
if (!text) { errEl.textContent = 'Введите текст вопроса'; return; }
|
||||
|
||||
let options = null;
|
||||
let correct_text = null;
|
||||
|
||||
if (type === 'short_answer') {
|
||||
correct_text = document.getElementById('qf-correct-text').value.trim();
|
||||
if (!correct_text) { errEl.textContent = 'Введите правильный ответ'; return; }
|
||||
} else if (type === 'matching') {
|
||||
document.querySelectorAll('#qf-match-rows [data-mi]').forEach((row, i) => {
|
||||
const [l, r] = row.querySelectorAll('input');
|
||||
if (window._matchPairs[i]) { window._matchPairs[i].left = l.value.trim(); window._matchPairs[i].right = r.value.trim(); }
|
||||
});
|
||||
if (window._matchPairs.length < 2) { errEl.textContent = 'Нужно минимум 2 пары'; return; }
|
||||
if (window._matchPairs.some(p => !p.left || !p.right)) { errEl.textContent = 'Заполните все пары'; return; }
|
||||
options = window._matchPairs.map(p => ({ text: p.left, match_pair: p.right, is_correct: 0 }));
|
||||
} else {
|
||||
document.querySelectorAll('#qf-opts .opt-row').forEach((row, i) => {
|
||||
if (_opts[i]) _opts[i].text = row.querySelector('input[type=text]').value.trim();
|
||||
});
|
||||
options = _opts.map(o => ({ text: o.text, is_correct: o.is_correct }));
|
||||
if (options.length < 2) { errEl.textContent = 'Нужно минимум 2 варианта ответа'; return; }
|
||||
if (options.some(o => !o.text)) { errEl.textContent = 'Заполните все варианты ответов'; return; }
|
||||
if (!options.some(o => o.is_correct)) { errEl.textContent = 'Отметьте правильный ответ'; return; }
|
||||
}
|
||||
|
||||
const knownId = _topicMap[topicText.toLowerCase()];
|
||||
const topic_id = knownId || null;
|
||||
const topic_name = !knownId && topicText ? topicText : null;
|
||||
|
||||
const image = document.getElementById('qf-image').value.trim() || null;
|
||||
|
||||
const btn = document.getElementById('qf-save');
|
||||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||||
try {
|
||||
if (editingQId) {
|
||||
await LS.updateQuestion(editingQId, { text, type, correct_text, difficulty, explanation: explanation||null, topic_id, topic_name, options, image });
|
||||
} else {
|
||||
await LS.createQuestion({ subject_slug, topic_id, topic_name, text, type, correct_text, difficulty, explanation: explanation||null, options, image });
|
||||
}
|
||||
closeQModal();
|
||||
load();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Image upload & preview ── */
|
||||
function updateImagePreview(url) {
|
||||
const wrap = document.getElementById('qf-image-preview');
|
||||
const img = document.getElementById('qf-image-img');
|
||||
if (url) { img.src = url; wrap.classList.add('visible'); }
|
||||
else { wrap.classList.remove('visible'); img.src = ''; }
|
||||
}
|
||||
|
||||
function clearQuestionImage() {
|
||||
document.getElementById('qf-image').value = '';
|
||||
updateImagePreview('');
|
||||
}
|
||||
|
||||
async function handleImageFileSelect(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
input.value = '';
|
||||
const btn = document.getElementById('btn-img-upload');
|
||||
const lbl = document.getElementById('btn-img-upload-lbl');
|
||||
btn.disabled = true;
|
||||
lbl.textContent = 'Загрузка…';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('title', 'Question image: ' + file.name);
|
||||
fd.append('is_public', '1');
|
||||
const res = await fetch('/api/files', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + localStorage.getItem('ls_token') },
|
||||
body: fd
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
||||
const { id } = await res.json();
|
||||
const url = `/api/files/${id}/download`;
|
||||
document.getElementById('qf-image').value = url;
|
||||
updateImagePreview(url);
|
||||
} catch (e) {
|
||||
document.getElementById('qf-error').textContent = 'Ошибка загрузки: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
lbl.textContent = 'Загрузить';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const imgInput = document.getElementById('qf-image');
|
||||
if (imgInput) imgInput.addEventListener('input', e => updateImagePreview(e.target.value.trim()));
|
||||
});
|
||||
|
||||
/* ── CSV Import ── */
|
||||
async function importCSVFile(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
input.value = '';
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const btn = document.querySelector('[onclick="document.getElementById(\'csv-file-input\').click()"]');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Импорт…'; }
|
||||
try {
|
||||
const { imported, errors } = await LS.importQuestions(fd);
|
||||
LS.toast(`Импортировано: ${imported} вопросов${errors.length ? ` (${errors.length} ошибок)` : ''}`, imported > 0 ? 'success' : 'warn', 5000);
|
||||
load();
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка импорта: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i data-lucide="upload" style="width:14px;height:14px;vertical-align:-2px"></i> Импорт CSV'; if(window.lucide)lucide.createIcons(); }
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCSVTemplate(e) {
|
||||
e.preventDefault();
|
||||
const header = 'subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year';
|
||||
const example = [
|
||||
'bio;Клетки;Что является «электростанцией» клетки?;2;single;Митохондрия;1;Рибосома;0;Лизосома;0;Ядро;0;;Митохондрии синтезируют АТФ;2024',
|
||||
'bio;Клетки;Какие органоиды участвуют в синтезе белка?;2;multi;Рибосома;1;Митохондрия;0;Эндоплазматическая сеть;1;Лизосома;0;;',
|
||||
'chem;Кислоты;Формула серной кислоты;1;short_answer;;;;;;;;H2SO4;;',
|
||||
].join('\n');
|
||||
const blob = new Blob(['' + header + '\n' + example], { type: 'text/csv;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'questions_template.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// Expose handlers used by onclick (HTML or sibling sections)
|
||||
window.onQSubjectChange = onQSubjectChange;
|
||||
window.loadQuestions = load;
|
||||
window.renderQuestions = renderQuestions;
|
||||
window.toggleQDetail = toggleQDetail;
|
||||
window.dupQ = dupQ;
|
||||
window.deleteQ = deleteQ;
|
||||
window.setQType = setQType;
|
||||
window.addMatchPair = addMatchPair;
|
||||
window.removeMatchPair = removeMatchPair;
|
||||
window.ins = ins;
|
||||
window.wrapMath = wrapMath;
|
||||
window.updateQPreview = updateQPreview;
|
||||
window.onCheckChange = onCheckChange;
|
||||
window.onRadioChange = onRadioChange;
|
||||
window.syncOptText = syncOptText;
|
||||
window.addOpt = addOpt;
|
||||
window.removeOpt = removeOpt;
|
||||
window.openQModal = openQModal;
|
||||
window.editQ = editQ;
|
||||
window.closeQModal = closeQModal;
|
||||
window.loadQModalTopics = loadQModalTopics;
|
||||
window.saveQuestion = saveQuestion;
|
||||
window.updateImagePreview = updateImagePreview;
|
||||
window.clearQuestionImage = clearQuestionImage;
|
||||
window.handleImageFileSelect = handleImageFileSelect;
|
||||
window.importCSVFile = importCSVFile;
|
||||
window.downloadCSVTemplate = downloadCSVTemplate;
|
||||
window.updateCharCounter = updateCharCounter;
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.questions = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
openModal: openQModal,
|
||||
loadModalTopics: loadQModalTopics,
|
||||
};
|
||||
})();
|
||||
@@ -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),
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
/* admin → sessions section: sessions timeline + drawer detail */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
let allSessions = [];
|
||||
// Phase 6: clicking a session row navigates to the deep page (#sessions/:id)
|
||||
// instead of toggling an inline drawer. The drawer rendering is gone.
|
||||
function gotoSession(id) {
|
||||
if (window.AdminRouter) AdminRouter.navigate('#sessions/' + id);
|
||||
}
|
||||
|
||||
/* 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>';
|
||||
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="gotoSession(${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();gotoSession(${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>`;
|
||||
}).join('')}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
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.gotoSession = gotoSession;
|
||||
window.quickDeleteSession = quickDeleteSession;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.sessions = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
/* admin → shop section: items + purchases + award coins */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
let _shopItems = [];
|
||||
let _shopEditId = null;
|
||||
let _shopSaving = false;
|
||||
let _shopSearchTimer = null;
|
||||
let _coinsAwarding = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [stats, items] = await Promise.all([
|
||||
LS.adminShopStats(),
|
||||
LS.adminShopGetItems()
|
||||
]);
|
||||
const topName = stats.topItems?.[0]?.name || '—';
|
||||
document.getElementById('shop-stats-grid').innerHTML = `
|
||||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
|
||||
<div class="stat-val violet">${stats.activeItems}/${stats.totalItems}</div>
|
||||
<div class="stat-label">Товаров</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--cyan)">
|
||||
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="receipt" class="stat-icon"></i></div>
|
||||
<div class="stat-val cyan">${stats.totalPurchases}</div>
|
||||
<div class="stat-label">Покупок</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--green)">
|
||||
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
|
||||
<div class="stat-val green">${stats.totalCoinsInCirculation}</div>
|
||||
<div class="stat-label">Монет в обороте</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
|
||||
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="star" class="stat-icon"></i></div>
|
||||
<div class="stat-val" style="color:var(--amber, #FFB347);font-size:1.1rem">${esc(topName)}</div>
|
||||
<div class="stat-label">Топ товар</div>
|
||||
</div>`;
|
||||
_shopItems = items;
|
||||
renderShopItems();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('shop-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderShopItems() {
|
||||
const body = document.getElementById('shop-items-body');
|
||||
if (!_shopItems.length) { body.innerHTML = '<tr><td colspan="7" class="empty">Нет товаров</td></tr>'; return; }
|
||||
const typeLabels = { frame:'Рамка', title:'Титул', theme:'Тема', effect:'Эффект' };
|
||||
body.innerHTML = _shopItems.map(it => `<tr>
|
||||
<td>${it.id}</td>
|
||||
<td><strong>${esc(it.name)}</strong></td>
|
||||
<td><span class="mode-badge mode-practice">${typeLabels[it.type] || esc(it.type)}</span></td>
|
||||
<td>${it.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
|
||||
<td>${it.sold_count || 0}</td>
|
||||
<td>
|
||||
<label class="adm-toggle">
|
||||
<input type="checkbox" ${it.is_active ? 'checked' : ''} onchange="shopAdminToggleActive(${it.id}, this.checked)" />
|
||||
<span class="track"></span><span class="thumb"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-edit-q" onclick="shopAdminEditItem(${it.id})">Ред.</button>
|
||||
<button class="btn-del-q" onclick="shopAdminDeleteItem(${it.id})">Удалить</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
function shopAdminCreateItem() {
|
||||
_shopEditId = null;
|
||||
document.getElementById('shop-form-title').textContent = 'Добавить товар';
|
||||
document.getElementById('shop-f-name').value = '';
|
||||
document.getElementById('shop-f-type').value = 'frame';
|
||||
document.getElementById('shop-f-price').value = '100';
|
||||
document.getElementById('shop-f-desc').value = '';
|
||||
document.getElementById('shop-f-icon').value = '';
|
||||
document.getElementById('shop-f-data').value = '';
|
||||
document.getElementById('shop-f-active').checked = true;
|
||||
document.getElementById('shop-item-form').style.display = '';
|
||||
}
|
||||
|
||||
function shopAdminEditItem(id) {
|
||||
const it = _shopItems.find(i => i.id === id);
|
||||
if (!it) return;
|
||||
_shopEditId = id;
|
||||
document.getElementById('shop-form-title').textContent = 'Редактировать товар #' + id;
|
||||
document.getElementById('shop-f-name').value = it.name || '';
|
||||
document.getElementById('shop-f-type').value = it.type || 'frame';
|
||||
document.getElementById('shop-f-price').value = it.price ?? 100;
|
||||
document.getElementById('shop-f-desc').value = it.description || '';
|
||||
document.getElementById('shop-f-icon').value = it.icon || '';
|
||||
document.getElementById('shop-f-data').value = it.data ? (typeof it.data === 'string' ? it.data : JSON.stringify(it.data)) : '';
|
||||
document.getElementById('shop-f-active').checked = !!it.is_active;
|
||||
document.getElementById('shop-item-form').style.display = '';
|
||||
}
|
||||
|
||||
function shopAdminCancelForm() {
|
||||
document.getElementById('shop-item-form').style.display = 'none';
|
||||
_shopEditId = null;
|
||||
}
|
||||
|
||||
async function shopAdminSaveItem() {
|
||||
if (_shopSaving) return;
|
||||
_shopSaving = true;
|
||||
const data = {
|
||||
name: document.getElementById('shop-f-name').value.trim(),
|
||||
type: document.getElementById('shop-f-type').value,
|
||||
price: parseInt(document.getElementById('shop-f-price').value) || 0,
|
||||
description: document.getElementById('shop-f-desc').value.trim(),
|
||||
icon: document.getElementById('shop-f-icon').value.trim(),
|
||||
data: document.getElementById('shop-f-data').value.trim() || null,
|
||||
is_active: document.getElementById('shop-f-active').checked ? 1 : 0
|
||||
};
|
||||
if (!data.name) { LS.toast('Введите название', 'error'); _shopSaving = false; return; }
|
||||
try {
|
||||
if (_shopEditId) {
|
||||
await LS.adminShopUpdateItem(_shopEditId, data);
|
||||
LS.toast('Товар обновлён', 'success');
|
||||
} else {
|
||||
await LS.adminShopCreateItem(data);
|
||||
LS.toast('Товар создан', 'success');
|
||||
}
|
||||
shopAdminCancelForm();
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
finally { _shopSaving = false; }
|
||||
}
|
||||
|
||||
async function shopAdminDeleteItem(id) {
|
||||
if (!await LS.confirm('Все покупки этого товара будут удалены.', { title: 'Удалить товар?', confirmText: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await LS.adminShopDeleteItem(id);
|
||||
LS.toast('Товар удалён', 'success');
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function shopAdminToggleActive(id, active) {
|
||||
try {
|
||||
await LS.adminShopUpdateItem(id, { is_active: active ? 1 : 0 });
|
||||
LS.toast(active ? 'Товар активирован' : 'Товар деактивирован', 'success');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function shopSearchUser(q) {
|
||||
clearTimeout(_shopSearchTimer);
|
||||
const box = document.getElementById('shop-award-results');
|
||||
if (q.length < 2) { box.classList.remove('open'); return; }
|
||||
_shopSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await LS.adminGetUsers({ q, limit: 8 });
|
||||
const label = u => u.name || u.email;
|
||||
box.innerHTML = (r.users || []).map(u => `<div class="us-item" data-uid="${u.id}" data-name="${esc(label(u))}" onclick="shopPickUser(this)">
|
||||
<span>${esc(label(u))}</span><span class="us-role">${esc(u.role)}</span>
|
||||
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
|
||||
box.classList.add('open');
|
||||
} catch(e) { box.classList.remove('open'); }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function shopPickUser(el) {
|
||||
document.getElementById('shop-award-uid').value = el.dataset.uid;
|
||||
document.getElementById('shop-award-user').value = el.dataset.name || '';
|
||||
document.getElementById('shop-award-results').classList.remove('open');
|
||||
}
|
||||
|
||||
async function shopAdminAwardCoins() {
|
||||
if (_coinsAwarding) return;
|
||||
const userId = parseInt(document.getElementById('shop-award-uid').value);
|
||||
const amount = parseInt(document.getElementById('shop-award-amount').value);
|
||||
const reason = document.getElementById('shop-award-reason').value.trim();
|
||||
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
|
||||
if (!amount || amount <= 0) { LS.toast('Введите количество монет', 'error'); return; }
|
||||
_coinsAwarding = true;
|
||||
try {
|
||||
const r = await LS.adminShopAwardCoins({ userId, amount, reason });
|
||||
LS.toast(`Начислено ${amount} монет. Баланс: ${r.coins}`, 'success');
|
||||
document.getElementById('shop-award-uid').value = '';
|
||||
document.getElementById('shop-award-user').value = '';
|
||||
document.getElementById('shop-award-reason').value = '';
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
finally { _coinsAwarding = false; }
|
||||
}
|
||||
|
||||
// Expose onclick handlers
|
||||
window.shopAdminCreateItem = shopAdminCreateItem;
|
||||
window.shopAdminEditItem = shopAdminEditItem;
|
||||
window.shopAdminCancelForm = shopAdminCancelForm;
|
||||
window.shopAdminSaveItem = shopAdminSaveItem;
|
||||
window.shopAdminDeleteItem = shopAdminDeleteItem;
|
||||
window.shopAdminToggleActive = shopAdminToggleActive;
|
||||
window.shopSearchUser = shopSearchUser;
|
||||
window.shopPickUser = shopPickUser;
|
||||
window.shopAdminAwardCoins = shopAdminAwardCoins;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.shop = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
/* admin → sims (simulations) section */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
// Full list of available (non-null id) sims mirrored from /lab
|
||||
const ADMIN_SIMS = [
|
||||
{ id: 'graph', cat: 'Математика', title: 'График функции' },
|
||||
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
|
||||
{ id: 'geometry', cat: 'Математика', title: 'Планиметрия' },
|
||||
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
|
||||
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
|
||||
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
|
||||
{ id: 'probability', cat: 'Математика', title: 'Теория вероятностей' },
|
||||
{ id: 'trigcircle', cat: 'Математика', title: 'Тригонометрическая окружность' },
|
||||
{ id: 'normaldist', cat: 'Математика', title: 'Нормальное распределение' },
|
||||
{ id: 'projectile', cat: 'Физика', title: 'Бросок тела' },
|
||||
{ id: 'pendulum', cat: 'Физика', title: 'Маятник' },
|
||||
{ id: 'collision', cat: 'Физика', title: 'Столкновение шаров' },
|
||||
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
|
||||
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
|
||||
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
|
||||
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
|
||||
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
|
||||
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
|
||||
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
|
||||
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
|
||||
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
|
||||
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
|
||||
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
|
||||
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
|
||||
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
|
||||
{ id: 'electrolysis', cat: 'Химия', title: 'Электролиз' },
|
||||
{ id: 'bohratom', cat: 'Химия', title: 'Атом Бора' },
|
||||
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
|
||||
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
|
||||
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
|
||||
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
|
||||
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
|
||||
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
|
||||
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
|
||||
];
|
||||
|
||||
let _simsSettings = { module_disabled: false, disabled_ids: [] };
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await LS.api('/api/settings/sims');
|
||||
_simsSettings = data;
|
||||
_render();
|
||||
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
function _render() {
|
||||
// master toggle
|
||||
const masterChk = document.getElementById('sims-master-chk');
|
||||
if (masterChk) masterChk.checked = !_simsSettings.module_disabled;
|
||||
|
||||
// per-sim cards
|
||||
const grid = document.getElementById('sims-grid');
|
||||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||||
// group by category
|
||||
const byCat = {};
|
||||
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
|
||||
|
||||
let html = '';
|
||||
Object.entries(byCat).forEach(([cat, sims]) => {
|
||||
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(cat)}</div>`;
|
||||
sims.forEach(s => {
|
||||
const enabled = !dis.has(s.id);
|
||||
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}">
|
||||
<div class="perm-info">
|
||||
<div class="perm-label">${esc(s.title)}</div>
|
||||
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div>
|
||||
</div>
|
||||
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" />
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>
|
||||
</div>`;
|
||||
});
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function simsMasterToggle(checked) {
|
||||
try {
|
||||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
|
||||
_simsSettings.module_disabled = !checked;
|
||||
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function simToggleOne(simId, enabled) {
|
||||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||||
if (enabled) dis.delete(simId); else dis.add(simId);
|
||||
const disabled_ids = [...dis];
|
||||
try {
|
||||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) });
|
||||
_simsSettings.disabled_ids = disabled_ids;
|
||||
const card = document.getElementById('simcard-' + simId);
|
||||
if (card) card.classList.toggle('enabled', enabled);
|
||||
LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
window.simsMasterToggle = simsMasterToggle;
|
||||
window.simToggleOne = simToggleOne;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.sims = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
/* admin → stats section */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const s = await LS.adminGetStats();
|
||||
document.getElementById('stats-grid').innerHTML = `
|
||||
<div class="stat-card" style="--stat-top:var(--violet)">
|
||||
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="users" class="stat-icon"></i></div>
|
||||
<div class="stat-val violet">${s.totalUsers}</div>
|
||||
<div class="stat-label">Пользователей</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--cyan)">
|
||||
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="file-text" class="stat-icon"></i></div>
|
||||
<div class="stat-val cyan">${s.totalTests}</div>
|
||||
<div class="stat-label">Тестов пройдено</div>
|
||||
</div>
|
||||
<div class="stat-card" style="--stat-top:var(--green)">
|
||||
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="target" class="stat-icon"></i></div>
|
||||
<div class="stat-val green">${s.avgScore ?? '—'}%</div>
|
||||
<div class="stat-label">Средний результат</div>
|
||||
</div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
const subjEl = document.getElementById('subj-stats');
|
||||
if (!s.bySubject?.length) { subjEl.innerHTML = '<div class="empty">Нет данных</div>'; return; }
|
||||
subjEl.innerHTML = s.bySubject.map(b => {
|
||||
const pct = b.avg_pct ?? 0;
|
||||
const barColor = pct >= 75 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--pink)';
|
||||
return `<div class="subj-stat">
|
||||
<div><div class="subj-stat-name">${esc(b.name)}</div><div class="subj-stat-info">${b.tests} тестов</div></div>
|
||||
<div>
|
||||
<div class="subj-stat-pct">${b.avg_pct ?? '—'}%</div>
|
||||
<div style="width:60px;height:3px;background:rgba(15,23,42,0.06);border-radius:99px;margin-top:5px;overflow:hidden"><div style="width:${pct}%;height:100%;background:${barColor};border-radius:99px"></div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
LS.state.error(document.getElementById('stats-grid'), e, load);
|
||||
}
|
||||
}
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.stats = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,338 @@
|
||||
'use strict';
|
||||
/* admin → subjects (доступные тесты) section */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
|
||||
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
|
||||
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
|
||||
|
||||
// кэш тестов по предмету для селектора
|
||||
const _scTests = {};
|
||||
async function loadScTests(slug) {
|
||||
if (_scTests[slug]) return _scTests[slug];
|
||||
const tests = await LS.getTests(slug);
|
||||
_scTests[slug] = tests;
|
||||
return tests;
|
||||
}
|
||||
|
||||
function setSrcMode(slug, src) {
|
||||
const rndBtn = document.getElementById(`sc-src-rnd-${slug}`);
|
||||
const fixBtn = document.getElementById(`sc-src-fix-${slug}`);
|
||||
const pick = document.getElementById(`sc-test-pick-${slug}`);
|
||||
const cntWrap = document.getElementById(`sc-count-wrap-${slug}`);
|
||||
rndBtn.classList.toggle('active', src === 'random');
|
||||
fixBtn.classList.toggle('active', src === 'fixed');
|
||||
pick.classList.toggle('open', src === 'fixed');
|
||||
cntWrap.style.display = src === 'random' ? '' : 'none';
|
||||
if (src === 'fixed') {
|
||||
loadAndRenderTestPick(slug);
|
||||
} else {
|
||||
const dr = document.getElementById(`sc-qdr-${slug}`);
|
||||
if (dr) { dr.style.display = 'none'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderTestPick(slug) {
|
||||
const sel = document.getElementById(`sc-test-sel-${slug}`);
|
||||
if (sel.dataset.loaded) return;
|
||||
sel.innerHTML = '<option value="">Загрузка…</option>';
|
||||
try {
|
||||
const tests = await loadScTests(slug);
|
||||
const cur = document.getElementById(`sc-card-${slug}`)?.dataset.testId || '';
|
||||
sel.innerHTML = `<option value="">— случайные вопросы —</option>` +
|
||||
tests.map(t => `<option value="${t.id}"${String(t.id) === cur ? ' selected' : ''}>${esc(t.title)} (${t.question_count ?? '?'} вопр.)</option>`).join('');
|
||||
sel.dataset.loaded = '1';
|
||||
} catch(e) {
|
||||
sel.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const wrap = document.getElementById('subj-config-list');
|
||||
wrap.innerHTML = LS.skeleton(4);
|
||||
try {
|
||||
const subjects = await LS.getSubjects();
|
||||
wrap.innerHTML = subjects.map(s => {
|
||||
const hasFix = !!s.default_test_id;
|
||||
const color = SC_COLORS[s.slug] || '#9B5DE5';
|
||||
const mode = s.default_mode || 'exam';
|
||||
const count = s.default_count || 25;
|
||||
const srcLabel = hasFix ? 'Фикс. тест' : `${count} вопросов`;
|
||||
return `
|
||||
<div class="sc-card" id="sc-card-${s.slug}" data-test-id="${s.default_test_id || ''}">
|
||||
<div class="sc-row-top" onclick="toggleScCard('${s.slug}')">
|
||||
<div class="sc-icon" style="background:${color}"><i data-lucide="${SC_ICONS[s.slug]||'book'}"></i></div>
|
||||
<div class="sc-info">
|
||||
<div class="sc-name">${esc(s.name)}</div>
|
||||
<div class="sc-summary" id="sc-sum-${s.slug}">
|
||||
<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span>
|
||||
<span class="sc-tag">${srcLabel}</span>
|
||||
<span class="sc-qcount">${s.question_count ?? 0} в базе</span>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="chevron-down" class="sc-chevron"></i>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<!-- Quick presets -->
|
||||
<div class="sc-presets">
|
||||
<button class="sc-preset${mode==='exam'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',25)">Экзамен 25</button>
|
||||
<button class="sc-preset${mode==='exam'&&count===40&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',40)">Экзамен 40</button>
|
||||
<button class="sc-preset${mode==='practice'&&count===15&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',15)">Практика 15</button>
|
||||
<button class="sc-preset${mode==='practice'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',25)">Практика 25</button>
|
||||
</div>
|
||||
<!-- Detailed fields -->
|
||||
<div class="sc-fields">
|
||||
<div class="sc-field">
|
||||
<span class="sc-label">Режим</span>
|
||||
<select class="sc-select" id="sc-mode-${s.slug}">
|
||||
${Object.entries(SC_MODES).map(([v, l]) =>
|
||||
`<option value="${v}"${mode === v ? ' selected' : ''}>${l}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="sc-field">
|
||||
<span class="sc-label">Источник</span>
|
||||
<div class="sc-src-toggle">
|
||||
<button class="sc-src-btn${hasFix ? '' : ' active'}" id="sc-src-rnd-${s.slug}" onclick="setSrcMode('${s.slug}','random')">Случайные</button>
|
||||
<button class="sc-src-btn${hasFix ? ' active' : ''}" id="sc-src-fix-${s.slug}" onclick="setSrcMode('${s.slug}','fixed')">Из теста</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-field" id="sc-count-wrap-${s.slug}" style="${hasFix ? 'display:none' : ''}">
|
||||
<span class="sc-label">Вопросов</span>
|
||||
<input class="sc-input" type="number" id="sc-count-${s.slug}" min="5" max="100" value="${count}" />
|
||||
</div>
|
||||
<div class="sc-test-pick${hasFix ? ' open' : ''}" id="sc-test-pick-${s.slug}">
|
||||
<div class="sc-field">
|
||||
<span class="sc-label">Тест</span>
|
||||
<select class="sc-select" id="sc-test-sel-${s.slug}" onchange="onScTestChange('${s.slug}')">
|
||||
<option value="${s.default_test_id || ''}" selected>Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="sc-save-add" id="sc-qdr-btn-${s.slug}" style="display:${hasFix?'':'none'};align-self:flex-start"
|
||||
onclick="toggleScDrawer('${s.slug}')"><i data-lucide="list" style="width:13px;height:13px;vertical-align:-2px"></i> Вопросы</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="sc-footer">
|
||||
<button class="sc-save" id="sc-save-btn-${s.slug}" onclick="saveSubjectConfig('${s.slug}')">Сохранить</button>
|
||||
<button class="sc-save-add" onclick="goAddQuestion('${s.slug}')"><i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Вопрос</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sc-qdr-${s.slug}" style="display:none;border-top:1px solid var(--border);padding:20px 24px;background:rgba(238,242,255,0.5)">
|
||||
<div id="sc-qdr-inner-${s.slug}"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
subjects.filter(s => s.default_test_id).forEach(s => {
|
||||
loadAndRenderTestPick(s.slug);
|
||||
const btn = document.getElementById(`sc-qdr-btn-${s.slug}`);
|
||||
if (btn) btn.style.display = '';
|
||||
});
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScCard(slug) {
|
||||
const card = document.getElementById('sc-card-' + slug);
|
||||
if (!card) return;
|
||||
const wasOpen = card.classList.contains('open');
|
||||
document.querySelectorAll('.sc-card.open').forEach(c => c.classList.remove('open'));
|
||||
if (!wasOpen) {
|
||||
card.classList.add('open');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [card] });
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreset(slug, mode, count) {
|
||||
document.getElementById('sc-mode-' + slug).value = mode;
|
||||
document.getElementById('sc-count-' + slug).value = count;
|
||||
setSrcMode(slug, 'random');
|
||||
const card = document.getElementById('sc-card-' + slug);
|
||||
card.querySelectorAll('.sc-preset').forEach(p => p.classList.remove('active'));
|
||||
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
|
||||
card.querySelectorAll('.sc-preset').forEach(p => {
|
||||
const txt = p.textContent.trim();
|
||||
const mLabel = SC_MODES[mode];
|
||||
if (txt === mLabel + ' ' + count && !isFix) p.classList.add('active');
|
||||
});
|
||||
saveSubjectConfig(slug);
|
||||
}
|
||||
|
||||
function updateScSummary(slug) {
|
||||
const el = document.getElementById('sc-sum-' + slug);
|
||||
if (!el) return;
|
||||
const mode = document.getElementById('sc-mode-' + slug).value;
|
||||
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
|
||||
const count = document.getElementById('sc-count-' + slug).value;
|
||||
const srcLabel = isFix ? 'Фикс. тест' : count + ' вопросов';
|
||||
el.innerHTML = `<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span><span class="sc-tag">${srcLabel}</span>`;
|
||||
}
|
||||
|
||||
async function saveSubjectConfig(slug) {
|
||||
const btn = document.getElementById(`sc-save-btn-${slug}`);
|
||||
const mode = document.getElementById(`sc-mode-${slug}`).value;
|
||||
const isFix = document.getElementById(`sc-src-fix-${slug}`).classList.contains('active');
|
||||
const count = Number(document.getElementById(`sc-count-${slug}`)?.value || 25);
|
||||
const testId = isFix ? (document.getElementById(`sc-test-sel-${slug}`).value || null) : null;
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||||
const payload = { default_mode: mode, default_count: count, default_test_id: testId ? Number(testId) : null };
|
||||
try {
|
||||
await LS.updateSubject(slug, payload);
|
||||
document.getElementById(`sc-card-${slug}`).dataset.testId = testId || '';
|
||||
if (isFix) document.getElementById(`sc-test-sel-${slug}`).dataset.loaded = '';
|
||||
updateScSummary(slug);
|
||||
if (btn) { btn.classList.add('saved'); btn.textContent = 'Сохранено'; }
|
||||
setTimeout(() => { if (btn) { btn.classList.remove('saved'); btn.textContent = 'Сохранить'; btn.disabled = false; } }, 1500);
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Сохранить'; }
|
||||
}
|
||||
}
|
||||
|
||||
function onScTestChange(slug) {
|
||||
const tid = document.getElementById(`sc-test-sel-${slug}`).value;
|
||||
const btn = document.getElementById(`sc-qdr-btn-${slug}`);
|
||||
btn.style.display = tid ? '' : 'none';
|
||||
const dr = document.getElementById(`sc-qdr-${slug}`);
|
||||
dr.style.display = 'none';
|
||||
document.getElementById(`sc-qdr-inner-${slug}`).innerHTML = '';
|
||||
}
|
||||
|
||||
async function toggleScDrawer(slug) {
|
||||
const dr = document.getElementById(`sc-qdr-${slug}`);
|
||||
const tid = Number(document.getElementById(`sc-test-sel-${slug}`).value);
|
||||
if (!tid) return;
|
||||
if (dr.style.display !== 'none') { dr.style.display = 'none'; return; }
|
||||
dr.style.display = '';
|
||||
await renderScDrawer(slug, tid);
|
||||
}
|
||||
|
||||
const _scCache = {}; // tid → { test, subjectQs }
|
||||
async function renderScDrawer(slug, tid) {
|
||||
const inner = document.getElementById(`sc-qdr-inner-${slug}`);
|
||||
inner.innerHTML = LS.skeleton(3, 'row');
|
||||
try {
|
||||
const [t, subjectQs] = await Promise.all([
|
||||
LS.getTest(tid),
|
||||
LS.getQuestions(slug, null, 'date_asc').catch(() => []),
|
||||
]);
|
||||
_scCache[tid] = { test: t, subjectQs };
|
||||
inner.innerHTML = `
|
||||
<div class="tst-cols">
|
||||
<div>
|
||||
<div class="tst-panel-title">Вопросы в тесте (<span id="sc-qcnt-${tid}">${t.questions.length}</span>)</div>
|
||||
<div class="tst-q-list" id="sc-ql-${tid}">${renderScQList(t.questions, tid, slug)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tst-panel-title">Добавить из базы</div>
|
||||
<input class="tst-search" placeholder="Поиск…" oninput="filterScPicker(${tid},'${slug}',this.value)" />
|
||||
<div class="tst-q-list" id="sc-pick-${tid}">${renderScPicker(subjectQs, new Set(t.questions.map(q=>q.id)), tid, slug)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
AdminCtx.renderMath(inner);
|
||||
} catch(e) {
|
||||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderScQList(questions, tid, slug) {
|
||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Пусто. Добавьте вопросы справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
|
||||
return questions.map((q,i) => `
|
||||
<div class="tst-q-item" id="sc-qi-${tid}-${q.id}">
|
||||
<span class="tst-q-num">${i+1}.</span>
|
||||
<div class="tst-q-body">
|
||||
<span class="tst-q-text">${esc(q.text)}</span>
|
||||
<div class="tst-q-meta">
|
||||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
|
||||
${qTypeBadge(q.type)}
|
||||
${qOptsPreview(q)}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-tst-rem" onclick="scRemoveQ(${tid},'${slug}',${q.id})" title="Убрать">−</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderScPicker(questions, inIds, tid, slug) {
|
||||
const { DIFF_LABELS, qTypeBadge } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
|
||||
return questions.map(q => {
|
||||
const added = inIds.has(q.id);
|
||||
return `
|
||||
<div class="tst-q-item" id="sc-pick-item-${tid}-${q.id}" style="${added?'opacity:0.4;pointer-events:none':''}">
|
||||
<div class="tst-q-body" style="flex:1">
|
||||
<span class="tst-q-text">${esc(q.text)}</span>
|
||||
<div class="tst-q-meta">
|
||||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
|
||||
${qTypeBadge(q.type)}
|
||||
${q.topic ? `<span class="tst-q-badge" style="background:rgba(6,214,224,0.1);color:#05aab3">${esc(q.topic)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-tst-add" id="sc-add-btn-${tid}-${q.id}" onclick="scAddQ(${tid},'${slug}',${q.id},this)" title="Добавить">${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+' }</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function filterScPicker(tid, slug, q) {
|
||||
const cache = _scCache[tid];
|
||||
if (!cache) return;
|
||||
const lq = q.toLowerCase();
|
||||
const filtered = lq.length < 1
|
||||
? cache.subjectQs
|
||||
: cache.subjectQs.filter(x => x.text.toLowerCase().includes(lq) || (x.topic||'').toLowerCase().includes(lq));
|
||||
const inIds = new Set(cache.test.questions.map(x=>x.id));
|
||||
document.getElementById(`sc-pick-${tid}`).innerHTML = renderScPicker(filtered, inIds, tid, slug);
|
||||
}
|
||||
|
||||
async function scAddQ(tid, slug, qid, btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
await LS.addQuestionsToTest(tid, [qid]);
|
||||
const t = await LS.getTest(tid);
|
||||
_scCache[tid].test = t;
|
||||
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
|
||||
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
|
||||
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
|
||||
if (item) { item.style.opacity='0.4'; item.style.pointerEvents='none'; }
|
||||
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
|
||||
if (addBtn) { addBtn.innerHTML = '<i data-lucide="check" style="width:14px;height:14px"></i>'; if(window.lucide)lucide.createIcons(); }
|
||||
AdminCtx.renderMath(document.getElementById(`sc-ql-${tid}`));
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled=false; btn.textContent='+'; }
|
||||
}
|
||||
|
||||
async function scRemoveQ(tid, slug, qid) {
|
||||
try {
|
||||
await LS.removeQFromTest(tid, qid);
|
||||
const t = await LS.getTest(tid);
|
||||
_scCache[tid].test = t;
|
||||
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
|
||||
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
|
||||
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
|
||||
if (item) { item.style.opacity=''; item.style.pointerEvents=''; }
|
||||
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
|
||||
if (addBtn) { addBtn.textContent='+'; addBtn.disabled=false; }
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Expose onclick handlers
|
||||
window.toggleScCard = toggleScCard;
|
||||
window.applyPreset = applyPreset;
|
||||
window.setSrcMode = setSrcMode;
|
||||
window.saveSubjectConfig = saveSubjectConfig;
|
||||
window.onScTestChange = onScTestChange;
|
||||
window.toggleScDrawer = toggleScDrawer;
|
||||
window.filterScPicker = filterScPicker;
|
||||
window.scAddQ = scAddQ;
|
||||
window.scRemoveQ = scRemoveQ;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.subjects = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
/* admin → sublog (submission log) section */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
const SL_STATUSES = { new:'На проверке', reviewed:'Проверено', accepted:'Принято', revision:'На доработке', resubmitted:'Повторно' };
|
||||
|
||||
async function load() {
|
||||
const el = document.getElementById('sublog-list');
|
||||
const countEl = document.getElementById('sublog-count');
|
||||
const classId = document.getElementById('sublog-class-filter').value;
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
countEl.textContent = '';
|
||||
try {
|
||||
const url = classId ? `/api/submissions/log?class_id=${classId}` : '/api/submissions/log';
|
||||
const rows = await LS.api(url);
|
||||
|
||||
// Populate class filter on first load
|
||||
const sel = document.getElementById('sublog-class-filter');
|
||||
if (sel.options.length <= 1 && rows.length) {
|
||||
const classMap = new Map();
|
||||
rows.forEach(r => { if (r.class_id && r.class_name) classMap.set(r.class_id, r.class_name); });
|
||||
classMap.forEach((name, id) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
countEl.textContent = rows.length ? `${rows.length} записей` : '';
|
||||
|
||||
if (!rows.length) {
|
||||
el.innerHTML = `<div class="sl-empty">
|
||||
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
|
||||
Удалённых работ нет
|
||||
</div>`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
return;
|
||||
}
|
||||
|
||||
const ROLE_LABELS = { admin: 'Админ', teacher: 'Учитель', student: 'Ученик' };
|
||||
|
||||
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
||||
<thead><tr>
|
||||
<th>Дата</th>
|
||||
<th>Ученик</th>
|
||||
<th>Файл</th>
|
||||
<th>Задание</th>
|
||||
<th>Класс</th>
|
||||
<th>Статус</th>
|
||||
<th>Оценка</th>
|
||||
<th>Удалил</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(r => {
|
||||
const dt = r.deleted_at ? new Date(r.deleted_at.includes('T') ? r.deleted_at : r.deleted_at.replace(' ','T')+'Z') : null;
|
||||
const dateStr = dt ? dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}) : '—';
|
||||
const initials = (r.student_name || '?').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
|
||||
const st = r.status || 'new';
|
||||
const gradeVal = r.grade != null ? r.grade : null;
|
||||
const gradeCls = gradeVal != null ? (gradeVal >= 80 ? 'sl-grade-hi' : gradeVal >= 50 ? 'sl-grade-mid' : 'sl-grade-lo') : 'sl-grade-none';
|
||||
const roleCls = 'sl-role-' + (r.deleted_by_role || 'student');
|
||||
return `<tr>
|
||||
<td><span class="sl-date">${dateStr}</span></td>
|
||||
<td><span class="sl-student"><span class="sl-student-avatar">${initials}</span>${esc(r.student_name || '—')}</span></td>
|
||||
<td><span class="sl-file" title="${esc(r.original_name || '')}">${esc(r.original_name || '—')}</span></td>
|
||||
<td><span class="sl-assignment">${esc(r.assignment_title || '—')}</span></td>
|
||||
<td><span class="sl-class">${esc(r.class_name || '—')}</span></td>
|
||||
<td><span class="sl-status sl-status-${st}">${SL_STATUSES[st] || st}</span></td>
|
||||
<td><span class="sl-grade ${gradeCls}">${gradeVal != null ? gradeVal : '—'}</span></td>
|
||||
<td><span class="sl-deleted-by">${esc(r.deleted_by_name || '—')} <span class="sl-role-badge ${roleCls}">${ROLE_LABELS[r.deleted_by_role] || r.deleted_by_role || '?'}</span></span></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
document.getElementById('btn-clear-sublog').style.display = '';
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="sl-empty" style="color:#c0306a">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSubmissionLog() {
|
||||
if (!await LS.confirm('Очистить весь журнал удалённых работ? Это действие необратимо.', { title: 'Очистка журнала', confirmText: 'Очистить', danger: true })) return;
|
||||
try {
|
||||
await LS.api('/api/submissions/log', { method: 'DELETE' });
|
||||
document.getElementById('btn-clear-sublog').style.display = 'none';
|
||||
document.getElementById('sublog-count').textContent = '';
|
||||
document.getElementById('sublog-list').innerHTML = `<div class="sl-empty">
|
||||
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
|
||||
Журнал очищен
|
||||
</div>`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById('sublog-list')] });
|
||||
LS.toast('Журнал очищен', 'success');
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Expose handlers used by HTML onclicks
|
||||
window.loadSubmissionLog = load;
|
||||
window.clearSubmissionLog = clearSubmissionLog;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.sublog = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,283 @@
|
||||
'use strict';
|
||||
/* admin → tests section (тест-шаблоны: создание + редактирование + список вопросов) */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
let allTests = [];
|
||||
let openTstId = null;
|
||||
let editingTstId = null;
|
||||
let _tstShowAnswers = true;
|
||||
const _tstPickerCache = {};
|
||||
|
||||
async function load() {
|
||||
const subj = document.getElementById('tst-subj').value;
|
||||
const wrap = document.getElementById('tst-list-wrap');
|
||||
wrap.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
allTests = await LS.getTests(subj || null);
|
||||
renderTests();
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTests() {
|
||||
const { fmtDate } = AdminCtx;
|
||||
const search = document.getElementById('tst-search').value.toLowerCase();
|
||||
const filtered = search ? allTests.filter(t => t.title.toLowerCase().includes(search)) : allTests;
|
||||
document.getElementById('tst-count').textContent = `${filtered.length} тестов`;
|
||||
const wrap = document.getElementById('tst-list-wrap');
|
||||
if (!filtered.length) { wrap.innerHTML = '<div class="empty">Тестов не найдено</div>'; return; }
|
||||
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
||||
wrap.innerHTML = `<div class="q-list">${filtered.map(t => `
|
||||
<div class="q-card" id="tstcard-${t.id}">
|
||||
<div class="q-card-head">
|
||||
<span class="q-card-num">#${t.id}</span>
|
||||
<div class="q-card-body" onclick="toggleTstDrawer(${t.id})">
|
||||
<div class="q-card-text">${esc(t.title)}</div>
|
||||
<div class="q-card-meta">
|
||||
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
|
||||
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-card-actions">
|
||||
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
|
||||
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tst-drawer" id="tstdrawer-${t.id}" style="display:none">
|
||||
<div class="tst-drawer-inner" id="tstdinner-${t.id}">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('')}</div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function toggleTstDrawer(id) {
|
||||
const drawer = document.getElementById('tstdrawer-' + id);
|
||||
if (!drawer) return;
|
||||
if (openTstId && openTstId !== id) {
|
||||
const old = document.getElementById('tstdrawer-' + openTstId);
|
||||
if (old) old.style.display = 'none';
|
||||
}
|
||||
if (openTstId === id) {
|
||||
drawer.style.display = 'none'; openTstId = null; return;
|
||||
}
|
||||
openTstId = id;
|
||||
drawer.style.display = '';
|
||||
await renderTstDrawer(id);
|
||||
}
|
||||
|
||||
async function renderTstDrawer(id) {
|
||||
const inner = document.getElementById('tstdinner-' + id);
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const [t, subjectQs] = await Promise.all([
|
||||
LS.getTest(id),
|
||||
LS.getQuestions(
|
||||
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
|
||||
null, 'date_asc'
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
const inIds = new Set(t.questions.map(q => q.id));
|
||||
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
|
||||
|
||||
inner.innerHTML = `
|
||||
<div class="tst-cols">
|
||||
<div>
|
||||
<div class="tst-panel-title">Вопросы в тесте (${t.questions.length})</div>
|
||||
<div class="tst-q-list" id="tstql-${id}">${renderTstQList(t.questions, id)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tst-panel-title">Добавить вопросы</div>
|
||||
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
|
||||
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
AdminCtx.renderMath(inner);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch (e) {
|
||||
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTstQList(questions, tid) {
|
||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
|
||||
return questions.map((q, i) => `
|
||||
<div class="tst-q-item" id="tstqitem-${tid}-${q.id}">
|
||||
<span class="tst-q-num">${i+1}.</span>
|
||||
<div class="tst-q-body">
|
||||
<span class="tst-q-text">${esc(q.text)}</span>
|
||||
<div class="tst-q-meta">
|
||||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
|
||||
${qTypeBadge(q.type)}
|
||||
${qOptsPreview(q)}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-tst-rem" onclick="tstRemoveQ(${tid},${q.id})" title="Убрать">−</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderTstPicker(questions, inIds, tid) {
|
||||
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
|
||||
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
|
||||
return questions.map(q => {
|
||||
const added = inIds.has(q.id);
|
||||
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
|
||||
<div class="tst-q-body">
|
||||
<span class="tst-q-text">${esc(q.text)}</span>
|
||||
<div class="tst-q-meta">
|
||||
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||''}</span>
|
||||
${qTypeBadge(q.type)}
|
||||
${qOptsPreview(q)}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-tst-add${added?' added':''}" id="tstbtn-${tid}-${q.id}"
|
||||
title="${added?'Уже в тесте':'Добавить'}" ${added?'disabled':'onclick="tstAddQ('+tid+','+q.id+')"'}>${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+'}</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function filterTstPicker(tid) {
|
||||
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
|
||||
const cache = _tstPickerCache[tid];
|
||||
if (!cache) return;
|
||||
const filtered = search
|
||||
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
|
||||
: cache.subjectQs;
|
||||
const picker = document.getElementById('tstpicker-'+tid);
|
||||
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
|
||||
}
|
||||
|
||||
async function tstAddQ(tid, qid) {
|
||||
const btn = document.getElementById(`tstbtn-${tid}-${qid}`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||
try {
|
||||
await LS.addQuestionsToTest(tid, [qid]);
|
||||
const t = allTests.find(x => x.id === tid);
|
||||
if (t) t.question_count++;
|
||||
renderTests();
|
||||
openTstId = tid;
|
||||
document.getElementById('tstdrawer-' + tid).style.display = '';
|
||||
await renderTstDrawer(tid);
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); if (btn) { btn.disabled=false; btn.textContent='+'; } }
|
||||
}
|
||||
|
||||
async function tstRemoveQ(tid, qid) {
|
||||
try {
|
||||
await LS.removeQFromTest(tid, qid);
|
||||
const t = allTests.find(x => x.id === tid);
|
||||
if (t) t.question_count = Math.max(0, t.question_count - 1);
|
||||
renderTests();
|
||||
openTstId = tid;
|
||||
document.getElementById('tstdrawer-' + tid).style.display = '';
|
||||
await renderTstDrawer(tid);
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ── Test modal ── */
|
||||
function setTstShowAnswers(val) {
|
||||
_tstShowAnswers = val;
|
||||
document.getElementById('tstf-show-yes').classList.toggle('active', val);
|
||||
document.getElementById('tstf-show-no').classList.toggle('active', !val);
|
||||
}
|
||||
|
||||
function openTstModal(t = null) {
|
||||
editingTstId = t ? t.id : null;
|
||||
document.getElementById('tst-modal-title').textContent = t ? `Редактировать: ${t.title}` : 'Создать тест';
|
||||
document.getElementById('tstf-title').value = t?.title || '';
|
||||
document.getElementById('tstf-subject').value = t?.subject_slug || '';
|
||||
document.getElementById('tstf-desc').value = t?.description || '';
|
||||
document.getElementById('tstf-time').value = t?.time_limit || '';
|
||||
document.getElementById('tstf-error').textContent = '';
|
||||
setTstShowAnswers(t ? (t.show_answers !== 0) : true);
|
||||
document.getElementById('tst-modal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('tstf-title').focus(), 80);
|
||||
}
|
||||
|
||||
function editTst(id) {
|
||||
const t = allTests.find(x => x.id === id);
|
||||
if (t) openTstModal(t);
|
||||
}
|
||||
|
||||
function closeTstModal() {
|
||||
document.getElementById('tst-modal').classList.remove('open');
|
||||
editingTstId = null;
|
||||
}
|
||||
|
||||
async function saveTst() {
|
||||
const title = document.getElementById('tstf-title').value.trim();
|
||||
const subject_slug= document.getElementById('tstf-subject').value;
|
||||
const description = document.getElementById('tstf-desc').value.trim();
|
||||
const errEl = document.getElementById('tstf-error');
|
||||
errEl.textContent = '';
|
||||
if (!title) { errEl.textContent = 'Введите название'; return; }
|
||||
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
|
||||
|
||||
const btn = document.getElementById('tstf-save');
|
||||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||||
const show_answers = _tstShowAnswers ? 1 : 0;
|
||||
const timeVal = parseInt(document.getElementById('tstf-time').value, 10);
|
||||
const time_limit = timeVal >= 1 ? Math.min(600, timeVal) : null;
|
||||
try {
|
||||
if (editingTstId) {
|
||||
await LS.updateTest(editingTstId, { title, subject_slug, description: description||null, show_answers, time_limit });
|
||||
const idx = allTests.findIndex(x => x.id === editingTstId);
|
||||
if (idx !== -1) Object.assign(allTests[idx], { title, subject_slug, description, show_answers, time_limit });
|
||||
} else {
|
||||
const { id } = await LS.createTest({ title, subject_slug, description: description||null, show_answers, time_limit });
|
||||
allTests.unshift({ id, title, subject_slug, description, question_count: 0, created_at: new Date().toISOString() });
|
||||
closeTstModal();
|
||||
renderTests();
|
||||
openTstId = id;
|
||||
document.getElementById('tstdrawer-' + id).style.display = '';
|
||||
await renderTstDrawer(id);
|
||||
return;
|
||||
}
|
||||
closeTstModal();
|
||||
renderTests();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTst(id) {
|
||||
const t = allTests.find(x => x.id === id);
|
||||
if (!await LS.confirm(`Удалить тест «${t?.title}»?`, { title: 'Удалить тест', confirmText: 'Удалить' })) return;
|
||||
try {
|
||||
await LS.deleteTest(id);
|
||||
allTests = allTests.filter(x => x.id !== id);
|
||||
if (openTstId === id) openTstId = null;
|
||||
renderTests();
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Expose handlers
|
||||
window.loadTests = load;
|
||||
window.renderTests = renderTests;
|
||||
window.toggleTstDrawer = toggleTstDrawer;
|
||||
window.filterTstPicker = filterTstPicker;
|
||||
window.tstAddQ = tstAddQ;
|
||||
window.tstRemoveQ = tstRemoveQ;
|
||||
window.setTstShowAnswers = setTstShowAnswers;
|
||||
window.openTstModal = openTstModal;
|
||||
window.editTst = editTst;
|
||||
window.closeTstModal = closeTstModal;
|
||||
window.saveTst = saveTst;
|
||||
window.deleteTst = deleteTst;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.tests = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
/* admin → tpl (templates: courses + lessons) section */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [courses, lessons] = await Promise.all([
|
||||
LS.getCourseTemplates().catch(() => []),
|
||||
LS.getLessonTemplates().catch(() => [])
|
||||
]);
|
||||
renderTplTable('tpl-course-body', courses, 'courses');
|
||||
renderTplTable('tpl-lesson-body', lessons, 'lessons');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('tpl-course-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTplTable(bodyId, items, type) {
|
||||
const body = document.getElementById(bodyId);
|
||||
if (!items || !items.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" class="empty">Нет шаблонов</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = items.map(t => `<tr>
|
||||
<td>${t.id}</td>
|
||||
<td><strong>${esc(t.name || t.title || '—')}</strong></td>
|
||||
<td>${esc(t.subject || '—')}</td>
|
||||
<td>${esc(t.category || '—')}</td>
|
||||
<td>${esc(t.author_name || t.author || '—')}</td>
|
||||
<td>
|
||||
<label class="adm-toggle">
|
||||
<input type="checkbox" ${t.is_public ? 'checked' : ''} onchange="tplTogglePublic('${type}', ${t.id}, this.checked)" />
|
||||
<span class="track"></span><span class="thumb"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-del-q" onclick="tplDelete('${type}', ${t.id})">Удалить</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function tplTogglePublic(type, id, isPublic) {
|
||||
try {
|
||||
const endpoint = type === 'courses' ? '/api/templates/courses/' : '/api/templates/lessons/';
|
||||
await LS.api(endpoint + id, { method: 'PUT', body: JSON.stringify({ is_public: isPublic ? 1 : 0 }) });
|
||||
LS.toast(isPublic ? 'Шаблон опубликован' : 'Шаблон скрыт', 'success');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function tplDelete(type, id) {
|
||||
if (!confirm('Удалить шаблон #' + id + '?')) return;
|
||||
try {
|
||||
if (type === 'courses') await LS.deleteCourseTemplate(id);
|
||||
else await LS.deleteLessonTemplate(id);
|
||||
LS.toast('Шаблон удалён', 'success');
|
||||
inited = false;
|
||||
await load();
|
||||
inited = true;
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
window.tplTogglePublic = tplTogglePublic;
|
||||
window.tplDelete = tplDelete;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.tpl = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,423 @@
|
||||
'use strict';
|
||||
/* admin → user-detail (Phase 6) — deep page for a single user (#users/:id).
|
||||
*
|
||||
* Replaces the legacy `.user-panel` overlay. Lazy-init via
|
||||
* AdminSections['user-detail'].init(id, subTab)
|
||||
* where subTab ∈ 'overview' | 'sessions' | 'classes' | 'audit'.
|
||||
*
|
||||
* Reuses existing user-related modals (openEditUserModal, openUserPermsModal,
|
||||
* etc.) — they live in sections/users.js and operate on `window.activeUid`,
|
||||
* which we set before opening any of them.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ── one-time CSS injection ── */
|
||||
function ensureUdStyles() {
|
||||
if (document.getElementById('user-detail-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'user-detail-style';
|
||||
s.textContent = `
|
||||
.ud-wrap { padding: 4px 2px 24px; }
|
||||
.ud-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; }
|
||||
.ud-back:hover { background:rgba(155,93,229,.07); color:var(--violet); }
|
||||
.ud-back svg { width:14px; height:14px; }
|
||||
.ud-header { display:flex; align-items:flex-start; gap:20px; padding:24px 26px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-bottom:20px; flex-wrap:wrap; }
|
||||
.ud-avatar { width:64px; height:64px; border-radius:18px; display:flex; align-items:center; justify-content:center; font-family:'Unbounded',sans-serif; font-size:1.1rem; font-weight:800; color:#fff; flex-shrink:0; }
|
||||
.ud-avatar.banned { filter:grayscale(1); opacity:.6; }
|
||||
.ud-id-block { flex:1; min-width:200px; }
|
||||
.ud-name { font-family:'Unbounded',sans-serif; font-size:1.25rem; font-weight:800; line-height:1.2; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.ud-name .ud-role-badge { font-size:0.7rem; padding:3px 9px; border-radius:var(--r-pill); font-weight:700; letter-spacing:.02em; vertical-align:middle; }
|
||||
.ud-name .ud-banned-tag { font-size:0.66rem; padding:2px 7px; border-radius:4px; background:rgba(239,68,68,.12); color:#EF4444; font-weight:700; }
|
||||
.ud-email { font-size:0.88rem; color:var(--text-3); margin-top:6px; }
|
||||
.ud-meta-row { display:flex; gap:18px; margin-top:10px; font-size:0.76rem; color:var(--text-3); flex-wrap:wrap; }
|
||||
.ud-meta-row strong { color:var(--text-2); font-weight:600; }
|
||||
.ud-actions { display:flex; flex-wrap:wrap; gap:6px; align-items:flex-start; margin-left:auto; }
|
||||
.ud-actions .btn-edit-q, .ud-actions .btn-del-q { white-space:nowrap; }
|
||||
.ud-tabs { display:flex; gap:2px; border-bottom:1px solid var(--border); margin-bottom:20px; overflow-x:auto; }
|
||||
.ud-tab-btn { background:transparent; border:0; padding:11px 18px; font-family:inherit; font-size:0.86rem; font-weight:600; color:var(--text-3); cursor:pointer; border-bottom:2px solid transparent; transition:color .12s, border-color .12s; white-space:nowrap; }
|
||||
.ud-tab-btn:hover { color:var(--text-2); }
|
||||
.ud-tab-btn.active { color:var(--violet); border-bottom-color:var(--violet); }
|
||||
.ud-tab-pane { display:none; }
|
||||
.ud-tab-pane.active { display:block; }
|
||||
.ud-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px; margin-bottom:24px; }
|
||||
.ud-stat { padding:18px 18px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); }
|
||||
.ud-stat-val { font-family:'Unbounded',sans-serif; font-size:1.5rem; font-weight:800; line-height:1.1; }
|
||||
.ud-stat-val.pct-hi { color:var(--green); }
|
||||
.ud-stat-val.pct-mid { color:var(--amber); }
|
||||
.ud-stat-val.pct-lo { color:var(--pink); }
|
||||
.ud-stat-label { font-size:0.74rem; color:var(--text-3); font-weight:600; text-transform:uppercase; letter-spacing:.03em; margin-top:6px; }
|
||||
.ud-sess-list { display:flex; flex-direction:column; gap:6px; }
|
||||
.ud-sess-row { display:flex; align-items:center; gap:14px; padding:12px 16px; background:var(--surface); border:1px solid var(--border); border-radius:12px; cursor:pointer; transition:border-color .12s, background .12s; }
|
||||
.ud-sess-row:hover { border-color:rgba(155,93,229,.35); background:rgba(155,93,229,.04); }
|
||||
.ud-sess-pct { font-family:'Unbounded',sans-serif; font-weight:800; font-size:0.9rem; width:50px; text-align:center; padding:6px 0; border-radius:8px; }
|
||||
.ud-sess-pct.pct-hi { color:var(--green); background:rgba(16,185,129,.1); }
|
||||
.ud-sess-pct.pct-mid { color:var(--amber); background:rgba(255,179,71,.12); }
|
||||
.ud-sess-pct.pct-lo { color:var(--pink); background:rgba(241,91,181,.1); }
|
||||
.ud-sess-info { flex:1; min-width:0; }
|
||||
.ud-sess-subj { font-weight:600; font-size:0.9rem; }
|
||||
.ud-sess-meta { font-size:0.76rem; color:var(--text-3); margin-top:2px; }
|
||||
.ud-sess-score { font-weight:700; font-size:0.88rem; }
|
||||
.ud-sess-chev { color:var(--text-3); flex-shrink:0; }
|
||||
.ud-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); }
|
||||
.ud-audit-list { display:flex; flex-direction:column; gap:6px; }
|
||||
.ud-audit-row { display:flex; gap:14px; padding:10px 14px; background:var(--surface); border:1px solid var(--border); border-radius:10px; font-size:0.84rem; align-items:center; flex-wrap:wrap; }
|
||||
.ud-audit-when { font-size:0.74rem; color:var(--text-3); min-width:140px; }
|
||||
.ud-audit-action { font-weight:700; font-size:0.78rem; }
|
||||
.ud-audit-detail { color:var(--text-3); font-size:0.78rem; flex:1; min-width:140px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ud-chart-card { padding:18px 20px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); margin-top:20px; }
|
||||
.ud-chart-title { font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-3); margin-bottom:12px; }
|
||||
.ud-bars { display:flex; flex-direction:column; gap:8px; }
|
||||
.ud-bar-row { display:flex; align-items:center; gap:10px; }
|
||||
.ud-bar-name { font-size:0.84rem; min-width:120px; }
|
||||
.ud-bar-track { flex:1; height:18px; background:rgba(15,23,42,.06); border-radius:6px; overflow:hidden; position:relative; }
|
||||
.ud-bar-fill { height:100%; border-radius:6px; transition:width .3s; }
|
||||
.ud-bar-fill.pct-hi { background:var(--green); }
|
||||
.ud-bar-fill.pct-mid { background:var(--amber); }
|
||||
.ud-bar-fill.pct-lo { background:var(--pink); }
|
||||
.ud-bar-val { font-family:'Unbounded',sans-serif; font-size:0.82rem; font-weight:700; min-width:48px; text-align:right; }
|
||||
@media (max-width: 640px) {
|
||||
.ud-header { padding:18px 16px; gap:14px; }
|
||||
.ud-actions { margin-left:0; width:100%; }
|
||||
.ud-actions .btn-edit-q, .ud-actions .btn-del-q { font-size:0.78rem; padding:6px 10px; }
|
||||
.ud-sess-row { padding:10px 12px; gap:10px; }
|
||||
.ud-sess-meta { font-size:0.72rem; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { student:'Ученик', free_student:'Своб. ученик', teacher:'Учитель', admin:'Админ' };
|
||||
const ROLE_BG = {
|
||||
admin: 'linear-gradient(135deg,#9B5DE5,#c084fc)',
|
||||
teacher: 'linear-gradient(135deg,#06D6E0,#9B5DE5)',
|
||||
free_student: 'linear-gradient(135deg,#10B981,#059669)',
|
||||
student: 'linear-gradient(135deg,#8898AA,#3D4F6B)',
|
||||
};
|
||||
const ROLE_BADGE_BG = {
|
||||
admin: 'rgba(155,93,229,.14)', teacher: 'rgba(6,214,224,.14)',
|
||||
free_student: 'rgba(16,185,129,.14)', student: 'rgba(136,152,170,.14)',
|
||||
};
|
||||
const ROLE_BADGE_FG = {
|
||||
admin: 'var(--violet)', teacher: '#05aab3',
|
||||
free_student: 'var(--green)', student: 'var(--text-2)',
|
||||
};
|
||||
|
||||
/* SVG icons */
|
||||
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>',
|
||||
chev: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
||||
ban: '<i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i>',
|
||||
};
|
||||
|
||||
/* State */
|
||||
let _userId = null;
|
||||
let _userData = null; // last fetched user object
|
||||
let _sessions = []; // last fetched sessions array
|
||||
let _activeSubTab = 'overview';
|
||||
|
||||
/* ── Public init: called by admin.js dispatch ── */
|
||||
async function init(id, subTab) {
|
||||
ensureUdStyles();
|
||||
const newId = Number(id);
|
||||
if (!Number.isFinite(newId) || newId <= 0) {
|
||||
renderError('Некорректный ID пользователя');
|
||||
return;
|
||||
}
|
||||
_activeSubTab = subTab || 'overview';
|
||||
// Make user-related modal handlers (openEditUserModal etc.) work — they read window.activeUid.
|
||||
window.activeUid = newId;
|
||||
|
||||
if (_userId === newId && _userData) {
|
||||
// Same user — just switch sub-tab without re-fetch
|
||||
renderShell();
|
||||
switchSubTab(_activeSubTab, /*pushUrl*/ false);
|
||||
return;
|
||||
}
|
||||
_userId = newId;
|
||||
_userData = null;
|
||||
_sessions = [];
|
||||
renderLoading();
|
||||
try {
|
||||
const data = await LS.adminGetUserSessions(newId);
|
||||
_userData = data.user;
|
||||
_sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
// Sync globals used by overlay-era modal helpers (still live in users.js).
|
||||
window.activeUid = newId;
|
||||
window.activeUserRole = _userData?.role || null;
|
||||
renderShell();
|
||||
switchSubTab(_activeSubTab, /*pushUrl*/ false);
|
||||
} catch (e) {
|
||||
renderError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const el = document.getElementById('user-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="ud-wrap"><div class="spinner"></div></div>';
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
const el = document.getElementById('user-detail-content');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="ud-wrap">
|
||||
<button type="button" class="ud-back" onclick="AdminRouter.navigate('#users')">${ICONS.arrowLeft} К списку</button>
|
||||
<div class="ud-empty" style="color:var(--pink)">${esc(msg)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
const el = document.getElementById('user-detail-content');
|
||||
if (!el || !_userData) return;
|
||||
const u = _userData;
|
||||
const isAdmin = AdminCtx.isAdmin;
|
||||
const isSelf = AdminCtx.user && AdminCtx.user.id === u.id;
|
||||
const canAct = isAdmin && !isSelf;
|
||||
const initials = (u.name || '?').split(' ').slice(0, 2).map(w => (w[0] || '').toUpperCase()).join('') || '?';
|
||||
const avatarBg = ROLE_BG[u.role] || ROLE_BG.student;
|
||||
const roleLabel = ROLE_LABEL[u.role] || u.role;
|
||||
const bannedTag = u.is_banned ? ' <span class="ud-banned-tag">заблокирован</span>' : '';
|
||||
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
|
||||
|
||||
const actions = canAct ? `
|
||||
<div class="ud-actions">
|
||||
<button class="btn-edit-q" onclick="openEditUserModal()"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
|
||||
${u.role === 'teacher' ? '<button class="btn-edit-q" onclick="openUserPermsModal()"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>' : ''}
|
||||
<button class="btn-del-q" onclick="clearUserHistory()"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
|
||||
<button class="btn-del-q" onclick="toggleBanUser()" ${u.is_banned ? 'style="background:rgba(34,197,94,.12);color:#22C55E;border-color:rgba(34,197,94,.25)"' : ''}>${ICONS.ban} <span id="up-ban-label">${banLabel}</span></button>
|
||||
<button class="btn-del-q" onclick="confirmDeleteUser()" style="background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)"><i data-lucide="user-x" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button>
|
||||
</div>` : '';
|
||||
|
||||
const created = u.created_at ? AdminCtx.fmtDate(u.created_at) : '—';
|
||||
const lastLog = u.last_login ? new Date(u.last_login).toLocaleString('ru', { day:'numeric', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }) : '—';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ud-wrap">
|
||||
<button type="button" class="ud-back" onclick="AdminRouter.navigate('#users')">${ICONS.arrowLeft} К списку пользователей</button>
|
||||
<div class="ud-header">
|
||||
<div class="ud-avatar${u.is_banned ? ' banned' : ''}" style="background:${avatarBg}">${esc(initials)}</div>
|
||||
<div class="ud-id-block">
|
||||
<div class="ud-name">
|
||||
<span id="up-name">${esc(u.name)}</span>
|
||||
<span class="ud-role-badge" style="background:${ROLE_BADGE_BG[u.role] || ROLE_BADGE_BG.student};color:${ROLE_BADGE_FG[u.role] || ROLE_BADGE_FG.student}">${roleLabel}</span>
|
||||
${bannedTag}
|
||||
</div>
|
||||
<div class="ud-email" id="up-email">${esc(u.email || '')}</div>
|
||||
<div class="ud-meta-row">
|
||||
<span><strong>Регистрация:</strong> ${created}</span>
|
||||
<span><strong>Последний вход:</strong> ${lastLog}</span>
|
||||
<span><strong>ID:</strong> #${u.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
${actions}
|
||||
</div>
|
||||
<div class="ud-tabs" role="tablist">
|
||||
<button type="button" class="ud-tab-btn" data-st="overview" onclick="udSwitchTab('overview')">Обзор</button>
|
||||
<button type="button" class="ud-tab-btn" data-st="sessions" onclick="udSwitchTab('sessions')">Сессии</button>
|
||||
<button type="button" class="ud-tab-btn" data-st="classes" onclick="udSwitchTab('classes')">Классы</button>
|
||||
${isAdmin ? '<button type="button" class="ud-tab-btn" data-st="audit" onclick="udSwitchTab(\'audit\')">Audit</button>' : ''}
|
||||
</div>
|
||||
<div class="ud-tab-pane" id="ud-pane-overview"></div>
|
||||
<div class="ud-tab-pane" id="ud-pane-sessions"></div>
|
||||
<div class="ud-tab-pane" id="ud-pane-classes"></div>
|
||||
<div class="ud-tab-pane" id="ud-pane-audit"></div>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||
}
|
||||
|
||||
function switchSubTab(name, pushUrl) {
|
||||
const allowed = ['overview', 'sessions', 'classes', 'audit'];
|
||||
if (!allowed.includes(name)) name = 'overview';
|
||||
_activeSubTab = name;
|
||||
document.querySelectorAll('#user-detail-content .ud-tab-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.st === name);
|
||||
});
|
||||
document.querySelectorAll('#user-detail-content .ud-tab-pane').forEach(p => p.classList.remove('active'));
|
||||
const pane = document.getElementById('ud-pane-' + name);
|
||||
if (pane) pane.classList.add('active');
|
||||
|
||||
if (pushUrl && window.AdminRouter && _userId) {
|
||||
const target = name === 'overview' ? `#users/${_userId}` : `#users/${_userId}/${name}`;
|
||||
AdminRouter.navigate(target, { replace: true, silent: true });
|
||||
}
|
||||
|
||||
if (name === 'overview') renderOverview();
|
||||
else if (name === 'sessions') renderSessions();
|
||||
else if (name === 'classes') renderClasses();
|
||||
else if (name === 'audit') renderAudit();
|
||||
}
|
||||
|
||||
/* ── Overview tab ── */
|
||||
function renderOverview() {
|
||||
const pane = document.getElementById('ud-pane-overview');
|
||||
if (!pane || !_userData) return;
|
||||
const u = _userData;
|
||||
const total = _sessions.length;
|
||||
const completed = _sessions.filter(s => s.score !== null && s.score !== undefined);
|
||||
const avgPct = completed.length
|
||||
? Math.round(completed.reduce((acc, s) => acc + Math.round((s.score / s.total) * 100), 0) / completed.length)
|
||||
: null;
|
||||
const pcCls = AdminCtx.pctClass(avgPct);
|
||||
const lastSess = _sessions[0];
|
||||
const lastDate = lastSess ? AdminCtx.fmtDate(lastSess.started_at) : '—';
|
||||
|
||||
// Aggregate by subject for simple bar chart
|
||||
const bySubj = {};
|
||||
completed.forEach(s => {
|
||||
const k = s.subject_name || 'Без предмета';
|
||||
bySubj[k] = bySubj[k] || { sum: 0, n: 0 };
|
||||
bySubj[k].sum += Math.round((s.score / s.total) * 100);
|
||||
bySubj[k].n += 1;
|
||||
});
|
||||
const subjBars = Object.entries(bySubj)
|
||||
.map(([name, v]) => ({ name, pct: Math.round(v.sum / v.n), n: v.n }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
.slice(0, 6);
|
||||
|
||||
const barHtml = subjBars.length ? subjBars.map(b => {
|
||||
const pc = AdminCtx.pctClass(b.pct);
|
||||
return `<div class="ud-bar-row">
|
||||
<div class="ud-bar-name">${esc(b.name)} <span style="color:var(--text-3);font-size:0.72rem">(${b.n})</span></div>
|
||||
<div class="ud-bar-track"><div class="ud-bar-fill ${pc}" style="width:${b.pct}%"></div></div>
|
||||
<div class="ud-bar-val">${b.pct}%</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="ud-empty" style="padding:14px">Нет данных по предметам</div>';
|
||||
|
||||
pane.innerHTML = `
|
||||
<div class="ud-stats">
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val">${total}</div>
|
||||
<div class="ud-stat-label">Всего сессий</div>
|
||||
</div>
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val ${pcCls}">${avgPct !== null ? avgPct + '%' : '—'}</div>
|
||||
<div class="ud-stat-label">Средний %</div>
|
||||
</div>
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val" style="font-size:1rem">${u.created_at ? AdminCtx.fmtDate(u.created_at) : '—'}</div>
|
||||
<div class="ud-stat-label">Регистрация</div>
|
||||
</div>
|
||||
<div class="ud-stat">
|
||||
<div class="ud-stat-val" style="font-size:1rem">${lastDate}</div>
|
||||
<div class="ud-stat-label">Последняя сессия</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ud-chart-card">
|
||||
<div class="ud-chart-title">Успеваемость по предметам</div>
|
||||
<div class="ud-bars">${barHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Sessions tab ── */
|
||||
function renderSessions() {
|
||||
const pane = document.getElementById('ud-pane-sessions');
|
||||
if (!pane) return;
|
||||
if (!_sessions.length) {
|
||||
pane.innerHTML = '<div class="ud-empty">Тестов нет</div>';
|
||||
return;
|
||||
}
|
||||
const { MODES, pctClass, fmtDate } = AdminCtx;
|
||||
pane.innerHTML = '<div class="ud-sess-list">' + _sessions.map(s => {
|
||||
const pct = (s.score !== null && s.score !== undefined && s.total)
|
||||
? Math.round((s.score / s.total) * 100)
|
||||
: null;
|
||||
const pc = pctClass(pct);
|
||||
return `<div class="ud-sess-row" onclick="AdminRouter.navigate('#sessions/${s.id}')">
|
||||
<div class="ud-sess-pct ${pc}">${pct !== null ? pct + '%' : '—'}</div>
|
||||
<div class="ud-sess-info">
|
||||
<div class="ud-sess-subj">${esc(s.subject_name || 'Тест')}</div>
|
||||
<div class="ud-sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode] || s.mode}</div>
|
||||
</div>
|
||||
<div class="ud-sess-score">${s.score ?? '—'} / ${s.total}</div>
|
||||
<div class="ud-sess-chev">${ICONS.chev}</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
/* ── Classes tab ── */
|
||||
/* No per-user "classes" endpoint exists; show empty state pointing to the
|
||||
* Classes section. Post-merge: add GET /admin/users/:id/classes for full list.
|
||||
*/
|
||||
function renderClasses() {
|
||||
const pane = document.getElementById('ud-pane-classes');
|
||||
if (!pane) return;
|
||||
pane.innerHTML = `<div class="ud-empty">
|
||||
Информация о классах пользователя пока недоступна.<br>
|
||||
<a href="/classes" style="color:var(--violet);font-weight:600;text-decoration:none">Открыть управление классами →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Audit tab ── */
|
||||
/* audit_log is system-wide; filter client-side by target containing user_id
|
||||
* or by admin_id if this user IS an admin. */
|
||||
async function renderAudit() {
|
||||
const pane = document.getElementById('ud-pane-audit');
|
||||
if (!pane) return;
|
||||
pane.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const rows = await LS.api('/api/admin/audit-log?limit=500');
|
||||
const uid = _userId;
|
||||
// Match if target string includes "user:<uid>" or "userId=<uid>" or starts with uid,
|
||||
// or if admin_id equals uid (this user performed the action).
|
||||
const re = new RegExp(`(^|\\D)${uid}(\\D|$)`);
|
||||
const filtered = (rows || []).filter(r => {
|
||||
if (r.admin_id === uid) return true;
|
||||
if (r.target && re.test(String(r.target))) return true;
|
||||
return false;
|
||||
});
|
||||
if (!filtered.length) {
|
||||
pane.innerHTML = '<div class="ud-empty">Нет записей аудита, связанных с этим пользователем</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': 'Рассылка', 'session.delete': 'Удаление сессии',
|
||||
};
|
||||
pane.innerHTML = '<div class="ud-audit-list">' + filtered.map(r => {
|
||||
const dt = new Date(r.created_at);
|
||||
const when = dt.toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }) +
|
||||
' ' + dt.toLocaleTimeString('ru', { hour:'2-digit', minute:'2-digit' });
|
||||
const lbl = ACTION_LABELS[r.action] || r.action;
|
||||
const who = r.admin_id === uid ? '(сам пользователь)' : (r.admin_name ? `от ${esc(r.admin_name)}` : '');
|
||||
return `<div class="ud-audit-row">
|
||||
<span class="ud-audit-when">${when}</span>
|
||||
<span class="ud-audit-action" style="color:var(--violet)">${esc(lbl)}</span>
|
||||
<span class="ud-audit-detail">${esc(r.detail || '')} ${who}</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
} catch (e) {
|
||||
pane.innerHTML = `<div class="ud-empty" style="color:var(--pink)">Ошибка загрузки аудита: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reload after mutations (called from action handlers) ── */
|
||||
async function reload() {
|
||||
if (!_userId) return;
|
||||
try {
|
||||
const data = await LS.adminGetUserSessions(_userId);
|
||||
_userData = data.user;
|
||||
_sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
window.activeUserRole = _userData?.role || null;
|
||||
renderShell();
|
||||
switchSubTab(_activeSubTab, /*pushUrl*/ false);
|
||||
} catch (e) {
|
||||
LS.toast('Не удалось обновить: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expose handlers used by inline onclicks ── */
|
||||
window.udSwitchTab = function (name) { switchSubTab(name, /*pushUrl*/ true); };
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections['user-detail'] = {
|
||||
/* Called by admin.js dispatch. id REQUIRED. subTab optional. */
|
||||
init,
|
||||
reload,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,461 @@
|
||||
'use strict';
|
||||
/* admin → users section: users table + pagination + user-panel overlay + user-perms modal */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
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-related modal state.
|
||||
* After Phase 6 the .user-panel overlay is gone — instead the modals
|
||||
* (edit, perms) operate on window.activeUid which is set by user-detail.js
|
||||
* when the deep page opens, or transiently by row actions on the list. */
|
||||
let _editUid = null;
|
||||
let _upPermsData = null;
|
||||
// Helper: read the currently-active user id (set by user-detail.js or quick actions).
|
||||
const getActiveUid = () => window.activeUid || null;
|
||||
// Helper: after a mutation that may affect the active user, refresh the deep page
|
||||
// (if it's currently showing the same user) AND the list.
|
||||
function reloadDetailAndList() {
|
||||
const sec = (window.AdminSections || {})['user-detail'];
|
||||
if (sec && typeof sec.reload === 'function') sec.reload();
|
||||
load();
|
||||
}
|
||||
|
||||
async function load(page) {
|
||||
const { pctClass, fmtDate, renderPgnControls } = AdminCtx;
|
||||
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 || [];
|
||||
const tbody = document.getElementById('users-body');
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>';
|
||||
document.getElementById('users-pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = users.map(u => {
|
||||
const pc = pctClass(u.avg_pct);
|
||||
const initials = (u.name||'?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'?';
|
||||
const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)';
|
||||
const roleCell = isAdmin && u.id !== user.id
|
||||
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
|
||||
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option>
|
||||
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option>
|
||||
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option>
|
||||
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option>
|
||||
</select>`
|
||||
: `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`;
|
||||
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="AdminRouter.navigate('#users/${u.id}')">
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:36px;height:36px;border-radius:10px;background:${avatarBg};display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.62rem;font-weight:800;color:#fff;flex-shrink:0;${u.is_banned?'filter:grayscale(1);opacity:.5':''}">${initials}</div>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:0.88rem;color:var(--text)">${esc(u.name)}${u.is_banned ? ' <span style="font-size:0.7rem;background:rgba(239,68,68,.12);color:#EF4444;border-radius:4px;padding:1px 5px;font-weight:600;vertical-align:middle">заблокирован</span>' : ''}</div>
|
||||
<div style="color:var(--text-3);font-size:0.76rem">${esc(u.email)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td onclick="event.stopPropagation()">${roleCell}</td>
|
||||
<td style="font-weight:700">${u.tests_count}</td>
|
||||
<td>
|
||||
<span class="pct-cell ${pc}">${u.avg_pct !== null ? u.avg_pct+'%' : '—'}</span>
|
||||
${u.avg_pct !== null ? `<div class="perf-bar"><div class="perf-fill ${pc}" style="width:${u.avg_pct}%"></div></div>` : ''}
|
||||
</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 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');
|
||||
} catch (e) {
|
||||
document.getElementById('users-body').innerHTML = `<tr><td colspan="7"></td></tr>`;
|
||||
LS.state.error(document.getElementById('users-body').querySelector('td'), e, load);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 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 ? 'Разблокировать' : 'Заблокировать';
|
||||
// Pass uid/name via data-* attributes (esc() escapes & < > " for the attribute
|
||||
// context; dataset reads back the raw string — no JS-string injection surface).
|
||||
return `<div class="row-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="row-action-btn" title="${banLabel}"
|
||||
data-uid="${u.id}" data-banned="${u.is_banned?1:0}"
|
||||
onclick="event.stopPropagation();quickToggleBan(this)">${banIcon}</button>
|
||||
<button type="button" class="row-action-btn" title="Начислить монеты"
|
||||
data-uid="${u.id}" data-name="${esc(u.name)}"
|
||||
onclick="event.stopPropagation();quickAwardCoins(this)">${ICONS.coins}</button>
|
||||
<button type="button" class="row-action-btn" title="История сессий"
|
||||
data-uid="${u.id}"
|
||||
onclick="event.stopPropagation();quickOpenUserSessions(this)">${ICONS.history}</button>
|
||||
<button type="button" class="row-action-btn danger" title="Удалить пользователя"
|
||||
data-uid="${u.id}" data-name="${esc(u.name)}"
|
||||
onclick="event.stopPropagation();quickDeleteUser(this)">${ICONS.trash}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function quickToggleBan(btn) {
|
||||
const uid = +btn.dataset.uid;
|
||||
const isBanned = +btn.dataset.banned;
|
||||
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 (getActiveUid() === uid) {
|
||||
const sec = (window.AdminSections || {})['user-detail'];
|
||||
if (sec && typeof sec.reload === 'function') sec.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function quickAwardCoins(btn) {
|
||||
const uid = +btn.dataset.uid;
|
||||
const name = btn.dataset.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(btn) {
|
||||
const uid = +btn.dataset.uid;
|
||||
// Phase 6: open the user's deep page with the Sessions sub-tab active.
|
||||
if (window.AdminRouter) AdminRouter.navigate('#users/' + uid + '/sessions');
|
||||
else if (typeof window.switchTab === 'function') {
|
||||
const btn = document.querySelector('.admin-nav-item[onclick*="sessions"]');
|
||||
if (btn) window.switchTab(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function quickDeleteUser(btn) {
|
||||
const uid = +btn.dataset.uid;
|
||||
const name = btn.dataset.name || '';
|
||||
if (!await LS.confirm(
|
||||
`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
|
||||
{ title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }
|
||||
)) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await LS.adminDeleteUser(uid);
|
||||
LS.toast('Пользователь удалён', 'success');
|
||||
// If the deleted user is currently open as a deep page, go back to the list.
|
||||
if (getActiveUid() === uid && window.AdminRouter) {
|
||||
AdminRouter.navigate('#users');
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gotoUsersPage(n) {
|
||||
_usersPage = n;
|
||||
load();
|
||||
document.getElementById('tab-users')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function changeRole(select) {
|
||||
select.disabled = true;
|
||||
try { await LS.adminUpdateRole(select.dataset.uid, select.value); LS.toast('Роль изменена', 'success', 2000); }
|
||||
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
finally { select.disabled = false; }
|
||||
}
|
||||
|
||||
/* ─── User actions (called from the user-detail deep page header buttons) ───
|
||||
* Pre-Phase 6 these talked to the .user-panel overlay; now they:
|
||||
* - read the active uid via getActiveUid() (set by user-detail.init)
|
||||
* - read display name from the #up-name span rendered inside the deep page
|
||||
* - reload via AdminSections['user-detail'].reload() */
|
||||
function _activeName() {
|
||||
const el = document.getElementById('up-name');
|
||||
return el ? el.textContent.trim() : '';
|
||||
}
|
||||
|
||||
async function clearUserHistory() {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
const name = _activeName();
|
||||
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
|
||||
try {
|
||||
await LS.adminClearUserSessions(uid);
|
||||
reloadDetailAndList();
|
||||
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleBanUser() {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
const banLbl = document.getElementById('up-ban-label');
|
||||
const isBanning = banLbl ? banLbl.textContent.trim() === 'Заблокировать' : true;
|
||||
const name = _activeName();
|
||||
const msg = isBanning
|
||||
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
|
||||
: `Разблокировать пользователя «${name}»?`;
|
||||
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
|
||||
try {
|
||||
await LS.adminBanUser(uid, isBanning);
|
||||
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
|
||||
reloadDetailAndList();
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
const name = _activeName();
|
||||
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
|
||||
try {
|
||||
await LS.adminDeleteUser(uid);
|
||||
LS.toast('Пользователь удалён', 'success');
|
||||
if (window.AdminRouter) AdminRouter.navigate('#users');
|
||||
load();
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ─── Edit user modal ─── */
|
||||
function closeEditUserModal() {
|
||||
document.getElementById('eu-modal').classList.remove('open');
|
||||
_editUid = null;
|
||||
}
|
||||
|
||||
function openEditUserModal() {
|
||||
_editUid = getActiveUid();
|
||||
if (!_editUid) return;
|
||||
document.getElementById('eu-name').value = document.getElementById('up-name').textContent;
|
||||
document.getElementById('eu-email').value = document.getElementById('up-email').textContent;
|
||||
document.getElementById('eu-password').value = '';
|
||||
document.getElementById('eu-error').textContent = '';
|
||||
document.getElementById('eu-modal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('eu-name').focus(), 80);
|
||||
}
|
||||
|
||||
async function saveEditUser() {
|
||||
const name = document.getElementById('eu-name').value.trim();
|
||||
const email = document.getElementById('eu-email').value.trim();
|
||||
const password = document.getElementById('eu-password').value;
|
||||
const errEl = document.getElementById('eu-error');
|
||||
errEl.textContent = '';
|
||||
if (!name) { errEl.textContent = 'Введите имя'; return; }
|
||||
if (!email) { errEl.textContent = 'Введите email'; return; }
|
||||
if (password && password.length < 6) { errEl.textContent = 'Пароль должен быть не менее 6 символов'; return; }
|
||||
const payload = { name, email };
|
||||
if (password) payload.password = password;
|
||||
const btn = document.getElementById('eu-save');
|
||||
btn.disabled = true; btn.textContent = 'Сохранение…';
|
||||
try {
|
||||
await LS.adminUpdateUser(_editUid, payload);
|
||||
closeEditUserModal();
|
||||
reloadDetailAndList();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── User permissions modal (opened from inside user-panel) ─── */
|
||||
function closeUserPermsModal() {
|
||||
document.getElementById('up-modal').classList.remove('open');
|
||||
_upPermsData = null;
|
||||
}
|
||||
|
||||
async function openUserPermsModal() {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
const name = _activeName();
|
||||
document.getElementById('up-modal-title').textContent = `Права: ${name}`;
|
||||
document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row');
|
||||
document.getElementById('up-modal').classList.add('open');
|
||||
try {
|
||||
_upPermsData = await LS.getUserPermissions(uid);
|
||||
renderUserPerms();
|
||||
} catch(e) {
|
||||
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserPerms() {
|
||||
if (!_upPermsData) return;
|
||||
const list = document.getElementById('up-modal-list');
|
||||
list.innerHTML = _upPermsData.permissions.map(p => {
|
||||
const hasOverride = p.userVal !== undefined;
|
||||
const checked = p.effective;
|
||||
const badge = hasOverride
|
||||
? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Инд.</span>`
|
||||
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
|
||||
const resetBtn = hasOverride
|
||||
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
|
||||
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
|
||||
onclick="doResetOneUserPerm('${esc(p.key)}')" title="Сбросить к роли">×</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
|
||||
<div class="perm-info">
|
||||
<div style="display:flex;align-items:center;gap:7px">
|
||||
<span class="perm-label">${esc(p.label)}</span>
|
||||
${badge}
|
||||
${resetBtn}
|
||||
</div>
|
||||
<div class="perm-desc">${esc(p.desc)}</div>
|
||||
</div>
|
||||
<label class="perm-toggle">
|
||||
<input type="checkbox" ${checked ? 'checked' : ''}
|
||||
onchange="doSetUserPerm('${esc(p.key)}', this.checked, this)">
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const hasAny = _upPermsData.permissions.some(p => p.userVal !== undefined);
|
||||
document.getElementById('up-modal-reset-btn').style.opacity = hasAny ? '1' : '0.4';
|
||||
}
|
||||
|
||||
async function doSetUserPerm(key, enabled, checkbox) {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
await LS.setUserPermission(uid, key, enabled);
|
||||
_upPermsData = await LS.getUserPermissions(uid);
|
||||
renderUserPerms();
|
||||
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
|
||||
} catch(e) {
|
||||
checkbox.checked = !enabled;
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
} finally {
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doResetOneUserPerm(key) {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
try {
|
||||
await LS.resetUserPermissions(uid, key);
|
||||
_upPermsData = await LS.getUserPermissions(uid);
|
||||
renderUserPerms();
|
||||
LS.toast('Сброшено к значению роли', 'success');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function doResetAllUserPerms() {
|
||||
const uid = getActiveUid();
|
||||
if (!uid) return;
|
||||
const name = _activeName();
|
||||
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
|
||||
try {
|
||||
await LS.resetUserPermissions(uid);
|
||||
_upPermsData = await LS.getUserPermissions(uid);
|
||||
renderUserPerms();
|
||||
LS.toast('Права сброшены к роли', 'success');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Expose handlers used by HTML onclicks
|
||||
window.loadUsers = load;
|
||||
window.gotoUsersPage = gotoUsersPage;
|
||||
window.changeRole = changeRole;
|
||||
window.clearUserHistory = clearUserHistory;
|
||||
window.toggleBanUser = toggleBanUser;
|
||||
window.confirmDeleteUser = confirmDeleteUser;
|
||||
window.closeEditUserModal = closeEditUserModal;
|
||||
window.openEditUserModal = openEditUserModal;
|
||||
window.saveEditUser = saveEditUser;
|
||||
window.closeUserPermsModal = closeUserPermsModal;
|
||||
window.openUserPermsModal = openUserPermsModal;
|
||||
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 = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -1585,7 +1585,7 @@ class ChemSandboxSim {
|
||||
if (rx.fx.gas) {
|
||||
questions.push(`Получи газ ${rx.fx.gas}`);
|
||||
}
|
||||
questions.push(`Проведи реакцию: ${prods}`);
|
||||
questions.push(`Проведи реакцию: ${_csClean(prods)}`);
|
||||
if (rx.type === 'Нейтрализация') {
|
||||
questions.push('Проведи реакцию нейтрализации');
|
||||
}
|
||||
@@ -1622,7 +1622,7 @@ class ChemSandboxSim {
|
||||
score: this._quizScore,
|
||||
total: this._quizTotal,
|
||||
result: this._quizResult,
|
||||
answer: this._quizTask ? this._quizTask.rx.eq : null,
|
||||
answer: this._quizTask ? _csClean(this._quizTask.rx.eq) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1821,7 +1821,9 @@ class ChemSandboxSim {
|
||||
const eqEl = document.getElementById('csbar-v4');
|
||||
eqEl.innerHTML = info.equation || '—';
|
||||
eqEl.title = (info.equation || '').replace(/<[^>]*>/g, '');
|
||||
document.getElementById('csbar-v5').textContent = info.products || '—';
|
||||
const prodEl = document.getElementById('csbar-v5');
|
||||
prodEl.innerHTML = info.products || '—';
|
||||
prodEl.title = (info.products || '').replace(/<[^>]*>/g, '');
|
||||
const ionEl = document.getElementById('csbar-v6');
|
||||
ionEl.innerHTML = info.ionNet || '—';
|
||||
ionEl.title = (info.ionNet || '').replace(/<[^>]*>/g, '');
|
||||
|
||||
@@ -709,7 +709,7 @@ class CollisionSim {
|
||||
ctx.fillText(label, ix, iy);
|
||||
} else if (lossPct === 0 && keBefore > 0.1) {
|
||||
const ix = this._impactPt.x, iy = this._impactPt.y - 42;
|
||||
const label = 'KE сохранена <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const label = 'KE сохранена ✓';
|
||||
ctx.font = 'bold 10px Manrope';
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(123,245,164,.15)';
|
||||
|
||||
@@ -15,11 +15,11 @@ class IonExSim {
|
||||
reacts: ['Ba²⁺', 'SO₄²⁻'],
|
||||
spectators: ['Cl⁻', 'Na⁺'],
|
||||
product: { f: 'BaSO₄', color: '#E0E0E0' },
|
||||
mol: 'BaCl₂ + Na₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaCl',
|
||||
full_ion: 'Ba²⁺ + 2Cl⁻ + 2Na⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2Cl⁻',
|
||||
net_ion: 'Ba²⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
||||
mol: 'BaCl₂ + Na₂SO₄ → BaSO₄↓ + 2NaCl',
|
||||
full_ion: 'Ba²⁺ + 2Cl⁻ + 2Na⁺ + SO₄²⁻ → BaSO₄↓ + 2Na⁺ + 2Cl⁻',
|
||||
net_ion: 'Ba²⁺ + SO₄²⁻ → BaSO₄↓',
|
||||
type: 'precip', pcolor: '#E0E0E0', pname: 'BaSO₄ — белый осадок',
|
||||
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#E0E0E0',
|
||||
sign: '↓', signColor: '#E0E0E0',
|
||||
},
|
||||
ag_cl: {
|
||||
name: 'AgNO₃ + NaCl',
|
||||
@@ -28,11 +28,11 @@ class IonExSim {
|
||||
reacts: ['Ag⁺', 'Cl⁻'],
|
||||
spectators: ['NO₃⁻', 'Na⁺'],
|
||||
product: { f: 'AgCl', color: '#F5F5F5' },
|
||||
mol: 'AgNO₃ + NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + NaNO₃',
|
||||
full_ion: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na⁺ + NO₃⁻',
|
||||
net_ion: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
||||
mol: 'AgNO₃ + NaCl → AgCl↓ + NaNO₃',
|
||||
full_ion: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ → AgCl↓ + Na⁺ + NO₃⁻',
|
||||
net_ion: 'Ag⁺ + Cl⁻ → AgCl↓',
|
||||
type: 'precip', pcolor: '#F5F5F5', pname: 'AgCl — белый творожистый осадок',
|
||||
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F5F5F5',
|
||||
sign: '↓', signColor: '#F5F5F5',
|
||||
},
|
||||
co3_hcl: {
|
||||
name: 'Na₂CO₃ + HCl',
|
||||
@@ -40,12 +40,12 @@ class IonExSim {
|
||||
right: [{ f: 'H⁺', color: '#EF5350', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }],
|
||||
reacts: ['CO₃²⁻', 'H⁺'],
|
||||
spectators: ['Na⁺', 'Cl⁻'],
|
||||
product: { f: 'CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', color: '#B0BEC5' },
|
||||
mol: 'Na₂CO₃ + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
|
||||
full_ion: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2Cl⁻ + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
|
||||
net_ion: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
|
||||
product: { f: 'CO₂↑', color: '#B0BEC5' },
|
||||
mol: 'Na₂CO₃ + 2HCl → 2NaCl + CO₂↑ + H₂O',
|
||||
full_ion: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ → 2Na⁺ + 2Cl⁻ + CO₂↑ + H₂O',
|
||||
net_ion: 'CO₃²⁻ + 2H⁺ → CO₂↑ + H₂O',
|
||||
type: 'gas', gcolor: '#B0BEC5', gname: 'CO₂ — углекислый газ',
|
||||
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', signColor: '#B0BEC5',
|
||||
sign: '↑', signColor: '#B0BEC5',
|
||||
},
|
||||
pb_i: {
|
||||
name: 'Pb(NO₃)₂ + KI',
|
||||
@@ -54,11 +54,11 @@ class IonExSim {
|
||||
reacts: ['Pb²⁺', 'I⁻'],
|
||||
spectators: ['NO₃⁻', 'K⁺'],
|
||||
product: { f: 'PbI₂', color: '#F9A825' },
|
||||
mol: 'Pb(NO₃)₂ + 2KI <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KNO₃',
|
||||
full_ion: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + 2I⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + 2NO₃⁻',
|
||||
net_ion: 'Pb²⁺ + 2I⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
||||
mol: 'Pb(NO₃)₂ + 2KI → PbI₂↓ + 2KNO₃',
|
||||
full_ion: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + 2I⁻ → PbI₂↓ + 2K⁺ + 2NO₃⁻',
|
||||
net_ion: 'Pb²⁺ + 2I⁻ → PbI₂↓',
|
||||
type: 'precip', pcolor: '#F9A825', pname: 'PbI₂ — ярко-жёлтый осадок',
|
||||
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F9A825',
|
||||
sign: '↓', signColor: '#F9A825',
|
||||
},
|
||||
ca_co3: {
|
||||
name: 'CaCl₂ + Na₂CO₃',
|
||||
@@ -67,11 +67,11 @@ class IonExSim {
|
||||
reacts: ['Ca²⁺', 'CO₃²⁻'],
|
||||
spectators: ['Cl⁻', 'Na⁺'],
|
||||
product: { f: 'CaCO₃', color: '#F5F5F5' },
|
||||
mol: 'CaCl₂ + Na₂CO₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaCl',
|
||||
full_ion: 'Ca²⁺ + 2Cl⁻ + 2Na⁺ + CO₃²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2Cl⁻',
|
||||
net_ion: 'Ca²⁺ + CO₃²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
||||
mol: 'CaCl₂ + Na₂CO₃ → CaCO₃↓ + 2NaCl',
|
||||
full_ion: 'Ca²⁺ + 2Cl⁻ + 2Na⁺ + CO₃²⁻ → CaCO₃↓ + 2Na⁺ + 2Cl⁻',
|
||||
net_ion: 'Ca²⁺ + CO₃²⁻ → CaCO₃↓',
|
||||
type: 'precip', pcolor: '#F5F5F5', pname: 'CaCO₃ — белый осадок (мел)',
|
||||
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F5F5F5',
|
||||
sign: '↓', signColor: '#F5F5F5',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -466,7 +466,7 @@ class IonExSim {
|
||||
ctx.fillStyle = rxn.signColor; ctx.font = 'bold 10px monospace';
|
||||
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
|
||||
ctx.shadowColor = rxn.signColor; ctx.shadowBlur = 8;
|
||||
const label = rxn.type === 'precip' ? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${rxn.sign} осадок` : `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${rxn.sign} газ`;
|
||||
const label = rxn.type === 'precip' ? `✓ ${rxn.sign} осадок` : `✓ ${rxn.sign} газ`;
|
||||
ctx.fillText(label, W - 14, py + 3);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ class NewtonSim {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Физика I-B : орбита <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> прямолинейное движение ────────── */
|
||||
/* ── Физика I-B : орбита → прямолинейное движение ────────── */
|
||||
|
||||
_step1B(dt) {
|
||||
const s = this._1B;
|
||||
@@ -804,10 +804,10 @@ class NewtonSim {
|
||||
const alpha = Math.min(1, s.forceFlash * 2.5);
|
||||
const fScale = 72 * alpha;
|
||||
const ny = g.gY - CH - 32;
|
||||
/* Сила на ядро <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вправо */
|
||||
this._arrow(ctx, s.cx + CW / 2 + 20, ny, s.cx + CW / 2 + 20 + fScale, ny, '#EF476F', 'F<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>ядро', 2.5);
|
||||
/* Реакция на пушку <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> влево */
|
||||
this._arrow(ctx, s.cx - CW / 2 - 20, ny, s.cx - CW / 2 - 20 - fScale, ny, '#4CC9F0', 'F<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>пушка', 2.5);
|
||||
/* Сила на ядро → вправо */
|
||||
this._arrow(ctx, s.cx + CW / 2 + 20, ny, s.cx + CW / 2 + 20 + fScale, ny, '#EF476F', 'F→ядро', 2.5);
|
||||
/* Реакция на пушку → влево */
|
||||
this._arrow(ctx, s.cx - CW / 2 - 20, ny, s.cx - CW / 2 - 20 - fScale, ny, '#4CC9F0', 'F→пушка', 2.5);
|
||||
|
||||
ctx.save(); ctx.globalAlpha = alpha;
|
||||
ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
|
||||
@@ -990,7 +990,7 @@ class NewtonSim {
|
||||
}
|
||||
/* Falling after fuel out — show gravity arrow */
|
||||
if (s.fuel <= 0 && !s.stopped) {
|
||||
this._arrow(ctx, rx, ry + 25, rx, ry + 65, '#EF476F', 'mg<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', 2.5);
|
||||
this._arrow(ctx, rx, ry + 25, rx, ry + 65, '#EF476F', 'mg↓', 2.5);
|
||||
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#EF476F';
|
||||
ctx.textAlign = 'center'; ctx.fillText('Топливо кончилось — ракета падает!', W / 2, H * 0.15); ctx.textAlign = 'left';
|
||||
}
|
||||
@@ -1009,7 +1009,7 @@ class NewtonSim {
|
||||
ctx.textAlign = 'center'; ctx.fillText('Нажмите «Запуск» для включения двигателя', W / 2, H * 0.50); ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
this._caption(ctx, 'Газ вниз <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ракета вверх\n(3-й закон Ньютона)', W, H);
|
||||
this._caption(ctx, 'Газ вниз → ракета вверх\n(3-й закон Ньютона)', W, H);
|
||||
}
|
||||
|
||||
/* ── Вспомогательные рисовалки ──────────────────────────── */
|
||||
@@ -1350,8 +1350,8 @@ function _nwt_lighten(hex, d) {
|
||||
|
||||
// action button label
|
||||
const lbl = sceneData.action || (law === 1 ? '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Нить' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Действие');
|
||||
document.getElementById('newton-action-label').textContent = lbl;
|
||||
document.getElementById('newton-action-top').textContent = lbl;
|
||||
document.getElementById('newton-action-label').innerHTML = lbl;
|
||||
document.getElementById('newton-action-top').innerHTML = lbl;
|
||||
|
||||
// show/hide sliders
|
||||
document.getElementById('newton-mu-block').style.display = law === 1 && scene === 'A' ? '' : 'none';
|
||||
|
||||
@@ -147,8 +147,8 @@ class ProjectileSim {
|
||||
}
|
||||
}
|
||||
const st = this.stats();
|
||||
const windStr = this.wind !== 0 ? ` <svg class="ic" viewBox="0 0 24 24"><path d="M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2"/><path d="M9.6 4.6A2 2 0 1 1 11 8H2"/><path d="M12.6 19.4A2 2 0 1 0 14 16H2"/></svg>${this.wind > 0 ? '+' : ''}${this.wind}` : '';
|
||||
const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>' : ''}`;
|
||||
const windStr = this.wind !== 0 ? ` ветер ${this.wind > 0 ? '+' : ''}${this.wind}` : '';
|
||||
const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' ↩' : ''}`;
|
||||
const color = this._GHOST_COLORS[this._ghostIdx % this._GHOST_COLORS.length];
|
||||
this._ghostIdx++;
|
||||
this._ghosts.push({ points, color, label, range: st.range, hMax: st.hMax });
|
||||
@@ -811,12 +811,12 @@ class ProjectileSim {
|
||||
bRight -= 130;
|
||||
}
|
||||
if (this.wind !== 0) {
|
||||
const dir = this.wind > 0 ? '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
|
||||
const dir = this.wind > 0 ? '→' : '←';
|
||||
this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
|
||||
bRight -= 130;
|
||||
}
|
||||
if (this.bounce) {
|
||||
this._drawBadge(ctx, bRight, PT + 6, '<svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg> e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)');
|
||||
this._drawBadge(ctx, bRight, PT + 6, '↩ e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)');
|
||||
}
|
||||
|
||||
/* speed badge bottom-right */
|
||||
@@ -1077,7 +1077,7 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
pSim.onPlayPause = projPlayPause;
|
||||
}
|
||||
pSim.fit();
|
||||
projParam(); // sync sliders <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> sim
|
||||
projParam(); // sync sliders → sim
|
||||
pSim.draw();
|
||||
_projUpdateUI(pSim.stats());
|
||||
}));
|
||||
@@ -1187,7 +1187,7 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
|
||||
function projWindChange() {
|
||||
const wind = +document.getElementById('sl-wind').value;
|
||||
const label = wind === 0 ? '0 м/с' : (wind > 0 ? '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> +' : '<svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> ') + Math.abs(wind) + ' м/с';
|
||||
const label = wind === 0 ? '0 м/с' : (wind > 0 ? '→ +' : '← ') + Math.abs(wind) + ' м/с';
|
||||
document.getElementById('p-wind').textContent = label;
|
||||
document.getElementById('ps-loss-wrap').style.display = wind !== 0 ? '' : (pSim && pSim.drag ? '' : 'none');
|
||||
if (pSim) { pSim.setParams({ wind }); _projSyncPlayBtn(); }
|
||||
|
||||
@@ -564,7 +564,7 @@ class ReactionSim {
|
||||
ctx.fillText('C', ex + ew, toY(pE) - 4);
|
||||
|
||||
// Mode label at bottom
|
||||
const modeTxt = { forward: '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> C', reversible: '⇌ A + B ⇌ C', chain: 'цепная реакция' }[this.mode] || '';
|
||||
const modeTxt = { forward: '→ A + B → C', reversible: '⇌ A + B ⇌ C', chain: 'цепная реакция' }[this.mode] || '';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.22)';
|
||||
ctx.font = '8px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
@@ -150,6 +150,11 @@ async function importQuestions(formData) {
|
||||
|
||||
/* ── admin ────────────────────────────────────────────────────────────── */
|
||||
async function adminGetStats() { return req('GET', '/admin/stats'); }
|
||||
async function adminGetOverview() { return req('GET', '/admin/overview'); }
|
||||
async function adminGlobalSearch(q) {
|
||||
// Limits are hardcoded server-side (top 5 users / 3 tests / 3 classes).
|
||||
return req('GET', `/admin/search?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
async function adminGetUsers(params = {}) {
|
||||
const p = new URLSearchParams();
|
||||
if (params.page) p.set('page', params.page);
|
||||
@@ -170,6 +175,7 @@ async function adminGetSessions(params = {}) {
|
||||
return req('GET', `/admin/sessions?${p}`);
|
||||
}
|
||||
async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); }
|
||||
async function adminDeleteSession(id) { return req('DELETE',`/admin/sessions/${id}`); }
|
||||
async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); }
|
||||
async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); }
|
||||
async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); }
|
||||
@@ -939,7 +945,7 @@ window.LS = {
|
||||
register, login, fetchMe, updateProfile,
|
||||
getSubjects, updateSubject, getTopics,
|
||||
startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions,
|
||||
adminGetStats, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
|
||||
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
|
||||
regenerateInviteCode, classJournal,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Feature Context: Admin Panel Redesign
|
||||
|
||||
## Current State
|
||||
|
||||
(будет обновляться после каждой фазы)
|
||||
|
||||
- ✅ Phase 1 implemented — `window.AdminRouter` обёртывает старый `switchTab` (hash ↔ tab двусторонне). `switchTab` принимает 2-й аргумент `{ fromRouter: true }` для предотвращения рекурсии. Default = `#stats`. Файлы: `frontend/js/admin/router.js` (новый), `frontend/admin.html` (+1 строка), `frontend/js/admin/admin.js` (модификация `switchTab` + IIFE `initAdminRouter`).
|
||||
- ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил.
|
||||
- ✅ Phase 3 implemented — `#overview` стал дефолтным route'ом admin-панели. Backend: `GET /api/admin/overview` (admin-only, ~0.08ms/call) возвращает digest за 24ч: новые регистрации, запущенные сессии, активные юзеры, активные классы, failed-сессии, забаненные за неделю (из audit log), топ-5 завершённых сессий. Frontend: `frontend/js/admin/sections/overview.js` (~205L) рендерит bento-grid карточки + alerts + топ-таблицу + quick-links (deep-link через `AdminRouter.navigate`). `admin.js`: дефолт `'stats'` → `'overview'` в `activate()`, initial nav, и initial init. Old `#stats` остался работающим (доступен через nav-item). Файлы: `frontend/js/admin/sections/overview.js` (NEW), `backend/src/controllers/adminController.js` (+57L: `overviewStmts` + `getOverview`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper), `frontend/admin.html` (nav-item + tab-pane + script tag), `frontend/js/admin/admin.js` (ROUTE_TO_SECTION + default route refs).
|
||||
- ✅ Phase 4 implemented — Cmd+K (Ctrl+K) global command palette. Backend: `GET /api/admin/search?q=X` (admin-only) returns `{users[5], tests[3], classes[3]}` via 3 prepared LIKE queries (`title AS name` for tests, `invite_code AS code` for classes). Frontend: `frontend/js/admin/palette.js` (~320L) — custom modal (NOT LS.modal) with capture-phase Ctrl+K listener that `stopImmediatePropagation`'s to override `/js/search.js`. Debounced 150ms, ↑↓ Enter Esc keyboard nav, click-outside close. Action registry (8 entries) is hardcoded — extend by appending to `ACTIONS` const. Result interactions: user → `AdminRouter.navigate('#users/' + id)` (Phase 6 deep page hook), test → `#tests`, class → `/classes#id`. Exposed: `window.AdminPalette = { open, close, isOpen }`, `LS.adminGlobalSearch(q)`. Files: `frontend/js/admin/palette.js` (NEW), `backend/src/controllers/adminController.js` (+50L: `searchStmts` + `globalSearch`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+4L helper + export), `frontend/admin.html` (+1 script tag).
|
||||
- ✅ Phase 5 implemented — per-row hover quick actions для users + sessions tables. Users row (admin && uid !== self): 4 кнопки (Ban/Unban toggle, Award coins via LS.modal с amount+reason, Sessions → AdminRouter.navigate('#sessions'), Delete). Sessions row: 2 кнопки (View → toggleDrawer, Delete). Все `event.stopPropagation()` чтобы не триггерить row-click overlay/drawer. CSS injected ONCE через `ensureRowActionsStyles()` (de-dup по `#row-actions-style` id, обе секции проверяют existence). Mobile ≤768px: actions hidden (row-click overlay остаётся fallback'ом). Backend: NEW `DELETE /api/admin/sessions/:id` (admin-only) → `_deleteSessionTx` транзакция: nullify `assignment_sessions.session_id`, delete `user_answers` + `session_questions` (FK CASCADE но делаем explicit для visibility), delete `test_sessions`. Audit log: `'session.delete'`. Файлы: `frontend/js/admin/sections/users.js` (343→469L, +126), `frontend/js/admin/sections/sessions.js` (159→210L, +51), `backend/src/controllers/adminController.js` (+27L: `_deleteSessionTx` + `deleteSession`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper + export). NO эмоджи, inline SVG (Lucide outline-style 24x24 viewBox), Lucide уже доступен через CDN. User-panel overlay НЕ удалена — оставлена для Phase 6.
|
||||
- ✅ Phase 6 implemented (sub-commits bd30200 + new) — deep entity pages replace legacy `.user-panel` overlay. NEW: `frontend/js/admin/sections/user-detail.js` (~370L) and `frontend/js/admin/sections/session-detail.js` (~180L), both IIFE pattern. `admin.js` has `DEEP_ROUTES = { users:'user-detail', sessions:'session-detail' }` + `activateDeepPane()`; `activate(route, params)` checks for first-param to dispatch deep page (parent nav-item stays highlighted). Sub-tabs (overview/sessions/classes/audit) with URL sync via `udSwitchTab()` → `AdminRouter.navigate('#users/N/<sub>', { replace: true, silent: true })`. Backend endpoints reused: `GET /api/admin/users/:id/sessions` (user history), `GET /api/admin/sessions/:id` (session detail), `GET /api/admin/audit-log?limit=500` (client-side filtered by uid for Audit tab). Removed: `<div class="user-panel" id="user-panel">` overlay HTML, `.user-panel*` CSS, `openUserPanel`/`closeUserPanel`/`reloadUserPanel` JS, `toggleDrawer`/`renderDrawer` in sessions.js. Row onclick: `openUserPanel(...)` → `AdminRouter.navigate('#users/N')`; sessions row → `gotoSession(id)` → `AdminRouter.navigate('#sessions/N')`. `clearUserHistory`/`toggleBanUser`/`confirmDeleteUser` now use `getActiveUid()` helper (reads `window.activeUid` set by user-detail.init) instead of overlay closure. `quickOpenUserSessions(uid)` → `#users/<uid>/sessions` (deep page, Sessions sub-tab). Classes sub-tab is placeholder (no per-user classes endpoint exists). Charts: simple inline SVG bar chart for per-subject avg %.
|
||||
|
||||
## Phase 6 Routes Glossary
|
||||
|
||||
- `#users` — list (Phase 2 section)
|
||||
- `#users/123` — deep page, default Overview sub-tab
|
||||
- `#users/123/sessions` — deep page, Sessions sub-tab
|
||||
- `#users/123/classes` — deep page, Classes sub-tab (placeholder)
|
||||
- `#users/123/audit` — deep page, Audit sub-tab (admin only)
|
||||
- `#sessions` — list (Phase 2 section)
|
||||
- `#sessions/456` — deep page
|
||||
- Cmd+K palette user pick → `#users/N` (opens deep page)
|
||||
|
||||
## Temporary Workarounds
|
||||
|
||||
(пусто — заполняется implementer'ом)
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
|
||||
- **Phase 2 depends on Phase 1:** sections подписываются на router events, чтобы lazy-init по hashchange
|
||||
- **Phases 3, 4, 5 depend on Phase 2:** новые модули будут добавляться в `js/admin/sections/` (структура из фазы 2)
|
||||
- **Phase 6 depends on Phase 2:** deep page для user/session — это новые sections в той же структуре
|
||||
- **Phase 6 removes** старую `.user-panel` overlay из admin.html — фазы 1-5 НЕ должны её удалять
|
||||
|
||||
## Router Contract (Phase 1)
|
||||
|
||||
```js
|
||||
// Subscribe in any future module:
|
||||
AdminRouter.on('change', ({ route, params, raw }) => { /* ... */ });
|
||||
|
||||
// Programmatic deep-link without polluting history:
|
||||
AdminRouter.navigate('#users/123', { replace: true, silent: true });
|
||||
```
|
||||
|
||||
- Events emitted: `'change'` only (payload: parsed route).
|
||||
- Late subscribers do NOT receive replay — call `AdminRouter.current()` on init.
|
||||
- `silent: true` suppresses the synchronous emit but native `hashchange` still fires;
|
||||
the internal `_navigating` flag in router.js prevents the listener from re-firing.
|
||||
- `switchTab(btn, { fromRouter: true })` — call from router handlers to skip the
|
||||
reverse-sync write to `location.hash` (avoids redundant `replaceState`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Существующая структура (что менять / что НЕ менять)
|
||||
|
||||
**Точки входа в admin.js:**
|
||||
- `LS.initPage()` — auth + role check
|
||||
- `switchTab(btn)` — текущий tab-роутер; будет обёрнут router'ом, но не удалён до фазы 6
|
||||
- Per-tab `*Inited` флаги (`usersInited`, `sessionsInited`, ...) — переедут в section modules
|
||||
|
||||
**Backward compat обязателен:**
|
||||
- `goAddQuestion(slug)` и подобные cross-tab onclick handlers должны работать
|
||||
- Старые ссылки `<a href="#stats">` (если есть) тоже
|
||||
|
||||
### Конвенции вновь создаваемых модулей (Phase 2 закреплено)
|
||||
|
||||
Каждая section:
|
||||
```js
|
||||
// js/admin/sections/<name>.js
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
async function load() { /* fetch + render */ }
|
||||
// Optional onclick handlers used by HTML / dynamic templates:
|
||||
window.handlerX = handlerX;
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.<name> = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
// Optional extras for cross-section calls (e.g. questions.openModal):
|
||||
// openModal: (...) => { ... },
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
Shared utilities — на `window.AdminCtx` (см. `_shared.js`):
|
||||
- `user`, `isTeacher`, `isAdmin` (filled by admin.js)
|
||||
- `MODES`, `DIFFS`, `DIFF_LABELS`, `TYPE_LABELS`
|
||||
- `pctClass`, `fmtDate`, `fmtTime`, `fmtDuration`
|
||||
- `renderMath`, `qTypeBadge`, `qOptsPreview`
|
||||
- `renderPgnControls`, `ensurePgnStyles`
|
||||
|
||||
ROUTE_TO_SECTION map в admin.js — добавлять новые ключи при добавлении секций
|
||||
(Phase 3 = `overview`, Phase 6 = `user`/`session` deep pages).
|
||||
|
||||
Router (фаза 1):
|
||||
```js
|
||||
// js/admin/router.js
|
||||
window.AdminRouter = {
|
||||
navigate(hash) { /* update hash + dispatch */ },
|
||||
current() { /* parse current hash */ },
|
||||
on(event, fn) { /* subscribe */ },
|
||||
};
|
||||
```
|
||||
|
||||
### Какие onclick handlers есть сейчас (выборка)
|
||||
|
||||
Из admin.html / admin.js:
|
||||
- `onclick="switchTab(this)"` — на каждой admin-nav-item
|
||||
- `onclick="openUserPanel(event, ${u.id}, '${u.role}')"` — на user row
|
||||
- `onclick="changeRole(this)"` — на role-select
|
||||
- `onclick="goAddQuestion('${slug}')"` — cross-tab
|
||||
|
||||
Эти должны работать без изменений до фазы 6.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Feature: Admin Panel Redesign
|
||||
|
||||
**Branch:** `feature/admin-redesign`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-05-16
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Incremental
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
|
||||
Превратить admin-панель LearnSpace из монолитного tab-роутера (1900L HTML + 3500L JS в одном модуле) в master-detail SPA с hash-routing, lazy-loaded per-section модулями, dashboard-landing, Cmd+K command palette, per-row quick actions и deep entity pages вместо overlay-панели.
|
||||
|
||||
**Текущее состояние:**
|
||||
- `frontend/admin.html` ~1900L
|
||||
- `frontend/js/admin/admin.js` ~3500L (после недавнего extract из inline `<script>`)
|
||||
- 13 табов: stats, questions, tests, assignments, subjects, users, sessions, permissions, shop, gam, tpl, sims, games, sublog
|
||||
- `switchTab()` ручной tab-роутер, состояние теряется при F5
|
||||
- User detail = выезжающая `.user-panel` overlay внутри tab-users
|
||||
|
||||
**Цели:**
|
||||
- F5/bookmark на `#users/123` работают
|
||||
- admin.js ≤ 800L
|
||||
- Dashboard + Ctrl+K + hover-actions для частых сценариев
|
||||
- Полноценная страница user/session вместо overlay
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
- **Start:** `cd backend && npm start` (vanilla JS, нет бандлера — server раздаёт static)
|
||||
- **Dev:** `cd backend && npm run dev` (nodemon)
|
||||
- **Test:** `cd backend && npm test` (node --test)
|
||||
- **Lint:** `cd backend && npm run lint:routes` (route auth checker)
|
||||
- **Manual verify:** открыть `http://localhost:3000/admin` и пройти основные сценарии
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md)
|
||||
- [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
|
||||
- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5)
|
||||
- [x] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
|
||||
- [x] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4)
|
||||
- [x] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
|
||||
|
||||
**Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2.
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
|
||||
| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
|
||||
| Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd |
|
||||
| Phase 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 |
|
||||
| Phase 5: Quick actions | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 69113ab |
|
||||
| Phase 6: Deep pages | frontend | ✅ Done | ✅ PASS w/ notes (stale activeUid post-merge polish) | ✅ | ✅ bd30200 + 3f89030 |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review (final-reviewer agent)
|
||||
- [ ] Security review (auth-touching changes in new endpoints)
|
||||
- [ ] Full build passes (server starts, no errors)
|
||||
- [ ] Manual smoke test
|
||||
- [ ] Merged to `master`
|
||||
|
||||
## Acceptance Criteria (whole feature)
|
||||
|
||||
- F5 на любом `#sub-route` восстанавливает state
|
||||
- admin.js ≤ 800L
|
||||
- Ctrl+K находит пользователя по имени за <100ms
|
||||
- Dashboard `#overview` показывает данные за 24ч
|
||||
- Per-row hover-actions на users/sessions
|
||||
- `#users/123` = полноценная страница, не overlay
|
||||
- Все existing onclick handlers продолжают работать (backward compat в фазах 1-5)
|
||||
- Нет регрессий в тестах
|
||||
|
||||
## Tech Stack & Conventions Reference
|
||||
|
||||
- **Stack:** vanilla JS, Express 4, SQLite (better-sqlite3 sync), JWT, WebSocket+SSE, KaTeX, Lucide
|
||||
- **Frontend:** pages = `frontend/*.html`, JS = `/js/*` или `frontend/js/*`, все API через `window.LS.*`
|
||||
- **UI primitives:** `LS.modal`, `LS.confirm`, `LS.toast`, `LS.state`, `LS.skeleton`, `LS.esc`
|
||||
- **localStorage prefix:** `ls_*`
|
||||
- **Icons:** inline SVG `.ic` или Lucide CDN — **эмоджи запрещены**
|
||||
- **Search в коде:** только ast-index (пользователь категорически запретил Grep)
|
||||
- **Backend:** layer-based — `controllers/`, `routes/`, `services/`, `db/migrations/NNN_*.sql`
|
||||
@@ -0,0 +1,133 @@
|
||||
# Phase 1: Hash-router
|
||||
|
||||
**Status:** ✅ Implemented (awaiting review)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
|
||||
Заложить фундамент для URL-роутинга admin-панели через `location.hash`. После этой фазы можно делать F5 на `#users`, делиться deep-links, использовать browser back/forward. Старая система табов (`switchTab`) продолжает работать без изменений — router её обёртывает, а не заменяет.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Создать `frontend/js/admin/router.js` с `window.AdminRouter`:
|
||||
- `parse(hash)` → `{ route: 'users', params: ['123'], raw }`
|
||||
- `navigate(routeOrHash, { replace?, silent? })` — программная навигация
|
||||
- `current()` → текущий route object
|
||||
- `on(event, fn)` / `off(event, fn)` — pub/sub для 'change' event
|
||||
- Поддержка форматов: `#stats`, `#users`, `#users/123`, `#sessions/456`
|
||||
- [x] Подключить `router.js` в `admin.html` ДО `admin.js`
|
||||
- [x] В `admin.js` модифицировать `switchTab(btn)`:
|
||||
- Дополнительно вызывать `AdminRouter.navigate('#' + name, { silent: true })`
|
||||
- НЕ удалять старую логику
|
||||
- [x] Добавить листенер `AdminRouter.on('change', ...)` в admin.js:
|
||||
- При route change → найти соответствующий `.admin-nav-item[data-tab="X"]` и активировать его (через имеющийся switchTab, но с `silent`-флагом чтобы избежать рекурсии)
|
||||
- [x] При инициализации страницы:
|
||||
- Если `location.hash` пустой → set default `#stats`
|
||||
- Если есть hash → распарсить и переключить на соответствующий tab
|
||||
- [x] Логировать unknown routes: `console.warn('AdminRouter: unknown route', route)` + fallback на `#stats`
|
||||
- [x] Защита от инфинит-loop'а: флаг `_routerNavigating` при programmatic-навигации, чтобы handler не реагировал на свой же hash change
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
- `frontend/js/admin/router.js` — новый, ~80-120L
|
||||
- `frontend/admin.html` — добавить `<script src="/js/admin/router.js"></script>` в `<head>` или перед admin.js
|
||||
- `frontend/js/admin/admin.js` — модифицировать `switchTab` + добавить init-логику (~15-25L изменений)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- F5 на `http://localhost:3000/admin#users` восстанавливает users-tab
|
||||
- Browser back/forward переключают между табами (без полного reload)
|
||||
- Клик по admin-nav-item обновляет URL (`#users` появляется в адресной строке)
|
||||
- Клик по cross-tab handler типа `goAddQuestion('bio')` — старая логика работает, URL обновляется
|
||||
- Unknown hash (например `#nonexistent`) → console.warn + fallback на `#stats`, нет crash
|
||||
- `#users/123` парсится корректно (params=['123']), но пока никто его не использует — Phase 6 подключит
|
||||
|
||||
## Notes
|
||||
|
||||
### Почему hash-router, а не history.pushState
|
||||
|
||||
Backend Express раздаёт admin.html по `/admin`. С `pushState` пришлось бы либо настраивать catch-all route на server-стороне (`/admin/*`), либо делать SPA-style роутинг. Hash-router работает out-of-the-box и не требует backend-изменений. Это критично для incremental-стратегии — мы не трогаем server в Phase 1.
|
||||
|
||||
### Защита от рекурсии
|
||||
|
||||
Сценарий: пользователь кликает на tab → switchTab вызывает navigate → navigate меняет hash → срабатывает hashchange → router emits 'change' → handler вызывает switchTab → snake eats tail.
|
||||
|
||||
Решение:
|
||||
```js
|
||||
let _navigating = false;
|
||||
function navigate(hash) {
|
||||
_navigating = true;
|
||||
location.hash = hash;
|
||||
_navigating = false;
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (_navigating) return;
|
||||
// emit 'change'
|
||||
});
|
||||
```
|
||||
|
||||
Или передавать `{ silent: true }` через объект-параметр и проверять его в handler'е switchTab.
|
||||
|
||||
### Существующий пример hashchange
|
||||
|
||||
В `frontend/js/textbook-tracker.js:438` уже есть `addEventListener('hashchange', handleHashNav)` — это safe-pattern, можно подсмотреть структуру.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] router.js не использует Grep / эмоджи / отсутствующие LS-помощники
|
||||
- [ ] Старый switchTab НЕ удалён, только обёрнут
|
||||
- [ ] Нет регрессий: все 13 табов переключаются, lazy-load работает
|
||||
- [ ] F5 / back / forward проверены вручную в браузере (или симуляция через subagent)
|
||||
- [ ] Default route `#stats` срабатывает при пустом hash
|
||||
- [ ] Unknown route не крашит панель
|
||||
- [ ] Код следует конвенциям проекта (no emoji, inline SVG для иконок, LS.* для API)
|
||||
- [ ] Build passes: `cd backend && npm start` → http://localhost:3000/admin загружается
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
**Router API location:** `window.AdminRouter` (defined in `frontend/js/admin/router.js`, loaded **before** `admin.js` from `admin.html`).
|
||||
|
||||
**Public surface:**
|
||||
|
||||
```js
|
||||
AdminRouter.parse('#users/123')
|
||||
// → { route: 'users', params: ['123'], raw: '#users/123' }
|
||||
AdminRouter.current()
|
||||
// → parsed location.hash
|
||||
AdminRouter.navigate('#users', { replace: false, silent: false })
|
||||
// replace → history.replaceState (no extra entry)
|
||||
// silent → suppress synchronous 'change' emit; hashchange still fires natively
|
||||
AdminRouter.on('change', ({ route, params, raw }) => { ... })
|
||||
AdminRouter.off('change', fn)
|
||||
```
|
||||
|
||||
**Events emitted:** only `'change'` for now. Payload is the parsed route plus `silent: false`. Internal `_navigating` flag suppresses re-emit when *we* set the hash (prevents the snake-eats-tail loop).
|
||||
|
||||
**How Phase 2 sections subscribe:**
|
||||
|
||||
```js
|
||||
AdminRouter.on('change', ({ route, params }) => {
|
||||
if (route === 'users') AdminSections.users.init();
|
||||
if (route === 'sessions' && params[0]) AdminSections.sessions.openDetail(params[0]);
|
||||
});
|
||||
```
|
||||
|
||||
Sections should call `AdminRouter.current()` once on load to handle the initial route (the router does NOT replay past 'change' events to late subscribers).
|
||||
|
||||
**switchTab contract change:**
|
||||
`switchTab(btn, opts)` — `opts.fromRouter === true` prevents `switchTab` from re-pushing the hash (used by router when responding to a hashchange / deep-link). Existing call sites (`switchTab(this)`, `switchTab(qBtn)`, `switchTab(this);loadAvatarRequests()`) keep working — they call without `opts`, so the URL syncs as expected.
|
||||
|
||||
**Default route:** `#stats` (matches existing initially-active tab). Phase 3 will change default to `#overview` once dashboard ships.
|
||||
|
||||
**Unknown / locked routes:** logged via `console.warn('AdminRouter: unknown route', name)`, then `replace`-navigated to `#stats` without polluting browser history.
|
||||
|
||||
**Files touched:**
|
||||
- `frontend/js/admin/router.js` — NEW, 97 lines
|
||||
- `frontend/admin.html` — +1 line (`<script src="/js/admin/router.js"></script>` before admin.js)
|
||||
- `frontend/js/admin/admin.js` — `switchTab` signature `(btn, opts)`, +6 lines for hash-sync; new ~36-line `initAdminRouter` IIFE in init block
|
||||
|
||||
**Backward compat verified:**
|
||||
- All 21 `onclick="switchTab(this)"` callsites untouched.
|
||||
- `goAddQuestion(slug)` works (calls `switchTab(qBtn)` without `opts` → URL also updates to `#questions`).
|
||||
- `onclick="switchTab(this);loadAvatarRequests()"` on the avatars tab still works.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Phase 2: Split admin.html → per-section modules
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
**Commit:** 92030b4
|
||||
|
||||
## Objective
|
||||
|
||||
Разделить монолит `admin.js` (3500L) на per-section модули в `frontend/js/admin/sections/*.js`. После фазы `admin.js` становится оркестратором (~500-800L): он только подключает router, инициализирует общие виджеты (notif, sidebar) и делегирует загрузку section-данных в соответствующий модуль.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Создать `frontend/js/admin/sections/` директорию
|
||||
- [x] Определить единый паттерн модуля:
|
||||
```js
|
||||
// js/admin/sections/<name>.js
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
const ctx = { user: null, isAdmin: false }; // прокидываем из admin.js
|
||||
async function load() { /* существующий loadX код */ }
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.<name> = {
|
||||
init: async (sharedCtx) => {
|
||||
Object.assign(ctx, sharedCtx);
|
||||
if (inited) return; inited = true; await load();
|
||||
},
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
```
|
||||
- [x] Извлечь 13 секций (в порядке риска — от меньшего к большему):
|
||||
- [x] `stats.js` — `loadStats` + связанные функции (50L)
|
||||
- [x] `sublog.js` — submission log (104L)
|
||||
- [x] `sims.js` (118L), `games.js` (132L), `tpl.js` (73L) — admin-only
|
||||
- [x] `subjects.js` — настройка доступных тестов (338L)
|
||||
- [x] `permissions.js` (68L)
|
||||
- [x] `shop.js` — items + purchases + award coins (207L)
|
||||
- [x] `gam.js` — gamification stats + award xp (183L)
|
||||
- [x] `assignments.js` (477L)
|
||||
- [x] `tests.js` (283L)
|
||||
- [x] `questions.js` — самая большая, 535L (включая Q-modal)
|
||||
- [x] `users.js` — users-table + pagination + user-panel (343L, overlay остался)
|
||||
- [x] `sessions.js` — sessions-table + session detail (159L)
|
||||
- [x] Модифицировать `admin.js`:
|
||||
- Удалить функции, перенесённые в sections
|
||||
- Заменить inline вызовы (`loadUsers()` → `AdminSections.users.init()`)
|
||||
- Добавить ROUTE_TO_SECTION mapping (см. ниже)
|
||||
```js
|
||||
const ROUTE_TO_SECTION = {
|
||||
stats: 'stats', users: 'users', sessions: 'sessions',
|
||||
questions: 'questions', tests: 'tests', assignments: 'assignments',
|
||||
subjects: 'subjects', permissions: 'permissions',
|
||||
shop: 'shop', gam: 'gam', tpl: 'tpl', sims: 'sims', games: 'games', sublog: 'sublog',
|
||||
};
|
||||
```
|
||||
Маппинг применяется внутри `switchTab` (не отдельный router-listener) —
|
||||
`switchTab` уже вызывается router'ом на change через `activate(route)`,
|
||||
поэтому достаточно один раз dispatch'ить в `switchTab`.
|
||||
- [x] Все 14 `<script>` тегов добавлены в `admin.html` (_shared.js + 13 sections, после router.js, перед admin.js)
|
||||
- [x] Глобальные функции, которые используются из onclick HTML, оставлены доступными через `window.X`:
|
||||
- `changeRole`, `openUserPanel`, `goAddQuestion`, `confirmDeleteUser`, etc.
|
||||
- Каждый section module экспортирует свои onclick-handler'ы через `window.X = X` в конце IIFE
|
||||
- Cross-section orchestrator `goAddQuestion` живёт в admin.js (вызывает `AdminSections.questions.openModal`)
|
||||
- [x] Удалены per-tab `*Inited` флаги из admin.js — они переехали внутрь section modules
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
- `frontend/js/admin/sections/stats.js` — новый
|
||||
- `frontend/js/admin/sections/users.js` — новый, ~400-500L
|
||||
- `frontend/js/admin/sections/sessions.js` — новый
|
||||
- `frontend/js/admin/sections/questions.js` — новый, ~800L
|
||||
- `frontend/js/admin/sections/tests.js` — новый
|
||||
- `frontend/js/admin/sections/assignments.js` — новый
|
||||
- `frontend/js/admin/sections/subjects.js` — новый
|
||||
- `frontend/js/admin/sections/permissions.js` — новый
|
||||
- `frontend/js/admin/sections/shop.js` — новый
|
||||
- `frontend/js/admin/sections/gam.js` — новый
|
||||
- `frontend/js/admin/sections/tpl.js` — новый
|
||||
- `frontend/js/admin/sections/sims.js` — новый
|
||||
- `frontend/js/admin/sections/games.js` — новый
|
||||
- `frontend/js/admin/sections/sublog.js` — новый
|
||||
- `frontend/js/admin/admin.js` — сильно ужать (с 3500L до ~500-800L)
|
||||
- `frontend/admin.html` — добавить 13 `<script>` тегов
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `admin.js` ≤ 800L (от 3500L)
|
||||
- Каждый section-файл ≤ 900L (questions.js самый большой)
|
||||
- Все 13 табов работают идентично текущему поведению (no regressions)
|
||||
- Cross-tab handlers (`goAddQuestion`, `confirmDelete*`) работают
|
||||
- Lazy-load работает: при первом открытии tab делается fetch, при повторном — нет
|
||||
- F5 на любом `#X` корректно ленево-грузит секцию (через router из Phase 1)
|
||||
- Browser back/forward работают
|
||||
- Никаких console errors в Devtools
|
||||
|
||||
## Notes
|
||||
|
||||
### Стратегия извлечения
|
||||
|
||||
Один section за раз, мелкими безопасными шагами:
|
||||
1. Скопировать функции `loadX, openXModal, deleteX, ...` в новый файл sections/<name>.js, обернуть в IIFE
|
||||
2. Экспортировать через `window.AdminSections.X`
|
||||
3. Подключить `<script>` в admin.html
|
||||
4. В admin.js заменить вызовы (`loadX()` → `AdminSections.X.init(ctx)`)
|
||||
5. Удалить дубликаты в admin.js
|
||||
6. Тест: открыть tab — работает?
|
||||
7. Перейти к следующей секции
|
||||
|
||||
### Что НЕ переезжает в sections
|
||||
|
||||
- `LS.initPage()` + auth check — остаётся в admin.js
|
||||
- `switchTab` (helper) — остаётся
|
||||
- `pctClass`, `fmtDate`, `fmtTime` — общие утилиты, остаются (или переезжают в `admin/_shared.js`)
|
||||
- Sidebar / notif init — остаётся
|
||||
- Router setup — остаётся
|
||||
|
||||
### Глобальные функции из onclick
|
||||
|
||||
Сейчас многие функции вызываются из HTML onclick (`onclick="openUserPanel(...)"`). Чтобы не переписывать HTML на этой фазе, в каждом section module экспортируем нужные функции через `window.X = X` внутри IIFE. Phase 5/6 могут заменить onclick на event delegation, но Phase 2 этого не делает (incremental).
|
||||
|
||||
### Тестирование каждой секции
|
||||
|
||||
После каждой выделенной секции:
|
||||
- Открыть `/admin` → переключиться на этот tab → данные загрузились
|
||||
- Все кнопки/модалки секции работают
|
||||
- Cross-tab navigation (если есть) работает
|
||||
- F5 на `#<route>` корректно открывает tab
|
||||
|
||||
Если регрессия — откатить эту итерацию, разобраться, починить.
|
||||
|
||||
### Совет implementer'у
|
||||
|
||||
Если фаза становится огромной — можно сделать несколько коммитов внутри phase branch. Это inscope. Не нужно делать один гигантский коммит на 14 файлов.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [x] Все 13 секций имеют одинаковую структуру (init/reload)
|
||||
- [x] admin.js = 701L (≤ 800L), нет дублирования с sections
|
||||
- [x] Все window.X экспорты есть для onclick handlers (см. handoff ниже)
|
||||
- [x] Lazy-init работает: `inited` флаг внутри каждой section IIFE
|
||||
- [x] F5 на каждом из 13 routes восстанавливает секцию (через router.activate → switchTab → AdminSections.X.init)
|
||||
- [x] Sanity: все 14 .js файлов проходят `node --check`
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Section module API (Phase 3+ должна следовать)
|
||||
|
||||
```js
|
||||
window.AdminSections.<name> = {
|
||||
init: async () => { /* lazy: первый вызов делает fetch, повторные — no-op */ },
|
||||
reload: async () => { /* всегда выполняет fetch (для refresh-кнопок) */ },
|
||||
// опционально: extra-методы для cross-section orchestration:
|
||||
// openModal(...), loadModalTopics() — см. questions.js
|
||||
};
|
||||
```
|
||||
|
||||
### Где живут shared утилиты
|
||||
|
||||
`frontend/js/admin/_shared.js` — экспортирует на `window.AdminCtx`:
|
||||
- `user`, `isTeacher`, `isAdmin` (filled by admin.js после LS.initPage())
|
||||
- `MODES`, `DIFFS`, `DIFF_LABELS`, `TYPE_LABELS` — константы
|
||||
- `pctClass(p)`, `fmtDate(d)`, `fmtTime(s)`, `fmtDuration(s)` — форматтеры
|
||||
- `renderMath(el)` — KaTeX
|
||||
- `qTypeBadge(type)`, `qOptsPreview(q)` — used by tests + subjects
|
||||
- `renderPgnControls(elId, page, total, perPage, gotoFn)` + `ensurePgnStyles()` — пагинация
|
||||
|
||||
### ROUTE_TO_SECTION map (admin.js)
|
||||
|
||||
13 ключей, расширение для Phase 3 (`overview`) и Phase 6 (`user`, `session` deep pages):
|
||||
|
||||
```js
|
||||
const ROUTE_TO_SECTION = {
|
||||
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',
|
||||
};
|
||||
```
|
||||
|
||||
**Phase 3:** добавить `overview: 'overview'` + sections/overview.js, и поменять
|
||||
дефолтный hash в admin.js с `#stats` на `#overview`.
|
||||
|
||||
**Phase 6:** добавить `user: 'userDetail'`, `session: 'sessionDetail'` —
|
||||
sections/user-detail.js и sections/session-detail.js будут читать
|
||||
`AdminRouter.current().params[0]` для id.
|
||||
|
||||
### Window-exposed globals (для HTML onclicks)
|
||||
|
||||
**Из admin.js (orchestrator):**
|
||||
- `switchTab`, `toggleAdminGroup`, `goAddQuestion`
|
||||
- Topics: `showAddTopic`, `createTopic`, `renameTopic`, `deleteTopic`
|
||||
- Logs/health: `sendBroadcast`, `clearAuditLog`, `clearErrorLog`
|
||||
- Classroom: `crMasterToggle`, `crHistDebounce`, `loadCrHistory`,
|
||||
`toggleCrDetail`, `adminEndSession`, `adminExportChat`, `adminDeleteSession`
|
||||
- Avatars: `avatarApprove`, `avatarRejectPrompt`, `avatarReject`
|
||||
- Function declarations at script-top-level (`loadTopics`, `loadCrActiveSessions`,
|
||||
`loadAvatarRequests`, `loadHealth`, `loadAuditLog`, `loadErrorLog`, `loadCrModuleState`,
|
||||
`loadCrSessionDetail`, `loadTopicSubjects`, `renderCrPagination`, `fmtLiveDuration`,
|
||||
`avatarReject`) — автоматически на `window` в non-module script.
|
||||
|
||||
**Из section modules (явный `window.X = X` в IIFE):**
|
||||
- questions: ~25 handlers (openQModal, saveQuestion, setQType, addOpt, removeOpt, etc.)
|
||||
- users: ~17 handlers (loadUsers, openUserPanel, changeRole, etc.)
|
||||
- tests: ~12 handlers
|
||||
- assignments: ~18 handlers
|
||||
- subjects: ~9 handlers (toggleScCard, applyPreset, scAddQ/scRemoveQ, etc.)
|
||||
- shop, gam, sims, games, tpl, permissions, sessions, sublog: 1-9 each
|
||||
|
||||
### Cross-section dependencies
|
||||
|
||||
- `goAddQuestion(slug)` → admin.js orchestrator → switches tab + calls
|
||||
`AdminSections.questions.openModal()` + `loadModalTopics()`. The section
|
||||
exposes these as extra methods on `AdminSections.questions`.
|
||||
- Subjects-section's `goAddQuestion(slug)` onclick uses the admin.js
|
||||
orchestrator (same window-global).
|
||||
- `_matchPairs` (matching-question editor state): exposed on `window` because
|
||||
inline `oninput="window._matchPairs[i].left=this.value"` references it.
|
||||
|
||||
### Что НЕ переехало (по плану)
|
||||
|
||||
- Tabs *topics, audit, errors, health, classroom, avatars* остались inline
|
||||
в admin.js — Phase 2 их не extract'ил (не входило в 13 секций плана).
|
||||
- `.user-panel` overlay markup в admin.html не тронут (Phase 6 удалит).
|
||||
@@ -0,0 +1,160 @@
|
||||
# Phase 3: Dashboard #overview
|
||||
|
||||
**Status:** ✅ Implemented (pending review)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
**Parallelizable with:** Phase 4, Phase 5
|
||||
|
||||
## Objective
|
||||
|
||||
Сделать `#overview` дефолтным route'ом admin-панели — landing-страница "что требует внимания". Заменяет нынешние обезличенные stat-cards (totalUsers, totalTests, ...) на actionable digest за последние 24-48 часов.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Backend: новый endpoint `GET /api/admin/overview`:
|
||||
```js
|
||||
{
|
||||
newUsers24h: number, // регистрации за 24ч
|
||||
newSessions24h: number, // запущенных тестов
|
||||
bannedThisWeek: [{ id, name, banned_at }],
|
||||
failedSessions24h: number, // тесты со статусом не completed
|
||||
topSessions24h: [{ id, user_name, subject, score, total, finished_at }], // топ-5 за 24ч
|
||||
activeUsers24h: number, // unique last_login за 24ч
|
||||
pendingMigrations: number, // если возможно проверить
|
||||
recentErrors: [{ id, type, message, created_at }] // если есть audit log с типом 'error'
|
||||
}
|
||||
```
|
||||
- Контроллер: `backend/src/controllers/adminController.js` → новая функция `getOverview`
|
||||
- Route: добавить в `backend/src/routes/admin.js`
|
||||
- Auth: admin или teacher (как остальные admin/* — RBAC same)
|
||||
- Performance: один запрос для каждого поля, простые COUNT/SELECT, без JOIN'ов где возможно
|
||||
- [x] Frontend: новый section `frontend/js/admin/sections/overview.js`:
|
||||
- Использует структуру из Phase 2
|
||||
- Загружает `/api/admin/overview`
|
||||
- Рендерит карточки в `<div id="tab-overview">` секции:
|
||||
- **Активность 24ч** — registr, sessions, active users (число + спарклайн если есть данные)
|
||||
- **Требует внимания** — banned this week (если >0), failed sessions (если >5%), pending migrations
|
||||
- **Топ-сессии** — таблица top-5 за день с click→drilldown
|
||||
- **Quick links** — "Все пользователи", "Все сессии", "Создать тест" (deep-link в соответствующие routes)
|
||||
- LS.skeleton при загрузке, LS.state.error на fail
|
||||
- [x] HTML: добавить `<div class="tab-pane" id="tab-overview">` в admin.html (перед остальными tab-pane)
|
||||
- [x] Nav: добавить admin-nav-item для `overview` (icon: layout-dashboard / activity)
|
||||
- [x] Регистрация в ROUTE_TO_SECTION (из Phase 2): `overview: 'overview'`
|
||||
- [x] Сделать `#overview` дефолтным route'ом в router (из Phase 1) — если пустой hash, navigate to `#overview` вместо `#stats`
|
||||
- [x] Старый `#stats` остаётся как доступный route (legacy backend stats), но не дефолтный
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
- `backend/src/controllers/adminController.js` — добавить `getOverview` функцию (~60-90L)
|
||||
- `backend/src/routes/admin.js` — добавить `router.get('/overview', requireAdmin, getOverview)`
|
||||
- `frontend/js/admin/sections/overview.js` — новый, ~250-350L
|
||||
- `frontend/admin.html` — добавить `<div id="tab-overview">` + nav-item + `<script>` тег
|
||||
- `frontend/js/admin/admin.js` — изменить default route на `#overview` (если ещё не сделано через router.js config)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `/admin` (без hash) → редирект на `#overview`
|
||||
- `#overview` показывает реальные данные (новые регистрации видны если кто-то зарегистрировался)
|
||||
- Карточки кликабельные (click → deep-link в users / sessions с фильтром)
|
||||
- При отсутствии данных (свежая БД) — empty states, не crash
|
||||
- Endpoint выполняется <100ms на тестовой БД
|
||||
- F5 на `#overview` работает
|
||||
- Auth: только teacher/admin (как остальные /admin/*)
|
||||
|
||||
## Notes
|
||||
|
||||
### Что считать "требует внимания"
|
||||
|
||||
Делаем простые threshold'ы для MVP:
|
||||
- `bannedThisWeek` > 0 — показать жёлтую карточку с именами
|
||||
- `failedSessions24h` > 0 — показать список (failed = status != 'completed')
|
||||
- `pendingMigrations` > 0 — показать красную (но это редкий случай — миграции применяются на старте)
|
||||
|
||||
Можно потом расширить до "новых жалоб", "переполненных классов", и т.д. — это уже после merge.
|
||||
|
||||
### Дизайн карточек
|
||||
|
||||
Bento-grid из 4-6 карточек:
|
||||
```
|
||||
+---------+---------+
|
||||
| 24ч | Внимание|
|
||||
| метрики | ! |
|
||||
+---------+---------+
|
||||
| Топ сессии |
|
||||
| (table) |
|
||||
+---------+---------+
|
||||
| Quick links |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
Использовать существующие стили `.stat-card`, `.section-title` из admin.html — не изобретать.
|
||||
|
||||
### Что НЕ делать в этой фазе
|
||||
|
||||
- Не делать реалтайм WebSocket-обновления (это уже Phase 7+)
|
||||
- Не делать графики/чарты (пока числа + sparkline опционально)
|
||||
- Не делать персонализацию (например "ваши классы")
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Endpoint работает и возвращает корректную форму ответа
|
||||
- [ ] Frontend handles empty state gracefully
|
||||
- [ ] Click на quick-link корректно навигирует через AdminRouter
|
||||
- [ ] Нет hardcoded date math (использовать SQL `datetime('now', '-24 hours')`)
|
||||
- [ ] Roles correct (admin/teacher only, не у students)
|
||||
- [ ] No SQL injection — параметры через `?` placeholders
|
||||
- [ ] Build passes
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
**Endpoint shape (`GET /api/admin/overview`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"newUsers24h": 0,
|
||||
"newSessions24h": 0,
|
||||
"activeUsers24h": 2,
|
||||
"activeClasses": 5,
|
||||
"failedSessions24h": 0,
|
||||
"bannedThisWeek": [
|
||||
{ "id": 42, "name": "...", "email": "...", "banned_at": "2026-05-15 12:30:00" }
|
||||
],
|
||||
"topSessions24h": [
|
||||
{ "id": 101, "user_name": "...", "subject_name": "Физика",
|
||||
"score": 18, "total": 20, "percent": 90.0, "finished_at": "2026-05-16 09:14:22" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Performance:** ~0.08ms/call avg (benchmarked 100 iters) — well under 100ms target.
|
||||
|
||||
**Auth:** uses `router.use(requireRole('admin'))` block (admin-only, same as `/stats`).
|
||||
`/features` block (teacher+admin) is above the route. Teacher access NOT granted — matches
|
||||
sibling `/stats` behavior. If Phase 4-6 wants teacher access, move `/overview` above the
|
||||
admin-only `router.use(...)` line in `backend/src/routes/admin.js`.
|
||||
|
||||
**Quick-link wiring (Phase 4/5 extension point):**
|
||||
Quick-link buttons live in `overview.js` → render() → `.ov-quick-grid`. They use a
|
||||
`data-go="#hash"` attribute and a delegated click → `AdminRouter.navigate(...)`. To add
|
||||
a new quick-link, append a `<button class="ov-quick-btn" data-go="#whatever">` to the grid.
|
||||
|
||||
**Deep-link openings for Phase 6:**
|
||||
- `#users` and `#sessions` are wired (vanilla routes — no filter params yet)
|
||||
- Future: extend `AdminRouter` to support `#users?filter=banned` / `#sessions?status=failed`
|
||||
query-style params. Currently router parses `route + params` (`/users/123` form), no `?`
|
||||
parsing — Phase 6 can extend `router.js::parseHash()`.
|
||||
|
||||
**Router defaults updated:**
|
||||
- `admin.js::initAdminRouter::activate()` fallback route changed `'stats'` → `'overview'`
|
||||
- Initial dispatch (empty hash) navigates to `#overview` instead of `#stats`
|
||||
- Initial init call: `AdminSections.overview.init()` instead of `AdminSections.stats.init()`
|
||||
|
||||
**Files modified/created (line counts):**
|
||||
- NEW: `frontend/js/admin/sections/overview.js` (~205 lines)
|
||||
- `backend/src/controllers/adminController.js` (+57 lines: new statements + `getOverview`)
|
||||
- `backend/src/routes/admin.js` (+1 line)
|
||||
- `js/api.js` (+1 line helper + 1 export)
|
||||
- `frontend/admin.html` (nav-item +3 lines, tab-pane +4 lines, script tag +1 line, swap `active`)
|
||||
- `frontend/js/admin/admin.js` (`ROUTE_TO_SECTION` +1 entry, default route refs swapped from `stats` → `overview`)
|
||||
|
||||
**Unrelated to Phase 3 (do not touch):** old `#stats` route remains functional — used as fallback in the past, can be linked from Overview as "детальная статистика" in a future polish pass.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Phase 4: Cmd+K command palette
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
**Parallelizable with:** Phase 3, Phase 5
|
||||
|
||||
## Objective
|
||||
|
||||
Глобальный палеттный поиск по Ctrl+K (Cmd+K на Mac) — нахоит entities (users, tests, classes, sessions) + actions ("выдать монеты ученику", "разбанить", "создать класс", deep-link routes). Радикально сокращает количество кликов для частых сценариев.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Backend: новый endpoint `GET /api/admin/search?q=X&limit=8`:
|
||||
- Возвращает смешанный результат:
|
||||
```js
|
||||
{
|
||||
users: [{ id, name, email, role }], // top 5 по name LIKE / email LIKE
|
||||
tests: [{ id, name, subject_slug }], // top 3
|
||||
classes: [{ id, name, code }], // top 3
|
||||
sessions: [] // skip пока, добавим если нужно
|
||||
}
|
||||
```
|
||||
- Контроллер: новая функция `globalSearch` в `adminController.js`
|
||||
- Route: `router.get('/search', requireAdmin, globalSearch)`
|
||||
- Каждая sub-query SELECT отдельно с LIMIT, общий ответ — простой json
|
||||
- Auth: admin only (teachers видят только своих учеников; для упрощения — admin)
|
||||
- [x] Frontend: `frontend/js/admin/palette.js` — palette модуль:
|
||||
- Не section, а глобальный widget — подключается в admin.js init
|
||||
- Слушает `keydown` на `Ctrl+K` / `Cmd+K` (preventDefault)
|
||||
- Открывает modal через `LS.modal()`:
|
||||
- Header: search input (autofocus)
|
||||
- Body: список результатов с keyboard nav (↑↓ Enter Esc)
|
||||
- Иконка типа справа от каждого результата (User, Test, Class, Action)
|
||||
- Дебаунс поиска ~150ms
|
||||
- Min длина query: 2 символа
|
||||
- При query='' → показать "Recent Actions" hardcoded list
|
||||
- [x] Actions index (hardcoded в palette.js):
|
||||
```js
|
||||
const ACTIONS = [
|
||||
{ id: 'award_coins', name: 'Выдать монеты', icon: 'coins', handler: () => AdminRouter.navigate('#shop') },
|
||||
{ id: 'award_xp', name: 'Выдать XP', icon: 'zap', handler: () => AdminRouter.navigate('#gam') },
|
||||
{ id: 'new_class', name: 'Создать класс', icon: 'plus-circle', handler: () => window.location.href = '/classes' },
|
||||
{ id: 'new_test', name: 'Создать тест', icon: 'file-plus', handler: () => AdminRouter.navigate('#tests') },
|
||||
{ id: 'view_users', name: 'Все пользователи', icon: 'users', handler: () => AdminRouter.navigate('#users') },
|
||||
{ id: 'view_sessions', name: 'Все сессии', icon: 'history', handler: () => AdminRouter.navigate('#sessions') },
|
||||
{ id: 'view_audit', name: 'Audit log', icon: 'shield', handler: () => AdminRouter.navigate('#sublog') },
|
||||
// …добавлять по мере надобности
|
||||
];
|
||||
```
|
||||
- Fuzzy-match в JS (substring match по name) при query
|
||||
- [x] Открытие результата:
|
||||
- User → `AdminRouter.navigate('#users/' + id)` (Phase 6 будет рендерить deep page; пока fallback на `#users` + opening user-panel через имеющийся `openUserPanel`)
|
||||
- Test → `AdminRouter.navigate('#tests')` + scroll к row (если поддерживается, иначе просто tab)
|
||||
- Class → `window.location.href = '/classes#' + id`
|
||||
- Action → выполнить handler
|
||||
- [x] Стили palette: глассморфизм/blur, центрировано, max-width 600px, dark/light theme-friendly. Использовать существующие токены `--surface`, `--border`, `--text-2`.
|
||||
- [x] Подсказка в UI: footer dialog'а "↑↓ — навигация · ↵ — выбрать · esc — закрыть"
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
- `backend/src/controllers/adminController.js` — добавить `globalSearch` (~60L)
|
||||
- `backend/src/routes/admin.js` — добавить `/search` route
|
||||
- `js/api.js` — добавить `LS.adminGlobalSearch(q)` helper (~5L)
|
||||
- `frontend/js/admin/palette.js` — новый, ~300-400L
|
||||
- `frontend/admin.html` — добавить `<script src="/js/admin/palette.js"></script>`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Ctrl+K (Cmd+K) открывает palette из любого таба admin
|
||||
- Esc закрывает
|
||||
- Печать "иван" → top users с именем "Иван..."
|
||||
- Печать "монеты" → action "Выдать монеты"
|
||||
- ↑↓ навигация работает, Enter выполняет
|
||||
- Поиск отрабатывает <100ms для 8 результатов на тестовой БД
|
||||
- Click outside / Esc закрывают
|
||||
- LS.modal используется (не reinventing wheel)
|
||||
- Auth: только admin может открыть (teachers — палетту не открывают)
|
||||
|
||||
## Notes
|
||||
|
||||
### Почему Ctrl+K а не /
|
||||
|
||||
Ctrl+K — индустри-стандарт (GitHub, Linear, Vercel, Slack). `/` конфликтует с input'ами.
|
||||
|
||||
### Дебаунсинг
|
||||
|
||||
Простой setTimeout/clearTimeout. Без библиотек.
|
||||
|
||||
### LS.modal compat
|
||||
|
||||
LS.modal сейчас принимает `{ title, body, footer, onOk, onClose, size }`. Для palette нужен focus management — autofocus input при открытии. Можно использовать через колбэк `onMount` если он есть, либо `setTimeout(() => input.focus(), 0)` после открытия.
|
||||
|
||||
### Что НЕ делать в этой фазе
|
||||
|
||||
- Не делать ML/fuzzy-search в backend (LIKE достаточно)
|
||||
- Не делать historic recents (Cmd+K recents) — это уже после merge
|
||||
- Не делать collaboration ("кто-то ещё печатает")
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Ctrl+K не конфликтует с системными shortcut'ами браузера
|
||||
- [ ] Palette не открывается если фокус в textarea / input (если требует ввод)... опционально, можно открывать всегда
|
||||
- [ ] No SQL injection в /admin/search
|
||||
- [ ] Эскейпинг через LS.esc для рендеринга имён пользователей
|
||||
- [ ] No N+1 queries (один SELECT на тип сущности)
|
||||
- [ ] Build passes
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Endpoint contract — `GET /api/admin/search?q=<query>&limit=8`
|
||||
|
||||
Auth: admin only (inside `requireRole('admin')` block in `backend/src/routes/admin.js`).
|
||||
If `q.trim().length < 2`, returns empty arrays without hitting DB. Errors → 500 `{error:'Search failed'}`.
|
||||
|
||||
Response shape (top 5 users / top 3 tests / top 3 classes):
|
||||
```js
|
||||
{
|
||||
users: [{ id, name, email, role }],
|
||||
tests: [{ id, name, subject_slug }], // alias: tests.title AS name
|
||||
classes: [{ id, name, code }], // alias: classes.invite_code AS code
|
||||
}
|
||||
```
|
||||
|
||||
Backend perf: 3 simple parameterised SELECTs with LIMIT — well under 100ms.
|
||||
|
||||
### Navigation contract from palette → router
|
||||
|
||||
| Result kind | Action |
|
||||
|-------------|--------|
|
||||
| Action | calls the hardcoded `go()` callback (most go through `AdminRouter.navigate('#…')`) |
|
||||
| User | `AdminRouter.navigate('#users/' + id)` — params parsed by router, but ROUTE_TO_SECTION currently only dispatches `users` section. **Phase 6** can add a `user` section that reads `params.id` and renders a deep page. |
|
||||
| Test | `AdminRouter.navigate('#tests')` (no deep page yet) |
|
||||
| Class | `window.location.href = '/classes#' + id` — leaves admin (classes UI is a separate page) |
|
||||
|
||||
### Action registry (hardcoded in `frontend/js/admin/palette.js`)
|
||||
|
||||
`award_coins → #shop`, `award_xp → #gam`, `new_class → /classes`, `new_test → #tests`,
|
||||
`view_users → #users`, `view_sessions → #sessions`, `view_audit → #sublog`, `view_overview → #overview`.
|
||||
|
||||
Phase 5 (quick actions) and Phase 6 (deep pages) may extend the `ACTIONS` array — just add to it; the action's `name` field is what users see and what is fuzzy-matched (lowercase substring on `name` + optional `hint` keyword).
|
||||
|
||||
### Ctrl+K conflict with `/js/search.js`
|
||||
|
||||
`/js/search.js` is also loaded on admin.html and binds its own Ctrl+K listener (bubble phase). Palette binds in **capture phase** + `e.stopImmediatePropagation()`, so on admin pages the palette wins. On non-admin pages the generic search remains intact (palette.js is only loaded from admin.html).
|
||||
|
||||
### Exposed globals
|
||||
|
||||
- `window.AdminPalette = { open, close, isOpen }` — for future programmatic open from quick-actions.
|
||||
- `LS.adminGlobalSearch(q)` — exported helper in `js/api.js`.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase 5: Per-row quick actions
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
**Parallelizable with:** Phase 3, Phase 4
|
||||
|
||||
## Objective
|
||||
|
||||
На hover-строке user / session показывать кнопки частых action прямо в таблице — без открытия overlay-панели. Сокращает 2-3 клика до 1 для типичных задач (бан, выдача монет, удаление сессии).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **Users table** (`frontend/js/admin/sections/users.js`):
|
||||
- Добавлена `<td class="row-actions-cell">` с inline-flex блоком `.row-actions` (заменяет старый `›` индикатор)
|
||||
- Visible: только на `:hover` строки (CSS opacity transition)
|
||||
- Кнопки (inline SVG, Lucide-style):
|
||||
- **Ban / Unban** — `quickToggleBan(uid, isBanned, btn)` → `LS.confirm` → `LS.adminBanUser`
|
||||
- **Award coins** — `quickAwardCoins(uid, name)` → `LS.modal` (sm) с inputs amount+reason → `LS.adminShopAwardCoins`
|
||||
- **Sessions** — `quickOpenUserSessions(uid)` → `AdminRouter.navigate('#sessions')` (fallback на `switchTab`)
|
||||
- **Delete** — `quickDeleteUser(uid, name, btn)` → `LS.confirm` (destructive) → `LS.adminDeleteUser`
|
||||
- SVG-иконки (inline, Lucide outline-style), НЕТ эмоджи
|
||||
- `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` (чтобы не открывать user-panel overlay)
|
||||
- Hidden для self (`u.id !== user.id`) и для non-admin — fallback на старый `›`
|
||||
- [x] **Sessions table** (`frontend/js/admin/sections/sessions.js`):
|
||||
- **View (eye icon)** — `toggleDrawer(id)` (тот же flow что и row-click)
|
||||
- **Delete (trash, danger)** — `quickDeleteSession(id, btn)` → `LS.confirm` → `LS.adminDeleteSession` → `load()` (refresh)
|
||||
- [x] **Backend `DELETE /api/admin/sessions/:id`** — endpoint отсутствовал, добавлен:
|
||||
- Route: `backend/src/routes/admin.js` (внутри `requireRole('admin')` блока)
|
||||
- Controller: `deleteSession(req, res, next)` в `adminController.js` — транзакция:
|
||||
1. `UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?` (explicit null, hoarded slot stays)
|
||||
2. `DELETE FROM user_answers WHERE session_id = ?` (FK has `ON DELETE CASCADE`, но делаем явно)
|
||||
3. `DELETE FROM session_questions WHERE session_id = ?` (то же)
|
||||
4. `DELETE FROM test_sessions WHERE id = ?`
|
||||
- Audit: `audit(req, 'session.delete', 'session:${sid}', 'user:N mode:X')`
|
||||
- Validates `Number.isInteger(sid) && sid > 0`; 404 if not found
|
||||
- API helper: `LS.adminDeleteSession(id)` → `DELETE /admin/sessions/:id`
|
||||
- [x] **CSS** (`#row-actions-style`):
|
||||
- Inject ONCE из обеих секций (de-dup по element id) — оба `ensureRowActionsStyles()` проверяют `getElementById('row-actions-style')` перед добавлением
|
||||
- Стили: `.row-actions`, `.row-action-btn` (default + .danger), `.row-actions-cell`, `@media (max-width: 768px) { display: none }`
|
||||
- Также handle `tr.selected .row-actions` и `.sess-tl-item.open .row-actions` → opacity 1 (для активных строк)
|
||||
- [x] `title="…"` на каждой кнопке (tooltip)
|
||||
- [x] `LS.confirm(message, { title, confirmText })` использован везде (signature: `lsConfirm(message, { title, confirmText, danger=true })` — `danger:true` default, gradient pink→violet)
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
- `frontend/js/admin/sections/users.js` — модификация renderRow + action handlers (~50-100L добавления)
|
||||
- `frontend/js/admin/sections/sessions.js` — same (~30-50L)
|
||||
- `frontend/admin.html` — стили для `.row-actions` (~30L)
|
||||
- `backend/src/controllers/adminController.js` — `deleteSession` если отсутствует
|
||||
- `backend/src/routes/admin.js` — `DELETE /sessions/:id` если отсутствует
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Hover на user row → видны 4 кнопки справа без раздвигания layout
|
||||
- Hover на session row → видны 2 кнопки
|
||||
- Каждая кнопка работает (ban / coins / sessions / delete)
|
||||
- Click на кнопку НЕ открывает user-panel overlay (stopPropagation)
|
||||
- Tooltip на hover каждой кнопки
|
||||
- Confirm для деструктивных action (delete, ban)
|
||||
- LS.toast после success
|
||||
- Auth check — все action available только admin
|
||||
- Mobile: actions hidden (tap-only context), либо альтернативный UI (long-press → menu) — пока минимум скрыть на ≤768px
|
||||
|
||||
## Notes
|
||||
|
||||
### Существующие helpers использовать
|
||||
|
||||
- `LS.confirm(message, { okText, danger })` для подтверждений
|
||||
- `LS.modal(...)` если нужна форма (например award coins amount)
|
||||
- `LS.toast` для feedback
|
||||
- Существующие admin* функции (toggleBanUser, awardCoins, etc.) — не дублировать
|
||||
|
||||
### Визуальный паттерн
|
||||
|
||||
Inspired by Linear / Vercel admin: actions visible on row hover, positioned right-aligned, ghost-style buttons (transparent bg, border on hover). Иконки только.
|
||||
|
||||
### Что НЕ делать в этой фазе
|
||||
|
||||
- Не делать bulk-actions (select multiple → action) — это после merge
|
||||
- Не делать undo (toast с "отменить" внутри) — Phase 6+
|
||||
- Не менять структуру таблицы radically
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [x] Кнопки не сдвигают layout — `opacity: 0 → 1` без display swap, занимают слот старого `›`
|
||||
- [x] Имя пользователя в onclick экранируется через `esc()` + `replace(/'/g, "\\'")` для безопасности SQL/HTML-injection в строковых литералах
|
||||
- [x] No emoji — только inline SVG (Lucide-style outline-stroke, viewBox 24x24)
|
||||
- [x] `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` div (defence in depth)
|
||||
- [x] Confirm через `LS.confirm` для destructive (delete user, delete session, ban/unban)
|
||||
- [x] `title` атрибут есть на каждой кнопке
|
||||
- [x] Mobile (≤768px): `.row-actions { display: none }` — row-click overlay по-прежнему работает как fallback
|
||||
- [x] `node --check` all modified files OK
|
||||
- [x] Tests: 32/35 pass (3 pre-existing auth-test failures, unrelated)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
**Phase 6 (deep entity pages) рекомендации:**
|
||||
|
||||
1. **`quickOpenUserSessions(uid)`** сейчас просто навигирует на `#sessions` без фильтра. Phase 6 должна:
|
||||
- Расширить router до `#sessions?user=N` (или новый формат `#sessions/user/N`)
|
||||
- В `sessions.js` `load()` читать query param и передавать `user_id` в `LS.adminGetSessions({ user_id })` (backend уже поддерживает `user_id` query param — см. `getAllSessions` controller)
|
||||
- Обновить хелпер: `AdminRouter.navigate('#sessions?user=' + uid)` (когда router научится parse'ить query)
|
||||
|
||||
2. **User-panel overlay vs hover actions:** Phase 6 удалит старую `.user-panel` overlay. Когда это произойдёт, row-click больше не будет открывать панель. Hover-actions останутся как primary UX. Рекомендация: при удалении overlay row-click сделать `onclick="AdminRouter.navigate('#users/' + uid)"` (deep page).
|
||||
|
||||
3. **Mobile UX gap:** на ≤768px actions сейчас полностью скрыты. Когда Phase 6 добавит deep page, mobile-row-click станет переходом на deep page → primary actions доступны там. До тех пор mobile = read-only browse.
|
||||
|
||||
4. **Backend `DELETE /admin/sessions/:id`** уже там, готов для Phase 6 deep session page (где будет кнопка "Удалить эту сессию" в header).
|
||||
|
||||
5. **Award coins modal pattern** (используем `LS.modal` с body=DOM Node + actions с `onClick({close, setError})`) — может быть полезен Phase 6 для inline-edit flow на deep user page.
|
||||
|
||||
6. **Linter note:** `npm run lint:routes` показывает FAIL (65 unprotected vs baseline 56) — pre-existing проблема, my new admin-protected `DELETE /sessions/:id` добавил +1 false-positive (роут защищён через `router.use(requireRole('admin'))` блок, который linter не видит). Не требует действий — это known limitation скрипта.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 6: Deep entity pages
|
||||
|
||||
**Status:** ✅ Done (sub-commits: bd30200 + final remove-overlay commit)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
|
||||
Заменить выезжающую `.user-panel` overlay на полноценную страницу с URL `#users/123`. Аналогично для session: `#sessions/456` = full detail page. Это самая комплексная фаза — она ломает совместимость с старым overlay UI (удаляет код), потому идёт ПОСЛЕ всех остальных.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **User detail page** (`frontend/js/admin/sections/user-detail.js`):
|
||||
- Реагирует на route `#users/:id`
|
||||
- Layout:
|
||||
- **Header**: avatar, name, role badge, email, action buttons (ban/edit/perms/delete), back-link to `#users`
|
||||
- **Tabs** (sub-nav в странице):
|
||||
- Overview — статистика (тестов, средний %, регистрация, посл вход)
|
||||
- Sessions — таблица последних 20 сессий с pagination
|
||||
- Classes — список классов где он состоит
|
||||
- Audit — журнал действий (если есть audit log с user_id)
|
||||
- **Graphs** (опционально, можно отдельным таб'ом):
|
||||
- Простой SVG-чарт: успеваемость по неделям
|
||||
- Mini-bar chart: avg % по предметам
|
||||
- [x] **Session detail page** (`frontend/js/admin/sections/session-detail.js`):
|
||||
- Реагирует на route `#sessions/:id`
|
||||
- Layout: header (user, subject, score, дата) + список вопросов/ответов (правильно/нет, текст), back-link
|
||||
- [x] **Router updates** (`frontend/js/admin/router.js` если ещё не поддерживает): router из Phase 1 уже парсит params — обновлять не пришлось
|
||||
- [x] **Admin.js dispatch**: добавлена `DEEP_ROUTES` map + `activateDeepPane()` + `activate(route, params)`
|
||||
- [x] **Удалить overlay-код:**
|
||||
- [x] В `frontend/admin.html` удалён `<div class="user-panel" id="user-panel">` блок + `.user-panel*` CSS
|
||||
- [x] В `sections/users.js` удалены `openUserPanel`, `closeUserPanel`, `reloadUserPanel`
|
||||
- [x] В `sections/users.js` onclick переключён на `AdminRouter.navigate('#users/${u.id}')`
|
||||
- [x] **Replace** в Phase 5 quick action "Sessions": `quickOpenUserSessions(uid)` → `AdminRouter.navigate('#users/' + uid + '/sessions')`
|
||||
- Парсить sub-tab из route (выполнено через `params[1]` в `activate()`)
|
||||
- Открывать user-detail page с активным Sessions tab
|
||||
- [x] **Глоссарий routes после фазы:**
|
||||
- `#overview` — dashboard (Phase 3)
|
||||
- `#users` — list
|
||||
- `#users/123` — user detail (overview tab default)
|
||||
- `#users/123/sessions` — user detail with sessions sub-tab
|
||||
- `#sessions` — list
|
||||
- `#sessions/456` — session detail
|
||||
- … остальные без params — как было
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
- `frontend/js/admin/sections/user-detail.js` — новый, ~400-600L
|
||||
- `frontend/js/admin/sections/session-detail.js` — новый, ~200-300L
|
||||
- `frontend/admin.html` — удалить `.user-panel` overlay, добавить `<div id="tab-user-detail">` и `<div id="tab-session-detail">`, добавить `<script>` теги
|
||||
- `frontend/js/admin/sections/users.js` — удалить overlay-функции (~100-150L удаления)
|
||||
- `frontend/js/admin/router.js` — улучшения parsing для sub-routes (если нужно)
|
||||
- `frontend/js/admin/admin.js` — dispatch logic для routes с params
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Click на user row → URL становится `#users/123`, открывается deep page
|
||||
- F5 на `#users/123` восстанавливает страницу
|
||||
- Back navigation → возврат на `#users` list
|
||||
- Header содержит все action-кнопки (ban, edit, perms, delete)
|
||||
- Sub-tabs (overview, sessions, classes, audit) переключаются, URL обновляется
|
||||
- Старая `.user-panel` overlay полностью удалена из HTML и JS
|
||||
- Click на session id (в любом контексте) → `#sessions/456` → detail page
|
||||
- Нет console errors
|
||||
- Графики (если делаются) рендерятся корректно
|
||||
|
||||
## Notes
|
||||
|
||||
### Backward compat
|
||||
|
||||
После Phase 6 старые ссылки/onclick типа `openUserPanel(...)` УЖЕ НЕ работают. Это intentional — мы их удалили. Но `onclick="AdminRouter.navigate('#users/N')"` работает везде.
|
||||
|
||||
Если есть external links на админку user-panel — они продолжат работать как `#users/N` через router.
|
||||
|
||||
### Графики
|
||||
|
||||
Можно использовать chart.js (CDN ~50KB), но проще — inline SVG bar/line chart на нескольких десятках строк. У нас уже есть `.perf-bar` для процентов — можно расширить.
|
||||
|
||||
Не обязательно делать графики в этой фазе — можно сделать MVP без них и добавить чартами позже. В acceptance criteria графики помечены опционально.
|
||||
|
||||
### Audit log
|
||||
|
||||
Если в БД есть таблица `audit_log` с `user_id` — sub-tab Audit показывает её. Если нет — sub-tab скрывается или показывает empty state "Audit logging не активирован".
|
||||
|
||||
### Session detail
|
||||
|
||||
Сейчас session detail открывается через `adminGetSessionDetail` → возвращает массив answers. Используем тот же endpoint, рендерим в полноценную страницу вместо modal.
|
||||
|
||||
### Удаление overlay-кода (опасный шаг)
|
||||
|
||||
Делать в КОНЦЕ фазы, после того как deep page работает. Сначала добавить deep page, протестировать, потом удалить overlay. Можно даже сделать отдельным коммитом ("remove overlay").
|
||||
|
||||
### Что НЕ делать
|
||||
|
||||
- Не делать realtime updates (Phase 7+)
|
||||
- Не делать collaborative cursors
|
||||
- Не оптимизировать графики до production-grade (chart.js or similar OK)
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Deep pages работают: F5, back/forward
|
||||
- [ ] Sub-tabs URL-обновляемы
|
||||
- [ ] Old overlay code fully removed
|
||||
- [ ] No regressions: ban/edit/delete user работают из deep page
|
||||
- [ ] Mobile-friendly: tabs scrollable, layout не ломается
|
||||
- [ ] Build passes
|
||||
- [ ] **Final smoke test:** пройти полный сценарий — открыть админку, найти пользователя через Cmd+K, перейти на deep page, выдать монеты, посмотреть сессии, забанить, разбанить, вернуться в overview
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
Это финальная фаза. Что реализовано в этой фазе:
|
||||
|
||||
### Done
|
||||
|
||||
- Deep page `#users/:id` с sub-tabs (overview/sessions/classes/audit) и URL-sync sub-routing
|
||||
- Deep page `#sessions/:id` с полным разбором ответов
|
||||
- F5 / browser-back / закладки работают на любом deep-URL
|
||||
- Overlay `.user-panel` полностью удалён (HTML + CSS + JS)
|
||||
- Sessions row-click переключён с inline drawer на deep page navigation (`gotoSession(id)`)
|
||||
- Audit sub-tab фильтрует system-wide audit-log по uid client-side
|
||||
- Inline SVG bar chart для per-subject avg % на Overview sub-tab (no chart.js dep)
|
||||
- Cmd+K palette user-pick (Phase 4) теперь открывает deep page (ранее был fallback на `#users`)
|
||||
- 2 sub-commit разбивка для безопасности: `bd30200` (add deep pages, overlay still works) → finale (remove overlay)
|
||||
|
||||
### Post-merge follow-ups (NOT блокирующие для merge)
|
||||
|
||||
1. **Classes sub-tab — placeholder.** Нет backend endpoint `GET /admin/users/:id/classes`. Текущий UI показывает empty-state со ссылкой на `/classes`. После merge добавить endpoint (выбрать из `class_members` по `user_id`).
|
||||
2. **Audit sub-tab — client-side filter.** Фильтрация делается на 500 строк из общего лога — для бóльших инсталляций нужен `GET /admin/audit-log?user_id=N` (server-side). Сейчас работает корректно для типичной нагрузки LearnSpace (<10k записей).
|
||||
3. **Charts — single bar chart only.** План включал опциональные графики "успеваемость по неделям" — оставил на post-merge. Использовать тот же inline SVG паттерн (`.ud-bars` + `.ud-bar-fill`).
|
||||
4. **Mobile.** Header кнопки сжимаются на ≤640px (см. CSS `.ud-header` block в user-detail.js), но при большом количестве действий могут перекрыть. Можно добавить overflow menu (`<details>`) post-merge если жалобы.
|
||||
5. **`.user-panel` CSS не полностью удалён** — оставлен только `.btn-close` (используется ещё где-то?). Если нет — можно удалить тоже.
|
||||
6. **`window.activeUid` — глобальное состояние.** Сейчас и user-detail.js, и users.js пишут/читают `window.activeUid`. Это работает, но в идеале нужно перенести user-only modals (eu-modal, up-modal) в user-detail.js целиком. Не критично, но улучшит изоляцию.
|
||||
|
||||
### Final smoke checklist (для final-reviewer)
|
||||
|
||||
- [ ] Открыть `/admin#overview` — dashboard
|
||||
- [ ] Cmd+K → найти юзера → пик из списка → открыть deep page
|
||||
- [ ] На deep page переключить sub-tabs (URL обновляется)
|
||||
- [ ] F5 на `#users/N/sessions` → page восстановлен
|
||||
- [ ] Browser back → возврат на `#users` list
|
||||
- [ ] Header action: Изменить → modal → save → header обновлён
|
||||
- [ ] Header action: Бан → toast → header обновлён (метка "Разблокировать")
|
||||
- [ ] Click on session row in Sessions sub-tab → `#sessions/M` (deep session page)
|
||||
- [ ] Session page: Delete → toast → navigate back to `#sessions` list
|
||||
- [ ] No console errors
|
||||
Reference in New Issue
Block a user