feat(lab-content-engine): phase 4 - каталог симуляций в БД + API + админка
- Миграция 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>
This commit is contained in:
@@ -1,87 +1,65 @@
|
||||
'use strict';
|
||||
/* admin → sims (simulations) section */
|
||||
/* admin → sims (simulations) section — контент-движок, Фаза 4.
|
||||
*
|
||||
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
|
||||
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
|
||||
* теги. Мастер-тумблер модуля — по-прежнему /api/settings/sims. */
|
||||
(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: 'emfield', cat: 'Физика', title: 'Электромагнитные поля' },
|
||||
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
|
||||
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
|
||||
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
|
||||
{ id: 'opticsbench', cat: 'Физика', title: 'Оптическая скамья' },
|
||||
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
|
||||
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
|
||||
{ id: 'heatengine', cat: 'Физика', title: 'Тепловые двигатели' },
|
||||
{ id: 'radioactive', cat: 'Физика', title: 'Радиоактивный распад' },
|
||||
{ id: 'race', cat: 'Физика', title: 'Гонка с задачами' },
|
||||
{ id: 'logic', 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: 'stoichiometry', cat: 'Химия', title: 'Стехиометрия' },
|
||||
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
|
||||
{ id: 'qualanalysis', cat: 'Химия', title: 'Качественный анализ' },
|
||||
{ id: 'periodic', cat: 'Химия', title: 'Периодическая таблица' },
|
||||
{ id: 'organic', cat: 'Химия', title: 'Органическая химия' },
|
||||
{ id: 'solutions', cat: 'Химия', title: 'Растворы' },
|
||||
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
|
||||
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
|
||||
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
|
||||
];
|
||||
const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игры' };
|
||||
const CAT_ORDER = ['math', 'phys', 'chem', 'bio', 'game'];
|
||||
|
||||
let _simsSettings = { module_disabled: false, disabled_ids: [] };
|
||||
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/settings/sims');
|
||||
_simsSettings = data;
|
||||
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'); }
|
||||
} 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;
|
||||
if (masterChk) masterChk.checked = !_moduleDisabled;
|
||||
|
||||
// per-sim cards
|
||||
const grid = document.getElementById('sims-grid');
|
||||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||||
// group by category
|
||||
if (!grid) return;
|
||||
|
||||
// group by category, preserving catalogue sort within group
|
||||
const byCat = {};
|
||||
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
|
||||
_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 = '';
|
||||
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}">
|
||||
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)}</div>
|
||||
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div>
|
||||
<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="${enabled ? 'Отключить' : 'Включить'}">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" />
|
||||
<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>
|
||||
@@ -95,26 +73,35 @@
|
||||
async function simsMasterToggle(checked) {
|
||||
try {
|
||||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
|
||||
_simsSettings.module_disabled = !checked;
|
||||
_moduleDisabled = !checked;
|
||||
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
} 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;
|
||||
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'); }
|
||||
} 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 = {
|
||||
|
||||
Reference in New Issue
Block a user