feat(groups): расширенный экспорт фото — галерея и оригиналы
This commit is contained in:
+10
-6
@@ -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
@@ -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 = 'Не удалось сформировать архив';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user