Files
wr.do/hooks/use-file-upload.ts

400 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useState } from "react";
import { extractFileNameAndExtension } from "@/lib/utils";
import { BucketInfo } from "@/components/file";
export interface FileUploadItem {
id: string;
file: File;
fileName: string;
originalName: string;
url?: string;
progress: number;
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
error?: string;
abortController?: AbortController;
etag?: string;
userFileId?: string;
}
interface Props {
bucketInfo: BucketInfo;
userId?: string;
api: string;
}
export function useFileUpload({ bucketInfo, userId, api }: Props) {
const [files, setFiles] = useState<FileUploadItem[]>([]);
const [isUploading, setIsUploading] = useState(false);
const addFiles = useCallback((newFiles: FileList | File[]) => {
const fileArray = Array.from(newFiles);
const uploadItems: FileUploadItem[] = fileArray.map((file) => ({
id: `${Date.now()}-${Math.random()}`,
file,
fileName: "",
originalName: file.name,
progress: 0,
status: "pending" as const,
abortController: new AbortController(),
}));
setFiles((prev) => [...prev, ...uploadItems]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => {
const file = prev.find((f) => f.id === id);
if (file?.abortController) {
file.abortController.abort();
}
return prev.filter((f) => f.id !== id);
});
}, []);
const cancelUpload = useCallback((id: string) => {
setFiles((prev) =>
prev.map((file) => {
if (file.id === id && file.abortController) {
file.abortController.abort();
return { ...file, status: "cancelled" as const };
}
return file;
}),
);
}, []);
const retryUpload = useCallback((id: string) => {
setFiles((prev) =>
prev.map((file) => {
if (file.id === id) {
return {
...file,
status: "pending" as const,
progress: 0,
error: undefined,
abortController: new AbortController(),
};
}
return file;
}),
);
}, []);
const createUserFileData = async (item: FileUploadItem, etag: string) => {
if (!userId) {
return null;
}
try {
const extractKey = extractFileNameAndExtension(item.fileName);
const response = await fetch(`${api}/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId,
name: extractKey.fileName,
originalName: extractKey.nameWithoutExtension,
mimeType: item.file.type || "-",
path: item.fileName,
etag,
storageClass: "",
channel: bucketInfo?.channel || "",
platform: bucketInfo?.platform || "",
providerName: bucketInfo?.provider_name || "",
size: item.file.size,
bucket: bucketInfo?.bucket || "",
lastModified: new Date(),
}),
});
if (!response.ok) {
throw new Error(
`Error creating user file data: ${response.statusText}`,
);
}
const result = await response.json();
return result.data;
} catch (error) {
console.error("Error creating user file data:", error);
throw error;
}
};
// 获取预签名 URL
const getPresignedUrls = async (uploadFiles: FileUploadItem[]) => {
const filesData = uploadFiles.map((item) => ({
name: item.originalName,
type: item.file.type,
size: item.file.size,
}));
const response = await fetch(api, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
files: filesData,
prefix: bucketInfo.prefix,
bucket: bucketInfo.bucket,
provider: bucketInfo.provider_name,
}),
});
if (!response.ok) {
// 尝试获取后端返回的具体错误信息
let errorMessage = "获取预签名 URL 失败";
try {
const errorText = await response.text();
if (errorText) {
// 如果返回的是JSON格式的错误信息尝试解析
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || errorData.error || errorText;
} catch {
// 如果不是JSON直接使用文本内容
errorMessage = errorText;
}
}
} catch {
// 如果无法读取响应内容,使用默认错误信息
errorMessage = `上传失败 (${response.status})`;
}
throw new Error(errorMessage);
}
const data = await response.json();
return data.urls;
};
const uploadSingleFile = async (item: FileUploadItem) => {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
setFiles((prev) =>
prev.map((file) =>
file.id === item.id ? { ...file, progress } : file,
),
);
}
};
xhr.onload = async () => {
if (xhr.status === 200) {
try {
// 从响应头获取 ETag
const etag = xhr.getResponseHeader("ETag")?.replace(/"/g, "") || "";
// 更新文件状态为已完成
setFiles((prev) =>
prev.map((file) =>
file.id === item.id
? { ...file, status: "completed", etag }
: file,
),
);
// 创建用户文件数据
if (userId) {
try {
const userFileData = await createUserFileData(item, etag);
setFiles((prev) =>
prev.map((file) =>
file.id === item.id
? { ...file, userFileId: userFileData?.id }
: file,
),
);
} catch (userFileError) {
console.error("创建用户文件数据失败:", userFileError);
setFiles((prev) =>
prev.map((file) =>
file.id === item.id
? {
...file,
error: `文件上传成功,但创建记录失败: ${userFileError}`,
}
: file,
),
);
}
}
resolve();
} catch (error) {
console.error("上传后处理失败:", error);
setFiles((prev) =>
prev.map((file) =>
file.id === item.id
? {
...file,
status: "error",
error: `上传后处理失败: ${error}`,
}
: file,
),
);
reject(error);
}
} else {
setFiles((prev) =>
prev.map((file) =>
file.id === item.id
? {
...file,
status: "error",
error: `上传失败: ${xhr.statusText}`,
}
: file,
),
);
reject(new Error(`上传失败: ${xhr.statusText}`));
}
};
xhr.onerror = () => {
let errorMessage = "网络错误";
if (xhr.status === 0) {
errorMessage = "CORS 错误或网络连接问题";
} else if (xhr.status >= 400) {
errorMessage = `上传失败 (${xhr.status})`;
}
setFiles((prev) =>
prev.map((file) =>
file.id === item.id
? {
...file,
status: "error",
error: errorMessage,
}
: file,
),
);
reject(new Error(errorMessage));
};
xhr.onabort = () => {
setFiles((prev) =>
prev.map((file) =>
file.id === item.id ? { ...file, status: "cancelled" } : file,
),
);
reject(new Error("上传已取消"));
};
// 监听取消信号
if (item.abortController) {
item.abortController.signal.addEventListener("abort", () => {
xhr.abort();
});
}
xhr.open("PUT", item.url!);
xhr.setRequestHeader("Content-Type", item.file.type);
xhr.send(item.file);
});
};
const startUpload = useCallback(async () => {
const pendingFiles = files.filter((f) => f.status === "pending");
if (pendingFiles.length === 0) return;
setIsUploading(true);
try {
// 获取预签名 URL
const urls = await getPresignedUrls(pendingFiles);
// 更新文件信息
setFiles((prev) =>
prev.map((file) => {
const urlInfo = urls.find(
(u: any) => u.originalName === file.originalName,
);
if (urlInfo && file.status === "pending") {
return {
...file,
url: urlInfo.url,
fileName: urlInfo.fileName,
status: "uploading" as const,
};
}
return file;
}),
);
// 并发上传文件
const uploadPromises = pendingFiles.map(async (file) => {
const urlInfo = urls.find(
(u: any) => u.originalName === file.originalName,
);
if (urlInfo) {
const updatedFile = {
...file,
url: urlInfo.url,
fileName: urlInfo.fileName,
};
return uploadSingleFile(updatedFile);
}
});
await Promise.allSettled(uploadPromises);
} catch (error) {
console.error("上传失败:", error);
// 将所有 pending 状态的文件设置为错误状态,并显示具体错误信息
const errorMessage = error instanceof Error ? error.message : "上传失败";
setFiles((prev) =>
prev.map((file) =>
file.status === "pending"
? {
...file,
status: "error",
error: errorMessage,
}
: file,
),
);
} finally {
setIsUploading(false);
}
}, [files]);
const clearAll = useCallback(() => {
files.forEach((file) => {
if (file.abortController) {
file.abortController.abort();
}
});
setFiles([]);
}, [files]);
const stats = {
total: files.length,
pending: files.filter((f) => f.status === "pending").length,
uploading: files.filter((f) => f.status === "uploading").length,
completed: files.filter((f) => f.status === "completed").length,
error: files.filter((f) => f.status === "error").length,
cancelled: files.filter((f) => f.status === "cancelled").length,
};
return {
files,
isUploading,
stats,
addFiles,
removeFile,
cancelUpload,
retryUpload,
startUpload,
clearAll,
};
}