feat(access): Фаза 2c — история правил + пресет «копировать доступ из класса»
История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил правило для контента (из admin_audit_log, имена классов/учеников резолвятся). Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений». Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет текущие правила открытыми правилами класса-источника). Тест: история (admin видит запись, учителю 403). content-access 13/13. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -269,7 +269,29 @@
|
||||
<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>` : ''}`;
|
||||
</div>` : ''}
|
||||
<div style="margin-top:18px;border-top:1px solid var(--border);padding-top:12px">
|
||||
<button class="adm-btn adm-btn-small" style="background:transparent;color:var(--text-3);border:1px solid var(--border)" onclick="accShowLog()">История изменений</button>
|
||||
<div id="acc-log" style="margin-top:10px"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function showLog() {
|
||||
const box = document.getElementById('acc-log');
|
||||
if (!box || !_selContent) return;
|
||||
box.innerHTML = '<p style="color:var(--muted);font-size:12px">Загрузка…</p>';
|
||||
try {
|
||||
const log = await LS.accessLog(_selContent.type, _selContent.ref);
|
||||
if (!log.length) { box.innerHTML = '<p style="color:var(--muted);font-size:12px">Изменений пока нет.</p>'; return; }
|
||||
const A = { grant: 'открыл', deny: 'закрыл (исключение)', inherit: 'сбросил (наследование)' };
|
||||
box.innerHTML = log.map(e => `
|
||||
<div style="font-size:12.5px;color:var(--text-1);padding:5px 0;border-top:1px solid var(--border-soft,#f0f0f0)">
|
||||
<b>${esc(e.actor)}</b> ${A[e.action] || esc(e.action)} · ${esc(e.targetName)}
|
||||
<span style="color:var(--muted)"> · ${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
box.innerHTML = `<p style="color:var(--muted);font-size:12px">${e && e.status === 403 ? 'История доступна только администратору.' : 'Ошибка: ' + esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* пересчёт бейджа для текущего контента по отображаемым классам */
|
||||
@@ -359,6 +381,17 @@
|
||||
${subjects.map(s => `<button class="adm-btn adm-btn-small" style="background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5)" onclick="accClassSubj('${esc(s)}')">+ ${esc(subjLabel(s))}</button>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
const others = (_targets.classes || []).filter(c => c.id !== _selClass.id);
|
||||
if (others.length) {
|
||||
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px">
|
||||
<span style="font-size:12px;color:var(--muted)">Скопировать доступ из класса:</span>
|
||||
<select id="acc-copy-src" style="padding:5px 8px;border:1px solid var(--border);border-radius:7px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:12.5px">
|
||||
<option value="">— выберите —</option>
|
||||
${others.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('')}
|
||||
</select>
|
||||
<button class="adm-btn adm-btn-small" onclick="accCopyFrom()">Скопировать</button>
|
||||
</div>`;
|
||||
}
|
||||
CONTENT_TYPES.forEach(type => {
|
||||
const items = itemsOf(type);
|
||||
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`;
|
||||
@@ -397,6 +430,24 @@
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||||
}
|
||||
|
||||
/* пресет: скопировать открытый доступ из другого класса в текущий (дополняет) */
|
||||
async function copyFrom() {
|
||||
const sel = document.getElementById('acc-copy-src');
|
||||
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
|
||||
const srcId = Number(sel.value);
|
||||
const srcName = sel.options[sel.selectedIndex].text;
|
||||
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
|
||||
try {
|
||||
const src = await LS.accessClassOpen(srcId);
|
||||
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
|
||||
if (!items.length) { LS.toast('В классе-источнике нет открытого контента', 'error'); return; }
|
||||
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1)));
|
||||
items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } });
|
||||
renderRight();
|
||||
LS.toast(`Скопировано из «${srcName}» (${items.length})`, 'success');
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||||
}
|
||||
|
||||
async function classBulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
||||
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
|
||||
@@ -532,6 +583,8 @@
|
||||
window.accClassToggle = classToggle;
|
||||
window.accClassBulk = classBulk;
|
||||
window.accClassSubj = classSubjectBulk;
|
||||
window.accCopyFrom = copyFrom;
|
||||
window.accShowLog = showLog;
|
||||
window.accMx = mxToggle;
|
||||
window.accMxSearch = mxSearch;
|
||||
window.accMxRowBulk = mxRowBulk;
|
||||
|
||||
Reference in New Issue
Block a user