fix(ai): adaptive max_tokens per backend (reasoning profiles get 1024+, native llama.cpp stays compact; per-profile max_tokens override); include entry texts in ai_status SSE notify so journal updates live without reload; audit log action filter; links pagination; settings validation & UI improvements
This commit is contained in:
@@ -41,6 +41,11 @@ const ACTION_LABELS = {
|
||||
'ai.wake': 'Воркер ИИ разбужен вручную',
|
||||
'ai.enabled': 'Переключена автопроверка ИИ',
|
||||
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
||||
'ai.profile.create': 'Создан профиль ИИ',
|
||||
'ai.profile.update': 'Изменён профиль ИИ',
|
||||
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||
'export.student': 'Экспорт данных ученика',
|
||||
};
|
||||
|
||||
const AUDIT_PAGE_SIZE = 50;
|
||||
@@ -95,6 +100,8 @@ async function loadAudit() {
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', AUDIT_PAGE_SIZE);
|
||||
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
||||
const action = document.getElementById('auditActionFilter').value;
|
||||
if (action) p.set('action', action);
|
||||
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const rows = await res.json();
|
||||
@@ -126,6 +133,18 @@ function goAuditPage(p) {
|
||||
loadAudit();
|
||||
}
|
||||
|
||||
function fillActionFilter() {
|
||||
const sel = document.getElementById('auditActionFilter');
|
||||
const current = sel.value;
|
||||
const opts = Object.entries(ACTION_LABELS)
|
||||
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
|
||||
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
|
||||
.join('');
|
||||
sel.innerHTML = '<option value="">Все действия</option>' + opts;
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
|
||||
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
||||
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
||||
|
||||
@@ -148,6 +167,7 @@ document.getElementById('auditDetailsModal').addEventListener('click', e => {
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
fillActionFilter();
|
||||
loadAudit();
|
||||
}
|
||||
})();
|
||||
+61
-13
@@ -84,7 +84,8 @@ async function detachFile(id) {
|
||||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
||||
if (res.ok) {
|
||||
showToast('Файл откреплён');
|
||||
loadEntries();
|
||||
if (f.entry_id) refreshEntryCard(f.entry_id);
|
||||
else loadEntries();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Ошибка');
|
||||
@@ -161,6 +162,29 @@ function renderGrid() {
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
function refreshEntryCardDom(e) {
|
||||
const btn = document.querySelector(`[data-edit="${e.id}"]`);
|
||||
const card = btn ? btn.closest('.card, .journal-card') : null;
|
||||
if (!card) return;
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = (view === 'cards' ? cardEntryHTML(e) : gridEntryHTML(e)).trim();
|
||||
const fresh = tmp.firstElementChild;
|
||||
fresh.classList.remove('entry-enter', 'entry-appear');
|
||||
fresh.style.animationDelay = '';
|
||||
card.replaceWith(fresh);
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
async function refreshEntryCard(id) {
|
||||
const res = await fetch(`${API}/api/entries/${id}`, { headers: hdr() });
|
||||
if (!res.ok) return false;
|
||||
const e = await res.json();
|
||||
const idx = currentEntries.findIndex(x => x.id === e.id);
|
||||
if (idx !== -1) currentEntries[idx] = e;
|
||||
refreshEntryCardDom(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setView(v) {
|
||||
view = v === 'cards' ? 'cards' : 'list';
|
||||
localStorage.setItem('journalView', view);
|
||||
@@ -236,26 +260,36 @@ function goPage(p) {
|
||||
|
||||
let liveSource = null;
|
||||
let liveReconnectTimer = null;
|
||||
let aiRefreshTimer = null;
|
||||
let entriesRefreshTimer = null;
|
||||
function applyAiUpdate(update) {
|
||||
const e = currentEntries.find(x => x.id === update.id);
|
||||
if (!e) return;
|
||||
e.ai_status = update.ai_status;
|
||||
e.ai_error = update.ai_error || null;
|
||||
if (update.ai_status === 'done') {
|
||||
if (aiRefreshTimer) return;
|
||||
aiRefreshTimer = setTimeout(() => { aiRefreshTimer = null; loadEntries(); }, 300);
|
||||
return;
|
||||
}
|
||||
if (update.description !== null && update.description !== undefined) e.description = update.description;
|
||||
if (update.description_ai !== null && update.description_ai !== undefined) e.description_ai = update.description_ai;
|
||||
if (update.description_original !== null && update.description_original !== undefined) e.description_original = update.description_original;
|
||||
if (update.ai_status === 'done' || update.ai_status === 'reverted') e.ai_checked_at = new Date().toISOString();
|
||||
document.querySelectorAll(`[data-ai-badge="${update.id}"]`).forEach(badgeEl => {
|
||||
badgeEl.outerHTML = aiBadge(e);
|
||||
const desc = badgeEl.closest('.desc');
|
||||
if (desc && update.description !== null && update.description !== undefined) {
|
||||
desc.textContent = e.description;
|
||||
desc.insertAdjacentHTML('beforeend', aiBadge(e));
|
||||
} else {
|
||||
badgeEl.outerHTML = aiBadge(e);
|
||||
}
|
||||
});
|
||||
renderIcons();
|
||||
const editBadge = document.getElementById('editAiStatus');
|
||||
if (editId === update.id && editBadge && editBadge.style.display !== 'none') {
|
||||
renderEditAiStatus(e);
|
||||
}
|
||||
if (editId === update.id) {
|
||||
const ta = document.getElementById('editDesc');
|
||||
if (ta && document.activeElement !== ta && update.description !== null && update.description !== undefined) {
|
||||
ta.value = e.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
function connectLiveUpdates() {
|
||||
if (liveSource) { liveSource.close(); liveSource = null; }
|
||||
@@ -335,7 +369,7 @@ function renderEditPhotos(photos, mainPath) {
|
||||
if (res.ok) {
|
||||
div.remove();
|
||||
showToast('Фото удалено');
|
||||
loadEntries();
|
||||
refreshEntryCard(editId);
|
||||
} else {
|
||||
alert('Ошибка удаления');
|
||||
}
|
||||
@@ -368,7 +402,7 @@ async function applyMainPhoto(btn) {
|
||||
btn.style.background = '#16a34a';
|
||||
btn.style.color = '#fff';
|
||||
showToast('Главное фото обновлено');
|
||||
loadEntries();
|
||||
refreshEntryCard(editId);
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
@@ -450,17 +484,29 @@ async function revertAi(id) {
|
||||
if (!confirm('Вернуть исходный текст записи (до ИИ-проверки)?')) return;
|
||||
const res = await fetch(`${API}/api/entries/${id}/ai/revert`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (e) {
|
||||
if (e.description_original) e.description = e.description_original;
|
||||
e.description_ai = null;
|
||||
e.ai_status = 'reverted';
|
||||
e.ai_error = null;
|
||||
}
|
||||
showToast('Оригинал восстановлен');
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
refreshEntryCard(id);
|
||||
}
|
||||
|
||||
async function recheckAi(id) {
|
||||
const res = await fetch(`${API}/api/entries/${id}/ai/recheck`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (e) {
|
||||
e.ai_status = 'pending';
|
||||
e.ai_error = null;
|
||||
}
|
||||
showToast('Отправлено на ИИ-проверку');
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
refreshEntryCard(id);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
@@ -501,8 +547,10 @@ async function saveEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
const id = editId;
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
const refreshed = await refreshEntryCard(id);
|
||||
if (!refreshed) loadEntries();
|
||||
showToast('Запись обновлена');
|
||||
}
|
||||
|
||||
|
||||
+23
-6
@@ -3,6 +3,8 @@ let groups = [];
|
||||
let editingId = null;
|
||||
let studentsLoaded = false;
|
||||
let linkSettings = null;
|
||||
let linkPage = 1;
|
||||
const LINK_PAGE_SIZE = 20;
|
||||
|
||||
async function loadLinkSettings() {
|
||||
if (!linkSettings) {
|
||||
@@ -16,21 +18,30 @@ function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
||||
|
||||
async function loadLinks() {
|
||||
await loadLinkSettings();
|
||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
||||
links = await res.json();
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', LINK_PAGE_SIZE);
|
||||
p.set('offset', (linkPage - 1) * LINK_PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/links?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
links = data.items;
|
||||
const total = data.total;
|
||||
const el = document.getElementById('linksList');
|
||||
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
||||
if (!links.length) { el.innerHTML = `<div class="empty">${linkPage > 1 ? 'На этой странице ссылок нет.' : 'Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».'}</div>`; renderPager(document.getElementById('pager'), 1, 1, goLinkPage); return; }
|
||||
const now = Date.now();
|
||||
el.innerHTML = links.map(l => {
|
||||
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
|
||||
const expired = l.expires_at && new Date(l.expires_at).getTime() < now;
|
||||
const daysLeft = l.expires_at ? Math.ceil((new Date(l.expires_at).getTime() - now) / 86400000) : null;
|
||||
const soon = !expired && daysLeft !== null && daysLeft <= 1;
|
||||
return `
|
||||
<div class="item">
|
||||
<div class="item${expired ? ' expired' : ''}">
|
||||
<div style="min-width:0">
|
||||
<div class="name">${esc(l.name)}</div>
|
||||
<div class="name">${esc(l.name)}${expired ? ' <span class="badge-expired">истекла</span>' : soon ? ' <span class="badge-soon">истекает</span>' : ''}</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||
@@ -44,6 +55,12 @@ async function loadLinks() {
|
||||
</div>
|
||||
</div>
|
||||
`;}).join('');
|
||||
renderPager(document.getElementById('pager'), linkPage, Math.max(1, Math.ceil(total / LINK_PAGE_SIZE)), goLinkPage);
|
||||
}
|
||||
|
||||
function goLinkPage(p) {
|
||||
linkPage = p;
|
||||
loadLinks();
|
||||
}
|
||||
|
||||
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||
|
||||
+308
-89
@@ -1,4 +1,12 @@
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
'login-bruteforce': 'Подбор пароля входа',
|
||||
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||
'manual': 'Вручную',
|
||||
};
|
||||
|
||||
function fieldValue(el) {
|
||||
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
||||
@@ -8,19 +16,67 @@ function setBtnLoading(btn, on, spinnerClass) {
|
||||
if (on) {
|
||||
btn.dataset.label = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
|
||||
btn.innerHTML = `<span class="spinner ${cls}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.classList.add('show', 'error');
|
||||
clearTimeout(t._errTimer);
|
||||
t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
|
||||
}
|
||||
|
||||
function showFieldError(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
const err = document.getElementById(`${id}Error`);
|
||||
if (el) el.classList.add('invalid');
|
||||
if (err) { err.textContent = msg; err.hidden = false; }
|
||||
}
|
||||
|
||||
function clearFieldError(id) {
|
||||
const el = document.getElementById(id);
|
||||
const err = document.getElementById(`${id}Error`);
|
||||
if (el) el.classList.remove('invalid');
|
||||
if (err) err.hidden = true;
|
||||
}
|
||||
|
||||
function plural(n, one, few, many) {
|
||||
const a = n % 10, b = n % 100;
|
||||
if (a === 1 && b !== 11) return one;
|
||||
if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
function humanizeMinutes(min) {
|
||||
const n = Number(min);
|
||||
if (!Number.isFinite(n) || n < 1) return '';
|
||||
if (n === 1440) return 'сутки';
|
||||
if (n > 1440 && n % 1440 === 0) {
|
||||
const d = n / 1440;
|
||||
return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
|
||||
}
|
||||
const h = Math.floor(n / 60);
|
||||
const m = n % 60;
|
||||
const parts = [];
|
||||
if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
|
||||
if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
|
||||
if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirty = DIRTY_FIELDS.some(id => {
|
||||
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
||||
const el = document.getElementById(id);
|
||||
return String(el.dataset.original ?? '') !== fieldValue(el);
|
||||
});
|
||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||||
}).length;
|
||||
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
|
||||
}
|
||||
|
||||
function bindDirty() {
|
||||
@@ -36,11 +92,54 @@ function snapshotDirty() {
|
||||
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;
|
||||
});
|
||||
clearFieldError('spamInterval');
|
||||
updateSpamUi();
|
||||
updateDirty();
|
||||
showToast('Изменения отменены');
|
||||
}
|
||||
|
||||
function initSectionNav() {
|
||||
const nav = document.getElementById('settingsNav');
|
||||
if (!nav) return;
|
||||
const links = [...nav.querySelectorAll('a')];
|
||||
const smooth = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
||||
let ticking = false;
|
||||
const setActive = () => {
|
||||
ticking = false;
|
||||
let current = SECTION_IDS[0];
|
||||
for (const id of SECTION_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.getBoundingClientRect().top <= 170) current = id;
|
||||
}
|
||||
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${current}`));
|
||||
};
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) { ticking = true; requestAnimationFrame(setActive); }
|
||||
}, { passive: true });
|
||||
setActive();
|
||||
nav.addEventListener('click', e => {
|
||||
const a = e.target.closest('a');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const target = document.getElementById(a.getAttribute('href').slice(1));
|
||||
if (target) target.scrollIntoView({ behavior: smooth, block: 'start' });
|
||||
history.replaceState(null, '', a.getAttribute('href'));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
]);
|
||||
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
||||
const s = await sRes.json();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
@@ -53,27 +152,52 @@ async function loadSettings() {
|
||||
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
||||
snapshotDirty();
|
||||
updateSpamUi();
|
||||
loadAiQueue();
|
||||
loadAiProfiles();
|
||||
if (stRes.ok) {
|
||||
const st = await stRes.json();
|
||||
document.getElementById('dataStats').innerHTML = `
|
||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('dataStats').innerHTML = '';
|
||||
}
|
||||
renderStats(stRes);
|
||||
loadSystemInfo();
|
||||
}
|
||||
|
||||
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (siRes.ok) {
|
||||
const si = await siRes.json();
|
||||
renderSystemInfo(si);
|
||||
} else {
|
||||
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
async function renderStats(res) {
|
||||
const el = document.getElementById('dataStats');
|
||||
if (!el) return;
|
||||
if (!res || !res.ok) { el.innerHTML = '<span class="chip">Статистика недоступна</span>'; return; }
|
||||
const st = await res.json();
|
||||
el.innerHTML = `
|
||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
const el = document.getElementById('dataStats');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<span class="chip">Обновление…</span>';
|
||||
try {
|
||||
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
|
||||
} catch {
|
||||
el.innerHTML = '<span class="chip">Ошибка сети</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemInfo() {
|
||||
const el = document.getElementById('systemInfo');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (res.ok) {
|
||||
renderSystemInfo(await res.json());
|
||||
const live = document.getElementById('systemLive');
|
||||
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
} catch {
|
||||
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +211,7 @@ function renderSystemInfo(si) {
|
||||
let tablesHtml = '';
|
||||
if (db.tables && db.tables.length) {
|
||||
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
|
||||
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
|
||||
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
|
||||
'</div></details>';
|
||||
}
|
||||
|
||||
@@ -95,29 +219,28 @@ function renderSystemInfo(si) {
|
||||
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-item">
|
||||
<span class="sys-label">Диск</span>
|
||||
<span class="sys-value">${disk.used} / ${disk.total}</span>
|
||||
</div>
|
||||
<div class="sys-bar-wrap">
|
||||
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
|
||||
</div>
|
||||
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
|
||||
`;
|
||||
<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">${db.size || '—'}</span></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} · ${photos.group_photos?.size || '0 B'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${photos.total_size || '0 B'}</b></span></div>
|
||||
<div 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>
|
||||
@@ -126,18 +249,14 @@ function renderSystemInfo(si) {
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Загрузки</div>
|
||||
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Диск</div>
|
||||
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
|
||||
</div>
|
||||
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAiProfiles() {
|
||||
const listEl = document.getElementById('aiProfilesList');
|
||||
const activeBadge = document.getElementById('aiProfileActive');
|
||||
const select = document.getElementById('aiActiveProfile');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
@@ -145,16 +264,6 @@ async function loadAiProfiles() {
|
||||
if (!res.ok) { listEl.innerHTML = '<span class="chip">Не удалось загрузить профили</span>'; return; }
|
||||
const data = await res.json();
|
||||
const profiles = data.profiles || [];
|
||||
if (activeBadge) {
|
||||
if (data.active === 'native') {
|
||||
activeBadge.innerHTML = `По умолчанию: <b>Нативная (llama.cpp)</b> — <code>${esc(data.native.model)}</code>`;
|
||||
} else {
|
||||
const p = profiles.find(x => x.id === data.active);
|
||||
activeBadge.innerHTML = p
|
||||
? `По умолчанию: <b>${esc(p.name)}</b> — <code>${esc(p.model)}</code>`
|
||||
: 'По умолчанию: нативная модель';
|
||||
}
|
||||
}
|
||||
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('');
|
||||
@@ -212,8 +321,14 @@ async function loadAiQueue() {
|
||||
if (!res.ok) { el.textContent = ''; return; }
|
||||
const q = await res.json();
|
||||
const c = q.counts || {};
|
||||
const state = q.enabled ? 'включена' : 'выключена';
|
||||
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
|
||||
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 = ''; }
|
||||
}
|
||||
|
||||
@@ -233,7 +348,7 @@ async function saveAiProfile() {
|
||||
loadAiProfiles();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
st.innerHTML = `<span style="color:#ef4444;font-weight:600">${esc(err.error || 'Ошибка сохранения')}</span>`;
|
||||
st.innerHTML = `<span class="fail">${esc(err.error || 'Ошибка сохранения')}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,8 +358,8 @@ async function testAiProfile() {
|
||||
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 style="color:#16a34a;font-weight:600">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
|
||||
: `<span style="color:#ef4444;font-weight:600">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
|
||||
? `<span class="ok">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
|
||||
: `<span class="fail">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
|
||||
}
|
||||
|
||||
async function activateAiProfile(id) {
|
||||
@@ -255,34 +370,79 @@ async function activateAiProfile(id) {
|
||||
loadAiQueue();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Ошибка');
|
||||
showError(err.error || 'Ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAiProfile(id, name) {
|
||||
if (!confirm(`Удалить профиль «${name}»?`)) return;
|
||||
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(() => ({}));
|
||||
alert(err.error || 'Ошибка удаления');
|
||||
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.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
||||
});
|
||||
document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value));
|
||||
document.getElementById('aiProfilesList').addEventListener('click', e => {
|
||||
const del = e.target.closest('[data-del-profile]');
|
||||
if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName); return; }
|
||||
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() })
|
||||
@@ -294,9 +454,20 @@ document.getElementById('aiProfilesList').addEventListener('click', e => {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||
const v = spamInput.value;
|
||||
const n = Number(v);
|
||||
if (v === '' || !Number.isFinite(n) || Math.round(n) < 1) {
|
||||
showFieldError('spamInterval', 'Укажите целое число минут (минимум 1)');
|
||||
spamInput.focus();
|
||||
showError('Проверьте поле «Интервал ожидания»');
|
||||
return;
|
||||
}
|
||||
clearFieldError('spamInterval');
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
@@ -320,7 +491,13 @@ async function saveSettings() {
|
||||
if (res.ok) {
|
||||
showToast('Настройки сохранены');
|
||||
snapshotDirty();
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
updateSpamUi();
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
showError(e.error || 'Ошибка сохранения');
|
||||
}
|
||||
} catch {
|
||||
showError('Ошибка сети при сохранении');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
@@ -332,7 +509,7 @@ async function downloadBackup() {
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
@@ -344,6 +521,8 @@ async function downloadBackup() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
} catch {
|
||||
showError('Ошибка сети при формировании бэкапа');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
@@ -367,7 +546,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
|
||||
} catch (err) {
|
||||
st.textContent = '';
|
||||
alert('Ошибка сети: ' + err.message);
|
||||
showError('Ошибка сети: ' + err.message);
|
||||
e.target.value = '';
|
||||
return;
|
||||
} finally {
|
||||
@@ -381,8 +560,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
} else {
|
||||
st.classList.add('err');
|
||||
st.innerHTML = 'Ошибка восстановления';
|
||||
const t = await res.text();
|
||||
alert('Ошибка: ' + t);
|
||||
showError(await res.text());
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
@@ -390,35 +568,37 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
async function loadBans() {
|
||||
const el = document.getElementById('bansList');
|
||||
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
||||
if (res.status === 403) { document.getElementById('bansCard').style.display = 'none'; return; }
|
||||
if (res.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" data-chip="${esc(b.ip)}">
|
||||
<b>${esc(b.ip)}</b> — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
|
||||
<button type="button" data-unban="${esc(b.ip)}" title="Разблокировать" style="background:none;border:none;color:var(--primary);cursor:pointer;font-weight:700">✕</button>
|
||||
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
||||
<b>${esc(b.ip)}</b>
|
||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
|
||||
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
||||
</span>`).join('');
|
||||
}
|
||||
|
||||
async function doUnban(ip) {
|
||||
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
|
||||
else showToast('Ошибка разблокировки');
|
||||
else showError('Ошибка разблокировки');
|
||||
}
|
||||
|
||||
document.getElementById('bansList').addEventListener('click', e => {
|
||||
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
||||
if (!ip) return;
|
||||
const chip = e.target.closest('[data-chip]');
|
||||
if (chip.dataset.confirming) return;
|
||||
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.style.background = 'rgba(239,68,68,.08)';
|
||||
e.target.outerHTML = `
|
||||
<span style="white-space:nowrap">
|
||||
Убрать из блокировки?
|
||||
<button type="button" data-unban-yes style="background:none;border:none;color:#16a34a;cursor:pointer;font-weight:700">Да</button>
|
||||
<button type="button" data-unban-no style="background:none;border:none;color:var(--muted);cursor:pointer;font-weight:700">Нет</button>
|
||||
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());
|
||||
@@ -427,12 +607,51 @@ document.getElementById('bansList').addEventListener('click', e => {
|
||||
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
|
||||
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
|
||||
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||
document.getElementById('resetBtn').addEventListener('click', revertChanges);
|
||||
|
||||
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
|
||||
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
|
||||
systemRefreshBtn.classList.add('spinning');
|
||||
await loadSystemInfo();
|
||||
systemRefreshBtn.classList.remove('spinning');
|
||||
});
|
||||
|
||||
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
||||
statsRefreshBtn.classList.add('spinning');
|
||||
await refreshStats();
|
||||
statsRefreshBtn.classList.remove('spinning');
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
const modal = document.getElementById('aiProfileModal');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (!modal.classList.contains('open') && document.getElementById('saveBar').classList.contains('show') && !saveBtn.disabled) saveSettings();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (document.getElementById('saveBar').classList.contains('show')) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
loadSettings();
|
||||
initSectionNav();
|
||||
updateSpamUi();
|
||||
const authStatus = document.getElementById('authStatus');
|
||||
try {
|
||||
await loadSettings();
|
||||
if (authStatus) authStatus.classList.add('loaded');
|
||||
} catch {
|
||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||
}
|
||||
loadBans();
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user