Files
WhatIDo/public/js/settings.js
T

753 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
'login-bruteforce': 'Подбор пароля входа',
'share-password-bruteforce': 'Подбор пароля ссылки',
'manual': 'Вручную',
};
let currentLogo = '';
let logoOriginal = '';
function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg');
const fb = document.getElementById('logoPreviewFallback');
const rm = document.getElementById('logoRemoveBtn');
if (!img || !fb || !rm) return;
const hasLogo = !!currentLogo;
img.style.display = hasLogo ? 'block' : 'none';
fb.style.display = hasLogo ? 'none' : '';
if (hasLogo) img.src = currentLogo;
rm.hidden = !hasLogo;
}
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 = `<span class="spinner ${cls}"></span> ${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 => {
const el = document.getElementById(id);
el.addEventListener('input', updateDirty);
el.addEventListener('change', 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;
});
currentLogo = logoOriginal;
renderLogoPreview();
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();
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
currentLogo = s.system_logo || '';
logoOriginal = currentLogo;
renderLogoPreview();
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 || '';
const pr = document.getElementById('photoResolution');
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
if (!pr.value) pr.value = '640x480';
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto';
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 = '<span class="chip">Статистика недоступна</span>'; return; }
const st = await res.json();
el.innerHTML = `
<span class="chip">Записей: <b>${st.entries}</b></span>
<span class="chip">Учеников: <b>${st.students}</b></span>
<span class="chip">Групп: <b>${st.groups}</b></span>
<span class="chip">Сегодня: <b>${st.today}</b></span>
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
`;
}
async function refreshStats() {
const el = document.getElementById('dataStats');
if (!el) return;
el.innerHTML = '<span class="chip">Обновление…</span>';
try {
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
} catch {
el.innerHTML = '<span class="chip">Ошибка сети</span>';
}
}
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 = '<div class="sys-card err">Ошибка загрузки</div>';
} catch {
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
}
}
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 = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
'</div></details>';
}
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 = `
<div class="sys-card${cardCls}">
<div class="sys-title">Диск</div>
<div class="sys-item"><span class="sys-label">Заполнено</span><span class="sys-value">${pct}%</span></div>
<div class="sys-item"><span class="sys-label">Использовано</span><span class="sys-value">${esc(disk.used)} / ${esc(disk.total)}</span></div>
<div class="sys-bar-wrap"><div class="sys-bar" style="width:${pct}%; background:${color}"></div></div>
<div class="sys-meta">Свободно: ${esc(disk.free)} (${100 - pct}%)</div>
</div>`;
}
document.getElementById('systemInfo').innerHTML = `
<div class="sys-card">
<div class="sys-title">База данных</div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(db.size || '—')}</span></div>
${tablesHtml}
</div>
<div class="sys-card">
<div class="sys-title">Фото</div>
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}</span></div>
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}</span></div>
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${esc(photos.total_size || '0 B')}</b></span></div>
</div>
<div class="sys-card">
<div class="sys-title">Файлы</div>
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Загрузки</div>
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
</div>
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
`;
}
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 = '<span class="chip">Не удалось загрузить профили</span>'; return; }
const data = await res.json();
const profiles = data.profiles || [];
if (select) {
select.innerHTML = `<option value="native">Нативная — ${esc(data.native.model)}</option>` +
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).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 => `
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
<div class="pi-info">
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
</div>
<div class="pi-actions">
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
${p.native ? '' : `<button type="button" class="pi-btn danger" data-del-profile="${esc(p.id)}" data-del-name="${esc(p.name)}">Удалить</button>`}
</div>
</div>
`).join('');
} catch { listEl.innerHTML = '<span class="chip">Ошибка загрузки</span>'; }
}
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('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
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,
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').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 = `
<span class="chip">Автопроверка: <b>${q.enabled ? 'включена' : 'выключена'}</b></span>
<span class="chip">Модель: <b>${esc(q.model || '—')}</b></span>
<span class="chip">В очереди: <b>${c.pending || 0}</b></span>
<span class="chip">Проверено: <b>${c.done || 0}</b></span>
<span class="chip">Ошибок: <b>${c.error || 0}</b></span>
<span class="chip">Пропущено: <b>${c.skipped || 0}</b></span>
<a class="queue-link" href="worker.html">Монитор воркера →</a>`;
} catch { el.textContent = ''; }
}
async function saveAiProfile() {
const body = aiProfileFormData();
const st = document.getElementById('aiProfileTestStatus');
st.innerHTML = '<span class="spinner dark"></span> Сохранение...';
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 = `<span class="fail">${esc(err.error || 'Ошибка сохранения')}</span>`;
}
}
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
? `<span class="ok">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
: `<span class="fail">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
}
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: {
system_name: document.getElementById('systemName').value.trim(),
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(),
photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value,
} })
});
if (res.ok) {
showToast('Настройки сохранены');
logoOriginal = currentLogo;
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 = '<span class="spinner dark"></span> Восстановление...';
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 = '<span class="chip">Не удалось загрузить</span>'; return; }
const bans = await res.json();
if (!bans.length) { el.innerHTML = '<span class="chip">Активных блокировок нет</span>'; return; }
el.innerHTML = bans.map(b => `
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b>
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
</span>`).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 = `
<span class="chip-confirm">
Разблокировать?
<button type="button" class="chip-yes" data-unban-yes>Да</button>
<button type="button" class="chip-no" data-unban-no>Нет</button>
</span>`;
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');
});
function logoStatusText(html) {
const st = document.getElementById('logoUploadStatus');
if (st) st.innerHTML = html;
}
document.getElementById('logoUploadBtn').addEventListener('click', () => document.getElementById('logoInput').click());
document.getElementById('logoInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const btn = document.getElementById('logoUploadBtn');
if (file.size > 1024 * 1024) {
e.target.value = '';
showError('Логотип не должен превышать 1 МБ');
return;
}
btn.disabled = true;
logoStatusText('<span class="spinner dark"></span> Загрузка...');
try {
const fd = new FormData();
fd.append('logo', file);
const res = await fetch(`${API}/api/settings/logo`, { method: 'POST', headers: hdr(), body: fd });
const d = await res.json().catch(() => ({}));
if (res.ok) {
currentLogo = d.system_logo || '';
renderLogoPreview();
logoStatusText('');
showToast('Логотип обновлён');
} else {
logoStatusText(`<span class="fail">${esc(d.error || 'Ошибка загрузки')}</span>`);
}
} catch {
logoStatusText('<span class="fail">Ошибка сети</span>');
} finally {
btn.disabled = false;
e.target.value = '';
}
});
document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
if (!confirm('Удалить логотип? Название системы сохранится.')) return;
logoStatusText('<span class="spinner dark"></span> Удаление...');
try {
const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
currentLogo = '';
logoOriginal = '';
renderLogoPreview();
logoStatusText('');
showToast('Логотип удалён');
} else {
logoStatusText('<span class="fail">Ошибка удаления</span>');
}
} catch {
logoStatusText('<span class="fail">Ошибка сети</span>');
}
});
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();
}
})();