feat(photo-ai): Stage 5 — фронтенд фото-ИИ (режимы, статус, метаданные)

Только фронтенд: server.js, worker.js, photo-ai/, compose и схема БД не тронуты.
Контракт /enhance-ai, /api/photo-ai/health и service в /api/photo-jobs/status
взят из Stage 4 как есть (I6 — новых колонок нет, метаданные из photo_jobs.params
и audit_log.target).

journal.html + journal.js:
- селект режима рядом с «🤖 ИИ»: Универсально (x2) / Быстро (x4) / Лица /
  Лица + фон; PHOTO_AI_MODES — единственная таблица режим→{model, face}
- значение уходит в POST .../enhance-ai телом {model, face, face_model};
  face_model подставляется только когда face !== 'off'
- дефолт селекта из photo_ai_face_mode (face=all → facesbg, face=face → faces)
- подсказка про медленный CPU: loadPhotoAiDevice() читает
  GET /api/photo-ai/health (только для админа)
- текст подтверждения и надпись «ИИ обрабатывает…» различают апскейл и face-режим
- кнопка и селект скрываются вместе при photo_ai_enabled === 'false' (I3)

settings.html + settings.js:
- новый раздел «Фото-ИИ»: режим по умолчанию, модель лиц, желаемое устройство
- read-only статус из /api/photo-jobs/status: фактическое устройство,
  device_name, VRAM, модели/модели лиц/загруженные; жёлтым — расхождение с
  желаемым устройством, красным — недоступность и незаданный PHOTO_AI_URL
- renderStackInfo() рисует карточку «Фото-ИИ» из stack.photo_ai
- новые id в DIRTY_FIELDS и в payload PUT /api/settings
- photo_ai_device_pref документирующий: фактическое устройство задаёт
  PHOTO_AI_DEVICE в контейнере

worker.html + worker.js:
- PHOTO_ACTION_LABELS: ai_face «ИИ + лица», ai_upscale «ИИ-апскейл»
- в модалке сравнения — чипы с моделью, режимом лиц, найденными лицами,
  устройством, временем обработки и предупреждениями
- строка состояния учитывает service.reachable === false («задания ждут»)
  и дописывает «· расчёт на <device>»

audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и
фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип).

Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом
изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md
оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6.
This commit is contained in:
dev
2026-09-30 11:15:54 +03:00
parent 884188e9ec
commit 45033bffa8
8 changed files with 311 additions and 15 deletions
+52 -3
View File
@@ -19,6 +19,7 @@ const STATUS_BADGE = {
let state = null;
let photoState = null;
let photoJobRow = null;
let photoJobMeta = {};
let refreshTimer = null;
let busy = false;
let photoBusy = false;
@@ -29,6 +30,8 @@ let photoTotal = 0;
const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка',
ai_face: 'ИИ + лица',
ai_upscale: 'ИИ-апскейл',
enhance: 'Улучшение',
restore: 'Возврат оригинала',
rollback: 'Откат',
@@ -224,9 +227,15 @@ function render() {
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 ?? ''}` : 'Ожидание очереди'));
const svc = photoState.service || {};
let hint;
if (!photoState.enabled) hint = 'Обработка фотографий выключена';
else if (!photoState.ai_configured) hint = 'PHOTO_AI_URL не настроен';
else if (svc.reachable === false) hint = 'Сервис фото-ИИ недоступен — задания ждут';
else if (w.processing) hint = `Обрабатывается задание #${w.current_id ?? ''}`;
else hint = 'Ожидание очереди';
if (photoState.enabled && photoState.ai_configured && svc.reachable && svc.device) hint += ` · расчёт на ${svc.device}`;
document.getElementById('photoStateHint').textContent = hint;
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
const c = photoState.counts || {};
document.getElementById('photoCounts').innerHTML = [
@@ -297,12 +306,52 @@ function setPhotoJobClip(p) {
document.getElementById('photoJobDivider').style.left = v + '%';
}
async function loadPhotoJobMeta() {
try {
const res = await fetch(`${API}/api/audit?action=photo.job.preview&limit=200`, { headers: hdr() });
if (!res.ok) return;
const rows = await res.json();
const map = {};
rows.forEach(r => {
const t = r.target;
if (t && t.job_id && !map[t.job_id]) map[t.job_id] = t;
});
photoJobMeta = map;
} catch {
}
}
function renderPhotoJobMeta(r) {
const el = document.getElementById('photoJobMeta');
if (!el) return;
const p = r.params && typeof r.params === 'object' ? r.params : {};
const m = photoJobMeta[r.id] || {};
const chips = [];
const model = m.model || p.model;
if (model) chips.push(['модель', esc(String(model))]);
const face = m.face || p.face;
if (face && face !== 'off') {
const fm = m.face_model || p.face_model;
const title = face === 'all' ? 'лица + фон' : 'только лица';
chips.push(['режим', esc(title) + (fm ? ` (${esc(String(fm))})` : '')]);
}
if (m.faces_found !== null && m.faces_found !== undefined) chips.push(['найдено лиц', String(m.faces_found)]);
if (m.device) chips.push(['устройство', esc(String(m.device))]);
if (m.elapsed_ms !== null && m.elapsed_ms !== undefined) chips.push(['обработка', esc(fmtDur(m.elapsed_ms))]);
if (Array.isArray(m.warnings) && m.warnings.length) chips.push(['предупреждения', esc(m.warnings.join('; '))]);
el.innerHTML = chips.length
? chips.map(([k, v]) => `<span class="chip">${esc(k)}: <b>${v}</b></span>`).join('')
: '<span class="hint">Метаданные обработки недоступны</span>';
}
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';
renderPhotoJobMeta(r);
loadPhotoJobMeta().then(() => { if (photoJobRow === r) renderPhotoJobMeta(r); });
setPhotoJobClip(50);
document.getElementById('photoJobModal').classList.add('open');
}