From 55c4b281c36e66f2954c91b7c4988fc160647412 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 2 Oct 2026 12:48:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(groups):=20ZIP-=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D1=81=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=D0=BE=D0=BC=20=D0=BA=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D0=B8=20=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма архива файлов группы на /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. --- public/groups.html | 60 ++++++++++++++++ public/js/audit.js | 1 + public/js/groups.js | 84 ++++++++++++++++++++++ server.js | 172 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+) diff --git a/public/groups.html b/public/groups.html index d97f77d..7ae0d26 100644 --- a/public/groups.html +++ b/public/groups.html @@ -71,6 +71,66 @@
+ + + diff --git a/public/js/audit.js b/public/js/audit.js index 6dc2f34..2e3b35d 100644 --- a/public/js/audit.js +++ b/public/js/audit.js @@ -81,6 +81,7 @@ const ACTION_LABELS = { 'ai.profile.delete': 'Удалён профиль ИИ', 'ai.profile.activate': 'Выбран активный профиль ИИ', 'export.student': 'Экспорт данных ученика', + 'export.group_files': 'Экспорт файлов группы (ZIP)', }; const AUDIT_PAGE_SIZE = 50; diff --git a/public/js/groups.js b/public/js/groups.js index a74d622..9248272 100644 --- a/public/js/groups.js +++ b/public/js/groups.js @@ -65,6 +65,7 @@ async function loadGroups() { +
${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}
@@ -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 => ``).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); diff --git a/server.js b/server.js index 7e73033..f0779a3 100644 --- a/server.js +++ b/server.js @@ -4132,6 +4132,178 @@ app.get('/api/export/student', requireAuth, async (req, res) => { } }); +// __GROUP_FILES_EXPORT__ +app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => { + const gid = parseInt(req.params.id, 10); + if (!Number.isInteger(gid) || gid < 1) return res.status(400).json({ error: 'Некорректная группа' }); + if (!(await groupBelongsToBranches(req.user, gid))) { + return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + const includeFiles = req.query.include_files !== '0'; + const includePhotos = req.query.include_photos !== '0'; + const photosMode = req.query.photos_mode === 'all' ? 'all' : 'latest'; + if (!includeFiles && !includePhotos) { + return res.status(400).json({ error: 'Выберите хотя бы одну категорию: работы или фото записи' }); + } + let dateFrom = null; + let dateTo = null; + try { + if (req.query.date_from) dateFrom = optDate(req.query.date_from); + if (req.query.date_to) dateTo = optDate(req.query.date_to); + } catch { + return res.status(400).json({ error: 'Неверный период' }); + } + if (dateFrom && dateTo && dateFrom > dateTo) { + return res.status(400).json({ error: 'Дата «С» позже даты «По»' }); + } + try { + const { rows: groupRows } = await pool.query('SELECT id, name FROM groups WHERE id = $1 AND deleted_at IS NULL', [gid]); + if (!groupRows.length) return res.status(404).json({ error: 'Группа не найдена' }); + const groupName = groupRows[0].name; + const conds = ['e.group_id = $1', 'e.deleted_at IS NULL']; + const params = [gid]; + if (dateFrom) { + params.push(dateFrom); + conds.push(`e.created_at >= $${params.length}::date`); + } + if (dateTo) { + params.push(dateTo); + conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`); + } + const condStr = conds.join(' AND '); + const [studentsRes, filesRes, photosRes, entryPhotosRes] = await Promise.all([ + pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]), + includeFiles + ? pool.query( + `SELECT pf.name, pf.path, e.student_name, e.created_at + FROM project_files pf + JOIN entries e ON e.id = pf.entry_id + WHERE ${condStr} AND pf.detached_at IS NULL + ORDER BY e.student_name, e.created_at DESC, pf.id DESC`, + params + ) + : null, + includePhotos + ? pool.query( + `SELECT e.photo_path, e.student_name, e.created_at + FROM entries e + WHERE ${condStr} AND e.photo_path IS NOT NULL + ORDER BY e.student_name, e.created_at DESC`, + params + ) + : null, + includePhotos && photosMode === 'all' + ? pool.query( + `SELECT ep.photo_path, ep.caption, e.student_name, e.created_at + FROM entry_photos ep + JOIN entries e ON e.id = ep.entry_id + WHERE ${condStr} + ORDER BY e.student_name, e.created_at DESC, ep.sort_order ASC, ep.id ASC`, + params + ) + : null, + ]); + const fileRows = filesRes ? filesRes.rows : []; + const photoRows = photosRes ? photosRes.rows : []; + const entryPhotoRows = entryPhotosRes ? entryPhotosRes.rows : []; + const studentNames = new Set(studentsRes.rows.map(s => s.name)); + const zip = createZipWriter(); + const folderOf = name => String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 80) || 'Без имени'; + const seenFolders = new Set(); + const uniqueFolder = base => { + let folder = base; + let n = 1; + while (seenFolders.has(folder)) { + n++; + folder = `${base} (${n})`; + } + seenFolders.add(folder); + return folder; + }; + const folderByStudent = new Map(); + const ensureFolder = studentName => { + const key = String(studentName || ''); + if (folderByStudent.has(key)) return folderByStudent.get(key); + const folder = uniqueFolder(folderOf(key)); + folderByStudent.set(key, folder); + zip.addDir(folder); + return folder; + }; + const filesBuilt = []; + const seenFiles = new Map(); + const seenPaths = new Set(); + async function addEntryFile(row, subDir) { + if (!isSafeUploadPath(row.path)) return false; + const stored = row.path.slice('/uploads/'.length); + if (seenPaths.has(stored)) return false; + const data = await storage.getBuffer(stored); + if (!data) return false; + seenPaths.add(stored); + const folder = ensureFolder(row.student_name); + const base = String(row.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 120) || 'file'; + const ext = path.extname(base); + const stem = ext ? base.slice(0, -ext.length) : base; + const keyPath = folder + '/' + (subDir ? subDir + '/' : ''); + let saved = base; + let n = 1; + while (seenFiles.has(keyPath + saved)) { + n++; + saved = `${stem}(${n})${ext}`; + } + seenFiles.set(keyPath + saved, true); + const zipPath = subDir ? `${folder}/${subDir}/${saved}` : `${folder}/${saved}`; + if (subDir && !seenFiles.has(folder + '/' + subDir + '/')) { + seenFiles.set(folder + '/' + subDir + '/', true); + zip.addDir(`${folder}/${subDir}`); + } + zip.addFile(zipPath, data, new Date(row.created_at || Date.now())); + filesBuilt.push({ folder: subDir ? `${folder}/${subDir}` : folder, file: saved, original: row.name, size: data.length, created_at: row.created_at || null }); + return true; + } + let worksAdded = 0; + let photosAdded = 0; + for (const f of fileRows) if (await addEntryFile(f, null)) worksAdded++; + for (const p of photoRows) { + if (await addEntryFile({ name: `photo_${new Date(p.created_at || Date.now()).toISOString().slice(0, 10)}.jpg`, path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++; + } + for (const p of entryPhotoRows) { + const storedName = String(p.photo_path).split('/').pop(); + if (await addEntryFile({ name: storedName || 'photo.jpg', path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++; + } + const meta = { + exported_at: new Date().toISOString(), + group: { id: gid, name: groupName }, + period: { date_from: dateFrom, date_to: dateTo }, + options: { files: includeFiles, photos: includePhotos, photos_mode: photosMode }, + totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded }, + students: [...studentNames].sort(), + files: filesBuilt, + }; + zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8')); + const buf = zip.toBuffer(); + await logAudit(req, 'export.group_files', { + group_id: gid, + group_name: groupName, + students: studentNames.size, + files: filesBuilt.length, + works: worksAdded, + photos: photosAdded, + photos_mode: photosMode, + date_from: dateFrom, + date_to: dateTo, + }); + const safeGroup = groupName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 60) || 'group'; + const zipDate = new Date().toISOString().slice(0, 10); + const zipFname = `group_${safeGroup}_${zipDate}.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`group_${groupName}_${zipDate}.zip`)}`); + res.send(buf); + } catch (err) { + console.error('export group files:', err); + res.status(500).json({ error: err.message }); + } +}); + // --- Entries --- app.get('/api/entries', requireAuth, async (req, res) => { const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;