Junhui Chen d31b587330 feat: 自动上传组件
feat: 自动上传组件
Co-authored-by: Junhui Chen <chenjunhui@fairclip.cn>
Co-committed-by: Junhui Chen <chenjunhui@fairclip.cn>
2025-07-17 15:55:27 +08:00

339 lines
12 KiB
TypeScript

import { fetchApi } from '@/lib/server-api-util';
import { ConfirmUpload, defaultExifData, ExifData, FileStatus, ImagesPickerProps, UploadResult, 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 * as Progress from 'react-native-progress';
export const ImagesPicker: React.FC<ImagesPickerProps> = ({
children,
style,
onPickImage,
compressQuality = 0.8,
maxWidth = 2048,
maxHeight = 2048,
preserveExif = true,
onUploadComplete,
onProgress,
}) => {
const [isLoading, setIsLoading] = useState(false);
// 请求权限
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 [currentFileStatus, setCurrentFileStatus] = useState<FileStatus>({
file: null as unknown as File,
status: 'pending',
progress: 0
});
// 使用函数更新文件状态,确保每次更新都是原子的
const updateFileStatus = (updates: Partial<FileStatus>) => {
setCurrentFileStatus((original) => ({ ...original, ...updates }))
};
// 上传文件
const uploadFile = async (file: File, metadata: Record<string, any> = {}): Promise<ConfirmUpload> => {
try {
// 初始化上传状态
updateFileStatus({ status: 'uploading', progress: 1 });
// 添加小延迟,确保初始状态能被看到
await new Promise(resolve => setTimeout(resolve, 300));
// 获取上传URL
const { upload_url, file_id } = await getUploadUrl(file, metadata);
// 上传文件到URL
await uploadFileToUrl(
file,
upload_url,
(progress) => {
// 将实际进度映射到 60%-90% 区间
const mappedProgress = 60 + (progress * 0.3);
updateFileStatus({ progress: Math.round(mappedProgress) });
}
);
// 确认上传到服务器
const fileData = confirmUpload(file_id)
// 将fileData, upload_url,file_id 传递出去
return { ...fileData, upload_url, file_id }
} catch (error) {
console.error('上传文件时出错:', error);
updateFileStatus({
status: 'error',
error: error instanceof Error ? error.message : '上传失败'
});
throw new Error('文件上传失败');
}
};
// 压缩并处理图片
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,
});
console.log("压缩后的文件", file);
return { file, uri: manipResult.uri };
} catch (error) {
console.error('图片压缩失败:', error);
throw new Error('图片处理失败');
}
};
// 处理图片选择
const pickImage = async () => {
try {
setIsLoading(true);
const hasPermission = await requestPermissions();
if (!hasPermission) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: false,
quality: 1,
exif: preserveExif,
});
if (result.canceled || !result.assets?.[0]) {
setIsLoading(false);
return;
}
const asset = result.assets[0];
try {
// 获取原图文件
const originalResponse = await fetch(asset.uri);
const originalBlob = await originalResponse.blob();
const originalFile = new File(
[originalBlob],
`original_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
{ type: asset.mimeType || 'image/jpeg' }
) as File;
// 压缩并处理图片
const { file: compressedFile } = await processImage(
asset.uri,
asset.fileName || 'photo.jpg',
asset.mimeType || 'image/jpeg'
);
let exifData: ExifData = { ...defaultExifData };
// 如果保留 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);
}
}
}
// 调用 onPickImage 回调
onPickImage?.(compressedFile, exifData);
// 上传文件
const uploadResults: UploadResult = {
originalUrl: undefined,
compressedUrl: '',
file: compressedFile,
exifData,
originalFile: {} as ConfirmUpload,
compressedFile: {} as ConfirmUpload,
};
try {
// 上传压缩后的图片
const compressedResult = await uploadFile(compressedFile, {
isCompressed: 'true',
...exifData,
});
uploadResults.originalFile = compressedResult;
// 上传原图
const originalResult = await uploadFile(originalFile, {
isCompressed: 'false',
...exifData,
});
uploadResults.compressedFile = originalResult;
// 添加到素材库
await addMaterial(uploadResults.originalFile?.file_id, uploadResults.compressedFile?.file_id);
// 等待一些时间再标记为成功
await new Promise(resolve => setTimeout(resolve, 300));
// 更新状态为成功
await updateFileStatus({ status: 'success', progress: 100, id: uploadResults.originalFile?.file_id });
// 调用上传完成回调
onUploadComplete?.(uploadResults);
} catch (error) {
updateFileStatus({ status: 'error', progress: 0, id: uploadResults.originalFile?.file_id });
throw error; // 重新抛出错误,让外层 catch 处理
}
} catch (error) {
Alert.alert('错误', '处理图片时出错');
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (onProgress) {
onProgress(currentFileStatus);
}
}, [currentFileStatus.progress]);
return (
<View style={[style]}>
{children ? (
<TouchableOpacity
onPress={pickImage}
disabled={isLoading}
activeOpacity={0.7}
>
{children}
</TouchableOpacity>
) : (
<Button
title={isLoading ? '处理中...' : '选择图片'}
onPress={pickImage}
disabled={isLoading}
/>
)}
{/* 添加上传进度条 */}
{currentFileStatus.status === 'uploading' && (
<Progress.Bar progress={currentFileStatus.progress / 100} width={200} key={currentFileStatus.id} animated={true} />
)}
</View>
);
};
export default ImagesPicker;