feat(access): Фаза 2b — поиск/группировка по предмету + «эффективный доступ»

Режим «По контенту»: поиск по названию в левой колонке (обновляет только список,
фокус сохраняется) + подзаголовки по предмету (Математика/Физика/…). У раскрытого
класса рядом с tri-state каждого ученика — бейдж итогового доступа «видит/не видит
· лично|по классу|по умолч.» (считается клиентски из загруженных правил) — снимает
путаницу «наследовать/открыт/закрыт».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 12:50:57 +03:00
parent 67a70c672d
commit 596e8d8b30
+66 -17
View File
@@ -27,6 +27,12 @@
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 = (type) => (type === 'textbook' ? 'textbooks' : 'exams');
const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
@@ -83,27 +89,57 @@
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);
const tbs = (_catalog.textbooks || []).filter(match);
const exs = (_catalog.exams || []).filter(match);
let html = '';
if (tbs.length) {
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Учебники</div>`;
let lastSubj = null;
tbs.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);
});
}
if (exs.length) {
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">Экзамены</div>`;
exs.forEach(it => { html += contentItemBtn('exam', 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') {
const total = _summary.totalClasses || 0;
const list = (type, items) => items.map(it => {
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>`;
}).join('');
left.innerHTML = `
<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Учебники</div>
${list('textbook', _catalog.textbooks || []) || empty('Нет учебников')}
<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">Экзамены</div>
${list('exam', _catalog.exams || []) || empty('Нет экзаменов')}`;
<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 = `
@@ -157,6 +193,17 @@
${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);
@@ -165,7 +212,8 @@
<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)}
<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 `
@@ -419,6 +467,7 @@
window.accClassBulk = classBulk;
window.accMx = mxToggle;
window.accMxSearch = mxSearch;
window.accLeftSearch = leftSearch;
window.AdminSections = window.AdminSections || {};
window.AdminSections.access = {