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:
Maxim Dolgolyov
2026-05-30 15:49:05 +03:00
parent 8ce4cec798
commit c1c5bafaff
8 changed files with 397 additions and 76 deletions
@@ -0,0 +1,65 @@
-- 042_lab_sims.sql — Контент-движок лаборатории, Фаза 4.
-- Каталог симуляций в БД: метаданные + оверрайды (вкл/выкл, порядок, теги,
-- рекомендуемые, курикулумные subject/grade). Источник истины каталога для
-- админки и (опционально) для /lab. Превью-SVG остаются в коде (frontend).
--
-- Совместимость: вкл/выкл также зеркалится в app_settings.sim_disabled_ids
-- на уровне API, поэтому существующая логика lab.html не ломается.
CREATE TABLE IF NOT EXISTS lab_sims (
id TEXT PRIMARY KEY, -- id симуляции ('pendulum', ...)
cat TEXT NOT NULL, -- math | phys | chem | bio | game
title TEXT NOT NULL,
subject TEXT, -- курикулум (Фаза 5), напр. 'physics'
grade INTEGER, -- класс (Фаза 5)
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = скрыта в каталоге
featured INTEGER NOT NULL DEFAULT 0, -- 1 = «рекомендуемая»
tags TEXT NOT NULL DEFAULT '[]', -- JSON-массив строк
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_lab_sims_sort ON lab_sims (sort_order);
-- Сид 40 симуляций в текущем порядке каталога /lab (из frontend SIMS).
INSERT OR IGNORE INTO lab_sims (id, cat, title, sort_order) VALUES
('graph', 'math', 'График функции', 1),
('graphtransform', 'math', 'Трансформации графиков', 2),
('geometry', 'math', 'Планиметрия', 3),
('triangle', 'math', 'Геометрия треугольника', 4),
('quadratic', 'math', 'Корни квадратного уравнения', 5),
('stereo', 'math', 'Стереометрия 3D', 6),
('probability', 'math', 'Теория вероятностей', 7),
('trigcircle', 'math', 'Тригонометрическая окружность', 8),
('normaldist', 'math', 'Нормальное распределение', 9),
('projectile', 'phys', 'Бросок тела', 10),
('pendulum', 'phys', 'Маятник', 11),
('collision', 'phys', 'Столкновение шаров', 12),
('emfield', 'phys', 'Электромагнитные поля', 13),
('circuit', 'phys', 'Электрические цепи', 14),
('hydrostatics', 'phys', 'Гидростатика', 15),
('dynamics', 'phys', 'Динамика', 16),
('opticsbench', 'phys', 'Оптическая скамья', 17),
('isoprocess', 'phys', 'Изопроцессы', 18),
('waves', 'phys', 'Волны и звук', 19),
('radioactive', 'phys', 'Радиоактивный распад', 20),
('race', 'phys', 'Гонка с задачами', 21),
('heatengine', 'phys', 'Тепловые двигатели', 22),
('logic', 'phys', 'Логические схемы', 23),
('molphys', 'chem', 'Молекулярная физика', 24),
('chemistry', 'chem', 'Химические реакции', 25),
('equilibrium', 'chem', 'Химическое равновесие', 26),
('electrolysis', 'chem', 'Электролиз', 27),
('bohratom', 'chem', 'Атом Бора', 28),
('orbitals', 'chem', 'Молекулярные орбитали', 29),
('titration', 'chem', 'pH и кривая титрования', 30),
('chemsandbox', 'chem', 'Химическая песочница', 31),
('stoichiometry', 'chem', 'Стехиометрия', 32),
('crystal', 'chem', 'Кристаллическая решётка', 33),
('qualanalysis', 'chem', 'Качественный анализ', 34),
('periodic', 'chem', 'Периодическая таблица', 35),
('organic', 'chem', 'Органическая химия', 36),
('solutions', 'chem', 'Растворы', 37),
('celldivision', 'bio', 'Деление клетки', 38),
('photosynthesis', 'bio', 'Фотосинтез и дыхание', 39),
('angrybirds', 'game', 'Angry Birds Physics', 40);
+130
View File
@@ -0,0 +1,130 @@
'use strict';
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фаза 4).
*
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги модуля.
* Чтение: любой авторизованный пользователь.
* PATCH /api/lab/sims/:id — изменить enabled/featured/tags/subject/grade. admin.
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
*
* Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому
* существующая логика lab.html (которая читает /api/settings/sims) продолжает
* корректно скрывать отключённые симуляции без правок фронта. */
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
router.use(authMiddleware);
/* ── helpers ───────────────────────────────────────────────────────────── */
function readModuleDisabled() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_module_disabled'`).get();
return row ? row.value === '1' : false;
}
function readLegacyDisabledIds() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_disabled_ids'`).get();
try { return new Set(JSON.parse(row && row.value || '[]')); } catch { return new Set(); }
}
function writeLegacyDisabledIds(set) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(JSON.stringify([...set]));
}
function parseTags(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } }
function rowToSim(r) {
return {
id: r.id, cat: r.cat, title: r.title,
subject: r.subject || null, grade: r.grade != null ? r.grade : null,
sort: r.sort_order, enabled: !!r.enabled, featured: !!r.featured,
tags: parseTags(r.tags),
};
}
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
router.get('/sims', (_req, res) => {
const rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
const legacyDisabled = readLegacyDisabledIds();
const sims = rows.map(r => {
const s = rowToSim(r);
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
s.enabled = s.enabled && !legacyDisabled.has(r.id);
return s;
});
res.json({ module_disabled: readModuleDisabled(), sims });
});
/* ── admin mutations ───────────────────────────────────────────────────── */
router.use(requireRole('admin'));
/* PATCH /api/lab/sims/:id body: { enabled?, featured?, tags?, subject?, grade?, title?, cat? } */
router.patch('/sims/:id', requireRole('admin'), (req, res) => {
const id = String(req.params.id || '');
const row = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'симуляция не найдена' });
const b = req.body || {};
const sets = [];
const vals = [];
if (b.enabled !== undefined) { sets.push('enabled = ?'); vals.push(b.enabled ? 1 : 0); }
if (b.featured !== undefined) { sets.push('featured = ?'); vals.push(b.featured ? 1 : 0); }
if (b.title !== undefined) {
const t = String(b.title).trim();
if (!t) return res.status(400).json({ error: 'пустой title' });
sets.push('title = ?'); vals.push(t);
}
if (b.cat !== undefined) {
if (!CATS.includes(b.cat)) return res.status(400).json({ error: 'неверная категория' });
sets.push('cat = ?'); vals.push(b.cat);
}
if (b.subject !== undefined) { sets.push('subject = ?'); vals.push(b.subject ? String(b.subject) : null); }
if (b.grade !== undefined) {
const g = b.grade === null || b.grade === '' ? null : Number(b.grade);
if (g !== null && (!Number.isInteger(g) || g < 1 || g > 11)) {
return res.status(400).json({ error: 'grade должен быть 1..11 или null' });
}
sets.push('grade = ?'); vals.push(g);
}
if (b.tags !== undefined) {
if (!Array.isArray(b.tags)) return res.status(400).json({ error: 'tags должен быть массивом' });
const clean = b.tags.map(t => String(t).trim()).filter(Boolean).slice(0, 20);
sets.push('tags = ?'); vals.push(JSON.stringify(clean));
}
if (!sets.length) return res.status(400).json({ error: 'нет полей для обновления' });
sets.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE lab_sims SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
// Зеркалим enabled в legacy sim_disabled_ids для совместимости с lab.html.
if (b.enabled !== undefined) {
const set = readLegacyDisabledIds();
if (b.enabled) set.delete(id); else set.add(id);
writeLegacyDisabledIds(set);
}
const updated = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
res.json({ ok: true, sim: rowToSim(updated) });
});
/* POST /api/lab/sims/reorder body: { order: [id, id, ...] } */
router.post('/sims/reorder', (req, res) => {
const order = (req.body && req.body.order) || [];
if (!Array.isArray(order) || !order.length) {
return res.status(400).json({ error: 'order должен быть непустым массивом id' });
}
const exists = new Set(db.prepare('SELECT id FROM lab_sims').all().map(r => r.id));
for (const id of order) {
if (!exists.has(id)) return res.status(400).json({ error: 'неизвестный id: ' + id });
}
const upd = db.prepare("UPDATE lab_sims SET sort_order = ?, updated_at = datetime('now') WHERE id = ?");
db.transaction(() => {
order.forEach((id, i) => upd.run(i + 1, id));
})();
res.json({ ok: true, count: order.length });
});
module.exports = router;
+2
View File
@@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep');
const textbookRoutes = require('./routes/textbooks'); const textbookRoutes = require('./routes/textbooks');
const accessRoutes = require('./routes/access'); const accessRoutes = require('./routes/access');
const teacherStudentsRoutes = require('./routes/teacherStudents'); const teacherStudentsRoutes = require('./routes/teacherStudents');
const labRoutes = require('./routes/lab');
const { requestId, errorHandler } = require('./middleware/errorHandler'); const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes);
app.use('/api/textbooks', textbookRoutes); app.use('/api/textbooks', textbookRoutes);
app.use('/api/access', accessRoutes); app.use('/api/access', accessRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes); app.use('/api/teacher-students', teacherStudentsRoutes);
app.use('/api/lab', labRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */ /* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db'); const _featDb = require('./db/db');
+122
View File
@@ -0,0 +1,122 @@
'use strict';
/**
* Integration tests: /api/lab/sims — catalog from DB + admin overrides.
* Covers: seeded catalog, auth, role-gating, enabled toggle (+legacy mirror),
* featured/tags/subject/grade patch, reorder, validation.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/lab on the shared test app (setup builds its own app without it).
app.use('/api/lab', require('../src/routes/lab'));
after(() => cleanup());
describe('/api/lab/sims', () => {
let adminToken, studentToken;
before(async () => {
adminToken = (await getToken('admin')).token;
studentToken = (await getToken('student')).token;
});
it('GET /api/lab/sims requires auth (401 without token)', async () => {
const res = await inject('GET', '/api/lab/sims', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.module_disabled, false);
assert.ok(Array.isArray(res.body.sims), 'sims is array');
assert.equal(res.body.sims.length, 40, `expected 40 sims, got ${res.body.sims.length}`);
const pend = res.body.sims.find(s => s.id === 'pendulum');
assert.ok(pend, 'pendulum present');
assert.equal(pend.cat, 'phys');
assert.equal(pend.enabled, true);
assert.deepEqual(pend.tags, []);
});
it('catalog is ordered by sort_order (graph first, angrybirds last)', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.body.sims[0].id, 'graph');
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
});
it('PATCH /api/lab/sims/:id is admin-only (student → 403)', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum', { featured: true }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('admin can disable a sim; it reflects in GET and in legacy sim_disabled_ids', async () => {
const res = await inject('PATCH', '/api/lab/sims/waves', { enabled: false }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.sim.enabled, false);
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const waves = get.body.sims.find(s => s.id === 'waves');
assert.equal(waves.enabled, false, 'waves disabled in catalog');
const legacy = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(legacy.includes('waves'), 'waves in legacy sim_disabled_ids');
await inject('PATCH', '/api/lab/sims/waves', { enabled: true }, adminToken);
const legacy2 = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(!legacy2.includes('waves'), 'waves removed from legacy after enable');
});
it('admin can set featured, tags, subject, grade', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum',
{ featured: true, tags: ['колебания', 'механика'], subject: 'physics', grade: 9 }, adminToken);
assert.equal(res.status, 200);
assert.equal(res.body.sim.featured, true);
assert.deepEqual(res.body.sim.tags, ['колебания', 'механика']);
assert.equal(res.body.sim.subject, 'physics');
assert.equal(res.body.sim.grade, 9);
});
it('PATCH rejects bad grade and bad category and non-array tags', async () => {
const g = await inject('PATCH', '/api/lab/sims/pendulum', { grade: 99 }, adminToken);
assert.equal(g.status, 400, 'bad grade rejected');
const c = await inject('PATCH', '/api/lab/sims/pendulum', { cat: 'nope' }, adminToken);
assert.equal(c.status, 400, 'bad cat rejected');
const t = await inject('PATCH', '/api/lab/sims/pendulum', { tags: 'notarray' }, adminToken);
assert.equal(t.status, 400, 'non-array tags rejected');
});
it('PATCH unknown sim → 404', async () => {
const res = await inject('PATCH', '/api/lab/sims/nonexistent', { featured: true }, adminToken);
assert.equal(res.status, 404, `got ${res.status}`);
});
it('POST /api/lab/sims/reorder updates sort order (admin)', async () => {
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const ids = get.body.sims.map(s => s.id);
const reordered = ['angrybirds', 'graph', ...ids.filter(id => id !== 'angrybirds' && id !== 'graph')];
const res = await inject('POST', '/api/lab/sims/reorder', { order: reordered }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.count, 40);
const get2 = await inject('GET', '/api/lab/sims', null, adminToken);
assert.equal(get2.body.sims[0].id, 'angrybirds', 'angrybirds now first');
assert.equal(get2.body.sims[1].id, 'graph', 'graph now second');
});
it('reorder rejects unknown id and empty order', async () => {
const bad = await inject('POST', '/api/lab/sims/reorder', { order: ['ghost'] }, adminToken);
assert.equal(bad.status, 400, 'unknown id rejected');
const empty = await inject('POST', '/api/lab/sims/reorder', { order: [] }, adminToken);
assert.equal(empty.status, 400, 'empty order rejected');
});
it('reorder is admin-only (student → 403)', async () => {
const res = await inject('POST', '/api/lab/sims/reorder', { order: ['graph'] }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
});
+58 -71
View File
@@ -1,87 +1,65 @@
'use strict'; 'use strict';
/* admin → sims (simulations) section */ /* admin → sims (simulations) section — контент-движок, Фаза 4.
*
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
* теги. Мастер-тумблер модуля — по-прежнему /api/settings/sims. */
(function () { (function () {
'use strict'; 'use strict';
let inited = false; let inited = false;
// Full list of available (non-null id) sims mirrored from /lab const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игры' };
const ADMIN_SIMS = [ const CAT_ORDER = ['math', 'phys', 'chem', 'bio', 'game'];
{ 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' },
];
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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function load() { async function load() {
try { try {
const data = await LS.api('/api/settings/sims'); const data = await LS.api('/api/lab/sims');
_simsSettings = data; _moduleDisabled = !!data.module_disabled;
_sims = Array.isArray(data.sims) ? data.sims : [];
_render(); _render();
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); } } catch (e) { LS.toast('Ошибка загрузки симуляций: ' + e.message, 'error'); }
} }
function _render() { function _render() {
// master toggle
const masterChk = document.getElementById('sims-master-chk'); 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 grid = document.getElementById('sims-grid');
const dis = new Set(_simsSettings.disabled_ids || []); if (!grid) return;
// group by category
// group by category, preserving catalogue sort within group
const byCat = {}; 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 = ''; let html = '';
Object.entries(byCat).forEach(([cat, sims]) => { 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)}</div>`; 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>`;
sims.forEach(s => { byCat[cat].forEach(s => {
const enabled = !dis.has(s.id); const tags = (s.tags || []).map(t => esc(t)).join(', ');
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}"> html += `<div class="perm-card${s.enabled ? ' enabled' : ''}" id="simcard-${esc(s.id)}">
<div class="perm-info"> <div class="perm-info">
<div class="perm-label">${esc(s.title)}</div> <div class="perm-label">
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div> ${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> </div>
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}"> <label class="perm-toggle" title="${s.enabled ? 'Отключить' : 'Включить'}">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" /> <input type="checkbox" ${s.enabled ? 'checked' : ''} onchange="simToggleOne('${esc(s.id)}', this.checked)" />
<span class="perm-track"></span> <span class="perm-track"></span>
<span class="perm-thumb"></span> <span class="perm-thumb"></span>
</label> </label>
@@ -95,26 +73,35 @@
async function simsMasterToggle(checked) { async function simsMasterToggle(checked) {
try { try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) }); 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'); 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) { 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 { try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) }); await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ enabled }) });
_simsSettings.disabled_ids = disabled_ids; const s = _sims.find(x => x.id === simId);
if (s) s.enabled = enabled;
const card = document.getElementById('simcard-' + simId); const card = document.getElementById('simcard-' + simId);
if (card) card.classList.toggle('enabled', enabled); if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning'); 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.simsMasterToggle = simsMasterToggle;
window.simToggleOne = simToggleOne; window.simToggleOne = simToggleOne;
window.simToggleFeatured = simToggleFeatured;
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
window.AdminSections.sims = { window.AdminSections.sims = {
+10 -1
View File
@@ -42,7 +42,16 @@ manifest: `{ id, cat, title, desc, preview(string|fn), theory?, bodyId?, mount?(
- `loadTheory(id)` — если `get(base).theory` есть → рендерим из него; иначе `THEORY[base]`. - `loadTheory(id)` — если `get(base).theory` есть → рендерим из него; иначе `THEORY[base]`.
- `closeSim()`/`_pauseAllSims()` — дополнительно `LabRegistry.stopActive()` / `destroyActive()`. - `closeSim()`/`_pauseAllSims()` — дополнительно `LabRegistry.stopActive()` / `destroyActive()`.
## RESUME STATE — Phase 3 done + FIXED (2026-05-30, latest) ## RESUME STATE — Phase 4 done (2026-05-30, latest)
- Ф4: каталог симуляций в БД. Миграция `042_lab_sims.sql` (таблица lab_sims, сид 40), `backend/src/routes/lab.js` (GET /api/lab/sims auth; PATCH /:id + POST /reorder admin), mount в server.js, 11 тестов, переписан admin/sections/sims.js (убран хардкод ADMIN_SIMS).
- enabled зеркалится в legacy app_settings.sim_disabled_ids → lab.html без правок. preview-SVG остаются в коде.
- Ревью PASS (без блокеров). route-auth lint чистый. Миграция применена к живой БД. Запушено, remote синхронен.
- ВАЖНО: `npm test` = 3 PRE-EXISTING baseline-фейла (НЕ мои; документированы с lab-split). pre-commit хук: BASELINE_FAILS=3, блокирует только при >3. Мои 11 проходят, +0.
- ⚠️ Параллельная сессия коммитит в ветку — был 2 behind, rebase прошёл чисто (без пересечений по файлам). Всегда fetch+rebase перед push.
- НЕ ПРОВЕРЕНО В БРАУЗЕРЕ: админка «Симуляции» (грузит /api/lab/sims, тумблеры, звезда featured) + исчезновение выключенной симуляции на /lab.
- ОСТАЛОСЬ: Фаза 5 (курикулум: lab_sim_links + кнопки «Открыть в лаборатории» в учебнике/теории + связанная теория/задачи на странице sim). subject/grade/featured/tags уже в схеме lab_sims.
## RESUME STATE — Phase 3 done + FIXED (2026-05-30, ранее)
- HEAD=9069d80 (Ф3 + критический фикс). ЗАПУШЕНО, remote синхронен (0 0). - HEAD=9069d80 (Ф3 + критический фикс). ЗАПУШЕНО, remote синхронен (0 0).
- ВАЖНЫЙ УРОК: коммит fc1139f был СЛОМАН — 2 edit'а (_register-all open-обёртка + lab-init Promise-обработка) не применились (упали по отступу old_string), а я запушил, не заметив. Ревью-агент поймал: lab.html убрал eager-скрипты, но open остался синхронным → ReferenceError на клике. Фикс в 9069d80. ПРАВИЛО: после каждого edit проверять `grep -c` маркера; не пушить пакет без поштучной верификации. - ВАЖНЫЙ УРОК: коммит fc1139f был СЛОМАН — 2 edit'а (_register-all open-обёртка + lab-init Promise-обработка) не применились (упали по отступу old_string), а я запушил, не заметив. Ревью-агент поймал: lab.html убрал eager-скрипты, но open остался синхронным → ReferenceError на клике. Фикс в 9069d80. ПРАВИЛО: после каждого edit проверять `grep -c` маркера; не пушить пакет без поштучной верификации.
- ТЕПЕРЬ КОРРЕКТНО: open → LabLoader.ensure(id).then(rawOpen); openSim обрабатывает Promise. E2E vm-harness (click→ensure→load→rawOpen, pendulum/stereo:cube/molphys/alias magnetic) ALL PASS. - ТЕПЕРЬ КОРРЕКТНО: open → LabLoader.ensure(id).then(rawOpen); openSim обрабатывает Promise. E2E vm-harness (click→ensure→load→rawOpen, pendulum/stereo:cube/molphys/alias magnetic) ALL PASS.
+2 -2
View File
@@ -27,7 +27,7 @@ if-цепочками. Далее — ленивая загрузка кода,
- [x] Phase 1: Миграция всех симуляций на манифесты [domain: frontend] → [subplan](./phase-1-migrate-all.md) - [x] Phase 1: Миграция всех симуляций на манифесты [domain: frontend] → [subplan](./phase-1-migrate-all.md)
- [x] Phase 2: Тела симуляций вынесены в labs-bodies.html (sync-инъекция) [domain: frontend] → [subplan](./phase-2-lazy-mount.md) - [x] Phase 2: Тела симуляций вынесены в labs-bodies.html (sync-инъекция) [domain: frontend] → [subplan](./phase-2-lazy-mount.md)
- [x] Phase 3: Ленивая загрузка кода симуляций [domain: frontend] → [subplan](./phase-3-lazy-load.md) - [x] Phase 3: Ленивая загрузка кода симуляций [domain: frontend] → [subplan](./phase-3-lazy-load.md)
- [ ] Phase 4: Реестр в БД + API + админка [domain: fullstack] → [subplan](./phase-4-db-admin.md) - [x] Phase 4: Реестр в БД + API + админка [domain: fullstack] → [subplan](./phase-4-db-admin.md)
- [ ] Phase 5: Курикулумная привязка [domain: fullstack] → [subplan](./phase-5-curriculum.md) - [ ] Phase 5: Курикулумная привязка [domain: fullstack] → [subplan](./phase-5-curriculum.md)
## Phase Progress Log ## Phase Progress Log
@@ -38,7 +38,7 @@ if-цепочками. Далее — ленивая загрузка кода,
| Phase 1: Миграция всех | frontend | ✅ Done (ebb2a9b) | ✅ PASS | ✅ n/a | ✅ pushed | | Phase 1: Миграция всех | frontend | ✅ Done (ebb2a9b) | ✅ PASS | ✅ n/a | ✅ pushed |
| Phase 2: Вынос тел | frontend | ✅ Done | ✅ браузер-проверка пройдена | ✅ n/a | ✅ pushed | | Phase 2: Вынос тел | frontend | ✅ Done | ✅ браузер-проверка пройдена | ✅ n/a | ✅ pushed |
| Phase 3: Ленивая загрузка | frontend | ✅ Done (201e94e +fix) | ✅ E2E harness ALL PASS | ✅ n/a | ⚠️ нужна браузер-проверка | | Phase 3: Ленивая загрузка | frontend | ✅ Done (201e94e +fix) | ✅ E2E harness ALL PASS | ✅ n/a | ⚠️ нужна браузер-проверка |
| Phase 4: БД + админка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: БД + админка | fullstack | ✅ Done | ✅ PASS (review) | ✅ 11/11 +0 baseline | ✅ pushed |
| Phase 5: Курикулум | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Курикулум | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review ## Final Review
+8 -2
View File
@@ -1,6 +1,6 @@
# Phase 4: Реестр в БД + API + админка # Phase 4: Реестр в БД + API + админка
**Status:** ⬜ Not Started **Status:** ✅ Done — review PASS, 11 тестов, миграция применена, запушено
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
@@ -39,4 +39,10 @@
- [ ] Тесты проходят - [ ] Тесты проходят
## Handoff to Next Phase ## Handoff to Next Phase
<!-- заполнить после фазы --> - РЕАЛИЗОВАНО: таблица `lab_sims` (миграция 042), `backend/src/routes/lab.js` (GET /api/lab/sims + PATCH /:id + POST /reorder), монтирование в server.js (require:58, mount:181), 11 тестов `lab-sims.test.js`, переписан `frontend/js/admin/sections/sims.js` (убран ADMIN_SIMS).
- ИСТОЧНИК ИСТИНЫ каталога теперь БД (lab_sims). preview-SVG остаются в коде. Поля subject/grade/featured/tags ГОТОВЫ в схеме и API — Фаза 5 их наполнит (курикулум) и фронт /lab может начать их потреблять.
- СОВМЕСТИМОСТЬ: enabled зеркалится в app_settings.sim_disabled_ids, поэтому lab.html (читает /api/settings/sims) скрывает выключенные без правок фронта. Каталог /lab пока НЕ читает /api/lab/sims (рендерится из кода-реестра+SIMS) — это опционально для Фазы 5: можно подтянуть порядок/featured/теги из БД.
- ТЕСТЫ: `npm test` имеет 3 PRE-EXISTING baseline-фейла (документированы с lab-split, не связаны с этой работой). pre-commit хук толерантен (BASELINE_FAILS=3). Мои 11 тестов проходят, +0 к фейлам.
- БРАУЗЕР-ПРОВЕРКА (желательно для Ф4): зайти в админку → раздел «Симуляции»: список грузится из API, тумблеры вкл/выкл и звёзда «рекомендуемая» работают; на /lab выключенная симуляция исчезает из каталога.
- ⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ активно коммитит в эту ветку (chemistry-8 и др.) — fetch+rebase перед push (в этой фазе было 2 behind, rebase прошёл чисто, без пересечений по файлам).
- Для Фазы 5: связи sim ↔ §учебника/тема/kmap можно класть в новую таблицу `lab_sim_links`; subject/grade уже есть в lab_sims для грубой привязки.