c1c5bafaff
- Миграция 042_lab_sims.sql: таблица lab_sims (id, cat, title, subject, grade,
sort_order, enabled, featured, tags JSON), сид 40 симуляций в порядке каталога
- backend/src/routes/lab.js: GET /api/lab/sims (мёрж БД + legacy-флаги, auth),
PATCH /api/lab/sims/:id (admin), POST /api/lab/sims/reorder (admin).
enabled зеркалится в legacy sim_disabled_ids -> lab.html без правок фронта
- server.js: монтирование /api/lab
- tests/lab-sims.test.js: 11 тестов (auth/роли/вкл-выкл+зеркало/featured/tags/
валидация/reorder/404), все проходят; +0 к baseline (3 pre-existing)
- admin/sections/sims.js: убран захардкоженный ADMIN_SIMS, каталог из /api/lab/sims,
тумблеры вкл-выкл и «рекомендуемая»; XSS-эскейп, иконки .ic
- plans/: Фаза 4 done + handoff
Независимое ревью: PASS, блокеров нет. route-auth lint: PATCH-роут защищён inline
requireRole('admin'). Миграция применена к живой БД.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
5.4 KiB
JavaScript
112 lines
5.4 KiB
JavaScript
'use strict';
|
|
/* admin → sims (simulations) section — контент-движок, Фаза 4.
|
|
*
|
|
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
|
|
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
|
|
* теги. Мастер-тумблер модуля — по-прежнему /api/settings/sims. */
|
|
(function () {
|
|
'use strict';
|
|
let inited = false;
|
|
|
|
const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игры' };
|
|
const CAT_ORDER = ['math', 'phys', 'chem', 'bio', 'game'];
|
|
|
|
let _moduleDisabled = false;
|
|
let _sims = []; // [{id,cat,title,enabled,featured,tags,subject,grade,sort}]
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const data = await LS.api('/api/lab/sims');
|
|
_moduleDisabled = !!data.module_disabled;
|
|
_sims = Array.isArray(data.sims) ? data.sims : [];
|
|
_render();
|
|
} catch (e) { LS.toast('Ошибка загрузки симуляций: ' + e.message, 'error'); }
|
|
}
|
|
|
|
function _render() {
|
|
const masterChk = document.getElementById('sims-master-chk');
|
|
if (masterChk) masterChk.checked = !_moduleDisabled;
|
|
|
|
const grid = document.getElementById('sims-grid');
|
|
if (!grid) return;
|
|
|
|
// group by category, preserving catalogue sort within group
|
|
const byCat = {};
|
|
_sims.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
|
|
const cats = CAT_ORDER.filter(c => byCat[c]).concat(
|
|
Object.keys(byCat).filter(c => !CAT_ORDER.includes(c)));
|
|
|
|
let html = '';
|
|
cats.forEach(cat => {
|
|
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_LABEL[cat] || cat)}</div>`;
|
|
byCat[cat].forEach(s => {
|
|
const tags = (s.tags || []).map(t => esc(t)).join(', ');
|
|
html += `<div class="perm-card${s.enabled ? ' enabled' : ''}" id="simcard-${esc(s.id)}">
|
|
<div class="perm-info">
|
|
<div class="perm-label">
|
|
${esc(s.title)}
|
|
<button class="sim-star" title="${s.featured ? 'Убрать из рекомендуемых' : 'Сделать рекомендуемой'}"
|
|
onclick="simToggleFeatured('${esc(s.id)}', ${s.featured ? 'false' : 'true'})"
|
|
style="background:none;border:none;cursor:pointer;padding:0 0 0 6px;vertical-align:middle">
|
|
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;${s.featured ? 'fill:var(--amber);stroke:var(--amber)' : 'fill:none;stroke:var(--text-3)'}"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}${tags ? ' · ' + tags : ''}</div>
|
|
</div>
|
|
<label class="perm-toggle" title="${s.enabled ? 'Отключить' : 'Включить'}">
|
|
<input type="checkbox" ${s.enabled ? 'checked' : ''} onchange="simToggleOne('${esc(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 }) });
|
|
_moduleDisabled = !checked;
|
|
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
|
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function simToggleOne(simId, enabled) {
|
|
try {
|
|
await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ enabled }) });
|
|
const s = _sims.find(x => x.id === simId);
|
|
if (s) s.enabled = enabled;
|
|
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'); }
|
|
}
|
|
|
|
async function simToggleFeatured(simId, featured) {
|
|
try {
|
|
await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ featured }) });
|
|
const s = _sims.find(x => x.id === simId);
|
|
if (s) s.featured = featured;
|
|
_render();
|
|
LS.toast(featured ? `«${simId}» в рекомендуемых` : `«${simId}» убрана из рекомендуемых`, 'success');
|
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
|
}
|
|
|
|
window.simsMasterToggle = simsMasterToggle;
|
|
window.simToggleOne = simToggleOne;
|
|
window.simToggleFeatured = simToggleFeatured;
|
|
|
|
window.AdminSections = window.AdminSections || {};
|
|
window.AdminSections.sims = {
|
|
init: async () => { if (inited) return; inited = true; await load(); },
|
|
reload: load,
|
|
};
|
|
})();
|