const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays']; const NOTIFY_IDS = []; const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans']; const SPAM_MAX = 10080; const BAN_REASONS = { 'honeypot': 'Антиспам-поле', 'login-bruteforce': 'Подбор пароля входа', 'share-password-bruteforce': 'Подбор пароля ссылки', 'manual': 'Вручную', }; let currentLogo = ''; let logoOriginal = ''; let currentSystemName = ''; let photoAiService = null; 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 = ` ${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 renderNotifyTypes(s) { const el = document.getElementById('notifyTypes'); if (!el) return; const enabledEl = document.getElementById('notifyEnabled'); if (enabledEl) enabledEl.checked = s.notify_enabled !== 'false'; const retentionEl = document.getElementById('notifyRetentionDays'); if (retentionEl) retentionEl.value = s.notify_retention_days !== undefined && s.notify_retention_days !== '' ? s.notify_retention_days : '30'; NOTIFY_IDS.length = 0; let types = []; try { const res = await fetch(`${API}/api/notifications/meta`, { headers: hdr() }); if (res.ok) types = (await res.json()).types || []; } catch {} if (!types.length) { el.innerHTML = 'Не удалось загрузить список событий'; return; } el.innerHTML = types.map(t => { NOTIFY_IDS.push(t.key); if (!DIRTY_FIELDS.includes(t.key)) DIRTY_FIELDS.push(t.key); const raw = s[t.key]; const value = raw !== undefined && raw !== '' ? raw : (t.default_enabled ? 'true' : 'false'); const badge = t.admin_only ? 'только админ' : ''; return ``; }).join(''); renderIcons(); } 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'; currentSystemName = 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('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30'; 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('lessonAiEnabled').checked = s.lesson_ai_enabled !== 'false'; document.getElementById('lessonAiPrompt').value = s.lesson_ai_prompt || ''; 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'; const afm = document.getElementById('photoAiFaceMode'); afm.value = ['off', 'face', 'all'].includes(s.photo_ai_face_mode) ? s.photo_ai_face_mode : 'off'; const afm2 = document.getElementById('photoAiFaceModel'); afm2.value = ['gfpgan', 'codeformer'].includes(s.photo_ai_face_model) ? s.photo_ai_face_model : 'gfpgan'; const adp = document.getElementById('photoAiDevicePref'); adp.value = ['auto', 'cuda', 'cpu'].includes(s.photo_ai_device_pref) ? s.photo_ai_device_pref : 'auto'; document.getElementById('camEnabled').checked = s.camera_enabled !== 'false'; await renderNotifyTypes(s); snapshotDirty(); updateSpamUi(); loadAiQueue(); loadAiProfiles(); renderStats(stRes); loadSystemInfo(); loadPhotoAiStatus(); } 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(btn) { const el = document.getElementById('systemInfo'); if (!el) return; if (btn) btn.classList.add('spinning'); try { const res = await fetch(`${API}/api/system-info`, { headers: hdr() }); if (res.ok) { const data = await res.json(); renderSystemInfo(data); renderStackInfo(data.stack); const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }); const live = document.getElementById('systemLive'); if (live) live.textContent = stamp; const stackLive = document.getElementById('stackLive'); if (stackLive) stackLive.textContent = stamp; return; } el.innerHTML = '
Ошибка загрузки
'; const st = document.getElementById('stackInfo'); if (st) st.innerHTML = '
Ошибка загрузки
'; } catch { el.innerHTML = '
Ошибка сети
'; const st = document.getElementById('stackInfo'); if (st) st.innerHTML = '
Ошибка сети
'; } finally { if (btn) btn.classList.remove('spinning'); } } function renderSystemInfo(si) { const db = si.database || {}; const photos = si.photos || {}; const files = si.files || {}; const uploads = si.uploads || {}; const storage = si.storage || {}; 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')}
Хранилище
Драйвер${storage.driver === 's3' ? 'S3' : 'Локальное'}
${storage.bucket ? `
Бакет${esc(storage.bucket)}${storage.prefix ? '/' + esc(storage.prefix) : ''}
` : ''}
Объектов${storage.count || 0}
Размер${esc(storage.size || '0 B')}
${diskHtml || '
Диск
Недоступно
'} `; } function fmtDuration(sec) { const s = Math.max(0, Math.round(Number(sec) || 0)); if (!s) return '—'; const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d) return `${d} ${plural(d, 'день', 'дня', 'дней')} ${h} ${plural(h, 'час', 'часа', 'часов')}`; if (h) return `${h} ${plural(h, 'час', 'часа', 'часов')} ${m} ${plural(m, 'минута', 'минуты', 'минут')}`; if (m) return `${m} ${plural(m, 'минута', 'минуты', 'минут')}`; return `${s} ${plural(s, 'секунда', 'секунды', 'секунд')}`; } function stackState(cls, text) { return `${esc(text)}`; } function stackBar(pct, color) { const p = Math.min(100, Math.max(0, Math.round(pct || 0))); return `
`; } function stackRow(label, value) { if (value === null || value === undefined || value === '') return ''; return `
${esc(label)}${value}
`; } function photoAiDeviceMismatch(pref, device) { const d = String(device || ''); if (pref === 'cuda') return !d.startsWith('cuda'); if (pref === 'cpu') return d !== 'cpu'; return false; } function renderPhotoAiStatus() { const el = document.getElementById('photoAiStatus'); if (!el) return; const svc = photoAiService; if (!svc) { el.innerHTML = 'Статус фото-ИИ недоступен'; return; } const pref = document.getElementById('photoAiDevicePref').value; const device = svc.device || '—'; const mismatch = photoAiDeviceMismatch(pref, svc.device); const color = svc.reachable === false ? '#ef4444' : mismatch ? '#f59e0b' : '#16a34a'; const chips = []; chips.push(`фактическое: ${esc(device)}`); if (svc.device_name) chips.push(`${esc(String(svc.device_name))}`); if (svc.vram_total_mb) chips.push(`VRAM: ${esc(String(svc.vram_free_mb ?? '—'))} свободно из ${esc(String(svc.vram_total_mb))} МБ`); chips.push(`Модели: ${esc((svc.models || []).join(', ') || '—')}`); chips.push(`Модели лиц: ${esc((svc.face_models || []).join(', ') || '—')}`); if (svc.loaded && svc.loaded.length) chips.push(`Загружено: ${esc(svc.loaded.join(', '))}`); if (!svc.configured) chips.push('PHOTO_AI_URL не настроен — ИИ-фото отключено'); else if (svc.reachable === false) chips.push(`сервис недоступен${svc.error ? `: ${esc(String(svc.error))}` : ''}`); else if (svc.ready === false) chips.push('модели загружаются'); if (mismatch) chips.push('желаемое устройство не совпадает с фактическим — задаётся переменной PHOTO_AI_DEVICE в контейнере'); el.innerHTML = chips.join(''); } async function loadPhotoAiStatus(btn) { const el = document.getElementById('photoAiStatus'); if (!el) return; if (btn) btn.classList.add('spinning'); try { const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=1`, { headers: hdr() }); if (!res.ok) throw new Error('status'); photoAiService = (await res.json()).service || null; renderPhotoAiStatus(); } catch { el.innerHTML = 'Не удалось загрузить статус фото-ИИ'; } finally { if (btn) btn.classList.remove('spinning'); } } function renderStackInfo(stack) { const el = document.getElementById('stackInfo'); if (!el) return; if (!stack) { el.innerHTML = '
Стек недоступен
'; return; } const app = stack.app || {}; const rt = stack.runtime || {}; const db = stack.database || {}; const cache = stack.cache || {}; const st = stack.storage || {}; const deps = stack.deps || {}; const load = Array.isArray(rt.cpu_load) ? rt.cpu_load : []; const cpus = rt.cpus || 0; let memPct = 0; if (rt.mem_total_bytes) memPct = Math.round(((rt.mem_total_bytes - (rt.mem_free_bytes || 0)) / rt.mem_total_bytes) * 100); const memColor = memPct > 90 ? '#ef4444' : memPct > 70 ? '#f59e0b' : '#16a34a'; const loadPct = cpus && load[0] ? Math.min(100, (load[0] / cpus) * 100) : 0; const loadColor = loadPct > 90 ? '#ef4444' : loadPct > 60 ? '#f59e0b' : '#16a34a'; const depsList = Object.entries(deps).filter(([, v]) => v); const depsHtml = depsList.length ? '
Библиотеки
' + depsList.map(([name, v]) => `
${esc(name)}${esc(v)}
`).join('') + '
' : ''; const appTitle = [currentSystemName || app.name, app.version].filter(Boolean).join(' '); const build = app.commit ? `${esc(appTitle)} · ${esc(app.commit)}` : esc(appTitle); const cacheState = cache.driver === 'redis' && cache.ready ? stackState('on', 'подключён') : cache.enabled ? stackState('err', 'нет связи — в памяти') : stackState('warn', 'отключён — в памяти'); const cacheTitle = cache.driver === 'redis' ? `Redis${cache.version ? ' ' + cache.version : ''}` : 'In-memory'; const s3 = st.driver === 's3'; const where = s3 ? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`)) : stackRow('Каталог', esc(st.dir || '—')); const ai = stack.photo_ai || {}; const aiState = !ai.configured ? stackState('warn', 'не настроен') : ai.reachable ? stackState(ai.ready === false ? 'warn' : 'on', ai.ready === false ? 'модели грузятся' : 'отвечает') : stackState('err', 'нет связи'); const aiVram = ai.vram_total_mb ? `${ai.vram_free_mb ?? '—'} свободно из ${ai.vram_total_mb} МБ` : null; el.innerHTML = `
Приложение
${stackRow('Версия', build)} ${stackRow('Node.js', esc(app.node || '—'))} ${stackRow('Процесс', esc(`PID ${app.pid} · ${app.rss || '—'}`))} ${stackRow('Аптайм', esc(fmtDuration(app.uptime_s)))}
Куча: ${esc(app.heap_used || '—')}${app.name ? ` · пакет ${esc(app.name)}` : ''}
${depsHtml}
Сервер
${stackRow('ОС', esc(rt.os || '—'))} ${stackRow('Ядро', esc(rt.kernel || '—'))} ${stackRow('Архитектура', esc(`${rt.arch || '—'} · ${cpus || '—'} ядер`))} ${rt.cpu_model ? `
${esc(rt.cpu_model)}
` : ''} ${stackRow('Загрузка', esc(load.length ? load.map(v => v.toFixed(2)).join(' / ') : '—'))} ${load.length ? stackBar(loadPct, loadColor) : ''} ${stackRow('Память', esc(`${rt.mem_free || '—'} свободно из ${rt.mem_total || '—'}`))} ${stackBar(memPct, memColor)} ${stackRow('Аптайм', esc(fmtDuration(rt.uptime_s)))} ${stackRow('Среда', rt.container ? esc(rt.container) : esc('без контейнера'))}
База данных
${stackRow('Состояние', db.version ? stackState('on', 'отвечает') : stackState('err', 'нет связи'))} ${stackRow('Движок', esc(`${db.engine || 'PostgreSQL'}${db.version ? ' ' + db.version : ''}`))} ${stackRow('Адрес', esc(db.host || '—'))} ${stackRow('Соединения', esc(`${db.pool_idle ?? 0} свободно из ${db.pool_total ?? 0}${db.pool_waiting ? ` · в очереди ${db.pool_waiting}` : ''}`))}
Кэш
${stackRow('Состояние', cacheState)} ${stackRow('Движок', esc(cacheTitle))} ${stackRow('Адрес', esc(cache.host || '—'))} ${stackRow('Ключей', esc(cache.keys === null || cache.keys === undefined ? '—' : String(cache.keys)))} ${stackRow('Память', esc(cache.used_memory || '—'))} ${stackRow('Попадания / промахи', esc(`${cache.hits ?? 0} / ${cache.misses ?? 0}`))} ${cache.fallback_ops ? stackRow('Операций в памяти', esc(String(cache.fallback_ops))) : ''} ${stackRow('Аптайм', esc(fmtDuration(cache.uptime_s)))}
Хранилище
${stackRow('Состояние', stackState('on', s3 ? 'S3-совместимое' : 'локальные файлы'))} ${stackRow('Драйвер', esc(s3 ? 's3' : 'local'))} ${s3 && st.endpoint ? stackRow('Адрес', esc(st.endpoint)) : ''} ${where} ${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))} ${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
Фото-ИИ
${stackRow('Состояние', aiState)} ${stackRow('Движок', esc(ai.engine || 'Real-ESRGAN + GFPGAN'))} ${stackRow('Адрес', esc(ai.host || '—'))} ${stackRow('Устройство', esc(`${ai.device || '—'}${ai.device_name ? ` · ${ai.device_name}` : ''}`))} ${ai.half === null || ai.half === undefined ? '' : stackRow('Режим', esc(ai.half ? 'fp16 (half)' : 'fp32'))} ${aiVram ? stackRow('VRAM', esc(aiVram)) : ''} ${stackRow('Модели', esc((ai.models || []).join(', ') || '—'))} ${stackRow('Модели лиц', esc((ai.face_models || []).join(', ') || '—'))} ${stackRow('Загружено', esc((ai.loaded || []).join(', ') || '—'))} ${ai.error ? `
${esc(String(ai.error))}
` : ''}
`; } 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.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}
${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('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 = ` Автопроверка: ${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(); }); function notifySettingsPayload(retentionDays) { const out = { notify_enabled: document.getElementById('notifyEnabled').checked ? 'true' : 'false', notify_retention_days: String(retentionDays), }; NOTIFY_IDS.forEach(id => { const el = document.getElementById(id); if (el) out[id] = el.checked ? 'true' : 'false'; }); return out; } 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 trashValue = document.getElementById('trashPurgeDays').value; const trashDays = Math.round(Number(trashValue)); if (trashValue === '' || !Number.isFinite(trashDays) || trashDays < 1 || trashDays > 365) { showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)'); return; } const notifyRetentionValue = document.getElementById('notifyRetentionDays').value; const notifyRetentionDays = Math.round(Number(notifyRetentionValue)); if (notifyRetentionValue === '' || !Number.isFinite(notifyRetentionDays) || notifyRetentionDays < 1 || notifyRetentionDays > 365) { showError('Проверьте поле «Хранение уведомлений» (от 1 до 365 дней)'); 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: { 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', lesson_ai_enabled: document.getElementById('lessonAiEnabled').checked ? 'true' : 'false', lesson_ai_prompt: document.getElementById('lessonAiPrompt').value.trim(), 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, photo_ai_face_mode: document.getElementById('photoAiFaceMode').value, photo_ai_face_model: document.getElementById('photoAiFaceModel').value, photo_ai_device_pref: document.getElementById('photoAiDevicePref').value, camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false', trash_purge_days: String(trashDays), ...notifySettingsPayload(notifyRetentionDays), } }) }); if (res.ok) { showToast('Настройки сохранены'); logoOriginal = currentLogo; snapshotDirty(); updateSpamUi(); } else { const e = await res.json().catch(() => ({})); showError(e.error || 'Ошибка сохранения'); } } catch { showError('Ошибка сети при сохранении'); } finally { setBtnLoading(btn, false); } } function formatBackupSize(b) { if (!b || b < 0) return ''; if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' ГБ'; return (b / 1048576).toFixed(1) + ' МБ'; } async function downloadBackup() { const btn = document.getElementById('backupBtn'); const st = document.getElementById('backupStatus'); btn.dataset.loadingText = 'Формирование...'; setBtnLoading(btn, true); if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; } try { const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; } showError('Ошибка формирования бэкапа: ' + (data.error || res.status)); return; } const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null); if (head && !head.ok) { if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; } showError('Ошибка скачивания бэкапа'); return; } const a = document.createElement('a'); a.href = data.url; a.download = data.filename || 'whatido-backup.tar.gz'; document.body.appendChild(a); a.click(); a.remove(); const size = formatBackupSize(data.size); if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`; showToast('Бэкап сформирован' + (size ? ': ' + size : '')); } catch (err) { if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; } 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', () => loadSystemInfo(systemRefreshBtn)); const stackRefreshBtn = document.getElementById('stackRefreshBtn'); if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn)); const photoAiRefreshBtn = document.getElementById('photoAiRefreshBtn'); if (photoAiRefreshBtn) photoAiRefreshBtn.addEventListener('click', () => loadPhotoAiStatus(photoAiRefreshBtn)); const photoAiDevicePref = document.getElementById('photoAiDevicePref'); if (photoAiDevicePref) photoAiDevicePref.addEventListener('change', () => renderPhotoAiStatus()); const notifyTestBtn = document.getElementById('notifyTestBtn'); if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => { notifyTestBtn.classList.add('spinning'); try { const res = await fetch(`${API}/api/notifications/test`, { method: 'POST', headers: hdr() }); const data = await res.json().catch(() => ({})); if (!res.ok) { showError(data.error || 'Ошибка отправки уведомления'); return; } if (data.delivered) showToast('Тестовое уведомление отправлено'); else showError('Уведомление не создано: выключен общий переключатель или событие «Тестовое уведомление»'); } catch { showError('Ошибка сети'); } finally { notifyTestBtn.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(' Загрузка...'); 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(`${esc(d.error || 'Ошибка загрузки')}`); } } catch { logoStatusText('Ошибка сети'); } finally { btn.disabled = false; e.target.value = ''; } }); document.getElementById('logoRemoveBtn').addEventListener('click', async () => { if (!confirm('Удалить логотип? Название системы сохранится.')) return; logoStatusText(' Удаление...'); try { const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() }); if (res.ok) { currentLogo = ''; logoOriginal = ''; renderLogoPreview(); logoStatusText(''); showToast('Логотип удалён'); } else { logoStatusText('Ошибка удаления'); } } catch { logoStatusText('Ошибка сети'); } }); 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); initSectionNav(); updateSpamUi(); const authStatus = document.getElementById('authStatus'); try { await loadSettings(); bindDirty(); if (authStatus) authStatus.classList.add('loaded'); } catch { if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; } } loadBans(); } })();