Files
Learn_System/frontend/js/admin/sections/access.js
T
Maxim Dolgolyov 471171b77c feat(access): доступ к учебникам и экзаменам по классам/ученикам из админ-панели
Модель allowlist (закрыто по умолчанию), правило ученика важнее класса.
Управляют админ (все) и учителя (свои классы/ученики).

- миграция 040: таблица content_access + непрерывный переход
  (всем существующим классам открыт текущий контент)
- сервис contentAccess: резолвинг доступа, главы наследуют хаб
- API /api/access (catalog/targets/rules) для admin+teacher
- гейты: каталог учебников, router.param slug/examKey, фильтр tracks
- клиентские редиректы на /403 (textbook-tracker, exam-prep boot)
- раздел админки «Доступ к учебникам»: классы + ученики (tri-state)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:33:05 +03:00

185 lines
9.4 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 → access section — открыть/закрыть доступ к учебникам и экзаменам
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
* правило ученика важнее правила класса. */
(function () {
'use strict';
let inited = false;
let _catalog = null; // { textbooks:[], exams:[] }
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
let _sel = null; // { type:'textbook'|'exam', ref, title }
let _rules = { classRules: {}, studentRules: {} };
const _open = new Set(); // class ids развёрнутых строк
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
async function load() {
try {
[_catalog, _targets] = await Promise.all([LS.accessCatalog(), LS.accessTargets()]);
renderList();
} catch (e) {
document.getElementById('acc-textbooks').innerHTML =
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
}
}
function itemBtn(type, ref, title, sub) {
const active = _sel && _sel.type === type && _sel.ref === ref;
return `<button class="acc-item${active ? ' active' : ''}" data-type="${type}" data-ref="${esc(ref)}"
onclick="accSelect('${type}','${esc(ref)}')"
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(title)}</span>
${sub ? `<span style="color:var(--muted);font-size:12px"> · ${esc(sub)}</span>` : ''}
</button>`;
}
function renderList() {
const tb = document.getElementById('acc-textbooks');
const ex = document.getElementById('acc-exams');
tb.innerHTML = (_catalog.textbooks || [])
.map(t => itemBtn('textbook', t.slug, t.title, t.grade ? t.grade + ' кл.' : '')).join('')
|| '<p style="color:var(--muted);font-size:12px;padding:6px 10px">Нет учебников</p>';
ex.innerHTML = (_catalog.exams || [])
.map(e => itemBtn('exam', e.exam_key, e.title, e.grade ? e.grade + ' кл.' : '')).join('')
|| '<p style="color:var(--muted);font-size:12px;padding:6px 10px">Нет экзаменов</p>';
}
async function select(type, ref) {
const src = type === 'textbook' ? _catalog.textbooks : _catalog.exams;
const keyName = type === 'textbook' ? 'slug' : 'exam_key';
const item = (src || []).find(x => x[keyName] === ref);
_sel = { type, ref, title: item ? item.title : ref };
renderList();
document.getElementById('acc-detail-empty').style.display = 'none';
const det = document.getElementById('acc-detail');
det.style.display = '';
det.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
try {
_rules = await LS.accessRules(type, ref);
renderDetail();
} catch (e) {
det.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
}
}
/* tri-state кнопки для ученика внутри класса */
function studentTri(uid) {
const v = _rules.studentRules[uid]; // 1 | 0 | undefined
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 class="acc-tri" style="display:inline-flex">
${btn('null', 'Наследовать', state === 'inherit')}
${btn(1, 'Открыт', state === 'open')}
${btn(0, 'Закрыт', state === 'closed')}
</span>`;
}
function classRow(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>
${studentTri(s.id)}
</div>`).join('')
: '<p style="color:var(--muted);font-size:12px;margin:4px 0">В классе нет учеников</p>'}
</div>` : '';
return `
<div class="acc-class" 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 class="acc-switch" 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 renderDetail() {
const det = document.getElementById('acc-detail');
const classes = _targets.classes || [];
const loose = _targets.looseStudents || [];
det.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(_sel.title)}</div>
<span class="badge ${_sel.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${_sel.type === 'exam' ? 'Экзамен' : 'Учебник'}</span>
</div>
<div class="acc-classes">
${classes.length ? classes.map(classRow).join('')
: '<p style="color:var(--muted);font-size:13px">Нет классов.</p>'}
</div>
${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>` : ''}
`;
}
/* ── handlers (optimistic update) ── */
async function setClass(classId, checked) {
const allow = checked ? 1 : null;
try {
await LS.accessSetRule(_sel.type, _sel.ref, 'class', classId, allow);
if (allow === 1) _rules.classRules[classId] = 1;
else delete _rules.classRules[classId];
renderDetail();
LS.toast(checked ? 'Открыт классу' : 'Закрыт для класса', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); }
}
async function setStudent(uid, allow) {
// allow: 1 | 0 | null (строка 'null' приходит из tri-кнопок)
if (allow === 'null') allow = null;
try {
await LS.accessSetRule(_sel.type, _sel.ref, 'student', uid, allow);
if (allow === 1) _rules.studentRules[uid] = 1;
else if (allow === 0) _rules.studentRules[uid] = 0;
else delete _rules.studentRules[uid];
renderDetail();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); }
}
function toggleExpand(classId) {
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
renderDetail();
}
window.accSelect = select;
window.accSetClass = setClass;
window.accSetStudent = setStudent;
window.accToggleExpand = toggleExpand;
window.AdminSections = window.AdminSections || {};
window.AdminSections.access = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();