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:
@@ -27,6 +27,7 @@
|
||||
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
||||
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||
@media (max-width: 800px) {
|
||||
.enhance-body { flex-direction: column; }
|
||||
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||
@@ -215,6 +216,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot enhance-foot">
|
||||
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||
@@ -224,6 +226,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="photoHistoryModal">
|
||||
<div class="edit-modal enhance-modal">
|
||||
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
|
||||
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
|
||||
<div class="card-foot enhance-foot">
|
||||
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Воркер ИИ — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<style>
|
||||
.enhance-compare { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
|
||||
.enhance-stage { transform-origin: 0 0; }
|
||||
.enhance-stage > img { display: block; width: 100%; height: auto; }
|
||||
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
|
||||
.enhance-top .enhance-stage { position: absolute; inset: 0; }
|
||||
.enhance-top .enhance-stage > img { height: 100%; object-fit: fill; }
|
||||
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); cursor: ew-resize; }
|
||||
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
|
||||
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
|
||||
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
|
||||
.enhance-badge.left { left: 10px; }
|
||||
.enhance-badge.right { right: 10px; }
|
||||
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="worker">
|
||||
<div class="layout">
|
||||
@@ -24,6 +39,8 @@
|
||||
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
|
||||
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
|
||||
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
|
||||
<button class="btn-link" id="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
|
||||
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
|
||||
<button class="btn-link" id="toggleBtn" type="button">…</button>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +95,28 @@
|
||||
<div class="pager" id="recentPager"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="image"></i></div>
|
||||
<h3>Обработка фотографий</h3>
|
||||
</div>
|
||||
<div class="actions-row" style="margin-bottom:10px">
|
||||
<span class="hint" id="photoStateHint"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-link" id="photoRequeueBtn" type="button" title="Вернуть задания с ошибкой в очередь">Повторить ошибки фото</button>
|
||||
</div>
|
||||
<div class="worker-counts" id="photoCounts" style="margin-bottom:10px"></div>
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="photoTable">
|
||||
<thead>
|
||||
<tr><th>Время</th><th>Запись</th><th>Действие</th><th>Статус</th><th>Было</th><th>Стало</th><th>Ошибка</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide" id="errorsCard" style="display:none">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="triangle-alert"></i></div>
|
||||
@@ -112,6 +151,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="photoJobModal">
|
||||
<div class="edit-modal" style="max-width:900px">
|
||||
<h3 id="photoJobTitle">Обработка фотографии</h3>
|
||||
<div class="enhance-compare" id="photoJobCompare">
|
||||
<div class="enhance-stage">
|
||||
<img id="photoJobBefore" alt="Было">
|
||||
</div>
|
||||
<div class="enhance-top" id="photoJobTop" style="clip-path:inset(0 0 0 50%)">
|
||||
<div class="enhance-stage enhance-stage-fill">
|
||||
<img id="photoJobAfter" alt="Стало">
|
||||
</div>
|
||||
</div>
|
||||
<div class="enhance-divider" id="photoJobDivider"><div class="enhance-handle">⇔</div></div>
|
||||
<div class="enhance-badge left">Было</div>
|
||||
<div class="enhance-badge right">Стало</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
|
||||
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
|
||||
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user