4549b4e819
Бэкенд /api/access обобщён на тип 'sim': catalog отдаёт симуляции (lab_sims), summary/matrix/class — карты по всем типам. Админ-секция «Доступ» теперь показывает «Симуляции» во всех трёх режимах (по контенту / по классу / матрица) + поиск; helpers (bucket/keyName/itemsOf) обобщены через карты типов (CONTENT_TYPES=textbook,exam,sim; course зарезервирован). Теперь админ/учитель могут открывать/закрывать конкретные симуляции классам и ученикам — закрыт UX- разрыв из 1a (новые классы без UI-управления). Тест: каталог включает sims; 210 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
483 lines
26 KiB
JavaScript
483 lines
26 KiB
JavaScript
'use strict';
|
||
/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам
|
||
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
|
||
* правило ученика важнее правила класса.
|
||
*
|
||
* Два режима:
|
||
* • «По контенту» — выбрать учебник/экзамен → раздать классам (+ ученики).
|
||
* • «По классу» — выбрать класс → отметить доступный ему контент. */
|
||
(function () {
|
||
'use strict';
|
||
let inited = false;
|
||
let _catalog = null; // { textbooks:[], exams:[] }
|
||
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
|
||
let _summary = { totalClasses: 0, textbooks: {}, exams: {} };
|
||
let _mode = 'content'; // 'content' | 'class'
|
||
|
||
// content-mode state
|
||
let _selContent = null; // { type, ref, title }
|
||
let _rules = { classRules: {}, studentRules: {} };
|
||
const _open = new Set(); // развёрнутые классы (показ учеников)
|
||
|
||
// class-mode state
|
||
let _selClass = null; // { id, name }
|
||
let _classOpen = { textbooks: new Set(), exams: new Set() };
|
||
|
||
// matrix-mode state
|
||
let _matrix = null; // { classes:[{id,name}], open:{ [cid]:{textbook:[],exam:[]} } }
|
||
let _mSearch = '';
|
||
|
||
// content-mode left search
|
||
let _leftSearch = '';
|
||
const SUBJ_LABEL = { math: 'Математика', physics: 'Физика', phys: 'Физика', chemistry: 'Химия',
|
||
chem: 'Химия', biology: 'Биология', bio: 'Биология', informatics: 'Информатика',
|
||
russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' };
|
||
|
||
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
|
||
const BUCKET = { textbook: 'textbooks', exam: 'exams', sim: 'sims', course: 'courses' };
|
||
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
|
||
const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' };
|
||
const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' };
|
||
const CONTENT_TYPES = ['textbook', 'exam', 'sim']; // course добавим отдельным шагом
|
||
const bucket = (type) => BUCKET[type] || (type + 's');
|
||
const keyName = (type) => KEYNAME[type] || 'id';
|
||
const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || [];
|
||
function contentTitle(type, ref) {
|
||
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
|
||
return it ? it.title : ref;
|
||
}
|
||
|
||
async function load() {
|
||
const root = document.getElementById('acc-root');
|
||
try {
|
||
[_catalog, _targets, _summary] = await Promise.all([
|
||
LS.accessCatalog(), LS.accessTargets(), LS.accessSummary(),
|
||
]);
|
||
renderRoot();
|
||
} catch (e) {
|
||
root.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
/* ── каркас: переключатель режимов + две колонки / матрица ── */
|
||
function renderRoot() {
|
||
const root = document.getElementById('acc-root');
|
||
const seg = (m, label, pos) => {
|
||
const radius = pos === 'first' ? 'border-radius:8px 0 0 8px'
|
||
: pos === 'last' ? 'border-radius:0 8px 8px 0;border-left:none' : 'border-left:none';
|
||
return `<button onclick="accMode('${m}')" style="border:1px solid var(--border);
|
||
background:${_mode === m ? 'var(--accent,#4f46e5)' : 'transparent'};color:${_mode === m ? '#fff' : 'var(--text-3)'};
|
||
font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;${radius}">${label}</button>`;
|
||
};
|
||
const tabs = `<div style="margin-bottom:16px;display:inline-flex">
|
||
${seg('content', 'По контенту', 'first')}${seg('class', 'По классу', 'mid')}${seg('matrix', 'Матрица', 'last')}
|
||
</div>`;
|
||
if (_mode === 'matrix') {
|
||
root.innerHTML = tabs + `<div class="adm-panel" id="acc-matrix" style="padding:14px"></div>`;
|
||
renderMatrix();
|
||
return;
|
||
}
|
||
root.innerHTML = tabs + `
|
||
<div class="acc-layout" style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||
<div class="adm-panel" id="acc-left" style="flex:0 0 290px;max-width:330px;padding:10px"></div>
|
||
<div class="adm-panel" id="acc-right" style="flex:1;min-width:340px;padding:18px"></div>
|
||
</div>`;
|
||
renderLeft();
|
||
renderRight();
|
||
}
|
||
|
||
/* ── badge «N/M» ── */
|
||
function badge(open, total) {
|
||
const has = open > 0;
|
||
return `<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;
|
||
background:${has ? 'var(--ok-soft,#dcfce7)' : 'var(--border-h,#eee)'};
|
||
color:${has ? 'var(--ok,#16a34a)' : 'var(--muted)'}">${open}/${total}</span>`;
|
||
}
|
||
|
||
/* ── список контента в левой колонке (с поиском + подзаголовками по предмету) ── */
|
||
function contentItemBtn(type, it, total) {
|
||
const ref = it[keyName(type)];
|
||
const active = _selContent && _selContent.type === type && _selContent.ref === ref;
|
||
const open = (_summary[bucket(type)] || {})[ref] || 0;
|
||
return `<button class="acc-item${active ? ' active' : ''}" onclick="accSelContent('${type}','${esc(ref)}')"
|
||
style="display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;text-align:left;border:none;
|
||
background:${active ? 'var(--accent-soft,#eef2ff)' : 'transparent'};padding:8px 10px;border-radius:8px;
|
||
cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--text-1);margin-bottom:2px">
|
||
<span style="font-weight:${active ? 600 : 500};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.title)}</span>
|
||
${badge(open, total)}</button>`;
|
||
}
|
||
function contentLeftList() {
|
||
const total = _summary.totalClasses || 0;
|
||
const term = _leftSearch.trim().toLowerCase();
|
||
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
|
||
let html = '';
|
||
CONTENT_TYPES.forEach(type => {
|
||
const items = itemsOf(type).filter(match);
|
||
if (!items.length) return;
|
||
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">${TYPE_LABEL[type]}</div>`;
|
||
if (type === 'textbook') {
|
||
let lastSubj = null;
|
||
items.forEach(it => {
|
||
const sj = it.subject || '';
|
||
if (sj !== lastSubj) {
|
||
lastSubj = sj;
|
||
html += `<div style="font-size:10.5px;color:var(--muted);padding:6px 10px 2px;text-transform:uppercase;letter-spacing:.04em">${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}</div>`;
|
||
}
|
||
html += contentItemBtn('textbook', it, total);
|
||
});
|
||
} else {
|
||
items.forEach(it => { html += contentItemBtn(type, it, total); });
|
||
}
|
||
});
|
||
return html || empty('Ничего не найдено');
|
||
}
|
||
function leftSearch(v) {
|
||
_leftSearch = v;
|
||
const el = document.getElementById('acc-left-list');
|
||
if (el) el.innerHTML = contentLeftList();
|
||
}
|
||
|
||
/* ── ЛЕВАЯ колонка ── */
|
||
function renderLeft() {
|
||
const left = document.getElementById('acc-left');
|
||
if (_mode === 'content') {
|
||
left.innerHTML = `
|
||
<input type="text" placeholder="Поиск по названию…" value="${esc(_leftSearch)}" oninput="accLeftSearch(this.value)"
|
||
style="width:100%;box-sizing:border-box;margin-bottom:8px;padding:7px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:13px">
|
||
<div id="acc-left-list">${contentLeftList()}</div>`;
|
||
} else {
|
||
const classes = _targets.classes || [];
|
||
left.innerHTML = `
|
||
<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Классы</div>
|
||
${classes.length ? classes.map(c => {
|
||
const active = _selClass && _selClass.id === c.id;
|
||
return `<button class="acc-item${active ? ' active' : ''}" onclick="accSelClass(${c.id})"
|
||
style="display:block;width:100%;text-align:left;border:none;background:${active ? 'var(--accent-soft,#eef2ff)' : 'transparent'};
|
||
padding:8px 10px;border-radius:8px;cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--text-1);margin-bottom:2px">
|
||
<span style="font-weight:${active ? 600 : 500}">${esc(c.name)}</span>
|
||
${c.teacher_name ? `<span style="color:var(--muted);font-size:12px"> · ${esc(c.teacher_name)}</span>` : ''}</button>`;
|
||
}).join('') : empty('Нет классов')}`;
|
||
}
|
||
}
|
||
const empty = (t) => `<p style="color:var(--muted);font-size:12px;padding:6px 10px">${t}</p>`;
|
||
|
||
/* ── ПРАВАЯ колонка ── */
|
||
function renderRight() {
|
||
const right = document.getElementById('acc-right');
|
||
if (_mode === 'content') {
|
||
if (!_selContent) { right.innerHTML = hint('Выберите учебник или экзамен слева, чтобы настроить доступ.'); return; }
|
||
renderContentDetail(right);
|
||
} else {
|
||
if (!_selClass) { right.innerHTML = hint('Выберите класс слева, чтобы открыть ему учебники и экзамены.'); return; }
|
||
renderClassDetail(right);
|
||
}
|
||
}
|
||
const hint = (t) => `<div style="color:var(--muted);font-size:14px">${t}</div>`;
|
||
|
||
/* ════════ режим «По контенту» ════════ */
|
||
async function selContent(type, ref) {
|
||
_selContent = { type, ref, title: contentTitle(type, ref) };
|
||
renderLeft();
|
||
const right = document.getElementById('acc-right');
|
||
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||
try {
|
||
_rules = await LS.accessRules(type, ref);
|
||
renderRight();
|
||
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
|
||
}
|
||
|
||
function studentTri(uid) {
|
||
const v = _rules.studentRules[uid];
|
||
const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit';
|
||
const btn = (val, label, on) =>
|
||
`<button onclick="accSetStudent(${uid},${val})"
|
||
style="border:1px solid var(--border);background:${on ? 'var(--accent,#4f46e5)' : 'transparent'};
|
||
color:${on ? '#fff' : 'var(--text-3)'};font-size:11.5px;padding:3px 9px;cursor:pointer;font-family:inherit;
|
||
${val === 'null' ? 'border-radius:7px 0 0 7px' : val === 0 ? 'border-radius:0 7px 7px 0;border-left:none' : 'border-left:none'}">${label}</button>`;
|
||
return `<span style="display:inline-flex">
|
||
${btn('null', 'Наследовать', state === 'inherit')}${btn(1, 'Открыт', state === 'open')}${btn(0, 'Закрыт', state === 'closed')}</span>`;
|
||
}
|
||
|
||
/* эффективный доступ ученика: что он реально видит и почему */
|
||
function effBadge(uid, classOpen) {
|
||
const v = _rules.studentRules[uid];
|
||
let open, why;
|
||
if (v === 1) { open = true; why = 'лично'; }
|
||
else if (v === 0) { open = false; why = 'лично'; }
|
||
else { open = !!classOpen; why = classOpen ? 'по классу' : 'по умолч.'; }
|
||
return `<span title="итоговый доступ" style="font-size:11px;padding:2px 7px;border-radius:20px;white-space:nowrap;
|
||
background:${open ? 'var(--ok-soft,#dcfce7)' : 'var(--border-h,#eee)'};color:${open ? 'var(--ok,#16a34a)' : 'var(--muted)'}">${open ? 'видит' : 'не видит'} · ${why}</span>`;
|
||
}
|
||
|
||
function classRowContent(c) {
|
||
const openToClass = _rules.classRules[c.id] === 1;
|
||
const expanded = _open.has(c.id);
|
||
const students = c.students || [];
|
||
const studentsHtml = expanded ? `
|
||
<div style="padding:6px 0 10px 26px">
|
||
${students.length ? students.map(s => `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--border-soft,#f0f0f0)">
|
||
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)}</span>
|
||
<span style="display:inline-flex;align-items:center;gap:8px">${effBadge(s.id, openToClass)}${studentTri(s.id)}</span>
|
||
</div>`).join('') : '<p style="color:var(--muted);font-size:12px;margin:4px 0">В классе нет учеников</p>'}
|
||
</div>` : '';
|
||
return `
|
||
<div style="border:1px solid var(--border);border-radius:10px;margin-bottom:10px;padding:10px 12px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||
<button onclick="accToggleExpand(${c.id})"
|
||
style="border:none;background:transparent;cursor:pointer;font-family:inherit;font-size:14px;font-weight:600;color:var(--text-1);display:flex;align-items:center;gap:6px">
|
||
<span style="display:inline-block;transition:transform .15s;transform:rotate(${expanded ? 90 : 0}deg)">▸</span>
|
||
${esc(c.name)}${c.teacher_name ? `<span style="font-weight:400;color:var(--muted);font-size:12px">· ${esc(c.teacher_name)}</span>` : ''}
|
||
</button>
|
||
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||
<span>${openToClass ? 'Открыт' : 'Закрыт'}</span>
|
||
<input type="checkbox" ${openToClass ? 'checked' : ''} onchange="accSetClass(${c.id}, this.checked)">
|
||
</label>
|
||
</div>${studentsHtml}
|
||
</div>`;
|
||
}
|
||
|
||
function looseRow(s) {
|
||
const open = _rules.studentRules[s.id] === 1;
|
||
return `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
|
||
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)} <span style="color:var(--muted);font-size:11.5px">${esc(s.email)}</span></span>
|
||
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||
<span>${open ? 'Открыт' : 'Закрыт'}</span>
|
||
<input type="checkbox" ${open ? 'checked' : ''} onchange="accSetStudent(${s.id}, this.checked ? 1 : null)">
|
||
</label>
|
||
</div>`;
|
||
}
|
||
|
||
function renderContentDetail(right) {
|
||
const classes = _targets.classes || [];
|
||
const loose = _targets.looseStudents || [];
|
||
right.innerHTML = `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px">
|
||
<div style="font-size:16px;font-weight:700;color:var(--text-1)">${esc(_selContent.title)}</div>
|
||
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${TYPE_BADGE[_selContent.type] || 'Контент'}</span>
|
||
</div>
|
||
${classes.length ? `
|
||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||
<button class="adm-btn adm-btn-small" onclick="accBulk(1)">Открыть всем классам</button>
|
||
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accBulk(0)">Закрыть у всех</button>
|
||
</div>` : ''}
|
||
${classes.length ? classes.map(classRowContent).join('') : '<p style="color:var(--muted);font-size:13px">Нет классов.</p>'}
|
||
${loose.length ? `
|
||
<div style="margin-top:18px">
|
||
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div>
|
||
${loose.map(looseRow).join('')}
|
||
</div>` : ''}`;
|
||
}
|
||
|
||
/* пересчёт бейджа для текущего контента по отображаемым классам */
|
||
function recountContent() {
|
||
if (!_selContent) return;
|
||
const open = (_targets.classes || []).filter(c => _rules.classRules[c.id] === 1).length;
|
||
_summary[bucket(_selContent.type)][_selContent.ref] = open;
|
||
}
|
||
|
||
async function setClass(classId, checked) {
|
||
const allow = checked ? 1 : null;
|
||
try {
|
||
await LS.accessSetRule(_selContent.type, _selContent.ref, 'class', classId, allow);
|
||
if (allow === 1) _rules.classRules[classId] = 1; else delete _rules.classRules[classId];
|
||
recountContent(); renderLeft(); renderRight();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
|
||
}
|
||
|
||
async function bulk(allow) {
|
||
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
|
||
const classes = _targets.classes || [];
|
||
try {
|
||
await Promise.all(classes.map(c =>
|
||
LS.accessSetRule(_selContent.type, _selContent.ref, 'class', c.id, allow ? 1 : null)));
|
||
for (const c of classes) { if (allow) _rules.classRules[c.id] = 1; else delete _rules.classRules[c.id]; }
|
||
recountContent(); renderLeft(); renderRight();
|
||
LS.toast(allow ? 'Открыто всем классам' : 'Закрыто у всех классов', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selContent(_selContent.type, _selContent.ref); }
|
||
}
|
||
|
||
async function setStudent(uid, allow) {
|
||
if (allow === 'null') allow = null;
|
||
try {
|
||
await LS.accessSetRule(_selContent.type, _selContent.ref, 'student', uid, allow);
|
||
if (allow === 1) _rules.studentRules[uid] = 1;
|
||
else if (allow === 0) _rules.studentRules[uid] = 0;
|
||
else delete _rules.studentRules[uid];
|
||
renderRight();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
|
||
}
|
||
|
||
function toggleExpand(classId) {
|
||
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
|
||
renderRight();
|
||
}
|
||
|
||
/* ════════ режим «По классу» ════════ */
|
||
async function selClass(id) {
|
||
const c = (_targets.classes || []).find(x => x.id === id);
|
||
_selClass = { id, name: c ? c.name : ('#' + id) };
|
||
renderLeft();
|
||
const right = document.getElementById('acc-right');
|
||
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||
try {
|
||
const open = await LS.accessClassOpen(id);
|
||
_classOpen = {};
|
||
CONTENT_TYPES.forEach(t => { _classOpen[bucket(t)] = new Set(open[bucket(t)] || []); });
|
||
renderRight();
|
||
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
|
||
}
|
||
|
||
function classContentRow(type, it) {
|
||
const ref = it[keyName(type)];
|
||
const open = _classOpen[bucket(type)].has(ref);
|
||
return `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
|
||
<span style="font-size:13.5px;color:var(--text-1)">${esc(it.title)}
|
||
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${TYPE_BADGE[type] || 'Контент'}</span></span>
|
||
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||
<span>${open ? 'Открыт' : 'Закрыт'}</span>
|
||
<input type="checkbox" ${open ? 'checked' : ''} onchange="accClassToggle('${type}','${esc(ref)}', this.checked)">
|
||
</label>
|
||
</div>`;
|
||
}
|
||
|
||
function renderClassDetail(right) {
|
||
let html = `
|
||
<div style="font-size:16px;font-weight:700;color:var(--text-1);margin-bottom:14px">Класс «${esc(_selClass.name)}»</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||
<button class="adm-btn adm-btn-small" onclick="accClassBulk(1)">Открыть весь контент</button>
|
||
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accClassBulk(0)">Закрыть весь</button>
|
||
</div>`;
|
||
CONTENT_TYPES.forEach(type => {
|
||
const items = itemsOf(type);
|
||
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`;
|
||
html += items.length ? items.map(it => classContentRow(type, it)).join('') : empty('Нет');
|
||
});
|
||
right.innerHTML = html;
|
||
}
|
||
|
||
function bumpSummary(type, ref, delta) {
|
||
const b = _summary[bucket(type)];
|
||
const cur = b[ref] || 0;
|
||
b[ref] = Math.max(0, Math.min(_summary.totalClasses || 0, cur + delta));
|
||
}
|
||
|
||
async function classToggle(type, ref, checked) {
|
||
try {
|
||
await LS.accessSetRule(type, ref, 'class', _selClass.id, checked ? 1 : null);
|
||
const set = _classOpen[bucket(type)];
|
||
const was = set.has(ref);
|
||
if (checked) set.add(ref); else set.delete(ref);
|
||
if (checked && !was) bumpSummary(type, ref, +1);
|
||
if (!checked && was) bumpSummary(type, ref, -1);
|
||
renderRight();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||
}
|
||
|
||
async function classBulk(allow) {
|
||
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
||
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
|
||
try {
|
||
await Promise.all(all.map(([type, ref]) =>
|
||
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
|
||
for (const [type, ref] of all) {
|
||
const set = _classOpen[bucket(type)];
|
||
const was = set.has(ref);
|
||
if (allow) { set.add(ref); if (!was) bumpSummary(type, ref, +1); }
|
||
else { set.delete(ref); if (was) bumpSummary(type, ref, -1); }
|
||
}
|
||
renderRight();
|
||
LS.toast(allow ? 'Открыт весь контент классу' : 'Закрыт весь контент', 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||
}
|
||
|
||
/* ════════ режим «Матрица» (класс × контент одним экраном) ════════ */
|
||
function matrixHeadCells(classes) {
|
||
return classes.map(c =>
|
||
`<th style="padding:6px 8px;font-size:11.5px;font-weight:600;color:var(--text-3);white-space:nowrap;border-bottom:1px solid var(--border)">${esc(c.name)}</th>`).join('');
|
||
}
|
||
function matrixBody() {
|
||
const classes = _matrix.classes || [];
|
||
const term = _mSearch.trim().toLowerCase();
|
||
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
|
||
const section = (type, items) => {
|
||
const rows = (items || []).filter(match).map(it => {
|
||
const ref = it[keyName(type)];
|
||
const cells = classes.map(c => {
|
||
const open = ((_matrix.open[c.id] || {})[type] || []).includes(ref);
|
||
return `<td style="text-align:center;border-bottom:1px solid var(--border-soft,#f0f0f0)">
|
||
<input type="checkbox" ${open ? 'checked' : ''} onchange="accMx('${type}','${esc(ref)}',${c.id},this.checked)" title="${esc(c.name)} · ${esc(it.title)}"></td>`;
|
||
}).join('');
|
||
return `<tr><th scope="row" style="text-align:left;padding:6px 10px;font-size:13px;font-weight:500;color:var(--text-1);white-space:nowrap;position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border-soft,#f0f0f0)">${esc(it.title)}</th>${cells}</tr>`;
|
||
}).join('');
|
||
if (!rows) return '';
|
||
return `<tr><th colspan="${classes.length + 1}" style="text-align:left;padding:10px 10px 4px;font-size:12px;font-weight:700;color:var(--text-3)">${TYPE_LABEL[type] || type}</th></tr>${rows}`;
|
||
};
|
||
const body = CONTENT_TYPES.map(t => section(t, itemsOf(t))).join('');
|
||
return body || `<tr><td colspan="${classes.length + 1}" style="padding:10px">${empty('Ничего не найдено')}</td></tr>`;
|
||
}
|
||
async function renderMatrix() {
|
||
const root = document.getElementById('acc-matrix');
|
||
if (!root) return;
|
||
if (!_matrix) {
|
||
root.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||
try { _matrix = await LS.accessMatrix(); }
|
||
catch (e) { root.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; return; }
|
||
}
|
||
const classes = _matrix.classes || [];
|
||
if (!classes.length) { root.innerHTML = empty('Нет классов'); return; }
|
||
root.innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
|
||
<input type="text" placeholder="Поиск по названию…" value="${esc(_mSearch)}" oninput="accMxSearch(this.value)"
|
||
style="flex:1;min-width:200px;max-width:320px;padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:13px">
|
||
<span style="font-size:12px;color:var(--muted)">отметьте, какой класс видит контент</span>
|
||
</div>
|
||
<div style="overflow:auto;max-height:70vh">
|
||
<table style="border-collapse:collapse;min-width:100%">
|
||
<thead><tr><th style="position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border);z-index:1"></th>${matrixHeadCells(classes)}</tr></thead>
|
||
<tbody id="acc-mx-body">${matrixBody()}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
async function mxToggle(type, ref, classId, checked) {
|
||
try {
|
||
await LS.accessSetRule(type, ref, 'class', classId, checked ? 1 : null);
|
||
const o = _matrix.open[classId] || (_matrix.open[classId] = { textbook: [], exam: [] });
|
||
const arr = o[type] || (o[type] = []);
|
||
const i = arr.indexOf(ref);
|
||
if (checked && i < 0) arr.push(ref);
|
||
if (!checked && i >= 0) arr.splice(i, 1);
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
|
||
}
|
||
function mxSearch(v) { _mSearch = v; const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); }
|
||
|
||
/* ── режим ── */
|
||
function setMode(m) {
|
||
if (m === _mode) return;
|
||
_mode = m;
|
||
if (m === 'matrix') _matrix = null; // всегда свежая матрица
|
||
renderRoot();
|
||
}
|
||
|
||
window.accMode = setMode;
|
||
window.accSelContent = selContent;
|
||
window.accSetClass = setClass;
|
||
window.accBulk = bulk;
|
||
window.accSetStudent = setStudent;
|
||
window.accToggleExpand = toggleExpand;
|
||
window.accSelClass = selClass;
|
||
window.accClassToggle = classToggle;
|
||
window.accClassBulk = classBulk;
|
||
window.accMx = mxToggle;
|
||
window.accMxSearch = mxSearch;
|
||
window.accLeftSearch = leftSearch;
|
||
|
||
window.AdminSections = window.AdminSections || {};
|
||
window.AdminSections.access = {
|
||
init: async () => { if (inited) return; inited = true; await load(); },
|
||
reload: load,
|
||
};
|
||
})();
|