' + escapeHtml(total > 1 ? 'Фото ' + (i + 1) + ' из ' + total : 'Фотография резидента') + '
' + + '' + escapeHtml( + (p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') + + (total > 1 ? ' · ' + v.name : '')) + '
' + + 'diff --git a/server.js b/server.js index de41dfe..80e6ca2 100644 --- a/server.js +++ b/server.js @@ -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')); diff --git a/student-report.js b/student-report.js index 08c000c..4fbcaa2 100644 --- a/student-report.js +++ b/student-report.js @@ -207,6 +207,7 @@ function safeHref(value) { const IMG_EXT = new Set(['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'BMP', 'AVIF', 'SVG', 'ICO', 'JFIF', 'HEIC', 'HEIF']); const VID_EXT = new Set(['MP4', 'WEBM', 'MOV', 'M4V', 'OGV', 'MKV', 'MPEG', 'MPG', '3GP', 'AVI']); +const NAV_LIMIT = 5; const EXT_ICONS = { PDF: 'file-text', DOC: 'file-text', DOCX: 'file-text', TXT: 'file-text', RTF: 'file-text', XLS: 'table-2', XLSX: 'table-2', CSV: 'table-2', PPT: 'presentation', PPTX: 'presentation', @@ -294,6 +295,18 @@ function buildProfileView(data, opts) { }; }); + const studentPhotoCards = showPhotos ? arr(data.studentPhotos).map(sp => { + const src = 'photos/' + sp.stored; + const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото ученика'); + return { + src, + g, + date: showDates ? longDate(sp.createdAt) : '', + main: !!sp.main, + student: true, + }; + }) : []; + const photoByEntry = new Map(); for (const p of photos) { if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []); @@ -465,18 +478,20 @@ function buildProfileView(data, opts) { if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.'); const tagList = arr(profile.tags).map(t => str(t, 40)).filter(Boolean); const heroTags = tagList.length ? tagList : education.slice(0, 6).map(m => m.title); - const avatar = showPhotos && photoCards.length ? (photoCards[1] || photoCards[0]) : null; - const cover = showPhotos && photoCards.length ? photoCards[0] : null; + const mainStudentCard = studentPhotoCards.find(p => p.main) || studentPhotoCards[0] || null; + const avatar = showPhotos ? (photoCards[1] || photoCards[0] || mainStudentCard) : null; + const cover = showPhotos ? (photoCards[0] || mainStudentCard) : null; const mainPhotoCard = showPhotos && groupPhotoCards.length ? groupPhotoCards[0] : null; - const profilePhotoCard = showPhotos && photoCards.length ? photoCards[0] : null; + const profilePhotoCard = showPhotos ? (mainStudentCard || photoCards[0] || null) : null; const eduYears = education.map(m => m.period).filter(Boolean); const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null; + const logo = /^[a-zA-Z0-9._-]+\.[a-zA-Z0-9]{1,8}$/.test(String(data.logo || '')) ? String(data.logo) : null; return { - name, student, profile, entries, education, projects, photoCards, groupPhotoCards, tasks, gallery, groups, stats, + name, student, profile, entries, education, projects, photoCards, groupPhotoCards, studentPhotoCards, tasks, gallery, groups, stats, skillGroups, experience, contacts, achievements, facts, heroTags, - avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, - role: str(profile.role, 200) || (education.length ? 'Резидент программы: ' + education[0].title : null), + avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, logo, + role: str(profile.role, 200), bio: str(profile.bio, 2000) || (autoBio.join(' ') || null), quote: str(profile.quote, 300), status: str(profile.status, 60) || 'Активно учится', @@ -519,9 +534,10 @@ svg{display:block;flex:none} .topbar{position:sticky;top:0;z-index:60;background:var(--topbar-bg);border-bottom:1px solid var(--stroke);backdrop-filter:blur(20px) saturate(160%);-webkit-backdrop-filter:blur(20px) saturate(160%)} .topbar__inner{max-width:var(--max);margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:14px} @media (max-width:640px){.topbar__inner{padding:10px 14px}} -.brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em} -.brand__logo{width:44px;height:44px;border-radius:14px;display:grid;place-items:center;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;box-shadow:0 10px 24px rgba(109,140,255,.35)} -.brand__logo svg{width:22px;height:22px;stroke-width:2.2} +.brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em;min-width:0} +.brand__logo{height:44px;flex:0 1 auto;min-width:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;box-shadow:0 10px 24px rgba(109,140,255,.35);overflow:hidden} +.brand__logo svg{width:22px;height:22px;stroke-width:2.2;flex:none} +.brand__logo img{max-width:min(40vw,240px);max-height:44px;width:auto;height:auto;object-fit:contain;display:block} .brand__text small{display:block;font-size:.72rem;font-weight:600;color:var(--muted);letter-spacing:.06em;text-transform:uppercase} @media (max-width:540px){.brand__text{display:none}} .topbar__nav{display:flex;align-items:center;gap:4px;flex:1;justify-content:center} @@ -612,7 +628,7 @@ svg{display:block;flex:none} .stat__delta{display:inline-flex;align-items:center;gap:5px;font-size:.78rem;font-weight:800;color:var(--ok)} .stat__delta svg{width:14px;height:14px;stroke-width:2.4} .modules{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--gap)} -@media (max-width:720px){.modules{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} +@media (max-width:760px){.modules{grid-template-columns:1fr}} .module{padding:clamp(14px,1.7vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0} .module__head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap} .module__head>div{min-width:0;flex:1 1 55%} @@ -636,7 +652,8 @@ svg{display:block;flex:none} .rail--half>*{flex:0 0 calc(50% - var(--gap)/2)} .rail--third>*{flex:0 0 calc((100% - var(--gap) * 2) / 3)} @media (max-width:1100px){.rail--third>*{flex:0 0 calc(50% - var(--gap)/2)}} -@media (max-width:720px){.rail{gap:12px;padding-bottom:12px}.rail--half>*,.rail--third>*{flex:0 0 calc(50% - 6px)}} +@media (max-width:720px){.rail{gap:12px;padding-bottom:12px}.rail--half>*{flex:0 0 calc(50% - 6px)}} +@media (max-width:720px){#projectsRail>*{flex:0 0 100%}} .rail-controls{display:flex;align-items:center;gap:8px} .rail-progress{height:6px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden;width:min(260px,40vw)} .rail-progress>i{display:block;height:100%;width:20%;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:transform .25s ease,width .25s ease} @@ -660,12 +677,18 @@ svg{display:block;flex:none} .prj-card__stats span{display:inline-flex;align-items:center;gap:5px} .prj-card__stats svg{width:15px;height:15px} .photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px} -@media (max-width:620px){.photos{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}} +@media (max-width:620px){.photos{grid-template-columns:1fr;gap:10px}} .photo-card{overflow:hidden;display:flex;flex-direction:column;min-width:0} .photo-card figcaption{padding:10px 12px;font-size:.8rem;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:2px;min-width:0} .photo-card figcaption b{color:var(--text);font-weight:700;overflow-wrap:anywhere} +.sp-list{display:flex;flex-direction:column;gap:var(--gap)} +.prj-card--row{flex-direction:row;align-items:stretch} +.prj-card--row>.media{flex:0 0 clamp(180px,26%,360px);aspect-ratio:4/3} +.prj-card--row .prj-card__body{flex:1} +@media (max-width:620px){.prj-card--row{flex-direction:column}.prj-card--row>.media{flex:0 0 auto;aspect-ratio:16/9}} +@media print{.sp-list>.prj-card{page-break-inside:avoid}} .skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--gap)} -@media (max-width:720px){.skills{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} +@media (max-width:720px){.skills{grid-template-columns:1fr;gap:12px}} .skill-group{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:14px;min-width:0} .skill-group__head{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1rem} .skill-group__head svg{width:19px;height:19px;color:var(--brand);flex:none} @@ -677,7 +700,7 @@ svg{display:block;flex:none} .skill__val{font-size:.74rem;font-weight:800;color:var(--muted);white-space:nowrap} .tasks{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px} @media (max-width:760px){.tasks{grid-template-columns:1fr}} -.task{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:center;padding:14px 16px;transition:background .2s ease,border-color .2s ease} +.task{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:start;padding:14px 16px;transition:background .2s ease,border-color .2s ease} .task:hover{background:var(--surface-hover);border-color:var(--stroke-strong)} .task__check{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none;background:rgba(52,211,153,.14);border:1px solid rgba(52,211,153,.32);color:var(--ok)} .task__check svg{width:20px;height:20px;stroke-width:2.4} @@ -694,6 +717,7 @@ svg{display:block;flex:none} .task__thumbs{display:flex;gap:6px;flex-wrap:wrap} .task__thumbs img{width:64px;height:64px;object-fit:cover;border-radius:10px;border:1px solid var(--stroke);cursor:zoom-in} @media (max-width:520px){.task{grid-template-columns:auto minmax(0,1fr)}.task__right{grid-column:2;align-items:flex-start}} +@media (max-width:620px){.task__right{grid-column:1 / -1;order:-1;align-items:flex-start}.task__thumbs{display:grid;grid-template-columns:1fr;gap:8px;width:100%}.task__thumbs img{width:100%;height:auto;aspect-ratio:4 / 3;border-radius:12px}} .about{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--gap)} @media (max-width:960px){.about{grid-template-columns:1fr}} .about__bio{padding:clamp(18px,2vw,26px);display:flex;flex-direction:column;gap:14px} @@ -821,6 +845,7 @@ function heroHtml(v) { actions.push(''); const mail = v.contacts.find(c => (c.href || '').toLowerCase().startsWith('mailto:')) || v.contacts.find(c => c.href); if (mail) actions.push('' + iconTag('send') + 'Написать'); + if (v.studentPhotoCards.length) actions.push('' + iconTag('user-round') + 'Фото ученика'); if (v.photoCards.length) actions.push('' + iconTag('images') + 'Фотографии'); const facts = v.facts.length ? v.facts.map(f => '' + iconTag(f.icon) + '' + escapeHtml(f.text) + '').join('') @@ -930,6 +955,30 @@ function groupPhotosHtml(v) { '').join(''); } +function studentPhotosHtml(v) { + if (!v.studentPhotoCards.length) { + return emptyBlock('image', 'Фотографий ученика пока нет', + 'Личные фотографии резидента появятся здесь, как только их добавят в данных профиля.'); + } + const total = v.studentPhotoCards.length; + return '
' + escapeHtml( + (p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') + + (total > 1 ? ' · ' + v.name : '')) + '
' + + '