Files
Learn_System/frontend/js/admin/sections/sims.js
T
Maxim Dolgolyov 8467d7202a fix(admin): видимость выпадающего списка учебников в панели «Связи» симуляций
select использовал var(--bg-2,#1a1a2e) — переменная не определена в светлой
теме, поэтому фон падал на тёмно-синий, а текст оставался тёмным (--text):
список сливался с фоном. Заменено на белый фон + явные цвета option.
2026-06-03 13:41:25 +03:00

213 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* admin → sims (simulations) section — контент-движок, Фазы 4-5.
*
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
* курикулумные связи (Фаза 5). Мастер-тумблер модуля — /api/settings/sims. */
(function () {
'use strict';
let inited = false;
const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игры' };
const CAT_ORDER = ['math', 'phys', 'chem', 'bio', 'game'];
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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function load() {
try {
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'); }
}
function _render() {
const masterChk = document.getElementById('sims-master-chk');
if (masterChk) masterChk.checked = !_moduleDisabled;
const grid = document.getElementById('sims-grid');
if (!grid) return;
// group by category, preserving catalogue sort within group
const byCat = {};
_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 = '';
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)}" style="flex-wrap:wrap">
<div class="perm-info">
<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>
<div style="display:flex;align-items:center;gap:8px">
<button class="sim-links-btn" title="Связи с программой"
onclick="simToggleLinks('${esc(s.id)}')"
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">
<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>`;
});
});
grid.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function simsMasterToggle(checked) {
try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
_moduleDisabled = !checked;
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function simToggleOne(simId, enabled) {
try {
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'); }
}
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'); }
}
/* ── Фаза 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)}" style="background:#fff;color:var(--text)">${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:#fff;color:var(--text);border:1px solid var(--border)">
<option value="" style="background:#fff;color:var(--text)">— выбрать учебник —</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.simToggleOne = simToggleOne;
window.simToggleFeatured = simToggleFeatured;
window.simToggleLinks = simToggleLinks;
window.simAddLink = simAddLink;
window.simDelLink = simDelLink;
window.AdminSections = window.AdminSections || {};
window.AdminSections.sims = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();