Add AI auto-check worker, share link message/link fields, and update journal/settings UI

This commit is contained in:
dev
2026-09-11 10:29:49 +03:00
parent 3850fe35e4
commit b7e798b867
20 changed files with 1403 additions and 127 deletions
+8
View File
@@ -19,6 +19,14 @@ const ACTION_LABELS = {
'link.delete': 'Удалена ссылка',
'backup.download': 'Скачан бэкап',
'backup.restore': 'Выполнено восстановление',
'entry.ai.auto-check': 'ИИ-автопроверка записи',
'entry.ai.retry': 'Повтор ИИ-проверки записи',
'entry.ai.recheck': 'Запись отправлена на ИИ-проверку',
'entry.ai.revert': 'Возвращён оригинал текста записи',
'entry.ai.error': 'Ошибка ИИ-проверки записи',
'ai.wake': 'Воркер ИИ разбужен вручную',
'ai.enabled': 'Переключена автопроверка ИИ',
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
};
const AUDIT_PAGE_SIZE = 50;
+187 -11
View File
@@ -9,11 +9,18 @@ let totalEntries = 0;
let searchTimer = null;
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
let aiCorrecting = false;
let editDeletePhoto = false;
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
}
function mainPhotoPath(e) {
if (e.photo_path) return e.photo_path;
if (e.photos && e.photos.length) return e.photos[0].photo_path;
return null;
}
function filesHTML(files) {
if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => {
@@ -24,6 +31,21 @@ function filesHTML(files) {
}).join('')}</div>`;
}
const AI_STATUS_LABELS = {
pending: 'ИИ: в очереди',
processing: 'ИИ: проверяет…',
done: 'ИИ: проверено',
skipped: 'ИИ: пропущено',
error: 'ИИ: ошибка',
reverted: 'ИИ: возвращён оригинал',
};
function aiBadge(e) {
if (!e || !e.ai_status) return '';
const label = AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`;
return `<span class="ai-badge ai-badge-${esc(e.ai_status)}" title="${esc(label)}">${esc(label)}</span>`;
}
function findFile(id) {
for (const e of currentEntries) {
const f = (e.files || []).find(x => x.id === id);
@@ -47,11 +69,12 @@ async function detachFile(id) {
}
function gridEntryHTML(e) {
const mp = mainPhotoPath(e);
return `
<div class="card">
${e.photo_path ? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">` : ''}
${mp ? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="name">${esc(e.student_name)}</div>${aiBadge(e)}
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
@@ -64,13 +87,14 @@ function gridEntryHTML(e) {
}
function cardEntryHTML(e) {
const mp = mainPhotoPath(e);
return `
<div class="journal-card">
${e.photo_path
? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">`
${mp
? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
<div class="body">
<div class="name">${esc(e.student_name)}</div>
<div class="name">${esc(e.student_name)}</div>${aiBadge(e)}
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
@@ -171,9 +195,11 @@ async function openEdit(id) {
const e = currentEntries.find(x => x.id === id);
if (!e) return;
editId = id;
editDeletePhoto = false;
fillAutocomplete();
document.getElementById('editName').value = e.student_name;
document.getElementById('editDesc').value = e.description;
renderEditAiStatus(e);
const sel = document.getElementById('editGroup');
if (!editGroups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
@@ -181,14 +207,85 @@ async function openEdit(id) {
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
sel.value = e.group_id;
renderEditPhotos(e.photos || [], e.photo_path);
document.getElementById('editModal').classList.add('open');
}
function renderEditPhotos(photos, mainPath) {
const preview = document.getElementById('editPhotoPreview');
preview.innerHTML = '';
if (!photos.length) return;
photos.forEach((p) => {
const isMain = p.photo_path === mainPath;
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
div.dataset.photoId = p.id;
const applyStyle = isMain
? 'background:#16a34a;border:none;color:#fff;border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:default;white-space:nowrap'
: 'background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:pointer;white-space:nowrap';
div.innerHTML = `<img src="${API}${esc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
preview.appendChild(div);
const thumb = div.querySelector('img');
const showMissing = () => {
const ph = document.createElement('span');
ph.textContent = 'нет файла';
ph.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px dashed var(--border);border-radius:4px;font-size:.6rem;color:var(--muted);text-align:center;line-height:1.1;flex:0 0 auto';
thumb.replaceWith(ph);
};
thumb.addEventListener('error', showMissing);
if (thumb.complete && thumb.naturalWidth === 0) showMissing();
div.querySelector('.delete-entry-photo').addEventListener('click', async () => {
if (!confirm('Удалить это фото?')) return;
const res = await fetch(`${API}/api/entries/${editId}/photos/${p.id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
div.remove();
showToast('Фото удалено');
loadEntries();
} else {
alert('Ошибка удаления');
}
});
});
}
async function applyMainPhoto(btn) {
const div = btn.closest('.file-preview-item');
const photoId = div?.dataset.photoId;
if (!photoId) return;
btn.disabled = true;
const res = await fetch(`${API}/api/entries/${editId}/photos/${photoId}/main`, { method: 'PUT', headers: hdr() });
if (!res.ok) {
btn.disabled = false;
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
return;
}
document.querySelectorAll('#editPhotoPreview .apply-photo-btn').forEach(b => {
b.disabled = false;
b.textContent = 'Применить';
b.title = 'Сделать главным фото записи';
b.style.background = 'var(--card)';
b.style.color = 'var(--text)';
});
btn.disabled = true;
btn.textContent = 'Применено ✓';
btn.title = 'Главное фото записи';
btn.style.background = '#16a34a';
btn.style.color = '#fff';
showToast('Главное фото обновлено');
loadEntries();
}
function closeEdit() {
document.getElementById('editModal').classList.remove('open');
document.getElementById('editFiles').value = '';
document.getElementById('editFilesPreview').innerHTML = '';
document.getElementById('editPhoto').value = '';
document.getElementById('editPhotoPreview').innerHTML = '';
document.getElementById('editAiStatus').style.display = 'none';
editId = null;
editDeletePhoto = false;
}
async function aiCorrect() {
@@ -238,27 +335,70 @@ function acceptAiCorrect() {
closeAiCorrect();
}
function renderEditAiStatus(e) {
const el = document.getElementById('editAiStatus');
if (!e || !e.ai_status) { el.style.display = 'none'; el.innerHTML = ''; return; }
el.style.display = 'block';
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
let html = `<span>${esc(parts.join(' · '))}</span>`;
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
el.innerHTML = html;
const rb = document.getElementById('aiRevertBtn');
if (rb) rb.addEventListener('click', () => revertAi(e.id));
const cb = document.getElementById('aiRecheckBtn');
if (cb) cb.addEventListener('click', () => recheckAi(e.id));
}
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; }
showToast('Оригинал восстановлен');
closeEdit();
loadEntries();
}
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; }
showToast('Отправлено на ИИ-проверку');
closeEdit();
loadEntries();
}
async function saveEdit() {
if (!editId) return;
const name = document.getElementById('editName').value.trim();
const group_id = document.getElementById('editGroup').value;
const description = document.getElementById('editDesc').value.trim();
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
const formData = new FormData();
formData.append('student_name', name);
formData.append('group_id', group_id);
formData.append('description', description);
const photoFiles = document.getElementById('editPhoto').files;
for (const f of photoFiles) formData.append('photo', f);
const res = await fetch(`${API}/api/entries/${editId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ student_name: name, group_id, description })
headers: hdr(),
body: formData
});
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
const files = document.getElementById('editFiles').files;
if (files.length) {
const formData = new FormData();
for (const f of files) formData.append('files', f);
const filesFormData = new FormData();
for (const f of files) filesFormData.append('files', f);
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
method: 'POST',
headers: hdr(),
body: formData
body: filesFormData
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => ({}));
@@ -303,6 +443,18 @@ document.getElementById('editFiles').addEventListener('change', (e) => {
});
});
document.getElementById('editPhoto').addEventListener('change', (e) => {
const preview = document.getElementById('editPhotoPreview');
Array.from(e.target.files).forEach(f => {
const div = document.createElement('div');
div.className = 'file-preview-item';
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
const url = URL.createObjectURL(f);
div.innerHTML = `<img src="${url}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-new-photo="${f.name}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
preview.appendChild(div);
});
});
function removeEditFile(index) {
const input = document.getElementById('editFiles');
const dt = new DataTransfer();
@@ -381,6 +533,8 @@ async function openLinkCreate() {
document.getElementById('linkExpiresDays').value = '7';
document.getElementById('linkAnonymizeNames').checked = false;
document.getElementById('linkPassword').value = '';
document.getElementById('linkMessage').value = '';
document.getElementById('linkUrl').value = '';
document.getElementById('linkCreateView').style.display = '';
document.getElementById('linkResultView').style.display = 'none';
document.getElementById('linkModal').classList.add('open');
@@ -399,13 +553,15 @@ async function createLink() {
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
const access_password = document.getElementById('linkPassword').value;
const message = document.getElementById('linkMessage').value.trim();
const link_url = document.getElementById('linkUrl').value.trim();
if (!name) { alert('Введите название'); return; }
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
const res = await fetch(`${API}/api/links`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null })
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null })
});
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
const link = await res.json();
@@ -436,6 +592,26 @@ document.getElementById('editFilesPreview').addEventListener('click', e => {
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
});
document.getElementById('editPhotoPreview').addEventListener('click', e => {
const removeBtn = e.target.closest('[data-remove-photo]');
if (removeBtn) {
document.getElementById('editPhoto').value = '';
document.getElementById('editPhotoPreview').innerHTML = '';
return;
}
const applyBtn = e.target.closest('.apply-photo-btn');
if (applyBtn) { applyMainPhoto(applyBtn); return; }
const removeNewBtn = e.target.closest('[data-remove-new-photo]');
if (removeNewBtn) {
const fileName = removeNewBtn.dataset.removeNewPhoto;
const input = document.getElementById('editPhoto');
const dt = new DataTransfer();
Array.from(input.files).forEach(f => { if (f.name !== fileName) dt.items.add(f); });
input.files = dt.files;
removeNewBtn.closest('.file-preview-item').remove();
}
});
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
+7 -1
View File
@@ -45,6 +45,8 @@ async function openLinkModal() {
document.getElementById('linkExpiresDays').value = '7';
document.getElementById('linkAnonymizeNames').checked = false;
document.getElementById('linkPassword').value = '';
document.getElementById('linkMessage').value = '';
document.getElementById('linkUrl').value = '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
@@ -72,6 +74,8 @@ async function openLinkEdit(id) {
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false;
document.getElementById('linkPassword').value = '';
document.getElementById('linkMessage').value = l.message || '';
document.getElementById('linkUrl').value = l.link_url || '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
@@ -104,10 +108,12 @@ async function saveLink() {
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
const access_password = document.getElementById('linkPassword').value;
const message = document.getElementById('linkMessage').value.trim();
const link_url = document.getElementById('linkUrl').value.trim();
if (!name) { alert('Введите название'); return; }
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null });
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null });
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
method: editingId ? 'PUT' : 'POST',
headers: hdrJson(),
+25 -3
View File
@@ -1,4 +1,8 @@
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck'];
function fieldValue(el) {
return el.type === 'checkbox' ? String(el.checked) : el.value;
}
function setBtnLoading(btn, on, spinnerClass) {
if (on) {
@@ -14,7 +18,7 @@ function setBtnLoading(btn, on, spinnerClass) {
function updateDirty() {
const dirty = DIRTY_FIELDS.some(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== el.value;
return String(el.dataset.original ?? '') !== fieldValue(el);
});
document.getElementById('saveBar').classList.toggle('show', dirty);
}
@@ -27,7 +31,7 @@ function bindDirty() {
function snapshotDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).dataset.original = document.getElementById(id).value;
document.getElementById(id).dataset.original = fieldValue(document.getElementById(id));
});
updateDirty();
}
@@ -41,7 +45,10 @@ async function loadSettings() {
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
snapshotDirty();
loadAiQueue();
if (stRes.ok) {
const st = await stRes.json();
document.getElementById('dataStats').innerHTML = `
@@ -122,6 +129,19 @@ function renderSystemInfo(si) {
`;
}
async function loadAiQueue() {
const el = document.getElementById('aiQueueInfo');
if (!el) return;
try {
const res = await fetch(`${API}/api/ai/queue`, { headers: hdr() });
if (!res.ok) { el.textContent = ''; return; }
const q = await res.json();
const c = q.counts || {};
const state = q.enabled ? 'включена' : 'выключена';
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
} catch { el.textContent = ''; }
}
async function saveSettings() {
const v = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
@@ -136,6 +156,8 @@ async function saveSettings() {
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
} })
});
if (res.ok) {
+25
View File
@@ -96,6 +96,8 @@ function showError(msg) {
document.getElementById('title').textContent = msg;
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
document.getElementById('photosBlock').style.display = 'none';
document.getElementById('shareMessage').style.display = 'none';
document.getElementById('shareLinkBlock').style.display = 'none';
}
function renderShare(data, password) {
@@ -107,6 +109,29 @@ function renderShare(data, password) {
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
const msgEl = document.getElementById('shareMessage');
if (data.message) {
msgEl.textContent = data.message;
msgEl.style.display = 'block';
} else {
msgEl.style.display = 'none';
}
const linkBlock = document.getElementById('shareLinkBlock');
linkBlock.innerHTML = '';
if (data.link_url && /^https?:\/\//i.test(data.link_url)) {
const a = document.createElement('a');
a.className = 'share-link-btn';
a.href = data.link_url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = '🔗 Перейти по ссылке';
linkBlock.appendChild(a);
linkBlock.style.display = 'block';
} else {
linkBlock.style.display = 'none';
}
const pb = document.getElementById('photosBlock');
if (data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
+234
View File
@@ -0,0 +1,234 @@
const AUTO_REFRESH_MS = 5000;
const STATUS_LABELS = {
pending: 'В очереди',
processing: 'Проверяется',
done: 'Проверено',
skipped: 'Пропущено',
error: 'Ошибка',
reverted: 'Оригинал',
};
const STATUS_BADGE = {
done: 'ai-badge-done',
error: 'ai-badge-error',
skipped: 'ai-badge-skipped',
reverted: 'ai-badge-reverted',
};
let state = null;
let refreshTimer = null;
let busy = false;
function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d) return `${d} д ${h} ч`;
if (h) return `${h} ч ${m} мин`;
if (m) return `${m} мин ${sec} с`;
return `${sec} с`;
}
function fmtTime(t) {
if (!t) return '—';
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
}
function ago(t) {
if (!t) return '—';
const diff = Date.now() - new Date(t).getTime();
if (Number.isNaN(diff)) return '—';
return fmtDur(diff) + ' назад';
}
function statusBadge(s) {
return `<span class="ai-badge ${STATUS_BADGE[s] || ''}">${esc(STATUS_LABELS[s] || s)}</span>`;
}
function renderState() {
const w = state.worker || {};
const svc = state.service || {};
const enabled = state.enabled;
const processing = !!w.processing;
const dotClass = !enabled ? '' : (svc.reachable ? (processing ? 'busy' : 'on') : 'err');
const label = !enabled
? 'Автопроверка выключена'
: (!svc.reachable ? 'Сервис модели недоступен' : (processing ? `Обрабатывается запись #${w.current_id ?? ''}` : 'Ожидание очереди'));
document.getElementById('stateLine').innerHTML = `<span class="worker-dot ${dotClass}"></span> ${esc(label)}`;
document.getElementById('toggleBtn').textContent = enabled ? 'Выключить автопроверку' : 'Включить автопроверку';
}
function renderCards() {
const w = state.worker || {};
const svc = state.service || {};
const cfg = w.config || {};
const pct = w.checks ? Math.round(((w.corrected || 0) / w.checks) * 100) : 0;
document.getElementById('workerCards').innerHTML = `
<div class="sys-card">
<div class="sys-title">Состояние воркера</div>
<div class="sys-item"><span class="sys-label">Режим</span><span class="sys-value">${w.processing ? 'Обработка' : 'Ожидание'}</span></div>
<div class="sys-item"><span class="sys-label">Текущая запись</span><span class="sys-value">${w.current_id ? '#' + w.current_id : '—'}</span></div>
<div class="sys-item"><span class="sys-label">Работает</span><span class="sys-value">${fmtDur(w.uptime_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Запущен</span><span class="sys-value">${fmtTime(w.started_at)}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Модель</div>
<div class="sys-item"><span class="sys-label">Модель</span><span class="sys-value">${esc(state.model)}</span></div>
<div class="sys-item"><span class="sys-label">Сервис</span><span class="sys-value">${svc.reachable ? '✅ доступен' : '❌ недоступен'}</span></div>
<div class="sys-item"><span class="sys-label">Отклик</span><span class="sys-value">${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}</span></div>
<div class="sys-item"><span class="sys-label">Адрес</span><span class="sys-value" style="font-size:.75rem">${esc(state.ai_url || '')}</span></div>
${svc.error ? `<div class="sys-meta" style="color:#ef4444">${esc(svc.error)}</div>` : ''}
</div>
<div class="sys-card">
<div class="sys-title">Итоги работы</div>
<div class="sys-item"><span class="sys-label">Проверок</span><span class="sys-value">${w.checks || 0}</span></div>
<div class="sys-item"><span class="sys-label">Исправлено</span><span class="sys-value">${w.corrected || 0} (${pct}%)</span></div>
<div class="sys-item"><span class="sys-label">Без изменений</span><span class="sys-value">${w.unchanged || 0}</span></div>
<div class="sys-item"><span class="sys-label">Ошибок</span><span class="sys-value">${w.errors || 0}</span></div>
<div class="sys-item"><span class="sys-label">Активность</span><span class="sys-value">${ago(w.last_at)}</span></div>
${w.last_error ? `<div class="sys-meta" style="color:#ef4444">${esc(w.last_error)}</div>` : ''}
</div>
<div class="sys-card">
<div class="sys-title">Параметры (мин. нагрузка)</div>
<div class="sys-item"><span class="sys-label">Пауза простоя</span><span class="sys-value">${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Таймаут запроса</span><span class="sys-value">${fmtDur(cfg.request_timeout_ms)}</span></div>
<div class="sys-item"><span class="sys-label">Макс. текст</span><span class="sys-value">${cfg.max_input_chars || 0} симв.</span></div>
<div class="sys-item"><span class="sys-label">Мин. текст</span><span class="sys-value">${cfg.min_text_chars || 0} симв.</span></div>
<div class="sys-item"><span class="sys-label">Повторы при сбое</span><span class="sys-value">${cfg.max_attempts || 0}</span></div>
</div>
`;
}
function renderCounts() {
const c = state.counts || {};
const chips = [
['pending', 'В очереди', c.pending || 0],
['processing', 'Проверяется', c.processing || 0],
['done', 'Проверено', c.done || 0],
['skipped', 'Пропущено', c.skipped || 0],
['error', 'Ошибка', c.error || 0],
['reverted', 'Оригинал', c.reverted || 0],
];
document.getElementById('workerCounts').innerHTML = chips
.map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`)
.join('');
}
function renderPending() {
const rows = state.pending || [];
document.getElementById('pendingCount').textContent = rows.length;
const tbody = document.querySelector('#pendingTable tbody');
const empty = document.getElementById('pendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr>
<td>#${r.id}</td>
<td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
</tr>`).join('');
}
function renderRecent() {
const filter = document.getElementById('recentFilter').value;
const all = state.recent || [];
const rows = filter ? all.filter(r => r.ai_status === filter) : all;
document.getElementById('recentCount').textContent = `${rows.length} из ${all.length}`;
const tbody = document.querySelector('#recentTable tbody');
const empty = document.getElementById('recentEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>${esc(r.student_name)}</td>
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
<td>${statusBadge(r.ai_status)}</td>
<td>${r.changed ? '✏️ исправлен' : '—'}</td>
</tr>`).join('');
}
function renderErrors() {
const rows = state.errors || [];
document.getElementById('errorsCount').textContent = rows.length;
document.getElementById('errorsCard').style.display = rows.length ? 'flex' : 'none';
document.querySelector('#errorsTable tbody').innerHTML = rows.map(r => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
<td>#${r.id}</td>
<td>${esc(r.student_name)}</td>
<td style="color:#ef4444">${esc(r.ai_error || '')}</td>
</tr>`).join('');
}
function render() {
if (!state) return;
renderState();
renderCards();
renderCounts();
renderPending();
renderRecent();
renderErrors();
}
async function load() {
if (busy) return;
busy = true;
try {
const res = await fetch(`${API}/api/ai/status`, { headers: hdr() });
if (!res.ok) return;
state = await res.json();
render();
} catch {
} finally {
busy = false;
}
}
async function post(path, body) {
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API}${path}`, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
return data;
}
async function wake() {
if (await post('/api/ai/wake')) { showToast('Воркер разбужен'); load(); }
}
async function toggleEnabled() {
const next = !(state && state.enabled);
const data = await post('/api/ai/enabled', { enabled: next });
if (data) { showToast(next ? 'Автопроверка включена' : 'Автопроверка выключена'); load(); }
}
async function requeueFailed() {
if (!confirm('Вернуть все записи с ошибкой в очередь на повторную проверку?')) return;
const data = await post('/api/ai/requeue-failed');
if (data) { showToast(`В очередь возвращено: ${data.count}`); load(); }
}
function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
}
document.getElementById('refreshBtn').addEventListener('click', load);
document.getElementById('wakeBtn').addEventListener('click', wake);
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', renderRecent);
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
load();
}
})();