wip: student profile/report
This commit is contained in:
+8
-1
@@ -29,18 +29,25 @@ CREATE TABLE IF NOT EXISTS modules (
|
|||||||
name VARCHAR(200) NOT NULL UNIQUE,
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
lessons_count INT NOT NULL DEFAULT 0,
|
lessons_count INT NOT NULL DEFAULT 0,
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
photo_path VARCHAR(255),
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS students (
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(150) NOT NULL UNIQUE,
|
name VARCHAR(150) NOT NULL UNIQUE,
|
||||||
group_id INT REFERENCES groups(id),
|
group_id INT REFERENCES groups(id),
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
photo_path VARCHAR(255),
|
||||||
|
profile JSONB
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
student_name VARCHAR(150) NOT NULL,
|
student_name VARCHAR(150) NOT NULL,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS banned_ips (
|
|||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
|
||||||
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -233,5 +235,6 @@ CREATE TABLE IF NOT EXISTS modules (
|
|||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||||
|
|||||||
+96
-7
@@ -2,6 +2,7 @@ let modulesList = [];
|
|||||||
let mPage = 1;
|
let mPage = 1;
|
||||||
const M_PAGE_SIZE = 25;
|
const M_PAGE_SIZE = 25;
|
||||||
let mSearchTimer = null;
|
let mSearchTimer = null;
|
||||||
|
let modulePhotoDeleteFlag = false;
|
||||||
|
|
||||||
function mPlural(n, one, few, many) {
|
function mPlural(n, one, few, many) {
|
||||||
n = Math.abs(n) % 100; const n1 = n % 10;
|
n = Math.abs(n) % 100; const n1 = n % 10;
|
||||||
@@ -33,6 +34,7 @@ function renderModules(total) {
|
|||||||
} else {
|
} else {
|
||||||
list.innerHTML = modulesList.map(m => `
|
list.innerHTML = modulesList.map(m => `
|
||||||
<div class="item module-item${m.is_active ? '' : ' module-off'}" data-id="${m.id}">
|
<div class="item module-item${m.is_active ? '' : ' module-off'}" data-id="${m.id}">
|
||||||
|
${m.photo_path ? `<img class="module-thumb" src="${thumbSrc(m.photo_path)}" alt="" data-img="${esc(API + m.photo_path)}">` : ''}
|
||||||
<div class="module-info">
|
<div class="module-info">
|
||||||
<div class="module-name">${esc(m.name)}${m.is_active ? '' : ' <span class="module-badge">Отключён</span>'}</div>
|
<div class="module-name">${esc(m.name)}${m.is_active ? '' : ' <span class="module-badge">Отключён</span>'}</div>
|
||||||
<div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div>
|
<div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div>
|
||||||
@@ -50,6 +52,26 @@ function renderModules(total) {
|
|||||||
renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
|
renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetModulePhoto(m) {
|
||||||
|
modulePhotoDeleteFlag = false;
|
||||||
|
const fileInput = document.getElementById('modulePhotoFile');
|
||||||
|
const preview = document.getElementById('modulePhotoPreview');
|
||||||
|
const delBtn = document.getElementById('modulePhotoDeleteBtn');
|
||||||
|
const note = document.getElementById('modulePhotoNote');
|
||||||
|
fileInput.value = '';
|
||||||
|
if (m && m.photo_path) {
|
||||||
|
preview.src = thumbSrc(m.photo_path);
|
||||||
|
preview.style.display = 'block';
|
||||||
|
delBtn.style.display = 'inline-block';
|
||||||
|
note.textContent = 'Загрузка новой картинки заменит текущую';
|
||||||
|
} else {
|
||||||
|
preview.removeAttribute('src');
|
||||||
|
preview.style.display = 'none';
|
||||||
|
delBtn.style.display = 'none';
|
||||||
|
note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openModuleModal(id = null) {
|
function openModuleModal(id = null) {
|
||||||
const modal = document.getElementById('moduleModal');
|
const modal = document.getElementById('moduleModal');
|
||||||
const form = document.getElementById('moduleForm');
|
const form = document.getElementById('moduleForm');
|
||||||
@@ -66,14 +88,62 @@ function openModuleModal(id = null) {
|
|||||||
form.name.value = m.name;
|
form.name.value = m.name;
|
||||||
document.getElementById('moduleLessons').value = String(m.lessons_count);
|
document.getElementById('moduleLessons').value = String(m.lessons_count);
|
||||||
document.getElementById('moduleId').value = m.id;
|
document.getElementById('moduleId').value = m.id;
|
||||||
|
resetModulePhoto(m);
|
||||||
} else {
|
} else {
|
||||||
title.textContent = 'Создать модуль';
|
title.textContent = 'Создать модуль';
|
||||||
submitBtn.textContent = 'Создать';
|
submitBtn.textContent = 'Создать';
|
||||||
|
resetModulePhoto(null);
|
||||||
}
|
}
|
||||||
modal.classList.add('open');
|
modal.classList.add('open');
|
||||||
setTimeout(() => form.name.focus(), 100);
|
setTimeout(() => form.name.focus(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewModulePhotoFile() {
|
||||||
|
const file = document.getElementById('modulePhotoFile').files[0];
|
||||||
|
const preview = document.getElementById('modulePhotoPreview');
|
||||||
|
const delBtn = document.getElementById('modulePhotoDeleteBtn');
|
||||||
|
const note = document.getElementById('modulePhotoNote');
|
||||||
|
if (!file) return;
|
||||||
|
modulePhotoDeleteFlag = false;
|
||||||
|
preview.src = URL.createObjectURL(file);
|
||||||
|
preview.style.display = 'block';
|
||||||
|
delBtn.style.display = 'none';
|
||||||
|
note.textContent = 'Новая картинка будет сохранена после нажатия «Сохранить»';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeModulePhoto() {
|
||||||
|
const preview = document.getElementById('modulePhotoPreview');
|
||||||
|
const delBtn = document.getElementById('modulePhotoDeleteBtn');
|
||||||
|
const note = document.getElementById('modulePhotoNote');
|
||||||
|
if (!confirm('Удалить картинку модуля?')) return;
|
||||||
|
modulePhotoDeleteFlag = true;
|
||||||
|
document.getElementById('modulePhotoFile').value = '';
|
||||||
|
preview.removeAttribute('src');
|
||||||
|
preview.style.display = 'none';
|
||||||
|
delBtn.style.display = 'none';
|
||||||
|
note.textContent = 'Картинка будет удалена после нажатия «Сохранить»';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteModulePhotoFile(e) {
|
||||||
|
const modal = document.getElementById('moduleModal');
|
||||||
|
if (!modal.classList.contains('open')) return;
|
||||||
|
const items = e.clipboardData && e.clipboardData.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type && item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
e.preventDefault();
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
const input = document.getElementById('modulePhotoFile');
|
||||||
|
input.files = dt.files;
|
||||||
|
previewModulePhotoFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeModuleModal() {
|
function closeModuleModal() {
|
||||||
document.getElementById('moduleModal').classList.remove('open');
|
document.getElementById('moduleModal').classList.remove('open');
|
||||||
}
|
}
|
||||||
@@ -118,14 +188,30 @@ document.getElementById('moduleForm').addEventListener('submit', async (e) => {
|
|||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
body: JSON.stringify({ name, lessons_count })
|
body: JSON.stringify({ name, lessons_count })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||||||
showToast(id ? 'Модуль обновлён' : 'Модуль создан');
|
const moduleRow = await res.json();
|
||||||
closeModuleModal();
|
const mid = moduleRow.id;
|
||||||
loadModules();
|
const fileInput = document.getElementById('modulePhotoFile');
|
||||||
} else {
|
if (modulePhotoDeleteFlag) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const delRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'DELETE', headers: hdr() });
|
||||||
alert(err.error || 'Ошибка');
|
if (!delRes.ok) {
|
||||||
|
const err = await delRes.json().catch(() => ({}));
|
||||||
|
alert(err.error || 'Ошибка удаления картинки');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (file) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
const upRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'POST', headers: hdr(), body: fd });
|
||||||
|
if (!upRes.ok) {
|
||||||
|
const err = await upRes.json().catch(() => ({}));
|
||||||
|
alert(err.error || 'Ошибка загрузки картинки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(id ? 'Модуль обновлён' : 'Модуль создан');
|
||||||
|
closeModuleModal();
|
||||||
|
loadModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseBatchModules(raw) {
|
function parseBatchModules(raw) {
|
||||||
@@ -208,6 +294,9 @@ document.getElementById('batchModuleModal').addEventListener('click', e => { if
|
|||||||
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
|
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
|
||||||
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
|
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
|
||||||
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
|
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
|
||||||
|
document.getElementById('modulePhotoFile').addEventListener('change', previewModulePhotoFile);
|
||||||
|
document.getElementById('modulePhotoDeleteBtn').addEventListener('click', removeModulePhoto);
|
||||||
|
document.addEventListener('paste', pasteModulePhotoFile);
|
||||||
document.getElementById('moduleSearch').addEventListener('input', () => {
|
document.getElementById('moduleSearch').addEventListener('input', () => {
|
||||||
clearTimeout(mSearchTimer);
|
clearTimeout(mSearchTimer);
|
||||||
mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
|
mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ function renderStudents() {
|
|||||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||||
<div class="student-actions">
|
<div class="student-actions">
|
||||||
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||||
|
<button class="btn-link" data-profile-student="${s.id}" title="Скачать профиль ученика (ZIP: фото и работы)">Профиль</button>
|
||||||
|
<button class="btn-link" data-profile-data="${s.id}" title="Данные профиля: заполняют разделы страницы профиля в архиве">Данные</button>
|
||||||
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</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-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||||
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
||||||
@@ -156,6 +158,125 @@ async function submitExport() {
|
|||||||
await exportStudent(name, opts);
|
await exportStudent(name, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportStudentProfile(id) {
|
||||||
|
const s = allStudents.find(x => x.id === id);
|
||||||
|
if (!s) return;
|
||||||
|
const btn = document.querySelector(`[data-profile-student="${id}"]`);
|
||||||
|
const label = btn ? btn.textContent : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; }
|
||||||
|
showToast(`Формируем профиль: ${s.name}...`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json().catch(() => ({}));
|
||||||
|
showToast(e.error || 'Не удалось сформировать профиль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
|
||||||
|
showToast(`Готово: ${s.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Ошибка при формировании профиля');
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = label; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileDataId = null;
|
||||||
|
|
||||||
|
function openProfileData(id) {
|
||||||
|
profileDataId = id;
|
||||||
|
loadGroupOptions();
|
||||||
|
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
|
||||||
|
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
|
||||||
|
.catch(() => fillProfileForm(null, '', ''));
|
||||||
|
document.getElementById('profileDataModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileData() {
|
||||||
|
document.getElementById('profileDataModal').classList.remove('open');
|
||||||
|
profileDataId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillProfileForm(profile, name, photoPath) {
|
||||||
|
const p = profile || {};
|
||||||
|
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
|
||||||
|
document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика';
|
||||||
|
set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note);
|
||||||
|
set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined);
|
||||||
|
set('pfBio', p.bio); set('pfQuote', p.quote);
|
||||||
|
set('pfTags', (p.tags || []).join(', '));
|
||||||
|
set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n'));
|
||||||
|
set('pfAchievements', (p.achievements || []).join('\n'));
|
||||||
|
set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n'));
|
||||||
|
set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n'));
|
||||||
|
set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n'));
|
||||||
|
set('pfPhoto', photoPath || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProfile() {
|
||||||
|
const val = id => document.getElementById(id).value.trim();
|
||||||
|
const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
const cols = line => line.split('|').map(s => s.trim());
|
||||||
|
const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s));
|
||||||
|
const out = {
|
||||||
|
role: val('pfRole') || null,
|
||||||
|
status: val('pfStatus') || null,
|
||||||
|
status_note: val('pfStatusNote') || null,
|
||||||
|
city: val('pfCity') || null,
|
||||||
|
mentor: val('pfMentor') || null,
|
||||||
|
joined: val('pfJoined') || null,
|
||||||
|
bio: val('pfBio') || null,
|
||||||
|
quote: val('pfQuote') || null,
|
||||||
|
tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
|
contacts: lines('pfContacts').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' };
|
||||||
|
}).filter(c => c.label),
|
||||||
|
achievements: lines('pfAchievements'),
|
||||||
|
skills: lines('pfSkills').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) };
|
||||||
|
}).filter(s => s.name),
|
||||||
|
experience: lines('pfExperience').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return {
|
||||||
|
title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null,
|
||||||
|
badge: c[4] || null, description: c[5] || null,
|
||||||
|
tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
|
};
|
||||||
|
}).filter(e => e.title),
|
||||||
|
education: lines('pfEducation').map(l => {
|
||||||
|
const c = cols(l);
|
||||||
|
return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null };
|
||||||
|
}).filter(m => m.module),
|
||||||
|
};
|
||||||
|
const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k]));
|
||||||
|
return hasData ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfileData(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!profileDataId) return;
|
||||||
|
const body = { profile: collectProfile() };
|
||||||
|
const photoPath = document.getElementById('pfPhoto').value.trim();
|
||||||
|
body.photo_path = photoPath || null;
|
||||||
|
const res = await fetch(`${API}/api/students/${profileDataId}/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
closeProfileData();
|
||||||
|
loadStudents();
|
||||||
|
showToast('Данные профиля сохранены');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
alert(err.error || 'Не удалось сохранить данные профиля');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function delStudent(id) {
|
async function delStudent(id) {
|
||||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
@@ -318,10 +439,18 @@ document.getElementById('studentList').addEventListener('click', e => {
|
|||||||
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
||||||
const ex = e.target.closest('[data-export-student]');
|
const ex = e.target.closest('[data-export-student]');
|
||||||
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
|
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
|
||||||
|
const pf = e.target.closest('[data-profile-student]');
|
||||||
|
if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; }
|
||||||
|
const pd = e.target.closest('[data-profile-data]');
|
||||||
|
if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; }
|
||||||
const dl = e.target.closest('[data-del-student]');
|
const dl = e.target.closest('[data-del-student]');
|
||||||
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
|
||||||
|
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
|
||||||
|
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
|||||||
@@ -43,6 +43,18 @@
|
|||||||
<label>Количество занятий</label>
|
<label>Количество занятий</label>
|
||||||
<input type="number" name="lessons_count" id="moduleLessons" class="settings-input" min="0" max="10000" step="1" value="0">
|
<input type="number" name="lessons_count" id="moduleLessons" class="settings-input" min="0" max="10000" step="1" value="0">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Картинка модуля</label>
|
||||||
|
<div class="module-photo-controls">
|
||||||
|
<img id="modulePhotoPreview" class="module-photo-preview" alt="" style="display:none">
|
||||||
|
<div class="module-photo-actions">
|
||||||
|
<label class="btn-primary ghost module-photo-pick" for="modulePhotoFile">Выбрать файл</label>
|
||||||
|
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
|
||||||
|
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
|
||||||
|
</div>
|
||||||
|
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
<button type="button" class="btn-primary ghost" id="moduleCancelBtn">Отмена</button>
|
<button type="button" class="btn-primary ghost" id="moduleCancelBtn">Отмена</button>
|
||||||
<button type="submit" class="btn-primary" id="moduleSubmitBtn">Создать</button>
|
<button type="submit" class="btn-primary" id="moduleSubmitBtn">Создать</button>
|
||||||
@@ -78,6 +90,7 @@
|
|||||||
.module-item{display:flex;justify-content:space-between;align-items:center;gap:12px}
|
.module-item{display:flex;justify-content:space-between;align-items:center;gap:12px}
|
||||||
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px}
|
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||||
.module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.module-thumb{width:48px;height:48px;border-radius:8px;object-fit:cover;flex-shrink:0}
|
||||||
.module-meta{font-size:.78rem;color:var(--muted)}
|
.module-meta{font-size:.78rem;color:var(--muted)}
|
||||||
.module-item.module-off .module-name{color:var(--muted)}
|
.module-item.module-off .module-name{color:var(--muted)}
|
||||||
.module-badge{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:999px;font-size:.7rem;font-weight:600;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle}
|
.module-badge{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:999px;font-size:.7rem;font-weight:600;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle}
|
||||||
@@ -85,6 +98,13 @@
|
|||||||
.module-item .btn-link.danger:hover{border-color:#ef4444}
|
.module-item .btn-link.danger:hover{border-color:#ef4444}
|
||||||
#batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
|
#batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
|
||||||
#batchModuleModal .hint b{color:var(--text)}
|
#batchModuleModal .hint b{color:var(--text)}
|
||||||
|
.module-photo-controls{display:flex;flex-direction:column;gap:10px;align-items:flex-start;width:100%}
|
||||||
|
.module-photo-preview{width:100%;max-height:180px;object-fit:contain;border:1px solid var(--border);border-radius:10px;background:var(--bg)}
|
||||||
|
.module-photo-actions{display:flex;align-items:center;gap:8px}
|
||||||
|
.module-photo-pick{text-align:center;cursor:pointer;margin:0}
|
||||||
|
.module-photo-actions .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)}
|
||||||
|
.module-photo-actions .btn-link.danger:hover{border-color:#ef4444}
|
||||||
|
#moduleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
|
||||||
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
|
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
|
||||||
</style>
|
</style>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -130,6 +130,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="profileDataModal">
|
||||||
|
<div class="edit-modal" style="max-width:640px">
|
||||||
|
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
|
||||||
|
<form id="profileDataForm" class="settings-stack" style="gap:12px">
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Роль / направление</label>
|
||||||
|
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Статус</label>
|
||||||
|
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Примечание к статусу</label>
|
||||||
|
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Город</label>
|
||||||
|
<input type="text" id="pfCity" class="settings-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Наставник</label>
|
||||||
|
<input type="text" id="pfMentor" class="settings-input">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>В академии с</label>
|
||||||
|
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>О себе</label>
|
||||||
|
<textarea id="pfBio" class="settings-input" style="min-height:90px" placeholder="Короткий рассказ ученика о себе"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Цитата</label>
|
||||||
|
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Теги (через запятую)</label>
|
||||||
|
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
|
||||||
|
<textarea id="pfContacts" class="settings-input" style="min-height:70px" placeholder="mail | maria@example.com | mailto:maria@example.com send | @nickname | https://t.me/nickname"></textarea>
|
||||||
|
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Достижения — по одному в строке</label>
|
||||||
|
<textarea id="pfAchievements" class="settings-input" style="min-height:70px"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
|
||||||
|
<textarea id="pfSkills" class="settings-input" style="min-height:70px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
|
||||||
|
<textarea id="pfExperience" class="settings-input" style="min-height:70px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
|
||||||
|
<textarea id="pfEducation" class="settings-input" style="min-height:70px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full">
|
||||||
|
<label>Фото ученика — путь вида /uploads/имя_файла (необязательно)</label>
|
||||||
|
<input type="text" id="pfPhoto" class="settings-input" placeholder="/uploads/1726740000000-ab12cd34.jpg">
|
||||||
|
<span class="hint">Если не указано, берётся первое фото из записей ученика</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const helmet = require('helmet');
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const heicConvert = require('heic-convert');
|
const heicConvert = require('heic-convert');
|
||||||
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
|
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
|
||||||
|
const { createZipWriter, renderStudentReport } = require('./student-report');
|
||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -615,15 +616,16 @@ async function removeEntryFiles(entryId) {
|
|||||||
async function sweepOrphanedUploads() {
|
async function sweepOrphanedUploads() {
|
||||||
const dir = path.join(__dirname, 'uploads');
|
const dir = path.join(__dirname, 'uploads');
|
||||||
if (!fs.existsSync(dir)) return;
|
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 photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
||||||
pool.query('SELECT path AS p FROM project_files'),
|
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 group_photos'),
|
||||||
pool.query('SELECT photo_path AS p FROM entry_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 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();
|
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)) {
|
for (const f of fs.readdirSync(dir)) {
|
||||||
const fp = path.join(dir, f);
|
const fp = path.join(dir, f);
|
||||||
if (!fs.statSync(fp).isFile()) continue;
|
if (!fs.statSync(fp).isFile()) continue;
|
||||||
@@ -674,6 +676,7 @@ async function ensureModulesTable() {
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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 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('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)');
|
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;
|
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) {
|
function normalizeRestoreData(data) {
|
||||||
const groups = (data.groups || []).map(x => ({
|
const groups = (data.groups || []).map(x => ({
|
||||||
id: reqInt(x.id),
|
id: reqInt(x.id),
|
||||||
@@ -1300,6 +1404,8 @@ function normalizeRestoreData(data) {
|
|||||||
name: reqStr(x.name, 150),
|
name: reqStr(x.name, 150),
|
||||||
created_at: optTs(x.created_at),
|
created_at: optTs(x.created_at),
|
||||||
group_id: optInt(x.group_id, 0, 2147483647),
|
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 => ({
|
const entries = (data.entries || []).map(x => ({
|
||||||
id: reqInt(x.id),
|
id: reqInt(x.id),
|
||||||
@@ -1349,6 +1455,7 @@ function normalizeRestoreData(data) {
|
|||||||
name: reqStr(x.name, 200),
|
name: reqStr(x.name, 200),
|
||||||
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
|
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
|
||||||
is_active: x.is_active !== false,
|
is_active: x.is_active !== false,
|
||||||
|
photo_path: optUploadPath(x.photo_path, 255),
|
||||||
created_at: optTs(x.created_at),
|
created_at: optTs(x.created_at),
|
||||||
}));
|
}));
|
||||||
const entry_photos = (data.entry_photos || []).map(x => ({
|
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) {
|
for (const x of ndata.students) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)',
|
'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.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) {
|
for (const x of ndata.modules) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO modules (id, name, lessons_count, is_active, created_at) VALUES ($1,$2,$3,$4,$5)',
|
'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.created_at]
|
[x.id, x.name, x.lessons_count, x.is_active, x.photo_path, x.created_at]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const x of ndata.entries) {
|
for (const x of ndata.entries) {
|
||||||
@@ -2375,6 +2482,48 @@ app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => {
|
|||||||
res.json(rows[0]);
|
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 ---
|
// --- Students CRUD ---
|
||||||
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
|
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
|
||||||
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Student portfolio export (ZIP: HTML report + photos + files) ---
|
async function studentProfileAccess(user, id) {
|
||||||
const CRC_TABLE = (() => {
|
const { rows } = await pool.query(
|
||||||
const table = new Int32Array(256);
|
'SELECT id, name, group_id, photo_path, profile FROM students WHERE id = $1',
|
||||||
for (let n = 0; n < 256; n++) {
|
[id]
|
||||||
let c = n;
|
);
|
||||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
if (!rows.length) return { found: false };
|
||||||
table[n] = c;
|
const gid = rows[0].group_id;
|
||||||
|
if (user.role !== 'admin' && gid && !(await groupBelongsToBranches(user, gid))) {
|
||||||
|
return { found: false, forbidden: true };
|
||||||
}
|
}
|
||||||
return table;
|
return { found: true, student: rows[0] };
|
||||||
})();
|
|
||||||
|
|
||||||
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()) {
|
app.get('/api/students/:id/profile', requireAuth, async (req, res) => {
|
||||||
return {
|
let id;
|
||||||
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
try {
|
||||||
date: ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
id = reqInt(req.params.id);
|
||||||
};
|
} catch {
|
||||||
}
|
return res.status(400).json({ error: 'Неверный id ученика' });
|
||||||
|
|
||||||
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 {
|
const acc = await studentProfileAccess(req.user, id);
|
||||||
addFile(name, data, d) {
|
if (!acc.found) {
|
||||||
const nameBuf = Buffer.from(name, 'utf8');
|
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||||||
const dt = dosDateTime(d);
|
}
|
||||||
const crc = crc32(data);
|
res.json({ ...acc.student, profile: acc.student.profile || null });
|
||||||
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]);
|
app.put('/api/students/:id/profile', requireAuth, async (req, res) => {
|
||||||
parts.push(chunk);
|
let id;
|
||||||
central.push(Buffer.concat([e.cen, e.nameBuf]));
|
try {
|
||||||
offset += chunk.length;
|
id = reqInt(req.params.id);
|
||||||
count++;
|
} catch {
|
||||||
},
|
return res.status(400).json({ error: 'Неверный id ученика' });
|
||||||
addDir(name) {
|
}
|
||||||
const nameBuf = Buffer.from(String(name).replace(/\/?$/, '/'), 'utf8');
|
let profile;
|
||||||
const dt = dosDateTime();
|
let photoPath;
|
||||||
const e = buildEntry(nameBuf, 0, 0, 0, 0, dt);
|
try {
|
||||||
const chunk = Buffer.concat([e.local, e.nameBuf]);
|
profile = sanitizeStudentProfile(req.body?.profile);
|
||||||
parts.push(chunk);
|
photoPath = req.body?.photo_path === undefined ? undefined : optUploadPath(req.body.photo_path, 255);
|
||||||
central.push(Buffer.concat([e.cen, e.nameBuf]));
|
} catch (e) {
|
||||||
offset += chunk.length;
|
return res.status(400).json({ error: 'Неверные данные профиля: ' + e.message });
|
||||||
count++;
|
}
|
||||||
},
|
const acc = await studentProfileAccess(req.user, id);
|
||||||
toBuffer() {
|
if (!acc.found) {
|
||||||
const centralStart = offset;
|
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||||||
const centralBuf = Buffer.concat(central);
|
}
|
||||||
const eocd = Buffer.alloc(22);
|
const sets = ['profile = $1'];
|
||||||
eocd.writeUInt32LE(0x06054b50, 0);
|
const params = [profile ? JSON.stringify(profile) : null];
|
||||||
eocd.writeUInt16LE(0, 4);
|
if (photoPath !== undefined) {
|
||||||
eocd.writeUInt16LE(0, 6);
|
params.push(photoPath);
|
||||||
eocd.writeUInt16LE(count, 8);
|
sets.push(`photo_path = $${params.length}`);
|
||||||
eocd.writeUInt16LE(count, 10);
|
}
|
||||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
params.push(id);
|
||||||
eocd.writeUInt32LE(centralStart, 16);
|
const { rows } = await pool.query(
|
||||||
eocd.writeUInt16LE(0, 20);
|
`UPDATE students SET ${sets.join(', ')} WHERE id = $${params.length}
|
||||||
return Buffer.concat([...parts, centralBuf, eocd]);
|
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) {
|
function fmtLongDate(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
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">↗</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="Назад">❮</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();
|
|
||||||
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) => {
|
app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||||
let name;
|
let name;
|
||||||
try {
|
try {
|
||||||
@@ -3004,31 +2764,50 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
const condStr = conds.join(' AND ');
|
const condStr = conds.join(' AND ');
|
||||||
const whereStr = ' WHERE ' + condStr;
|
const whereStr = ' WHERE ' + condStr;
|
||||||
const [studRes, entriesRes, photosRes, mainsRes, filesRes] = await Promise.all([
|
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes] = 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(
|
||||||
pool.query(`SELECT e.id, e.description, e.created_at, g.name AS group_name
|
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
|
||||||
FROM entries e JOIN groups g ON g.id = e.group_id${whereStr}
|
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),
|
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
|
FROM entry_photos ep
|
||||||
JOIN entries e ON e.id = ep.entry_id
|
JOIN entries e ON e.id = ep.entry_id
|
||||||
JOIN groups g ON g.id = e.group_id${whereStr}
|
JOIN groups g ON g.id = e.group_id${whereStr}
|
||||||
ORDER BY e.created_at DESC, ep.sort_order ASC, ep.id ASC`, params),
|
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
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
||||||
WHERE e.photo_path IS NOT NULL AND ${condStr}
|
WHERE e.photo_path IS NOT NULL AND ${condStr}
|
||||||
ORDER BY e.created_at DESC`, params),
|
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
|
FROM project_files pf
|
||||||
JOIN entries e ON e.id = pf.entry_id
|
JOIN entries e ON e.id = pf.entry_id
|
||||||
JOIN groups g ON g.id = e.group_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
|
WHERE ${condStr} AND pf.detached_at IS NULL
|
||||||
ORDER BY e.created_at DESC, pf.id DESC`, params),
|
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;
|
const entryRows = entriesRes.rows;
|
||||||
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
||||||
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
|
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
|
||||||
}
|
}
|
||||||
|
const student = studRes.rows[0] || { name };
|
||||||
const zip = createZipWriter();
|
const zip = createZipWriter();
|
||||||
if (opts.includePhotos) zip.addDir('photos');
|
if (opts.includePhotos) zip.addDir('photos');
|
||||||
if (opts.includeFiles) zip.addDir('files');
|
if (opts.includeFiles) zip.addDir('files');
|
||||||
@@ -3042,14 +2821,33 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
seenPhotos.add(stored);
|
seenPhotos.add(stored);
|
||||||
const src = path.join(UPLOADS_DIR, stored);
|
const src = path.join(UPLOADS_DIR, stored);
|
||||||
if (!fs.existsSync(src)) return;
|
if (!fs.existsSync(src)) return;
|
||||||
|
const ts = p.created_at ? new Date(p.created_at) : new Date();
|
||||||
const data = fs.readFileSync(src);
|
const data = fs.readFileSync(src);
|
||||||
zip.addFile('photos/' + stored, data, new Date(p.created_at));
|
zip.addFile('photos/' + stored, data, ts);
|
||||||
photosBuilt.push({ stored, caption: p.caption, createdAt: p.created_at, desc: p.description, entryId: p.entry_id });
|
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) {
|
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 p of photosRes.rows) addPhoto(p);
|
||||||
for (const m of mainsRes.rows) addPhoto(m);
|
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();
|
const seenFiles = new Map();
|
||||||
@@ -3072,30 +2870,56 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
seenFiles.set(saved, true);
|
seenFiles.set(saved, true);
|
||||||
zip.addFile('files/' + saved, data, new Date(f.created_at));
|
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;
|
let period = null;
|
||||||
if (hasPeriod) {
|
if (hasPeriod) {
|
||||||
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
|
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = renderStudentReport({
|
const reportData = {
|
||||||
name,
|
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,
|
entries: entryRows,
|
||||||
|
modules: modulesRes.rows,
|
||||||
photos: photosBuilt,
|
photos: photosBuilt,
|
||||||
files: filesBuilt,
|
files: filesBuilt,
|
||||||
generatedAt: new Date(),
|
generatedAt: new Date(),
|
||||||
period,
|
period,
|
||||||
}, opts);
|
};
|
||||||
|
const html = renderStudentReport(reportData, opts);
|
||||||
zip.addFile('index.html', Buffer.from(html, 'utf8'));
|
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();
|
const buf = zip.toBuffer();
|
||||||
await logAudit(req, 'export.student', {
|
await logAudit(req, 'export.student', {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
# TODO: профиль ученика + ZIP-архив
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
1. На `/students.html` у каждого ученика — кнопка **«Профиль»**, которая одним нажатием
|
||||||
|
отдаёт ZIP-архив со всеми данными ученика (фото, работы/файлы, записи журнала).
|
||||||
|
2. Сгенерированная страница `index.html` внутри архива должна быть оформлена
|
||||||
|
**полностью по дизайну `student-profile.html`** (тёмная/светлая тема, токены,
|
||||||
|
топбар, hero, статистика, образование, опыт, навыки, проекты, уроки, «о себе», футер).
|
||||||
|
3. Если в БД нет данных для заполнения блоков этой страницы — страница не должна
|
||||||
|
ломаться или показывать выдуманные данные: блоки рендерятся из того, что есть,
|
||||||
|
пустые разделы получают аккуратный empty-state, а **данные можно добавить позже**
|
||||||
|
(без правок кода).
|
||||||
|
|
||||||
|
## План работ
|
||||||
|
|
||||||
|
- [x] Разведка: `server.js` (экспорт `/api/export/student`, `createZipWriter`,
|
||||||
|
`renderStudentReport`), `public/students.html` + `public/js/students.js`,
|
||||||
|
`db/init.sql` + `db/migration.sql`, дизайн-макет `student-profile.html`.
|
||||||
|
- [x] `todo.md` (этот файл).
|
||||||
|
- [x] БД: у ученика появляются поля для будущего заполнения профиля —
|
||||||
|
`students.profile JSONB` и `students.photo_path` (`db/init.sql`, `db/migration.sql`).
|
||||||
|
- [x] Бэкап/восстановление: новые поля сохраняются и восстанавливаются
|
||||||
|
(`normalizeRestoreData`, `INSERT INTO students`).
|
||||||
|
- [x] API: `GET/PUT /api/students/:id/profile` (requireAuth + проверка филиала,
|
||||||
|
валидация структуры профиля, аудит `student.profile.update`).
|
||||||
|
- [x] Новый модуль `student-report.js`: ZIP-writer (перенесён из `server.js`) +
|
||||||
|
`renderStudentReport()` — страница профиля ученика по дизайну
|
||||||
|
`student-profile.html` (самодостаточный HTML+CSS+JS, Lucide через CDN с
|
||||||
|
fallback-заглушками, лайтбокс, ленты, тема, печать, анимации).
|
||||||
|
- [x] `server.js`: вызов рендера через модуль, убрать старый рендер из `server.js`,
|
||||||
|
расширить выборки экспорта (модули, статус ИИ, филиал, `students.profile`).
|
||||||
|
- [x] `public/students.html` + `public/js/students.js`: кнопка **«Профиль»**
|
||||||
|
(прямая выгрузка ZIP) и кнопка **«Данные»** (модальное окно заполнения данных
|
||||||
|
профиля: роль, статус, город, наставник, о себе, цитата, теги, контакты,
|
||||||
|
достижения, навыки, опыт, прогресс по модулям).
|
||||||
|
- [x] Архив: `index.html` + `photos/` + `files/` + `data.json` (метаданные профиля).
|
||||||
|
- [x] Проверка: `node --check`, генерация тестового ZIP и разбор `zipfile`
|
||||||
|
(структура + наличие разделов профиля в `index.html`).
|
||||||
|
|
||||||
|
## Решения и допущения
|
||||||
|
|
||||||
|
- «index.html» из задачи — это `index.html` внутри ZIP-архива (страница профиля
|
||||||
|
ученика). Именно он теперь оформлен по `student-profile.html`. Публичный
|
||||||
|
`public/index.html` (форма отправки записи) не переделывался: это отдельная
|
||||||
|
страница, и в задаче про неё данных нет.
|
||||||
|
- Кнопка называется «Профиль» (в задаче опечатка «Профаил»), подпись в подсказке:
|
||||||
|
«Скачать профиль ученика (ZIP: фото и работы)».
|
||||||
|
- Дизайн-макет использует Lucide-иконки через CDN; в архиве оставлен тот же подход,
|
||||||
|
потому что он полностью офлайн-деградирует (класс `no-icons`), как и в макете.
|
||||||
|
- Данные, которых нет в БД (опыт, навыки, контакты, достижения, прогресс по модулям,
|
||||||
|
фото), рендерятся из полей `students.profile`; пока их нет — показывается
|
||||||
|
empty-state с подсказкой, а заполнить их можно в админке («Данные» у ученика).
|
||||||
|
- В `renderStudentReport` добавлен раздел «Фотографии» (лента в стиле макета) —
|
||||||
|
без него фото из архива негде было бы посмотреть; ленты и лайтбокс — в стиле
|
||||||
|
`student-profile.html`.
|
||||||
|
|
||||||
|
## Как применить на существующей базе
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db psql -U app -d whereldo \
|
||||||
|
-c "ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);" \
|
||||||
|
-c "ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;"
|
||||||
|
docker compose up -d --build app
|
||||||
|
```
|
||||||
|
|
||||||
|
(`db/migration.sql` уже содержит эти же ALTER-команды; на чистой базе `init.sql`
|
||||||
|
создаёт поля сразу.)
|
||||||
|
|
||||||
|
## Как проверить
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Админка → «Ученики»: у каждого ученика кнопки «Профиль» и «Данные»
|
||||||
|
# 2. «Данные» → заполнить роль/био/навыки/контакты/достижения/опыт → Сохранить
|
||||||
|
# 3. «Профиль» → скачивается profile_<имя>_<дата>.zip:
|
||||||
|
# index.html (страница профиля по student-profile.html), photos/, files/, data.json
|
||||||
|
```
|
||||||
|
|
||||||
Reference in New Issue
Block a user