chore: 重构

This commit is contained in:
Junhui Chen 2025-07-16 19:22:08 +08:00
parent 56d8737bc9
commit 5abb5a6836
17 changed files with 5862 additions and 464 deletions

View File

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

View File

@ -1,5 +1,8 @@
import { fetchApi } from '@/lib/server-api-util'; import { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api';
import { defaultExifData, ExifData, ImagesuploaderProps, UploadUrlResponse } from '@/types/upload'; 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 ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location'; 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 { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
import UploadPreview from './preview'; 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> = ({ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
children, children,
style, style,
@ -81,191 +44,6 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
return true; 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> => { const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise<UploadResult | null> => {
console.log("asset111111", asset); console.log("asset111111", asset);
@ -329,15 +107,11 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
{ type: 'image/jpeg' } { type: 'image/jpeg' }
); );
} else { } else {
// 处理图片 // 处理图片,主图和缩略图都用 compressImage 方法
const [originalResponse, compressedFileResult] = await Promise.all([ // 主图压缩(按 maxWidth/maxHeight/compressQuality
fetch(asset.uri), const { file: compressedFile } = await compressImage(asset.uri, maxWidth);
ImageManipulator.manipulateAsync( // 缩略图压缩宽度800
asset.uri, const { file: thumbFile } = await compressImage(asset.uri, 800);
[{ resize: { width: 800 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
)
]);
// 如果保留 EXIF 数据,则获取 // 如果保留 EXIF 数据,则获取
if (preserveExif && asset.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 = compressedFile as File;
// 用缩略图文件作为预览
file = new File( thumbnailFile = thumbFile as 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' }
);
} }
// 准备上传任务 // 准备上传任务
@ -401,8 +165,11 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
const uploadResultsList = []; const uploadResultsList = [];
for (const task of uploadTasks) { for (const task of uploadTasks) {
try { try {
const result = await uploadWithProgress(task.file, task.metadata); // 统一通过 lib 的 uploadFileWithProgress 实现上传
uploadResultsList.push(result); 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) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
throw error; throw error;
@ -435,7 +202,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
if (uploadResults.originalFile?.file_id) { if (uploadResults.originalFile?.file_id) {
await addMaterial( await addMaterial(
uploadResults.originalFile.file_id, 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 ImageManipulator from 'expo-image-manipulator';
import * as VideoThumbnail from 'expo-video-thumbnails'; // import * as VideoThumbnail from 'expo-video-thumbnails';
export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => { // export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
try { // try {
// 获取视频的第一帧 // // 获取视频的第一帧
const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync( // const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
videoUri, // videoUri,
{ // {
time: 1000, // 1秒的位置 // time: 1000, // 1秒的位置
quality: 0.8, // quality: 0.8,
} // }
); // );
// 转换为 WebP 格式 // // 转换为 WebP 格式
const manipResult = await ImageManipulator.manipulateAsync( // const manipResult = await ImageManipulator.manipulateAsync(
thumbnailUri, // thumbnailUri,
[{ resize: { width: 800 } }], // 调整大小以提高性能 // [{ resize: { width: 800 } }], // 调整大小以提高性能
{ // {
compress: 0.8, // compress: 0.8,
format: ImageManipulator.SaveFormat.WEBP // format: ImageManipulator.SaveFormat.WEBP
} // }
); // );
// 转换为 File 对象 // // 转换为 File 对象
const response = await fetch(manipResult.uri); // const response = await fetch(manipResult.uri);
const blob = await response.blob(); // const blob = await response.blob();
const file = new File( // const file = new File(
[blob], // [blob],
`thumb_${Date.now()}.webp`, // `thumb_${Date.now()}.webp`,
{ type: 'image/webp' } // { type: 'image/webp' }
); // );
return { uri: manipResult.uri, file }; // return { uri: manipResult.uri, file };
} catch (error) { // } catch (error) {
console.error('Error generating video thumbnail:', error); // console.error('Error generating video thumbnail:', error);
throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error))); // throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
} // }
}; // };

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import * as ImageManipulator from 'expo-image-manipulator'; import { confirmUpload, getUploadUrl } from './api';
import { ExtendedAsset } from './types'; import { ExtendedAsset } from './types';
import { getUploadUrl, confirmUpload } from './api';
import { uploadFile } from './uploader'; import { uploadFile } from './uploader';
// 将 HEIC 图片转化 // 将 HEIC 图片转化
@ -39,16 +38,7 @@ export const convertHeicToJpeg = async (uri: string): Promise<File> => {
throw new Error('Failed to read file as 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. 创建文件名 // 5. 创建文件名
const originalName = uri.split('/').pop() || 'converted'; 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 }> => { import { compressImage } from '../image-process/imageCompress';
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;
}
};
// 提取视频的首帧进行压缩并上传 // 提取视频的首帧进行压缩并上传
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => { export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {

View File

@ -4,7 +4,8 @@ import { transformData } from '@/components/utils/objectFlat';
import { ExtendedAsset } from './types'; import { ExtendedAsset } from './types';
import { getMediaByDateRange } from './media'; import { getMediaByDateRange } from './media';
import { checkMediaLibraryPermission, getFileExtension, getMimeType } from './utils'; 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 { getUploadUrl, confirmUpload, addMaterial } from './api';
import { uploadFile } from './uploader'; import { uploadFile } from './uploader';
import * as MediaLibrary from 'expo-media-library'; 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 & { export type ExtendedAsset = MediaLibrary.Asset & {
exif?: Record<string, any>; 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> => { export const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); 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.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type); xhr.setRequestHeader('Content-Type', file.type);
// 进度监听 // 进度监听
xhr.upload.onprogress = (event) => { if (onProgress) {
if (event.lengthComputable) { xhr.upload.onprogress = (event) => {
const progress = Math.round((event.loaded / event.total) * 100); if (event.lengthComputable) {
} const progress = Math.round((event.loaded / event.total) * 100);
}; onProgress(progress);
}
};
}
xhr.onload = () => { xhr.onload = () => {
clearTimeout(timeoutId);
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(); resolve();
} else { } else {
@ -22,9 +52,16 @@ export const uploadFile = async (file: File, uploadUrl: string): Promise<void> =
}; };
xhr.onerror = () => { xhr.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('Network error during upload')); reject(new Error('Network error during upload'));
}; };
// 超时处理
timeoutId = setTimeout(() => {
xhr.abort();
reject(new Error('上传超时,请检查网络连接'));
}, timeout);
xhr.send(file); 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", "web": "expo start --web --port 5173",
"lint": "expo lint", "lint": "expo lint",
"prebuild": "npm run generate:translations", "prebuild": "npm run generate:translations",
"generate:translations": "tsx i18n/generate-imports.ts" "generate:translations": "tsx i18n/generate-imports.ts",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
@ -74,13 +75,16 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.3", "@types/node": "^24.0.3",
"@types/react": "~19.0.10", "@types/react": "~19.0.10",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0", "eslint-config-expo": "~9.2.0",
"jest": "^30.0.4",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.5.14",
"react-native-svg-transformer": "^1.5.1", "react-native-svg-transformer": "^1.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"ts-jest": "^29.4.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "~5.8.3" "typescript": "~5.8.3"

View File

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