diff --git a/frontend/src/features/admin/media/UploadSnackbar.tsx b/frontend/src/features/admin/media/UploadSnackbar.tsx index d3868c8..965fa62 100644 --- a/frontend/src/features/admin/media/UploadSnackbar.tsx +++ b/frontend/src/features/admin/media/UploadSnackbar.tsx @@ -25,7 +25,8 @@ function barWidth(item: UploadItem): number { /** Глобальный индикатор загрузок: живёт вне страниц, поэтому загрузка идёт при любой навигации. */ export function UploadSnackbar() { const { t } = useTranslation() - const { items, active, minimized, skipped, toggleMinimize, dismiss } = useUploadStore() + const { items, active, minimized, skipped, toggleMinimize, dismiss, cancel, cancelAll } = + useUploadStore() if (items.length === 0 && skipped === 0) return null @@ -50,6 +51,16 @@ export function UploadSnackbar() { {minimized ? : } )} + {active && ( + + )} {!active && ( + )}
void, + signal?: AbortSignal, ): Promise { return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } + const xhr = new XMLHttpRequest() const query = new URLSearchParams({ fileName: file.name }) xhr.open('POST', `/api/admin/media?${query.toString()}`) @@ -42,6 +48,9 @@ export function uploadMedia( const token = getAccessToken() if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) + signal?.addEventListener('abort', () => xhr.abort()) + xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')) + xhr.upload.onprogress = (event) => { if (event.lengthComputable && onProgress) onProgress(Math.round((event.loaded / event.total) * 100)) diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts index ffbbfb1..d14e63e 100644 --- a/frontend/src/features/admin/media/upload-store.ts +++ b/frontend/src/features/admin/media/upload-store.ts @@ -15,6 +15,8 @@ type UploadStore = { minimized: boolean skipped: number enqueue: (files: File[]) => Promise + cancel: (id: string) => void + cancelAll: () => void toggleMinimize: () => void dismiss: () => void } @@ -22,6 +24,7 @@ type UploadStore = { // Очередь и флаг живут вне React — загрузка продолжается при любой навигации. let counter = 0 const queue: { id: string; file: File }[] = [] +const controllers = new Map() let running = false const patch = (id: string, changes: Partial) => @@ -36,13 +39,19 @@ async function pump() { while (queue.length > 0) { const job = queue.shift()! + const controller = new AbortController() + controllers.set(job.id, controller) patch(job.id, { status: 'uploading', percent: 0 }) try { - await uploadMedia(job.file, (percent) => patch(job.id, { percent })) + await uploadMedia(job.file, (percent) => patch(job.id, { percent }), controller.signal) patch(job.id, { status: 'done', percent: 100 }) void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) - } catch { - patch(job.id, { status: 'error' }) + } catch (error) { + // Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой. + if (!(error instanceof DOMException && error.name === 'AbortError')) + patch(job.id, { status: 'error' }) + } finally { + controllers.delete(job.id) } } @@ -93,6 +102,23 @@ export const useUploadStore = create((set) => ({ if (newItems.length) void pump() }, + cancel: (id) => { + // Из очереди — если ещё не стартовал; активную загрузку прерываем. + const queuedIndex = queue.findIndex((j) => j.id === id) + if (queuedIndex !== -1) queue.splice(queuedIndex, 1) + controllers.get(id)?.abort() + set((s) => ({ items: s.items.filter((i) => i.id !== id) })) + }, + + cancelAll: () => { + queue.length = 0 + controllers.forEach((controller) => controller.abort()) + // Оставляем уже завершённые/ошибочные, убираем очередь и текущую загрузку. + set((s) => ({ + items: s.items.filter((i) => i.status === 'done' || i.status === 'error'), + })) + }, + toggleMinimize: () => set((s) => ({ minimized: !s.minimized })), dismiss: () => set({ items: [], skipped: 0 }), })) diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 54798f4..b205d02 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -117,6 +117,7 @@ const resources = { uploaded: 'Файл загружен, идёт обработка', uploadedCount: 'Загружено файлов: {{count}}', uploadingCount: 'Загрузка {{done}}/{{total}}', + cancelAll: 'Отменить все загрузки', skippedDuplicates: 'Пропущено дубликатов: {{count}}', filterActive: 'Активные', filterAll: 'Все', @@ -361,6 +362,7 @@ const resources = { uploaded: 'File uploaded, processing started', uploadedCount: 'Uploaded files: {{count}}', uploadingCount: 'Uploading {{done}}/{{total}}', + cancelAll: 'Cancel all uploads', skippedDuplicates: 'Skipped duplicates: {{count}}', filterActive: 'Active', filterAll: 'All',