568 lines
16 KiB
TypeScript
568 lines
16 KiB
TypeScript
import { fetchApi } from '@/lib/server-api-util';
|
|
import { defaultExifData, ExifData, ImagesuploaderProps, UploadUrlResponse } 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 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
|
|
console.log("uploadWithProgress", metadata);
|
|
|
|
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, { ...metadata, GPSVersionID: undefined });
|
|
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> => {
|
|
console.log("asset111111", asset);
|
|
|
|
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; |