feat(students): multi-photo gallery, group photos in export, KIBERone rebrand

This commit is contained in:
dev
2026-09-23 13:32:09 +03:00
parent 5e2533b876
commit 4623358f21
8 changed files with 475 additions and 105 deletions
+9
View File
@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS students (
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 student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
CREATE TABLE IF NOT EXISTS entries (
id SERIAL PRIMARY KEY,
student_name VARCHAR(150) NOT NULL,
+9
View File
@@ -240,3 +240,12 @@ ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT
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;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
+7
View File
@@ -196,6 +196,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.edit-modal .actions button:hover{opacity:.85}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1}
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
.pf-photo.main{border-color:var(--accent)}
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pf-del:hover{background:#ef4444}
/* --- Фото группы (хронология) --- */
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
+118
View File
@@ -183,10 +183,12 @@ async function exportStudentProfile(id) {
}
let profileDataId = null;
let profilePhotos = [];
function openProfileData(id) {
profileDataId = id;
loadGroupOptions();
loadStudentPhotos(id);
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))
@@ -199,6 +201,94 @@ function closeProfileData() {
profileDataId = null;
}
async function loadStudentPhotos(id) {
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
profilePhotos = data.photos || [];
document.getElementById('pfPhoto').value = data.photo_path || '';
renderStudentPhotos();
} catch (err) {
profilePhotos = [];
}
}
function renderStudentPhotos() {
const wrap = document.getElementById('pfPhotos');
if (!wrap) return;
const main = document.getElementById('pfPhoto').value.trim();
if (!profilePhotos.length) {
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
return;
}
wrap.innerHTML = profilePhotos.map(p => `
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
</div>
`).join('');
}
async function uploadStudentPhoto(file) {
if (!profileDataId || !file) return;
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;
}
showToast('Загружаем фото...');
const fd = new FormData();
fd.append('photo', file);
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
if (res.ok) {
await loadStudentPhotos(profileDataId);
showToast('Фото загружено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось загрузить фото');
}
} catch (err) {
alert('Ошибка при загрузке фото');
}
}
async function setMainStudentPhoto(pid) {
if (!profileDataId) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
if (res.ok) {
const r = await res.json();
document.getElementById('pfPhoto').value = r.photo_path || '';
renderStudentPhotos();
showToast('Главное фото обновлено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось выбрать главное фото');
}
} catch (err) {
alert('Ошибка при выборе главного фото');
}
}
async function delStudentPhoto(pid) {
if (!profileDataId) return;
if (!confirm('Удалить это фото?')) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
await loadStudentPhotos(profileDataId);
showToast('Фото удалено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось удалить фото');
}
} catch (err) {
alert('Ошибка при удалении фото');
}
}
function fillProfileForm(profile, name, photoPath) {
const p = profile || {};
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
@@ -449,6 +539,34 @@ document.getElementById('studentList').addEventListener('click', e => {
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
document.getElementById('pfPhotoFile').addEventListener('change', e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (f) uploadStudentPhoto(f);
});
document.getElementById('pfPhotos').addEventListener('click', e => {
const del = e.target.closest('[data-pf-del]');
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
const set = e.target.closest('[data-pf-set]');
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
});
document.addEventListener('paste', e => {
if (!profileDataId) return;
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) {
e.preventDefault();
uploadStudentPhoto(f);
}
return;
}
}
});
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
(async () => {
+47 -48
View File
@@ -131,78 +131,77 @@
</div>
<div class="modal-overlay" id="profileDataModal">
<div class="edit-modal" style="max-width:640px">
<div class="edit-modal" style="max-width:900px;width:96%;max-height:92dvh;overflow-y:auto">
<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>
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
<div class="settings-field">
<label>Роль / направление</label>
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
</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 class="settings-field">
<label>Статус</label>
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
</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 class="settings-field">
<label>Примечание к статусу</label>
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Город</label>
<input type="text" id="pfCity" class="settings-input">
</div>
<div class="settings-field">
<label>Наставник</label>
<input type="text" id="pfMentor" class="settings-input">
</div>
<div class="settings-field">
<label>В академии с</label>
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
</div>
<div class="settings-field" style="grid-column:1/-1">
<label>О себе</label>
<textarea id="pfBio" class="settings-input" style="min-height:90px" placeholder="Короткий рассказ ученика о себе"></textarea>
<textarea id="pfBio" class="settings-input" style="min-height:70px" placeholder="Короткий рассказ ученика о себе"></textarea>
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Цитата</label>
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Теги (через запятую)</label>
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
<textarea id="pfContacts" class="settings-input" style="min-height:70px" placeholder="mail | maria@example.com | mailto:maria@example.com&#10;send | @nickname | https://t.me/nickname"></textarea>
<textarea id="pfContacts" class="settings-input" style="min-height:64px" placeholder="mail | maria@example.com | mailto:maria@example.com&#10;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">
<div class="settings-field">
<label>Достижения — по одному в строке</label>
<textarea id="pfAchievements" class="settings-input" style="min-height:70px"></textarea>
<textarea id="pfAchievements" class="settings-input" style="min-height:64px"></textarea>
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
<textarea id="pfSkills" class="settings-input" style="min-height:70px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
<textarea id="pfSkills" class="settings-input" style="min-height:64px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
<textarea id="pfExperience" class="settings-input" style="min-height:70px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
<textarea id="pfExperience" class="settings-input" style="min-height:64px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
</div>
<div class="settings-field full">
<div class="settings-field">
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
<textarea id="pfEducation" class="settings-input" style="min-height:70px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
<textarea id="pfEducation" class="settings-input" style="min-height:64px" 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 class="settings-field" style="grid-column:1/-1">
<label>Фотографии ученика</label>
<input type="hidden" id="pfPhoto">
<div class="pf-photos" id="pfPhotos"></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px">
<input type="file" id="pfPhotoFile" accept="image/*" hidden>
<button type="button" class="btn-primary ghost" id="pfPhotoUploadBtn">Загрузить фото</button>
<span class="hint">Клик по фото — сделать главным. Можно вставить из буфера обмена (Ctrl+V)</span>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<div style="grid-column:1/-1;display:flex;justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
+192 -9
View File
@@ -616,16 +616,17 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }] = await Promise.all([
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
pool.query('SELECT photo_path AS p FROM entry_photos'),
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
pool.query('SELECT photo_path AS p FROM student_photos'),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
@@ -693,6 +694,16 @@ async function ensureEntryPhotosTable() {
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
}
async function ensureStudentPhotosTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id)');
}
async function ensurePhotoOriginalColumn() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
}
@@ -1475,6 +1486,12 @@ function normalizeRestoreData(data) {
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const student_photos = (data.student_photos || []).map(x => ({
id: reqInt(x.id),
student_id: reqInt(x.student_id),
photo_path: reqUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const group_photos = (data.group_photos || []).map(x => ({
id: reqInt(x.id),
group_id: reqInt(x.group_id),
@@ -1506,13 +1523,13 @@ function normalizeRestoreData(data) {
for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
}
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links, modules };
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules };
}
app.get('/api/backup', requireAdmin, async (req, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md] = await Promise.all([
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
@@ -1524,10 +1541,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
pool.query('SELECT * FROM group_photos ORDER BY id'),
pool.query('SELECT * FROM entry_photos ORDER BY id'),
pool.query('SELECT * FROM modules ORDER BY id'),
pool.query('SELECT * FROM student_photos ORDER BY id'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows };
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
const dir = path.join(__dirname, 'uploads');
@@ -1676,6 +1694,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at]
);
}
for (const x of ndata.student_photos) {
const ex = await client.query('SELECT 1 FROM students WHERE id = $1', [x.student_id]);
if (!ex.rowCount) continue;
await client.query(
'INSERT INTO student_photos (id, student_id, photo_path, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING',
[x.id, x.student_id, x.photo_path, x.created_at]
);
}
for (const x of ndata.users) {
await client.query(
'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
@@ -1694,7 +1720,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[k, String(v ?? '')]
);
}
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules']) {
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos']) {
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
}
@@ -2667,6 +2693,8 @@ app.delete('/api/students/:id', requireAuth, async (req, res) => {
}
}
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
const { rows: spRows } = await pool.query('SELECT photo_path AS p FROM student_photos WHERE student_id = $1', [req.params.id]);
spRows.forEach(r => safeUnlink(r.p));
await logAudit(req, 'student.delete', { id: req.params.id });
invalidateStudents();
invalidateStats();
@@ -2736,6 +2764,133 @@ app.put('/api/students/:id/profile', requireAuth, async (req, res) => {
res.json(rows[0]);
});
const studentPhotoUpload = upload.single('photo');
app.get('/api/students/:id/photos', requireAuth, async (req, res) => {
let id;
try {
id = reqInt(req.params.id);
} catch {
return res.status(400).json({ error: 'Неверный id ученика' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const { rows } = await pool.query(
'SELECT id, photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC',
[id]
);
res.json({ photos: rows, photo_path: acc.student.photo_path || null });
});
app.post('/api/students/:id/photos', requireAuth, (req, res, next) => {
studentPhotoUpload(req, res, (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: 'Недопустимый файл' });
}
next();
});
}, async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
let id;
try {
id = reqInt(req.params.id);
} catch {
removeUpload(req.file);
return res.status(400).json({ error: 'Неверный id ученика' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
removeUpload(req.file);
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
try {
await convertPhoto(req.file);
const photoPath = `/uploads/${req.file.filename}`;
const { rows } = await pool.query(
'INSERT INTO student_photos (student_id, photo_path) VALUES ($1, $2) RETURNING id, photo_path, created_at',
[id, photoPath]
);
if (!acc.student.photo_path) {
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [photoPath, id]);
}
await logAudit(req, 'student.photo.create', { id, photo_path: photoPath });
invalidateStudents();
invalidateShare();
res.status(201).json(rows[0]);
} catch (e) {
safeUnlink(`uploads/${req.file.filename}`);
console.error('POST /api/students/:id/photos:', e);
res.status(500).json({ error: e.message });
}
});
app.delete('/api/students/:id/photos/:pid', requireAuth, async (req, res) => {
let id, pid;
try {
id = reqInt(req.params.id);
pid = reqInt(req.params.pid);
} catch {
return res.status(400).json({ error: 'Неверный id' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const { rows } = await pool.query(
'SELECT id, photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
[pid, id]
);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
const { rows: rest } = await pool.query(
'SELECT photo_path FROM student_photos WHERE student_id = $1 AND id <> $2 ORDER BY id DESC LIMIT 1',
[id, pid]
);
try {
await pool.query('DELETE FROM student_photos WHERE id = $1', [pid]);
if (acc.student.photo_path === rows[0].photo_path) {
const next = rest.length ? rest[0].photo_path : null;
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [next, id]);
}
safeUnlink(rows[0].photo_path);
await logAudit(req, 'student.photo.delete', { id, photo_path: rows[0].photo_path });
invalidateStudents();
invalidateShare();
res.json({ ok: true });
} catch (e) {
console.error('DELETE /api/students/:id/photos/:pid:', e);
res.status(500).json({ error: e.message });
}
});
app.put('/api/students/:id/photos/:pid/main', requireAuth, async (req, res) => {
let id, pid;
try {
id = reqInt(req.params.id);
pid = reqInt(req.params.pid);
} catch {
return res.status(400).json({ error: 'Неверный id' });
}
const acc = await studentProfileAccess(req.user, id);
if (!acc.found) {
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
}
const { rows } = await pool.query(
'SELECT photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
[pid, id]
);
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, id]);
await logAudit(req, 'student.photo.main', { id, photo_path: rows[0].photo_path });
invalidateStudents();
invalidateShare();
res.json({ ok: true, photo_path: rows[0].photo_path });
});
function fmtLongDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
@@ -2789,7 +2944,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
}
const condStr = conds.join(' AND ');
const whereStr = ' WHERE ' + condStr;
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes] = await Promise.all([
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
pool.query(
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
g.name AS group_name, b.name AS branch_name
@@ -2827,6 +2982,11 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
JOIN modules m ON m.id = e.module_id
WHERE ${condStr}
GROUP BY m.id ORDER BY min(e.created_at)`, params),
pool.query(`SELECT gp.photo_path, gp.caption, gp.taken_at, gp.created_at, g.name AS group_name
FROM group_photos gp
JOIN groups g ON g.id = gp.group_id
WHERE gp.group_id = (SELECT group_id FROM students WHERE name = $1)
ORDER BY gp.sort_order ASC, gp.created_at DESC`, [name]),
]);
const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
@@ -2869,12 +3029,33 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
for (const m of mainsRes.rows) addPhoto(m);
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 });
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 groupPhotosBuilt = [];
if (opts.includePhotos) {
for (const gp of groupPhotosRes.rows) {
if (!isSafeUploadPath(gp.photo_path)) continue;
const stored = gp.photo_path.slice('/uploads/'.length);
if (seenPhotos.has(stored)) continue;
const src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) continue;
const ts = gp.taken_at || gp.created_at || new Date();
zip.addFile('photos/' + stored, fs.readFileSync(src), new Date(ts));
seenPhotos.add(stored);
groupPhotosBuilt.push({
stored,
caption: gp.caption || null,
takenAt: gp.taken_at || null,
createdAt: gp.created_at || null,
groupName: gp.group_name || null,
});
}
}
const seenFiles = new Map();
const filesBuilt = [];
if (opts.includeFiles) {
@@ -2927,6 +3108,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
entries: entryRows,
modules: modulesRes.rows,
photos: photosBuilt,
groupPhotos: groupPhotosBuilt,
files: filesBuilt,
generatedAt: new Date(),
period,
@@ -2938,7 +3120,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
student: reportData.student,
profile: student.profile || null,
period: period || null,
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length },
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.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 })),
@@ -4669,6 +4851,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensureStudentPhotosTable(); } catch (err) { console.error('Student photos table:', err); }
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
+4 -3
View File
@@ -290,16 +290,17 @@ svg{display:block;flex:none}
6. HERO: фото + имя (ProfileHero)
========================================================================== */
.hero{padding:clamp(20px,2.6vw,34px);overflow:hidden;position:relative}
.hero__cover{border-radius:var(--radius-sm);margin-bottom:clamp(-72px,-5vw,-48px)}
.hero__cover{border-radius:var(--radius-sm);margin-bottom:clamp(24px,3vw,40px);position:relative;z-index:0}
.hero__grid{
display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);
gap:clamp(18px,2.6vw,34px);align-items:end;
position:relative;z-index:1;
}
@media (max-width:900px){
.hero__grid{grid-template-columns:1fr;align-items:start}
.hero__cover{display:none}
}
.hero__media{position:relative;padding-top:clamp(10px,2vw,26px)}
.hero__media{position:relative;margin-top:clamp(-72px,-5vw,-48px);padding-top:clamp(10px,2vw,26px)}
.hero__avatar{border-radius:26px;box-shadow:var(--shadow)}
.hero__status{
position:absolute;left:14px;bottom:14px;padding:7px 12px;border-radius:999px;
@@ -444,7 +445,7 @@ svg{display:block;flex:none}
}
.task__check svg{width:20px;height:20px;stroke-width:2.4}
.task__check--progress{background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.32);color:var(--warn)}
.task__main{min-width:0}
.task__main{min-width:0;width:100%}
.task__title{font-size:.95rem;font-weight:800;overflow-wrap:anywhere}
.task__sub{font-size:.79rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere}
.task__right{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
+89 -45
View File
@@ -248,7 +248,7 @@ function buildProfileView(data, opts) {
const dbModules = arr(data.modules);
const photos = arr(data.photos);
const files = arr(data.files);
const name = str(data.name, 150) || str(student.name, 150) || 'Ученик';
const name = str(data.name, 150) || str(student.name, 150) || 'Резидент';
const profContacts = arr(profile.contacts);
const profAchievements = arr(profile.achievements);
@@ -281,6 +281,19 @@ function buildProfileView(data, opts) {
}
}
const groupPhotoCards = arr(data.groupPhotos).map(gp => {
const src = 'photos/' + gp.stored;
const g = galleryAdd('image', src, gp.caption || '');
const when = gp.takenAt || gp.createdAt;
return {
src,
g,
caption: showCaptions ? (str(gp.caption, 160) || '') : '',
date: showDates && when ? longDate(when) : '',
group: str(gp.groupName, 80) || '',
};
});
const photoByEntry = new Map();
for (const p of photos) {
if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []);
@@ -375,12 +388,12 @@ function buildProfileView(data, opts) {
});
const stats = [];
if (showEntries) stats.push({ icon: 'clipboard-list', value: entries.length, suffix: plural(entries.length, 'запись', 'записи', 'записей'), label: 'Записей в журнале', hint: 'что делал ученик на занятиях' });
if (showEntries) stats.push({ icon: 'clipboard-list', value: entries.length, suffix: plural(entries.length, 'запись', 'записи', 'записей'), label: 'Записей в журнале', hint: 'что делал резидент на занятиях' });
if (showPhotos) stats.push({ icon: 'image', value: photos.length, suffix: plural(photos.length, 'фото', 'фото', 'фото'), label: 'Фотографий', hint: 'снимки с занятий' });
if (showFiles) stats.push({ icon: 'folder-git-2', value: files.length, suffix: plural(files.length, 'работа', 'работы', 'работ'), label: 'Работ в портфолио', hint: 'файлы, прикреплённые к занятиям' });
stats.push({ icon: 'users-round', value: groups.length, suffix: plural(groups.length, 'группа', 'группы', 'групп'), label: 'Групп', hint: groups.join(' · ') || '—' });
stats.push({ icon: 'book-open-check', value: education.length, suffix: plural(education.length, 'тема', 'темы', 'тем'), label: 'Тем программы', hint: 'модули, которые проходил ученик' });
if (days && showEntries) stats.push({ icon: 'calendar-clock', value: days, suffix: plural(days, 'день', 'дня', 'дней'), label: 'В академии', hint: 'от первой записи в журнале' });
stats.push({ icon: 'book-open-check', value: education.length, suffix: plural(education.length, 'тема', 'темы', 'тем'), label: 'Тем программы', hint: 'модули, которые проходил резидент' });
if (days && showEntries) stats.push({ icon: 'calendar-clock', value: days, suffix: plural(days, 'день', 'дня', 'дней'), label: 'В KIBERone', hint: 'от первой записи в журнале' });
for (const s of arr(profile.stats)) {
const label = str(s && s.label, 80);
const value = (s && s.value !== undefined && s.value !== null) ? String(s.value).trim().slice(0, 20) : '';
@@ -439,29 +452,31 @@ function buildProfileView(data, opts) {
const city = str(profile.city, 120) || str(student.branch_name, 120);
if (city) facts.push({ icon: 'map-pin', text: city });
if (groups.length) facts.push({ icon: 'users-round', text: groups.join(' · ') });
const joined = str(profile.joined, 120) || (student.created_at ? 'В академии с ' + monthYear(student.created_at) : null);
const joined = str(profile.joined, 120) || (student.created_at ? 'В KIBERone с ' + monthYear(student.created_at) : null);
if (joined) facts.push({ icon: 'calendar-check', text: joined });
const mentor = str(profile.mentor, 150);
if (mentor) facts.push({ icon: 'user-check', text: 'Наставник: ' + mentor });
if (education.length) facts.push({ icon: 'book-open', text: education.length + ' ' + plural(education.length, 'тема', 'темы', 'тем') + ' программы' });
const autoBio = [];
if (groups.length) autoBio.push('Ученик группы ' + groups.join(', ') + '.');
if (groups.length) autoBio.push('Резидент группы ' + groups.join(', ') + '.');
if (entries.length) autoBio.push('В журнале ' + entries.length + ' ' + plural(entries.length, 'запись', 'записи', 'записей') + ' о занятиях.');
if (photos.length) autoBio.push('Фотографий: ' + photos.length + '.');
if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.');
const tagList = arr(profile.tags).map(t => str(t, 40)).filter(Boolean);
const heroTags = tagList.length ? tagList : education.slice(0, 6).map(m => m.title);
const avatar = showPhotos && photoCards.length ? photoCards[0] : null;
const cover = showPhotos && photoCards.length > 1 ? photoCards[1] : null;
const avatar = showPhotos && photoCards.length ? (photoCards[1] || photoCards[0]) : null;
const cover = showPhotos && photoCards.length ? photoCards[0] : null;
const mainPhotoCard = showPhotos && groupPhotoCards.length ? groupPhotoCards[0] : null;
const profilePhotoCard = showPhotos && photoCards.length ? photoCards[0] : null;
const eduYears = education.map(m => m.period).filter(Boolean);
const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null;
return {
name, student, profile, entries, education, projects, photoCards, tasks, gallery, groups, stats,
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, tasks, gallery, groups, stats,
skillGroups, experience, contacts, achievements, facts, heroTags,
avatar, cover, idLabel,
role: str(profile.role, 200) || (education.length ? 'Ученик программы: ' + education[0].title : null),
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard,
role: str(profile.role, 200) || (education.length ? 'Резидент программы: ' + education[0].title : null),
bio: str(profile.bio, 2000) || (autoBio.join(' ') || null),
quote: str(profile.quote, 300),
status: str(profile.status, 60) || 'Активно учится',
@@ -563,10 +578,11 @@ svg{display:block;flex:none}
.media--wide{aspect-ratio:21/9}
.media[data-g]{cursor:zoom-in}
.hero{padding:clamp(20px,2.6vw,34px);overflow:hidden;position:relative}
.hero__cover{border-radius:var(--radius-sm);margin-bottom:clamp(-72px,-5vw,-48px)}
.hero__grid{display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);gap:clamp(18px,2.6vw,34px);align-items:end}
.hero__cover{border-radius:var(--radius-sm);margin-bottom:clamp(24px,3vw,40px);position:relative;z-index:0}
.hero__cover .media{border-radius:var(--radius-sm)}
.hero__grid{display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);gap:clamp(18px,2.6vw,34px);align-items:end;position:relative;z-index:1}
@media (max-width:900px){.hero__grid{grid-template-columns:1fr;align-items:start}.hero__cover{display:none}}
.hero__media{position:relative;padding-top:clamp(10px,2vw,26px)}
.hero__media{position:relative;margin-top:clamp(-72px,-5vw,-48px);padding-top:clamp(10px,2vw,26px)}
.hero__avatar{border-radius:26px;overflow:hidden;box-shadow:var(--shadow)}
.hero__status{position:absolute;left:14px;bottom:14px;padding:7px 12px;border-radius:999px;background:rgba(6,10,24,.6);backdrop-filter:blur(8px);border:1px solid var(--stroke);color:#eaf0ff;font-size:.78rem;font-weight:800;display:inline-flex;align-items:center;gap:7px}
.hero__pulse{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 rgba(52,211,153,.7);animation:pulse 2s infinite}
@@ -667,11 +683,11 @@ svg{display:block;flex:none}
.task__check svg{width:20px;height:20px;stroke-width:2.4}
.task__check--progress{background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.32);color:var(--warn)}
.task__check--error{background:rgba(251,113,133,.14);border-color:rgba(251,113,133,.32);color:var(--danger)}
.task__main{min-width:0;display:flex;flex-direction:column;gap:6px}
.task__main{min-width:0;width:100%;display:flex;flex-direction:column;gap:6px}
.task__title{font-size:.95rem;font-weight:800;overflow-wrap:anywhere}
.task__sub{font-size:.79rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere}
.task__right{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
.task__extra{display:flex;flex-wrap:wrap;gap:6px}
.task__extra{grid-column:1 / -1;display:flex;flex-wrap:wrap;gap:6px}
.task__extra a{display:inline-flex;align-items:center;gap:5px;font-size:.75rem;font-weight:700;color:var(--muted);border:1px solid var(--stroke);border-radius:999px;padding:3px 9px;background:var(--surface-2)}
.task__extra a:hover{color:var(--text);border-color:var(--stroke-strong)}
.task__extra a svg{width:12px;height:12px}
@@ -790,12 +806,16 @@ function emptyBlock(icon, title, text) {
}
function heroHtml(v) {
const cover = v.cover
? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g)
: phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)');
const avatar = v.avatar
? mediaBlock(v.avatar.src, 'square', v.name, v.avatar.g)
: phBlock('Фото ученика', 480, 480, 'user-round');
const cover = v.mainPhotoCard
? mediaBlock(v.mainPhotoCard.src, 'wide', 'Обложка профиля', v.mainPhotoCard.g)
: (v.cover
? mediaBlock(v.cover.src, 'wide', 'Обложка профиля', v.cover.g)
: phBlock('Обложка профиля', 1440, 480, 'wallpaper', 'border-radius:var(--radius-sm)'));
const avatar = v.profilePhotoCard
? mediaBlock(v.profilePhotoCard.src, 'square', v.name, v.profilePhotoCard.g)
: (v.avatar
? mediaBlock(v.avatar.src, 'square', v.name, v.avatar.g)
: phBlock('Фото резидента', 480, 480, 'user-round'));
const eyebrow = [v.idLabel, str(v.periodLabel, 60)].filter(Boolean).join(' · ');
const actions = [];
actions.push('<button class="btn btn--primary" type="button" data-action="print">' + iconTag('printer') + 'Печать / PDF</button>');
@@ -842,7 +862,7 @@ function statsHtml(v) {
function skillsHtml(v) {
if (!v.skillGroups.length) {
return emptyBlock('sparkles', 'Навыки пока не заполнены',
'Список навыков и технологий можно добавить в данных профиля ученика.');
'Список навыков и технологий можно добавить в данных профиля резидента.');
}
return v.skillGroups.map(g => '<article class="card skill-group reveal">' +
'<div class="skill-group__head">' + iconTag(g.icon) + '<span>' + escapeHtml(g.title) + '</span>' +
@@ -865,7 +885,7 @@ function projectMedia(p) {
function projectsHtml(v) {
if (!v.projects.length) {
return emptyBlock('folder-git-2', 'Работ в портфолио пока нет',
'Файлы появятся здесь, как только ученик прикрепит их к записи занятия.');
'Файлы появятся здесь, как только резидент прикрепит их к записи занятия.');
}
return v.projects.map(p => '<article class="card prj-card">' +
projectMedia(p) +
@@ -885,7 +905,7 @@ function projectsHtml(v) {
function photosHtml(v) {
if (!v.photoCards.length) {
return emptyBlock('image', 'Фотографий пока нет',
'Фотографии занятий появятся здесь, как только ученик отправит их вместе с записью.');
'Фотографии занятий появятся здесь, как только резидент отправит их вместе с записью.');
}
return v.photoCards.map(p => '<figure class="card photo-card reveal">' +
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
@@ -896,22 +916,37 @@ function photosHtml(v) {
'</figure>').join('');
}
function groupPhotosHtml(v) {
if (!v.groupPhotoCards.length) {
return emptyBlock('images', 'Фото группы пока нет',
'Фотографии группы появятся здесь, как только их добавят в галерее группы.');
}
return v.groupPhotoCards.map(p => '<figure class="card photo-card reveal">' +
mediaBlock(p.src, '4x3', (p.group || 'Группа') + ' — фото', p.g) +
(p.caption || p.date || p.group
? '<figcaption>' + (p.caption ? '<b>' + escapeHtml(p.caption) + '</b>' : '') +
(p.date || p.group ? '<span>' + escapeHtml([p.date, p.group].filter(Boolean).join(' · ')) + '</span>' : '') + '</figcaption>'
: '') +
'</figure>').join('');
}
function tasksHtml(v) {
if (!v.tasks.length) {
return emptyBlock('list-checks', 'Записей о занятиях пока нет',
'Как только ученик отправит запись через форму, она появится в этом разделе.');
'Как только резидент отправит запись через форму, она появится в этом разделе.');
}
return v.tasks.map(t => {
const checkCls = t.done ? '' : (t.icon === 'triangle-alert' ? ' task__check--error' : ' task__check--progress');
const right = ['<span class="badge ' + (t.done ? 'badge--ok' : (t.icon === 'triangle-alert' ? 'badge--muted' : 'badge--progress')) + '">' + escapeHtml(t.statusLabel) + '</span>'];
if (t.thumbs.length) right.push('<div class="task__thumbs">' + t.thumbs.map(x => '<img src="' + escapeHtml(x.src) + '" alt="фото" data-g="' + x.g + '" loading="lazy">').join('') + '</div>');
if (t.files.length) right.push('<div class="task__extra">' + t.files.map(f => '<a href="' + escapeHtml(f.href) + '"' +
(f.g >= 0 ? ' data-g="' + f.g + '"' : ' target="_blank" rel="noopener"') + '>' + iconTag(f.icon) + escapeHtml(truncate(f.title, 28)) + '</a>').join('') + '</div>');
if (t.files.length) var extra = '<div class="task__extra">' + t.files.map(f => '<a href="' + escapeHtml(f.href) + '"' +
(f.g >= 0 ? ' data-g="' + f.g + '"' : ' target="_blank" rel="noopener"') + '>' + iconTag(f.icon) + escapeHtml(truncate(f.title, 28)) + '</a>').join('') + '</div>';
return '<article class="card task reveal">' +
'<span class="task__check' + checkCls + '">' + iconTag(t.icon) + '</span>' +
'<div class="task__main"><p class="task__title">' + escapeHtml(t.title) + '</p>' +
(t.sub ? '<p class="task__sub">' + escapeHtml(t.sub) + '</p>' : '') + '</div>' +
'<div class="task__right">' + right.join('') + '</div>' +
(extra || '') +
'</article>';
}).join('');
}
@@ -920,16 +955,20 @@ function aboutHtml(v) {
const bio = '<article class="card about__bio reveal">' +
'<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' +
(v.bio ? '<p class="about__text">' + escapeHtml(v.bio) + '</p>'
: '<p class="about__text">Здесь появится рассказ ученика о себе. Добавьте текст в данных профиля.</p>') +
: '<p class="about__text">Здесь появится рассказ резидента о себе. Добавьте текст в данных профиля.</p>') +
(v.quote ? '<p class="about__quote">«' + escapeHtml(v.quote) + '»</p>' : '') +
(v.facts.length ? '<div class="hero__facts">' + v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('') + '</div>' : '') +
'</article>';
const photoCard = '<article class="card about__card reveal"><h3>' + iconTag('image') + 'Фото автора</h3>' +
(v.avatar
? '<div class="about__photo"><img src="' + escapeHtml(v.avatar.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.avatar.g + '" loading="lazy"></div>'
: phBlock('Фото автора', 800, 600, 'image')) +
'<p class="meta">' + iconTag('info') + (v.avatar ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '</p></article>';
(v.profilePhotoCard
? '<div class="about__photo"><img src="' + escapeHtml(v.profilePhotoCard.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.profilePhotoCard.g + '" loading="lazy"></div>'
: (v.mainPhotoCard
? '<div class="about__photo"><img src="' + escapeHtml(v.mainPhotoCard.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.mainPhotoCard.g + '" loading="lazy"></div>'
: (v.avatar
? '<div class="about__photo"><img src="' + escapeHtml(v.avatar.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.avatar.g + '" loading="lazy"></div>'
: phBlock('Фото автора', 800, 600, 'image')))) +
'<p class="meta">' + iconTag('info') + ((v.profilePhotoCard || v.mainPhotoCard || v.avatar) ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '</p></article>';
const contactsCard = '<article class="card about__card reveal"><h3>' + iconTag('at-sign') + 'Контакты</h3>' +
(v.contacts.length
@@ -950,9 +989,10 @@ function footerHtml(v, generatedAt) {
const links = [];
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
if (v.photoCards.length) links.push('<a href="#photos">Фотографии</a>');
if (v.groupPhotoCards.length) links.push('<a href="#group-photos">Фото группы</a>');
if (v.projects.length) links.push('<a href="#projects">Работы</a>');
links.push('<a href="#top">Наверх</a>');
return '<footer class="footer"><p>© <span id="year"></span> Академия · профиль ученика ' + escapeHtml(v.name) +
return '<footer class="footer"><p>© <span id="year"></span> KIBERone · профиль резидента ' + escapeHtml(v.name) +
' · сформировано ' + escapeHtml(longDate(generatedAt)) + '</p>' +
'<nav class="footer__links" aria-label="Футер">' + links.join('') + '</nav></footer>';
}
@@ -960,6 +1000,7 @@ function footerHtml(v, generatedAt) {
function navItems(v) {
const items = ['<a class="nav-link" href="#profile" data-spy="profile">' + iconTag('user-round') + '<span>Профиль</span></a>'];
if (v.photoCards.length) items.push('<a class="nav-link" href="#photos" data-spy="photos">' + iconTag('images') + '<span>Фотографии</span></a>');
if (v.groupPhotoCards.length) items.push('<a class="nav-link" href="#group-photos" data-spy="group-photos">' + iconTag('users-round') + '<span>Фото группы</span></a>');
items.push('<a class="nav-link" href="#education" data-spy="education">' + iconTag('book-open') + '<span>Образование</span></a>');
if (v.experience.length) items.push('<a class="nav-link" href="#experience" data-spy="experience">' + iconTag('briefcase') + '<span>Опыт</span></a>');
if (v.skillGroups.length) items.push('<a class="nav-link" href="#skills" data-spy="skills">' + iconTag('sparkles') + '<span>Навыки</span></a>');
@@ -972,7 +1013,7 @@ function navItems(v) {
function educationHtml(v) {
if (!v.education.length) {
return emptyBlock('book-open', 'Темы программы ещё не заполнены',
'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля ученика.');
'Добавьте темы модулей в админке (раздел «Темы модулей») или отметьте пройденные модули в данных профиля резидента.');
}
return v.education.map(m => {
const p = m.progress;
@@ -1003,7 +1044,7 @@ function educationHtml(v) {
function experienceHtml(v) {
if (!v.experience.length) {
return emptyBlock('briefcase', 'Опыт работы пока не заполнен',
'Кейсы, стажировки и командные проекты можно добавить в данных профиля ученика — они появятся в этом архиве.');
'Кейсы, стажировки и командные проекты можно добавить в данных профиля резидента — они появятся в этом архиве.');
}
return v.experience.map(e => {
const media = e.media ? mediaBlock(e.media, '16x9', e.title, -1) : phBlock('Фото кейса', 1280, 720, 'image');
@@ -1102,41 +1143,44 @@ function renderStudentReport(data, opts) {
const sections = [];
sections.push('<section class="section" id="profile">' + heroHtml(v) + '</section>');
sections.push('<section class="section" id="stats">' +
sectionHead('activity', 'Информация об авторе', 'Показатели ученика по данным журнала занятий и архива работ.') +
sectionHead('activity', 'Информация об авторе', 'Показатели резидента по данным журнала занятий и архива работ.') +
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
'<div class="stats">' + statsHtml(v) + '</div></section>');
sections.push('<section class="section" id="photos">' +
sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') +
'<div class="photos">' + photosHtml(v) + '</div></section>');
sections.push('<section class="section" id="group-photos">' +
sectionHead('users-round', 'Фотографии группы', 'Снимки из хроники группы резидента. Нажмите на фотографию, чтобы открыть её крупнее.') +
'<div class="photos">' + groupPhotosHtml(v) + '</div></section>');
sections.push('<section class="section" id="education">' +
sectionHead('book-open', 'Образование', 'Темы программы, которые проходил ученик, и прогресс по ним.') +
sectionHead('book-open', 'Образование', 'Темы программы, которые проходил резидент, и прогресс по ним.') +
'<div class="modules">' + educationHtml(v) + '</div></section>');
sections.push('<section class="section" id="experience">' +
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
experienceHtml(v) + '</div></div></section>');
sections.push('<section class="section" id="skills">' +
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые ученик уже проходил.') +
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые резидент уже проходил.') +
'<div class="skills">' + skillsHtml(v) + '</div></section>');
sections.push('<section class="section" id="projects">' +
railHead('folder-git-2', 'Работы', 'Файлы, прикреплённые учеником к записям занятий.', 'projectsRail') +
railHead('folder-git-2', 'Работы', 'Файлы, прикреплённые резидентом к записям занятий.', 'projectsRail') +
'<div class="rail-wrap"><div class="rail rail--third" id="projectsRail" data-rail="projectsRail" tabindex="0" aria-label="Лента работ">' +
projectsHtml(v) + '</div></div></section>');
sections.push('<section class="section" id="lessons">' +
sectionHead('list-checks', 'Занятия', 'Записи журнала: что ученик делал, фотографии и файлы занятий.') +
sectionHead('list-checks', 'Занятия', 'Записи журнала: что резидент делал, фотографии и файлы занятий.') +
'<div class="tasks">' + tasksHtml(v) + '</div></section>');
sections.push('<section class="section" id="about">' +
sectionHead('info', 'Немного о себе', 'Рассказ ученика, контакты и достижения.') +
sectionHead('info', 'Немного о себе', 'Рассказ резидента, контакты и достижения.') +
'<div class="about">' + aboutHtml(v) + '</div></section>');
const topbar = '<header class="topbar" id="top"><div class="topbar__inner">' +
'<a class="brand" href="#top"><span class="brand__logo">' + iconTag('graduation-cap') + '</span>' +
'<span class="brand__text">Академия<small>Профиль ученика</small></span></a>' +
'<span class="brand__text">KIBERone<small>Профиль резидента</small></span></a>' +
'<nav class="topbar__nav" id="mainNav" aria-label="Меню профиля">' + navItems(v) + '</nav>' +
'<div class="topbar__actions">' +
'<button class="icon-btn" type="button" data-action="print" title="Печать / сохранить в PDF">' + iconTag('printer') + '</button>' +
'<button class="icon-btn" id="themeToggle" type="button" title="Светлая / тёмная тема">' + iconTag('moon') + '</button>' +
'<div class="user-chip" title="Ученик"><span class="user-chip__ph">' + iconTag('user-round') + '</span>' +
'<div class="user-chip" title="Резидент"><span class="user-chip__ph">' + iconTag('user-round') + '</span>' +
'<span class="user-chip__name">' + escapeHtml(chipInit(v.name)) + '</span></div>' +
'<button class="icon-btn topbar__burger" id="burger" type="button" aria-expanded="false" aria-controls="mainNav" aria-label="Открыть меню">' +
iconTag('menu') + '</button></div></div></header>';
@@ -1152,7 +1196,7 @@ function renderStudentReport(data, opts) {
return '<!DOCTYPE html>\n<html lang="ru" data-theme="dark">\n<head>\n' +
'<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
'<title>' + escapeHtml(v.name) + ' — профиль ученика</title>\n' +
'<title>' + escapeHtml(v.name) + ' — профиль резидента</title>\n' +
'<link rel="preconnect" href="https://fonts.googleapis.com">\n' +
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n' +
'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">\n' +