From 72dccbc38a9b3543fb125f6936095892a3d4c1a2 Mon Sep 17 00:00:00 2001 From: Norman-W <85535885@qq.com> Date: Sat, 20 Jun 2026 16:15:22 +0800 Subject: [PATCH] refactor: tidy upload queue modules and remove dead upload API Split status labels and file icons into focused modules, drop unused XHR upload path from api.ts, and remove Chinese region markers so the browse upload flow matches upstream style. Co-authored-by: Cursor --- src/lib/api.ts | 106 ---------------- src/lib/pausable-upload.ts | 110 +++++++++-------- src/pages/buckets/manage/browse/actions.tsx | 2 +- .../buckets/manage/browse/file-type-icon.tsx | 39 ++++++ src/pages/buckets/manage/browse/hooks.ts | 12 +- .../buckets/manage/browse/object-list.tsx | 10 +- src/pages/buckets/manage/browse/types.ts | 7 -- .../buckets/manage/browse/upload-queue.tsx | 116 ++++++++---------- .../manage/browse/upload-row-actions.tsx | 3 +- .../buckets/manage/browse/upload-row.tsx | 57 +++------ .../buckets/manage/browse/upload-status.ts | 25 ++++ 11 files changed, 191 insertions(+), 296 deletions(-) create mode 100644 src/pages/buckets/manage/browse/file-type-icon.tsx create mode 100644 src/pages/buckets/manage/browse/upload-status.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index f605df9..db03c9a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,25 +7,6 @@ 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 { @@ -106,93 +87,6 @@ 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 index 9bdd233..c13ec23 100644 --- a/src/lib/pausable-upload.ts +++ b/src/lib/pausable-upload.ts @@ -1,10 +1,9 @@ -//#region 常量/配置 +import { APIError } from "@/lib/api"; const CHUNK_SIZE = 512 * 1024; - -//#endregion - -//#region 模型/类型 +const DEFAULT_UPLOAD_TIMEOUT_MS = 3_600_000; +const PAUSE_POLL_MS = 80; +const MAX_UPLOAD_PERCENT_BEFORE_FINISH = 95; type StreamPhase = "header" | "body" | "footer"; @@ -14,11 +13,6 @@ type PausableStreamState = { offset: number; }; -export type PausableUploadProgress = { - percent: number; - phase: "uploading" | "finishing"; -}; - export type PausableUploadHandle = { promise: Promise; pause: () => void; @@ -26,13 +20,30 @@ export type PausableUploadHandle = { abort: () => void; }; -//#endregion +type UploadProgressEvent = { + percent: number; + phase: "uploading" | "finishing"; +}; -//#region 方法/工具 +type StartPausableUploadOptions = { + onProgress?: (event: UploadProgressEvent) => void; + timeoutMs?: number; + onUnauthorized?: () => void; +}; const escapeMultipartFilename = (name: string) => name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +const abortStream = (controller: ReadableStreamDefaultController) => { + controller.error(new DOMException("Upload aborted", "AbortError")); +}; + +const waitWhilePaused = async (state: PausableStreamState) => { + while (state.paused && !state.aborted) { + await new Promise((resolve) => setTimeout(resolve, PAUSE_POLL_MS)); + } +}; + const createMultipartFileStream = ( file: File, boundary: string, @@ -48,23 +59,16 @@ const createMultipartFileStream = ( 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")); + abortStream(controller); return; } - await waitWhilePaused(); - + await waitWhilePaused(state); if (state.aborted) { - controller.error(new DOMException("Upload aborted", "AbortError")); + abortStream(controller); return; } @@ -77,9 +81,9 @@ const createMultipartFileStream = ( if (phase === "body") { if (state.offset < file.size) { - await waitWhilePaused(); + await waitWhilePaused(state); if (state.aborted) { - controller.error(new DOMException("Upload aborted", "AbortError")); + abortStream(controller); return; } @@ -102,18 +106,31 @@ const createMultipartFileStream = ( }); }; -//#endregion +const parseUploadResponse = async (res: Response) => { + const contentType = res.headers.get("Content-Type") || ""; + const isJson = contentType.includes("application/json"); + const data = isJson ? await res.json() : await res.text(); -//#region 公开 API + if (res.status === 401) { + throw new APIError("unauthorized", 401); + } + + if (!res.ok) { + const message = isJson + ? (data as { message?: string })?.message + : typeof data === "string" + ? data + : res.statusText; + throw new APIError(message || res.statusText, res.status); + } + + return data; +}; export const startPausableUpload = ( url: string, file: File, - options?: { - onProgress?: (event: PausableUploadProgress) => void; - timeoutMs?: number; - onUnauthorized?: () => void; - } + options?: StartPausableUploadOptions ): PausableUploadHandle => { const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`; const state: PausableStreamState = { @@ -122,7 +139,7 @@ export const startPausableUpload = ( offset: 0, }; const abortController = new AbortController(); - const timeoutMs = options?.timeoutMs ?? 3_600_000; + const timeoutMs = options?.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS; let bodyFinished = false; const timeoutId = window.setTimeout(() => { @@ -142,7 +159,7 @@ export const startPausableUpload = ( const sentPercent = Math.round((sent / total) * 100); options?.onProgress?.({ phase: "uploading", - percent: Math.min(95, sentPercent), + percent: Math.min(MAX_UPLOAD_PERCENT_BEFORE_FINISH, sentPercent), }); }); @@ -159,25 +176,14 @@ export const startPausableUpload = ( .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"); + try { + return (await parseUploadResponse(res)) as T; + } catch (error) { + if (error instanceof APIError && error.status === 401) { + options?.onUnauthorized?.(); + } + throw error; } - - 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); @@ -198,5 +204,3 @@ export const startPausableUpload = ( }, }; }; - -//#endregion diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index 696dac7..071f442 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -79,7 +79,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => { }); const onSubmit = form.handleSubmit((values) => { - createFolder.mutate({ key: `${prefix}${values.name}/`, file: null }); + createFolder.mutate({ key: `${prefix}${values.name}/` }); }); return ( diff --git a/src/pages/buckets/manage/browse/file-type-icon.tsx b/src/pages/buckets/manage/browse/file-type-icon.tsx new file mode 100644 index 0000000..56a257c --- /dev/null +++ b/src/pages/buckets/manage/browse/file-type-icon.tsx @@ -0,0 +1,39 @@ +import mime from "mime/lite"; +import { + FileArchive, + FileIcon, + FileType, + type LucideIcon, +} from "lucide-react"; + +const ARCHIVE_EXTENSIONS = [ + "zip", + "rar", + "7z", + "iso", + "tar", + "gz", + "bz2", + "xz", +]; + +export const getBrowseFileIcon = (ext?: string | null): LucideIcon => { + if (ARCHIVE_EXTENSIONS.includes(ext || "")) return FileArchive; + + const type = mime.getType(ext || "")?.split("/")[0]; + if (type === "text") return FileType; + + return FileIcon; +}; + +type Props = { + ext?: string | null; + className?: string; +}; + +const BrowseFileTypeIcon = ({ ext, className }: Props) => { + const Icon = getBrowseFileIcon(ext); + return ; +}; + +export default BrowseFileTypeIcon; diff --git a/src/pages/buckets/manage/browse/hooks.ts b/src/pages/buckets/manage/browse/hooks.ts index 08f2808..e0bbe3d 100644 --- a/src/pages/buckets/manage/browse/hooks.ts +++ b/src/pages/buckets/manage/browse/hooks.ts @@ -26,17 +26,9 @@ export const usePutObject = ( options?: UseMutationOptions ) => { return useMutation({ - mutationFn: async (body) => { + mutationFn: ({ key }) => { const formData = new FormData(); - if (body.file) { - formData.append("file", body.file); - const handle = api.upload(`/browse/${bucket}/${body.key}`, formData, { - onProgress: body.onProgress, - }); - return handle.promise; - } - - return api.put(`/browse/${bucket}/${body.key}`, { body: formData }); + return api.put(`/browse/${bucket}/${key}`, { body: formData }); }, ...options, }); diff --git a/src/pages/buckets/manage/browse/object-list.tsx b/src/pages/buckets/manage/browse/object-list.tsx index ce2fffd..94144c1 100644 --- a/src/pages/buckets/manage/browse/object-list.tsx +++ b/src/pages/buckets/manage/browse/object-list.tsx @@ -74,14 +74,8 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => { ) : null} - {uploadItems.map((item, idx) => ( - = uploadItems.length - 2 && uploadItems.length > 5 - } - /> + {uploadItems.map((item) => ( + ))} {data?.prefixes.map((prefix) => ( diff --git a/src/pages/buckets/manage/browse/types.ts b/src/pages/buckets/manage/browse/types.ts index d3fa088..3a84375 100644 --- a/src/pages/buckets/manage/browse/types.ts +++ b/src/pages/buckets/manage/browse/types.ts @@ -18,11 +18,6 @@ export type Object = { url: string; }; -export type UploadProgressEvent = { - percent: number; - phase: "uploading" | "finishing"; -}; - export type UploadRowPhase = "uploading" | "finishing" | "paused"; export type UploadRowStatus = @@ -46,6 +41,4 @@ export type UploadRowItem = { export type PutObjectPayload = { key: string; - file: File | null; - onProgress?: (event: UploadProgressEvent) => void; }; diff --git a/src/pages/buckets/manage/browse/upload-queue.tsx b/src/pages/buckets/manage/browse/upload-queue.tsx index b700fcf..39e187d 100644 --- a/src/pages/buckets/manage/browse/upload-queue.tsx +++ b/src/pages/buckets/manage/browse/upload-queue.tsx @@ -8,9 +8,10 @@ import { type ReactNode, } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { APIError, API_URL } from "@/lib/api"; +import { API_URL } from "@/lib/api"; import { startPausableUpload } from "@/lib/pausable-upload"; -import * as utils from "@/lib/utils"; +import { url } from "@/lib/utils"; +import { toUploadErrorMessage } from "./upload-status"; import { UploadRowItem, UploadRowPhase, @@ -39,19 +40,25 @@ const createUploadId = () => ? crypto.randomUUID() : `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`; -const toUploadErrorMessage = (error: unknown) => { - if (error instanceof DOMException && error.name === "AbortError") return null; - if (error instanceof APIError) { - if (error.status === 0) return "Network error. Check your connection."; - if (error.status === 413) return "File exceeds the server upload size limit."; - if (error.status === 502 || error.status === 504) { - return "Server timed out while saving. Retry or use the S3 API for large files."; - } - if (error.message) return error.message; - } - if (error instanceof Error && error.message) return error.message; - return "Upload failed."; -}; +const progressToStatus = (phase: UploadRowPhase): UploadRowStatus => + phase === "finishing" ? "finishing" : "uploading"; + +const createUploadItem = ( + id: string, + bucket: string, + prefix: string, + file: File +): UploadRowItem => ({ + id, + bucket, + prefix, + key: prefix + file.name, + fileName: file.name, + size: file.size, + percent: 0, + phase: "uploading", + status: "uploading", +}); export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const queryClient = useQueryClient(); @@ -76,28 +83,28 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const runUpload = useCallback( (id: string, bucket: string, key: string, file: File) => { - const url = `${API_URL}/browse/${bucket}/${key}`; let lastEventKey = ""; - const handle = startPausableUpload(url, file, { - onUnauthorized: () => { - window.location.href = utils.url("/auth/login"); - }, - onProgress: (event) => { - const eventKey = `${event.phase}:${event.percent}`; - if (eventKey === lastEventKey) return; - lastEventKey = eventKey; + const handle = startPausableUpload( + `${API_URL}/browse/${bucket}/${key}`, + file, + { + onUnauthorized: () => { + window.location.href = url("/auth/login"); + }, + onProgress: (event) => { + const eventKey = `${event.phase}:${event.percent}`; + if (eventKey === lastEventKey) return; + lastEventKey = eventKey; - const status: UploadRowStatus = - event.phase === "finishing" ? "finishing" : "uploading"; - - patchItem(id, { - percent: event.percent, - phase: event.phase as UploadRowPhase, - status, - }); - }, - }); + patchItem(id, { + percent: event.percent, + phase: event.phase as UploadRowPhase, + status: progressToStatus(event.phase as UploadRowPhase), + }); + }, + } + ); activeRef.current.set(id, { pause: handle.pause, @@ -121,10 +128,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const message = toUploadErrorMessage(error); if (!message) return; - patchItem(id, { - status: "error", - errorMessage: message, - }); + patchItem(id, { status: "error", errorMessage: message }); }); }, [patchItem, queryClient, removeItem] @@ -134,24 +138,8 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { (bucket: string, prefix: string, files: File[]) => { for (const file of files) { const id = createUploadId(); - const key = prefix + file.name; - - setItems((prev) => [ - ...prev, - { - id, - bucket, - prefix, - key, - fileName: file.name, - size: file.size, - percent: 0, - phase: "uploading", - status: "uploading", - }, - ]); - - runUpload(id, bucket, key, file); + setItems((prev) => [...prev, createUploadItem(id, bucket, prefix, file)]); + runUpload(id, bucket, prefix + file.name, file); } }, [runUpload] @@ -163,10 +151,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { if (!active) return; active.pause(); - patchItem(id, { - status: "paused", - phase: "paused", - }); + patchItem(id, { status: "paused", phase: "paused" }); }, [patchItem] ); @@ -174,7 +159,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const resumeUpload = useCallback( (id: string) => { const active = activeRef.current.get(id); - const item = items.find((i) => i.id === id); + const item = items.find((entry) => entry.id === id); if (!active || !item || item.status !== "paused") return; patchItem(id, { @@ -189,7 +174,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const cancelUpload = useCallback( (id: string) => { - const item = items.find((i) => i.id === id); + const item = items.find((entry) => entry.id === id); if (!item) return; if ( @@ -202,11 +187,8 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { cancelledRef.current.add(id); const active = activeRef.current.get(id); - if (active) { - active.abort(); - } else { - removeItem(id); - } + if (active) active.abort(); + else removeItem(id); }, [items, removeItem] ); diff --git a/src/pages/buckets/manage/browse/upload-row-actions.tsx b/src/pages/buckets/manage/browse/upload-row-actions.tsx index 434e85c..f524f7c 100644 --- a/src/pages/buckets/manage/browse/upload-row-actions.tsx +++ b/src/pages/buckets/manage/browse/upload-row-actions.tsx @@ -5,10 +5,9 @@ import { useUploadQueue } from "./upload-queue"; type Props = { item: UploadRowItem; - end?: boolean; }; -const UploadRowActions = ({ item, end: _end }: Props) => { +const UploadRowActions = ({ item }: Props) => { const { pauseUpload, resumeUpload, cancelUpload } = useUploadQueue(); const isPaused = item.status === "paused"; const isActive = item.status === "uploading" || item.status === "finishing"; diff --git a/src/pages/buckets/manage/browse/upload-row.tsx b/src/pages/buckets/manage/browse/upload-row.tsx index 16e7351..cc3549f 100644 --- a/src/pages/buckets/manage/browse/upload-row.tsx +++ b/src/pages/buckets/manage/browse/upload-row.tsx @@ -1,26 +1,21 @@ -import { AlertCircle, FileArchive, FileIcon, FileType } from "lucide-react"; -import mime from "mime/lite"; +import { AlertCircle } from "lucide-react"; import { readableBytes } from "@/lib/utils"; +import BrowseFileTypeIcon from "./file-type-icon"; +import { getUploadStatusLabel } from "./upload-status"; import { UploadRowItem } from "./types"; import UploadRowActions from "./upload-row-actions"; type Props = { item: UploadRowItem; - end?: boolean; }; -const statusLabel = (item: UploadRowItem) => { - if (item.status === "error") return item.errorMessage ?? "Upload failed"; - if (item.status === "paused") return `Paused at ${item.percent}%`; - if (item.status === "finishing") return "Saving to storage…"; - return `Uploading — ${item.percent}%`; +const fileExtension = (fileName: string) => { + const dotIndex = fileName.lastIndexOf("."); + return dotIndex >= 0 ? fileName.substring(dotIndex + 1) : null; }; -const UploadRow = ({ item, end }: Props) => { - const extIdx = item.fileName.lastIndexOf("."); - const extBare = - extIdx >= 0 ? item.fileName.substring(extIdx + 1) : null; - +const UploadRow = ({ item }: Props) => { + const status = getUploadStatusLabel(item); const isError = item.status === "error"; return ( @@ -37,7 +32,10 @@ const UploadRow = ({ item, end }: Props) => { aria-hidden /> ) : ( - + )} {item.fileName} @@ -45,38 +43,13 @@ const UploadRow = ({ item, end }: Props) => { {readableBytes(item.size)} - - {statusLabel(item)} + + {status} - + ); }; -type UploadFileIconProps = { - ext: string | null; -}; - -const UploadFileIcon = ({ ext }: UploadFileIconProps) => { - const type = mime.getType(ext || "")?.split("/")[0]; - let Icon = FileIcon; - - if ( - ["zip", "rar", "7z", "iso", "tar", "gz", "bz2", "xz"].includes(ext || "") - ) { - Icon = FileArchive; - } else if (type === "text") { - Icon = FileType; - } - - return ( - - ); -}; - export default UploadRow; diff --git a/src/pages/buckets/manage/browse/upload-status.ts b/src/pages/buckets/manage/browse/upload-status.ts new file mode 100644 index 0000000..7b6b42b --- /dev/null +++ b/src/pages/buckets/manage/browse/upload-status.ts @@ -0,0 +1,25 @@ +import { APIError } from "@/lib/api"; +import { UploadRowItem } from "./types"; + +export const getUploadStatusLabel = (item: UploadRowItem) => { + if (item.status === "error") return item.errorMessage ?? "Upload failed"; + if (item.status === "paused") return `Paused at ${item.percent}%`; + if (item.status === "finishing") return "Saving to storage…"; + return `Uploading — ${item.percent}%`; +}; + +export const toUploadErrorMessage = (error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return null; + + if (error instanceof APIError) { + if (error.status === 0) return "Network error. Check your connection."; + if (error.status === 413) return "File exceeds the server upload size limit."; + if (error.status === 502 || error.status === 504) { + return "Server timed out while saving. Retry or use the S3 API for large files."; + } + if (error.message) return error.message; + } + + if (error instanceof Error && error.message) return error.message; + return "Upload failed."; +};