wip: student profile/report

This commit is contained in:
dev
2026-09-19 13:06:21 +03:00
parent 72eeb5cf9b
commit 1ef81f9d1c
10 changed files with 3269 additions and 480 deletions
+296 -472
View File
@@ -6,6 +6,7 @@ const helmet = require('helmet');
const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report');
const https = require('https');
const path = require('path');
@@ -615,15 +616,16 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }] = await Promise.all([
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
pool.query('SELECT photo_path AS p FROM entry_photos'),
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
@@ -674,6 +676,7 @@ async function ensureModulesTable() {
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true');
await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255)');
await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL');
await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)');
}
@@ -1285,6 +1288,107 @@ function reqAiStatus(v, fallback) {
return AI_STATUSES.has(s) ? s : fallback;
}
const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i;
const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function profText(v, max) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string') throw new Error('Ожидалась строка');
const s = v.trim();
if (!s) return null;
if (s.length > max) throw new Error('Слишком длинное значение');
return s;
}
function profIcon(v) {
const s = String(v || '').trim().toLowerCase();
return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link';
}
function profHref(v) {
const s = String(v || '').trim();
if (!s || s.length > 500) return null;
return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null;
}
function profList(v, max, fn) {
if (v === null || v === undefined) return [];
if (!Array.isArray(v)) throw new Error('Ожидался список');
const out = [];
for (const item of v.slice(0, max)) {
const row = fn(item);
if (row) out.push(row);
}
return out;
}
function sanitizeStudentProfile(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля');
const out = {
role: profText(raw.role, 200),
status: profText(raw.status, 60),
status_note: profText(raw.status_note, 120),
city: profText(raw.city, 120),
mentor: profText(raw.mentor, 150),
joined: profText(raw.joined, 120),
bio: profText(raw.bio, 2000),
quote: profText(raw.quote, 300),
tags: profList(raw.tags, 20, t => profText(t, 40)),
achievements: profList(raw.achievements, 40, a => profText(a, 200)),
contacts: profList(raw.contacts, 20, c => {
if (!c || typeof c !== 'object') return null;
const label = profText(c.label, 120);
if (!label) return null;
return { icon: profIcon(c.icon), label, href: profHref(c.href) };
}),
skills: profList(raw.skills, 80, s => {
if (!s || typeof s !== 'object') return null;
const name = profText(s.name, 120);
if (!name) return null;
const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100);
return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value };
}),
experience: profList(raw.experience, 30, e => {
if (!e || typeof e !== 'object') return null;
const title = profText(e.title, 160);
if (!title) return null;
return {
title,
company: profText(e.company, 160),
period: profText(e.period, 80),
date: profText(e.date, 40),
badge: profText(e.badge, 40),
description: profText(e.description, 800),
tags: profList(e.tags, 10, t => profText(t, 40)),
};
}),
education: profList(raw.education, 60, m => {
if (!m || typeof m !== 'object') return null;
const module = profText(m.module, 200);
if (!module) return null;
const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100);
return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) };
}),
stats: profList(raw.stats, 12, s => {
if (!s || typeof s !== 'object') return null;
const label = profText(s.label, 80);
const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20);
if (!label || !value) return null;
return {
icon: profIcon(s.icon || 'star'),
value,
suffix: profText(s.suffix, 20),
label,
hint: profText(s.hint, 120),
delta: profText(s.delta, 60),
};
}),
};
const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null));
return hasData ? out : null;
}
function normalizeRestoreData(data) {
const groups = (data.groups || []).map(x => ({
id: reqInt(x.id),
@@ -1300,6 +1404,8 @@ function normalizeRestoreData(data) {
name: reqStr(x.name, 150),
created_at: optTs(x.created_at),
group_id: optInt(x.group_id, 0, 2147483647),
photo_path: optUploadPath(x.photo_path, 255),
profile: sanitizeStudentProfile(x.profile),
}));
const entries = (data.entries || []).map(x => ({
id: reqInt(x.id),
@@ -1349,6 +1455,7 @@ function normalizeRestoreData(data) {
name: reqStr(x.name, 200),
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
is_active: x.is_active !== false,
photo_path: optUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const entry_photos = (data.entry_photos || []).map(x => ({
@@ -1526,14 +1633,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
}
for (const x of ndata.students) {
await client.query(
'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)',
[x.id, x.name, x.created_at, x.group_id]
'INSERT INTO students (id, name, created_at, group_id, photo_path, profile) VALUES ($1,$2,$3,$4,$5,$6)',
[x.id, x.name, x.created_at, x.group_id, x.photo_path, x.profile ? JSON.stringify(x.profile) : null]
);
}
for (const x of ndata.modules) {
await client.query(
'INSERT INTO modules (id, name, lessons_count, is_active, created_at) VALUES ($1,$2,$3,$4,$5)',
[x.id, x.name, x.lessons_count, x.is_active, x.created_at]
'INSERT INTO modules (id, name, lessons_count, is_active, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
[x.id, x.name, x.lessons_count, x.is_active, x.photo_path, x.created_at]
);
}
for (const x of ndata.entries) {
@@ -2375,6 +2482,48 @@ app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => {
res.json(rows[0]);
});
const modulePhotoUpload = upload.single('photo');
app.post('/api/modules/:id/photo', requireAdmin, (req, res) => {
modulePhotoUpload(req, res, async (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
if (err.message === 'Only images') return res.status(400).json({ error: 'Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
return res.status(400).json({ error: 'Недопустимый файл' });
}
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
try {
const mod = await pool.query('SELECT photo_path FROM modules WHERE id = $1', [req.params.id]);
if (!mod.rows.length) {
removeUpload(req.file);
return res.status(404).json({ error: 'Не найдено' });
}
await convertPhoto(req.file);
const oldPath = mod.rows[0].photo_path;
const { rows } = await pool.query(
'UPDATE modules SET photo_path = $1 WHERE id = $2 RETURNING *',
[`/uploads/${req.file.filename}`, req.params.id]
);
if (oldPath) safeUnlink(oldPath);
await logAudit(req, 'module.photo.create', { id: req.params.id, photo_path: rows[0].photo_path });
res.json(rows[0]);
} catch (e) {
removeUpload(req.file);
console.error('POST /api/modules/:id/photo:', e);
res.status(500).json({ error: e.message });
}
});
});
app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
const { rows } = await pool.query('SELECT id, photo_path FROM modules WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
if (rows[0].photo_path) safeUnlink(rows[0].photo_path);
await pool.query('UPDATE modules SET photo_path = NULL WHERE id = $1', [req.params.id]);
await logAudit(req, 'module.photo.delete', { id: req.params.id, photo_path: rows[0].photo_path });
res.json({ ok: true });
});
// --- Students CRUD ---
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
@@ -2499,463 +2648,74 @@ 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;
async function studentProfileAccess(user, id) {
const { rows } = await pool.query(
'SELECT id, name, group_id, photo_path, profile FROM students WHERE id = $1',
[id]
);
if (!rows.length) return { found: false };
const gid = rows[0].group_id;
if (user.role !== 'admin' && gid && !(await groupBelongsToBranches(user, gid))) {
return { found: false, forbidden: true };
}
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;
return { found: true, student: rows[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 };
app.get('/api/students/:id/profile', requireAuth, async (req, res) => {
let id;
try {
id = reqInt(req.params.id);
} catch {
return res.status(400).json({ error: 'Неверный id ученика' });
}
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]);
},
};
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
res.json({ ...acc.student, profile: acc.student.profile || null });
});
app.put('/api/students/:id/profile', requireAuth, async (req, res) => {
let id;
try {
id = reqInt(req.params.id);
} catch {
return res.status(400).json({ error: 'Неверный id ученика' });
}
let profile;
let photoPath;
try {
profile = sanitizeStudentProfile(req.body?.profile);
photoPath = req.body?.photo_path === undefined ? undefined : optUploadPath(req.body.photo_path, 255);
} catch (e) {
return res.status(400).json({ error: 'Неверные данные профиля: ' + e.message });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const sets = ['profile = $1'];
const params = [profile ? JSON.stringify(profile) : null];
if (photoPath !== undefined) {
params.push(photoPath);
sets.push(`photo_path = $${params.length}`);
}
params.push(id);
const { rows } = await pool.query(
`UPDATE students SET ${sets.join(', ')} WHERE id = $${params.length}
RETURNING id, name, group_id, photo_path, profile`,
params
);
await logAudit(req, 'student.profile.update', { id, name: acc.student.name, blocks: profile ? Object.keys(profile) : [] });
invalidateStudents();
res.json(rows[0]);
});
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 plural = (n, one, few, many) => {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
};
const counters = [];
if (showEntries) counters.push(`<div class="counter c1"><b>${entries.length}</b><span>${plural(entries.length, 'занятие', 'занятия', 'занятий')}</span></div>`);
if (showPhotos) counters.push(`<div class="counter c2"><b>${photos.length}</b><span>${plural(photos.length, 'фотография', 'фотографии', 'фотографий')}</span></div>`);
if (showFiles) counters.push(`<div class="counter c3"><b>${files.length}</b><span>${plural(files.length, 'работа', 'работы', 'работ')}</span></div>`);
counters.push(`<div class="counter c4"><b>${groups.length}</b><span>${plural(groups.length, 'группа', 'группы', 'групп')}</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 projectCards = showFiles ? files.map((f, i) => {
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 ? ` data-g="${fg}"` : ' target="_blank" rel="noopener"';
return `
<article class="proj">
<a class="proj-link${fg !== undefined ? ' gfile' : ''}" href="files/${escapeHtml(f.saved)}"${gattr}>
<span class="pnum">${String(i + 1).padStart(2, '0')}</span>
<span class="pbody">
<span class="pname">${escapeHtml(f.original)}</span>
<span class="pmeta"><span class="badge">${escapeHtml(ext)}</span><span class="pdate">${escapeHtml(meta)}</span></span>
</span>
<span class="parrow">&#8599;</span>
</a>
</article>`;
}).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>` : '';
return `
<article class="lesson">
${dt ? `<div class="ldate">${escapeHtml(fmtLongDate(e.created_at))}</div>` : ''}
<div class="lmain">
${gr ? `<span class="lgroup">${escapeHtml(e.group_name)}</span>` : ''}
${desc}
${thumbs ? `<div class="lthumbs">${thumbs}</div>` : ''}
${fls}
</div>
</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 = [];
navItems.push('<a class="navlink" href="#overview">Обзор</a>');
if (showPhotos) navItems.push('<a class="navlink" href="#photos">Фотографии</a>');
if (showFiles) navItems.push('<a class="navlink" href="#works">Работы</a>');
if (showEntries) navItems.push('<a class="navlink" href="#lessons">Занятия</a>');
const nav = navItems.join('');
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{--bg:#0c0c0c;--bg2:#141414;--ink:#ffffff;--muted:#8a8a8a;--line:#232323;--accent:#3290ff}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:Inter,"Inter Tight","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--bg);color:var(--ink);line-height:1.45;-webkit-font-smoothing:antialiased}
.wrap{max-width:1080px;margin:0 auto;padding:0 32px}
.topnav{position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:24px;background:rgba(12,12,12,.85);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:18px 32px}
.topnav .brand{font-size:.8rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;text-decoration:none;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.topnav .brand::before{content:"";display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--accent);margin-right:10px}
.topnav .links{display:flex;gap:26px;flex-wrap:wrap;justify-content:flex-end}
.topnav .navlink{text-decoration:none;color:var(--muted);font-size:.78rem;font-weight:500;letter-spacing:.1em;text-transform:uppercase;position:relative;padding-bottom:4px;transition:color .15s}
.topnav .navlink:hover{color:var(--ink)}
.topnav .navlink.on{color:var(--ink)}
.topnav .navlink.on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--accent)}
.hero{padding:88px 0 72px;border-bottom:1px solid var(--line)}
.hero-head{display:flex;align-items:center;gap:28px}
.avatar{flex:0 0 auto;width:88px;height:88px;border-radius:50%;overflow:hidden;border:1px solid var(--line);background:var(--bg2)}
.avatar img{width:100%;height:100%;object-fit:cover;display:block}
.avatar.aemp{display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;font-size:2rem}
.kicker{font-size:.78rem;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:12px;margin-bottom:14px}
.kicker::before{content:"[";color:var(--accent);font-weight:700}
.kicker::after{content:"]";color:var(--accent);font-weight:700}
h1{font-size:clamp(2.6rem,6.5vw,4.4rem);font-weight:800;letter-spacing:-.04em;line-height:1.02}
.meta{font-size:.92rem;color:var(--muted);margin-top:12px}
.cta-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:36px}
.btn{display:inline-block;border-radius:999px;padding:13px 28px;font-size:.92rem;font-weight:600;letter-spacing:-.01em;text-decoration:none;transition:opacity .15s,background .15s,color .15s;border:1px solid transparent}
.btn-solid{background:var(--accent);color:#fff}
.btn-solid:hover{opacity:.85}
.btn-line{border-color:var(--line);color:var(--ink)}
.btn-line:hover{border-color:#3a3a3a;background:var(--bg2)}
.sec{padding:72px 0;border-bottom:1px solid var(--line)}
.sec-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:36px}
.sec-head h2{font-size:clamp(1.6rem,3.2vw,2.2rem);font-weight:700;letter-spacing:-.03em;line-height:1.05}
.sec-head .count{color:var(--muted);font-size:.9rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.sec-head .count::before{content:"[";color:var(--accent)}
.sec-head .count::after{content:"]";color:var(--accent)}
.note{color:var(--muted);font-size:.95rem}
.counters{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:24px}
.counter{padding:10px 26px;border-left:1px solid var(--line)}
.counter:first-child{border-left:none;padding-left:0}
.counter b{display:block;font-size:clamp(2.8rem,5.5vw,4.2rem);font-weight:800;letter-spacing:-.04em;line-height:1;font-variant-numeric:tabular-nums;color:var(--accent)}
.counter span{display:block;margin-top:12px;font-size:.85rem;color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
.ph{cursor:zoom-in}
.ph img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;border-radius:16px;border:1px solid var(--line);transition:transform .3s}
.ph:hover img{transform:scale(1.02)}
.ph figcaption{padding:10px 2px 0;font-size:.85rem;color:var(--muted)}
.projects{display:flex;flex-direction:column}
.proj{border-top:1px solid var(--line)}
.proj:last-child{border-bottom:1px solid var(--line)}
.proj-link{display:grid;grid-template-columns:80px 1fr auto auto;gap:22px;align-items:center;padding:26px 2px;text-decoration:none;color:var(--ink)}
.pnum{font-size:2rem;font-weight:800;letter-spacing:-.04em;line-height:1;color:#2b2b2b;font-variant-numeric:tabular-nums;transition:color .15s}
.pbody{min-width:0;display:flex;flex-direction:column;gap:6px}
.pname{font-size:1.2rem;font-weight:600;letter-spacing:-.02em;word-break:break-word;transition:color .15s}
.pmeta{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.badge{flex:0 0 auto;font-size:.66rem;font-weight:600;letter-spacing:.08em;border:1px solid var(--line);border-radius:999px;padding:4px 12px;color:var(--muted)}
.pdate{color:var(--muted);font-size:.85rem;white-space:nowrap}
.parrow{color:var(--muted);font-size:1.05rem;transition:transform .15s,color .15s}
.proj-link:hover .pnum{color:var(--accent)}
.proj-link:hover .pname{color:var(--accent)}
.proj-link:hover .parrow{transform:translate(2px,-2px);color:var(--accent)}
.gfile{cursor:pointer}
.lessons{display:flex;flex-direction:column}
.lesson{display:grid;grid-template-columns:150px 1fr;gap:26px;padding:28px 2px;border-top:1px solid var(--line)}
.lesson:last-child{border-bottom:1px solid var(--line)}
.ldate{color:var(--accent);font-size:.85rem;font-weight:600;font-variant-numeric:tabular-nums;padding-top:4px}
.lmain{min-width:0}
.lgroup{display:inline-block;font-size:.7rem;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin-bottom:10px}
.ldesc{white-space:pre-wrap;font-size:1rem;letter-spacing:-.01em;max-width:660px;color:#b5b5b5}
.lthumbs{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
.tph{width:92px;height:92px;object-fit:cover;border-radius:14px;border:1px solid var(--line);cursor:zoom-in;transition:transform .2s}
.tph:hover{transform:scale(1.04)}
.lfiles{margin-top:14px;display:flex;flex-wrap:wrap;gap:10px}
.lfiles a{font-size:.88rem;color:#cfcfcf;text-decoration:none;border:1px solid var(--line);border-radius:12px;padding:8px 14px;word-break:break-word;transition:border-color .15s}
.lfiles a:hover{border-color:var(--accent);color:#fff}
.totop{position:fixed;right:22px;bottom:22px;z-index:45;width:46px;height:46px;border-radius:50%;border:1px solid var(--line);background:var(--bg2);color:var(--ink);font-size:1.1rem;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .2s,background .15s}
.totop.show{opacity:1;pointer-events:auto}
.totop:hover{background:var(--accent);border-color:var(--accent);color:#fff}
footer{padding:36px 0 30px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;color:var(--muted);font-size:.85rem}
.lightbox{position:fixed;inset:0;background:rgba(0,0,0,.94);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:1px solid var(--line);background:var(--bg2);color:#fff;font-size:1.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s}
.lightbox .lb-btn:hover{background:var(--accent);border-color:var(--accent)}
.lightbox .lb-prev{left:20px}
.lightbox .lb-next{right:20px}
.lightbox .lb-count{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(20,20,20,.85);color:#fff;border:1px solid var(--line);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:1px solid var(--line);background:rgba(20,20,20,.8);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s}
.lightbox .lb-close:hover{background:var(--accent);border-color:var(--accent)}
@media print{.topnav{display:none}.cta-row{display:none}.totop{display:none}.wrap{max-width:none;padding:0}body{background:#fff;color:#111}.sec,.proj,.lesson{break-inside:avoid}}
@media (max-width:900px){.lesson{grid-template-columns:1fr;gap:8px;padding:24px 0}.proj-link{grid-template-columns:56px 1fr auto auto;gap:14px}.pdate{display:none}.hero-head{flex-direction:column;align-items:flex-start;gap:18px}.counters{grid-template-columns:repeat(2,1fr)}.counter{padding:14px 0 4px 16px}.counter:nth-child(odd){border-left:none;padding-left:0}.counter b{font-size:2.4rem}}
@media (max-width:560px){.topnav{padding:14px 18px}.topnav .links{gap:14px}.wrap{padding:0 18px}.hero{padding:44px 0 40px}.proj-link{grid-template-columns:44px 1fr auto;padding:18px 0}.pnum{font-size:1.2rem}.pname{font-size:1rem}.grid{grid-template-columns:repeat(auto-fill,minmax(140px,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>
<nav class="topnav" id="topNav">
<a class="brand" href="#top">✦ ${escapeHtml(name)}</a>
<div class="links">${nav}</div>
</nav>
<div class="wrap" id="top">
<header class="hero" id="overview">
<div class="hero-head">
${heroAvatar || '<div class="avatar aemp"><span>🎨</span></div>'}
<div>
<p class="kicker">Портфолио ученика</p>
<h1>${escapeHtml(name)}</h1>
<p class="meta">${metaBits.join(' · ')}</p>
</div>
</div>
<div class="cta-row">
${showFiles ? '<a class="btn btn-solid" href="#works">Смотреть работы</a>' : ''}
${showEntries ? '<a class="btn btn-line" href="#lessons">Хронология занятий</a>' : ''}
</div>
</header>
<section id="overview-stats" class="sec">
<div class="sec-head"><h2>Обзор</h2></div>
<div class="counters">
${counters.join('')}
</div>
</section>
${showPhotos ? `<section id="photos" class="sec">
<div class="sec-head"><h2>Фотографии</h2><span class="count">[${photos.length}]</span></div>
${photoCards ? `<div class="grid">${photoCards}</div>` : '<p class="note">Фотографий пока нет.</p>'}
</section>` : ''}
${showFiles ? `<section id="works" class="sec">
<div class="sec-head"><h2>Проекты и работы</h2><span class="count">[${files.length}]</span></div>
${projectCards ? `<div class="projects">${projectCards}</div>` : '<p class="note">Работ пока нет.</p>'}
</section>` : ''}
${showEntries ? `<section id="lessons" class="sec">
<div class="sec-head"><h2>Журнал занятий</h2><span class="count">[${entries.length}]</span></div>
${lessonRows ? `<div class="lessons">${lessonRows}</div>` : '<p class="note">Записей о занятиях пока нет.</p>'}
</section>` : ''}
<footer>
<span>Сформировано ${genLabel}</span>
<span>WhatIDo · Портфолио</span>
</footer>
</div>
<div class="lightbox" id="lightbox">
<button type="button" class="lb-btn lb-prev" id="lbPrev" aria-label="Назад">&#10094;</button>
<div class="lb-media" id="lbMedia"></div>
<button type="button" class="lb-btn lb-next" id="lbNext" aria-label="Вперёд">&#10095;</button>
<button type="button" class="lb-close" id="lbClose" aria-label="Закрыть">&#10005;</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();
var toTop = document.getElementById('toTop');
function toTopSpy() {
if (window.scrollY > 420) toTop.classList.add('show');
else toTop.classList.remove('show');
}
toTop.addEventListener('click', function () { window.scrollTo({ top: 0, behavior: 'smooth' }); });
window.addEventListener('scroll', toTopSpy, { passive: true });
toTopSpy();
})();
</script>
</body>
</html>`;
}
app.get('/api/export/student', requireAuth, async (req, res) => {
let name;
try {
@@ -3004,31 +2764,50 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
}
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}
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes] = await Promise.all([
pool.query(
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
g.name AS group_name, b.name AS branch_name
FROM students s
LEFT JOIN groups g ON g.id = s.group_id
LEFT JOIN branches b ON b.id = g.branch_id
WHERE s.name = $1`,
[name]
),
pool.query(`SELECT e.id, e.description, e.created_at, e.module_id, e.ai_status, g.name AS group_name, m.name AS module_name
FROM entries e JOIN groups g ON g.id = e.group_id
LEFT JOIN modules m ON m.id = e.module_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
pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at, g.name AS group_name
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
pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id, g.name AS group_name
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
pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at, e.description, g.name AS group_name, m.name AS module_name
FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
JOIN groups g ON g.id = e.group_id
LEFT JOIN modules m ON m.id = e.module_id
WHERE ${condStr} AND pf.detached_at IS NULL
ORDER BY e.created_at DESC, pf.id DESC`, params),
pool.query(`SELECT m.id, m.name, m.lessons_count,
count(e.id)::int AS entries_count,
min(e.created_at) AS first_at, max(e.created_at) AS last_at
FROM entries e
JOIN groups g ON g.id = e.group_id
JOIN modules m ON m.id = e.module_id
WHERE ${condStr}
GROUP BY m.id ORDER BY min(e.created_at)`, params),
]);
const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
}
const student = studRes.rows[0] || { name };
const zip = createZipWriter();
if (opts.includePhotos) zip.addDir('photos');
if (opts.includeFiles) zip.addDir('files');
@@ -3042,14 +2821,33 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
seenPhotos.add(stored);
const src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) return;
const ts = p.created_at ? new Date(p.created_at) : new Date();
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 });
zip.addFile('photos/' + stored, data, ts);
photosBuilt.push({
stored,
caption: p.caption || null,
createdAt: p.created_at || null,
desc: p.description || null,
entryId: p.entry_id || null,
groupName: p.group_name || null,
});
}
let avatarStored = null;
if (opts.includePhotos) {
const profilePhoto = (student.profile && student.profile.photo_path) || student.photo_path;
if (profilePhoto && isSafeUploadPath(profilePhoto)) {
const stored = profilePhoto.slice('/uploads/'.length);
if (fs.existsSync(path.join(UPLOADS_DIR, stored))) avatarStored = stored;
}
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));
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
if (avatarStored) {
if (!seenPhotos.has(avatarStored)) 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 seenFiles = new Map();
@@ -3072,30 +2870,56 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
}
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 });
filesBuilt.push({
saved,
original: f.name,
ext: ext ? ext.slice(1).toUpperCase() : '',
size: data.length,
createdAt: f.created_at,
entryId: f.entry_id,
desc: f.description || null,
moduleName: f.module_name || null,
groupName: f.group_name || null,
});
}
}
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({
const reportData = {
name,
groups,
student: {
id: student.id || null,
name: student.name || name,
created_at: student.created_at || null,
group_name: student.group_name || null,
branch_name: student.branch_name || null,
},
profile: student.profile || null,
entries: entryRows,
modules: modulesRes.rows,
photos: photosBuilt,
files: filesBuilt,
generatedAt: new Date(),
period,
}, opts);
};
const html = renderStudentReport(reportData, opts);
zip.addFile('index.html', Buffer.from(html, 'utf8'));
const meta = {
exported_at: new Date().toISOString(),
student: reportData.student,
profile: student.profile || null,
period: period || null,
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.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 })),
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'));
const buf = zip.toBuffer();
await logAudit(req, 'export.student', {