feat(access): Фаза 2a — режим «Матрица» класс × контент в админке
GET /api/access/matrix (классы + карта открытого контента одним запросом, скоуп учителя). Клиент LS.accessMatrix. Третий режим вкладки «Доступ»: таблица контент × классы с чекбоксами (правка в один клик) + поиск по названию (обновляет только tbody — фокус ввода сохраняется), залипающие заголовки. Тест /api/access смонтирован в харнесс; content-access.test 11/11 (+матрица: учитель видит свои классы и открытый контент, ученику 403). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,10 @@
|
||||
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 = '';
|
||||
|
||||
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');
|
||||
@@ -44,18 +48,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── каркас: переключатель режимов + две колонки ── */
|
||||
/* ── каркас: переключатель режимов + две колонки / матрица ── */
|
||||
function renderRoot() {
|
||||
const root = document.getElementById('acc-root');
|
||||
const seg = (m, label) =>
|
||||
`<button onclick="accMode('${m}')" style="border:1px solid var(--border);
|
||||
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;
|
||||
${m === 'content' ? 'border-radius:8px 0 0 8px' : 'border-radius:0 8px 8px 0;border-left:none'}">${label}</button>`;
|
||||
root.innerHTML = `
|
||||
<div style="margin-bottom:16px;display:inline-flex">
|
||||
${seg('content', 'По контенту')}${seg('class', 'По классу')}
|
||||
</div>
|
||||
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>
|
||||
@@ -328,10 +339,72 @@
|
||||
} 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 '';
|
||||
const label = type === 'textbook' ? 'Учебники' : 'Экзамены';
|
||||
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)">${label}</th></tr>${rows}`;
|
||||
};
|
||||
const body = section('textbook', (_catalog || {}).textbooks) + section('exam', (_catalog || {}).exams);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -344,6 +417,8 @@
|
||||
window.accSelClass = selClass;
|
||||
window.accClassToggle = classToggle;
|
||||
window.accClassBulk = classBulk;
|
||||
window.accMx = mxToggle;
|
||||
window.accMxSearch = mxSearch;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.access = {
|
||||
|
||||
Reference in New Issue
Block a user