const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText']; 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 = ` ${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'; document.getElementById('cookieNoticeText').value = s.cookie_notice_text || ''; snapshotDirty(); loadAiQueue(); loadAiProfiles(); if (stRes.ok) { const st = await stRes.json(); document.getElementById('dataStats').innerHTML = ` Записей: ${st.entries} Учеников: ${st.students} Групп: ${st.groups} Сегодня: ${st.today} ${st.trash ? `В корзине: ${st.trash} →` : ''} `; } 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 = '
Ошибка загрузки
'; } } 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 => `
${t.name}${t.size}
`).join('') + '
'; } let diskHtml = ''; if (disk) { const pct = disk.used_pct; const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a'; diskHtml = `
Диск ${disk.used} / ${disk.total}
Свободно: ${disk.free} (${100 - pct}%)
`; } document.getElementById('systemInfo').innerHTML = `
База данных
Размер${db.size || '—'}
${tablesHtml}
Фото
Фото групп${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}
Фото в записях${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}
Всего${photos.total_count || 0} · ${photos.total_size || '0 B'}
Файлы
Проектные файлы${files.project_files?.count || 0}
Загрузки
Файлов${uploads.count || 0}
Размер${uploads.size || '0 B'}
Диск
${diskHtml || '
Недоступно
'}
`; } async function loadAiProfiles() { const listEl = document.getElementById('aiProfilesList'); const activeBadge = document.getElementById('aiProfileActive'); 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 (activeBadge) { if (data.active === 'native') { activeBadge.innerHTML = `По умолчанию: Нативная (llama.cpp) — ${esc(data.native.model)}`; } else { const p = profiles.find(x => x.id === data.active); activeBadge.innerHTML = p ? `По умолчанию: ${esc(p.name)} — ${esc(p.model)}` : 'По умолчанию: нативная модель'; } } 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('aiProfileForm').style.display = 'block'; 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('aiProfileForm').style.display = 'none'; } 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 || {}; const state = q.enabled ? 'включена' : 'выключена'; el.innerHTML = `Автопроверка: ${state} · Модель: ${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 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) { showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен'); closeAiProfileForm(); loadAiProfiles(); } else { const err = await res.json().catch(() => ({})); alert(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(() => ({})); alert(err.error || 'Ошибка'); } } async function deleteAiProfile(id, name) { if (!confirm(`Удалить профиль «${name}»?`)) 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(() => ({})); alert(err.error || 'Ошибка удаления'); } } 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('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); 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); }); } }); 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', cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(), } }) }); 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 = ' Восстановление...'; 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 = 'Не удалось загрузить'; return; } const bans = await res.json(); if (!bans.length) { el.innerHTML = 'Активных блокировок нет'; return; } el.innerHTML = bans.map(b => ` ${esc(b.ip)} — ${esc(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 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 = ` Убрать из блокировки? `; 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(); } })();