From c1c5bafaff5862ba1ead9129c6e5026066240c32 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 15:49:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(lab-content-engine):=20phase=204=20-=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D1=86=D0=B8=D0=B9=20=D0=B2=20=D0=91=D0=94=20?= =?UTF-8?q?+=20API=20+=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Миграция 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) --- backend/src/db/migrations/042_lab_sims.sql | 65 ++++++++++ backend/src/routes/lab.js | 130 +++++++++++++++++++ backend/src/server.js | 2 + backend/tests/lab-sims.test.js | 122 +++++++++++++++++ frontend/js/admin/sections/sims.js | 129 +++++++++--------- plans/lab-content-engine/CONTEXT.md | 11 +- plans/lab-content-engine/PLAN.md | 4 +- plans/lab-content-engine/phase-4-db-admin.md | 10 +- 8 files changed, 397 insertions(+), 76 deletions(-) create mode 100644 backend/src/db/migrations/042_lab_sims.sql create mode 100644 backend/src/routes/lab.js create mode 100644 backend/tests/lab-sims.test.js diff --git a/backend/src/db/migrations/042_lab_sims.sql b/backend/src/db/migrations/042_lab_sims.sql new file mode 100644 index 0000000..36688a7 --- /dev/null +++ b/backend/src/db/migrations/042_lab_sims.sql @@ -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); diff --git a/backend/src/routes/lab.js b/backend/src/routes/lab.js new file mode 100644 index 0000000..1cadd5b --- /dev/null +++ b/backend/src/routes/lab.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 5a7e988..3fb3b07 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep'); const textbookRoutes = require('./routes/textbooks'); const accessRoutes = require('./routes/access'); const teacherStudentsRoutes = require('./routes/teacherStudents'); +const labRoutes = require('./routes/lab'); const { requestId, errorHandler } = require('./middleware/errorHandler'); @@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes); app.use('/api/textbooks', textbookRoutes); app.use('/api/access', accessRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); +app.use('/api/lab', labRoutes); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); diff --git a/backend/tests/lab-sims.test.js b/backend/tests/lab-sims.test.js new file mode 100644 index 0000000..803578d --- /dev/null +++ b/backend/tests/lab-sims.test.js @@ -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}`); + }); +}); diff --git a/frontend/js/admin/sections/sims.js b/frontend/js/admin/sections/sims.js index 1e6fcaf..8eff83b 100644 --- a/frontend/js/admin/sections/sims.js +++ b/frontend/js/admin/sections/sims.js @@ -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 += `
${esc(cat)}
`; - sims.forEach(s => { - const enabled = !dis.has(s.id); - html += `
+ cats.forEach(cat => { + html += `
${esc(CAT_LABEL[cat] || cat)}
`; + byCat[cat].forEach(s => { + const tags = (s.tags || []).map(t => esc(t)).join(', '); + html += `
-
${esc(s.title)}
-
${esc(s.id)}
+
+ ${esc(s.title)} + +
+
${esc(s.id)}${tags ? ' · ' + tags : ''}
-