const AUTO_REFRESH_MS = 5000; const RECENT_PAGE_SIZE = 15; 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 photoState = null; let photoJobRow = null; let refreshTimer = null; let busy = false; let photoBusy = false; let recentRows = []; let recentPage = 1; const PHOTO_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат', }; const PHOTO_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка', }; const PHOTO_STATUS_BADGE = { done: 'ai-badge-done', error: 'ai-badge-error', processing: 'ai-badge-skipped', }; function thumbUrl(p) { if (!p) return ''; if (p.startsWith('/uploads/.originals/')) return `${API}${p}`; return `${API}/uploads/thumb/${p.split('/').pop()}`; } 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 `${esc(STATUS_LABELS[s] || s)}`; } 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 = ` ${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 = `
Состояние воркера
Режим${w.processing ? 'Обработка' : 'Ожидание'}
Текущая запись${w.current_id ? '#' + w.current_id : '—'}
Работает${fmtDur(w.uptime_ms)}
Запущен${fmtTime(w.started_at)}
Модель
Модель${esc(state.model)}
Сервис${svc.reachable ? ' доступен' : ' недоступен'}
Отклик${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}
Адрес${esc(state.ai_url || '')}
${svc.error ? `
${esc(svc.error)}
` : ''}
Итоги работы
Проверок${w.checks || 0}
Исправлено${w.corrected || 0} (${pct}%)
Без изменений${w.unchanged || 0}
Ошибок${w.errors || 0}
Активность${ago(w.last_at)}
${w.last_error ? `
${esc(w.last_error)}
` : ''}
Параметры (мин. нагрузка)
Пауза простоя${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}
Таймаут запроса${fmtDur(cfg.request_timeout_ms)}
Макс. текст${cfg.max_input_chars || 0} симв.
Мин. текст${cfg.min_text_chars || 0} симв.
Повторы при сбое${cfg.max_attempts || 0}
`; } 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]) => `${label}: ${n}`) .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 => ` #${r.id} ${esc(r.student_name)} ${esc(r.group_name || '')} ${fmtTime(r.created_at)} `).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'); const maxPage = Math.max(1, Math.ceil(rows.length / RECENT_PAGE_SIZE)); if (recentPage > maxPage) recentPage = maxPage; if (!rows.length) { recentRows = []; tbody.innerHTML = ''; empty.style.display = 'block'; renderPager(document.getElementById('recentPager'), 1, 1, () => {}); return; } empty.style.display = 'none'; const slice = rows.slice((recentPage - 1) * RECENT_PAGE_SIZE, recentPage * RECENT_PAGE_SIZE); recentRows = slice; tbody.innerHTML = slice.map((r, i) => ` ${fmtTime(r.ai_checked_at)} ${esc(r.student_name)} ${esc(r.group_name || '')} ${statusBadge(r.ai_status)} ${r.changed ? ' исправлен' : '—'} `).join(''); renderPager(document.getElementById('recentPager'), recentPage, maxPage, p => { recentPage = p; renderRecent(); }); } 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 => ` ${fmtTime(r.ai_checked_at)} #${r.id} ${esc(r.student_name)} ${esc(r.ai_error || '')} `).join(''); } function render() { if (!state) return; renderState(); renderCards(); renderCounts(); renderPending(); renderRecent(); renderErrors(); renderIcons(); } function renderPhoto() { if (!photoState) return; const w = photoState.worker || {}; document.getElementById('photoStateHint').textContent = !photoState.enabled ? 'Обработка фотографий выключена' : (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди')); document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер'; const c = photoState.counts || {}; document.getElementById('photoCounts').innerHTML = [ ['pending', 'В очереди', c.pending || 0], ['processing', 'Обрабатывается', c.processing || 0], ['done', 'Готово', c.done || 0], ['error', 'Ошибка', c.error || 0], ].map(([cls, label, n]) => `${label}: ${n}`).join(''); const rows = photoState.recent || []; const tbody = document.querySelector('#photoTable tbody'); const empty = document.getElementById('photoEmpty'); if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; tbody.innerHTML = rows.map(r => { const before = r.before_path ? `было` : '—'; const after = r.after_path ? `стало` : '—'; const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback'; const badge = `${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}`; return ` ${fmtTime(r.finished_at)} #${r.entry_id} ${esc(r.student_name || '')} ${esc(PHOTO_ACTION_LABELS[r.action] || r.action)} ${badge} ${before} ${after} ${r.error ? esc(r.error) : ''} ${canRollback ? `` : ''} `; }).join(''); renderIcons(); } function findPhotoJob(id) { return (photoState && photoState.recent || []).find(r => r.id === id) || null; } function setPhotoJobClip(p) { const v = Math.min(100, Math.max(0, p)); document.getElementById('photoJobTop').style.clipPath = `inset(0 0 0 ${v}%)`; document.getElementById('photoJobDivider').style.left = v + '%'; } function openPhotoJob(r) { photoJobRow = r; document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`; document.getElementById('photoJobBefore').src = thumbUrl(r.before_path); document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : ''; document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none'; setPhotoJobClip(50); document.getElementById('photoJobModal').classList.add('open'); } async function loadPhoto() { if (photoBusy) return; photoBusy = true; try { const res = await fetch(`${API}/api/photo-jobs/status`, { headers: hdr() }); if (!res.ok) return; photoState = await res.json(); renderPhoto(); } catch { } finally { photoBusy = false; } } async function postPhoto(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 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(); loadPhoto(); }, AUTO_REFRESH_MS); } document.getElementById('refreshBtn').addEventListener('click', () => { load(); loadPhoto(); }); document.getElementById('wakeBtn').addEventListener('click', wake); document.getElementById('toggleBtn').addEventListener('click', toggleEnabled); document.getElementById('requeueBtn').addEventListener('click', requeueFailed); document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); }); document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked)); document.getElementById('photoWakeBtn').addEventListener('click', async () => { if (await postPhoto('/api/photo-jobs/wake')) { showToast('Фото-воркер разбужен'); loadPhoto(); } }); document.getElementById('photoToggleBtn').addEventListener('click', async () => { const next = !(photoState && photoState.enabled); const data = await postPhoto('/api/photo-jobs/enabled', { enabled: next }); if (data) { showToast(next ? 'Обработка фото включена' : 'Обработка фото выключена'); loadPhoto(); } }); document.getElementById('photoRequeueBtn').addEventListener('click', async () => { if (!confirm('Вернуть все задания с ошибкой в очередь на повторную обработку?')) return; const data = await postPhoto('/api/photo-jobs/requeue-failed'); if (data) { showToast(`В очередь возвращено: ${data.count}`); loadPhoto(); } }); document.getElementById('recentTable').addEventListener('click', e => { const btn = e.target.closest('.ai-details'); if (!btn) return; const r = recentRows[parseInt(btn.dataset.aiIdx, 10)]; if (!r) return; document.getElementById('aiDetailTitle').textContent = `ИИ-проверка #${r.id} — ${r.student_name}`; document.getElementById('aiDetailInput').textContent = r.description_original || '—'; document.getElementById('aiDetailOutput').textContent = r.description_ai || '—'; document.getElementById('aiDetailModal').classList.add('open'); }); document.getElementById('aiDetailCloseBtn').addEventListener('click', () => { document.getElementById('aiDetailModal').classList.remove('open'); }); document.getElementById('aiDetailModal').addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); }); document.getElementById('photoTable').addEventListener('click', async e => { const viewBtn = e.target.closest('[data-photo-view]'); if (viewBtn) { const r = findPhotoJob(parseInt(viewBtn.dataset.photoView, 10)); if (r) openPhotoJob(r); return; } const rb = e.target.closest('[data-photo-rollback]'); if (rb) { const entryId = rb.dataset.photoEntry; const jobId = rb.dataset.photoRollback; if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return; const data = await postPhoto(`/api/entries/${entryId}/photo/jobs/${jobId}/rollback`); if (data) { showToast('Фотография отката'); loadPhoto(); } } }); document.getElementById('photoJobCloseBtn').addEventListener('click', () => { document.getElementById('photoJobModal').classList.remove('open'); }); document.getElementById('photoJobModal').addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); }); document.getElementById('photoJobRollbackBtn').addEventListener('click', async () => { const r = photoJobRow; if (!r) return; if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return; const data = await postPhoto(`/api/entries/${r.entry_id}/photo/jobs/${r.id}/rollback`); if (data) { showToast('Фотография отката'); document.getElementById('photoJobModal').classList.remove('open'); loadPhoto(); } }); (function initPhotoJobDrag() { const area = document.getElementById('photoJobCompare'); let dragging = false; const move = ev => { const rect = area.getBoundingClientRect(); setPhotoJobClip(((ev.clientX - rect.left) / rect.width) * 100); }; area.addEventListener('pointerdown', ev => { dragging = true; area.setPointerCapture(ev.pointerId); move(ev); }); area.addEventListener('pointermove', ev => { if (dragging) move(ev); }); area.addEventListener('pointerup', () => { dragging = false; }); area.addEventListener('pointercancel', () => { dragging = false; }); })(); (async () => { if (await requireAdminPage()) { buildSidebar(document.body.dataset.page); load(); loadPhoto(); } })();