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 <cursoragent@cursor.com>
This commit is contained in:
Norman-W 2026-06-20 16:15:22 +08:00
parent 2e4019ac6d
commit 72dccbc38a
11 changed files with 191 additions and 296 deletions

View File

@ -7,25 +7,6 @@ type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
body?: any;
};
type UploadProgressEvent = {
percent: number;
phase: "uploading" | "finishing";
};
type UploadOptions = {
params?: Record<string, any>;
onProgress?: (event: UploadProgressEvent) => void;
timeoutMs?: number;
};
export type UploadHandle<T = unknown> = {
promise: Promise<T>;
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<T = any>(
url: string,
body: FormData,
options?: UploadOptions
): UploadHandle<T> {
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<T>((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<T = any>(url: string, options?: Partial<FetchOptions>) {
return this.fetch<T>(url, {
...options,

View File

@ -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<T = unknown> = {
promise: Promise<T>;
pause: () => void;
@ -26,13 +20,30 @@ export type PausableUploadHandle<T = unknown> = {
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<Uint8Array>) => {
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<Uint8Array>({
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 = <T = unknown>(
url: string,
file: File,
options?: {
onProgress?: (event: PausableUploadProgress) => void;
timeoutMs?: number;
onUnauthorized?: () => void;
}
options?: StartPausableUploadOptions
): PausableUploadHandle<T> => {
const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`;
const state: PausableStreamState = {
@ -122,7 +139,7 @@ export const startPausableUpload = <T = unknown>(
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 = <T = unknown>(
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 = <T = unknown>(
.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) {
try {
return (await parseUploadResponse(res)) as T;
} catch (error) {
if (error instanceof APIError && error.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);
throw error;
}
return data as T;
})
.catch((error) => {
window.clearTimeout(timeoutId);
@ -198,5 +204,3 @@ export const startPausableUpload = <T = unknown>(
},
};
};
//#endregion

View File

@ -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 (

View File

@ -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 <Icon size={20} className={className} aria-hidden />;
};
export default BrowseFileTypeIcon;

View File

@ -26,17 +26,9 @@ export const usePutObject = (
options?: UseMutationOptions<any, Error, PutObjectPayload>
) => {
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,
});

View File

@ -74,14 +74,8 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</tr>
) : null}
{uploadItems.map((item, idx) => (
<UploadRow
key={item.id}
item={item}
end={
idx >= uploadItems.length - 2 && uploadItems.length > 5
}
/>
{uploadItems.map((item) => (
<UploadRow key={item.id} item={item} />
))}
{data?.prefixes.map((prefix) => (

View File

@ -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;
};

View File

@ -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, {
const handle = startPausableUpload(
`${API_URL}/browse/${bucket}/${key}`,
file,
{
onUnauthorized: () => {
window.location.href = utils.url("/auth/login");
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,
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]
);

View File

@ -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";

View File

@ -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
/>
) : (
<UploadFileIcon ext={extBare} />
<BrowseFileTypeIcon
ext={fileExtension(item.fileName)}
className="text-base-content/60 mr-2 shrink-0"
/>
)}
<span className="truncate min-w-0">{item.fileName}</span>
</span>
@ -45,38 +43,13 @@ const UploadRow = ({ item, end }: Props) => {
<td className="whitespace-nowrap">{readableBytes(item.size)}</td>
<td className="whitespace-nowrap" title={statusLabel(item)}>
{statusLabel(item)}
<td className="whitespace-nowrap" title={status}>
{status}
</td>
<UploadRowActions item={item} end={end} />
<UploadRowActions item={item} />
</tr>
);
};
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 (
<Icon
size={20}
className="text-base-content/60 mr-2 shrink-0"
aria-hidden
/>
);
};
export default UploadRow;

View File

@ -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.";
};