feat(journal): настраиваемые поля карточки отчёта по ученику

Добавлен плавающий виджет «Поля карточки» в журнале: набор видимых
полей карточки отчёта по ученику (фото, фамилия, имя, группа, модуль,
тема, дата, файлы) задаётся пользователем и сохраняется в localStorage.
Имя теперь рендерится двумя спанами, чтобы скрывать частично; добавлен
блок мета-информации с датой и чипами файлов — картинки открываются в
лайтбоксе, играбельное видео воспроизводится в #videoModal через
?play=1, остальное скачивается. Каталог полей вынесен в
REPORT_CARD_FIELDS как единственный источник правды, без дублирования
в HTML.

Также добавлена документация HTTP API (API.md, 45 разделов): адреса и
транспорт, аутентификация сессиями и API-ключами, лимиты частоты,
дата/время/часовой пояс, матрица соответствия и полный индекс
эндпоинтов с ссылками на строки server.js.
This commit is contained in:
dev
2026-10-05 13:15:38 +03:00
parent 5667198c9b
commit 3836fc82ef
4 changed files with 2976 additions and 11 deletions
+142 -8
View File
@@ -48,12 +48,75 @@ function mainPhotoPath(e) {
return null;
}
function openReportCard(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
const mp = mainPhotoPath(e);
const REPORT_CARD_FIELDS = [
{ key: 'photo', label: 'Фото' },
{ key: 'lastname', label: 'Фамилия' },
{ key: 'firstname', label: 'Имя' },
{ key: 'group', label: 'Группа' },
{ key: 'module', label: 'Модуль' },
{ key: 'topic', label: 'Тема' },
{ key: 'date', label: 'Дата и время' },
{ key: 'files', label: 'Файлы' },
];
const REPORT_CARD_FIELDS_KEY = 'reportCardFields';
const reportCardFields = loadReportCardFields();
let reportCardEntryId = null;
function loadReportCardFields() {
const all = REPORT_CARD_FIELDS.map(f => f.key);
try {
const raw = JSON.parse(localStorage.getItem(REPORT_CARD_FIELDS_KEY) || 'null');
if (Array.isArray(raw)) return all.filter(k => raw.includes(k));
} catch (err) {
return all;
}
return all;
}
function saveReportCardFields() {
try {
localStorage.setItem(REPORT_CARD_FIELDS_KEY, JSON.stringify(reportCardFields));
} catch (err) {
showToast('Не удалось сохранить настройки полей', true);
}
}
function reportCardFieldOn(key) {
return reportCardFields.includes(key);
}
function splitStudentName(full) {
const s = String(full || '').trim();
const i = s.indexOf(' ');
if (i < 0) return { lastname: s, firstname: '' };
return { lastname: s.slice(0, i), firstname: s.slice(i + 1).trim() };
}
function reportCardFilesHTML(e) {
if (!e.files || !e.files.length) return '';
return `<div class="student-card__meta-files">${e.files.map(f => {
const url = `${API}/api/files/${f.token}`;
if (isImageFile(f.name)) return `<a href="#" data-img="${url}" title="Открыть">${esc(f.name)}</a>`;
if (isPlayableVideoFile(f.name)) return `<a href="${url}?play=1" data-video="${url}?play=1" data-video-name="${esc(f.name)}" data-video-token="${f.token}" title="Смотреть">${esc(f.name)}</a>`;
const note = isVideoFile(f.name) ? 'Скачать (браузер не умеет играть этот формат)' : 'Скачать';
return `<a href="${url}" download title="${note}">${esc(f.name)}</a>`;
}).join('')}</div>`;
}
function reportCardMetaHTML(e) {
let html = '';
if (reportCardFieldOn('date') && e.created_at) {
html += `<div class="student-card__meta-val">${esc(fmtFull(e.created_at))}</div>`;
}
if (reportCardFieldOn('files')) html += reportCardFilesHTML(e);
return html;
}
function renderReportCard(e) {
const photo = document.getElementById('reportCardPhoto');
const noPhoto = document.getElementById('reportCardNoPhoto');
document.getElementById('reportCardMedia').hidden = !reportCardFieldOn('photo');
const mp = mainPhotoPath(e);
if (mp) {
photo.src = thumbSrc(mp);
photo.dataset.img = `${API}${mp}`;
@@ -67,21 +130,47 @@ function openReportCard(id) {
photo.hidden = true;
noPhoto.hidden = false;
}
document.getElementById('reportCardName').textContent = e.student_name || '';
document.getElementById('reportCardText').textContent = e.description || '';
const name = splitStudentName(e.student_name);
const lastEl = document.getElementById('reportCardLastName');
const firstEl = document.getElementById('reportCardFirstName');
lastEl.textContent = reportCardFieldOn('lastname') ? name.lastname : '';
firstEl.textContent = reportCardFieldOn('firstname') ? name.firstname : '';
document.getElementById('reportCardName').hidden = !lastEl.textContent && !firstEl.textContent;
const topic = e.description || '';
const topicEl = document.getElementById('reportCardText');
topicEl.textContent = topic;
topicEl.hidden = !reportCardFieldOn('topic') || !topic;
const groupBadge = document.getElementById('reportCardTime');
const group = e.group_name || '';
groupBadge.textContent = group;
groupBadge.parentElement.hidden = !group;
groupBadge.parentElement.hidden = !reportCardFieldOn('group') || !group;
const toolBadge = document.getElementById('reportCardModule').parentElement;
const tool = e.module_name || '';
document.getElementById('reportCardModule').textContent = tool;
toolBadge.hidden = !tool;
toolBadge.hidden = !reportCardFieldOn('module') || !tool;
document.getElementById('reportCardMeta').innerHTML = reportCardMetaHTML(e);
}
function refreshReportCard() {
if (reportCardEntryId === null) return;
const e = currentEntries.find(x => x.id === reportCardEntryId);
if (e) renderReportCard(e);
}
function openReportCard(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
reportCardEntryId = id;
renderReportCard(e);
document.getElementById('reportCardModal').classList.add('open');
document.body.classList.add('report-card-open');
}
function closeReportCard() {
reportCardEntryId = null;
document.getElementById('reportCardModal').classList.remove('open');
document.body.classList.remove('report-card-open');
setReportFieldsOpen(false);
}
function filesHTML(files) {
@@ -1555,9 +1644,54 @@ document.getElementById('linkModal').addEventListener('click', e => { if (e.targ
document.getElementById('reportCardModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeReportCard(); });
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('reportFields').classList.contains('open')) { setReportFieldsOpen(false); return; }
if (document.getElementById('reportCardModal').classList.contains('open')) closeReportCard();
});
function setReportCardFields(keys) {
reportCardFields.length = 0;
REPORT_CARD_FIELDS.map(f => f.key).filter(k => keys.includes(k)).forEach(k => reportCardFields.push(k));
saveReportCardFields();
renderReportFieldsList();
refreshReportCard();
}
function renderReportFieldsList() {
const list = document.getElementById('reportFieldsList');
if (!list) return;
list.innerHTML = REPORT_CARD_FIELDS.map(f => `
<label class="report-fields__item">
<input type="checkbox" data-report-field="${f.key}"${reportCardFieldOn(f.key) ? ' checked' : ''}>
<span>${esc(f.label)}</span>
</label>`).join('');
}
const reportFieldsRoot = document.getElementById('reportFields');
const reportFieldsBtn = document.getElementById('reportFieldsBtn');
const reportFieldsPanel = document.getElementById('reportFieldsPanel');
function setReportFieldsOpen(open) {
reportFieldsRoot.classList.toggle('open', open);
reportFieldsBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
renderReportFieldsList();
reportFieldsBtn.addEventListener('click', () => setReportFieldsOpen(!reportFieldsRoot.classList.contains('open')));
reportFieldsPanel.addEventListener('change', e => {
const cb = e.target.closest('[data-report-field]');
if (!cb) return;
const key = cb.dataset.reportField;
const keys = cb.checked
? REPORT_CARD_FIELDS.map(f => f.key).filter(k => reportCardFieldOn(k) || k === key)
: reportCardFields.filter(k => k !== key);
setReportCardFields(keys);
});
document.getElementById('reportFieldsAll').addEventListener('click', () => setReportCardFields(REPORT_CARD_FIELDS.map(f => f.key)));
document.getElementById('reportFieldsNone').addEventListener('click', () => setReportCardFields([]));
document.addEventListener('click', e => {
if (!reportFieldsRoot.contains(e.target)) setReportFieldsOpen(false);
});
document.getElementById('grid').addEventListener('click', e => {
const det = e.target.closest('[data-detach]');
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }