const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
'login-bruteforce': 'Подбор пароля входа',
'share-password-bruteforce': 'Подбор пароля ссылки',
'manual': 'Вручную',
};
function fieldValue(el) {
return el.type === 'checkbox' ? String(el.checked) : el.value;
}
function setBtnLoading(btn, on, spinnerClass) {
if (on) {
btn.dataset.label = btn.innerHTML;
btn.disabled = true;
const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
btn.innerHTML = ` ${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
function showError(msg) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg;
t.classList.add('show', 'error');
clearTimeout(t._errTimer);
t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
}
function showFieldError(id, msg) {
const el = document.getElementById(id);
const err = document.getElementById(`${id}Error`);
if (el) el.classList.add('invalid');
if (err) { err.textContent = msg; err.hidden = false; }
}
function clearFieldError(id) {
const el = document.getElementById(id);
const err = document.getElementById(`${id}Error`);
if (el) el.classList.remove('invalid');
if (err) err.hidden = true;
}
function plural(n, one, few, many) {
const a = n % 10, b = n % 100;
if (a === 1 && b !== 11) return one;
if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
return many;
}
function humanizeMinutes(min) {
const n = Number(min);
if (!Number.isFinite(n) || n < 1) return '';
if (n === 1440) return 'сутки';
if (n > 1440 && n % 1440 === 0) {
const d = n / 1440;
return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
}
const h = Math.floor(n / 60);
const m = n % 60;
const parts = [];
if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
return parts.join(' ');
}
function updateDirty() {
const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== fieldValue(el);
}).length;
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
});
}
function snapshotDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).dataset.original = fieldValue(document.getElementById(id));
});
updateDirty();
}
function revertChanges() {
DIRTY_FIELDS.forEach(id => {
const el = document.getElementById(id);
const original = el.dataset.original ?? '';
if (el.type === 'checkbox') el.checked = original === 'true';
else el.value = original;
});
clearFieldError('spamInterval');
updateSpamUi();
updateDirty();
showToast('Изменения отменены');
}
function initSectionNav() {
const nav = document.getElementById('settingsNav');
if (!nav) return;
const links = [...nav.querySelectorAll('a')];
const smooth = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
let ticking = false;
const setActive = () => {
ticking = false;
let current = SECTION_IDS[0];
for (const id of SECTION_IDS) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= 170) current = id;
}
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${current}`));
};
window.addEventListener('scroll', () => {
if (!ticking) { ticking = true; requestAnimationFrame(setActive); }
}, { passive: true });
setActive();
nav.addEventListener('click', e => {
const a = e.target.closest('a');
if (!a) return;
e.preventDefault();
const target = document.getElementById(a.getAttribute('href').slice(1));
if (target) target.scrollIntoView({ behavior: smooth, block: 'start' });
history.replaceState(null, '', a.getAttribute('href'));
});
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
]);
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
const s = await sRes.json();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
snapshotDirty();
updateSpamUi();
loadAiQueue();
loadAiProfiles();
renderStats(stRes);
loadSystemInfo();
}
async function renderStats(res) {
const el = document.getElementById('dataStats');
if (!el) return;
if (!res || !res.ok) { el.innerHTML = 'Статистика недоступна'; return; }
const st = await res.json();
el.innerHTML = `
Записей: ${st.entries}
Учеников: ${st.students}
Групп: ${st.groups}
Сегодня: ${st.today}
${st.trash ? `В корзине: ${st.trash} →` : ''}
`;
}
async function refreshStats() {
const el = document.getElementById('dataStats');
if (!el) return;
el.innerHTML = 'Обновление…';
try {
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
} catch {
el.innerHTML = 'Ошибка сети';
}
}
async function loadSystemInfo() {
const el = document.getElementById('systemInfo');
if (!el) return;
try {
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (res.ok) {
renderSystemInfo(await res.json());
const live = document.getElementById('systemLive');
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
return;
}
el.innerHTML = '
Ошибка загрузки
';
} catch {
el.innerHTML = 'Ошибка сети
';
}
}
function renderSystemInfo(si) {
const db = si.database || {};
const photos = si.photos || {};
const files = si.files || {};
const uploads = si.uploads || {};
const disk = si.disk;
let tablesHtml = '';
if (db.tables && db.tables.length) {
tablesHtml = 'Таблицы БД
' +
db.tables.map(t => `
${esc(t.name)}${esc(t.size)}
`).join('') +
'
';
}
let diskHtml = '';
if (disk) {
const pct = disk.used_pct;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
const cardCls = pct > 90 ? ' danger' : pct > 70 ? ' warn' : '';
diskHtml = `
Диск
Заполнено${pct}%
Использовано${esc(disk.used)} / ${esc(disk.total)}
Свободно: ${esc(disk.free)} (${100 - pct}%)
`;
}
document.getElementById('systemInfo').innerHTML = `
База данных
Размер${esc(db.size || '—')}
${tablesHtml}
Фото
Фото групп${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}
Фото в записях${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}
Всего${photos.total_count || 0} · ${esc(photos.total_size || '0 B')}
Файлы
Проектные файлы${files.project_files?.count || 0}
Загрузки
Файлов${uploads.count || 0}
Размер${esc(uploads.size || '0 B')}
${diskHtml || ''}
`;
}
async function loadAiProfiles() {
const listEl = document.getElementById('aiProfilesList');
const select = document.getElementById('aiActiveProfile');
if (!listEl) return;
try {
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
if (!res.ok) { listEl.innerHTML = 'Не удалось загрузить профили'; return; }
const data = await res.json();
const profiles = data.profiles || [];
if (select) {
select.innerHTML = `` +
profiles.map(p => ``).join('');
select.value = data.active;
}
const items = [{ id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: data.native.base_url, model: data.native.model, native: true }].concat(profiles);
listEl.innerHTML = items.map(p => `
${esc(p.name)}${data.active === p.id ? ' по умолчанию' : ''}
${esc(p.model)} · ${esc(p.base_url)}
${p.native ? '' : ``}
${p.native ? '' : ``}
`).join('');
} catch { listEl.innerHTML = 'Ошибка загрузки'; }
}
let aiProfileEditingId = null;
function openAiProfileForm(profile) {
aiProfileEditingId = profile ? profile.id : null;
document.getElementById('aiProfileModalTitle').textContent = profile ? 'Изменить профиль ИИ' : 'Добавить профиль ИИ';
document.getElementById('aiProfileModal').classList.add('open');
document.getElementById('aiProfileName').value = profile ? profile.name : '';
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
document.getElementById('aiProfileApiKey').value = '';
document.getElementById('aiProfileTestStatus').textContent = '';
document.getElementById('aiProfileName').focus();
}
function closeAiProfileForm() {
aiProfileEditingId = null;
document.getElementById('aiProfileModal').classList.remove('open');
}
function aiProfileFormData() {
return {
name: document.getElementById('aiProfileName').value,
model: document.getElementById('aiProfileModel').value,
base_url: document.getElementById('aiProfileBaseUrl').value,
api_key: document.getElementById('aiProfileApiKey').value,
};
}
async function loadAiQueue() {
const el = document.getElementById('aiQueueInfo');
if (!el) return;
try {
const res = await fetch(`${API}/api/ai/queue`, { headers: hdr() });
if (!res.ok) { el.textContent = ''; return; }
const q = await res.json();
const c = q.counts || {};
el.innerHTML = `
Автопроверка: ${q.enabled ? 'включена' : 'выключена'}
Модель: ${esc(q.model || '—')}
В очереди: ${c.pending || 0}
Проверено: ${c.done || 0}
Ошибок: ${c.error || 0}
Пропущено: ${c.skipped || 0}
Монитор воркера →`;
} catch { el.textContent = ''; }
}
async function saveAiProfile() {
const body = aiProfileFormData();
const st = document.getElementById('aiProfileTestStatus');
st.innerHTML = ' Сохранение...';
const res = await fetch(aiProfileEditingId ? `${API}/api/ai/profiles/${aiProfileEditingId}` : `${API}/api/ai/profiles`, {
method: aiProfileEditingId ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify(body),
});
if (res.ok) {
st.textContent = '';
showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен');
closeAiProfileForm();
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
st.innerHTML = `${esc(err.error || 'Ошибка сохранения')}`;
}
}
async function testAiProfile() {
const st = document.getElementById('aiProfileTestStatus');
st.textContent = 'Проверка…';
const res = await fetch(`${API}/api/ai/profiles/test`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(aiProfileFormData()) });
const d = await res.json().catch(() => ({ ok: false, error: 'Ошибка' }));
st.innerHTML = d.ok
? `✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}`
: `✗ ${esc(d.error || 'Не удалось подключиться')}`;
}
async function activateAiProfile(id) {
const res = await fetch(`${API}/api/ai/profiles/activate`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ id }) });
if (res.ok) {
showToast('Модель по умолчанию обновлена');
loadAiProfiles();
loadAiQueue();
} else {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Ошибка');
}
}
async function deleteAiProfile(id, name, btn) {
if (btn && !btn.dataset.confirming) {
btn.dataset.confirming = '1';
btn.classList.add('confirming');
btn.textContent = 'Точно?';
setTimeout(() => {
if (btn.isConnected) {
delete btn.dataset.confirming;
btn.classList.remove('confirming');
btn.textContent = 'Удалить';
}
}, 3000);
return;
}
const res = await fetch(`${API}/api/ai/profiles/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Профиль удалён');
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Ошибка удаления');
}
}
const spamInput = document.getElementById('spamInterval');
function updateSpamUi() {
const raw = spamInput.value;
const n = Math.round(Number(raw));
const valid = raw !== '' && Number.isFinite(n) && n >= 1;
const hint = document.getElementById('spamIntervalHint');
if (hint) hint.textContent = valid ? `Следующая отправка будет доступна через ${humanizeMinutes(n)}` : 'Минимальное значение — 1 минута';
document.querySelectorAll('#spamPresets .preset-btn').forEach(b => b.classList.toggle('active', valid && Number(b.dataset.min) === n));
}
function clampSpam(v) {
let n = Math.round(Number(v));
if (!Number.isFinite(n) || n < 1) n = 1;
if (n > SPAM_MAX) n = SPAM_MAX;
return n;
}
spamInput.addEventListener('change', () => {
const n = Math.round(Number(spamInput.value));
if (spamInput.value === '' || !Number.isFinite(n) || n < 1) {
spamInput.value = clampSpam(spamInput.dataset.original ?? 30);
updateDirty();
}
updateSpamUi();
});
spamInput.addEventListener('input', updateSpamUi);
document.getElementById('spamPresets').addEventListener('click', e => {
const btn = e.target.closest('[data-min]');
if (!btn) return;
spamInput.value = btn.dataset.min;
spamInput.dispatchEvent(new Event('input', { bubbles: true }));
spamInput.dispatchEvent(new Event('change', { bubbles: true }));
});
document.getElementById('aiProfileAddBtn').addEventListener('click', () => openAiProfileForm(null));
document.getElementById('aiProfileSaveBtn').addEventListener('click', saveAiProfile);
document.getElementById('aiProfileTestBtn').addEventListener('click', testAiProfile);
document.getElementById('aiProfileCancelBtn').addEventListener('click', closeAiProfileForm);
document.getElementById('aiProfileModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAiProfileForm(); });
document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value));
document.getElementById('aiProfilesList').addEventListener('click', e => {
const del = e.target.closest('[data-del-profile]');
if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName, del); return; }
const edit = e.target.closest('[data-edit-profile]');
if (edit) {
fetch(`${API}/api/ai/profiles`, { headers: hdr() })
.then(r => r.json())
.then(data => {
const p = (data.profiles || []).find(x => x.id === edit.dataset.editProfile);
if (p) openAiProfileForm(p);
});
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
});
async function saveSettings() {
const v = spamInput.value;
const n = Number(v);
if (v === '' || !Number.isFinite(n) || Math.round(n) < 1) {
showFieldError('spamInterval', 'Укажите целое число минут (минимум 1)');
spamInput.focus();
showError('Проверьте поле «Интервал ожидания»');
return;
}
clearFieldError('spamInterval');
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
} })
});
if (res.ok) {
showToast('Настройки сохранены');
snapshotDirty();
updateSpamUi();
} else {
const e = await res.json().catch(() => ({}));
showError(e.error || 'Ошибка сохранения');
}
} catch {
showError('Ошибка сети при сохранении');
} finally {
setBtnLoading(btn, false);
}
}
async function downloadBackup() {
const btn = document.getElementById('backupBtn');
btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
} catch {
showError('Ошибка сети при формировании бэкапа');
} finally {
setBtnLoading(btn, false);
}
}
document.getElementById('restoreFile').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
const st = document.getElementById('restoreStatus');
const backupBtn = document.getElementById('backupBtn');
const restoreBtn = document.getElementById('restoreBtn');
st.classList.remove('err');
st.innerHTML = ' Восстановление...';
backupBtn.disabled = true;
restoreBtn.disabled = true;
const fd = new FormData();
fd.append('backup', file);
let res;
try {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
showError('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
backupBtn.disabled = false;
restoreBtn.disabled = false;
}
if (res.ok) {
showToast('Данные восстановлены');
st.textContent = '';
loadSettings();
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
showError(await res.text());
}
e.target.value = '';
});
async function loadBans() {
const el = document.getElementById('bansList');
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
if (res.status === 403) { document.getElementById('sec-bans').style.display = 'none'; return; }
if (!res.ok) { el.innerHTML = 'Не удалось загрузить'; return; }
const bans = await res.json();
if (!bans.length) { el.innerHTML = 'Активных блокировок нет'; return; }
el.innerHTML = bans.map(b => `
${esc(b.ip)}
${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}
`).join('');
}
async function doUnban(ip) {
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
else showError('Ошибка разблокировки');
}
document.getElementById('bansList').addEventListener('click', e => {
const btn = e.target.closest('[data-unban]');
if (!btn) return;
const ip = btn.dataset.unban;
const chip = btn.closest('[data-chip]');
if (!chip || chip.dataset.confirming) return;
chip.dataset.confirming = '1';
chip.classList.add('confirming');
btn.outerHTML = `
Разблокировать?
`;
chip.querySelector('[data-unban-yes]').addEventListener('click', () => doUnban(ip));
chip.querySelector('[data-unban-no]').addEventListener('click', () => loadBans());
});
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
document.getElementById('saveBtn').addEventListener('click', saveSettings);
document.getElementById('resetBtn').addEventListener('click', revertChanges);
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
systemRefreshBtn.classList.add('spinning');
await loadSystemInfo();
systemRefreshBtn.classList.remove('spinning');
});
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
statsRefreshBtn.classList.add('spinning');
await refreshStats();
statsRefreshBtn.classList.remove('spinning');
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
const modal = document.getElementById('aiProfileModal');
const saveBtn = document.getElementById('saveBtn');
if (!modal.classList.contains('open') && document.getElementById('saveBar').classList.contains('show') && !saveBtn.disabled) saveSettings();
}
});
window.addEventListener('beforeunload', e => {
if (document.getElementById('saveBar').classList.contains('show')) {
e.preventDefault();
e.returnValue = '';
}
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
bindDirty();
initSectionNav();
updateSpamUi();
const authStatus = document.getElementById('authStatus');
try {
await loadSettings();
if (authStatus) authStatus.classList.add('loaded');
} catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
}
loadBans();
}
})();