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
+93 -1
View File
@@ -769,6 +769,31 @@ body[data-page=settings] .page-head{align-items:flex-end}
}
.student-card__sparks{position:absolute;top:15px;right:16px;width:104px;color:#E1ECFB;pointer-events:none}
.student-card__name{margin:0 0 8px;font-size:20px;font-weight:750;line-height:1.25;letter-spacing:-.1px;color:var(--sc-text);overflow-wrap:break-word}
.student-card__name[hidden]{display:none}
.student-card__media[hidden]{display:none}
.student-card__lastname,.student-card__firstname{display:inline;overflow-wrap:break-word}
.student-card__lastname:empty,.student-card__firstname:empty{display:none}
.student-card__text[hidden]{display:none}
.student-card__meta{display:flex;flex-direction:column;gap:10px;margin-top:14px}
.student-card__meta:empty{display:none}
.student-card__meta-val{font-size:14px;line-height:1.5;color:var(--sc-text-secondary);overflow-wrap:break-word;white-space:pre-wrap}
.student-card__meta-files{display:flex;flex-wrap:wrap;gap:6px}
.student-card__meta-files a{
display:inline-flex;
align-items:center;
max-width:100%;
padding:5px 11px;
border-radius:999px;
background:var(--sc-primary-soft);
color:#245FB4;
font-size:13px;
font-weight:600;
text-decoration:none;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.student-card__meta-files a:hover{background:#D7E8FF}
.student-card__text{margin:0;font-size:15px;line-height:1.6;color:var(--sc-text-secondary);white-space:pre-wrap;overflow-wrap:break-word}
.student-card__text:empty{display:none}
.student-card__smiley{position:absolute;right:24px;bottom:var(--sc-smiley-bottom);width:var(--sc-smiley-size);height:var(--sc-smiley-size);pointer-events:none}
@@ -797,12 +822,79 @@ body[data-page=settings] .page-head{align-items:flex-end}
}
.student-card__badge--time{background:rgba(23,59,120,.94)}
.student-card__badge--tool{background:var(--sc-primary);box-shadow:0 4px 12px rgba(52,120,246,.28)}
.student-card__badge--tool[hidden]{display:none}
.student-card__badge[hidden]{display:none}
.student-card__badge > span{overflow:hidden;text-overflow:ellipsis}
.student-card__icon{display:block;flex:none}
.student-card__icon--badge{width:17px;height:17px}
.student-card__icon--ps{width:19px;height:19px}
.student-card :focus-visible{outline:2px solid var(--sc-primary);outline-offset:2px;border-radius:6px}
/* --- Настройка полей карточки отчёта (журнал) --- */
.report-fields{
position:fixed;
right:24px;
bottom:24px;
z-index:120;
display:none;
flex-direction:column;
align-items:flex-end;
gap:10px;
}
body.report-card-open .report-fields{display:flex}
.report-fields__btn{
display:inline-flex;
align-items:center;
gap:9px;
background:var(--card);
color:var(--text);
border:1px solid var(--border);
border-radius:999px;
padding:11px 18px;
font-size:.85rem;
font-weight:600;
font-family:inherit;
cursor:pointer;
box-shadow:0 8px 24px rgba(0,0,0,.16);
white-space:nowrap;
-webkit-tap-highlight-color:transparent;
}
.report-fields__btn:hover{border-color:var(--accent);color:var(--accent)}
.report-fields__btn svg{width:17px;height:17px;display:block;flex:none}
.report-fields__panel{
display:none;
width:min(280px,calc(100vw - 32px));
background:var(--card);
border:1px solid var(--border);
border-radius:14px;
box-shadow:0 18px 40px rgba(0,0,0,.2);
padding:14px 16px;
overflow:hidden;
}
.report-fields:hover .report-fields__panel,
.report-fields.open .report-fields__panel{display:block}
.report-fields__head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.report-fields__title{font-size:.82rem;font-weight:700}
.report-fields__actions{display:flex;gap:10px}
.report-fields__link{background:none;border:none;color:var(--accent);cursor:pointer;font-size:.78rem;font-weight:600;padding:0;font-family:inherit}
.report-fields__link:hover{text-decoration:underline}
.report-fields__list{display:flex;flex-direction:column;gap:2px;max-height:min(56vh,420px);overflow:auto}
.report-fields__item{
display:flex;
align-items:center;
gap:10px;
padding:7px 8px;
border-radius:8px;
cursor:pointer;
font-size:.85rem;
user-select:none;
}
.report-fields__item:hover{background:var(--bg)}
.report-fields__item input{width:16px;height:16px;accent-color:var(--accent);cursor:pointer;flex:none;margin:0}
.report-fields__item span{overflow:hidden;text-overflow:ellipsis}
@media(max-width:640px){
.report-fields{right:16px;bottom:16px}
.report-fields__btn{padding:10px 14px}
}
.journal-card .foot .edit svg,.journal-card .foot .del svg,.card .edit svg,.card .del svg{width:16px;height:16px;display:block}
@media(max-width:420px){
.report-overlay{padding:16px 12px}
+20 -2
View File
@@ -265,7 +265,7 @@
<div class="modal-overlay report-overlay" id="reportCardModal">
<article class="student-card" aria-labelledby="reportCardName">
<div class="student-card__media">
<div class="student-card__media" id="reportCardMedia">
<img class="student-card__photo" id="reportCardPhoto" alt="" decoding="async">
<div class="student-card__nophoto" id="reportCardNoPhoto" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
@@ -283,8 +283,9 @@
<path d="M104 6l3 6M104 26l3-6"/>
</g>
</svg>
<h2 class="student-card__name" id="reportCardName"></h2>
<h2 class="student-card__name" id="reportCardName"><span class="student-card__lastname" id="reportCardLastName"></span> <span class="student-card__firstname" id="reportCardFirstName"></span></h2>
<p class="student-card__text" id="reportCardText"></p>
<div class="student-card__meta" id="reportCardMeta"></div>
<svg class="student-card__smiley" viewBox="0 0 72 72"
fill="none" stroke="#FFB71B" stroke-width="2.6"
stroke-linecap="round" stroke-linejoin="round"
@@ -326,6 +327,23 @@
</article>
</div>
<div class="report-fields" id="reportFields">
<button type="button" class="report-fields__btn" id="reportFieldsBtn" title="Поля карточки отчёта" aria-expanded="false" aria-haspopup="true">
<i data-lucide="sliders-horizontal"></i>
<span class="report-fields__btn-text">Поля карточки</span>
</button>
<div class="report-fields__panel" id="reportFieldsPanel" role="dialog" aria-label="Поля карточки отчёта">
<div class="report-fields__head">
<span class="report-fields__title">Поля карточки отчёта</span>
<div class="report-fields__actions">
<button type="button" class="report-fields__link" id="reportFieldsAll">Все</button>
<button type="button" class="report-fields__link" id="reportFieldsNone">Скрыть</button>
</div>
</div>
<div class="report-fields__list" id="reportFieldsList"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
+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; }