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]) => ``) + .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 = `` + profiles.map(p => ``).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 минута.

-
-
- -
- - минут -
+
+ +
+ + минут
+ +
+ + + + + +
+
-
+

Страница публичной ссылки /s/<hash>

Управление видимостью элементов на публичных страницах записей.

-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- - -
+ + + + +
+ + + Пусто — баннер не показывается
-
-
-
-

Футер на форме

-
-

Тексты показываются внизу формы ответа ученика.

-
-
- - -
-
- - -
-
-
- -
+

Промпт ИИ для исправления текста

Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».

-
-
- - -
-
-
-
- -
+ +
+ +
@@ -149,22 +135,41 @@
-
+

Резервное копирование

+

Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.

Загрузка статистики…
- +
-
+
+
+
+

Футер на форме

+
+

Тексты показываются внизу формы ответа ученика.

+
+
+ + +
+
+ + +
+
+
+ +

Блокировки IP

@@ -172,13 +177,17 @@

IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.

Загрузка…
+
- Есть несохранённые изменения - + Есть несохранённые изменения + + + +
- +
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}`);