289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos'];
|
||
|
||
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;
|
||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||
}
|
||
}
|
||
|
||
function updateDirty() {
|
||
const dirty = DIRTY_FIELDS.some(id => {
|
||
const el = document.getElementById(id);
|
||
return String(el.dataset.original ?? '') !== fieldValue(el);
|
||
});
|
||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function loadSettings() {
|
||
const [sRes, stRes] = await Promise.all([
|
||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||
]);
|
||
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';
|
||
snapshotDirty();
|
||
loadAiQueue();
|
||
if (stRes.ok) {
|
||
const st = await stRes.json();
|
||
document.getElementById('dataStats').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>
|
||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
||
`;
|
||
} else {
|
||
document.getElementById('dataStats').innerHTML = '';
|
||
}
|
||
|
||
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||
if (siRes.ok) {
|
||
const si = await siRes.json();
|
||
renderSystemInfo(si);
|
||
} else {
|
||
document.getElementById('systemInfo').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>${t.name}</span><span>${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';
|
||
diskHtml = `
|
||
<div class="sys-item">
|
||
<span class="sys-label">Диск</span>
|
||
<span class="sys-value">${disk.used} / ${disk.total}</span>
|
||
</div>
|
||
<div class="sys-bar-wrap">
|
||
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
|
||
</div>
|
||
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</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">${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} · ${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} · ${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>${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">${uploads.size || '0 B'}</span></div>
|
||
</div>
|
||
<div class="sys-card">
|
||
<div class="sys-title">Диск</div>
|
||
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 || {};
|
||
const state = q.enabled ? 'включена' : 'выключена';
|
||
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
|
||
} catch { el.textContent = ''; }
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const v = document.getElementById('spamInterval').value;
|
||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||
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',
|
||
} })
|
||
});
|
||
if (res.ok) {
|
||
showToast('Настройки сохранены');
|
||
snapshotDirty();
|
||
} else { const e = await res.json(); alert(e.error); }
|
||
} 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) { const t = await res.text(); alert('Ошибка: ' + t); 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('Бэкап скачан');
|
||
} 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 = '';
|
||
alert('Ошибка сети: ' + 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 = 'Ошибка восстановления';
|
||
const t = await res.text();
|
||
alert('Ошибка: ' + t);
|
||
}
|
||
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('bansCard').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" data-chip="${esc(b.ip)}">
|
||
<b>${esc(b.ip)}</b> — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
|
||
<button type="button" data-unban="${esc(b.ip)}" title="Разблокировать" style="background:none;border:none;color:var(--primary);cursor:pointer;font-weight:700">✕</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 showToast('Ошибка разблокировки');
|
||
}
|
||
|
||
document.getElementById('bansList').addEventListener('click', e => {
|
||
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
||
if (!ip) return;
|
||
const chip = e.target.closest('[data-chip]');
|
||
if (chip.dataset.confirming) return;
|
||
chip.dataset.confirming = '1';
|
||
chip.style.background = 'rgba(239,68,68,.08)';
|
||
e.target.outerHTML = `
|
||
<span style="white-space:nowrap">
|
||
Убрать из блокировки?
|
||
<button type="button" data-unban-yes style="background:none;border:none;color:#16a34a;cursor:pointer;font-weight:700">Да</button>
|
||
<button type="button" data-unban-no style="background:none;border:none;color:var(--muted);cursor:pointer;font-weight:700">Нет</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);
|
||
|
||
(async () => {
|
||
if (await requireAdminPage()) {
|
||
buildSidebar(document.body.dataset.page);
|
||
bindDirty();
|
||
loadSettings();
|
||
loadBans();
|
||
}
|
||
})(); |