feat(groups): ZIP-архив группы с выбором категорий и режима фото

Форма архива файлов группы на /groups.html получила галочки выгрузки:
- «Работы (файлы проектов)» — включена по умолчанию
- «Фото записи» — выключена по умолчанию
- режим фото «Последние» / «Все», по умолчанию «Последние»;
  радиокнопки блокируются, когда «Фото записи» выключена

Дефолты периода: дата «с» пустая, дата «по» — сегодня.

GET /api/groups/:id/export/files принимает include_files,
include_photos и photos_mode:
- latest — только entries.photo_path, то главное обработанное фото записи
- all — плюс вся галерея записи из entry_photos
- 400, если обе категории выключены

Запросы к БД для отключённых категорий не выполняются; в data.json
и в аудит добавлены options и раздельные счётчики works/photos.

В аудите добавлена метка export.group_files.

Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме
«последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи),
категории комбинируются независимо. api.smoketest.js 57/57,
diff.selftest.js 16/16.
This commit is contained in:
dev
2026-10-02 12:48:36 +03:00
parent 45033bffa8
commit 55c4b281c3
4 changed files with 317 additions and 0 deletions
+60
View File
@@ -71,6 +71,66 @@
</div>
<div class="toast" id="toast"></div>
<div class="modal-overlay" id="groupFilesModal">
<div class="edit-modal" style="max-width:440px">
<h3 id="groupFilesTitle">Архив файлов группы</h3>
<form id="groupFilesForm" class="settings-stack" style="gap:14px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="groupFilesGroup" class="settings-input" required></select>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Дата с</label>
<input type="date" id="groupFilesFrom" class="settings-input">
</div>
<div class="settings-field" style="flex:1">
<label>Дата по</label>
<input type="date" id="groupFilesTo" class="settings-input">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div class="hint" style="font-size:.78rem;color:var(--muted);margin:0">Файлы всех учеников группы за период. По умолчанию — с самого начала и по сегодня.</div>
</div>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="groupFilesIncludeWorks" checked>
Работы (файлы проектов)
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="groupFilesIncludePhotos">
Фото записи
</label>
</div>
<div class="settings-field full">
<label>Какие фото выгружать</label>
<div style="display:flex;gap:16px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="radio" name="groupFilesMode" value="latest" checked>
Последние
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="radio" name="groupFilesMode" value="all">
Все
</label>
</div>
<span class="hint" style="font-size:.78rem">Последние — только главное обработанное фото каждой записи. Все — главное фото плюс вся галерея записи.</span>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="closeGroupFilesBtn">Отмена</button>
<button type="submit" class="btn-primary" id="downloadGroupFilesBtn">Скачать ZIP</button>
</div>
</form>
</div>
</div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
+1
View File
@@ -81,6 +81,7 @@ const ACTION_LABELS = {
'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика',
'export.group_files': 'Экспорт файлов группы (ZIP)',
};
const AUDIT_PAGE_SIZE = 50;
+84
View File
@@ -65,6 +65,7 @@ async function loadGroups() {
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
</div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
@@ -297,6 +298,83 @@ async function exportGroupUrls(groupId, groupName) {
showToast(`Экспортировано: ${list.length}`);
}
function openGroupFilesModal(groupId, groupName) {
const sel = document.getElementById('groupFilesGroup');
sel.innerHTML = allGroups.map(g => `<option value="${g.id}" ${String(g.id) === String(groupId) ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
document.getElementById('groupFilesTitle').textContent = `Архив файлов — ${groupName}`;
document.getElementById('groupFilesFrom').value = '';
document.getElementById('groupFilesTo').value = todayISOLocal();
document.getElementById('groupFilesIncludeWorks').checked = true;
document.getElementById('groupFilesIncludePhotos').checked = false;
const latest = document.querySelector('input[name="groupFilesMode"][value="latest"]');
if (latest) latest.checked = true;
onGroupFilesPhotosToggle();
document.getElementById('groupFilesModal').classList.add('open');
}
function closeGroupFilesModal() {
document.getElementById('groupFilesModal').classList.remove('open');
}
function todayISOLocal() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function onGroupFilesPhotosToggle() {
const on = document.getElementById('groupFilesIncludePhotos').checked;
document.querySelectorAll('input[name="groupFilesMode"]').forEach(el => { el.disabled = !on; });
}
function groupFilesMode() {
const checked = document.querySelector('input[name="groupFilesMode"]:checked');
return checked && checked.value === 'all' ? 'all' : 'latest';
}
async function downloadGroupFiles() {
const groupId = document.getElementById('groupFilesGroup').value;
const dateFrom = document.getElementById('groupFilesFrom').value;
const dateTo = document.getElementById('groupFilesTo').value;
const includeWorks = document.getElementById('groupFilesIncludeWorks').checked;
const includePhotos = document.getElementById('groupFilesIncludePhotos').checked;
if (!groupId) { alert('Выберите группу'); return; }
if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; }
if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; }
const btn = document.getElementById('downloadGroupFilesBtn');
btn.disabled = true;
btn.textContent = 'Формируем...';
try {
const q = new URLSearchParams();
if (dateFrom) q.set('date_from', dateFrom);
if (dateTo) q.set('date_to', dateTo);
q.set('include_files', includeWorks ? '1' : '0');
q.set('include_photos', includePhotos ? '1' : '0');
q.set('photos_mode', groupFilesMode());
const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() });
if (!res.ok) {
let msg = 'Не удалось сформировать архив';
try { const e = await res.json(); msg = e.error || msg; } catch {}
alert(msg);
return;
}
const blob = await res.blob();
let disp = res.headers.get('Content-Disposition') || '';
let fname = 'group_files.zip';
const star = disp.match(/filename\*=UTF-8''([^;]+)/i);
const plain = disp.match(/filename="?([^";]+)"?/i);
if (star) { try { fname = decodeURIComponent(star[1]); } catch { fname = plain ? plain[1] : fname; } }
else if (plain) fname = plain[1];
downloadBlob(blob, fname);
showToast('Архив сформирован');
closeGroupFilesModal();
} catch (err) {
alert('Ошибка при формировании архива: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Скачать ZIP';
}
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
@@ -307,6 +385,10 @@ document.getElementById('photoForm').addEventListener('submit', e => {
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
document.getElementById('groupFilesModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGroupFilesModal(); });
document.getElementById('closeGroupFilesBtn').addEventListener('click', closeGroupFilesModal);
document.getElementById('groupFilesForm').addEventListener('submit', e => { e.preventDefault(); downloadGroupFiles(); });
document.getElementById('groupFilesIncludePhotos').addEventListener('change', onGroupFilesPhotosToggle);
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
@@ -320,6 +402,8 @@ document.getElementById('groupList').addEventListener('click', e => {
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
const eu = e.target.closest('[data-export-urls]');
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
const ef = e.target.closest('[data-export-files]');
if (ef) { openGroupFilesModal(parseInt(ef.dataset.exportFiles, 10), ef.dataset.groupName); return; }
const photoBtn = e.target.closest('.photo-btn');
if (photoBtn) {
const id = parseInt(photoBtn.dataset.groupId, 10);