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:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -42,7 +42,16 @@ manifest: `{ id, cat, title, desc, preview(string|fn), theory?, bodyId?, mount?(
|
||||
- `loadTheory(id)` — если `get(base).theory` есть → рендерим из него; иначе `THEORY[base]`.
|
||||
- `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).
|
||||
- ВАЖНЫЙ УРОК: коммит 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.
|
||||
|
||||
@@ -27,7 +27,7 @@ if-цепочками. Далее — ленивая загрузка кода,
|
||||
- [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 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 Progress Log
|
||||
@@ -38,7 +38,7 @@ if-цепочками. Далее — ленивая загрузка кода,
|
||||
| Phase 1: Миграция всех | frontend | ✅ Done (ebb2a9b) | ✅ PASS | ✅ n/a | ✅ pushed |
|
||||
| Phase 2: Вынос тел | frontend | ✅ Done | ✅ браузер-проверка пройдена | ✅ n/a | ✅ pushed |
|
||||
| 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 | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 4: Реестр в БД + API + админка
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Done — review PASS, 11 тестов, миграция применена, запушено
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
@@ -39,4 +39,10 @@
|
||||
- [ ] Тесты проходят
|
||||
|
||||
## 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 для грубой привязки.
|
||||
|
||||
Reference in New Issue
Block a user