feat(journal): настраиваемые поля карточки отчёта по ученику
Добавлен плавающий виджет «Поля карточки» в журнале: набор видимых полей карточки отчёта по ученику (фото, фамилия, имя, группа, модуль, тема, дата, файлы) задаётся пользователем и сохраняется в localStorage. Имя теперь рендерится двумя спанами, чтобы скрывать частично; добавлен блок мета-информации с датой и чипами файлов — картинки открываются в лайтбоксе, играбельное видео воспроизводится в #videoModal через ?play=1, остальное скачивается. Каталог полей вынесен в REPORT_CARD_FIELDS как единственный источник правды, без дублирования в HTML. Также добавлена документация HTTP API (API.md, 45 разделов): адреса и транспорт, аутентификация сессиями и API-ключами, лимиты частоты, дата/время/часовой пояс, матрица соответствия и полный индекс эндпоинтов с ссылками на строки server.js.
This commit is contained in:
+93
-1
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user