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);
|
||||
|
||||
Reference in New Issue
Block a user