upload-jh #7

Merged
txcjh merged 13 commits from upload-jh into upload 2025-07-17 15:55:28 +08:00
17 changed files with 5862 additions and 464 deletions
Showing only changes of commit 5abb5a6836 - Show all commits

View File

@ -23,6 +23,7 @@ export default function AskScreen() {
const insets = useSafeAreaInsets();
useEffect(() => {
checkAuthStatus(router);
router.replace('/login');
}, []);
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);

View File

@ -1,5 +1,8 @@
import { fetchApi } from '@/lib/server-api-util';
import { defaultExifData, ExifData, ImagesuploaderProps, UploadUrlResponse } from '@/types/upload';
import { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api';
import { ConfirmUpload, FileUploadItem, UploadResult, UploadTask } from '@/lib/background-uploader/types';
import { uploadFileWithProgress } from '@/lib/background-uploader/uploader';
import { compressImage } from '@/lib/image-process/imageCompress';
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';
@ -8,46 +11,6 @@ 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,
@ -81,191 +44,6 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
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);
@ -329,15 +107,11 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
{ 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 }
)
]);
// 处理图片,主图和缩略图都用 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) {
@ -358,20 +132,10 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
}
}
}
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' }
);
// 用压缩后主图作为上传主文件
file = compressedFile as File;
// 用缩略图文件作为预览
thumbnailFile = thumbFile as File;
}
// 准备上传任务
@ -401,8 +165,11 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
const uploadResultsList = [];
for (const task of uploadTasks) {
try {
const result = await uploadWithProgress(task.file, task.metadata);
uploadResultsList.push(result);
// 统一通过 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;
@ -435,7 +202,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
if (uploadResults.originalFile?.file_id) {
await addMaterial(
uploadResults.originalFile.file_id,
uploadResults.thumbnail
uploadResults.compressedFile?.file_id
);
}

View File

@ -1,39 +1,39 @@
import * as ImageManipulator from 'expo-image-manipulator';
import * as VideoThumbnail from 'expo-video-thumbnails';
// import * as ImageManipulator from 'expo-image-manipulator';
// import * as VideoThumbnail from 'expo-video-thumbnails';
export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
try {
// 获取视频的第一帧
const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
videoUri,
{
time: 1000, // 1秒的位置
quality: 0.8,
}
);
// export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
// try {
// // 获取视频的第一帧
// const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
// videoUri,
// {
// time: 1000, // 1秒的位置
// quality: 0.8,
// }
// );
// 转换为 WebP 格式
const manipResult = await ImageManipulator.manipulateAsync(
thumbnailUri,
[{ resize: { width: 800 } }], // 调整大小以提高性能
{
compress: 0.8,
format: ImageManipulator.SaveFormat.WEBP
}
);
// // 转换为 WebP 格式
// const manipResult = await ImageManipulator.manipulateAsync(
// thumbnailUri,
// [{ resize: { width: 800 } }], // 调整大小以提高性能
// {
// compress: 0.8,
// format: ImageManipulator.SaveFormat.WEBP
// }
// );
// 转换为 File 对象
const response = await fetch(manipResult.uri);
const blob = await response.blob();
const file = new File(
[blob],
`thumb_${Date.now()}.webp`,
{ type: 'image/webp' }
);
// // 转换为 File 对象
// const response = await fetch(manipResult.uri);
// const blob = await response.blob();
// const file = new File(
// [blob],
// `thumb_${Date.now()}.webp`,
// { type: 'image/webp' }
// );
return { uri: manipResult.uri, file };
} catch (error) {
console.error('Error generating video thumbnail:', error);
throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
}
};
// return { uri: manipResult.uri, file };
// } catch (error) {
// console.error('Error generating video thumbnail:', error);
// throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
// }
// };

View File

