feat(lab-content-engine): phase 5 завершение — редактор связей в админке + кнопка в учебнике
- 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=<id> Двусторонняя навигация sim <-> учебник готова. Иконки .ic, без эмодзи. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -218,6 +218,35 @@ router.get('/links', (req, res) => {
|
|||||||
res.json({ sims });
|
res.json({ sims });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* GET /api/lab/links/all?kind=textbook
|
||||||
|
→ { byRef: { <ref_id>: [{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: управление связями ─────────────────────────────────────────── */
|
/* ── admin: управление связями ─────────────────────────────────────────── */
|
||||||
|
|
||||||
/* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */
|
/* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */
|
||||||
|
|||||||
@@ -148,6 +148,24 @@ describe('/api/lab curriculum links', () => {
|
|||||||
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore
|
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 () => {
|
it('reverse lookup: bad kind → 400', async () => {
|
||||||
const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken);
|
const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken);
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* admin → sims (simulations) section — контент-движок, Фаза 4.
|
/* admin → sims (simulations) section — контент-движок, Фазы 4-5.
|
||||||
*
|
*
|
||||||
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
|
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
|
||||||
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
|
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
|
||||||
* теги. Мастер-тумблер модуля — по-прежнему /api/settings/sims. */
|
* курикулумные связи (Фаза 5). Мастер-тумблер модуля — /api/settings/sims. */
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
let inited = false;
|
let inited = false;
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
let _moduleDisabled = false;
|
let _moduleDisabled = false;
|
||||||
let _sims = []; // [{id,cat,title,enabled,featured,tags,subject,grade,sort}]
|
let _sims = []; // [{id,cat,title,enabled,featured,tags,subject,grade,sort}]
|
||||||
|
let _textbooks = null; // кэш каталога учебников для выпадающего списка связей
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
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>`;
|
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 => {
|
byCat[cat].forEach(s => {
|
||||||
const tags = (s.tags || []).map(t => esc(t)).join(', ');
|
const tags = (s.tags || []).map(t => esc(t)).join(', ');
|
||||||
html += `<div class="perm-card${s.enabled ? ' enabled' : ''}" id="simcard-${esc(s.id)}">
|
html += `<div class="perm-card${s.enabled ? ' enabled' : ''}" id="simcard-${esc(s.id)}" style="flex-wrap:wrap">
|
||||||
<div class="perm-info">
|
<div class="perm-info">
|
||||||
<div class="perm-label">
|
<div class="perm-label">
|
||||||
${esc(s.title)}
|
${esc(s.title)}
|
||||||
@@ -58,11 +59,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}${tags ? ' · ' + tags : ''}</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="${s.enabled ? 'Отключить' : 'Включить'}">
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
<input type="checkbox" ${s.enabled ? 'checked' : ''} onchange="simToggleOne('${esc(s.id)}', this.checked)" />
|
<button class="sim-links-btn" title="Связи с программой"
|
||||||
<span class="perm-track"></span>
|
onclick="simToggleLinks('${esc(s.id)}')"
|
||||||
<span class="perm-thumb"></span>
|
style="background:none;border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;cursor:pointer;padding:4px 8px;font-size:.7rem;color:var(--text-2);display:inline-flex;align-items:center;gap:4px">
|
||||||
</label>
|
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
Связи
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="sim-links-panel" id="simlinks-${esc(s.id)}" style="display:none;flex-basis:100%;width:100%;margin-top:8px;padding-top:8px;border-top:1px solid var(--border,rgba(255,255,255,.1))"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -99,9 +109,100 @@
|
|||||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
} 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 = '<div style="font-size:.72rem;color:var(--text-3)">Загрузка связей…</div>';
|
||||||
|
try {
|
||||||
|
const [rel] = await Promise.all([
|
||||||
|
LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related'),
|
||||||
|
_ensureTextbooks(),
|
||||||
|
]);
|
||||||
|
_renderLinksPanel(simId, rel);
|
||||||
|
} catch (e) {
|
||||||
|
panel.innerHTML = '<div style="font-size:.72rem;color:var(--pink,#f15bb5)">Ошибка: ' + esc(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderLinksPanel(simId, rel) {
|
||||||
|
const panel = document.getElementById('simlinks-' + simId);
|
||||||
|
if (!panel) return;
|
||||||
|
const links = (rel && rel.links) || {};
|
||||||
|
const tb = links.textbook || [];
|
||||||
|
|
||||||
|
let html = '<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-3);margin-bottom:6px">Связи с учебниками</div>';
|
||||||
|
|
||||||
|
if (tb.length) {
|
||||||
|
html += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">';
|
||||||
|
tb.forEach(l => {
|
||||||
|
html += `<span style="display:inline-flex;align-items:center;gap:6px;font-size:.72rem;padding:3px 6px 3px 10px;border-radius:999px;background:rgba(155,93,229,.14);color:var(--violet);border:1px solid rgba(155,93,229,.3)">
|
||||||
|
${esc(l.label || l.ref_id)}
|
||||||
|
<button title="Удалить связь" onclick="simDelLink('${esc(simId)}', ${Number(l.id)})"
|
||||||
|
style="background:none;border:none;cursor:pointer;color:inherit;padding:0;line-height:1;font-size:.95rem;opacity:.7">×</button>
|
||||||
|
</span>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="font-size:.72rem;color:var(--text-3);margin-bottom:8px">Пока нет связей</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// add-form: textbook <select> + button
|
||||||
|
const linkedSlugs = new Set(tb.map(l => l.ref_id));
|
||||||
|
const opts = (_textbooks || [])
|
||||||
|
.filter(t => !linkedSlugs.has(t.slug))
|
||||||
|
.map(t => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('');
|
||||||
|
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||||
|
<select id="simlink-sel-${esc(simId)}" style="flex:1;min-width:180px;font-size:.75rem;padding:5px 8px;border-radius:8px;background:var(--bg-2,#1a1a2e);color:var(--text);border:1px solid var(--border,rgba(255,255,255,.14))">
|
||||||
|
<option value="">— выбрать учебник —</option>${opts}
|
||||||
|
</select>
|
||||||
|
<button onclick="simAddLink('${esc(simId)}')"
|
||||||
|
style="font-size:.75rem;padding:5px 12px;border-radius:8px;border:1px solid var(--violet);background:rgba(155,93,229,.15);color:var(--violet);cursor:pointer">Добавить</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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.simsMasterToggle = simsMasterToggle;
|
||||||
window.simToggleOne = simToggleOne;
|
window.simToggleOne = simToggleOne;
|
||||||
window.simToggleFeatured = simToggleFeatured;
|
window.simToggleFeatured = simToggleFeatured;
|
||||||
|
window.simToggleLinks = simToggleLinks;
|
||||||
|
window.simAddLink = simAddLink;
|
||||||
|
window.simDelLink = simDelLink;
|
||||||
|
|
||||||
window.AdminSections = window.AdminSections || {};
|
window.AdminSections = window.AdminSections || {};
|
||||||
window.AdminSections.sims = {
|
window.AdminSections.sims = {
|
||||||
|
|||||||
Reference in New Issue
Block a user