609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
import { fetchApi } from "@/lib/server-api-util";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
|
||
// 导入子组件
|
||
import { ConfirmUpload } from "@/types/upload";
|
||
import { View } from "react-native";
|
||
import MultiFileUploader from "./multi-file-uploader";
|
||
import SingleFileUploader from "./single-file-uploader";
|
||
import UploadDropzone from "./upload-dropzone";
|
||
import { extractVideoFirstFrame, getVideoDuration } from "./utils/videoUtils";
|
||
|
||
// 默认允许的文件类型
|
||
export const DEFAULT_ALLOWED_FILE_TYPES = ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska"];
|
||
export const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||
|
||
// 上传URL响应接口
|
||
export interface UploadUrlResponse {
|
||
upload_url: string;
|
||
file_id: string;
|
||
}
|
||
|
||
// 文件状态接口
|
||
export interface FileStatus {
|
||
file: File;
|
||
id?: string;
|
||
progress: number;
|
||
error?: string;
|
||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||
url?: string;
|
||
thumbnailUrl?: string; // 添加缩略图URL
|
||
}
|
||
|
||
interface FileUploaderProps {
|
||
onFilesUploaded?: (files: FileStatus[]) => void;
|
||
maxFiles?: number;
|
||
allowMultiple?: boolean;
|
||
disabled?: boolean;
|
||
className?: string;
|
||
allowedFileTypes?: string[]; // 外部传入的允许文件类型
|
||
maxFileSize?: number; // 外部传入的最大文件大小
|
||
thumbnailPropsUrl?: string; // 注册后返回ai处理展示缩略图
|
||
}
|
||
|
||
export default function FileUploader({
|
||
onFilesUploaded,
|
||
maxFiles = 1,
|
||
allowMultiple = false,
|
||
disabled = false,
|
||
className = "",
|
||
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
|
||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||
thumbnailPropsUrl = ""
|
||
}: FileUploaderProps) {
|
||
const { t } = useTranslation();
|
||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
// 图片的最小值
|
||
const MIN_IMAGE_SIZE = 300;
|
||
|
||
// 校验图片尺寸(异步)
|
||
function validateImageDimensions(file: File): Promise<string | null> {
|
||
return new Promise((resolve) => {
|
||
const img = new window.Image();
|
||
img.onload = () => {
|
||
if (img.width < MIN_IMAGE_SIZE || img.height < MIN_IMAGE_SIZE) {
|
||
resolve(`图片尺寸不能小于${MIN_IMAGE_SIZE}px,当前为${img.width}x${img.height}`);
|
||
} else {
|
||
resolve(null);
|
||
}
|
||
};
|
||
img.onerror = () => resolve("无法读取图片尺寸");
|
||
img.src = URL.createObjectURL(file);
|
||
});
|
||
}
|
||
// 格式化文件大小
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
};
|
||
|
||
// 验证文件
|
||
const validateFile = (file: File): string | null => {
|
||
// 验证文件类型
|
||
if (!allowedFileTypes.includes(file.type)) {
|
||
const errorMsg = t('validation.file.invalidType');
|
||
// addToast({
|
||
// title: t('fileUploader.invalidFileTitle'),
|
||
// description: errorMsg,
|
||
// color: "danger",
|
||
// });
|
||
return errorMsg;
|
||
}
|
||
|
||
// 验证文件大小
|
||
// if (file.size > maxFileSize) {
|
||
// const errorMsg = t('validation.file.tooLarge');
|
||
// addToast({
|
||
// title: t('fileUploader.fileTooLargeTitle'),
|
||
// description: `${errorMsg} ${formatFileSize(maxFileSize)}`,
|
||
// color: "danger",
|
||
// });
|
||
// return errorMsg;
|
||
// }
|
||
|
||
return null;
|
||
};
|
||
|
||
// 创建缩略图
|
||
const createThumbnail = (file: File): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
// 如果是图片文件,直接使用图片作为缩略图
|
||
if (file.type.startsWith('image/')) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
if (e.target && typeof e.target.result === 'string') {
|
||
resolve(e.target.result);
|
||
} else {
|
||
reject(new Error('图片加载失败'));
|
||
}
|
||
};
|
||
reader.onerror = () => {
|
||
reject(new Error('图片读取失败'));
|
||
};
|
||
reader.readAsDataURL(file);
|
||
return;
|
||
}
|
||
|
||
// 如果是视频文件,创建视频缩略图
|
||
const videoUrl = URL.createObjectURL(file);
|
||
const video = document.createElement('video');
|
||
video.src = videoUrl;
|
||
video.crossOrigin = 'anonymous';
|
||
video.muted = true;
|
||
video.preload = 'metadata';
|
||
|
||
video.onloadeddata = () => {
|
||
try {
|
||
// 设置视频时间到第一帧
|
||
video.currentTime = 0.1;
|
||
} catch (e) {
|
||
URL.revokeObjectURL(videoUrl);
|
||
reject(e);
|
||
}
|
||
};
|
||
|
||
video.onseeked = () => {
|
||
try {
|
||
// 创建canvas并绘制视频帧
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
if (ctx) {
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||
URL.revokeObjectURL(videoUrl);
|
||
resolve(thumbnailUrl);
|
||
} else {
|
||
reject(new Error('无法创建canvas上下文'));
|
||
}
|
||
} catch (e) {
|
||
URL.revokeObjectURL(videoUrl);
|
||
reject(e);
|
||
}
|
||
};
|
||
|
||
video.onerror = () => {
|
||
URL.revokeObjectURL(videoUrl);
|
||
reject(new Error('视频加载失败'));
|
||
};
|
||
});
|
||
};
|
||
// 压缩图片函数
|
||
async function compressImageToFile(
|
||
file: File,
|
||
maxWidth = 600,
|
||
quality = 0.7,
|
||
outputType = 'image/png'
|
||
): Promise<File> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||
const target = event.target as FileReader;
|
||
if (!target || !target.result) {
|
||
reject(new Error('Failed to read file'));
|
||
return;
|
||
}
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// 计算压缩尺寸
|
||
const canvas = document.createElement('canvas');
|
||
let width = img.width;
|
||
let height = img.height;
|
||
|
||
if (width > maxWidth) {
|
||
height = Math.round((height * maxWidth) / width);
|
||
width = maxWidth;
|
||
}
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
// 绘制压缩图片
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) {
|
||
throw new Error('Could not get 2D context from canvas');
|
||
}
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 转为 Blob 并生成 File 对象
|
||
canvas.toBlob(
|
||
(blob: Blob | null) => {
|
||
if (!blob) {
|
||
reject(new Error('Failed to create blob from canvas'));
|
||
return;
|
||
}
|
||
let file_name = uuidv4() + ".png"
|
||
const compressedFile = new File([blob], file_name, {
|
||
type: outputType,
|
||
lastModified: Date.now()
|
||
});
|
||
resolve(compressedFile);
|
||
},
|
||
outputType,
|
||
quality
|
||
);
|
||
};
|
||
img.onerror = reject;
|
||
img.src = target.result as string;
|
||
};
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
// 新增素材
|
||
const addMaterial = async (file: string, compressFile: string) => {
|
||
await fetchApi('/material', {
|
||
method: 'POST',
|
||
body: JSON.stringify([{
|
||
"file_id": file,
|
||
"preview_file_id": compressFile
|
||
}])
|
||
}).catch((error) => {
|
||
console.log(error);
|
||
})
|
||
}
|
||
|
||
// 上传单个文件
|
||
const uploadFile = async (fileStatus: FileStatus, fileIndex: number, compressedFile: File) => {
|
||
// 创建新的文件状态对象,而不是修改原来的数组
|
||
// 这样可以避免多个异步操作对同一个数组的并发修改
|
||
let currentFileStatus: FileStatus = {
|
||
...fileStatus,
|
||
status: 'uploading' as const,
|
||
progress: 0
|
||
};
|
||
|
||
// 使用函数更新文件状态,确保每次更新都是原子的
|
||
const updateFileStatus = (updates: Partial<FileStatus>) => {
|
||
currentFileStatus = { ...currentFileStatus, ...updates };
|
||
setFiles(prevFiles => {
|
||
const newFiles = [...prevFiles];
|
||
newFiles[fileIndex] = currentFileStatus;
|
||
return newFiles;
|
||
});
|
||
};
|
||
|
||
// 初始化上传状态
|
||
updateFileStatus({ status: 'uploading', progress: 0 });
|
||
|
||
// 添加小延迟,确保初始状态能被看到
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
|
||
try {
|
||
// 获取视频时长
|
||
let metadata = {};
|
||
if (fileStatus.file.type.startsWith('video/')) {
|
||
metadata = {
|
||
duration: (await getVideoDuration(fileStatus.file)).toString()
|
||
}
|
||
}
|
||
|
||
// 获取上传URL
|
||
updateFileStatus({ progress: 10 });
|
||
const uploadUrlData = await getUploadUrl(fileStatus.file, metadata);
|
||
const compressedFileData = await getUploadUrl(compressedFile, {})
|
||
|
||
// 确保正确更新文件ID
|
||
updateFileStatus({ id: uploadUrlData.file_id, progress: 20 });
|
||
|
||
// 上传文件到URL
|
||
await uploadFileToUrl(
|
||
compressedFile,
|
||
compressedFileData.upload_url,
|
||
(progress) => {
|
||
// 将实际进度映射到 60%-90% 区间
|
||
const mappedProgress = 60 + (progress * 0.3);
|
||
updateFileStatus({ progress: Math.round(mappedProgress) });
|
||
}
|
||
);
|
||
|
||
await uploadFileToUrl(
|
||
fileStatus.file,
|
||
uploadUrlData.upload_url,
|
||
(progress) => {
|
||
// 将实际进度映射到 60%-90% 区间
|
||
const mappedProgress = 60 + (progress * 0.3);
|
||
updateFileStatus({ progress: Math.round(mappedProgress) });
|
||
}
|
||
);
|
||
|
||
// 向服务端confirm上传
|
||
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
file_id: uploadUrlData.file_id
|
||
})
|
||
});
|
||
|
||
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
file_id: compressedFileData.file_id
|
||
})
|
||
});
|
||
|
||
// 等待一些时间再标记为成功
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
|
||
// 更新状态为成功
|
||
updateFileStatus({ status: 'success', progress: 100, id: uploadUrlData.file_id });
|
||
|
||
await addMaterial(uploadUrlData.file_id, compressedFileData.file_id)
|
||
// 打印最终状态以进行调试
|
||
// console.log('最终文件状态:', currentFileStatus);
|
||
// 调用回调函数
|
||
if (onFilesUploaded) {
|
||
// 使用当前文件状态创建一个新的数组传递给回调函数
|
||
const updatedFiles = [...files];
|
||
updatedFiles[fileIndex] = {
|
||
...currentFileStatus,
|
||
id: uploadUrlData.file_id, // 确保 ID 正确传递
|
||
};
|
||
|
||
// 延迟调用回调函数,确保状态已更新
|
||
setTimeout(() => {
|
||
// console.log('传递给回调的文件:', updatedFiles);
|
||
onFilesUploaded(updatedFiles);
|
||
}, 100);
|
||
}
|
||
} catch (error) {
|
||
console.error('Upload error:', error);
|
||
// 更新状态为错误
|
||
updateFileStatus({
|
||
status: 'error',
|
||
error: error instanceof Error ? error.message : '上传失败'
|
||
});
|
||
}
|
||
};
|
||
|
||
// 处理文件选择
|
||
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
||
if (!selectedFiles) return;
|
||
|
||
setIsUploading(true);
|
||
|
||
const newFiles: FileStatus[] = [];
|
||
|
||
for (let i = 0; i < selectedFiles.length; i++) {
|
||
const file = selectedFiles[i];
|
||
const error = validateFile(file);
|
||
let dimensionError: string | null = null;
|
||
if (!error && file.type.startsWith('image/')) {
|
||
dimensionError = await validateImageDimensions(file);
|
||
}
|
||
|
||
let thumbnailUrl = '';
|
||
// 只在文件验证通过且尺寸合格时创建缩略图
|
||
if (!error && !dimensionError) {
|
||
try {
|
||
// 创建缩略图,支持图片和视频
|
||
thumbnailUrl = await createThumbnail(file);
|
||
newFiles.push({
|
||
file,
|
||
progress: 0,
|
||
error: error ?? undefined,
|
||
status: error ? 'error' : 'pending',
|
||
thumbnailUrl
|
||
});
|
||
} catch (e) {
|
||
console.error('缩略图创建失败:', e);
|
||
}
|
||
} else {
|
||
// 添加警告
|
||
// addToast({
|
||
// title: t('fileUploader.fileTooSmallTitle'),
|
||
// description: t('fileUploader.fileTooSmall'),
|
||
// color: "warning",
|
||
// });
|
||
}
|
||
}
|
||
|
||
// 更新文件列表
|
||
setFiles(prev => {
|
||
// 单文件模式下且已有文件时,替换现有文件
|
||
if (maxFiles === 1 && prev.length > 0 && newFiles.length > 0) {
|
||
return [newFiles[0]]; // 只保留新选择的第一个文件
|
||
} else {
|
||
// 多文件模式,合并并限制数量
|
||
const combinedFiles = [...prev, ...newFiles];
|
||
return combinedFiles.length > maxFiles
|
||
? combinedFiles.slice(0, maxFiles)
|
||
: combinedFiles;
|
||
}
|
||
});
|
||
|
||
// 在状态更新后,使用 useEffect 来处理上传
|
||
setIsUploading(false);
|
||
}, [maxFiles]);
|
||
|
||
|
||
// 获取上传URL
|
||
const getUploadUrl = async (file: File, metadata: { [key: string]: string }): Promise<UploadUrlResponse> => {
|
||
const body = {
|
||
filename: file.name,
|
||
content_type: file.type,
|
||
file_size: file.size,
|
||
metadata
|
||
}
|
||
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
|
||
method: 'POST',
|
||
body: JSON.stringify(body)
|
||
});
|
||
};
|
||
|
||
// 上传文件到URL
|
||
const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise<void> => {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
xhr.open('PUT', uploadUrl);
|
||
xhr.setRequestHeader('Content-Type', file.type);
|
||
|
||
// 进度监听
|
||
xhr.upload.onprogress = (event) => {
|
||
if (event.lengthComputable) {
|
||
const progress = Math.round((event.loaded / event.total) * 100);
|
||
onProgress(progress);
|
||
}
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => {
|
||
reject(new Error('Network error during upload'));
|
||
};
|
||
|
||
xhr.send(file);
|
||
});
|
||
};
|
||
|
||
// 移除文件
|
||
const removeFile = (fileToRemove: File) => {
|
||
setFiles(prev => prev.filter(fileStatus => fileStatus.file !== fileToRemove));
|
||
};
|
||
|
||
// 清除所有文件
|
||
const clearFiles = () => {
|
||
setFiles([]);
|
||
};
|
||
|
||
// 打开文件选择器
|
||
const openFileSelector = () => {
|
||
if (!disabled) {
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.click();
|
||
} else {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = allowedFileTypes.join(',');
|
||
input.multiple = allowMultiple;
|
||
input.onchange = (e) => handleFileSelect((e.target as HTMLInputElement).files);
|
||
input.click();
|
||
}
|
||
}
|
||
};
|
||
|
||
// 使用 useEffect 监听 files 变化,处理待上传的文件
|
||
useEffect(() => {
|
||
const processFiles = async () => {
|
||
// Only process files that are in 'pending' status
|
||
const pendingFiles = files
|
||
.filter(f => f.status === 'pending')
|
||
.filter((fileStatus, index, self) =>
|
||
index === self.findIndex(f => f.file === fileStatus.file)
|
||
); // Remove duplicates
|
||
|
||
if (pendingFiles.length === 0) return;
|
||
|
||
// Create a new array with updated status to prevent infinite loops
|
||
setFiles(prevFiles =>
|
||
prevFiles.map(file =>
|
||
pendingFiles.some(pf => pf.file === file.file)
|
||
? { ...file, status: 'uploading' as const }
|
||
: file
|
||
)
|
||
);
|
||
|
||
// Process each file sequentially to avoid race conditions
|
||
for (const fileStatus of pendingFiles) {
|
||
try {
|
||
const fileIndex = files.findIndex(f => f.file === fileStatus.file);
|
||
if (fileIndex === -1) continue;
|
||
|
||
let compressedFile: File;
|
||
if (fileStatus.file.type?.includes('video')) {
|
||
const frameFile = await extractVideoFirstFrame(fileStatus.file);
|
||
compressedFile = await compressImageToFile(frameFile, 600, 0.7);
|
||
} else {
|
||
compressedFile = fileStatus.file;
|
||
}
|
||
|
||
await uploadFile(
|
||
{ ...fileStatus, status: 'uploading' as const },
|
||
fileIndex,
|
||
compressedFile
|
||
);
|
||
} catch (error) {
|
||
console.error('Error processing file:', error);
|
||
setFiles(prevFiles =>
|
||
prevFiles.map(f =>
|
||
f.file === fileStatus.file
|
||
? {
|
||
...f,
|
||
status: 'error' as const,
|
||
error: error instanceof Error ? error.message : '处理文件失败'
|
||
}
|
||
: f
|
||
)
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
processFiles();
|
||
}, [files]); // Only run when files array changes
|
||
|
||
return (
|
||
<View className={className}
|
||
aria-label="文件上传"
|
||
>
|
||
{/* 隐藏的文件输入 */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={allowedFileTypes.join(',')}
|
||
multiple={allowMultiple}
|
||
onChange={(e) => handleFileSelect(e.target.files)}
|
||
className="hidden"
|
||
/>
|
||
|
||
{/* 文件上传区域 - 始终可见 */}
|
||
<View className="space-y-6 h-full">
|
||
{/* 上传区域 */}
|
||
{maxFiles === 1 && files.length === 1 ? (
|
||
/* 单文件模式且已有文件 - 不添加外层的onClick事件 */
|
||
<SingleFileUploader
|
||
file={files[0]}
|
||
onReplace={openFileSelector}
|
||
disabled={disabled}
|
||
/>
|
||
) : thumbnailPropsUrl ? <img
|
||
src={thumbnailPropsUrl}
|
||
className="w-full h-full object-cover"
|
||
/> : (
|
||
/* 多文件模式或无文件 - 只在组件上添加一个onClick事件 */
|
||
<UploadDropzone
|
||
onClick={openFileSelector}
|
||
disabled={disabled}
|
||
allowedFileTypes={allowedFileTypes}
|
||
// maxFileSize={maxFileSize}
|
||
/>
|
||
)}
|
||
|
||
{/* 文件列表区域 - 仅在多文件模式下显示 */}
|
||
{maxFiles !== 1 && files.length > 0 && (
|
||
<MultiFileUploader
|
||
files={files}
|
||
onRemove={removeFile}
|
||
onClearAll={clearFiles}
|
||
formatFileSize={formatFileSize}
|
||
disabled={disabled}
|
||
/>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
} |