feat(photos): раздел «Фото» — единая галерея всех загруженных фотографий
Новый read-only раздел для просмотра всех фотографий системы:
GET /api/photos собирает через UNION ALL пять источников — главное фото
записи, фото записи (entry_photos), фотохронику групп, фото учеников и фото
тем модулей. Фильтры search/student_name/group_id/date_from/date_to и
пагинация limit/offset, ответ { photos, total }. Не-admin ограничен
своими филиалами (branch_id), чужой group_id отдаёт 403.
Фронтенд: public/photos.html + public/js/photos.js — сетка превью
(/uploads/thumb/...), бейдж источника, описание, ученик/группа, дата,
ссылка на источник, lightbox по клику, поиск с debounce и пагинация.
Навигация: пункт «Фото» в сайдбаре и плитка в быстрых действиях дашборда.
This commit is contained in:
@@ -4401,6 +4401,89 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/photos', requireAuth, async (req, res) => {
|
||||
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (search) { params.push(`%${search}%`); conditions.push(`t.title ILIKE $${params.length}`); }
|
||||
if (student_name) { params.push(student_name); conditions.push(`t.student_name = $${params.length}`); }
|
||||
if (group_id) { params.push(group_id); conditions.push(`t.group_id = $${params.length}`); }
|
||||
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= $${params.length}::date`); }
|
||||
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||
if (req.user.role !== 'admin') {
|
||||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
const s = branchScope(req.user);
|
||||
if (!s.ids.length) {
|
||||
conditions.push('1 = 0');
|
||||
} else {
|
||||
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
||||
conditions.push(`t.branch_id IN (${ph})`);
|
||||
}
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const from = `FROM (
|
||||
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label,
|
||||
e.photo_path AS path, e.id AS source_id, e.student_name, e.group_id,
|
||||
g.branch_id, g.name AS group_name, e.created_at,
|
||||
e.description AS title
|
||||
FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.deleted_at IS NULL AND e.photo_path IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'entry_photo', 'Фото записи', ep.photo_path, ep.entry_id, e.student_name, e.group_id,
|
||||
g.branch_id, g.name, ep.created_at,
|
||||
COALESCE(NULLIF(ep.caption, ''), e.description)
|
||||
FROM entry_photos ep
|
||||
JOIN entries e ON e.id = ep.entry_id
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT 'group_photo', 'Фотохроника группы', gp.photo_path, gp.group_id, NULL::varchar, gp.group_id,
|
||||
g.branch_id, g.name, gp.created_at,
|
||||
COALESCE(NULLIF(gp.caption, ''), g.name)
|
||||
FROM group_photos gp
|
||||
JOIN groups g ON g.id = gp.group_id
|
||||
UNION ALL
|
||||
SELECT 'student_photo', 'Фото ученика', sp.photo_path, sp.student_id, s.name, s.group_id,
|
||||
g.branch_id, g.name, sp.created_at,
|
||||
s.name
|
||||
FROM student_photos sp
|
||||
JOIN students s ON s.id = sp.student_id
|
||||
LEFT JOIN groups g ON g.id = s.group_id
|
||||
UNION ALL
|
||||
SELECT 'module_photo', 'Тема модуля', m.photo_path, m.id, NULL::varchar, NULL::int,
|
||||
NULL::int, NULL::varchar, m.created_at,
|
||||
m.name
|
||||
FROM modules m
|
||||
WHERE m.photo_path IS NOT NULL
|
||||
) t`;
|
||||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}${where}`, params);
|
||||
const total = crows[0].n;
|
||||
let q = `SELECT t.source_type, t.source_label, t.path, t.source_id, t.student_name,
|
||||
t.group_id, t.group_name, t.created_at, t.title ${from}${where}
|
||||
ORDER BY t.created_at DESC`;
|
||||
const qparams = params.slice();
|
||||
const lim = parseInt(limit, 10);
|
||||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||
const off = parseInt(offset, 10);
|
||||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||||
const { rows } = await pool.query(q, qparams);
|
||||
const photos = rows.map(r => ({
|
||||
source_type: r.source_type,
|
||||
source_label: r.source_label,
|
||||
source_id: r.source_id,
|
||||
path: r.path,
|
||||
title: r.title || '',
|
||||
student_name: r.student_name || null,
|
||||
group_id: r.group_id || null,
|
||||
group_name: r.group_name || null,
|
||||
created_at: r.created_at
|
||||
}));
|
||||
res.json({ photos, total });
|
||||
});
|
||||
|
||||
app.get('/api/stats', requireAuth, async (req, res) => {
|
||||
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
Reference in New Issue
Block a user