feat(groups): расширенный экспорт фото — галерея и оригиналы

This commit is contained in:
dev
2026-10-02 13:07:47 +03:00
parent 55c4b281c3
commit 449b86955e
3 changed files with 64 additions and 19 deletions
+10 -6
View File
@@ -111,17 +111,21 @@
</div> </div>
<div class="settings-field full"> <div class="settings-field full">
<label>Какие фото выгружать</label> <label>Какие фото выгружать</label>
<div style="display:flex;gap:16px;flex-wrap:wrap"> <div style="display:flex;flex-direction:column;gap:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400"> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="radio" name="groupFilesMode" value="latest" checked> <input type="checkbox" id="groupFilesMainPhoto" checked>
Последние Главное фото каждой записи
</label> </label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400"> <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="radio" name="groupFilesMode" value="all"> <input type="checkbox" id="groupFilesGallery">
Все Вся галерея записи
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesOriginals">
Оригиналы фото (до обработки)
</label> </label>
</div> </div>
<span class="hint" style="font-size:.78rem">Последние — только главное обработанное фото каждой записи. Все — главное фото плюс вся галерея записи.</span> <span class="hint" style="font-size:.78rem">Главное фото — текущая версия снимка записи. Галерея — все фото записи. Оригиналы — версии до обработки, они попадают в подпапку «Оригиналы», если обработка уже применялась.</span>
</div> </div>
<div class="card-foot" style="justify-content:flex-end;gap:8px"> <div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="closeGroupFilesBtn">Отмена</button> <button type="button" class="btn-primary ghost" id="closeGroupFilesBtn">Отмена</button>
+25 -7
View File
@@ -306,8 +306,9 @@ function openGroupFilesModal(groupId, groupName) {
document.getElementById('groupFilesTo').value = todayISOLocal(); document.getElementById('groupFilesTo').value = todayISOLocal();
document.getElementById('groupFilesIncludeWorks').checked = true; document.getElementById('groupFilesIncludeWorks').checked = true;
document.getElementById('groupFilesIncludePhotos').checked = false; document.getElementById('groupFilesIncludePhotos').checked = false;
const latest = document.querySelector('input[name="groupFilesMode"][value="latest"]'); document.getElementById('groupFilesMainPhoto').checked = true;
if (latest) latest.checked = true; document.getElementById('groupFilesGallery').checked = false;
document.getElementById('groupFilesOriginals').checked = false;
onGroupFilesPhotosToggle(); onGroupFilesPhotosToggle();
document.getElementById('groupFilesModal').classList.add('open'); document.getElementById('groupFilesModal').classList.add('open');
} }
@@ -321,14 +322,27 @@ function todayISOLocal() {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} }
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
function onGroupFilesPhotosToggle() { function onGroupFilesPhotosToggle() {
const on = document.getElementById('groupFilesIncludePhotos').checked; const on = document.getElementById('groupFilesIncludePhotos').checked;
document.querySelectorAll('input[name="groupFilesMode"]').forEach(el => { el.disabled = !on; }); GROUP_FILES_PHOTO_BOXES.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = !on;
});
} }
function groupFilesMode() { function groupFilesPhotosOptions() {
const checked = document.querySelector('input[name="groupFilesMode"]:checked'); const main = document.getElementById('groupFilesMainPhoto').checked;
return checked && checked.value === 'all' ? 'all' : 'latest'; const gallery = document.getElementById('groupFilesGallery').checked;
const originals = document.getElementById('groupFilesOriginals').checked;
return {
main,
gallery,
originals,
mode: gallery ? 'all' : 'latest',
any: main || gallery || originals,
};
} }
async function downloadGroupFiles() { async function downloadGroupFiles() {
@@ -337,8 +351,10 @@ async function downloadGroupFiles() {
const dateTo = document.getElementById('groupFilesTo').value; const dateTo = document.getElementById('groupFilesTo').value;
const includeWorks = document.getElementById('groupFilesIncludeWorks').checked; const includeWorks = document.getElementById('groupFilesIncludeWorks').checked;
const includePhotos = document.getElementById('groupFilesIncludePhotos').checked; const includePhotos = document.getElementById('groupFilesIncludePhotos').checked;
const photoOpts = groupFilesPhotosOptions();
if (!groupId) { alert('Выберите группу'); return; } if (!groupId) { alert('Выберите группу'); return; }
if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; } if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; }
if (includePhotos && !photoOpts.any) { alert('Выберите, какие фото выгружать: главное фото, галерея или оригиналы'); return; }
if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; } if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; }
const btn = document.getElementById('downloadGroupFilesBtn'); const btn = document.getElementById('downloadGroupFilesBtn');
btn.disabled = true; btn.disabled = true;
@@ -349,7 +365,9 @@ async function downloadGroupFiles() {
if (dateTo) q.set('date_to', dateTo); if (dateTo) q.set('date_to', dateTo);
q.set('include_files', includeWorks ? '1' : '0'); q.set('include_files', includeWorks ? '1' : '0');
q.set('include_photos', includePhotos ? '1' : '0'); q.set('include_photos', includePhotos ? '1' : '0');
q.set('photos_mode', groupFilesMode()); q.set('include_main', photoOpts.main ? '1' : '0');
q.set('photos_mode', photoOpts.mode);
q.set('include_originals', photoOpts.originals ? '1' : '0');
const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() }); const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() });
if (!res.ok) { if (!res.ok) {
let msg = 'Не удалось сформировать архив'; let msg = 'Не удалось сформировать архив';
+29 -6
View File
@@ -2061,6 +2061,12 @@ function optPhotoRefPath(v, max) {
return reqPhotoRefPath(v, max); return reqPhotoRefPath(v, max);
} }
function photoRefKey(p) {
if (typeof p !== 'string') return null;
if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length);
return null;
}
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']); const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
function optAiText(v, max) { function optAiText(v, max) {
@@ -4142,6 +4148,8 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
const includeFiles = req.query.include_files !== '0'; const includeFiles = req.query.include_files !== '0';
const includePhotos = req.query.include_photos !== '0'; const includePhotos = req.query.include_photos !== '0';
const photosMode = req.query.photos_mode === 'all' ? 'all' : 'latest'; const photosMode = req.query.photos_mode === 'all' ? 'all' : 'latest';
const includeMain = includePhotos && req.query.include_main !== '0';
const includeOriginals = includePhotos && req.query.include_originals === '1';
if (!includeFiles && !includePhotos) { if (!includeFiles && !includePhotos) {
return res.status(400).json({ error: 'Выберите хотя бы одну категорию: работы или фото записи' }); return res.status(400).json({ error: 'Выберите хотя бы одну категорию: работы или фото записи' });
} }
@@ -4171,7 +4179,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`); conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
} }
const condStr = conds.join(' AND '); const condStr = conds.join(' AND ');
const [studentsRes, filesRes, photosRes, entryPhotosRes] = await Promise.all([ const [studentsRes, filesRes, photosRes, entryPhotosRes, originalsRes] = await Promise.all([
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]), pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
includeFiles includeFiles
? pool.query( ? pool.query(
@@ -4183,7 +4191,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
params params
) )
: null, : null,
includePhotos includePhotos && includeMain
? pool.query( ? pool.query(
`SELECT e.photo_path, e.student_name, e.created_at `SELECT e.photo_path, e.student_name, e.created_at
FROM entries e FROM entries e
@@ -4202,10 +4210,20 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
params params
) )
: null, : null,
includeOriginals
? pool.query(
`SELECT e.photo_original_path, e.student_name, e.created_at
FROM entries e
WHERE ${condStr} AND e.photo_original_path IS NOT NULL
ORDER BY e.student_name, e.created_at DESC`,
params
)
: null,
]); ]);
const fileRows = filesRes ? filesRes.rows : []; const fileRows = filesRes ? filesRes.rows : [];
const photoRows = photosRes ? photosRes.rows : []; const photoRows = photosRes ? photosRes.rows : [];
const entryPhotoRows = entryPhotosRes ? entryPhotosRes.rows : []; const entryPhotoRows = entryPhotosRes ? entryPhotosRes.rows : [];
const originalRows = originalsRes ? originalsRes.rows : [];
const studentNames = new Set(studentsRes.rows.map(s => s.name)); const studentNames = new Set(studentsRes.rows.map(s => s.name));
const zip = createZipWriter(); const zip = createZipWriter();
const folderOf = name => String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 80) || 'Без имени'; const folderOf = name => String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 80) || 'Без имени';
@@ -4233,8 +4251,8 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
const seenFiles = new Map(); const seenFiles = new Map();
const seenPaths = new Set(); const seenPaths = new Set();
async function addEntryFile(row, subDir) { async function addEntryFile(row, subDir) {
if (!isSafeUploadPath(row.path)) return false; const stored = photoRefKey(row.path);
const stored = row.path.slice('/uploads/'.length); if (!stored) return false;
if (seenPaths.has(stored)) return false; if (seenPaths.has(stored)) return false;
const data = await storage.getBuffer(stored); const data = await storage.getBuffer(stored);
if (!data) return false; if (!data) return false;
@@ -4262,6 +4280,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
} }
let worksAdded = 0; let worksAdded = 0;
let photosAdded = 0; let photosAdded = 0;
let originalsAdded = 0;
for (const f of fileRows) if (await addEntryFile(f, null)) worksAdded++; for (const f of fileRows) if (await addEntryFile(f, null)) worksAdded++;
for (const p of photoRows) { 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++; 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++;
@@ -4270,12 +4289,15 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
const storedName = String(p.photo_path).split('/').pop(); 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++; if (await addEntryFile({ name: storedName || 'photo.jpg', path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++;
} }
for (const p of originalRows) {
if (await addEntryFile({ name: `original_${new Date(p.created_at || Date.now()).toISOString().slice(0, 10)}.jpg`, path: p.photo_original_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи/Оригиналы')) originalsAdded++;
}
const meta = { const meta = {
exported_at: new Date().toISOString(), exported_at: new Date().toISOString(),
group: { id: gid, name: groupName }, group: { id: gid, name: groupName },
period: { date_from: dateFrom, date_to: dateTo }, period: { date_from: dateFrom, date_to: dateTo },
options: { files: includeFiles, photos: includePhotos, photos_mode: photosMode }, options: { files: includeFiles, photos: includePhotos, main_photo: includeMain, photos_mode: photosMode, originals: includeOriginals },
totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded }, totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded, originals: originalsAdded },
students: [...studentNames].sort(), students: [...studentNames].sort(),
files: filesBuilt, files: filesBuilt,
}; };
@@ -4288,6 +4310,7 @@ app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
files: filesBuilt.length, files: filesBuilt.length,
works: worksAdded, works: worksAdded,
photos: photosAdded, photos: photosAdded,
originals: originalsAdded,
photos_mode: photosMode, photos_mode: photosMode,
date_from: dateFrom, date_from: dateFrom,
date_to: dateTo, date_to: dateTo,