From 4bebe64ff0213ac6a207c08a1839bb7abb70ad21 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 08:06:54 +0300 Subject: [PATCH] Enhance media upload functionality: add abort signal support for uploadMedia, enabling cancellation of ongoing uploads. Implement cancel and cancelAll methods in upload store to manage queued and active uploads. Update UploadSnackbar to include cancel buttons for individual and all uploads. Enhance i18n with new cancellation strings. --- .../features/admin/media/UploadSnackbar.tsx | 26 ++++++++++++++- frontend/src/features/admin/media/api.ts | 9 ++++++ .../src/features/admin/media/upload-store.ts | 32 +++++++++++++++++-- frontend/src/shared/lib/i18n.ts | 2 ++ 4 files changed, 65 insertions(+), 4 deletions(-) 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',