feat(access): вид «по классу», массовые действия, бейджи состояния + чистка orphan-правил
По итогам ревью системы прав: - админка: переключатель режимов «По контенту» / «По классу» - кнопки «Открыть всем классам» / «Закрыть у всех» (и зеркально по классу) - бейджи N/M (сколько классов открыто) в списке контента - эндпоинты /api/access/summary и /api/access/class/:id - вкладка «Доступ к учебникам» перенесена к «Права доступа» (группа Пользователи) - чистка content_access при удалении класса/ученика (нет FK) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -480,6 +480,8 @@ const _deleteUserTx = db.transaction((uid) => {
|
|||||||
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
|
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
|
||||||
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
|
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
|
||||||
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
|
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
|
||||||
|
// Персональные правила доступа к контенту (нет FK — чистим вручную):
|
||||||
|
db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?").run(uid);
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ function deleteClass(req, res) {
|
|||||||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
stmts.deleteClass.run(req.params.id);
|
stmts.deleteClass.run(req.params.id);
|
||||||
|
// Правила доступа к контенту для этого класса (нет FK — чистим вручную):
|
||||||
|
db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?").run(req.params.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,36 @@ router.get('/targets', (req, res) => {
|
|||||||
res.json({ classes: classList, looseStudents });
|
res.json({ classes: classList, looseStudents });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── Сводка: сколько классов открыто по каждому контенту ───────────────── */
|
||||||
|
/* GET /api/access/summary
|
||||||
|
→ { totalClasses, textbooks:{[slug]:openCount}, exams:{[key]:openCount} } */
|
||||||
|
router.get('/summary', (req, res) => {
|
||||||
|
const admin = isAdmin(req);
|
||||||
|
const totalClasses = admin
|
||||||
|
? db.prepare('SELECT COUNT(*) n FROM classes').get().n
|
||||||
|
: db.prepare('SELECT COUNT(*) n FROM classes WHERE teacher_id = ?').get(req.user.id).n;
|
||||||
|
|
||||||
|
const rows = admin
|
||||||
|
? db.prepare(`
|
||||||
|
SELECT content_type, content_ref, COUNT(DISTINCT target_id) n
|
||||||
|
FROM content_access
|
||||||
|
WHERE scope = 'class' AND allow = 1
|
||||||
|
GROUP BY content_type, content_ref`).all()
|
||||||
|
: db.prepare(`
|
||||||
|
SELECT content_type, content_ref, COUNT(DISTINCT target_id) n
|
||||||
|
FROM content_access
|
||||||
|
WHERE scope = 'class' AND allow = 1
|
||||||
|
AND target_id IN (SELECT id FROM classes WHERE teacher_id = ?)
|
||||||
|
GROUP BY content_type, content_ref`).all(req.user.id);
|
||||||
|
|
||||||
|
const textbooks = {}, exams = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.content_type === 'textbook') textbooks[r.content_ref] = r.n;
|
||||||
|
else exams[r.content_ref] = r.n;
|
||||||
|
}
|
||||||
|
res.json({ totalClasses, textbooks, exams });
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Текущие правила для одного контента ───────────────────────────────── */
|
/* ── Текущие правила для одного контента ───────────────────────────────── */
|
||||||
/* 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} } */
|
||||||
@@ -101,6 +131,23 @@ router.get('/rules', (req, res) => {
|
|||||||
function teacherOwnsClass(teacherId, classId) {
|
function teacherOwnsClass(teacherId, classId) {
|
||||||
return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId);
|
return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Открытый классу контент (для вида «по классу») ────────────────────── */
|
||||||
|
/* GET /api/access/class/:id → { textbooks:[slug], exams:[exam_key] } (allow=1) */
|
||||||
|
router.get('/class/:id', requireRole('admin', 'teacher'), (req, res) => {
|
||||||
|
const cid = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id' });
|
||||||
|
if (!isAdmin(req) && !teacherOwnsClass(req.user.id, cid)) {
|
||||||
|
return res.status(403).json({ error: 'Нет прав на этот класс' });
|
||||||
|
}
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT content_type, content_ref FROM content_access
|
||||||
|
WHERE scope = 'class' AND target_id = ? AND allow = 1
|
||||||
|
`).all(cid);
|
||||||
|
const textbooks = [], exams = [];
|
||||||
|
for (const r of rows) (r.content_type === 'textbook' ? textbooks : exams).push(r.content_ref);
|
||||||
|
res.json({ textbooks, exams });
|
||||||
|
});
|
||||||
function teacherCanManageStudent(teacherId, studentId) {
|
function teacherCanManageStudent(teacherId, studentId) {
|
||||||
const inClass = db.prepare(`
|
const inClass = db.prepare(`
|
||||||
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
|
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
|
||||||
|
|||||||
+5
-18
@@ -956,9 +956,6 @@
|
|||||||
<button class="admin-nav-item" data-tab="topics" onclick="switchTab(this)">
|
<button class="admin-nav-item" data-tab="topics" onclick="switchTab(this)">
|
||||||
<i data-lucide="list-tree" style="width:15px;height:15px"></i> Темы
|
<i data-lucide="list-tree" style="width:15px;height:15px"></i> Темы
|
||||||
</button>
|
</button>
|
||||||
<button class="admin-nav-item" data-tab="access" onclick="switchTab(this)">
|
|
||||||
<i data-lucide="book-lock" style="width:15px;height:15px"></i> Доступ к учебникам
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -974,6 +971,9 @@
|
|||||||
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
|
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
|
||||||
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
|
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
|
||||||
</button>
|
</button>
|
||||||
|
<button class="admin-nav-item" data-tab="access" onclick="switchTab(this)">
|
||||||
|
<i data-lucide="book-lock" style="width:15px;height:15px"></i> Доступ к учебникам
|
||||||
|
</button>
|
||||||
<button class="admin-nav-item" data-tab="avatars" onclick="switchTab(this);loadAvatarRequests()">
|
<button class="admin-nav-item" data-tab="avatars" onclick="switchTab(this);loadAvatarRequests()">
|
||||||
<i data-lucide="image" style="width:15px;height:15px"></i> Аватары
|
<i data-lucide="image" style="width:15px;height:15px"></i> Аватары
|
||||||
<span class="admin-badge" id="av-badge" style="display:none"></span>
|
<span class="admin-badge" id="av-badge" style="display:none"></span>
|
||||||
@@ -1515,25 +1515,12 @@
|
|||||||
<!-- ── Доступ к учебникам / экзаменам ── -->
|
<!-- ── Доступ к учебникам / экзаменам ── -->
|
||||||
<div class="tab-pane" id="tab-access">
|
<div class="tab-pane" id="tab-access">
|
||||||
<div class="section-title">Доступ к учебникам и экзаменам</div>
|
<div class="section-title">Доступ к учебникам и экзаменам</div>
|
||||||
<p style="color:var(--muted);font-size:13px;margin:4px 0 16px;max-width:720px">
|
<p style="color:var(--muted);font-size:13px;margin:4px 0 16px;max-width:760px">
|
||||||
По умолчанию доступ <b>закрыт</b>. Откройте учебник или экзамен-модуль нужным классам.
|
По умолчанию доступ <b>закрыт</b>. Откройте учебник или экзамен-модуль нужным классам.
|
||||||
Внутри класса можно сделать точечное исключение для отдельного ученика —
|
Внутри класса можно сделать точечное исключение для отдельного ученика —
|
||||||
индивидуальное правило важнее правила класса.
|
индивидуальное правило важнее правила класса.
|
||||||
</p>
|
</p>
|
||||||
<div class="acc-layout" style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
<div id="acc-root"></div>
|
||||||
<div class="acc-list adm-panel" style="flex:0 0 280px;max-width:320px;padding:10px">
|
|
||||||
<div class="acc-list-head" style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Учебники</div>
|
|
||||||
<div id="acc-textbooks"></div>
|
|
||||||
<div class="acc-list-head" style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">Экзамены</div>
|
|
||||||
<div id="acc-exams"></div>
|
|
||||||
</div>
|
|
||||||
<div class="acc-detail adm-panel" style="flex:1;min-width:340px;padding:18px">
|
|
||||||
<div id="acc-detail-empty" style="color:var(--muted);font-size:14px">
|
|
||||||
Выберите учебник или экзамен слева, чтобы настроить доступ.
|
|
||||||
</div>
|
|
||||||
<div id="acc-detail" style="display:none"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Рассылка ── -->
|
<!-- ── Рассылка ── -->
|
||||||
|
|||||||
@@ -1,85 +1,152 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам
|
/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам
|
||||||
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
|
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
|
||||||
* правило ученика важнее правила класса. */
|
* правило ученика важнее правила класса.
|
||||||
|
*
|
||||||
|
* Два режима:
|
||||||
|
* • «По контенту» — выбрать учебник/экзамен → раздать классам (+ ученики).
|
||||||
|
* • «По классу» — выбрать класс → отметить доступный ему контент. */
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
let inited = false;
|
let inited = false;
|
||||||
let _catalog = null; // { textbooks:[], exams:[] }
|
let _catalog = null; // { textbooks:[], exams:[] }
|
||||||
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
|
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
|
||||||
let _sel = null; // { type:'textbook'|'exam', ref, title }
|
let _summary = { totalClasses: 0, textbooks: {}, exams: {} };
|
||||||
|
let _mode = 'content'; // 'content' | 'class'
|
||||||
|
|
||||||
|
// content-mode state
|
||||||
|
let _selContent = null; // { type, ref, title }
|
||||||
let _rules = { classRules: {}, studentRules: {} };
|
let _rules = { classRules: {}, studentRules: {} };
|
||||||
const _open = new Set(); // class ids развёрнутых строк
|
const _open = new Set(); // развёрнутые классы (показ учеников)
|
||||||
|
|
||||||
|
// class-mode state
|
||||||
|
let _selClass = null; // { id, name }
|
||||||
|
let _classOpen = { textbooks: new Set(), exams: new Set() };
|
||||||
|
|
||||||
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 keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
|
||||||
|
const itemsOf = (type) => (type === 'textbook' ? _catalog.textbooks : _catalog.exams) || [];
|
||||||
|
function contentTitle(type, ref) {
|
||||||
|
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
|
||||||
|
return it ? it.title : ref;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const root = document.getElementById('acc-root');
|
||||||
try {
|
try {
|
||||||
[_catalog, _targets] = await Promise.all([LS.accessCatalog(), LS.accessTargets()]);
|
[_catalog, _targets, _summary] = await Promise.all([
|
||||||
renderList();
|
LS.accessCatalog(), LS.accessTargets(), LS.accessSummary(),
|
||||||
|
]);
|
||||||
|
renderRoot();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('acc-textbooks').innerHTML =
|
root.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
|
||||||
`<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;
|
function renderRoot() {
|
||||||
return `<button class="acc-item${active ? ' active' : ''}" data-type="${type}" data-ref="${esc(ref)}"
|
const root = document.getElementById('acc-root');
|
||||||
onclick="accSelect('${type}','${esc(ref)}')"
|
const seg = (m, label) =>
|
||||||
style="display:block;width:100%;text-align:left;border:none;background:${active ? 'var(--accent-soft,#eef2ff)' : 'transparent'};
|
`<button onclick="accMode('${m}')" style="border:1px solid var(--border);
|
||||||
padding:8px 10px;border-radius:8px;cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--text-1);margin-bottom:2px">
|
background:${_mode === m ? 'var(--accent,#4f46e5)' : 'transparent'};color:${_mode === m ? '#fff' : 'var(--text-3)'};
|
||||||
<span style="font-weight:${active ? 600 : 500}">${esc(title)}</span>
|
font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;
|
||||||
${sub ? `<span style="color:var(--muted);font-size:12px"> · ${esc(sub)}</span>` : ''}
|
${m === 'content' ? 'border-radius:8px 0 0 8px' : 'border-radius:0 8px 8px 0;border-left:none'}">${label}</button>`;
|
||||||
</button>`;
|
root.innerHTML = `
|
||||||
|
<div style="margin-bottom:16px;display:inline-flex">
|
||||||
|
${seg('content', 'По контенту')}${seg('class', 'По классу')}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>`;
|
||||||
|
renderLeft();
|
||||||
|
renderRight();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList() {
|
/* ── badge «N/M» ── */
|
||||||
const tb = document.getElementById('acc-textbooks');
|
function badge(open, total) {
|
||||||
const ex = document.getElementById('acc-exams');
|
const has = open > 0;
|
||||||
tb.innerHTML = (_catalog.textbooks || [])
|
return `<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;
|
||||||
.map(t => itemBtn('textbook', t.slug, t.title, t.grade ? t.grade + ' кл.' : '')).join('')
|
background:${has ? 'var(--ok-soft,#dcfce7)' : 'var(--border-h,#eee)'};
|
||||||
|| '<p style="color:var(--muted);font-size:12px;padding:6px 10px">Нет учебников</p>';
|
color:${has ? 'var(--ok,#16a34a)' : 'var(--muted)'}">${open}/${total}</span>`;
|
||||||
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;
|
function renderLeft() {
|
||||||
const keyName = type === 'textbook' ? 'slug' : 'exam_key';
|
const left = document.getElementById('acc-left');
|
||||||
const item = (src || []).find(x => x[keyName] === ref);
|
if (_mode === 'content') {
|
||||||
_sel = { type, ref, title: item ? item.title : ref };
|
const total = _summary.totalClasses || 0;
|
||||||
renderList();
|
const list = (type, items) => items.map(it => {
|
||||||
document.getElementById('acc-detail-empty').style.display = 'none';
|
const ref = it[keyName(type)];
|
||||||
const det = document.getElementById('acc-detail');
|
const active = _selContent && _selContent.type === type && _selContent.ref === ref;
|
||||||
det.style.display = '';
|
const open = (_summary[bucket(type)] || {})[ref] || 0;
|
||||||
det.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
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('Нет экзаменов')}`;
|
||||||
|
} else {
|
||||||
|
const classes = _targets.classes || [];
|
||||||
|
left.innerHTML = `
|
||||||
|
<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Классы</div>
|
||||||
|
${classes.length ? classes.map(c => {
|
||||||
|
const active = _selClass && _selClass.id === c.id;
|
||||||
|
return `<button class="acc-item${active ? ' active' : ''}" onclick="accSelClass(${c.id})"
|
||||||
|
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(c.name)}</span>
|
||||||
|
${c.teacher_name ? `<span style="color:var(--muted);font-size:12px"> · ${esc(c.teacher_name)}</span>` : ''}</button>`;
|
||||||
|
}).join('') : empty('Нет классов')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const empty = (t) => `<p style="color:var(--muted);font-size:12px;padding:6px 10px">${t}</p>`;
|
||||||
|
|
||||||
|
/* ── ПРАВАЯ колонка ── */
|
||||||
|
function renderRight() {
|
||||||
|
const right = document.getElementById('acc-right');
|
||||||
|
if (_mode === 'content') {
|
||||||
|
if (!_selContent) { right.innerHTML = hint('Выберите учебник или экзамен слева, чтобы настроить доступ.'); return; }
|
||||||
|
renderContentDetail(right);
|
||||||
|
} else {
|
||||||
|
if (!_selClass) { right.innerHTML = hint('Выберите класс слева, чтобы открыть ему учебники и экзамены.'); return; }
|
||||||
|
renderClassDetail(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hint = (t) => `<div style="color:var(--muted);font-size:14px">${t}</div>`;
|
||||||
|
|
||||||
|
/* ════════ режим «По контенту» ════════ */
|
||||||
|
async function selContent(type, ref) {
|
||||||
|
_selContent = { type, ref, title: contentTitle(type, ref) };
|
||||||
|
renderLeft();
|
||||||
|
const right = document.getElementById('acc-right');
|
||||||
|
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||||||
try {
|
try {
|
||||||
_rules = await LS.accessRules(type, ref);
|
_rules = await LS.accessRules(type, ref);
|
||||||
renderDetail();
|
renderRight();
|
||||||
} catch (e) {
|
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
|
||||||
det.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tri-state кнопки для ученика внутри класса */
|
|
||||||
function studentTri(uid) {
|
function studentTri(uid) {
|
||||||
const v = _rules.studentRules[uid]; // 1 | 0 | undefined
|
const v = _rules.studentRules[uid];
|
||||||
const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit';
|
const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit';
|
||||||
const btn = (val, label, on) =>
|
const btn = (val, label, on) =>
|
||||||
`<button onclick="accSetStudent(${uid},${val})"
|
`<button onclick="accSetStudent(${uid},${val})"
|
||||||
style="border:1px solid var(--border);background:${on ? 'var(--accent,#4f46e5)' : 'transparent'};
|
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;
|
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>`;
|
${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">
|
return `<span style="display:inline-flex">
|
||||||
${btn('null', 'Наследовать', state === 'inherit')}
|
${btn('null', 'Наследовать', state === 'inherit')}${btn(1, 'Открыт', state === 'open')}${btn(0, 'Закрыт', state === 'closed')}</span>`;
|
||||||
${btn(1, 'Открыт', state === 'open')}
|
|
||||||
${btn(0, 'Закрыт', state === 'closed')}
|
|
||||||
</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function classRow(c) {
|
function classRowContent(c) {
|
||||||
const openToClass = _rules.classRules[c.id] === 1;
|
const openToClass = _rules.classRules[c.id] === 1;
|
||||||
const expanded = _open.has(c.id);
|
const expanded = _open.has(c.id);
|
||||||
const students = c.students || [];
|
const students = c.students || [];
|
||||||
@@ -87,25 +154,22 @@
|
|||||||
<div style="padding:6px 0 10px 26px">
|
<div style="padding:6px 0 10px 26px">
|
||||||
${students.length ? students.map(s => `
|
${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)">
|
<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>
|
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)}</span>${studentTri(s.id)}
|
||||||
${studentTri(s.id)}
|
</div>`).join('') : '<p style="color:var(--muted);font-size:12px;margin:4px 0">В классе нет учеников</p>'}
|
||||||
</div>`).join('')
|
|
||||||
: '<p style="color:var(--muted);font-size:12px;margin:4px 0">В классе нет учеников</p>'}
|
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
return `
|
return `
|
||||||
<div class="acc-class" style="border:1px solid var(--border);border-radius:10px;margin-bottom:10px;padding:10px 12px">
|
<div 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">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||||
<button onclick="accToggleExpand(${c.id})"
|
<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">
|
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>
|
<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>` : ''}
|
${esc(c.name)}${c.teacher_name ? `<span style="font-weight:400;color:var(--muted);font-size:12px">· ${esc(c.teacher_name)}</span>` : ''}
|
||||||
</button>
|
</button>
|
||||||
<label class="acc-switch" style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||||||
<span>${openToClass ? 'Открыт' : 'Закрыт'}</span>
|
<span>${openToClass ? 'Открыт' : 'Закрыт'}</span>
|
||||||
<input type="checkbox" ${openToClass ? 'checked' : ''} onchange="accSetClass(${c.id}, this.checked)">
|
<input type="checkbox" ${openToClass ? 'checked' : ''} onchange="accSetClass(${c.id}, this.checked)">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>${studentsHtml}
|
||||||
${studentsHtml}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,60 +185,163 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail() {
|
function renderContentDetail(right) {
|
||||||
const det = document.getElementById('acc-detail');
|
|
||||||
const classes = _targets.classes || [];
|
const classes = _targets.classes || [];
|
||||||
const loose = _targets.looseStudents || [];
|
const loose = _targets.looseStudents || [];
|
||||||
det.innerHTML = `
|
right.innerHTML = `
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px">
|
<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>
|
<div style="font-size:16px;font-weight:700;color:var(--text-1)">${esc(_selContent.title)}</div>
|
||||||
<span class="badge ${_sel.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${_sel.type === 'exam' ? 'Экзамен' : 'Учебник'}</span>
|
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${_selContent.type === 'exam' ? 'Экзамен' : 'Учебник'}</span>
|
||||||
</div>
|
|
||||||
<div class="acc-classes">
|
|
||||||
${classes.length ? classes.map(classRow).join('')
|
|
||||||
: '<p style="color:var(--muted);font-size:13px">Нет классов.</p>'}
|
|
||||||
</div>
|
</div>
|
||||||
|
${classes.length ? `
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||||||
|
<button class="adm-btn adm-btn-small" onclick="accBulk(1)">Открыть всем классам</button>
|
||||||
|
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accBulk(0)">Закрыть у всех</button>
|
||||||
|
</div>` : ''}
|
||||||
|
${classes.length ? classes.map(classRowContent).join('') : '<p style="color:var(--muted);font-size:13px">Нет классов.</p>'}
|
||||||
${loose.length ? `
|
${loose.length ? `
|
||||||
<div style="margin-top:18px">
|
<div style="margin-top:18px">
|
||||||
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div>
|
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div>
|
||||||
${loose.map(looseRow).join('')}
|
${loose.map(looseRow).join('')}
|
||||||
</div>` : ''}
|
</div>` : ''}`;
|
||||||
`;
|
}
|
||||||
|
|
||||||
|
/* пересчёт бейджа для текущего контента по отображаемым классам */
|
||||||
|
function recountContent() {
|
||||||
|
if (!_selContent) return;
|
||||||
|
const open = (_targets.classes || []).filter(c => _rules.classRules[c.id] === 1).length;
|
||||||
|
_summary[bucket(_selContent.type)][_selContent.ref] = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── handlers (optimistic update) ── */
|
|
||||||
async function setClass(classId, checked) {
|
async function setClass(classId, checked) {
|
||||||
const allow = checked ? 1 : null;
|
const allow = checked ? 1 : null;
|
||||||
try {
|
try {
|
||||||
await LS.accessSetRule(_sel.type, _sel.ref, 'class', classId, allow);
|
await LS.accessSetRule(_selContent.type, _selContent.ref, 'class', classId, allow);
|
||||||
if (allow === 1) _rules.classRules[classId] = 1;
|
if (allow === 1) _rules.classRules[classId] = 1; else delete _rules.classRules[classId];
|
||||||
else delete _rules.classRules[classId];
|
recountContent(); renderLeft(); renderRight();
|
||||||
renderDetail();
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
|
||||||
LS.toast(checked ? 'Открыт классу' : 'Закрыт для класса', 'success');
|
}
|
||||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); }
|
|
||||||
|
async function bulk(allow) {
|
||||||
|
const classes = _targets.classes || [];
|
||||||
|
try {
|
||||||
|
await Promise.all(classes.map(c =>
|
||||||
|
LS.accessSetRule(_selContent.type, _selContent.ref, 'class', c.id, allow ? 1 : null)));
|
||||||
|
for (const c of classes) { if (allow) _rules.classRules[c.id] = 1; else delete _rules.classRules[c.id]; }
|
||||||
|
recountContent(); renderLeft(); renderRight();
|
||||||
|
LS.toast(allow ? 'Открыто всем классам' : 'Закрыто у всех классов', 'success');
|
||||||
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selContent(_selContent.type, _selContent.ref); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setStudent(uid, allow) {
|
async function setStudent(uid, allow) {
|
||||||
// allow: 1 | 0 | null (строка 'null' приходит из tri-кнопок)
|
|
||||||
if (allow === 'null') allow = null;
|
if (allow === 'null') allow = null;
|
||||||
try {
|
try {
|
||||||
await LS.accessSetRule(_sel.type, _sel.ref, 'student', uid, allow);
|
await LS.accessSetRule(_selContent.type, _selContent.ref, 'student', uid, allow);
|
||||||
if (allow === 1) _rules.studentRules[uid] = 1;
|
if (allow === 1) _rules.studentRules[uid] = 1;
|
||||||
else if (allow === 0) _rules.studentRules[uid] = 0;
|
else if (allow === 0) _rules.studentRules[uid] = 0;
|
||||||
else delete _rules.studentRules[uid];
|
else delete _rules.studentRules[uid];
|
||||||
renderDetail();
|
renderRight();
|
||||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); }
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand(classId) {
|
function toggleExpand(classId) {
|
||||||
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
|
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
|
||||||
renderDetail();
|
renderRight();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.accSelect = select;
|
/* ════════ режим «По классу» ════════ */
|
||||||
|
async function selClass(id) {
|
||||||
|
const c = (_targets.classes || []).find(x => x.id === id);
|
||||||
|
_selClass = { id, name: c ? c.name : ('#' + id) };
|
||||||
|
renderLeft();
|
||||||
|
const right = document.getElementById('acc-right');
|
||||||
|
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||||||
|
try {
|
||||||
|
const open = await LS.accessClassOpen(id);
|
||||||
|
_classOpen = { textbooks: new Set(open.textbooks || []), exams: new Set(open.exams || []) };
|
||||||
|
renderRight();
|
||||||
|
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function classContentRow(type, it) {
|
||||||
|
const ref = it[keyName(type)];
|
||||||
|
const open = _classOpen[bucket(type)].has(ref);
|
||||||
|
return `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
|
||||||
|
<span style="font-size:13.5px;color:var(--text-1)">${esc(it.title)}
|
||||||
|
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${type === 'exam' ? 'Экзамен' : 'Учебник'}</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="accClassToggle('${type}','${esc(ref)}', this.checked)">
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClassDetail(right) {
|
||||||
|
const tb = _catalog.textbooks || [], ex = _catalog.exams || [];
|
||||||
|
right.innerHTML = `
|
||||||
|
<div style="font-size:16px;font-weight:700;color:var(--text-1);margin-bottom:14px">Класс «${esc(_selClass.name)}»</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||||||
|
<button class="adm-btn adm-btn-small" onclick="accClassBulk(1)">Открыть весь контент</button>
|
||||||
|
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accClassBulk(0)">Закрыть весь</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:4px 0 8px">Учебники</div>
|
||||||
|
${tb.length ? tb.map(it => classContentRow('textbook', it)).join('') : empty('Нет учебников')}
|
||||||
|
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">Экзамены</div>
|
||||||
|
${ex.length ? ex.map(it => classContentRow('exam', it)).join('') : empty('Нет экзаменов')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpSummary(type, ref, delta) {
|
||||||
|
const b = _summary[bucket(type)];
|
||||||
|
const cur = b[ref] || 0;
|
||||||
|
b[ref] = Math.max(0, Math.min(_summary.totalClasses || 0, cur + delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classToggle(type, ref, checked) {
|
||||||
|
try {
|
||||||
|
await LS.accessSetRule(type, ref, 'class', _selClass.id, checked ? 1 : null);
|
||||||
|
const set = _classOpen[bucket(type)];
|
||||||
|
const was = set.has(ref);
|
||||||
|
if (checked) set.add(ref); else set.delete(ref);
|
||||||
|
if (checked && !was) bumpSummary(type, ref, +1);
|
||||||
|
if (!checked && was) bumpSummary(type, ref, -1);
|
||||||
|
renderRight();
|
||||||
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classBulk(allow) {
|
||||||
|
const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]),
|
||||||
|
...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])];
|
||||||
|
try {
|
||||||
|
await Promise.all(all.map(([type, ref]) =>
|
||||||
|
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
|
||||||
|
for (const [type, ref] of all) {
|
||||||
|
const set = _classOpen[bucket(type)];
|
||||||
|
const was = set.has(ref);
|
||||||
|
if (allow) { set.add(ref); if (!was) bumpSummary(type, ref, +1); }
|
||||||
|
else { set.delete(ref); if (was) bumpSummary(type, ref, -1); }
|
||||||
|
}
|
||||||
|
renderRight();
|
||||||
|
LS.toast(allow ? 'Открыт весь контент классу' : 'Закрыт весь контент', 'success');
|
||||||
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── режим ── */
|
||||||
|
function setMode(m) {
|
||||||
|
if (m === _mode) return;
|
||||||
|
_mode = m;
|
||||||
|
renderRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.accMode = setMode;
|
||||||
|
window.accSelContent = selContent;
|
||||||
window.accSetClass = setClass;
|
window.accSetClass = setClass;
|
||||||
|
window.accBulk = bulk;
|
||||||
window.accSetStudent = setStudent;
|
window.accSetStudent = setStudent;
|
||||||
window.accToggleExpand = toggleExpand;
|
window.accToggleExpand = toggleExpand;
|
||||||
|
window.accSelClass = selClass;
|
||||||
|
window.accClassToggle = classToggle;
|
||||||
|
window.accClassBulk = classBulk;
|
||||||
|
|
||||||
window.AdminSections = window.AdminSections || {};
|
window.AdminSections = window.AdminSections || {};
|
||||||
window.AdminSections.access = {
|
window.AdminSections.access = {
|
||||||
|
|||||||
@@ -1025,7 +1025,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, accessRules, accessSetRule,
|
accessCatalog, accessTargets, accessSummary, accessClassOpen, 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,
|
||||||
@@ -1292,6 +1292,8 @@ async function resetUserPermissions(uid, permission) { return req('DELETE'
|
|||||||
/* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */
|
/* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */
|
||||||
async function accessCatalog() { return req('GET', '/access/catalog'); }
|
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 accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); }
|
||||||
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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user