feat(trash): deferred purge after configurable days, groups in trash

This commit is contained in:
dev
2026-09-24 13:11:31 +03:00
parent e6c69c8a78
commit 8bf54fb95d
10 changed files with 482 additions and 73 deletions
+5
View File
@@ -257,6 +257,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
.del-btn:hover{opacity:.8}
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
.pending-toggle{margin-bottom:16px;max-width:680px}
.trash-section{margin-top:24px}
.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600}
.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)}
.trash-time.warn{color:#d97706;font-weight:600}
/* --- Настройки (UX/UI) --- */
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
+8
View File
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
'group.create': 'Создана группа',
'group.update': 'Изменена группа',
'group.delete': 'Удалена группа',
'group.soft-delete': 'Группа в корзину',
'group.restore': 'Группа восстановлена',
'group.permanent-delete': 'Группа удалена безвозвратно',
'group.schedule-delete': 'Группа помечена на удаление',
'group.unschedule': 'Удаление группы отменено',
'group.photo.create': 'Загружена фото группы',
'group.photo.update': 'Изменено фото группы',
'group.photo.reorder': 'Изменён порядок фото группы',
@@ -44,6 +49,9 @@ const ACTION_LABELS = {
'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно',
'entry.schedule-delete': 'Запись помечена на удаление',
'entry.unschedule': 'Удаление записи отменено',
'trash.clear': 'Корзина помечена на удаление',
'link.create': 'Создана ссылка',
'link.update': 'Изменена ссылка',
'link.delete': 'Удалена ссылка',
+3 -2
View File
@@ -113,8 +113,9 @@ async function addGroup() {
}
async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
loadGroups();
}
+10 -2
View File
@@ -1,5 +1,5 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled'];
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -165,6 +165,7 @@ async function loadSettings() {
logoOriginal = currentLogo;
renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
@@ -501,6 +502,12 @@ async function saveSettings() {
return;
}
clearFieldError('spamInterval');
const trashValue = document.getElementById('trashPurgeDays').value;
const trashDays = Math.round(Number(trashValue));
if (trashValue === '' || !Number.isFinite(trashDays) || trashDays < 1 || trashDays > 365) {
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
return;
}
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
@@ -524,6 +531,7 @@ async function saveSettings() {
photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value,
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays),
} })
});
if (res.ok) {
+177 -13
View File
@@ -1,7 +1,14 @@
const PAGE_SIZE = 20;
let currentEntries = [];
let currentGroups = [];
let pendingEntries = [];
let pendingGroups = [];
let page = 1;
let totalEntries = 0;
let totalGroups = 0;
let pendingTotalEntries = 0;
let pendingTotalGroups = 0;
let purgeDays = 30;
function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
@@ -28,32 +35,129 @@ function entryHTML(e) {
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">&times;</button>
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">&times;</button>
</div>
`;
}
function groupHTML(g) {
return `
<div class="card">
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">&times;</button>
</div>
`;
}
function pendingEntryHTML(e) {
return `
<div class="card pending">
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
</div>
`;
}
function pendingGroupHTML(g) {
return `
<div class="card pending">
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
</div>
`;
}
function renderGroups() {
const grid = document.getElementById('groupsGrid');
grid.className = 'grid';
grid.innerHTML = currentGroups.length
? currentGroups.map(groupHTML).join('')
: '<div class="empty">Групп в корзине нет</div>';
document.getElementById('groupsInfo').textContent =
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
}
function renderGrid() {
const grid = document.getElementById('grid');
grid.className = 'grid';
grid.innerHTML = currentEntries.length
? currentEntries.map(entryHTML).join('')
: '<div class="empty">Корзина пуста</div>';
: '<div class="empty">Записей в корзине нет</div>';
document.getElementById('entriesInfo').textContent =
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
document.getElementById('trashInfo').textContent =
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
document.getElementById('emptyBtn').disabled = totalEntries === 0;
totalEntries + totalGroups > 0
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
: 'Корзина пуста';
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
function renderPending() {
document.getElementById('pendingGroupsGrid').className = 'grid';
document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length
? pendingGroups.map(pendingGroupHTML).join('')
: '<div class="empty">Нет групп, помеченных на удаление</div>';
document.getElementById('pendingGrid').className = 'grid';
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
? pendingEntries.map(pendingEntryHTML).join('')
: '<div class="empty">Нет записей, помеченных на удаление</div>';
document.getElementById('pendingGroupsInfo').textContent =
pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : '';
document.getElementById('pendingEntriesInfo').textContent =
pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : '';
const n = pendingTotalEntries + pendingTotalGroups;
document.getElementById('pendingInfo').textContent =
n > 0 ? `— будет удалено: ${n}` : '';
const hint = document.getElementById('pendingToggleHint');
if (hint) {
hint.textContent = n > 0
? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней`
: 'Данных, помеченных на удаление, нет';
}
document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked;
}
async function loadTrash() {
const p = new URLSearchParams();
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
const data = await res.json();
const [trashRes, pendRes] = await Promise.all([
fetch(`${API}/api/trash?${p}`, { headers: hdr() }),
fetch(`${API}/api/trash/pending`, { headers: hdr() }),
]);
const data = await trashRes.json();
const pend = await pendRes.json();
currentEntries = data.entries;
totalEntries = data.total;
currentGroups = data.groups;
totalGroups = data.total_groups;
pendingEntries = pend.entries;
pendingTotalEntries = pend.total;
pendingGroups = pend.groups;
pendingTotalGroups = pend.total_groups;
if (pend.purge_days > 0) purgeDays = pend.purge_days;
renderGroups();
renderGrid();
renderPending();
}
function goPage(p) { page = p; loadTrash(); }
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
}
async function hardDelete(id) {
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
showToast('Запись удалена навсегда');
loadTrash();
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function restoreGroup(id) {
const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Группа восстановлена'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
}
async function hardDeleteGroup(id, name) {
if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return;
const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function unscheduleEntry(id) {
const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function unscheduleGroup(id) {
const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function emptyTrash() {
if (totalEntries === 0) return;
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
const parts = [];
if (totalEntries) parts.push(`записей: ${totalEntries}`);
if (totalGroups) parts.push(`групп: ${totalGroups}`);
if (!parts.length) return;
if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return;
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.getElementById('pendingToggle').addEventListener('change', () => {
const open = document.getElementById('pendingToggle').checked;
document.getElementById('pendingSection').hidden = !open;
if (open) loadTrash();
});
document.getElementById('grid').addEventListener('click', e => {
const card = e.target.closest('.card');
@@ -91,6 +228,33 @@ document.getElementById('grid').addEventListener('click', e => {
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
});
document.getElementById('groupsGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const rs = card.querySelector('[data-restore-group]');
if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; }
const hd = card.querySelector('[data-hard-group]');
if (hd && (e.target === hd || hd.contains(e.target))) {
const id = parseInt(hd.dataset.hardGroup, 10);
const g = currentGroups.find(x => String(x.id) === String(id));
hardDeleteGroup(id, g ? g.name : id);
}
});
document.getElementById('pendingGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const us = card.querySelector('[data-unschedule]');
if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); }
});
document.getElementById('pendingGroupsGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const us = card.querySelector('[data-unschedule-group]');
if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); }
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
+17
View File
@@ -27,6 +27,7 @@
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
</aside>
@@ -243,6 +244,22 @@
</div>
</div>
<div class="settings-card" id="sec-trash">
<div class="card-head">
<div class="ic"><i data-lucide="trash-2"></i></div>
<h3>Корзина</h3>
</div>
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
<div class="settings-field">
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
<div class="unit-row">
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
<span class="unit">дней</span>
</div>
<span class="hint">От 1 до 365 дней</span>
</div>
</div>
<div class="settings-card" id="sec-bans">
<div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div>
+22 -2
View File
@@ -15,8 +15,28 @@
<span class="trash-muted" id="trashInfo"></span>
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
</div>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
<label class="toggle pending-toggle" id="pendingToggleRow">
<input type="checkbox" id="pendingToggle">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Показать помеченные на удаление</b><small id="pendingToggleHint">Данные не удаляются сразу: они помечаются и исчезают из корзины. Здесь их можно вернуть в корзину и восстановить</small></span>
</label>
<div class="trash-section" id="pendingSection" hidden>
<h3>Помечены на удаление <span class="trash-muted" id="pendingInfo"></span></h3>
<p class="trash-muted">Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.</p>
<h4>Группы <span class="trash-muted" id="pendingGroupsInfo"></span></h4>
<div class="grid" id="pendingGroupsGrid"><div class="empty">Загрузка...</div></div>
<h4>Записи журнала <span class="trash-muted" id="pendingEntriesInfo"></span></h4>
<div class="grid" id="pendingGrid"><div class="empty">Загрузка...</div></div>
</div>
<div class="trash-section" id="groupsSection">
<h3>Группы <span class="trash-muted" id="groupsInfo"></span></h3>
<div class="grid" id="groupsGrid"><div class="empty">Загрузка...</div></div>
</div>
<div class="trash-section">
<h3>Записи журнала <span class="trash-muted" id="entriesInfo"></span></h3>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
</div>