feat: persistent photo job queue, worker, history with rollback
- Replace in-memory photoAiJobs Map with DB-backed photo_jobs table - Background photo worker (worker.js) with retry, backoff, stale reset - Handles both AI (Real-ESRGAN) and server-side (sharp) enhancement - Controlled via photo_worker_enabled setting - Photo job history in enhance modal with before/after thumbnails + rollback - Worker dashboard: photo jobs section with status counts, recent table, compare slider for before/after, rollback from worker UI - New endpoints: /api/photo-jobs/status|wake|enabled|requeue-failed, /api/entries/:id/photo/jobs (history), .../rollback - swapEntryPhotoFiles logs every mutation to photo_jobs table
This commit is contained in:
+179
-2
@@ -16,11 +16,38 @@ const STATUS_BADGE = {
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -190,6 +217,90 @@ function render() {
|
||||
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 || [];
|
||||
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 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;
|
||||
@@ -231,15 +342,28 @@ async function requeueFailed() {
|
||||
|
||||
function setAutoRefresh(on) {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
|
||||
if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS);
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', load);
|
||||
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');
|
||||
@@ -258,10 +382,63 @@ 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();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user