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:
@@ -673,11 +673,98 @@ function cleanupAiPreview() {
|
||||
function closeEnhance() {
|
||||
cleanupAiPreview();
|
||||
document.getElementById('enhanceModal').classList.remove('open');
|
||||
document.getElementById('photoHistoryModal').classList.remove('open');
|
||||
enhanceEntryId = null;
|
||||
enhanceSourceImg = null;
|
||||
enhanceAiResultPath = null;
|
||||
}
|
||||
|
||||
function photoJobThumb(p) {
|
||||
if (!p) return '';
|
||||
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
|
||||
return `${API}/uploads/thumb/${p.split('/').pop()}`;
|
||||
}
|
||||
|
||||
const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' };
|
||||
const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка' };
|
||||
|
||||
async function loadPhotoHistory() {
|
||||
if (!enhanceEntryId) return;
|
||||
const list = document.getElementById('photoHistoryList');
|
||||
list.innerHTML = '<div style="color:var(--muted);font-size:.85rem">Загрузка...</div>';
|
||||
try {
|
||||
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs`, { headers: hdr() });
|
||||
if (!res.ok) throw new Error('Ошибка загрузки истории');
|
||||
const rows = await res.json();
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<div style="color:var(--muted);font-size:.85rem">Изменений фотографии пока не было.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r => {
|
||||
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
|
||||
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
|
||||
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
|
||||
const err = r.error ? `<div style="color:#ef4444;font-size:.75rem;margin-top:4px">${esc(r.error)}</div>` : '';
|
||||
const time = r.finished_at || r.created_at;
|
||||
return `<div style="display:flex;gap:10px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:10px">
|
||||
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
|
||||
${before}
|
||||
<span style="font-size:.65rem;color:var(--muted)">было</span>
|
||||
</div>
|
||||
<div style="color:var(--muted)">→</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
|
||||
${after}
|
||||
<span style="font-size:.65rem;color:var(--muted)">стало</span>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
|
||||
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
|
||||
</div>
|
||||
${err}
|
||||
</div>
|
||||
${canRollback ? `<button type="button" class="btn-primary ghost" style="white-space:nowrap" data-job-rollback="${r.id}">↩ Откатить</button>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="color:#ef4444;font-size:.85rem">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openPhotoHistory() {
|
||||
document.getElementById('photoHistoryTitle').textContent = `История изменений фотографии — запись #${enhanceEntryId}`;
|
||||
document.getElementById('photoHistoryModal').classList.add('open');
|
||||
loadPhotoHistory();
|
||||
}
|
||||
|
||||
async function rollbackPhotoJob(jobId) {
|
||||
if (!enhanceEntryId || enhanceSaving) return;
|
||||
if (!confirm('Откатить фотографию к сохранённой версии? Текущая версия сохранится в истории.')) return;
|
||||
enhanceSaving = true;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/rollback`, { method: 'POST', headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Ошибка отката');
|
||||
showToast('Фотография отката');
|
||||
document.getElementById('photoHistoryModal').classList.remove('open');
|
||||
const mp = data.photo_path;
|
||||
document.getElementById('enhanceOrig').src = `${API}${mp}`;
|
||||
showEnhanceResult(false);
|
||||
setEnhanceLoadingText('Загрузка...');
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
enhanceSourceImg = img;
|
||||
renderEnhancePreview();
|
||||
};
|
||||
img.onerror = () => setEnhanceLoadingText('Не удалось загрузить фото');
|
||||
img.src = `${API}${mp}`;
|
||||
refreshEntryCard(enhanceEntryId);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
enhanceSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function medianFilterPass(ctx, w, h) {
|
||||
const data = ctx.getImageData(0, 0, w, h);
|
||||
const src = data.data;
|
||||
@@ -1257,6 +1344,16 @@ document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance)
|
||||
document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi);
|
||||
document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal);
|
||||
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance);
|
||||
document.getElementById('enhanceHistoryBtn').addEventListener('click', openPhotoHistory);
|
||||
document.getElementById('photoHistoryCloseBtn').addEventListener('click', () => document.getElementById('photoHistoryModal').classList.remove('open'));
|
||||
document.getElementById('photoHistoryModal').addEventListener('click', e => {
|
||||
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
||||
});
|
||||
document.getElementById('photoHistoryList').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-job-rollback]');
|
||||
if (!btn) return;
|
||||
rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10));
|
||||
});
|
||||
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
|
||||
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
||||
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
|
||||
|
||||
+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