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:
@@ -71,6 +71,66 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></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="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const ACTION_LABELS = {
|
|||||||
'ai.profile.delete': 'Удалён профиль ИИ',
|
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||||
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||||
'export.student': 'Экспорт данных ученика',
|
'export.student': 'Экспорт данных ученика',
|
||||||
|
'export.group_files': 'Экспорт файлов группы (ZIP)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 50;
|
const AUDIT_PAGE_SIZE = 50;
|
||||||
|
|||||||
@@ -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="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-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-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>
|
||||||
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</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>
|
</div>
|
||||||
@@ -297,6 +298,83 @@ async function exportGroupUrls(groupId, groupName) {
|
|||||||
showToast(`Экспортировано: ${list.length}`);
|
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('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||||||
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
|
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
|
||||||
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
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('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
|
||||||
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
|
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('openPhotoBtn').addEventListener('click', openAddPhoto);
|
||||||
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
|
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
|
||||||
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
|
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; }
|
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
|
||||||
const eu = e.target.closest('[data-export-urls]');
|
const eu = e.target.closest('[data-export-urls]');
|
||||||
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
|
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');
|
const photoBtn = e.target.closest('.photo-btn');
|
||||||
if (photoBtn) {
|
if (photoBtn) {
|
||||||
const id = parseInt(photoBtn.dataset.groupId, 10);
|
const id = parseInt(photoBtn.dataset.groupId, 10);
|
||||||
|
|||||||
@@ -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 ---
|
// --- Entries ---
|
||||||
app.get('/api/entries', requireAuth, async (req, res) => {
|
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;
|
const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||||||
|
|||||||
Reference in New Issue
Block a user