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

566 lines
16 KiB
TypeScript

import { fetchApi } from '@/lib/server-api-util';
import { defaultExifData, ExifData, ImagesuploaderProps } from '@/types/upload';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import * as MediaLibrary from 'expo-media-library';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
import { UploadUrlResponse } from './file-uploader';
import UploadPreview from './preview';
// 在文件顶部添加这些类型
type UploadTask = {
file: File;
metadata: {
isCompressed: string;
type: string;
isThumbnail?: string;
[key: string]: any;
};
};
type FileUploadItem = {
id: string;
name: string;
progress: number;
status: 'pending' | 'uploading' | 'done' | 'error';
error: string | null;
type: 'image' | 'video';
thumbnail: string | null;
};
type ConfirmUpload = {
file_id: string;
upload_url: string;
name: string;
size: number;
content_type: string;
file_path: string;
};
type UploadResult = {
originalUrl?: string;
compressedUrl: string;
file: File | null;
exif: any;
originalFile: ConfirmUpload;
compressedFile: ConfirmUpload;
thumbnail: string;
thumbnailFile: File;
};
export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
children,
style,
compressQuality = 0.8,
maxWidth = 2048,
maxHeight = 2048,
preserveExif = true,
onUploadComplete,
multipleChoice = false,
showPreview = true,
fileType = ['images'],
}) => {
const [isLoading, setIsLoading] = useState(false);
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
// 请求权限
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (mediaStatus !== 'granted') {
Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片');
return false;
}
const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();;
if (locationStatus !== 'granted') {
Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息');
}
}
return true;
};
// 获取上传URL
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: 'image',
isCompressed: metadata.isCompressed || 'false',
},
};
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 向服务端confirm上传
const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({
file_id
})
});
// 新增素材
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);
})
}
// 上传文件到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 processImage = async (uri: string, fileName: string, mimeType: string) => {
try {
// 压缩图片
const manipResult = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: maxWidth,
height: maxHeight,
},
},
],
{
compress: compressQuality,
format: ImageManipulator.SaveFormat.JPEG,
base64: false,
}
);
// 获取压缩后的图片数据
const response = await fetch(manipResult.uri);
const blob = await response.blob();
// 创建文件对象
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
type: mimeType,
});
return { file, uri: manipResult.uri };
} catch (error) {
// console.error('图片压缩失败:', error);
throw new Error('图片处理失败');
}
};
const uploadWithProgress = async (file: File, metadata: any): Promise<ConfirmUpload> => {
let timeoutId: number
try {
console.log("Starting upload for file:", file.name, "size:", file.size, "type:", file.type);
// 检查文件大小
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
throw new Error(`文件大小超过限制 (${(MAX_FILE_SIZE / 1024 / 1024).toFixed(1)}MB)`);
}
const uploadUrlData = await getUploadUrl(file, {});
console.log("Got upload URL for:", file.name);
return new Promise<ConfirmUpload>((resolve, reject) => {
try {
// 设置超时
timeoutId = setTimeout(() => {
reject(new Error('上传超时,请检查网络连接'));
}, 30000);
// 上传文件
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrlData.upload_url, true);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
console.log(`Upload progress for ${file.name}: ${progress}%`);
}
};
xhr.onload = async () => {
clearTimeout(timeoutId!);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = await confirmUpload(uploadUrlData.file_id);
resolve({
...result,
file_id: uploadUrlData.file_id,
upload_url: uploadUrlData.upload_url,
});
} catch (error) {
reject(error);
}
} else {
reject(new Error(`上传失败,状态码: ${xhr.status}`));
}
};
xhr.onerror = () => {
clearTimeout(timeoutId!);
reject(new Error('网络错误,请检查网络连接'));
};
xhr.send(file);
} catch (error) {
clearTimeout(timeoutId!);
reject(error);
}
});
} catch (error) {
console.error('Error in uploadWithProgress:', {
error,
fileName: file?.name,
fileSize: file?.size,
fileType: file?.type
});
throw error;
}
};
// 处理单个资源
const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise<UploadResult | null> => {
const fileId = `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const isVideo = asset.type === 'video';
const uploadResults: UploadResult = {
originalUrl: undefined,
compressedUrl: '',
file: null,
exif: {},
originalFile: {} as ConfirmUpload,
compressedFile: {} as ConfirmUpload,
thumbnail: '',
thumbnailFile: {} as File,
};
// 创建上传项
const newFileItem: FileUploadItem = {
id: fileId,
name: asset.fileName || 'file',
progress: 0,
status: 'uploading' as const,
error: null,
type: isVideo ? 'video' : 'image',
thumbnail: null,
};
setUploadQueue(prev => [...prev, newFileItem]);
const updateProgress = (progress: number) => {
setUploadQueue(prev =>
prev.map(item =>
item.id === fileId ? { ...item, progress } : item
)
);
};
try {
let file: File;
let thumbnailFile: File | null = null;
let exifData: ExifData = { ...defaultExifData };
if (isVideo) {
// 处理视频文件
file = new File(
[await (await fetch(asset.uri)).blob()],
`video_${Date.now()}.mp4`,
{ type: 'video/mp4' }
);
// 生成视频缩略图
const thumbnailResult = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: { width: 300 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
);
thumbnailFile = new File(
[await (await fetch(thumbnailResult.uri)).blob()],
`thumb_${Date.now()}.jpg`,
{ type: 'image/jpeg' }
);
} else {
// 处理图片
const [originalResponse, compressedFileResult] = await Promise.all([
fetch(asset.uri),
ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: { width: 800 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
)
]);
// 如果保留 EXIF 数据,则获取
if (preserveExif && asset.exif) {
exifData = { ...exifData, ...asset.exif };
if (asset.uri && Platform.OS !== 'web') {
try {
const mediaAsset = await MediaLibrary.getAssetInfoAsync(asset.uri);
if (mediaAsset.exif) {
exifData = { ...exifData, ...mediaAsset.exif };
}
if (mediaAsset.location) {
exifData.GPSLatitude = mediaAsset.location.latitude;
exifData.GPSLongitude = mediaAsset.location.longitude;
}
} catch (error) {
console.warn('从媒体库获取 EXIF 数据失败:', error);
}
}
}
const originalBlob = await originalResponse.blob();
const compressedBlob = await compressedFileResult.file;
file = new File(
[originalBlob],
`original_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
{ type: asset.mimeType || 'image/jpeg' }
);
thumbnailFile = new File(
[compressedBlob],
`compressed_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
{ type: 'image/jpeg' }
);
}
// 准备上传任务
const uploadTasks: UploadTask[] = [
{
file,
metadata: {
isCompressed: 'false',
type: isVideo ? 'video' : 'image',
...(isVideo ? {} : exifData)
}
}
];
if (thumbnailFile) {
uploadTasks.push({
file: thumbnailFile,
metadata: {
isCompressed: 'true',
type: 'image',
isThumbnail: 'true'
}
});
}
// 顺序上传文件
const uploadResultsList = [];
for (const task of uploadTasks) {
try {
const result = await uploadWithProgress(task.file, task.metadata);
uploadResultsList.push(result);
} catch (error) {
console.error('Upload failed:', error);
throw error;
}
}
// 处理上传结果
const [mainUpload, thumbnailUpload] = uploadResultsList;
uploadResults.originalFile = mainUpload;
uploadResults.compressedFile = thumbnailUpload || mainUpload;
uploadResults.thumbnail = thumbnailUpload?.upload_url || '';
uploadResults.thumbnailFile = thumbnailFile;
// 更新上传状态
updateProgress(100);
setUploadQueue(prev =>
prev.map(item =>
item.id === fileId
? {
...item,
status: 'done' as const,
progress: 100,
thumbnail: uploadResults.thumbnail
}
: item
)
);
// 添加到素材库
if (uploadResults.originalFile?.file_id) {
await addMaterial(
uploadResults.originalFile.file_id,
uploadResults.thumbnail
);
}
return uploadResults;
} catch (error) {
console.error('Error processing file:', error);
setUploadQueue(prev =>
prev.map(item =>
item.id === fileId
? {
...item,
status: 'error' as const,
error: error instanceof Error ? error.message : '上传失败'
}
: item
)
);
return null;
}
};
// 处理所有选中的图片
const processAssets = async (assets: ImagePicker.ImagePickerAsset[]): Promise<UploadResult[]> => {
// 设置最大并发数
const CONCURRENCY_LIMIT = 3;
const results: UploadResult[] = [];
// 分批处理资源
for (let i = 0; i < assets.length; i += CONCURRENCY_LIMIT) {
const batch = assets.slice(i, i + CONCURRENCY_LIMIT);
// 并行处理当前批次的所有资源
const batchResults = await Promise.allSettled(
batch.map(asset => processSingleAsset(asset))
);
// 收集成功的结果
for (const result of batchResults) {
if (result.status === 'fulfilled' && result.value) {
results.push(result.value);
}
}
// 添加小延迟,避免过多占用系统资源
if (i + CONCURRENCY_LIMIT < assets.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return results;
};
// 处理图片选择
const pickImage = async () => {
try {
setIsLoading(true);
const hasPermission = await requestPermissions();
console.log("hasPermission", hasPermission);
if (!hasPermission) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: fileType,
allowsMultipleSelection: multipleChoice,
quality: 1,
exif: preserveExif,
});
console.log("result", result?.assets);
if (result.canceled || !result.assets) {
setIsLoading(false);
return;
}
try {
const uploadResults = await processAssets(result.assets);
// 所有文件处理完成后的回调
// @ts-ignore
onUploadComplete?.(uploadResults?.map((item, index) => {
return {
...item,
preview: result?.assets?.[index]?.uri
}
}));
} catch (error) {
Alert.alert('错误', '部分文件处理失败,请重试');
} finally {
setIsLoading(false);
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
} finally {
setIsLoading(false);
}
};
// 在组件卸载时清理已完成的上传
useEffect(() => {
return () => {
// 只保留未完成的上传项
setUploadQueue(prev =>
prev.filter(item =>
item.status === 'uploading' || item.status === 'pending'
)
);
};
}, []);
return (
<View style={[style]}>
{children ? (
<TouchableOpacity onPress={pickImage} disabled={isLoading} activeOpacity={0.7}>
{children}
</TouchableOpacity>
) : (
<Button
title={isLoading ? '处理中...' : '选择图片'}
onPress={pickImage}
disabled={isLoading}
/>
)}
{/* 上传预览 */}
{showPreview && <UploadPreview items={uploadQueue} />}
</View>
);
};
export default ImagesUploader;