import { fetchApi } from "@/lib/server-api-util"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import uuid from 'react-native-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([]); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); // 图片的最小值 const MIN_IMAGE_SIZE = 300; // 校验图片尺寸(异步) function validateImageDimensions(file: File): Promise { 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 => { 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 { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event: ProgressEvent) => { 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 = uuid.v4() + ".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) => { 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('/file/confirm-upload', { method: 'POST', body: JSON.stringify({ file_id: uploadUrlData.file_id }) }); await fetchApi('/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 => { const body = { filename: file.name, content_type: file.type, file_size: file.size, metadata } return await fetchApi("/file/generate-upload-url", { method: 'POST', body: JSON.stringify(body) }); }; // 上传文件到URL const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise => { 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 ( {/* 隐藏的文件输入 */} handleFileSelect(e.target.files)} className="hidden" /> {/* 文件上传区域 - 始终可见 */} {/* 上传区域 */} {maxFiles === 1 && files.length === 1 ? ( /* 单文件模式且已有文件 - 不添加外层的onClick事件 */ ) : thumbnailPropsUrl ? : ( /* 多文件模式或无文件 - 只在组件上添加一个onClick事件 */ )} {/* 文件列表区域 - 仅在多文件模式下显示 */} {maxFiles !== 1 && files.length > 0 && ( )} ); }