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:
Maxim Dolgolyov
2026-06-03 12:43:00 +03:00
parent 1bbddc00c8
commit 67a70c672d
5 changed files with 126 additions and 10 deletions
+24
View File
@@ -107,6 +107,30 @@ router.get('/summary', (req, res) => {
res.json({ totalClasses, textbooks, exams }); res.json({ totalClasses, textbooks, exams });
}); });
/* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */
/* GET /api/access/matrix
→ { classes:[{id,name}], open:{ [class_id]:{ textbook:[ref], exam:[ref] } } } */
router.get('/matrix', (req, res) => {
const admin = isAdmin(req);
const classes = admin
? db.prepare('SELECT id, name FROM classes ORDER BY name').all()
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id);
const open = {};
classes.forEach(c => { open[c.id] = { textbook: [], exam: [] }; });
const ids = classes.map(c => c.id);
if (ids.length) {
const ph = ids.map(() => '?').join(',');
const rows = db.prepare(`
SELECT content_type, content_ref, target_id FROM content_access
WHERE scope = 'class' AND allow = 1 AND target_id IN (${ph})`).all(...ids);
for (const r of rows) {
const o = open[r.target_id];
if (o && o[r.content_type]) o[r.content_type].push(r.content_ref);
}
}
res.json({ classes, open });
});
/* ── Текущие правила для одного контента ───────────────────────────────── */ /* ── Текущие правила для одного контента ───────────────────────────────── */
/* GET /api/access/rules?content_type=&content_ref= /* GET /api/access/rules?content_type=&content_ref=
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
+15
View File
@@ -95,6 +95,21 @@ describe('contentAccess', () => {
assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='student' AND target_id=?").get(student.userId).c, 0); assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='student' AND target_id=?").get(student.userId).c, 0);
}); });
it('GET /api/access/matrix — учитель видит свои классы и открытый контент', async () => {
clearHub();
setRule('class', classId, 1);
const r = await inject('GET', '/api/access/matrix', null, teacher.token);
assert.equal(r.status, 200, JSON.stringify(r.body));
const cls = (r.body.classes || []).find(c => c.id === classId);
assert.ok(cls, 'класс учителя в матрице');
assert.ok((r.body.open[classId].textbook || []).includes(HUB), 'открытый учебник в матрице');
});
it('GET /api/access/matrix — ученику 403', async () => {
const r = await inject('GET', '/api/access/matrix', null, student.token);
assert.equal(r.status, 403);
});
it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => { it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => {
setRule('class', classId, 1); setRule('class', classId, 1);
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token); const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);
+1
View File
@@ -44,6 +44,7 @@ app.use('/api/questions', require('../src/routes/questions'));
// Additional routes for integration tests // Additional routes for integration tests
app.use('/api/permissions', require('../src/routes/permissions')); app.use('/api/permissions', require('../src/routes/permissions'));
app.use('/api/access', require('../src/routes/access'));
// Feature-gated routes (requireFeature checks app_settings in DB) // Feature-gated routes (requireFeature checks app_settings in DB)
const { requireFeature } = require('../src/middleware/features'); const { requireFeature } = require('../src/middleware/features');
+84 -9
View File
@@ -23,6 +23,10 @@
let _selClass = null; // { id, name } let _selClass = null; // { id, name }
let _classOpen = { textbooks: new Set(), exams: new Set() }; 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 esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
const bucket = (type) => (type === 'textbook' ? 'textbooks' : 'exams'); const bucket = (type) => (type === 'textbook' ? 'textbooks' : 'exams');
const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key'); const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
@@ -44,18 +48,25 @@
} }
} }
/* ── каркас: переключатель режимов + две колонки ── */ /* ── каркас: переключатель режимов + две колонки / матрица ── */
function renderRoot() { function renderRoot() {
const root = document.getElementById('acc-root'); const root = document.getElementById('acc-root');
const seg = (m, label) => const seg = (m, label, pos) => {
`<button onclick="accMode('${m}')" style="border:1px solid var(--border); 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)'}; 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; font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;${radius}">${label}</button>`;
${m === 'content' ? 'border-radius:8px 0 0 8px' : 'border-radius:0 8px 8px 0;border-left:none'}">${label}</button>`; };
root.innerHTML = ` const tabs = `<div style="margin-bottom:16px;display:inline-flex">
<div style="margin-bottom:16px;display:inline-flex"> ${seg('content', 'По контенту', 'first')}${seg('class', 'По классу', 'mid')}${seg('matrix', 'Матрица', 'last')}
${seg('content', 'По контенту')}${seg('class', 'По классу')} </div>`;
</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="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-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 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); } } 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) { function setMode(m) {
if (m === _mode) return; if (m === _mode) return;
_mode = m; _mode = m;
if (m === 'matrix') _matrix = null; // всегда свежая матрица
renderRoot(); renderRoot();
} }
@@ -344,6 +417,8 @@
window.accSelClass = selClass; window.accSelClass = selClass;
window.accClassToggle = classToggle; window.accClassToggle = classToggle;
window.accClassBulk = classBulk; window.accClassBulk = classBulk;
window.accMx = mxToggle;
window.accMxSearch = mxSearch;
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
window.AdminSections.access = { window.AdminSections.access = {
+2 -1
View File
@@ -1029,7 +1029,7 @@ window.LS = {
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessRules, accessSetRule, accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessRules, accessSetRule,
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate,
getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark,
@@ -1300,6 +1300,7 @@ async function accessCatalog() { return req('GET', '/access/catalog'); }
async function accessTargets() { return req('GET', '/access/targets'); } async function accessTargets() { return req('GET', '/access/targets'); }
async function accessSummary() { return req('GET', '/access/summary'); } async function accessSummary() { return req('GET', '/access/summary'); }
async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); } async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); }
async function accessMatrix() { return req('GET', '/access/matrix'); }
async function accessRules(content_type, content_ref) { async function accessRules(content_type, content_ref) {
const p = new URLSearchParams({ content_type, content_ref }); const p = new URLSearchParams({ content_type, content_ref });
return req('GET', `/access/rules?${p}`); return req('GET', `/access/rules?${p}`);