Files
Maxim Dolgolyov b702b04ed2 feat(access): Фаза 2c — история правил + пресет «копировать доступ из класса»
История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил
правило для контента (из admin_audit_log, имена классов/учеников резолвятся).
Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений».
Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет
текущие правила открытыми правилами класса-источника). Тест: история (admin
видит запись, учителю 403). content-access 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:55:02 +03:00

600 lines
34 KiB
JavaScript
Raw Permalink 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 → 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)]) || [];
const subjOf = (it) => it.subject || it.subject_slug || ''; // нормализация поля предмета
const subjLabel = (s) => SUBJ_LABEL[s] || s || 'Прочее';
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>` : ''}
<div style="margin-top:18px;border-top:1px solid var(--border);padding-top:12px">
<button class="adm-btn adm-btn-small" style="background:transparent;color:var(--text-3);border:1px solid var(--border)" onclick="accShowLog()">История изменений</button>
<div id="acc-log" style="margin-top:10px"></div>
</div>`;
}
async function showLog() {
const box = document.getElementById('acc-log');
if (!box || !_selContent) return;
box.innerHTML = '<p style="color:var(--muted);font-size:12px">Загрузка…</p>';
try {
const log = await LS.accessLog(_selContent.type, _selContent.ref);
if (!log.length) { box.innerHTML = '<p style="color:var(--muted);font-size:12px">Изменений пока нет.</p>'; return; }
const A = { grant: 'открыл', deny: 'закрыл (исключение)', inherit: 'сбросил (наследование)' };
box.innerHTML = log.map(e => `
<div style="font-size:12.5px;color:var(--text-1);padding:5px 0;border-top:1px solid var(--border-soft,#f0f0f0)">
<b>${esc(e.actor)}</b> ${A[e.action] || esc(e.action)} · ${esc(e.targetName)}
<span style="color:var(--muted)"> · ${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
</div>`).join('');
} catch (e) {
box.innerHTML = `<p style="color:var(--muted);font-size:12px">${e && e.status === 403 ? 'История доступна только администратору.' : 'Ошибка: ' + esc(e.message)}</p>`;
}
}
/* пересчёт бейджа для текущего контента по отображаемым классам */
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:10px">
<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>`;
const subjects = [...new Set(CONTENT_TYPES.flatMap(t => itemsOf(t).map(subjOf)).filter(Boolean))].sort();
if (subjects.length) {
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px">
<span style="font-size:12px;color:var(--muted)">Открыть по предмету:</span>
${subjects.map(s => `<button class="adm-btn adm-btn-small" style="background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5)" onclick="accClassSubj('${esc(s)}')">+ ${esc(subjLabel(s))}</button>`).join('')}
</div>`;
}
const others = (_targets.classes || []).filter(c => c.id !== _selClass.id);
if (others.length) {
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px">
<span style="font-size:12px;color:var(--muted)">Скопировать доступ из класса:</span>
<select id="acc-copy-src" style="padding:5px 8px;border:1px solid var(--border);border-radius:7px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:12.5px">
<option value="">— выберите —</option>
${others.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('')}
</select>
<button class="adm-btn adm-btn-small" onclick="accCopyFrom()">Скопировать</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 classSubjectBulk(subj) {
const items = CONTENT_TYPES.flatMap(t => itemsOf(t).filter(it => subjOf(it) === subj).map(it => [t, it[keyName(t)]]));
if (!items.length) return;
try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1)));
items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } });
renderRight();
LS.toast(`Открыт весь контент по предмету «${subjLabel(subj)}»`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
/* пресет: скопировать открытый доступ из другого класса в текущий (дополняет) */
async function copyFrom() {
const sel = document.getElementById('acc-copy-src');
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
const srcId = Number(sel.value);
const srcName = sel.options[sel.selectedIndex].text;
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
try {
const src = await LS.accessClassOpen(srcId);
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
if (!items.length) { LS.toast('В классе-источнике нет открытого контента', 'error'); return; }
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1)));
items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } });
renderRight();
LS.toast(`Скопировано из «${srcName}» (${items.length})`, 'success');
} 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;border-bottom:1px solid var(--border)">
<button onclick="accMxColBulk(${c.id})" title="Открыть/закрыть весь контент классу «${esc(c.name)}»"
style="border:none;background:transparent;cursor:pointer;font-family:inherit;font-size:11.5px;font-weight:600;color:var(--text-3);white-space:nowrap;padding:0">${esc(c.name)}</button>
</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;white-space:nowrap;position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border-soft,#f0f0f0)">
<button onclick="accMxRowBulk('${type}','${esc(ref)}')" title="Открыть/закрыть «${esc(it.title)}» всем классам"
style="border:none;background:transparent;cursor:pointer;font-family:inherit;font-size:13px;font-weight:500;color:var(--text-1);text-align:left;padding:0">${esc(it.title)}</button>
</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 mxRepaint() { const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); }
function mxApply(o, type, ref, open) {
const arr = o[type] || (o[type] = []);
const i = arr.indexOf(ref);
if (open && i < 0) arr.push(ref);
if (!open && i >= 0) arr.splice(i, 1);
}
/* строка матрицы: открыть/закрыть один контент всем классам */
async function mxRowBulk(type, ref) {
const classes = _matrix.classes || [];
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
const open = !allOpen;
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return;
try {
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
mxRepaint();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
}
/* столбец матрицы: открыть/закрыть весь контент одному классу */
async function mxColBulk(classId) {
const items = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
const o = _matrix.open[classId] || (_matrix.open[classId] = {});
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
const open = !allOpen;
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return;
try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
items.forEach(([t, ref]) => mxApply(o, t, ref, open));
mxRepaint();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
}
/* ── режим ── */
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.accClassSubj = classSubjectBulk;
window.accCopyFrom = copyFrom;
window.accShowLog = showLog;
window.accMx = mxToggle;
window.accMxSearch = mxSearch;
window.accMxRowBulk = mxRowBulk;
window.accMxColBulk = mxColBulk;
window.accLeftSearch = leftSearch;
window.AdminSections = window.AdminSections || {};
window.AdminSections.access = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();