feat: редизайн страницы отчёта ученика — портфолио-стиль, галерея с листанием и видео
- renderStudentReport: детский учебный дизайн (крупные скругления, sticky-навигация, секции-карточки) - журнал занятий в две колонки (одна на мобильных) - единая галерея фото и видео: листание кнопками/стрелками, счётчик - воспроизведение видео прямо в лайтбоксе - кнопка закрытия ✕, закрытие по фону и Esc - модалка экспорта отчёта ученика (период и выбор содержимого)
This commit is contained in:
@@ -47,6 +47,7 @@ function renderStudents() {
|
||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||
<div class="student-actions">
|
||||
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
|
||||
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
||||
</div>
|
||||
@@ -80,6 +81,81 @@ async function saveStudent(id) {
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
let exportPendingName = null;
|
||||
|
||||
function openExportModal(name) {
|
||||
exportPendingName = name;
|
||||
document.getElementById('exportName').textContent = name;
|
||||
document.getElementById('exportForm').reset();
|
||||
document.getElementById('exportDateFrom').value = '';
|
||||
document.getElementById('expEntries').checked = true;
|
||||
document.getElementById('expPhotos').checked = true;
|
||||
document.getElementById('expCaptions').checked = true;
|
||||
document.getElementById('expFiles').checked = true;
|
||||
document.getElementById('expDates').checked = true;
|
||||
document.getElementById('exportModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeExportModal() {
|
||||
document.getElementById('exportModal').classList.remove('open');
|
||||
exportPendingName = null;
|
||||
}
|
||||
|
||||
function onExportPhotosToggle() {
|
||||
const on = document.getElementById('expPhotos').checked;
|
||||
document.getElementById('expCaptions').disabled = !on;
|
||||
}
|
||||
|
||||
async function exportStudent(name, opts) {
|
||||
showToast(`Формируем отчёт: ${name}...`);
|
||||
try {
|
||||
const q = new URLSearchParams({ name });
|
||||
if (opts.dateFrom) q.set('date_from', opts.dateFrom);
|
||||
if (opts.dateTo) q.set('date_to', opts.dateTo);
|
||||
q.set('include_entries', opts.entries ? '1' : '0');
|
||||
q.set('include_photos', opts.photos ? '1' : '0');
|
||||
q.set('include_captions', opts.captions ? '1' : '0');
|
||||
q.set('include_files', opts.files ? '1' : '0');
|
||||
q.set('show_dates', opts.dates ? '1' : '0');
|
||||
const res = await fetch(`${API}/api/export/student?${q}`, { headers: hdr() });
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
alert(e.error || 'Не удалось сформировать отчёт');
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `student_${safeWindowsName(name)}_report.zip`);
|
||||
showToast(`Готово: ${name}`);
|
||||
} catch (err) {
|
||||
alert('Ошибка при формировании отчёта');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExport() {
|
||||
const name = exportPendingName;
|
||||
if (!name) return;
|
||||
const captionsEl = document.getElementById('expCaptions');
|
||||
const opts = {
|
||||
entries: document.getElementById('expEntries').checked,
|
||||
photos: document.getElementById('expPhotos').checked,
|
||||
files: document.getElementById('expFiles').checked,
|
||||
captions: captionsEl.checked && !captionsEl.disabled,
|
||||
dates: document.getElementById('expDates').checked,
|
||||
dateFrom: document.getElementById('exportDateFrom').value,
|
||||
dateTo: document.getElementById('exportDateTo').value,
|
||||
};
|
||||
if (!opts.entries && !opts.photos && !opts.files) {
|
||||
alert('Выберите хотя бы одну категорию: записи, фото или файлы');
|
||||
return;
|
||||
}
|
||||
if (opts.dateFrom && opts.dateTo && opts.dateFrom > opts.dateTo) {
|
||||
alert('Дата «С» позже даты «По»');
|
||||
return;
|
||||
}
|
||||
closeExportModal();
|
||||
await exportStudent(name, opts);
|
||||
}
|
||||
|
||||
async function delStudent(id) {
|
||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
@@ -217,6 +293,14 @@ document.getElementById('attachSearch').addEventListener('input', () => {
|
||||
aSearchTimer = setTimeout(renderAttachList, 300);
|
||||
});
|
||||
|
||||
document.getElementById('exportForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
submitExport();
|
||||
});
|
||||
document.getElementById('exportCancelBtn').addEventListener('click', closeExportModal);
|
||||
document.getElementById('expPhotos').addEventListener('change', onExportPhotosToggle);
|
||||
document.getElementById('exportModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeExportModal(); });
|
||||
|
||||
document.getElementById('addStudentBtn').addEventListener('click', addStudent);
|
||||
document.getElementById('batchAddBtn').addEventListener('click', openBatchAdd);
|
||||
document.getElementById('batchAttachBtn').addEventListener('click', openBatchAttach);
|
||||
@@ -232,6 +316,8 @@ document.getElementById('studentList').addEventListener('click', e => {
|
||||
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
|
||||
const cp = e.target.closest('[data-copy-student]');
|
||||
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
||||
const ex = e.target.closest('[data-export-student]');
|
||||
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
|
||||
const dl = e.target.closest('[data-del-student]');
|
||||
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
||||
});
|
||||
|
||||
@@ -53,6 +53,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="exportModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3>Экспорт отчёта: <span id="exportName" style="color:var(--accent)"></span></h3>
|
||||
<form id="exportForm" class="settings-stack" style="gap:12px">
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Период (необязательно)</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<input type="date" id="exportDateFrom" class="settings-input" style="min-width:0;flex:1">
|
||||
<input type="date" id="exportDateTo" class="settings-input" style="min-width:0;flex:1">
|
||||
</div>
|
||||
<span class="hint">Ограничить записи/фото/файлы датами</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="expEntries" checked>
|
||||
Записи занятий (текст)
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="expPhotos" checked>
|
||||
Фотографии
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="expCaptions" checked>
|
||||
Подписи к фотографиям
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="expFiles" checked>
|
||||
Файлы
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="expDates" checked>
|
||||
Показывать даты
|
||||
</label>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="exportCancelBtn">Отмена</button>
|
||||
<button type="submit" class="btn-primary">Скачать ZIP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="attachModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3>Прикрепить к группе</h3>
|
||||
|
||||
@@ -2163,6 +2163,574 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Student portfolio export (ZIP: HTML report + photos + files) ---
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(d = new Date()) {
|
||||
return {
|
||||
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
||||
date: ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
||||
};
|
||||
}
|
||||
|
||||
function createZipWriter() {
|
||||
const parts = [];
|
||||
const central = [];
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
function buildEntry(nameBuf, method, crc, compressed, plain, dt) {
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(dt.time, 10);
|
||||
local.writeUInt16LE(dt.date, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(compressed, 18);
|
||||
local.writeUInt32LE(plain, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
const cen = Buffer.alloc(46);
|
||||
cen.writeUInt32LE(0x02014b50, 0);
|
||||
cen.writeUInt16LE(20, 4);
|
||||
cen.writeUInt16LE(20, 6);
|
||||
cen.writeUInt16LE(0x0800, 8);
|
||||
cen.writeUInt16LE(method, 10);
|
||||
cen.writeUInt16LE(dt.time, 12);
|
||||
cen.writeUInt16LE(dt.date, 14);
|
||||
cen.writeUInt32LE(crc, 16);
|
||||
cen.writeUInt32LE(compressed, 20);
|
||||
cen.writeUInt32LE(plain, 24);
|
||||
cen.writeUInt16LE(nameBuf.length, 28);
|
||||
cen.writeUInt16LE(0, 30);
|
||||
cen.writeUInt16LE(0, 32);
|
||||
cen.writeUInt16LE(0, 34);
|
||||
cen.writeUInt16LE(0, 36);
|
||||
cen.writeUInt32LE(0, 38);
|
||||
cen.writeUInt32LE(offset, 42);
|
||||
return { local, cen, nameBuf };
|
||||
}
|
||||
return {
|
||||
addFile(name, data, d) {
|
||||
const nameBuf = Buffer.from(name, 'utf8');
|
||||
const dt = dosDateTime(d);
|
||||
const crc = crc32(data);
|
||||
const compressed = zlib.deflateRawSync(data, { level: 9 });
|
||||
const e = buildEntry(nameBuf, 8, crc, compressed.length, data.length, dt);
|
||||
const chunk = Buffer.concat([e.local, e.nameBuf, compressed]);
|
||||
parts.push(chunk);
|
||||
central.push(Buffer.concat([e.cen, e.nameBuf]));
|
||||
offset += chunk.length;
|
||||
count++;
|
||||
},
|
||||
addDir(name) {
|
||||
const nameBuf = Buffer.from(String(name).replace(/\/?$/, '/'), 'utf8');
|
||||
const dt = dosDateTime();
|
||||
const e = buildEntry(nameBuf, 0, 0, 0, 0, dt);
|
||||
const chunk = Buffer.concat([e.local, e.nameBuf]);
|
||||
parts.push(chunk);
|
||||
central.push(Buffer.concat([e.cen, e.nameBuf]));
|
||||
offset += chunk.length;
|
||||
count++;
|
||||
},
|
||||
toBuffer() {
|
||||
const centralStart = offset;
|
||||
const centralBuf = Buffer.concat(central);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(count, 8);
|
||||
eocd.writeUInt16LE(count, 10);
|
||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||
eocd.writeUInt32LE(centralStart, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([...parts, centralBuf, eocd]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fmtLongDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
if (n < 1024) return n + ' Б';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(/\.0$/, '') + ' КБ';
|
||||
return (n / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + ' МБ';
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
const s = String(str || '');
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||
}
|
||||
|
||||
function renderStudentReport(data, opts) {
|
||||
const o = opts || {};
|
||||
const showEntries = o.includeEntries !== false;
|
||||
const showPhotos = o.includePhotos !== false;
|
||||
const showFiles = o.includeFiles !== false;
|
||||
const showCaptions = o.includeCaptions !== false;
|
||||
const showDates = o.showDates !== false;
|
||||
const { name, groups, entries, photos, files, generatedAt, period } = data;
|
||||
const IMG_EXT = new Set(['JPG','JPEG','PNG','GIF','WEBP','BMP','AVIF','SVG','ICO','JFIF']);
|
||||
const VID_EXT = new Set(['MP4','WEBM','MOV','M4V','OGV','MKV','MPEG','MPG','3GP','AVI']);
|
||||
const gallery = [];
|
||||
const galIdx = new Map();
|
||||
for (const p of photos) { gallery.push({ type: 'image', src: 'photos/' + p.stored }); galIdx.set('photos/' + p.stored, gallery.length - 1); }
|
||||
for (const f of files) {
|
||||
const fn = String(f.original || f.saved || '');
|
||||
const ext = fn.indexOf('.') >= 0 ? fn.split('.').pop().toUpperCase() : '';
|
||||
if (VID_EXT.has(ext)) { gallery.push({ type: 'video', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
|
||||
else if (IMG_EXT.has(ext)) { gallery.push({ type: 'image', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
|
||||
}
|
||||
const avatar = showPhotos && photos.length ? photos[0].stored : null;
|
||||
const statsChips = [];
|
||||
if (showEntries) statsChips.push(`<div class="chip"><b>${entries.length}</b><span>занятий</span></div>`);
|
||||
if (showPhotos) statsChips.push(`<div class="chip"><b>${photos.length}</b><span>фотографий</span></div>`);
|
||||
if (showFiles) statsChips.push(`<div class="chip"><b>${files.length}</b><span>файлов</span></div>`);
|
||||
const photoCards = showPhotos ? photos.map(p => {
|
||||
let caption = '';
|
||||
if (showCaptions && p.caption) caption = truncate(p.caption, 120);
|
||||
if (!caption && showDates && !p.caption) caption = fmtLongDate(p.createdAt);
|
||||
const pg = galIdx.get('photos/' + p.stored);
|
||||
return `
|
||||
<figure class="ph" data-g="${pg}">
|
||||
<img src="photos/${escapeHtml(p.stored)}" alt="${escapeHtml(name)} — фото" loading="lazy">
|
||||
${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''}
|
||||
</figure>`;
|
||||
}).join('') : '';
|
||||
const fileRows = showFiles ? files.map(f => {
|
||||
const ext = f.original.indexOf('.') >= 0 ? f.original.split('.').pop().toUpperCase().slice(0, 8) : 'FILE';
|
||||
const meta = showDates ? `${fmtLongDate(f.createdAt)} · ${fmtBytes(f.size)}` : fmtBytes(f.size);
|
||||
const fg = galIdx.get('files/' + f.saved);
|
||||
const gattr = fg !== undefined ? ` class="gfile" data-g="${fg}"` : ' target="_blank" rel="noopener"';
|
||||
return `
|
||||
<li>
|
||||
<a href="files/${escapeHtml(f.saved)}"${gattr}>
|
||||
<span class="badge">${escapeHtml(ext)}</span>
|
||||
<span class="fname">${escapeHtml(f.original)}</span>
|
||||
<span class="fmeta">${escapeHtml(meta)}</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}).join('') : '';
|
||||
const photosByEntry = new Map();
|
||||
for (const p of photos) {
|
||||
if (!photosByEntry.has(p.entryId)) photosByEntry.set(p.entryId, []);
|
||||
photosByEntry.get(p.entryId).push(p.stored);
|
||||
}
|
||||
const lessonRows = showEntries ? entries.map(e => {
|
||||
const thumbs = showPhotos ? (photosByEntry.get(e.id) || []).slice(0, 4).map(t => `
|
||||
<img class="tph" src="photos/${escapeHtml(t)}" alt="фото" data-g="${galIdx.get('photos/' + t)}" loading="lazy">`).join('') : '';
|
||||
const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : [];
|
||||
const fls = entryFiles.length ? `<div class="lfiles">${entryFiles.map(f => `<a href="files/${escapeHtml(f.saved)}" target="_blank" rel="noopener">${escapeHtml(f.original)}</a>`).join('')}</div>` : '';
|
||||
const desc = e.description ? `<p class="ldesc">${escapeHtml(e.description)}</p>` : '';
|
||||
const gr = e.group_name ? `<span class="lgroup">${escapeHtml(e.group_name)}</span>` : '';
|
||||
const dt = showDates && e.created_at ? `<span class="ldate">${escapeHtml(fmtLongDate(e.created_at))}</span>` : '';
|
||||
const head = dt + gr;
|
||||
return `
|
||||
<article class="lesson">
|
||||
${head ? `<div class="lhead">${head}</div>` : ''}
|
||||
${desc}
|
||||
${thumbs ? `<div class="lthumbs">${thumbs}</div>` : ''}
|
||||
${fls}
|
||||
</article>`;
|
||||
}).join('') : '';
|
||||
const groupLine = groups.length ? escapeHtml(groups.join(' · ')) : '';
|
||||
const genLabel = escapeHtml(fmtLongDate(generatedAt));
|
||||
const metaBits = [];
|
||||
if (groupLine) metaBits.push(groupLine);
|
||||
if (period) metaBits.push(escapeHtml(period));
|
||||
metaBits.push(`Сформировано ${genLabel}`);
|
||||
const heroAvatar = avatar ? `<div class="avatar"><img src="photos/${escapeHtml(avatar)}" alt="${escapeHtml(name)}"></div>` : '';
|
||||
const navItems = [];
|
||||
if (showPhotos) navItems.push('<a class="navlink" href="#photos">Фотографии</a>');
|
||||
if (showFiles) navItems.push('<a class="navlink" href="#files">Работы</a>');
|
||||
if (showEntries) navItems.push('<a class="navlink" href="#lessons">Занятия</a>');
|
||||
const nav = navItems.length >= 2 ? '<nav class="nav" id="topNav">' + navItems.join('') + '</nav>' : '';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(name)} — портфолио</title>
|
||||
<style>
|
||||
:root{--cream:#FFF7EC;--ink:#2B2B33;--muted:#98919B;--yellow:#F7C90E;--teal:#14B8A6;--violet:#8B5CF6;--coral:#FF6B6B;--sky:#38BDF8;--line:#FFEAD2}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:"Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--cream);color:var(--ink);line-height:1.55}
|
||||
body::before{content:"";position:fixed;inset:0;z-index:-1;background:radial-gradient(520px 400px at 8% -2%,rgba(247,201,14,.18),transparent 62%),radial-gradient(620px 430px at 100% 3%,rgba(20,184,166,.15),transparent 58%)}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 58px}
|
||||
|
||||
.nav{position:sticky;top:12px;z-index:40;display:flex;justify-content:center;gap:8px;flex-wrap:wrap;background:rgba(255,255,255,.92);border:1px solid #F1E2C6;border-radius:999px;padding:8px 14px;box-shadow:0 12px 26px -16px rgba(20,30,50,.35);margin-top:26px}
|
||||
.nav .navlink{text-decoration:none;color:var(--ink);background:#fff;border:1px solid #F1E2C6;border-radius:999px;padding:8px 18px;font-size:.9rem;font-weight:600;transition:all .15s}
|
||||
.nav .navlink:hover{transform:translateY(-1px)}
|
||||
.nav .navlink.on{background:var(--yellow);color:#231f12;border-color:transparent;box-shadow:0 4px 12px -4px rgba(247,201,14,.5)}
|
||||
|
||||
.hero{overflow:hidden;border-radius:34px;padding:36px 34px;background:linear-gradient(135deg,#FFF6DB,#FFEDBF 52%,#FFE19A);box-shadow:0 24px 50px -24px rgba(247,201,14,.5);position:relative}
|
||||
.hero .deco{position:absolute;border-radius:50%;background:rgba(255,255,255,.55);pointer-events:none}
|
||||
.hero .deco.d1{width:150px;height:150px;right:-40px;top:-46px}
|
||||
.hero .deco.d2{width:80px;height:80px;left:40%;bottom:-30px;background:rgba(20,184,166,.14)}
|
||||
.hero .deco.d3{width:44px;height:44px;left:-14px;top:30%}
|
||||
.avatar-col{position:relative;display:flex;gap:24px;align-items:center}
|
||||
.avatar{flex:0 0 auto;width:104px;height:104px;border-radius:50%;overflow:hidden;border:5px solid #fff;background:#fff;box-shadow:0 10px 24px -6px rgba(0,0,0,.26);transform:rotate(-4deg)}
|
||||
.avatar img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%}
|
||||
.avatar.aemp{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#F7C90E,#FFB34A);font-size:2.4rem;transform:rotate(-4deg)}
|
||||
.kicker{font-size:.82rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#B07A00;margin-bottom:6px}
|
||||
h1{font-size:clamp(1.65rem,4.4vw,2.35rem);font-weight:800;letter-spacing:-.02em;line-height:1.12;margin-bottom:6px}
|
||||
.meta{font-size:.95rem;color:#7A6A33;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
|
||||
.stats{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.chip{background:#fff;border:1px solid #F3DFC0;border-radius:18px;padding:10px 16px;display:flex;flex-direction:column;min-width:92px;box-shadow:0 8px 18px -12px rgba(20,30,50,.35)}
|
||||
.chip b{font-size:1.5rem;line-height:1.05}
|
||||
.chip span{font-size:.78rem;color:var(--muted)}
|
||||
.stats .chip:nth-child(1) b{color:var(--coral)}
|
||||
.stats .chip:nth-child(2) b{color:var(--teal)}
|
||||
.stats .chip:nth-child(3) b{color:var(--violet)}
|
||||
section{background:#fff;border:1px solid var(--line);border-radius:30px;padding:26px 26px 28px;margin-top:26px;box-shadow:0 16px 34px -22px rgba(20,30,50,.4)}
|
||||
.p-sec{border-top:6px solid var(--teal)}
|
||||
.f-sec{border-top:6px solid var(--violet)}
|
||||
.l-sec{border-top:6px solid var(--coral)}
|
||||
h2{font-size:1.25rem;font-weight:800;display:flex;align-items:center;gap:10px;margin-bottom:18px}
|
||||
h2 .sico{width:38px;height:38px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.25rem;flex:0 0 auto}
|
||||
.p-sec h2 .sico{background:#E3FAF7}
|
||||
.f-sec h2 .sico{background:#EFE9FF}
|
||||
.l-sec h2 .sico{background:#FFE9EE}
|
||||
.empty{color:var(--muted);border:2px dashed #F0DDBE;border-radius:20px;padding:24px 18px;text-align:center;font-size:.95rem}
|
||||
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
|
||||
.ph{background:#fff;border-radius:20px;overflow:hidden;cursor:zoom-in;border:3px solid #fff;box-shadow:0 10px 22px -14px rgba(20,30,50,.4);transition:transform .16s ease,box-shadow .16s ease}
|
||||
.ph:hover{transform:translateY(-4px) rotate(-1deg);box-shadow:0 18px 30px -16px rgba(20,30,50,.45)}
|
||||
.ph img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}
|
||||
.ph figcaption{background:rgba(255,247,236,.92);padding:9px 12px;font-size:.82rem;color:#6A6155;line-height:1.35}
|
||||
|
||||
.filelist{list-style:none;display:flex;flex-direction:column;gap:9px}
|
||||
.filelist li a{display:flex;align-items:center;gap:14px;background:#FDFAF4;border:1px solid #F1E2C6;border-radius:18px;padding:12px 16px;text-decoration:none;color:var(--ink);transition:transform .15s,box-shadow .15s}
|
||||
.filelist li a:hover{transform:translateX(4px);box-shadow:0 10px 20px -12px rgba(20,30,50,.35)}
|
||||
.badge{flex:0 0 auto;min-width:52px;text-align:center;font-size:.68rem;font-weight:800;letter-spacing:.04em;padding:6px 9px;border-radius:10px;background:linear-gradient(135deg,#F7C90E,#FFB34A);color:#231f12}
|
||||
.fname{flex:1;min-width:0;font-weight:700;word-break:break-word}
|
||||
.fmeta{flex:0 0 auto;color:var(--muted);font-size:.8rem;white-space:nowrap}
|
||||
.gfile{cursor:pointer}
|
||||
|
||||
.lessons{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}
|
||||
.lesson{background:#FFFCF6;border:1px solid #F3E4CA;border-left:6px solid var(--coral);border-radius:20px;padding:16px 20px}
|
||||
.lhead{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
|
||||
.ldate{background:var(--coral);color:#fff;border-radius:999px;padding:3px 12px;font-size:.74rem;font-weight:700}
|
||||
.lgroup{background:rgba(20,184,166,.14);color:#0C9488;font-weight:700;border-radius:999px;padding:3px 12px;font-size:.74rem}
|
||||
.ldesc{white-space:pre-wrap;color:#3A3733;font-size:.95rem}
|
||||
.lthumbs{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||
.tph{width:80px;height:80px;object-fit:cover;border-radius:14px;cursor:zoom-in;border:2px solid #fff;box-shadow:0 6px 12px -8px rgba(20,30,50,.35)}
|
||||
.lfiles{margin-top:10px;display:flex;flex-wrap:wrap;gap:8px}
|
||||
.lfiles a{font-size:.8rem;color:#0C9488;background:rgba(20,184,166,.1);border-radius:999px;padding:5px 12px;text-decoration:none;word-break:break-word}
|
||||
.lfiles a:hover{background:rgba(20,184,166,.18)}
|
||||
|
||||
footer{margin-top:44px;color:#B9B4BE;font-size:.82rem;text-align:center}
|
||||
.lightbox{position:fixed;inset:0;background:rgba(22,20,40,.9);display:none;flex-direction:column;z-index:60;padding:16px 92px 48px;cursor:zoom-out;overflow:hidden}
|
||||
.lightbox.open{display:flex}
|
||||
.lightbox .lb-media{display:flex;align-items:center;justify-content:center;flex:1;min-height:0}
|
||||
.lightbox .lb-media img,.lightbox .lb-media video{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:14px}
|
||||
.lightbox .lb-media video{background:#000;max-height:calc(100% - 16px)}
|
||||
.lightbox .lb-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:5;width:52px;height:52px;border-radius:50%;border:none;background:rgba(247,201,14,.94);color:#231f12;font-size:1.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 22px -8px rgba(0,0,0,.5)}
|
||||
.lightbox .lb-btn:hover{background:#F7C90E}
|
||||
.lightbox .lb-prev{left:20px}
|
||||
.lightbox .lb-next{right:20px}
|
||||
.lightbox .lb-count{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(22,20,40,.72);color:#fff;border-radius:999px;padding:6px 16px;font-size:.85rem;letter-spacing:.03em}
|
||||
.lightbox .lb-close{position:absolute;top:16px;right:20px;z-index:6;width:44px;height:44px;border-radius:50%;border:none;background:rgba(22,20,40,.6);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 18px -8px rgba(0,0,0,.5)}
|
||||
.lightbox .lb-close:hover{background:rgba(247,201,14,.92);color:#231f12}
|
||||
|
||||
@media print{body::before{display:none}.nav{display:none}.wrap{max-width:none;padding:0;-webkit-print-color-adjust:exact}body{background:#fff}.hero,section{box-shadow:none}section,.lesson,.ph{break-inside:avoid}}
|
||||
@media (max-width:560px){.avatar-col{flex-direction:column;align-items:flex-start;gap:16px}.hero{padding:26px 22px}.grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}.stats{gap:8px}.fmeta{display:none}.chip{min-width:74px;padding:8px 12px}.lessons{grid-template-columns:1fr}.lightbox{padding:8px 6px 44px}.lb-prev{left:6px}.lb-next{right:6px}.lb-btn{width:44px;height:44px;font-size:1.4rem}.lb-close{width:38px;height:38px;top:10px;right:10px;font-size:1.15rem}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="hero">
|
||||
<span class="deco d1"></span>
|
||||
<span class="deco d2"></span>
|
||||
<span class="deco d3"></span>
|
||||
<div class="avatar-col">
|
||||
${heroAvatar || '<div class="avatar aemp"><span>🎨</span></div>'}
|
||||
<div>
|
||||
<p class="kicker">✦ Портфолио ученика</p>
|
||||
<h1>${escapeHtml(name)}</h1>
|
||||
<p class="meta">${metaBits.join(' · ')}</p>
|
||||
<div class="stats">
|
||||
${statsChips.join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
${nav}
|
||||
|
||||
${showPhotos ? `<section id="photos" class="p-sec">
|
||||
<h2><span class="sico">🏞️</span>Фотографии</h2>
|
||||
${photoCards ? `<div class="grid">${photoCards}</div>` : '<p class="empty">🎈 Фотографий пока нет</p>'}
|
||||
</section>` : ''}
|
||||
|
||||
${showFiles ? `<section id="files" class="f-sec">
|
||||
<h2><span class="sico">📁</span>Работы и файлы</h2>
|
||||
${fileRows ? `<ul class="filelist">${fileRows}</ul>` : '<p class="empty">🎈 Файлов пока нет</p>'}
|
||||
</section>` : ''}
|
||||
|
||||
${showEntries ? `<section id="lessons" class="l-sec">
|
||||
<h2><span class="sico">🎒</span>Журнал занятий</h2>
|
||||
${lessonRows ? `<div class="lessons">${lessonRows}</div>` : '<p class="empty">🎈 Записей о занятиях пока нет</p>'}
|
||||
</section>` : ''}
|
||||
|
||||
<footer>Сформировано ${genLabel} · WhatIDo</footer>
|
||||
</div>
|
||||
<div class="lightbox" id="lightbox">
|
||||
<button type="button" class="lb-btn lb-prev" id="lbPrev" aria-label="Назад">❮</button>
|
||||
<div class="lb-media" id="lbMedia"></div>
|
||||
<button type="button" class="lb-btn lb-next" id="lbNext" aria-label="Вперёд">❯</button>
|
||||
<button type="button" class="lb-close" id="lbClose" aria-label="Закрыть">✕</button>
|
||||
<div class="lb-count" id="lbCount"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var GAL = ${JSON.stringify(gallery)};
|
||||
var box = document.getElementById('lightbox');
|
||||
var media = document.getElementById('lbMedia');
|
||||
var count = document.getElementById('lbCount');
|
||||
var current = 0;
|
||||
function isVid(m) { return m && m.type === 'video'; }
|
||||
function render(i) {
|
||||
if (!GAL.length) return;
|
||||
var m = GAL[i];
|
||||
current = i;
|
||||
count.textContent = (i + 1) + ' / ' + GAL.length;
|
||||
media.innerHTML = '';
|
||||
if (isVid(m)) {
|
||||
var v = document.createElement('video');
|
||||
v.src = m.src;
|
||||
v.controls = true;
|
||||
v.autoplay = true;
|
||||
media.appendChild(v);
|
||||
} else {
|
||||
var im = document.createElement('img');
|
||||
im.src = m.src;
|
||||
im.alt = '';
|
||||
media.appendChild(im);
|
||||
}
|
||||
}
|
||||
function open(i) { if (!GAL.length) return; render(i); box.classList.add('open'); }
|
||||
function close() { box.classList.remove('open'); media.innerHTML = ''; }
|
||||
function step(d) { if (!GAL.length) return; render((current + d + GAL.length) % GAL.length); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-g]');
|
||||
if (t) { e.preventDefault(); var gi = parseInt(t.getAttribute('data-g'), 10); if (!isNaN(gi) && gi >= 0 && GAL[gi]) open(gi); return; }
|
||||
if (e.target === box) close();
|
||||
});
|
||||
document.getElementById('lbPrev').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(-1); });
|
||||
document.getElementById('lbNext').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(1); });
|
||||
document.getElementById('lbClose').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); close(); });
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!box.classList.contains('open')) return;
|
||||
if (e.key === 'Escape') close();
|
||||
else if (e.key === 'ArrowRight') step(1);
|
||||
else if (e.key === 'ArrowLeft') step(-1);
|
||||
});
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll('.navlink'));
|
||||
function spy() {
|
||||
if (!links.length) return;
|
||||
var hit = null;
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
var el = document.getElementById(links[i].getAttribute('href').slice(1));
|
||||
if (el && el.getBoundingClientRect().top <= 140) hit = links[i];
|
||||
}
|
||||
for (var j = 0; j < links.length; j++) {
|
||||
if (links[j] === hit) links[j].classList.add('on');
|
||||
else links[j].classList.remove('on');
|
||||
}
|
||||
}
|
||||
if (links.length) window.addEventListener('scroll', spy, { passive: true });
|
||||
spy();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
let name;
|
||||
try {
|
||||
name = reqStr(req.query.name, 150);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Укажите имя ученика' });
|
||||
}
|
||||
const opts = {
|
||||
includeEntries: req.query.include_entries !== '0',
|
||||
includePhotos: req.query.include_photos !== '0',
|
||||
includeFiles: req.query.include_files !== '0',
|
||||
includeCaptions: req.query.include_captions !== '0',
|
||||
showDates: req.query.show_dates !== '0',
|
||||
};
|
||||
if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
|
||||
return res.status(400).json({ error: 'Выберите, что включать в отчёт' });
|
||||
}
|
||||
let dateFrom = null;
|
||||
let dateTo = null;
|
||||
try {
|
||||
if (req.query.date_from) dateFrom = optDate(req.query.date_from);
|
||||
if (req.query.date_to) dateTo = optDate(req.query.date_to);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Неверный период' });
|
||||
}
|
||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||
return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
|
||||
}
|
||||
const hasPeriod = !!(dateFrom || dateTo);
|
||||
try {
|
||||
const conds = ['e.student_name = $1', 'e.deleted_at IS NULL'];
|
||||
const params = [name];
|
||||
const bw = branchWhere(req.user, 'g');
|
||||
if (dateFrom) {
|
||||
params.push(dateFrom);
|
||||
conds.push(`e.created_at >= $${params.length}::date`);
|
||||
}
|
||||
if (dateTo) {
|
||||
params.push(dateTo);
|
||||
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
|
||||
}
|
||||
if (bw.params.length) {
|
||||
const start = params.length + 1;
|
||||
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
|
||||
params.push(...bw.params);
|
||||
}
|
||||
const condStr = conds.join(' AND ');
|
||||
const whereStr = ' WHERE ' + condStr;
|
||||
const [studRes, entriesRes, photosRes, mainsRes, filesRes] = await Promise.all([
|
||||
pool.query(`SELECT s.name, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.name = $1`, [name]),
|
||||
pool.query(`SELECT e.id, e.description, e.created_at, g.name AS group_name
|
||||
FROM entries e JOIN groups g ON g.id = e.group_id${whereStr}
|
||||
ORDER BY e.created_at DESC`, params),
|
||||
pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at
|
||||
FROM entry_photos ep
|
||||
JOIN entries e ON e.id = ep.entry_id
|
||||
JOIN groups g ON g.id = e.group_id${whereStr}
|
||||
ORDER BY e.created_at DESC, ep.sort_order ASC, ep.id ASC`, params),
|
||||
pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id
|
||||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.photo_path IS NOT NULL AND ${condStr}
|
||||
ORDER BY e.created_at DESC`, params),
|
||||
pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at
|
||||
FROM project_files pf
|
||||
JOIN entries e ON e.id = pf.entry_id
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
WHERE ${condStr} AND pf.detached_at IS NULL
|
||||
ORDER BY e.created_at DESC, pf.id DESC`, params),
|
||||
]);
|
||||
const entryRows = entriesRes.rows;
|
||||
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
||||
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
|
||||
}
|
||||
const zip = createZipWriter();
|
||||
if (opts.includePhotos) zip.addDir('photos');
|
||||
if (opts.includeFiles) zip.addDir('files');
|
||||
|
||||
const seenPhotos = new Set();
|
||||
const photosBuilt = [];
|
||||
function addPhoto(p) {
|
||||
if (!isSafeUploadPath(p.photo_path)) return;
|
||||
const stored = p.photo_path.slice('/uploads/'.length);
|
||||
if (seenPhotos.has(stored)) return;
|
||||
seenPhotos.add(stored);
|
||||
const src = path.join(UPLOADS_DIR, stored);
|
||||
if (!fs.existsSync(src)) return;
|
||||
const data = fs.readFileSync(src);
|
||||
zip.addFile('photos/' + stored, data, new Date(p.created_at));
|
||||
photosBuilt.push({ stored, caption: p.caption, createdAt: p.created_at, desc: p.description, entryId: p.entry_id });
|
||||
}
|
||||
if (opts.includePhotos) {
|
||||
for (const p of photosRes.rows) addPhoto(p);
|
||||
for (const m of mainsRes.rows) addPhoto(m);
|
||||
photosBuilt.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
const seenFiles = new Map();
|
||||
const filesBuilt = [];
|
||||
if (opts.includeFiles) {
|
||||
for (const f of filesRes.rows) {
|
||||
if (!isSafeUploadPath(f.path)) continue;
|
||||
const stored = f.path.slice('/uploads/'.length);
|
||||
const src = path.join(UPLOADS_DIR, stored);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
const data = fs.readFileSync(src);
|
||||
let base = String(f.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[.\s]+/, '').slice(0, 120) || 'file';
|
||||
const ext = path.extname(base);
|
||||
const stem = ext ? base.slice(0, -ext.length) : base;
|
||||
let saved = base;
|
||||
let n = 1;
|
||||
while (seenFiles.has(saved)) {
|
||||
n++;
|
||||
saved = `${stem}(${n})${ext}`;
|
||||
}
|
||||
seenFiles.set(saved, true);
|
||||
zip.addFile('files/' + saved, data, new Date(f.created_at));
|
||||
filesBuilt.push({ saved, original: f.name, size: data.length, createdAt: f.created_at, entryId: f.entry_id });
|
||||
}
|
||||
}
|
||||
|
||||
const groupSet = new Set();
|
||||
if (studRes.rows[0]?.group_name) groupSet.add(studRes.rows[0].group_name);
|
||||
for (const e of entryRows) if (e.group_name) groupSet.add(e.group_name);
|
||||
const groups = [...groupSet];
|
||||
|
||||
let period = null;
|
||||
if (hasPeriod) {
|
||||
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
|
||||
}
|
||||
|
||||
const html = renderStudentReport({
|
||||
name,
|
||||
groups,
|
||||
entries: entryRows,
|
||||
photos: photosBuilt,
|
||||
files: filesBuilt,
|
||||
generatedAt: new Date(),
|
||||
period,
|
||||
}, opts);
|
||||
zip.addFile('index.html', Buffer.from(html, 'utf8'));
|
||||
|
||||
const buf = zip.toBuffer();
|
||||
await logAudit(req, 'export.student', {
|
||||
student: name,
|
||||
entries: entryRows.length,
|
||||
photos: photosBuilt.length,
|
||||
files: filesBuilt.length,
|
||||
opts,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
});
|
||||
const safeName = name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'student';
|
||||
const zipDate = new Date().toISOString().slice(0, 10);
|
||||
const zipFname = `student_${safeName}_${zipDate}.zip`;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`student_${name}_${zipDate}.zip`)}`);
|
||||
res.send(buf);
|
||||
} catch (err) {
|
||||
console.error('export student:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Entries ---
|
||||
app.get('/api/entries', requireAuth, async (req, res) => {
|
||||
const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||||
|
||||
Reference in New Issue
Block a user