Лимиты были захардкожены в четырёх местах фронтенда и в константах multer, из-за чего расходились с текстами ошибок на сервере. - UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env; оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты ошибок собираются из тех же констант вместо литералов - UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают - GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb, фронтенд читает их вместо собственных констант Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и 225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
330 lines
14 KiB
JavaScript
330 lines
14 KiB
JavaScript
let modulesList = [];
|
|
let mPage = 1;
|
|
const M_PAGE_SIZE = 25;
|
|
let mSearchTimer = null;
|
|
let modulePhotoDeleteFlag = false;
|
|
let modulePhotoLimitMb = 50;
|
|
|
|
function modulePhotoHint() {
|
|
return `jpg, png, gif, webp, avif, heic и др., до ${modulePhotoLimitMb} МБ · или вставьте из буфера (Ctrl+V)`;
|
|
}
|
|
|
|
function mPlural(n, one, few, many) {
|
|
n = Math.abs(n) % 100; const n1 = n % 10;
|
|
if (n > 10 && n < 20) return many;
|
|
if (n1 > 1 && n1 < 5) return few;
|
|
if (n1 === 1) return one;
|
|
return many;
|
|
}
|
|
|
|
async function loadModules() {
|
|
const p = new URLSearchParams();
|
|
const q = document.getElementById('moduleSearch').value.trim();
|
|
if (q) p.set('search', q);
|
|
p.set('limit', M_PAGE_SIZE);
|
|
p.set('offset', (mPage - 1) * M_PAGE_SIZE);
|
|
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
modulesList = data.modules || [];
|
|
const totalPages = Math.max(1, Math.ceil(data.total / M_PAGE_SIZE));
|
|
if (mPage > totalPages) { mPage = totalPages; return loadModules(); }
|
|
renderModules(data.total);
|
|
}
|
|
|
|
function renderModules(total) {
|
|
const list = document.getElementById('moduleList');
|
|
if (!modulesList.length) {
|
|
list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>';
|
|
} else {
|
|
list.innerHTML = modulesList.map(m => `
|
|
<div class="item module-item${m.is_active ? '' : ' module-off'}" data-id="${m.id}">
|
|
${m.photo_path ? `<img class="module-thumb" src="${thumbSrc(m.photo_path)}" alt="" data-img="${esc(API + m.photo_path)}">` : ''}
|
|
<div class="module-info">
|
|
<div class="module-name">${esc(m.name)}${m.is_active ? '' : ' <span class="module-badge">Отключён</span>'}</div>
|
|
<div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div>
|
|
</div>
|
|
<div class="student-actions">
|
|
${m.is_active
|
|
? `<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>
|
|
<button class="btn-link danger" type="button" data-off-module="${m.id}">Отключить</button>`
|
|
: `<button class="btn-link" type="button" data-on-module="${m.id}">Включить</button>
|
|
<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>`}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
|
|
}
|
|
|
|
function resetModulePhoto(m) {
|
|
modulePhotoDeleteFlag = false;
|
|
const fileInput = document.getElementById('modulePhotoFile');
|
|
const preview = document.getElementById('modulePhotoPreview');
|
|
const delBtn = document.getElementById('modulePhotoDeleteBtn');
|
|
const note = document.getElementById('modulePhotoNote');
|
|
fileInput.value = '';
|
|
if (m && m.photo_path) {
|
|
preview.src = thumbSrc(m.photo_path);
|
|
preview.style.display = 'block';
|
|
delBtn.style.display = 'inline-block';
|
|
note.textContent = 'Загрузка новой картинки заменит текущую';
|
|
} else {
|
|
preview.removeAttribute('src');
|
|
preview.style.display = 'none';
|
|
delBtn.style.display = 'none';
|
|
note.textContent = modulePhotoHint();
|
|
}
|
|
}
|
|
|
|
function openModuleModal(id = null) {
|
|
const modal = document.getElementById('moduleModal');
|
|
const form = document.getElementById('moduleForm');
|
|
const title = document.getElementById('moduleModalTitle');
|
|
const submitBtn = document.getElementById('moduleSubmitBtn');
|
|
form.reset();
|
|
document.getElementById('moduleId').value = '';
|
|
document.getElementById('moduleLessons').value = '0';
|
|
if (id) {
|
|
const m = modulesList.find(x => x.id === id);
|
|
if (!m) return;
|
|
title.textContent = 'Редактировать модуль';
|
|
submitBtn.textContent = 'Сохранить';
|
|
form.name.value = m.name;
|
|
document.getElementById('moduleLessons').value = String(m.lessons_count);
|
|
document.getElementById('moduleId').value = m.id;
|
|
resetModulePhoto(m);
|
|
} else {
|
|
title.textContent = 'Создать модуль';
|
|
submitBtn.textContent = 'Создать';
|
|
resetModulePhoto(null);
|
|
}
|
|
modal.classList.add('open');
|
|
setTimeout(() => form.name.focus(), 100);
|
|
}
|
|
|
|
function previewModulePhotoFile() {
|
|
const file = document.getElementById('modulePhotoFile').files[0];
|
|
const preview = document.getElementById('modulePhotoPreview');
|
|
const delBtn = document.getElementById('modulePhotoDeleteBtn');
|
|
const note = document.getElementById('modulePhotoNote');
|
|
if (!file) return;
|
|
modulePhotoDeleteFlag = false;
|
|
preview.src = URL.createObjectURL(file);
|
|
preview.style.display = 'block';
|
|
delBtn.style.display = 'none';
|
|
note.textContent = 'Новая картинка будет сохранена после нажатия «Сохранить»';
|
|
}
|
|
|
|
function removeModulePhoto() {
|
|
const preview = document.getElementById('modulePhotoPreview');
|
|
const delBtn = document.getElementById('modulePhotoDeleteBtn');
|
|
const note = document.getElementById('modulePhotoNote');
|
|
if (!confirm('Удалить картинку модуля?')) return;
|
|
modulePhotoDeleteFlag = true;
|
|
document.getElementById('modulePhotoFile').value = '';
|
|
preview.removeAttribute('src');
|
|
preview.style.display = 'none';
|
|
delBtn.style.display = 'none';
|
|
note.textContent = 'Картинка будет удалена после нажатия «Сохранить»';
|
|
}
|
|
|
|
function pasteModulePhotoFile(e) {
|
|
const modal = document.getElementById('moduleModal');
|
|
if (!modal.classList.contains('open')) return;
|
|
const items = e.clipboardData && e.clipboardData.items;
|
|
if (!items) return;
|
|
for (const item of items) {
|
|
if (item.type && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
e.preventDefault();
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
const input = document.getElementById('modulePhotoFile');
|
|
input.files = dt.files;
|
|
previewModulePhotoFile();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function closeModuleModal() {
|
|
document.getElementById('moduleModal').classList.remove('open');
|
|
}
|
|
|
|
async function disableModule(id) {
|
|
const m = modulesList.find(x => x.id === id);
|
|
const msg = m && m.entries_count > 0
|
|
? `Отключить модуль «${m.name}»? Он станет недоступен для выбора, у ${m.entries_count} связанных записей тема сохранится.`
|
|
: `Отключить модуль${m ? ` «${m.name}»` : ''}? Он станет недоступен для выбора.`;
|
|
if (!confirm(msg)) return;
|
|
const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() });
|
|
if (res.ok) {
|
|
showToast('Модуль отключён');
|
|
loadModules();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
alert(err.error || 'Ошибка');
|
|
}
|
|
}
|
|
|
|
async function enableModule(id) {
|
|
const res = await fetch(`${API}/api/modules/${id}/restore`, { method: 'PUT', headers: hdr() });
|
|
if (res.ok) {
|
|
showToast('Модуль включён');
|
|
loadModules();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
alert(err.error || 'Ошибка');
|
|
}
|
|
}
|
|
|
|
document.getElementById('moduleForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const id = document.getElementById('moduleId').value;
|
|
const name = form.name.value.trim();
|
|
const lessons_count = parseInt(form.lessons_count.value, 10);
|
|
if (!name) { alert('Название обязательно'); return; }
|
|
if (!Number.isInteger(lessons_count) || lessons_count < 0) { alert('Количество занятий — целое неотрицательное число'); return; }
|
|
const res = await fetch(id ? `${API}/api/modules/${id}` : `${API}/api/modules`, {
|
|
method: id ? 'PUT' : 'POST',
|
|
headers: hdrJson(),
|
|
body: JSON.stringify({ name, lessons_count })
|
|
});
|
|
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
|
const moduleRow = await res.json();
|
|
const mid = moduleRow.id;
|
|
const fileInput = document.getElementById('modulePhotoFile');
|
|
if (modulePhotoDeleteFlag) {
|
|
const delRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'DELETE', headers: hdr() });
|
|
if (!delRes.ok) {
|
|
const err = await delRes.json().catch(() => ({}));
|
|
alert(err.error || 'Ошибка удаления картинки');
|
|
}
|
|
}
|
|
const file = fileInput.files[0];
|
|
if (file) {
|
|
const fd = new FormData();
|
|
fd.append('photo', file);
|
|
const upRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'POST', headers: hdr(), body: fd });
|
|
if (!upRes.ok) {
|
|
const err = await upRes.json().catch(() => ({}));
|
|
alert(err.error || 'Ошибка загрузки картинки');
|
|
}
|
|
}
|
|
showToast(id ? 'Модуль обновлён' : 'Модуль создан');
|
|
closeModuleModal();
|
|
loadModules();
|
|
});
|
|
|
|
function parseBatchModules(raw) {
|
|
const items = [];
|
|
const errors = [];
|
|
const seen = new Set();
|
|
String(raw).split('\n').forEach((line, idx) => {
|
|
const text = line.trim();
|
|
if (!text) return;
|
|
const lineNo = idx + 1;
|
|
let name = '';
|
|
let lessonsRaw = '';
|
|
const sepIndex = text.search(/[|;=\t]/);
|
|
if (sepIndex !== -1) {
|
|
name = text.slice(0, sepIndex).trim();
|
|
lessonsRaw = text.slice(sepIndex + 1).trim();
|
|
} else {
|
|
const numMatch = text.match(/^(.*\S)\s+(\d+)$/);
|
|
if (numMatch) {
|
|
name = numMatch[1].trim();
|
|
lessonsRaw = numMatch[2];
|
|
}
|
|
}
|
|
if (!name && !lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — укажите название и количество занятий, например: Основы Python | 10`); return; }
|
|
if (!name) { errors.push(`Строка ${lineNo}: «${text}» — не указано название`); return; }
|
|
if (!lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — не указано количество занятий`); return; }
|
|
const lessons = Number(lessonsRaw);
|
|
if (!Number.isInteger(lessons) || lessons < 0 || lessons > 10000) {
|
|
errors.push(`Строка ${lineNo}: «${text}» — количество занятий должно быть целым числом от 0 до 10000`);
|
|
return;
|
|
}
|
|
const key = name.toLowerCase();
|
|
if (seen.has(key)) { errors.push(`Строка ${lineNo}: «${text}» — дубликат названия в списке`); return; }
|
|
seen.add(key);
|
|
items.push({ name, lessons_count: lessons });
|
|
});
|
|
return { items, errors };
|
|
}
|
|
|
|
function openBatchModuleModal() {
|
|
document.getElementById('batchModuleForm').reset();
|
|
document.getElementById('batchModuleModal').classList.add('open');
|
|
setTimeout(() => document.getElementById('batchModuleText').focus(), 100);
|
|
}
|
|
|
|
function closeBatchModuleModal() {
|
|
document.getElementById('batchModuleModal').classList.remove('open');
|
|
}
|
|
|
|
async function batchAddModules() {
|
|
const submitBtn = document.getElementById('batchModuleSubmitBtn');
|
|
const { items, errors } = parseBatchModules(document.getElementById('batchModuleText').value);
|
|
if (errors.length) {
|
|
alert('Исправьте строки:\n\n' + errors.join('\n'));
|
|
return;
|
|
}
|
|
if (!items.length) { alert('Добавьте хотя бы один модуль'); return; }
|
|
submitBtn.disabled = true;
|
|
let added = 0, dupes = 0, failed = 0;
|
|
for (const item of items) {
|
|
const res = await fetch(`${API}/api/modules`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(item) });
|
|
if (res.ok) added++;
|
|
else if (res.status === 409) dupes++;
|
|
else failed++;
|
|
}
|
|
submitBtn.disabled = false;
|
|
closeBatchModuleModal();
|
|
loadModules();
|
|
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}${failed ? `, ошибок: ${failed}` : ''}`);
|
|
}
|
|
|
|
document.getElementById('batchModuleForm').addEventListener('submit', e => {
|
|
e.preventDefault();
|
|
batchAddModules();
|
|
});
|
|
document.getElementById('batchModuleBtn').addEventListener('click', openBatchModuleModal);
|
|
document.getElementById('batchModuleCancelBtn').addEventListener('click', closeBatchModuleModal);
|
|
document.getElementById('batchModuleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchModuleModal(); });
|
|
|
|
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
|
|
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
|
|
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
|
|
document.getElementById('modulePhotoFile').addEventListener('change', previewModulePhotoFile);
|
|
document.getElementById('modulePhotoDeleteBtn').addEventListener('click', removeModulePhoto);
|
|
document.addEventListener('paste', pasteModulePhotoFile);
|
|
document.getElementById('moduleSearch').addEventListener('input', () => {
|
|
clearTimeout(mSearchTimer);
|
|
mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
|
|
});
|
|
|
|
document.getElementById('moduleList').addEventListener('click', e => {
|
|
const ed = e.target.closest('[data-edit-module]');
|
|
if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; }
|
|
const off = e.target.closest('[data-off-module]');
|
|
if (off) { disableModule(parseInt(off.dataset.offModule, 10)); return; }
|
|
const on = e.target.closest('[data-on-module]');
|
|
if (on) enableModule(parseInt(on.dataset.onModule, 10));
|
|
});
|
|
|
|
(async () => {
|
|
if (await requireAdminPage()) {
|
|
try {
|
|
const res = await fetch(`${API}/api/public-settings`);
|
|
const f = await res.json();
|
|
const n = parseInt(f.upload_file_limit_mb, 10);
|
|
if (Number.isFinite(n) && n >= 1) modulePhotoLimitMb = n;
|
|
} catch (e) { /* ignore */ }
|
|
buildSidebar(document.body.dataset.page);
|
|
loadModules();
|
|
}
|
|
})(); |