From fb6175e4a23233714b8f17ead0b00791ee48679b Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 17:26:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(lab-content-engine):=20phase=205=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9=20=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5=20+=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lab.js: GET /api/lab/links/all?kind= — пакетный обратный поиск (byRef map), чтобы каталог учебников не делал N+1 запросов - tests/lab-links.test.js: +3 теста для /links/all (group/400/401) -> 21/21 - admin/sections/sims.js: inline-редактор курикулумных связей на карточке симуляции (кнопка «Связи» -> панель: список связей с удалением + выбор учебника + добавить); использует /api/access/catalog, POST/DELETE /links. Без LS.modal (inline-панель) - textbooks.html: кнопка «В лабораторию» на карточке учебника, если есть связанные симуляции (один батч-запрос /links/all при загрузке); deep-link /lab?sim= Двусторонняя навигация sim <-> учебник готова. Иконки .ic, без эмодзи. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/lab.js | 29 +++++++ backend/tests/lab-links.test.js | 18 +++++ frontend/js/admin/sections/sims.js | 117 +++++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/lab.js b/backend/src/routes/lab.js index d3649d8..429083e 100644 --- a/backend/src/routes/lab.js +++ b/backend/src/routes/lab.js @@ -218,6 +218,35 @@ router.get('/links', (req, res) => { res.json({ sims }); }); +/* GET /api/lab/links/all?kind=textbook + → { byRef: { : [{id,title,cat}] } } — пакетный обратный поиск для всех + ref_id данного типа за один запрос (избегаем N+1 на странице каталога учебников). + Отдаёт только включённые симуляции. */ +router.get('/links/all', (req, res) => { + const kind = String(req.query.kind || ''); + if (!LINK_KINDS.includes(kind)) { + return res.status(400).json({ error: 'неверный kind' }); + } + let rows; + try { + rows = db.prepare(` + SELECT l.ref_id, s.id, s.title, s.cat, s.enabled, s.sort_order + FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id + WHERE l.kind = ? + ORDER BY s.sort_order, s.id + `).all(kind); + } catch (e) { + return res.json({ byRef: {}, needs_migration: true }); + } + const legacyDisabled = readLegacyDisabledIds(); + const byRef = {}; + for (const r of rows) { + if (!r.enabled || legacyDisabled.has(r.id)) continue; + (byRef[r.ref_id] || (byRef[r.ref_id] = [])).push({ id: r.id, title: r.title, cat: r.cat }); + } + res.json({ byRef }); +}); + /* ── admin: управление связями ─────────────────────────────────────────── */ /* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */ diff --git a/backend/tests/lab-links.test.js b/backend/tests/lab-links.test.js index e1e1c68..ff60339 100644 --- a/backend/tests/lab-links.test.js +++ b/backend/tests/lab-links.test.js @@ -148,6 +148,24 @@ describe('/api/lab curriculum links', () => { await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore }); + it('batch reverse lookup: GET /links/all?kind=textbook groups by ref_id', async () => { + const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, studentToken); + assert.equal(res.status, 200, `got ${res.status}`); + assert.ok(res.body.byRef, 'byRef present'); + assert.ok(Array.isArray(res.body.byRef[tbSlug]), `byRef[${tbSlug}] is array`); + assert.ok(res.body.byRef[tbSlug].some(s => s.id === 'pendulum'), 'pendulum grouped under tbSlug'); + }); + + it('batch reverse lookup: bad kind → 400', async () => { + const res = await inject('GET', '/api/lab/links/all?kind=nope', null, studentToken); + assert.equal(res.status, 400); + }); + + it('batch reverse lookup requires auth (401)', async () => { + const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, null); + assert.equal(res.status, 401); + }); + it('reverse lookup: bad kind → 400', async () => { const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken); assert.equal(res.status, 400); diff --git a/frontend/js/admin/sections/sims.js b/frontend/js/admin/sections/sims.js index 8eff83b..d5b752d 100644 --- a/frontend/js/admin/sections/sims.js +++ b/frontend/js/admin/sections/sims.js @@ -1,9 +1,9 @@ 'use strict'; -/* admin → sims (simulations) section — контент-движок, Фаза 4. +/* admin → sims (simulations) section — контент-движок, Фазы 4-5. * * Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка. * Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая», - * теги. Мастер-тумблер модуля — по-прежнему /api/settings/sims. */ + * курикулумные связи (Фаза 5). Мастер-тумблер модуля — /api/settings/sims. */ (function () { 'use strict'; let inited = false; @@ -13,6 +13,7 @@ let _moduleDisabled = false; let _sims = []; // [{id,cat,title,enabled,featured,tags,subject,grade,sort}] + let _textbooks = null; // кэш каталога учебников для выпадающего списка связей function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => @@ -46,7 +47,7 @@ html += `
${esc(CAT_LABEL[cat] || cat)}
`; byCat[cat].forEach(s => { const tags = (s.tags || []).map(t => esc(t)).join(', '); - html += `
+ html += `
${esc(s.title)} @@ -58,11 +59,20 @@
${esc(s.id)}${tags ? ' · ' + tags : ''}
- +
+ + +
+
`; }); }); @@ -99,9 +109,100 @@ } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } + /* ── Фаза 5: редактор курикулумных связей (inline-панель под карточкой) ── */ + + async function _ensureTextbooks() { + if (_textbooks) return _textbooks; + try { + const data = await LS.api('/api/access/catalog'); + _textbooks = (data && data.textbooks) || []; + } catch (e) { _textbooks = []; } + return _textbooks; + } + + async function simToggleLinks(simId) { + const panel = document.getElementById('simlinks-' + simId); + if (!panel) return; + if (panel.style.display !== 'none') { panel.style.display = 'none'; return; } + panel.style.display = 'block'; + panel.innerHTML = '
Загрузка связей…
'; + try { + const [rel] = await Promise.all([ + LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related'), + _ensureTextbooks(), + ]); + _renderLinksPanel(simId, rel); + } catch (e) { + panel.innerHTML = '
Ошибка: ' + esc(e.message) + '
'; + } + } + + function _renderLinksPanel(simId, rel) { + const panel = document.getElementById('simlinks-' + simId); + if (!panel) return; + const links = (rel && rel.links) || {}; + const tb = links.textbook || []; + + let html = '
Связи с учебниками
'; + + if (tb.length) { + html += '
'; + tb.forEach(l => { + html += ` + ${esc(l.label || l.ref_id)} + + `; + }); + html += '
'; + } else { + html += '
Пока нет связей
'; + } + + // add-form: textbook + ${opts} + + +
`; + + panel.innerHTML = html; + } + + async function simAddLink(simId) { + const sel = document.getElementById('simlink-sel-' + simId); + const slug = sel && sel.value; + if (!slug) { LS.toast('Выберите учебник', 'warning'); return; } + try { + await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/links', + { method: 'POST', body: JSON.stringify({ kind: 'textbook', ref_id: slug }) }); + LS.toast('Связь добавлена', 'success'); + const rel = await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related'); + _renderLinksPanel(simId, rel); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } + } + + async function simDelLink(simId, linkId) { + try { + await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/links/' + linkId, { method: 'DELETE' }); + LS.toast('Связь удалена', 'success'); + const rel = await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related'); + _renderLinksPanel(simId, rel); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } + } + window.simsMasterToggle = simsMasterToggle; window.simToggleOne = simToggleOne; window.simToggleFeatured = simToggleFeatured; + window.simToggleLinks = simToggleLinks; + window.simAddLink = simAddLink; + window.simDelLink = simDelLink; window.AdminSections = window.AdminSections || {}; window.AdminSections.sims = {