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 '
' + v.studentPhotoCards.map((p, i) => + '
' + + mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) + + '
' + + '' + + iconTag(p.main ? 'star' : 'image') + (p.main ? 'Главное фото' : 'Фото ученика') + '' + + '

' + escapeHtml(total > 1 ? 'Фото ' + (i + 1) + ' из ' + total : 'Фотография резидента') + '

' + + '

' + escapeHtml( + (p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') + + (total > 1 ? ' · ' + v.name : '')) + '

' + + '
' + + (p.date ? '' + iconTag('calendar-days') + escapeHtml(p.date) + '' : '') + + (total > 1 ? '' + iconTag('images') + total + ' ' + plural(total, 'фото', 'фото', 'фото') + '' : '') + + '' + + iconTag('maximize-2') + 'Просмотр
' + + '
').join('') + '
'; +} + function tasksHtml(v) { if (!v.tasks.length) { return emptyBlock('list-checks', 'Записей о занятиях пока нет', @@ -968,7 +1017,9 @@ function aboutHtml(v) { : (v.avatar ? '
' + escapeHtml(v.name) + '
' : phBlock('Фото автора', 800, 600, 'image')))) + - '

' + iconTag('info') + ((v.profilePhotoCard || v.mainPhotoCard || v.avatar) ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '

'; + '

' + iconTag('info') + ((v.profilePhotoCard || v.mainPhotoCard || v.avatar) + ? (v.profilePhotoCard && v.profilePhotoCard.student ? 'Личная фотография резидента' : 'Фотография из архива занятий') + : 'Место под реальное фото автора профиля') + '

'; const contactsCard = '

' + iconTag('at-sign') + 'Контакты

' + (v.contacts.length @@ -988,6 +1039,7 @@ function aboutHtml(v) { function footerHtml(v, generatedAt) { const links = []; if (v.entries.length) links.push('Занятия'); + if (v.studentPhotoCards.length) links.push('Фото ученика'); if (v.photoCards.length) links.push('Фотографии'); if (v.groupPhotoCards.length) links.push('Фото группы'); if (v.projects.length) links.push('Работы'); @@ -998,16 +1050,35 @@ function footerHtml(v, generatedAt) { } function navItems(v) { - const items = ['' + iconTag('user-round') + 'Профиль']; - if (v.photoCards.length) items.push('' + iconTag('images') + 'Фотографии'); - if (v.groupPhotoCards.length) items.push('' + iconTag('users-round') + 'Фото группы'); - items.push('' + iconTag('book-open') + 'Образование'); - if (v.experience.length) items.push('' + iconTag('briefcase') + 'Опыт'); - if (v.skillGroups.length) items.push('' + iconTag('sparkles') + 'Навыки'); - if (v.projects.length) items.push('' + iconTag('folder-git-2') + 'Работы'); - if (v.tasks.length) items.push('' + iconTag('list-checks') + 'Занятия'); - items.push('' + iconTag('info') + 'О себе'); - return items.join(''); + const all = [ + { id: 'profile', icon: 'user-round', label: 'Профиль', rank: 0 }, + { id: 'student-photos', icon: 'images', label: 'Фото ученика', rank: 1 }, + { id: 'lessons', icon: 'list-checks', label: 'Занятия', rank: 2 }, + { id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 3 }, + { id: 'education', icon: 'book-open', label: 'Образование', rank: 4 }, + { id: 'about', icon: 'info', label: 'О себе', rank: 5 }, + { id: 'photos', icon: 'images', label: 'Фотографии', rank: 6 }, + { id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 7 }, + { id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 8 }, + { id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 9 }, + ]; + const sections = { + 'profile': true, + 'student-photos': v.studentPhotoCards.length > 0, + 'photos': v.photoCards.length > 0, + 'group-photos': v.groupPhotoCards.length > 0, + 'education': true, + 'experience': v.experience.length > 0, + 'skills': v.skillGroups.length > 0, + 'projects': v.projects.length > 0, + 'lessons': v.tasks.length > 0, + 'about': true, + }; + return all + .filter(i => sections[i.id]) + .slice(0, NAV_LIMIT) + .map(i => '' + iconTag(i.icon) + '' + i.label + '') + .join(''); } function educationHtml(v) { @@ -1146,6 +1217,11 @@ function renderStudentReport(data, opts) { sectionHead('activity', 'Информация об авторе', 'Показатели резидента по данным журнала занятий и архива работ.') + (periodChip ? '
' + periodChip + '
' : '') + '
' + statsHtml(v) + '
'); + sections.push('
' + + sectionHead('user-round', 'Фотографии ученика', v.studentPhotoCards.length > 1 + ? 'Личные фотографии резидента. Нажмите «Просмотр», чтобы открыть снимок крупнее.' + : 'Личная фотография резидента. Нажмите «Просмотр», чтобы открыть её крупнее.') + + studentPhotosHtml(v) + '
'); sections.push('
' + sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') + '
' + photosHtml(v) + '
'); @@ -1155,10 +1231,12 @@ function renderStudentReport(data, opts) { sections.push('
' + sectionHead('book-open', 'Образование', 'Темы программы, которые проходил резидент, и прогресс по ним.') + '
' + educationHtml(v) + '
'); - sections.push('
' + - railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') + - '
' + - experienceHtml(v) + '
'); + if (v.experience.length) { + sections.push('
' + + railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') + + '
' + + experienceHtml(v) + '
'); + } sections.push('
' + sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые резидент уже проходил.') + '
' + skillsHtml(v) + '
'); @@ -1174,7 +1252,8 @@ function renderStudentReport(data, opts) { '
' + aboutHtml(v) + '
'); const topbar = '