Files
WhatIDo/public/js/worker.js
T
dev 5874e2b619 feat: photo jobs apply/reject flow with applied flag in history
- DB: add photo_jobs.applied column (init + migration + ensure)
- Worker: generate preview only (after_path), no longer mutates entry
- New POST /api/entries/:id/photo/jobs/:jobId/apply — apply done job
  result to entry (backs up current photo, marks applied)
- New POST /api/entries/:id/photo/jobs/:jobId/reject — discard result,
  delete temp file, mark rejected
- saveEnhance: apply AI result directly when sliders are at defaults
- Photo history: '✓ Применить' action for unapplied done AI jobs;
  'rejected' status label
- sweepOrphanedUploads keeps done-not-applied preview files
2026-09-17 18:51:26 +03:00

467 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 'Ошибка',
rejected: 'Отклонено',
};
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 `<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 class="lucide-inline" style="color:#16a34a"><i data-lucide="check"></i></span> доступен' : '<span class="lucide-inline" style="color:#ef4444"><i data-lucide="x"></i></span> недоступен'}</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');
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) => `
<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 ? '<span class="lucide-inline" style="color:#f59e0b"><i data-lucide="pencil"></i></span> исправлен' : '—'}</td>
<td><button type="button" class="ai-details" data-ai-idx="${i}">Подробнее</button></td>
</tr>`).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 => `
<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();
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]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`).join('');
const rows = photoState.recent || [];
renderPhotoPending();
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 ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
const badge = `<span class="ai-badge ${PHOTO_STATUS_BADGE[r.status] || ''}">${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}</span>`;
return `<tr>
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.finished_at)}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')}</td>
<td>${esc(PHOTO_ACTION_LABELS[r.action] || r.action)}</td>
<td>${badge}</td>
<td>${before}</td>
<td>${after}</td>
<td style="color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.error || '')}">${r.error ? esc(r.error) : ''}</td>
<td style="white-space:nowrap">
<button type="button" class="btn-link" data-photo-view="${r.id}">Смотреть</button>
${canRollback ? `<button type="button" class="btn-link" data-photo-rollback="${r.id}" data-photo-entry="${r.entry_id}">↩ Откатить</button>` : ''}
</td>
</tr>`;
}).join('');
renderIcons();
}
function renderPhotoPending() {
const rows = photoState.pending || [];
document.getElementById('photoPendingCount').textContent = rows.length;
const tbody = document.querySelector('#photoPendingTable tbody');
const empty = document.getElementById('photoPendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => {
const processing = r.status === 'processing';
const action = esc(PHOTO_ACTION_LABELS[r.action] || r.action);
const badge = `<span class="ai-badge ${processing ? 'ai-badge-skipped' : ''}">${processing ? '⏳ Обрабатывается' : 'В очереди'}</span>`;
return `<tr${processing ? ' style="background:rgba(59,130,246,.08)"' : ''}>
<td>#${r.id}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')} <span style="color:var(--muted)">(${esc(r.group_name || '')})</span></td>
<td>${action} ${badge}</td>
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
</tr>`;
}).join('');
}
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();
}
})();