feat(admin): тумблер вкл/выкл для экзамен-модулей (exam-prep)

Не было UI для управления exam_tracks.enabled (только флаг в БД, ставился
миграцией). Добавлена админ-секция «Экзамен-модули»:
- backend exam-prep.js: GET /admin/tracks (все треки, вкл. выключенные, + число
  заданий) и PATCH /admin/track (exam_key, enabled), обе requireRole('admin').
  Пути без :examKey, чтобы не задеть гейт content_access.
- frontend: секция sections/exams.js (список треков + переключатель enabled),
  вкладка в admin.html (admin-only через ADMIN_ONLY_TABS, locked для не-админов),
  регистрация в admin.js (ROUTE_TO_SECTION).

Выключенный трек скрыт у учеников и пропадает из каталога прав доступа (тот
берёт exam_tracks WHERE enabled=1). Доступ ученикам по-прежнему в «Доступ · контент».
Требует перезапуска бэкенда + Ctrl+F5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-15 12:32:01 +03:00
parent 1cf8083c0e
commit 6fed18f819
4 changed files with 111 additions and 2 deletions
+23 -1
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const router = require('express').Router(); const router = require('express').Router();
const db = require('../db/db'); const db = require('../db/db');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware, requireRole } = require('../middleware/auth');
const access = require('../services/contentAccess'); const access = require('../services/contentAccess');
router.use(authMiddleware); router.use(authMiddleware);
@@ -416,6 +416,28 @@ router.get('/tracks', (req, res) => {
res.json({ tracks }); res.json({ tracks });
}); });
/* ── Админ: управление экзамен-модулями (вкл/выкл) ──
Отдельные пути (без :examKey, чтобы не задеть гейт content_access). */
router.get('/admin/tracks', requireRole('admin'), (_req, res) => {
const tracks = db.prepare(`
SELECT exam_key, title, subject_slug, grade, enabled, variants_count, sort_order,
(SELECT COUNT(*) FROM exam_tasks t WHERE t.exam_key = exam_tracks.exam_key) AS task_count
FROM exam_tracks
ORDER BY sort_order, exam_key
`).all();
res.json({ tracks });
});
router.patch('/admin/track', requireRole('admin'), (req, res) => {
const key = String(req.body && req.body.exam_key || '').trim();
if (!key) return res.status(400).json({ error: 'exam_key required' });
if (!db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(key))
return res.status(404).json({ error: 'Unknown exam track' });
const enabled = req.body && req.body.enabled ? 1 : 0;
db.prepare('UPDATE exam_tracks SET enabled = ? WHERE exam_key = ?').run(enabled, key);
res.json({ ok: true, exam_key: key, enabled });
});
/* ── GET /api/exam-prep/:examKey/info ── /* ── GET /api/exam-prep/:examKey/info ──
Track metadata + global counts + this user's aggregate progress. */ Track metadata + global counts + this user's aggregate progress. */
// @public-by-design: router-level authMiddleware (line 6) covers this route // @public-by-design: router-level authMiddleware (line 6) covers this route
+14
View File
@@ -1067,6 +1067,9 @@
<button class="admin-nav-item" data-tab="sims" onclick="switchTab(this)" id="btn-tab-sims" style="display:none"> <button class="admin-nav-item" data-tab="sims" onclick="switchTab(this)" id="btn-tab-sims" style="display:none">
<i data-lucide="atom" style="width:15px;height:15px"></i> Симуляции <i data-lucide="atom" style="width:15px;height:15px"></i> Симуляции
</button> </button>
<button class="admin-nav-item" data-tab="exams" onclick="switchTab(this)" id="btn-tab-exams" style="display:none">
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
</button>
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none"> <button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры <i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
</button> </button>
@@ -1616,6 +1619,16 @@
<div id="topics-list"></div> <div id="topics-list"></div>
</div> </div>
<!-- ── Экзамен-модули (вкл/выкл) ── -->
<div class="tab-pane" id="tab-exams">
<div class="section-title">Экзамен-модули</div>
<p style="color:var(--muted);font-size:13px;margin:4px 0 16px;max-width:760px">
Включение/выключение модулей подготовки к экзамену (<code>/exam-prep</code>). Выключенный модуль
скрыт у учеников и не показывается в каталоге прав доступа. Доступ ученикам открывается отдельно
в разделе «Доступ · контент» → «Экзамены».</p>
<div class="perm-grid" id="exams-grid"></div>
</div>
<!-- ── Доступ к учебникам / экзаменам ── --> <!-- ── Доступ к учебникам / экзаменам ── -->
<div class="tab-pane" id="tab-access"> <div class="tab-pane" id="tab-access">
<div class="section-title">Доступ к учебникам и экзаменам</div> <div class="section-title">Доступ к учебникам и экзаменам</div>
@@ -2136,6 +2149,7 @@
<script src="/js/admin/sections/overview.js"></script> <script src="/js/admin/sections/overview.js"></script>
<script src="/js/admin/sections/sublog.js"></script> <script src="/js/admin/sections/sublog.js"></script>
<script src="/js/admin/sections/sims.js"></script> <script src="/js/admin/sections/sims.js"></script>
<script src="/js/admin/sections/exams.js"></script>
<script src="/js/admin/sections/games.js"></script> <script src="/js/admin/sections/games.js"></script>
<script src="/js/admin/sections/assistant.js"></script> <script src="/js/admin/sections/assistant.js"></script>
<script src="/js/admin/sections/imggen.js"></script> <script src="/js/admin/sections/imggen.js"></script>
+2 -1
View File
@@ -15,7 +15,7 @@
AdminCtx.isAdmin = isAdmin; AdminCtx.isAdmin = isAdmin;
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */ /* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant','btn-tab-imggen']; const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-exams','btn-tab-games','btn-tab-assistant','btn-tab-imggen'];
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
ADMIN_ONLY_TABS.forEach(id => { ADMIN_ONLY_TABS.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -64,6 +64,7 @@
gam: 'gam', gam: 'gam',
tpl: 'tpl', tpl: 'tpl',
sims: 'sims', sims: 'sims',
exams: 'exams',
games: 'games', games: 'games',
assistant: 'assistant', assistant: 'assistant',
imggen: 'imggen', imggen: 'imggen',
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/* admin → exams (exam-prep modules) section.
* Список ВСЕХ экзамен-треков (вкл. выключенные) + тумблер enabled.
* Источник: GET /api/exam-prep/admin/tracks; переключение: PATCH /api/exam-prep/admin/track.
* Влияет на видимость модуля в /exam-prep и в каталоге прав доступа (Экзамены). */
(function () {
'use strict';
let inited = false;
let _tracks = [];
const SUBJ = { math: 'Математика', physics: 'Физика', phys: 'Физика', chemistry: 'Химия',
chem: 'Химия', biology: 'Биология', bio: 'Биология' };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function load() {
try {
const data = await LS.api('/api/exam-prep/admin/tracks');
_tracks = Array.isArray(data.tracks) ? data.tracks : [];
_render();
} catch (e) { LS.toast('Ошибка загрузки экзамен-модулей: ' + e.message, 'error'); }
}
function _render() {
const grid = document.getElementById('exams-grid');
if (!grid) return;
if (!_tracks.length) { grid.innerHTML = '<p style="color:var(--muted);font-size:13px">Нет экзамен-модулей.</p>'; return; }
grid.innerHTML = _tracks.map(t => {
const subj = SUBJ[t.subject_slug] || t.subject_slug || '';
const meta = [subj, t.grade ? (t.grade + ' кл.') : '', (t.task_count || 0) + ' заданий']
.filter(Boolean).join(' · ');
return `<div class="perm-card${t.enabled ? ' enabled' : ''}" id="examcard-${esc(t.exam_key)}" style="flex-wrap:wrap">
<div class="perm-info">
<div class="perm-label">${esc(t.title)}</div>
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(t.exam_key)}${meta ? ' · ' + esc(meta) : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<a href="/exam-prep/${esc(t.exam_key)}" target="_blank" title="Открыть модуль"
style="font-size:.72rem;color:var(--text-2);text-decoration:none;border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;padding:4px 8px">Открыть</a>
<label class="perm-toggle" title="${t.enabled ? 'Выключить модуль' : 'Включить модуль'}">
<input type="checkbox" ${t.enabled ? 'checked' : ''} onchange="examToggle('${esc(t.exam_key)}', this.checked)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
async function examToggle(examKey, enabled) {
try {
await LS.api('/api/exam-prep/admin/track', {
method: 'PATCH', body: JSON.stringify({ exam_key: examKey, enabled }),
});
const t = _tracks.find(x => x.exam_key === examKey);
if (t) t.enabled = enabled ? 1 : 0;
const card = document.getElementById('examcard-' + examKey);
if (card) card.classList.toggle('enabled', !!enabled);
LS.toast(enabled ? `«${examKey}» включён` : `«${examKey}» выключен`, enabled ? 'success' : 'warning');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.examToggle = examToggle;
window.AdminSections = window.AdminSections || {};
window.AdminSections.exams = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();