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

400 lines
11 KiB
TypeScript

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