All checks were successful
Dev Deploy / Explore-Gitea-Actions (push) Successful in 24s
338 lines
12 KiB
TypeScript
338 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,
|
|
});
|
|
|
|
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; |