471171b77c
Модель 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>
185 lines
9.4 KiB
JavaScript
185 lines
9.4 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 _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,
|
||
};
|
||
})();
|