From 2e4019ac6da9177ffbd9888a25edb4ef50835846 Mon Sep 17 00:00:00 2001 From: Norman-W <85535885@qq.com> Date: Sat, 20 Jun 2026 16:02:50 +0800 Subject: [PATCH] feat: inline upload queue rows with progress and pause/resume Show in-progress uploads directly in the browse file list instead of toasts, with percent/status text, pause/resume/cancel controls, and a pausable streaming upload that survives tab switches within the bucket. Co-authored-by: Cursor --- src/app/app.tsx | 6 +- src/app/styles.css | 5 + src/lib/api.ts | 106 ++++++++ src/lib/pausable-upload.ts | 202 ++++++++++++++ src/pages/buckets/manage/browse/actions.tsx | 24 +- src/pages/buckets/manage/browse/hooks.ts | 4 + .../buckets/manage/browse/object-list.tsx | 31 ++- src/pages/buckets/manage/browse/types.ts | 27 ++ .../buckets/manage/browse/upload-queue.tsx | 252 ++++++++++++++++++ .../manage/browse/upload-row-actions.tsx | 42 +++ .../buckets/manage/browse/upload-row.tsx | 82 ++++++ src/pages/buckets/manage/page.tsx | 5 +- 12 files changed, 764 insertions(+), 22 deletions(-) create mode 100644 src/lib/pausable-upload.ts create mode 100644 src/pages/buckets/manage/browse/upload-queue.tsx create mode 100644 src/pages/buckets/manage/browse/upload-row-actions.tsx create mode 100644 src/pages/buckets/manage/browse/upload-row.tsx diff --git a/src/app/app.tsx b/src/app/app.tsx index 535a82e..5bf3e5d 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -14,7 +14,11 @@ const App = () => { - + ); diff --git a/src/app/styles.css b/src/app/styles.css index c49feab..a324023 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -36,4 +36,9 @@ .modal-backdrop { @apply border-none outline-none; } + + /* Sonner — follow daisyUI theme tokens. */ + [data-sonner-toaster] [data-sonner-toast].garage-toast-host { + @apply bg-base-100 text-base-content border border-base-300 shadow-lg; + } } diff --git a/src/lib/api.ts b/src/lib/api.ts index db03c9a..f605df9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,6 +7,25 @@ type FetchOptions = Omit & { body?: any; }; +type UploadProgressEvent = { + percent: number; + phase: "uploading" | "finishing"; +}; + +type UploadOptions = { + params?: Record; + onProgress?: (event: UploadProgressEvent) => void; + timeoutMs?: number; +}; + +export type UploadHandle = { + promise: Promise; + abort: () => void; +}; + +/** Default upload timeout aligned with nginx proxy_read_timeout for /oss/. */ +const DEFAULT_UPLOAD_TIMEOUT_MS = 3_600_000; + export const API_URL = BASE_PATH + "/api"; export class APIError extends Error { @@ -87,6 +106,93 @@ const api = { }); }, + upload( + url: string, + body: FormData, + options?: UploadOptions + ): UploadHandle { + const _url = new URL(API_URL + url, window.location.origin); + + if (options?.params) { + Object.entries(options.params).forEach(([key, value]) => { + _url.searchParams.set(key, String(value)); + }); + } + + const xhr = new XMLHttpRequest(); + xhr.open("PUT", _url.toString(), true); + xhr.withCredentials = true; + xhr.timeout = options?.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS; + + xhr.upload.addEventListener("progress", (event) => { + if (!event.lengthComputable || !options?.onProgress) return; + + const sentPercent = Math.round((event.loaded / event.total) * 100); + options.onProgress({ + phase: "uploading", + percent: Math.min(95, sentPercent), + }); + }); + + xhr.upload.addEventListener("load", () => { + options?.onProgress?.({ phase: "finishing", percent: 99 }); + }); + + const promise = new Promise((resolve, reject) => { + xhr.addEventListener("load", () => { + const contentType = xhr.getResponseHeader("Content-Type") || ""; + const isJson = contentType.includes("application/json"); + let data: unknown; + + try { + data = isJson ? JSON.parse(xhr.responseText) : xhr.responseText; + } catch { + reject(new APIError("Invalid response", xhr.status)); + return; + } + + if (xhr.status === 401 && !url.startsWith("/auth")) { + window.location.href = utils.url("/auth/login"); + reject(new APIError("unauthorized", xhr.status)); + return; + } + + if (xhr.status < 200 || xhr.status >= 300) { + const message = isJson + ? (data as { message?: string })?.message + : typeof data === "string" + ? data + : xhr.statusText; + reject(new APIError(message || xhr.statusText, xhr.status)); + return; + } + + resolve(data as T); + }); + + xhr.addEventListener("abort", () => { + reject(new APIError("Upload aborted", 0)); + }); + + xhr.addEventListener("error", () => { + reject(new APIError("Network error", 0)); + }); + + xhr.addEventListener("timeout", () => { + reject( + new APIError("Upload timed out while waiting for the server", 504) + ); + }); + + xhr.send(body); + }); + + return { + promise, + abort: () => xhr.abort(), + }; + }, + async delete(url: string, options?: Partial) { return this.fetch(url, { ...options, diff --git a/src/lib/pausable-upload.ts b/src/lib/pausable-upload.ts new file mode 100644 index 0000000..9bdd233 --- /dev/null +++ b/src/lib/pausable-upload.ts @@ -0,0 +1,202 @@ +//#region 常量/配置 + +const CHUNK_SIZE = 512 * 1024; + +//#endregion + +//#region 模型/类型 + +type StreamPhase = "header" | "body" | "footer"; + +type PausableStreamState = { + paused: boolean; + aborted: boolean; + offset: number; +}; + +export type PausableUploadProgress = { + percent: number; + phase: "uploading" | "finishing"; +}; + +export type PausableUploadHandle = { + promise: Promise; + pause: () => void; + resume: () => void; + abort: () => void; +}; + +//#endregion + +//#region 方法/工具 + +const escapeMultipartFilename = (name: string) => + name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + +const createMultipartFileStream = ( + file: File, + boundary: string, + state: PausableStreamState, + onBytesSent: (sent: number, total: number) => void +) => { + const encoder = new TextEncoder(); + const header = encoder.encode( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${escapeMultipartFilename(file.name)}"\r\n` + + `Content-Type: ${file.type || "application/octet-stream"}\r\n\r\n` + ); + const footer = encoder.encode(`\r\n--${boundary}--\r\n`); + let phase: StreamPhase = "header"; + + const waitWhilePaused = async () => { + while (state.paused && !state.aborted) { + await new Promise((resolve) => setTimeout(resolve, 80)); + } + }; + + return new ReadableStream({ + async pull(controller) { + if (state.aborted) { + controller.error(new DOMException("Upload aborted", "AbortError")); + return; + } + + await waitWhilePaused(); + + if (state.aborted) { + controller.error(new DOMException("Upload aborted", "AbortError")); + return; + } + + if (phase === "header") { + controller.enqueue(header); + phase = "body"; + onBytesSent(0, file.size); + return; + } + + if (phase === "body") { + if (state.offset < file.size) { + await waitWhilePaused(); + if (state.aborted) { + controller.error(new DOMException("Upload aborted", "AbortError")); + return; + } + + const end = Math.min(state.offset + CHUNK_SIZE, file.size); + const chunk = await file.slice(state.offset, end).arrayBuffer(); + controller.enqueue(new Uint8Array(chunk)); + state.offset = end; + onBytesSent(state.offset, file.size); + return; + } + + phase = "footer"; + } + + if (phase === "footer") { + controller.enqueue(footer); + controller.close(); + } + }, + }); +}; + +//#endregion + +//#region 公开 API + +export const startPausableUpload = ( + url: string, + file: File, + options?: { + onProgress?: (event: PausableUploadProgress) => void; + timeoutMs?: number; + onUnauthorized?: () => void; + } +): PausableUploadHandle => { + const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`; + const state: PausableStreamState = { + paused: false, + aborted: false, + offset: 0, + }; + const abortController = new AbortController(); + const timeoutMs = options?.timeoutMs ?? 3_600_000; + let bodyFinished = false; + + const timeoutId = window.setTimeout(() => { + state.aborted = true; + abortController.abort(); + }, timeoutMs); + + const stream = createMultipartFileStream(file, boundary, state, (sent, total) => { + if (bodyFinished) return; + + if (sent >= total) { + bodyFinished = true; + options?.onProgress?.({ phase: "finishing", percent: 99 }); + return; + } + + const sentPercent = Math.round((sent / total) * 100); + options?.onProgress?.({ + phase: "uploading", + percent: Math.min(95, sentPercent), + }); + }); + + const promise = fetch(url, { + method: "PUT", + body: stream, + credentials: "include", + signal: abortController.signal, + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }, + duplex: "half", + } as RequestInit) + .then(async (res) => { + window.clearTimeout(timeoutId); + + const contentType = res.headers.get("Content-Type") || ""; + const isJson = contentType.includes("application/json"); + const data = isJson ? await res.json() : await res.text(); + + if (res.status === 401) { + options?.onUnauthorized?.(); + throw new Error("unauthorized"); + } + + if (!res.ok) { + const message = isJson + ? (data as { message?: string })?.message + : typeof data === "string" + ? data + : res.statusText; + throw new Error(message || res.statusText); + } + + return data as T; + }) + .catch((error) => { + window.clearTimeout(timeoutId); + throw error; + }); + + return { + promise, + pause: () => { + state.paused = true; + }, + resume: () => { + state.paused = false; + }, + abort: () => { + state.aborted = true; + abortController.abort(); + }, + }; +}; + +//#endregion diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index 9b616a5..696dac7 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -1,6 +1,5 @@ import { FolderPlus, UploadIcon } from "lucide-react"; import Button from "@/components/ui/button"; -import { usePutObject } from "./hooks"; import { toast } from "sonner"; import { handleError, openFilePicker } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; @@ -12,6 +11,8 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { InputField } from "@/components/ui/input"; import { useEffect } from "react"; +import { usePutObject } from "./hooks"; +import { useUploadQueue } from "./upload-queue"; type Props = { prefix: string; @@ -19,15 +20,7 @@ type Props = { const Actions = ({ prefix }: Props) => { const { bucketName } = useBucketContext(); - const queryClient = useQueryClient(); - - const putObject = usePutObject(bucketName, { - onSuccess: () => { - toast.success("File uploaded!"); - queryClient.invalidateQueries({ queryKey: ["browse", bucketName] }); - }, - onError: handleError, - }); + const { enqueueFiles } = useUploadQueue(); const onUploadFile = () => { openFilePicker((files) => { @@ -36,24 +29,23 @@ const Actions = ({ prefix }: Props) => { return; } - for (const file of files) { - const key = prefix + file.name; - putObject.mutate({ key, file }); - } + enqueueFiles( + bucketName, + prefix, + Array.from(files) + ); }, { multiple: true }); }; return ( <> - {/*