400 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|