@ -1,138 +1,138 @@
/**
* File对象
* @param videoFile
* @returns File对象
*/
export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(videoFile);
const video = document.createElement('video');
video.src = videoUrl;
video.crossOrigin = 'anonymous';
video.muted = true;
video.preload = 'metadata';
// /**
// * 从视频文件中提取第一帧并返回为File对象
// * @param videoFile 视频文件
// * @returns 包含视频第一帧的File对象
// */
// export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
// return new Promise((resolve, reject) => {
// const videoUrl = URL.createObjectURL(videoFile);
// const video = document.createElement('video');
// video.src = videoUrl;
// video.crossOrigin = 'anonymous';
// video.muted = true;
// video.preload = 'metadata';
video.onloadeddata = () => {
try {
// 设置视频时间到第一帧
video.currentTime = 0.1;
} catch (e) {
URL.revokeObjectURL(videoUrl);
reject(e);
}
};
// video.onloadeddata = () => {
// try {
// // 设置视频时间到第一帧
// video.currentTime = 0.1;
// } catch (e) {
// URL.revokeObjectURL(videoUrl);
// reject(e);
// }
// };
video.onseeked = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
// video.onseeked = () => {
// try {
// const canvas = document.createElement('canvas');
// canvas.width = video.videoWidth;
// canvas.height = video.videoHeight;
// const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取canvas上下文');
}
// if (!ctx) {
// throw new Error('无法获取canvas上下文');
// }
// 绘制视频帧到canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// // 绘制视频帧到canvas
// ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 将canvas转换为DataURL
const dataUrl = canvas.toDataURL('image/jpeg');
// // 将canvas转换为DataURL
// const dataUrl = canvas.toDataURL('image/jpeg');
// 将DataURL转换为Blob
const byteString = atob(dataUrl.split(',')[1]);
const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
// // 将DataURL转换为Blob
// const byteString = atob(dataUrl.split(',')[1]);
// const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
// const ab = new ArrayBuffer(byteString.length);
// const ia = new Uint8Array(ab);
// for (let i = 0; i < byteString.length; i++) {
// ia[i] = byteString.charCodeAt(i);
// }
// const blob = new Blob([ab], { type: mimeString });
// 创建File对象
const frameFile = new File(
[blob],
`${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
{ type: 'image/jpeg' }
);
// // 创建File对象
// const frameFile = new File(
// [blob],
// `${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
// { type: 'image/jpeg' }
// );
// 清理URL对象
URL.revokeObjectURL(videoUrl);
resolve(frameFile);
} catch (e) {
URL.revokeObjectURL(videoUrl);
reject(e);
}
};
// // 清理URL对象
// URL.revokeObjectURL(videoUrl);
// resolve(frameFile);
// } catch (e) {
// URL.revokeObjectURL(videoUrl);
// reject(e);
// }
// };
video.onerror = () => {
URL.revokeObjectURL(videoUrl);
reject(new Error('视频加载失败'));
};
});
};
// video.onerror = () => {
// URL.revokeObjectURL(videoUrl);
// reject(new Error('视频加载失败'));
// };
// });
// };
// 获取视频时长
export const getVideoDuration = (file: File): Promise<number> => {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
// // 获取视频时长
// export const getVideoDuration = (file: File): Promise<number> => {
// return new Promise((resolve) => {
// const video = document.createElement('video');
// video.preload = 'metadata';
video.onloadedmetadata = () => {
URL.revokeObjectURL(video.src);
resolve(video.duration);
};
// video.onloadedmetadata = () => {
// URL.revokeObjectURL(video.src);
// resolve(video.duration);
// };
video.onerror = () => {
URL.revokeObjectURL(video.src);
resolve(0); // Return 0 if we can't get the duration
};
// video.onerror = () => {
// URL.revokeObjectURL(video.src);
// resolve(0); // Return 0 if we can't get the duration
// };
video.src = URL.createObjectURL(file);
});
};
// video.src = URL.createObjectURL(file);
// });
// };
// 根据 mp4 的url来获取视频时长
/**
* URL获取视频时长
* @param videoUrl URL
* @returns Promise
*/
export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
return await new Promise((resolve, reject) => {
// 创建临时的video元素
const video = document.createElement('video');
// // 根据 mp4 的url来获取视频时长
// /**
// * 根据视频URL获取视频时长
// * @param videoUrl 视频的URL
// * @returns 返回一个Promise解析为视频时长
// */
// export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
// return await new Promise((resolve, reject) => {
// // 创建临时的video元素
// const video = document.createElement('video');
// 设置为只加载元数据,不加载整个视频
video.preload = 'metadata';
// // 设置为只加载元数据,不加载整个视频
// video.preload = 'metadata';
// 处理加载成功
video.onloadedmetadata = () => {
// 释放URL对象
URL.revokeObjectURL(video.src);
// 返回视频时长(秒)
resolve(video.duration);
};
// // 处理加载成功
// video.onloadedmetadata = () => {
// // 释放URL对象
// URL.revokeObjectURL(video.src);
// // 返回视频时长(秒)
// resolve(video.duration);
// };
// 处理加载错误
video.onerror = () => {
URL.revokeObjectURL(video.src);
reject(new Error('无法加载视频'));
};
// // 处理加载错误
// video.onerror = () => {
// URL.revokeObjectURL(video.src);
// reject(new Error('无法加载视频'));
// };
// 处理网络错误
video.onabort = () => {
URL.revokeObjectURL(video.src);
reject(new Error('视频加载被中止'));
};
// // 处理网络错误
// video.onabort = () => {
// URL.revokeObjectURL(video.src);
// reject(new Error('视频加载被中止'));
// };
// 设置视频源
video.src = videoUrl;
// // 设置视频源
// video.src = videoUrl;
// 添加跨域属性(如果需要)
video.setAttribute('crossOrigin', 'anonymous');
// // 添加跨域属性(如果需要)
// video.setAttribute('crossOrigin', 'anonymous');
// 开始加载元数据
video.load();
});
};
// // 开始加载元数据
// video.load();
// });
// };

