2025-07-17 15:45:04 +08:00

326 lines
10 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 { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api';
import { ConfirmUpload, ExifData, FileUploadItem, ImagesuploaderProps, UploadResult, UploadTask, defaultExifData } from '@/lib/background-uploader/types';
import { uploadFileWithProgress } from '@/lib/background-uploader/uploader';
import { compressImage } from '@/lib/image-process/imageCompress';
import { createVideoThumbnailFile } from '@/lib/video-process/videoThumbnail';
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';
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 [files, setFiles] = useState<FileUploadItem[]>([]);
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;
};
// 处理单个资源
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,
uri: asset.uri,
previewUrl: asset.uri, // 使用 asset.uri 作为初始预览
name: asset.fileName || 'file',
progress: 0,
status: 'uploading',
error: undefined,
type: isVideo ? 'video' : 'image',
thumbnail: undefined,
};
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' }
);
// 使用复用函数生成视频缩略图
thumbnailFile = await createVideoThumbnailFile(asset, 300);
} else {
// 处理图片,主图和缩略图都用 compressImage 方法
// 主图压缩(按 maxWidth/maxHeight/compressQuality
const { file: compressedFile } = await compressImage(asset.uri, maxWidth);
// 缩略图压缩宽度800
const { file: thumbFile } = await compressImage(asset.uri, 800);
// 如果保留 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);
}
}
}
// 用压缩后主图作为上传主文件
file = compressedFile as File;
// 用缩略图文件作为预览
thumbnailFile = thumbFile as File;
}
// 准备上传任务
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 {
// 统一通过 lib 的 uploadFileWithProgress 实现上传
const uploadUrlData = await getUploadUrl(task.file, { ...task.metadata, GPSVersionID: undefined });
await uploadFileWithProgress(task.file, uploadUrlData.upload_url, updateProgress, 30000);
const result = await confirmUpload(uploadUrlData.file_id);
uploadResultsList.push({ ...result, file_id: uploadUrlData.file_id, upload_url: uploadUrlData.upload_url });
} 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: 'success' as const,
progress: 100,
thumbnail: uploadResults.thumbnail
}
: item
)
);
// 添加到素材库
if (uploadResults.originalFile?.file_id) {
await addMaterial(
uploadResults.originalFile.file_id,
uploadResults.compressedFile?.file_id
);
}
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;