feat(export): фото ученика и логотип в экспорте профиля
- /api/export/student: выборка student_photos, отдельный блок photos/ и метаданные student_photos в data.json, счётчик в totals - логотип из system_logo кладётся в корень архива и подставляется в шапку - student-report.js: секция «Фотографии ученика», логотип в топбаре, навигация ограничена 5 пунктами, пустые секции (Опыт работы) не рендерятся - главное фото профиля/обложки приоритетно берётся из личных фото ученика
This commit is contained in:
@@ -3992,10 +3992,25 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
|
||||
}
|
||||
const student = studRes.rows[0] || { name };
|
||||
const studentPhotosRes = student.id
|
||||
? await pool.query('SELECT photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC', [student.id])
|
||||
: { rows: [] };
|
||||
const zip = createZipWriter();
|
||||
if (opts.includePhotos) zip.addDir('photos');
|
||||
if (opts.includeFiles) zip.addDir('files');
|
||||
|
||||
let logoFile = null;
|
||||
const logoSetting = await getSetting('system_logo', '');
|
||||
if (isSafeUploadPath(logoSetting)) {
|
||||
const logoKey = logoSetting.slice('/uploads/'.length);
|
||||
const logoData = await storage.getBuffer(logoKey);
|
||||
if (logoData) {
|
||||
const logoExt = (path.extname(logoKey) || '.png').toLowerCase();
|
||||
logoFile = 'logo' + (/^\.[a-z0-9]{1,5}$/.test(logoExt) ? logoExt : '.png');
|
||||
zip.addFile(logoFile, logoData, new Date());
|
||||
}
|
||||
}
|
||||
|
||||
const seenPhotos = new Set();
|
||||
const photosBuilt = [];
|
||||
async function addPhoto(p) {
|
||||
@@ -4026,11 +4041,39 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
for (const p of photosRes.rows) await addPhoto(p);
|
||||
for (const m of mainsRes.rows) await addPhoto(m);
|
||||
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
|
||||
if (avatarStored) {
|
||||
if (!seenPhotos.has(avatarStored)) await addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото резидента', created_at: student.created_at });
|
||||
const idx = photosBuilt.findIndex(p => p.stored === avatarStored);
|
||||
if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]);
|
||||
}
|
||||
|
||||
const studentPhotosBuilt = [];
|
||||
if (opts.includePhotos) {
|
||||
const mainPath = (student.profile && student.profile.photo_path) || student.photo_path || null;
|
||||
for (const sp of studentPhotosRes.rows) {
|
||||
if (!isSafeUploadPath(sp.photo_path)) continue;
|
||||
const stored = sp.photo_path.slice('/uploads/'.length);
|
||||
if (studentPhotosBuilt.some(p => p.stored === stored)) continue;
|
||||
if (!seenPhotos.has(stored)) {
|
||||
const data = await storage.getBuffer(stored);
|
||||
if (!data) continue;
|
||||
zip.addFile('photos/' + stored, data, sp.created_at ? new Date(sp.created_at) : new Date());
|
||||
seenPhotos.add(stored);
|
||||
}
|
||||
studentPhotosBuilt.push({
|
||||
stored,
|
||||
createdAt: sp.created_at || null,
|
||||
main: sp.photo_path === mainPath,
|
||||
});
|
||||
}
|
||||
if (avatarStored && !studentPhotosBuilt.some(p => p.stored === avatarStored)) {
|
||||
if (!seenPhotos.has(avatarStored)) {
|
||||
const data = await storage.getBuffer(avatarStored);
|
||||
if (data) {
|
||||
zip.addFile('photos/' + avatarStored, data, student.created_at ? new Date(student.created_at) : new Date());
|
||||
seenPhotos.add(avatarStored);
|
||||
}
|
||||
}
|
||||
studentPhotosBuilt.unshift({ stored: avatarStored, createdAt: student.created_at || null, main: true });
|
||||
}
|
||||
const mi = studentPhotosBuilt.findIndex(p => p.main);
|
||||
if (mi > 0) studentPhotosBuilt.unshift(studentPhotosBuilt.splice(mi, 1)[0]);
|
||||
}
|
||||
|
||||
const groupPhotosBuilt = [];
|
||||
@@ -4106,9 +4149,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
modules: modulesRes.rows,
|
||||
photos: photosBuilt,
|
||||
groupPhotos: groupPhotosBuilt,
|
||||
studentPhotos: studentPhotosBuilt,
|
||||
files: filesBuilt,
|
||||
generatedAt: new Date(),
|
||||
period,
|
||||
logo: logoFile,
|
||||
};
|
||||
const html = renderStudentReport(reportData, opts);
|
||||
zip.addFile('index.html', Buffer.from(html, 'utf8'));
|
||||
@@ -4117,10 +4162,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
student: reportData.student,
|
||||
profile: student.profile || null,
|
||||
period: period || null,
|
||||
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length },
|
||||
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length, studentPhotos: studentPhotosBuilt.length },
|
||||
options: opts,
|
||||
entries: entryRows.map(e => ({ id: e.id, group: e.group_name, module: e.module_name || null, created_at: e.created_at, ai_status: e.ai_status, description: e.description })),
|
||||
photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })),
|
||||
student_photos: studentPhotosBuilt.map(p => ({ file: 'photos/' + p.stored, main: !!p.main, created_at: p.createdAt })),
|
||||
files: filesBuilt.map(f => ({ file: 'files/' + f.saved, name: f.original, size: f.size, created_at: f.createdAt, entry_id: f.entryId })),
|
||||
};
|
||||
zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8'));
|
||||
|
||||
Reference in New Issue
Block a user