2025-07-07 13:42:11 +08:00

609 lines
19 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 { 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<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 = 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<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>
);
}