История — список генераций

Просмотр всех заданий пользователя (или всей франшизы при gensvc.history.read.all). Активные jobs автообновляются, завершённые сгруппированы по статусу. Главная точка получения результата генерации.

Файл

ai-photo-studio-frontend/src/pages/HistoryPage.tsx

Роут

GET /history

Доступ

Permission gensvc.access. Без gensvc.history.read.all показывает только jobs текущего пользователя (бэк фильтрует по user_id).

Layout

┌─────────────────────────────────────────────────────────┐
│ История генераций                                       │
│ Слева — что вы загрузили, справа — что AI сгенерировал  │
├─────────────────────────────────────────────────────────┤
│ В РАБОТЕ (N)   Обновляется автоматически                │  ← раздел появляется только при наличии активных
│ ┌──────────────┐ ┌──────────────┐                       │
│ │ Spinner      │ │ Spinner      │                       │
│ │ Генерируется │ │ В очереди... │                       │
│ │ Обычно 3-6   │ │ Обычно 3-6   │                       │
│ └──────────────┘ └──────────────┘                       │
├─────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐                       │
│ │ before/after │ │ before/after │  ← SucceededCard      │
│ │ [Скачать ↓]  │ │              │                       │
│ └──────────────┘ └──────────────┘                       │
├─────────────────────────────────────────────────────────┤
│ НЕ ПОЛУЧИЛОСЬ ─────────                                 │
│ ┌──────────────────────────────┐                        │
│ │ thumb │ Не получилось …      │  ← FailedCard         │
│ │       │ [Повторить]          │                        │
│ └──────────────────────────────┘                        │
├─────────────────────────────────────────────────────────┤
│           [Показать ещё]                                │
└─────────────────────────────────────────────────────────┘

Секции (порядок сверху вниз)

1. Active section — «В работе (N)»

Показывается только если есть jobs со статусом pending или running.

  • Карточка ActiveCard (см. § Карточки): миниатюра входного фото + спиннер + статус-текст + подсказка «Обычно 3-6 минут».
  • Заголовок секции: В работе (N) · Обновляется автоматически.
  • Тег aria-live="polite" для скрин-ридеров.

2. Succeeded grid

Сетка grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)).

  • Карточка SucceededCard:
    • Если outputs.length > 1 — мини-grid 2×2 из outputs[0..3].
    • Иначе — split before/after (input ← right output).
    • Подпись: дата + SizeBadge.
    • Кнопка «Скачать ↓» (см. § Download).

3. Failed section

Карточки с компактной структурой (grid-template-columns: 80px 1fr):

  • Слева — миниатюра входного фото.
  • Справа — заголовок «Не получилось сгенерировать», error_message курсивом, дата + SizeBadge, кнопка «Повторить с теми же настройками».
  • Повтор → POST /v1/jobs/{id}/retry → редирект /photo?job=<new_id>.

4. Pagination

nextCursor от бэка. Кнопка «Показать ещё» внизу — loadMore() дописывает страницу к существующему allJobs[].

Polling — auto-refresh active jobs

const POLL_INTERVAL_MS = 5000;
const hasActive = allJobs.some(j => j.status === "pending" || j.status === "running");
 
useEffect(() => {
  if (!hasActive) return;
  const id = setInterval(() => fetchJobs(/*silent=*/true), POLL_INTERVAL_MS);
  return () => clearInterval(id);
}, [hasActive]);
  • Polling запускается только при наличии активных jobs, останавливается когда все settled.
  • silent=true — не показывать spinner на полный экран при фоновом fetch (только обновить список).
  • setLoading не дёргается, ошибки в silent-fetch проглатываются (уже есть данные на экране).

Notification API — push при завершении

При первом заходе /history:

if (Notification.permission === "default") void Notification.requestPermission();

При появлении нового succeeded job (diff lastSucceededIdsRef ↔ текущий список):

if (Notification.permission === "granted") {
  new Notification("✅ Фото готово", {
    body: `Сгенерировано ${fresh.length} фото${fresh.length > 1 ? "графий" : "графия"}`,
  });
}

Уведомление работает из background-вкладки — пользователь не должен сидеть на странице.

Download — blob через бэкенд

<a href download> на cross-origin presigned URL не работает — браузер игнорирует download и просто открывает в новой вкладке. Поэтому:

async function downloadOutput(assetId, jobId) {
  const blob = await downloadAsset(assetId);          // GET /v1/assets/{id} с Bearer
  const blobUrl = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = blobUrl;
  a.download = `dish-${jobId.slice(-8)}.png`;
  a.click();
  setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
  • Имя файла: dish-XXXXXXXX.png (последние 8 символов job_id).
  • На время скачивания кнопка показывает «Скачивание…» и блокируется.
  • На неуспех — fallback window.open(presignedURL) чтобы файл всё равно достался.

Карточки — данные

Все карточки получают Job из ответа GET /v1/jobs:

{
  id, kind, status, user_id, franchise_id,
  preset_id, user_preset_id, extra_prompt,
  size, count, created_at,
  input_url,                  // presigned, TTL 1ч
  output_url,                 // legacy single output
  output_asset_id,            // для скачивания
  outputs: [{ idx, asset_id, url }],   // multi-output
  error_message,
}

Состояния и пустые экраны

СостояниеЧто показываем
loading && allJobs.length === 0Большой spinner по центру
errorКрасная плашка с текстом ошибки
allJobs.length === 0 (но не loading)Empty state: «Пока нет завершённых генераций. Загрузите фото в Студии — результат появится здесь»
activeJobs.length > 0 && succeeded === 0 && failed === 0Только секция «В работе» (без empty state)

Связанные API

  • GET /v1/jobs?cursor=... — список jobs текущего пользователя
  • GET /v1/jobs?status=pending,running (если фильтр пригодится — пока берём все)
  • GET /v1/assets/{id} — скачивание (с Bearer)
  • POST /v1/jobs/{id}/retry — повтор failed-задания

Ссылки