diff --git a/db/init.sql b/db/init.sql
index 9eebcb7..dbe8511 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -59,7 +59,7 @@ BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
- PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error)::text);
+ PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
diff --git a/db/migration.sql b/db/migration.sql
index 112b0e6..003efce 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -190,7 +190,7 @@ BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
- PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error)::text);
+ PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
diff --git a/public/admin.css b/public/admin.css
index a97a3f6..f0cd875 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -127,6 +127,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.item .name-input:focus{border-color:var(--accent)}
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
.item .group-select:focus{border-color:var(--accent)}
+.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
+.item.expired:hover{opacity:.85}
+.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
+.badge-soon{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(245,158,11,.14);color:#f59e0b;vertical-align:middle;margin-left:6px}
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
@@ -158,7 +162,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.group-card .save-sched:hover{opacity:.85}
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
.group-card .btn-link:hover{border-color:var(--accent)}
-.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
+.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:16px}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
@@ -247,11 +251,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
.page-sub{color:var(--muted);font-size:.85rem}
-.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
+.settings-stack{display:flex;flex-direction:column;gap:20px}
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
.link-fields > .full{grid-column:1/-1}
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
-.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
+.settings-stack-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
+@media(max-width:900px){.settings-stack-grid{grid-template-columns:1fr}}
.settings-stack-grid > .settings-card{max-width:none;min-width:0;height:100%}
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
@@ -439,4 +444,82 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444}
.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap}
+/* --- Настройки (UX/UI v2) --- */
+body[data-page=settings] .page-head{align-items:flex-end}
+.page-status{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font-size:.8rem;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 14px;white-space:nowrap;transition:opacity .3s}
+.page-status.loaded{opacity:0;pointer-events:none}
+.page-status.err{color:#ef4444;opacity:1}
+.head-btn{display:inline-flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}
+.head-btn:hover{color:var(--accent);border-color:var(--accent)}
+.head-btn svg{width:14px;height:14px}
+.settings-layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:28px;align-items:start}
+.settings-nav{position:sticky;top:16px;display:flex;flex-direction:column;gap:2px}
+.settings-nav a{display:block;padding:9px 13px;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.86rem;font-weight:500;transition:all .15s;white-space:nowrap}
+.settings-nav a:hover{color:var(--text);background:var(--card)}
+.settings-nav a.active{color:var(--accent);background:var(--card);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
+.settings-nav a svg{width:15px;height:15px;flex-shrink:0;vertical-align:-3px;margin-right:2px}
+.settings-layout .settings-stack,.settings-layout .settings-stack-grid{max-width:none}
+.settings-card[id]{scroll-margin-top:16px}
+.settings-card{transition:border-color .2s,box-shadow .2s}
+.settings-card:target{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
+.head-live{margin-left:auto;font-size:.72rem;color:var(--muted);white-space:nowrap}
+.toggle{position:relative;display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--bg);border:1px solid transparent;border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
+.toggle:hover{border-color:var(--border);background:var(--card)}
+.toggle input{position:absolute;opacity:0;width:1px;height:1px}
+.toggle-track{position:relative;width:40px;height:22px;border-radius:999px;background:#d4d4d4;flex-shrink:0;transition:background .2s}
+.toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .2s}
+.toggle input:checked ~ .toggle-track{background:var(--accent)}
+.toggle input:checked ~ .toggle-track .toggle-thumb{transform:translateX(18px)}
+.toggle input:focus-visible ~ .toggle-track{box-shadow:0 0 0 3px rgba(37,99,235,.25)}
+.toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0}
+.toggle-text b{font-size:.87rem;font-weight:600}
+.toggle-text small{font-size:.74rem;color:var(--muted);line-height:1.4}
+.settings-field{position:relative}
+.field-error{color:#ef4444;font-size:.75rem;font-weight:600;min-height:0;display:block}
+.settings-input.invalid{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
+.settings-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.84rem;line-height:1.5;min-height:110px;resize:vertical}
+.preset-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
+.preset-btn{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;font-size:.75rem;font-weight:600;color:var(--muted);cursor:pointer;transition:all .15s}
+.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
+.preset-btn.active{background:rgba(37,99,235,.08);border-color:var(--accent);color:var(--accent)}
+.toast.error{background:#dc2626}
+.queue-link{color:var(--accent);text-decoration:none;font-weight:600;font-size:.8rem;white-space:nowrap}
+.queue-link:hover{text-decoration:underline}
+.ai-queue{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.ai-queue .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 12px;font-size:.78rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
+.ai-queue .chip b{color:var(--accent);font-weight:700}
+.ban-chip{flex-direction:column;align-items:flex-start;gap:2px;padding-right:8px}
+.chip-sub{font-size:.72rem;color:var(--muted);font-weight:400}
+.unban-btn{background:none;border:none;color:var(--accent);cursor:pointer;font-weight:700;font-size:.9rem;padding:0 2px;line-height:1}
+.unban-btn:hover{color:#ef4444}
+.ban-chip.confirming{background:rgba(239,68,68,.07);border-color:rgba(239,68,68,.4)}
+.chip-confirm{display:inline-flex;align-items:center;gap:8px;font-size:.74rem;font-weight:600;white-space:nowrap;padding-right:4px}
+.chip-yes,.chip-no{background:none;border:none;cursor:pointer;font-weight:700;padding:0}
+.chip-yes{color:#16a34a}
+.chip-no{color:var(--muted)}
+.btn-primary.danger-ghost{background:var(--card);border:1px solid rgba(239,68,68,.35);color:#ef4444}
+.btn-primary.danger-ghost:hover{border-color:#ef4444;opacity:1}
+.ai-test-status{font-size:.85rem;min-height:1.3em}
+.ai-test-status .ok{color:#16a34a;font-weight:600}
+.ai-test-status .fail{color:#ef4444;font-weight:600}
+.save-bar{animation:saveBarIn .25s cubic-bezier(.22,1,.36,1)}
+.save-bar.show{border-color:rgba(245,158,11,.5)}
+.save-bar-actions{display:flex;gap:10px;flex-wrap:wrap}
+@keyframes saveBarIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
+.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
+.icon-btn:hover{color:var(--accent);border-color:var(--accent)}
+.icon-btn svg{width:14px;height:14px}
+.icon-btn.spinning svg{animation:spin 1s linear infinite}
+.sys-card.warn{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05)}
+.sys-card.danger{border-color:rgba(239,68,68,.5);background:rgba(239,68,68,.05)}
+@media(max-width:1024px){
+ .settings-layout{grid-template-columns:1fr;gap:14px}
+ .settings-nav{position:sticky;top:0;z-index:40;flex-direction:row;gap:6px;overflow-x:auto;background:var(--bg);padding:8px 0;scrollbar-width:none}
+ .settings-nav::-webkit-scrollbar{display:none}
+ .settings-nav a{border:1px solid var(--border);background:var(--card);border-radius:999px;padding:7px 14px;font-size:.8rem}
+ .settings-nav a.active{box-shadow:none;border-color:var(--accent)}
+ .settings-card[id]{scroll-margin-top:60px}
+}
+@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
+
diff --git a/public/audit.html b/public/audit.html
index 42db7fe..dfaf60c 100644
--- a/public/audit.html
+++ b/public/audit.html
@@ -16,6 +16,9 @@
+
+ Все действия
+
Обновить
diff --git a/public/js/audit.js b/public/js/audit.js
index b4eba53..c70d4c6 100644
--- a/public/js/audit.js
+++ b/public/js/audit.js
@@ -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 = '
Ошибка загрузки '; 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]) => `
${esc(v)} `)
+ .join('');
+ sel.innerHTML = '
Все действия ' + 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();
}
})();
\ No newline at end of file
diff --git a/public/js/journal.js b/public/js/journal.js
index 69c82f0..3d61e10 100644
--- a/public/js/journal.js
+++ b/public/js/journal.js
@@ -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('Запись обновлена');
}
diff --git a/public/js/links.js b/public/js/links.js
index 4f20551..c34f5cd 100644
--- a/public/js/links.js
+++ b/public/js/links.js
@@ -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 = '
Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».
'; return; }
+ if (!links.length) { el.innerHTML = `
${linkPage > 1 ? 'На этой странице ссылок нет.' : 'Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».'}
`; 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 `
-
+
-
${esc(l.name)}
+
${esc(l.name)}${expired ? ' истекла ' : soon ? ' истекает ' : ''}
${l.group_name ? `Группа: ${esc(l.group_name)} ` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: ${esc(l.student_name)} ` : ''}
${l.date_from || l.date_to ? ` · период: ${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'} — ${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'} ` : ' · весь период'}
- ${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
+ ${expired ? ` · срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')} ` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
${namesHidden ? ` · имена скрыты ` : ''}
${l.access_password_hash ? ` · 🔒 пароль ` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')}
@@ -44,6 +55,12 @@ async function loadLinks() {
`;}).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}` : ''; }
diff --git a/public/js/settings.js b/public/js/settings.js
index d18afe9..f5362aa 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -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 = `
${btn.dataset.loadingText || 'Обработка...'}`;
+ const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
+ btn.innerHTML = `
${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
+function showError(msg) {
+ const t = document.getElementById('toast');
+ if (!t) return;
+ t.textContent = msg;
+ t.classList.add('show', 'error');
+ clearTimeout(t._errTimer);
+ t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
+}
+
+function showFieldError(id, msg) {
+ const el = document.getElementById(id);
+ const err = document.getElementById(`${id}Error`);
+ if (el) el.classList.add('invalid');
+ if (err) { err.textContent = msg; err.hidden = false; }
+}
+
+function clearFieldError(id) {
+ const el = document.getElementById(id);
+ const err = document.getElementById(`${id}Error`);
+ if (el) el.classList.remove('invalid');
+ if (err) err.hidden = true;
+}
+
+function plural(n, one, few, many) {
+ const a = n % 10, b = n % 100;
+ if (a === 1 && b !== 11) return one;
+ if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
+ return many;
+}
+
+function humanizeMinutes(min) {
+ const n = Number(min);
+ if (!Number.isFinite(n) || n < 1) return '';
+ if (n === 1440) return 'сутки';
+ if (n > 1440 && n % 1440 === 0) {
+ const d = n / 1440;
+ return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
+ }
+ const h = Math.floor(n / 60);
+ const m = n % 60;
+ const parts = [];
+ if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
+ if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
+ if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
+ return parts.join(' ');
+}
+
function updateDirty() {
- const 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 = `
-
Записей: ${st.entries}
-
Учеников: ${st.students}
-
Групп: ${st.groups}
-
Сегодня: ${st.today}
-
${st.trash ? `В корзине: ${st.trash} → ` : ''}
- `;
- } 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 = '
Ошибка загрузки
';
+async function renderStats(res) {
+ const el = document.getElementById('dataStats');
+ if (!el) return;
+ if (!res || !res.ok) { el.innerHTML = '
Статистика недоступна '; return; }
+ const st = await res.json();
+ el.innerHTML = `
+
Записей: ${st.entries}
+
Учеников: ${st.students}
+
Групп: ${st.groups}
+
Сегодня: ${st.today}
+ ${st.trash ? `
В корзине: ${st.trash} → ` : ''}
+ `;
+}
+
+async function refreshStats() {
+ const el = document.getElementById('dataStats');
+ if (!el) return;
+ el.innerHTML = '
Обновление… ';
+ try {
+ await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
+ } catch {
+ el.innerHTML = '
Ошибка сети ';
+ }
+}
+
+async function loadSystemInfo() {
+ 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 = '
Ошибка загрузки
';
+ } catch {
+ el.innerHTML = '
Ошибка сети
';
}
}
@@ -87,7 +211,7 @@ function renderSystemInfo(si) {
let tablesHtml = '';
if (db.tables && db.tables.length) {
tablesHtml = '
Таблицы БД ' +
- db.tables.map(t => `
${t.name} ${t.size}
`).join('') +
+ db.tables.map(t => `
${esc(t.name)} ${esc(t.size)}
`).join('') +
'
';
}
@@ -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 = `
-
- Диск
- ${disk.used} / ${disk.total}
-
-
-
Свободно: ${disk.free} (${100 - pct}%)
- `;
+
+
Диск
+
Заполнено ${pct}%
+
Использовано ${esc(disk.used)} / ${esc(disk.total)}
+
+
Свободно: ${esc(disk.free)} (${100 - pct}%)
+
`;
}
document.getElementById('systemInfo').innerHTML = `
База данных
-
Размер ${db.size || '—'}
+
Размер ${esc(db.size || '—')}
${tablesHtml}
Фото
-
Фото групп ${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}
-
Фото в записях ${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}
-
Всего ${photos.total_count || 0} · ${photos.total_size || '0 B'}
+
Фото групп ${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}
+
Фото в записях ${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}
+
Всего ${photos.total_count || 0} · ${esc(photos.total_size || '0 B')}
Файлы
@@ -126,18 +249,14 @@ function renderSystemInfo(si) {
Загрузки
Файлов ${uploads.count || 0}
-
Размер ${uploads.size || '0 B'}
-
-
-
Диск
- ${diskHtml || '
Недоступно
'}
+
Размер ${esc(uploads.size || '0 B')}
+ ${diskHtml || '
'}
`;
}
async function loadAiProfiles() {
const listEl = document.getElementById('aiProfilesList');
- const activeBadge = document.getElementById('aiProfileActive');
const select = document.getElementById('aiActiveProfile');
if (!listEl) return;
try {
@@ -145,16 +264,6 @@ async function loadAiProfiles() {
if (!res.ok) { listEl.innerHTML = '
Не удалось загрузить профили '; return; }
const data = await res.json();
const profiles = data.profiles || [];
- if (activeBadge) {
- if (data.active === 'native') {
- activeBadge.innerHTML = `По умолчанию:
Нативная (llama.cpp) —
${esc(data.native.model)}`;
- } else {
- const p = profiles.find(x => x.id === data.active);
- activeBadge.innerHTML = p
- ? `По умолчанию:
${esc(p.name)} —
${esc(p.model)}`
- : 'По умолчанию: нативная модель';
- }
- }
if (select) {
select.innerHTML = `
Нативная — ${esc(data.native.model)} ` +
profiles.map(p => `
${esc(p.name)} — ${esc(p.model)} `).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 = `Автопроверка:
${state} · Модель:
${esc(q.model)} · В очереди:
${c.pending || 0} · Проверено:
${c.done || 0} · Ошибок:
${c.error || 0} · Пропущено:
${c.skipped || 0} ·
Монитор воркера → `;
+ el.innerHTML = `
+
Автопроверка: ${q.enabled ? 'включена' : 'выключена'}
+
Модель: ${esc(q.model || '—')}
+
В очереди: ${c.pending || 0}
+
Проверено: ${c.done || 0}
+
Ошибок: ${c.error || 0}
+
Пропущено: ${c.skipped || 0}
+
Монитор воркера → `;
} catch { el.textContent = ''; }
}
@@ -233,7 +348,7 @@ async function saveAiProfile() {
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
- st.innerHTML = `
${esc(err.error || 'Ошибка сохранения')} `;
+ st.innerHTML = `
${esc(err.error || 'Ошибка сохранения')} `;
}
}
@@ -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
- ? `
✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''} `
- : `
✗ ${esc(d.error || 'Не удалось подключиться')} `;
+ ? `
✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''} `
+ : `
✗ ${esc(d.error || 'Не удалось подключиться')} `;
}
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 = '
Не удалось загрузить '; return; }
const bans = await res.json();
if (!bans.length) { el.innerHTML = '
Активных блокировок нет '; return; }
el.innerHTML = bans.map(b => `
-
- ${esc(b.ip)} — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
- ✕
+
+ ${esc(b.ip)}
+ ${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}
+ ✕
`).join('');
}
async function doUnban(ip) {
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
- else 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 = `
-
- Убрать из блокировки?
- Да
- Нет
+ chip.classList.add('confirming');
+ btn.outerHTML = `
+
+ Разблокировать?
+ Да
+ Нет
`;
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();
}
-})();
\ No newline at end of file
+})();
diff --git a/public/links.html b/public/links.html
index 49bb426..0a77a75 100644
--- a/public/links.html
+++ b/public/links.html
@@ -13,6 +13,7 @@
Ссылки
Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.
+
diff --git a/public/settings.html b/public/settings.html
index 7525b65..73eac9c 100644
--- a/public/settings.html
+++ b/public/settings.html
@@ -12,120 +12,106 @@
Настройки
-
Параметры формы и данные системы
+
Параметры формы, публичных страниц и данные системы
+
Загрузка…
+
Сбросить
-
-
+
+
+
+
+
-
+
Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.
-
-
-
Интервал ожидания
-
-
- минут
-
+
+
Интервал ожидания
+
+
+ минут
+
+
+ 1 мин
+ 15 мин
+ 30 мин
+ 1 час
+ Сутки
+
+
-
+
Страница публичной ссылки /s/<hash>
Управление видимостью элементов на публичных страницах записей.
-
-
-
-
- Показать сообщение ученика
-
-
-
-
-
-
-
- Показать дату в карточке записи
-
-
-
-
-
-
-
- Показывать фамилии и имена учеников
-
-
-
-
-
-
-
- Показывать фото группы
-
-
-
-
-
-
-
Тексты показываются внизу формы ответа ученика.
-
-
- Текст слева
-
-
-
- Текст справа
-
-
-
-
-
-
+
Промпт ИИ для исправления текста
Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».
-
-
-
-
-
- Автоматически проверять новые записи (без участия человека)
-
-
+
+
+
+ Автопроверка новых записей Без участия человека — воркер сам обрабатывает очередь
+
+
+ Промпт
+
@@ -149,22 +135,41 @@
-
+
Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.
Загрузка статистики…
-
+
+
+
Блокировки IP
@@ -172,13 +177,17 @@
IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.
Загрузка…
+
- Есть несохранённые изменения
- Сохранить изменения
+ Есть несохранённые изменения
+
+ Отменить
+ Сохранить
+
@@ -200,7 +209,7 @@
API-ключ (если требуется)
-
+
Сохранить
Проверить
diff --git a/server.js b/server.js
index 5ccbf6c..703880b 100644
--- a/server.js
+++ b/server.js
@@ -44,7 +44,7 @@ lister.on('notification', (msg) => {
try { payload = JSON.parse(msg.payload || '{}'); } catch (e) { payload = null; }
const type = payload && payload.type ? payload.type : 'entry_created';
if (type === 'ai_status') {
- broadcastAiStatus(payload.id, payload.status, payload.error);
+ broadcastAiStatus(payload);
} else {
broadcastEntryChanged();
}
@@ -105,8 +105,17 @@ function broadcastEntryChanged() {
}
}
-function broadcastAiStatus(entryId, status, error) {
- const frame = `event: ai_status\ndata: ${JSON.stringify({ id: entryId, ai_status: status, ai_error: error || null, ts: Date.now() })}\n\n`;
+function broadcastAiStatus(payload) {
+ const data = {
+ id: payload.id,
+ ai_status: payload.status,
+ ai_error: payload.error || null,
+ description: payload.description === undefined ? null : payload.description,
+ description_ai: payload.description_ai === undefined ? null : payload.description_ai,
+ description_original: payload.description_original === undefined ? null : payload.description_original,
+ ts: Date.now()
+ };
+ const frame = `event: ai_status\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
try { client.write(frame); } catch (e) { sseClients.delete(client); }
}
@@ -325,7 +334,7 @@ function getDiskInfo() {
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
- used_pct: Math.round((used / total) * 100),
+ used_pct: Math.min(100, Math.max(0, Math.round((used / total) * 100))),
};
}
try {
@@ -344,7 +353,7 @@ function getDiskInfo() {
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
- used_pct: Math.round((used / total) * 100),
+ used_pct: Math.min(100, Math.max(0, Math.round((used / total) * 100))),
};
}
} catch {}
@@ -957,8 +966,8 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
for (const [key, value] of Object.entries(settings)) {
if (key === 'spam_interval_min') {
const n = parseInt(String(value), 10);
- if (!Number.isFinite(n) || n < 1) {
- return res.status(400).json({ error: 'spam_interval_min должен быть целым числом ≥ 1' });
+ if (!Number.isFinite(n) || n < 1 || n > 10080) {
+ return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
}
}
}
@@ -990,11 +999,19 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
app.get('/api/audit', requireAdmin, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
+ let where = '';
+ const params = [];
+ const action = typeof req.query.action === 'string' && req.query.action.trim() ? req.query.action.trim() : null;
+ if (action) {
+ where = 'WHERE a.action = $1';
+ params.push(action);
+ }
+ params.push(limit, offset);
const { rows } = await pool.query(
`SELECT a.id, a.action, a.target, a.ip, a.created_at, a.user_id, u.username AS user_name
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
- ORDER BY a.id DESC LIMIT $1 OFFSET $2`,
- [limit, offset]
+ ${where} ORDER BY a.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
+ params
);
res.json(rows);
});
@@ -1043,12 +1060,14 @@ async function aiCorrectText(text) {
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
let model = AI_MODEL;
const headers = { 'Content-Type': 'application/json' };
+ let maxTokens = Math.min(256, Math.max(128, text.length + 64));
if (profile) {
const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`;
model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
+ maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
}
try {
const systemPrompt = await getAiPrompt();
@@ -1062,7 +1081,7 @@ async function aiCorrectText(text) {
{ role: 'user', content: text }
],
temperature: 0.1,
- max_tokens: 512
+ max_tokens: maxTokens
})
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
@@ -1527,6 +1546,24 @@ app.get('/api/links', requireAuth, async (req, res) => {
where = `WHERE l.group_id IS NULL AND 1 = 0`;
}
}
+ const limit = optInt(req.query.limit, 1, 200);
+ const offset = optInt(req.query.offset, 0, Infinity) || 0;
+ if (limit != null) {
+ params.push(limit);
+ params.push(offset);
+ const { rows: totalRows } = await pool.query(
+ `SELECT COUNT(*)::int AS total FROM share_links l
+ LEFT JOIN groups g ON g.id = l.group_id ${where}`,
+ params.slice(0, params.length - 2)
+ );
+ const { rows } = await pool.query(
+ `SELECT l.*, g.name AS group_name FROM share_links l
+ LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
+ params
+ );
+ rows.forEach(normDates);
+ return res.json({ items: rows, total: totalRows[0].total });
+ }
const { rows } = await pool.query(
`SELECT l.*, g.name AS group_name FROM share_links l
LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC`,
@@ -2905,6 +2942,31 @@ app.get('/api/entries', requireAuth, async (req, res) => {
res.json({ entries: rows, total });
});
+app.get('/api/entries/:id', requireAuth, async (req, res) => {
+ if (req.user.role !== 'admin') {
+ const acc = await entryAccessible(req.user, req.params.id);
+ if (!acc.found) return res.status(404).json({ error: 'Not found' });
+ if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
+ }
+ const { rows } = await pool.query(
+ 'SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1',
+ [req.params.id]
+ );
+ if (!rows.length) return res.status(404).json({ error: 'Not found' });
+ const entry = rows[0];
+ const fRes = await pool.query(
+ 'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
+ [entry.id]
+ );
+ entry.files = fRes.rows;
+ const pRes = await pool.query(
+ 'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
+ [entry.id]
+ );
+ entry.photos = pRes.rows;
+ res.json(entry);
+});
+
app.get('/api/entries/:id/files', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [req.params.id]);
diff --git a/worker.js b/worker.js
index 2a0e32b..ff2be29 100644
--- a/worker.js
+++ b/worker.js
@@ -96,15 +96,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
const headers = { 'Content-Type': 'application/json' };
let url;
let model;
+ let maxTokens;
if (profile) {
const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`;
model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
+ maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
} else {
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
model = MODEL;
+ maxTokens = Math.min(256, Math.max(128, text.length + 64));
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -120,7 +123,7 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
{ role: 'user', content: text },
],
temperature: 0.1,
- max_tokens: Math.min(512, Math.max(64, text.length + 32)),
+ max_tokens: maxTokens,
}),
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);