diff --git a/frontend/src/features/admin/media/UploadSnackbar.tsx b/frontend/src/features/admin/media/UploadSnackbar.tsx index 965fa62..eeea8d2 100644 --- a/frontend/src/features/admin/media/UploadSnackbar.tsx +++ b/frontend/src/features/admin/media/UploadSnackbar.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, X } from 'lucide-react' +import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, RotateCw, X } from 'lucide-react' import { cn } from '@/shared/lib/cn' import { type UploadItem, useUploadStore } from './upload-store' @@ -25,7 +25,7 @@ function barWidth(item: UploadItem): number { /** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */ export function UploadSnackbar() { const { t } = useTranslation() - const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll } = + const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll, retry } = useUploadStore() if (items.length === 0 && skipped === 0) return null @@ -103,6 +103,16 @@ export function UploadSnackbar() { )} + {item.status === 'error' && ( + + )}
Promise cancel: (id: string) => void cancelAll: () => void + retry: (id: string) => void toggleMinimize: () => void dismiss: () => void } @@ -28,10 +30,27 @@ export type EnqueueOptions = { showId?: string } // Очередь и флаг живут вне React — загрузка продолжается при любой навигации. let counter = 0 -const queue: { id: string; file: File; showId?: string }[] = [] +type Job = { id: string; file: File; showId?: string } +const queue: Job[] = [] const controllers = new Map() +const failed = new Map() // упавшие — для ручного повтора let running = false +const MAX_ATTEMPTS = 3 +const RETRY_DELAY_MS = 1500 + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Временная ли ошибка (стоит ретраить): 5xx/сеть — да; 4xx (формат/дубликат) — нет. */ +function isTransient(error: unknown): boolean { + if (error instanceof HttpError) return error.status >= 500 || error.status === 0 + return true +} + +function isAbort(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + const patch = (id: string, changes: Partial) => useUploadStore.setState((s) => ({ items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)), @@ -46,13 +65,35 @@ async function pump() { const job = queue.shift()! const controller = new AbortController() controllers.set(job.id, controller) - patch(job.id, { status: 'uploading', percent: 0 }) - try { - const created = await uploadMedia( - job.file, - (percent) => patch(job.id, { percent }), - controller.signal, - ) + + let created: { id: string } | null = null + let lastError: unknown = null + // Ретраим временные сбои (например, 502 от прокси) — до MAX_ATTEMPTS попыток. + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + patch(job.id, { status: 'uploading', percent: 0 }) + try { + created = await uploadMedia( + job.file, + (percent) => patch(job.id, { percent }), + controller.signal, + ) + lastError = null + break + } catch (error) { + lastError = error + if (isAbort(error) || controller.signal.aborted) break + // Истёк access-токен (XHR идёт мимо авто-refresh) — обновляем и повторяем сразу. + if (error instanceof HttpError && error.status === 401 && attempt < MAX_ATTEMPTS) { + if (await refreshAccessToken()) continue + break + } + if (attempt < MAX_ATTEMPTS && isTransient(error)) await delay(RETRY_DELAY_MS) + else break + } + } + controllers.delete(job.id) + + if (created) { patch(job.id, { status: 'done', percent: 100 }) void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) @@ -65,12 +106,11 @@ async function pump() { toast.error(`${job.file.name}: не удалось добавить в шоу`) } } - } catch (error) { - // Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой. - if (!(error instanceof DOMException && error.name === 'AbortError')) - patch(job.id, { status: 'error' }) - } finally { - controllers.delete(job.id) + } else if (isAbort(lastError) || controller.signal.aborted) { + // Отмена — тихо: элемент уже убран из списка. + } else { + failed.set(job.id, job) // сохраняем для ручного повтора + patch(job.id, { status: 'error' }) } } @@ -126,6 +166,7 @@ export const useUploadStore = create((set) => ({ const queuedIndex = queue.findIndex((j) => j.id === id) if (queuedIndex !== -1) queue.splice(queuedIndex, 1) controllers.get(id)?.abort() + failed.delete(id) set((s) => ({ items: s.items.filter((i) => i.id !== id) })) }, @@ -138,6 +179,18 @@ export const useUploadStore = create((set) => ({ })) }, + retry: (id) => { + const job = failed.get(id) + if (!job) return + failed.delete(id) + queue.push(job) + patch(id, { status: 'queued', percent: 0 }) + void pump() + }, + toggleMinimize: () => set((s) => ({ minimized: !s.minimized })), - dismiss: () => set({ items: [], skipped: 0 }), + dismiss: () => { + failed.clear() + set({ items: [], skipped: 0 }) + }, })) diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 6593b50..76f2eec 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -24,7 +24,7 @@ type RequestOptions = { skipRefresh?: boolean } -async function refreshAccessToken(): Promise { +export async function refreshAccessToken(): Promise { if (!refreshInFlight) { refreshInFlight = (async () => { try { diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index f1ac733..2e4813c 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -19,6 +19,7 @@ const resources = { common: { save: 'Сохранить', cancel: 'Отмена', + retry: 'Повторить', delete: 'Удалить', create: 'Создать', loading: 'Загрузка…', @@ -279,6 +280,7 @@ const resources = { common: { save: 'Save', cancel: 'Cancel', + retry: 'Retry', delete: 'Delete', create: 'Create', loading: 'Loading…',