Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
1183 lines
54 KiB
JavaScript
1183 lines
54 KiB
JavaScript
const DIRTY_FIELDS = ['systemName', 'timezone', 'timeFormat', '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-datetime', '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 fillTimezoneOptions() {
|
||
const sel = document.getElementById('timezone');
|
||
if (!sel || sel.options.length) return;
|
||
let zones = [];
|
||
try { zones = Intl.supportedValuesOf('timeZone'); } catch { zones = []; }
|
||
if (!zones.length) zones = [DateTime.tz || 'Europe/Moscow'];
|
||
const browserTz = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
|
||
const current = sel.dataset.value || DateTime.tz;
|
||
sel.innerHTML = zones.map(z => {
|
||
const off = tzOffsetLabel(z);
|
||
const label = off ? `${z} (UTC${off})` : z;
|
||
return `<option value="${esc(z)}">${esc(label)}</option>`;
|
||
}).join('');
|
||
const extra = browserTz && !zones.includes(browserTz) ? [browserTz] : [];
|
||
extra.forEach(z => { sel.insertAdjacentHTML('afterbegin', `<option value="${esc(z)}">${esc(z)}</option>`); });
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
function tzOffsetLabel(tz) {
|
||
try {
|
||
const parts = new Intl.DateTimeFormat('ru-RU', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(new Date());
|
||
const name = parts.find(p => p.type === 'timeZoneName');
|
||
return name ? String(name.value).replace('GMT', '') : '';
|
||
} catch { return ''; }
|
||
}
|
||
|
||
function previewTz() {
|
||
const sel = document.getElementById('timezone');
|
||
return sel && dtValidTz(sel.value) ? sel.value : DateTime.tz;
|
||
}
|
||
|
||
function previewHour12() {
|
||
const el = document.getElementById('timeFormat');
|
||
return !!el && el.value === '12h';
|
||
}
|
||
|
||
function renderTimePreview() {
|
||
const box = document.getElementById('timePreview');
|
||
if (!box) return;
|
||
const tz = previewTz();
|
||
const h12 = previewHour12();
|
||
const now = new Date();
|
||
const sample = new Date(now.getTime() + 3661 * 1000);
|
||
box.innerHTML = `<span class="chip">Сейчас: ${esc(fmtIn(sample, tz, h12))}</span>`
|
||
+ `<span class="chip-sub">${esc(fmtIn(now, tz, h12))}</span>`
|
||
+ `<span class="chip-sub">Расписание 18:30 → ${esc(fmtHmIn('18:30:00', h12))}</span>`
|
||
+ `<span class="chip-sub">Отчёт за занятие 09:05 → ${esc(fmtHmIn('09:05:00', h12))}</span>`;
|
||
}
|
||
|
||
function fmtIn(date, tz, h12) {
|
||
return new Intl.DateTimeFormat('ru-RU', { timeZone: tz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: h12 }).format(date);
|
||
}
|
||
|
||
function fmtHmIn(v, h12) {
|
||
const saved = DateTime.hour12;
|
||
DateTime.hour12 = h12;
|
||
const out = fmtHmStr(v);
|
||
DateTime.hour12 = saved;
|
||
return out;
|
||
}
|
||
|
||
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 bindTimePreview() {
|
||
['timezone', 'timeFormat'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.addEventListener('change', renderTimePreview);
|
||
el.addEventListener('input', renderTimePreview);
|
||
});
|
||
}
|
||
|
||
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();
|
||
renderTimePreview();
|
||
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 = '<span class="hint">Не удалось загрузить список событий</span>';
|
||
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 ? '<span class="notify-type-badge">только админ</span>' : '';
|
||
return `<label class="toggle">
|
||
<input type="checkbox" id="${esc(t.key)}" ${value !== 'false' ? 'checked' : ''}>
|
||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||
<span class="toggle-text"><b>${esc(t.label)}${badge}</b><small>${esc(t.hint || '')}</small></span>
|
||
</label>`;
|
||
}).join('');
|
||
renderIcons();
|
||
}
|
||
|
||
async function loadSettings() {
|
||
fillTimezoneOptions();
|
||
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';
|
||
const tzSel = document.getElementById('timezone');
|
||
const storedTz = String(s.timezone || '');
|
||
tzSel.value = Array.from(tzSel.options).some(o => o.value === storedTz) ? storedTz : DateTime.tz;
|
||
document.getElementById('timeFormat').value = ['24h', '12h'].includes(s.time_format) ? s.time_format : '24h';
|
||
renderTimePreview();
|
||
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 = '<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(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 = 'обновлено ' + fmtTimeOnly(new Date());
|
||
const live = document.getElementById('systemLive');
|
||
if (live) live.textContent = stamp;
|
||
const stackLive = document.getElementById('stackLive');
|
||
if (stackLive) stackLive.textContent = stamp;
|
||
return;
|
||
}
|
||
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||
const st = document.getElementById('stackInfo');
|
||
if (st) st.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||
} catch {
|
||
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||
const st = document.getElementById('stackInfo');
|
||
if (st) st.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||
} 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 = '<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>
|
||
<div class="sys-card">
|
||
<div class="sys-title">Хранилище</div>
|
||
<div class="sys-item"><span class="sys-label">Драйвер</span><span class="sys-value">${storage.driver === 's3' ? 'S3' : 'Локальное'}</span></div>
|
||
${storage.bucket ? `<div class="sys-item"><span class="sys-label">Бакет</span><span class="sys-value">${esc(storage.bucket)}${storage.prefix ? '/' + esc(storage.prefix) : ''}</span></div>` : ''}
|
||
<div class="sys-item"><span class="sys-label">Объектов</span><span class="sys-value">${storage.count || 0}</span></div>
|
||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(storage.size || '0 B')}</span></div>
|
||
</div>
|
||
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
|
||
`;
|
||
}
|
||
|
||
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 `<span class="sys-inline"><span class="worker-dot ${cls}"></span>${esc(text)}</span>`;
|
||
}
|
||
|
||
function stackBar(pct, color) {
|
||
const p = Math.min(100, Math.max(0, Math.round(pct || 0)));
|
||
return `<div class="sys-bar-wrap"><div class="sys-bar" style="width:${p}%; background:${color}"></div></div>`;
|
||
}
|
||
|
||
function stackRow(label, value) {
|
||
if (value === null || value === undefined || value === '') return '';
|
||
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
|
||
}
|
||
|
||
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 = '<span class="hint">Статус фото-ИИ недоступен</span>'; 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(`<span class="chip" style="color:${color}">фактическое: <b>${esc(device)}</b></span>`);
|
||
if (svc.device_name) chips.push(`<span class="chip">${esc(String(svc.device_name))}</span>`);
|
||
if (svc.vram_total_mb) chips.push(`<span class="chip">VRAM: <b>${esc(String(svc.vram_free_mb ?? '—'))}</b> свободно из ${esc(String(svc.vram_total_mb))} МБ</span>`);
|
||
chips.push(`<span class="chip">Модели: <b>${esc((svc.models || []).join(', ') || '—')}</b></span>`);
|
||
chips.push(`<span class="chip">Модели лиц: <b>${esc((svc.face_models || []).join(', ') || '—')}</b></span>`);
|
||
if (svc.loaded && svc.loaded.length) chips.push(`<span class="chip">Загружено: <b>${esc(svc.loaded.join(', '))}</b></span>`);
|
||
if (!svc.configured) chips.push('<span class="chip" style="color:#ef4444">PHOTO_AI_URL не настроен — ИИ-фото отключено</span>');
|
||
else if (svc.reachable === false) chips.push(`<span class="chip" style="color:#ef4444">сервис недоступен${svc.error ? `: ${esc(String(svc.error))}` : ''}</span>`);
|
||
else if (svc.ready === false) chips.push('<span class="chip" style="color:#f59e0b">модели загружаются</span>');
|
||
if (mismatch) chips.push('<span class="chip" style="color:#f59e0b">желаемое устройство не совпадает с фактическим — задаётся переменной PHOTO_AI_DEVICE в контейнере</span>');
|
||
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 = '<span class="hint">Не удалось загрузить статус фото-ИИ</span>';
|
||
} finally {
|
||
if (btn) btn.classList.remove('spinning');
|
||
}
|
||
}
|
||
|
||
function renderStackInfo(stack) {
|
||
const el = document.getElementById('stackInfo');
|
||
if (!el) return;
|
||
if (!stack) {
|
||
el.innerHTML = '<div class="sys-card err">Стек недоступен</div>';
|
||
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
|
||
? '<details class="sys-details"><summary>Библиотеки</summary><div class="sys-table-list">' +
|
||
depsList.map(([name, v]) => `<div class="sys-table-row"><span>${esc(name)}</span><span>${esc(v)}</span></div>`).join('') +
|
||
'</div></details>'
|
||
: '';
|
||
|
||
const appTitle = [currentSystemName || app.name, app.version].filter(Boolean).join(' ');
|
||
const build = app.commit
|
||
? `${esc(appTitle)} · <span title="${esc(app.commit_date || '')}">${esc(app.commit)}</span>`
|
||
: 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 = `
|
||
<div class="sys-card">
|
||
<div class="sys-title">Приложение</div>
|
||
${stackRow('Версия', build)}
|
||
${stackRow('Node.js', esc(app.node || '—'))}
|
||
${stackRow('Процесс', esc(`PID ${app.pid} · ${app.rss || '—'}`))}
|
||
${stackRow('Аптайм', esc(fmtDuration(app.uptime_s)))}
|
||
<div class="sys-meta">Куча: ${esc(app.heap_used || '—')}${app.name ? ` · пакет ${esc(app.name)}` : ''}</div>
|
||
${depsHtml}
|
||
</div>
|
||
|
||
<div class="sys-card${memPct > 90 ? ' danger' : memPct > 70 ? ' warn' : ''}">
|
||
<div class="sys-title">Сервер</div>
|
||
${stackRow('ОС', esc(rt.os || '—'))}
|
||
${stackRow('Ядро', esc(rt.kernel || '—'))}
|
||
${stackRow('Архитектура', esc(`${rt.arch || '—'} · ${cpus || '—'} ядер`))}
|
||
${rt.cpu_model ? `<div class="sys-meta">${esc(rt.cpu_model)}</div>` : ''}
|
||
${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('без контейнера'))}
|
||
</div>
|
||
|
||
<div class="sys-card">
|
||
<div class="sys-title">База данных</div>
|
||
${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}` : ''}`))}
|
||
</div>
|
||
|
||
<div class="sys-card">
|
||
<div class="sys-title">Кэш</div>
|
||
${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)))}
|
||
</div>
|
||
|
||
<div class="sys-card">
|
||
<div class="sys-title">Хранилище</div>
|
||
${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 ? 'хранятся' : 'удаляются')) : ''}
|
||
</div>
|
||
|
||
<div class="sys-card">
|
||
<div class="sys-title">Фото-ИИ</div>
|
||
${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 ? `<div class="sys-meta" style="color:#ef4444">${esc(String(ai.error))}</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();
|
||
});
|
||
|
||
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(),
|
||
timezone: document.getElementById('timezone').value,
|
||
time_format: document.getElementById('timeFormat').value,
|
||
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) {
|
||
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
|
||
renderTimePreview();
|
||
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 size = formatBackupSize(data.size);
|
||
if (st) {
|
||
st.classList.remove('err');
|
||
st.innerHTML = '';
|
||
const link = document.createElement('a');
|
||
link.href = data.url;
|
||
link.download = data.filename || 'whatido-backup.tar.gz';
|
||
link.textContent = data.filename || 'whatido-backup.tar.gz';
|
||
link.style.color = 'var(--accent)';
|
||
st.appendChild(link);
|
||
const note = document.createElement('span');
|
||
note.textContent = `${size ? ` (${size})` : ''} — ссылка активна 30 минут, скачивание можно повторить`;
|
||
st.appendChild(note);
|
||
}
|
||
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();
|
||
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 = '<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 || '—')} · до ${fmtFull(b.banned_until)}</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', () => 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('<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);
|
||
initSectionNav();
|
||
updateSpamUi();
|
||
const authStatus = document.getElementById('authStatus');
|
||
try {
|
||
await loadSettings();
|
||
bindDirty();
|
||
bindTimePreview();
|
||
if (authStatus) authStatus.classList.add('loaded');
|
||
} catch {
|
||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||
}
|
||
loadBans();
|
||
}
|
||
})();
|