11
jest.config.ts Normal file
View File

@ -0,0 +1,11 @@
const { createDefaultPreset } = require("ts-jest");
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
};

View File

@ -12,7 +12,7 @@ export async function identityCheck(token: string) {
},
});
const data = await res.json();
return data.code == 0;
return data.code != 0;
}
/**
@ -28,10 +28,7 @@ export async function checkAuthStatus(router: ReturnType<typeof useRouter>, onAu
}
const loggedIn = !!token && await identityCheck(token);
console.log('token', token);
console.log('loggedIn', loggedIn);
if (!loggedIn) {
console.log('未登录');
router.replace('/login');
return false;
}

View File

@ -1,8 +1,8 @@
import { fetchApi } from '@/lib/server-api-util';
import { ConfirmUpload, UploadUrlResponse } from './types';
// 获取上传URL
export const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
export const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
const body = {
filename: file.name,
content_type: file.type,
@ -11,19 +11,19 @@ export const getUploadUrl = async (file: File, metadata: Record<string, any> = {
...metadata,
originalName: file.name,
fileType: file.type.startsWith('video/') ? 'video' : 'image',
isCompressed: 'true',
isCompressed: metadata.isCompressed || 'false',
},
};
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
return await fetchApi<UploadUrlResponse>('/file/generate-upload-url', {
method: 'POST',
body: JSON.stringify(body)
});
};
// 确认上传
export const confirmUpload = async (file_id: string) => {
return await fetchApi('/file/confirm-upload', {
// 确认上传
export const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => {
return await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({ file_id })
});

View File

@ -1,7 +1,6 @@
import * as FileSystem from 'expo-file-system';
import * as ImageManipulator from 'expo-image-manipulator';
import { confirmUpload, getUploadUrl } from './api';
import { ExtendedAsset } from './types';
import { getUploadUrl, confirmUpload } from './api';
import { uploadFile } from './uploader';
// 将 HEIC 图片转化
@ -39,16 +38,7 @@ export const convertHeicToJpeg = async (uri: string): Promise<File> => {
throw new Error('Failed to read file as base64');
}
// 4. 创建 Blob
const response = await fetch(`data:image/jpeg;base64,${base64}`);
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}`);
}
const blob = await response.blob();
if (!blob || blob.size === 0) {
throw new Error('Failed to create blob from base64');
}
// 5. 创建文件名
const originalName = uri.split('/').pop() || 'converted';
@ -78,35 +68,7 @@ export const convertHeicToJpeg = async (uri: string): Promise<File> => {
};
// 压缩图片
export const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => {
try {
const manipResult = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: 1200,
height: 1200,
},
},
],
{
compress: 0.7,
format: ImageManipulator.SaveFormat.JPEG,
base64: false,
}
);
const response = await fetch(manipResult.uri);
const blob = await response.blob();
const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
return { uri: manipResult.uri, file };
} catch (error) {
throw error;
}
};
import { compressImage } from '../image-process/imageCompress';
// 提取视频的首帧进行压缩并上传
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {

View File

@ -4,7 +4,8 @@ import { transformData } from '@/components/utils/objectFlat';
import { ExtendedAsset } from './types';
import { getMediaByDateRange } from './media';
import { checkMediaLibraryPermission, getFileExtension, getMimeType } from './utils';
import { convertHeicToJpeg, compressImage, uploadVideoThumbnail } from './fileProcessor';
import { convertHeicToJpeg, uploadVideoThumbnail } from './fileProcessor';
import { compressImage } from '../image-process/imageCompress';
import { getUploadUrl, confirmUpload, addMaterial } from './api';
import { uploadFile } from './uploader';
import * as MediaLibrary from 'expo-media-library';

View File

@ -3,3 +3,53 @@ import * as MediaLibrary from 'expo-media-library';
export type ExtendedAsset = MediaLibrary.Asset & {
exif?: Record<string, any>;
};
// 上传任务类型
export type UploadTask = {
file: File;
metadata: {
isCompressed: string;
type: string;
isThumbnail?: string;
[key: string]: any;
};
};
// 上传队列项
export type FileUploadItem = {
id: string;
name: string;
progress: number;
status: 'pending' | 'uploading' | 'done' | 'error';
error: string | null;
type: 'image' | 'video';
thumbnail: string | null;
};
// 确认上传返回
export type ConfirmUpload = {
file_id: string;
upload_url: string;
name: string;
size: number;
content_type: string;
file_path: string;
};
// 上传结果
export type UploadResult = {
originalUrl?: string;
compressedUrl: string;
file: File | null;
exif: any;
originalFile: ConfirmUpload;
compressedFile: ConfirmUpload;
thumbnail: string;
thumbnailFile: File;
};
// 上传URL响应类型
export type UploadUrlResponse = {
upload_url: string;
file_id: string;
};

View File

@ -1,19 +1,49 @@
// 上传文件到URL
// 上传文件到URL(基础版,无进度回调)
export const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
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);
});
};
// 支持进度回调和超时的上传实现
export const uploadFileWithProgress = async (
file: File,
uploadUrl: string,
onProgress?: (progress: number) => void,
timeout: number = 30000
): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let timeoutId: number | undefined;
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
// 进度监听
if (onProgress) {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
onProgress(progress);
}
};
}
xhr.onload = () => {
clearTimeout(timeoutId);
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
@ -22,9 +52,16 @@ export const uploadFile = async (file: File, uploadUrl: string): Promise<void> =
};
xhr.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('Network error during upload'));
};
// 超时处理
timeoutId = setTimeout(() => {
xhr.abort();
reject(new Error('上传超时,请检查网络连接'));
}, timeout);
xhr.send(file);
});
};

View File

@ -0,0 +1,47 @@
import * as FileSystem from 'expo-file-system';
// 将 HEIC/HEIF 图片转为 JPEG
export const convertHeicToJpeg = async (uri: string): Promise<File> => {
try {
// 1. 将文件复制到缓存目录
const cacheDir = FileSystem.cacheDirectory;
if (!cacheDir) {
throw new Error('Cache directory not available');
}
const tempUri = `${cacheDir}${Date.now()}.heic`;
await FileSystem.copyAsync({ from: uri, to: tempUri });
// 2. 检查文件是否存在
const fileInfo = await FileSystem.getInfoAsync(tempUri);
if (!fileInfo.exists) {
throw new Error('Temporary file was not created');
}
// 3. 读取文件为 base64
const base64 = await FileSystem.readAsStringAsync(tempUri, {
encoding: FileSystem.EncodingType.Base64,
});
if (!base64) {
throw new Error('Failed to read file as base64');
}
// 4. 创建 Blob
const response = await fetch(`data:image/jpeg;base64,${base64}`);
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}`);
}
const blob = await response.blob();
if (!blob || blob.size === 0) {
throw new Error('Failed to create blob from base64');
}
// 5. 创建文件名
const originalName = uri.split('/').pop() || 'converted';
const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg');
// 清理临时文件
try {
await FileSystem.deleteAsync(tempUri, { idempotent: true });
} catch (cleanupError) {
console.warn('Failed to clean up temporary file:', cleanupError);
}
return new File([blob], filename, { type: 'image/jpeg' });
} catch (error: unknown) {
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
}
};

View File

@ -0,0 +1,56 @@
import * as ImageManipulator from 'expo-image-manipulator';
import { Image } from 'react-native';
// 压缩图片,自动等比缩放,最大边不超过 800
export const compressImage = async (uri: string, maxSize = 800): Promise<{ uri: string; file: File }> => {
// 获取原图尺寸
const getImageSize = (uri: string): Promise<{ width: number; height: number }> =>
new Promise((resolve, reject) => {
Image.getSize(
uri,
(width, height) => resolve({ width, height }),
reject
);
});
try {
const { width, height } = await getImageSize(uri);
let targetWidth = width;
let targetHeight = height;
if (width > maxSize || height > maxSize) {
if (width > height) {
targetWidth = maxSize;
targetHeight = Math.round((height / width) * maxSize);
} else {
targetHeight = maxSize;
targetWidth = Math.round((width / height) * maxSize);
}
}
const manipResult = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: targetWidth,
height: targetHeight,
},
},
],
{
compress: 0.7,
format: ImageManipulator.SaveFormat.WEBP,
base64: false,
}
);
const response = await fetch(manipResult.uri);
const blob = await response.blob();
const filename = uri.split('/').pop() || `image_${Date.now()}.webp`;
const file = new File([blob], filename, { type: 'image/webp' });
return { uri: manipResult.uri, file };
} catch (error) {
throw error;
}
};

View File

@ -0,0 +1,31 @@
import { getUploadUrl, confirmUpload } from '../background-uploader/api';
import { uploadFile } from '../background-uploader/uploader';
import { compressImage } from '../image-process/imageCompress';
import { ExtendedAsset } from '../background-uploader/types';
// 提取视频的首帧进行压缩并上传
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
try {
const manipResult = await compressImage(asset.uri);
const response = await fetch(manipResult.uri);
const blob = await response.blob();
const filename = asset.filename ?
`compressed_${asset.filename}` :
`image_${Date.now()}_compressed.jpg`;
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
originalUri: asset.uri,
creationTime: asset.creationTime,
mediaType: 'image',
isCompressed: true
});
await uploadFile(compressedFile, upload_url);
await confirmUpload(file_id);
return { success: true, file_id };
} catch (error) {
return { success: false, error };
}
};

5433
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,8 @@
"web": "expo start --web --port 5173",
"lint": "expo lint",
"prebuild": "npm run generate:translations",
"generate:translations": "tsx i18n/generate-imports.ts"
"generate:translations": "tsx i18n/generate-imports.ts",
"test": "jest"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
@ -74,13 +75,16 @@
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.3",
"@types/react": "~19.0.10",
"eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0",
"jest": "^30.0.4",
"prettier-plugin-tailwindcss": "^0.5.14",
"react-native-svg-transformer": "^1.5.1",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.4.0",
"ts-node": "^10.9.2",
"tsx": "^4.20.3",
"typescript": "~5.8.3"

View File

@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base",
"typeRoots": ["./node_modules/@types", "./src/types"],
"compilerOptions": {
"types": ["jest", "node"],
"strict": true,
"baseUrl": ".",
"paths": {