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;