This commit is contained in:
Junhui Chen 2025-07-16 18:07:43 +08:00
parent 49012df68e
commit 56d8737bc9
25 changed files with 894 additions and 712 deletions

View File

@ -0,0 +1,11 @@
name: Create Production Builds
jobs:
build_android:
type: build # This job type creates a production build for Android
params:
platform: android
build_ios:
type: build # This job type creates a production build for iOS
params:
platform: ios

View File

@ -1,50 +1,18 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
## Dependencies
### SQLite
- [SQLite](https://sqlite.org/index.html)
```shell
expo install expo-sqlite
cp node_modules/wa-sqlite/dist/wa-sqlite.wasm node_modules/expo-sqlite/web/wa-sqlite/
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Build
### EAS Build
```shell
eas build --platform android --profile development
```
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
### EAS Workflow
```shell
npx eas-cli@latest workflow:run create-production-builds.yml
```

View File

@ -15,7 +15,11 @@
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
"ITSAppUsesNonExemptEncryption": false,
"UIBackgroundModes": ["fetch", "location", "audio"]
"UIBackgroundModes": [
"fetch",
"location",
"audio"
]
},
"bundleIdentifier": "com.memowake.app"
},
@ -48,7 +52,8 @@
},
"plugins": [
"expo-router",
"expo-secure-store", [
"expo-secure-store",
[
"expo-background-task",
{
"minimumInterval": 15
@ -68,15 +73,6 @@
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置"
}
],
// [
// "expo-notifications",
// {
// "color": "#ffffff",
// "defaultChannel": "default",
// "enableBackgroundRemoteNotifications": false,
// "mode": "client"
// }
// ],
[
"expo-audio",
{
@ -90,7 +86,8 @@
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
"isAccessMediaLocationEnabled": true
}
]
],
"expo-sqlite"
],
"experiments": {
"typedRoutes": true

View File

@ -3,6 +3,7 @@ import Chat from "@/components/ask/chat";
import AskHello from "@/components/ask/hello";
import SendMessage from "@/components/ask/send";
import { ThemedText } from "@/components/ThemedText";
import { checkAuthStatus } from '@/lib/auth';
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router";
@ -20,6 +21,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
const insets = useSafeAreaInsets();
useEffect(() => {
checkAuthStatus(router);
}, []);
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
const [isHello, setIsHello] = useState(true);
const [conversationId, setConversationId] = useState<string | null>(null);

View File

@ -1,11 +1,10 @@
import IP from '@/assets/icons/svg/ip.svg';
import { registerBackgroundUploadTask, triggerManualUpload } from '@/components/file-upload/backgroundUploader';
import { registerBackgroundUploadTask } from '@/lib/background-uploader';
import * as MediaLibrary from 'expo-media-library';
import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, Text, TouchableOpacity, View } from 'react-native';
import { Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
import MemoList from './memo-list';
@ -41,9 +40,6 @@ export default function HomeScreen() {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status === 'granted') {
await registerBackgroundUploadTask();
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
await triggerManualUpload(oneDayAgo, now);
}
router.replace('/ask');
}
@ -140,6 +136,6 @@ export default function HomeScreen() {
</TouchableOpacity>
</View>
}
</View>
</View >
);
}

View File

@ -53,7 +53,7 @@ const LoginScreen = () => {
}
useEffect(() => {
setError('123')
// setError('123')
}, [])
return (

View File

@ -12,15 +12,29 @@ import ResourceComponent from '@/components/owner/resource';
import SettingModal from '@/components/owner/setting';
import UserInfo from '@/components/owner/userName';
import { formatDuration } from '@/components/utils/time';
import { checkAuthStatus } from '@/lib/auth';
import { fetchApi } from '@/lib/server-api-util';
import { CountData, UserInfoDetails } from '@/types/user';
import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function OwnerPage() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const router = useRouter();
useEffect(() => {
const checkAuth = async () => {
const authStatus = await checkAuthStatus(router);
if (!authStatus) {
router.push('/login');
}
};
checkAuth();
}, [router]);
// 设置弹窗
const [modalVisible, setModalVisible] = useState(false);

View File

@ -2,14 +2,16 @@ import Choice from '@/components/user-message.tsx/choice';
import Done from '@/components/user-message.tsx/done';
import Look from '@/components/user-message.tsx/look';
import UserName from '@/components/user-message.tsx/userName';
import { checkAuthStatus } from '@/lib/auth';
import { fetchApi } from '@/lib/server-api-util';
import { FileUploadItem } from '@/types/upload';
import { User } from '@/types/user';
import { useLocalSearchParams } from 'expo-router';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
export type Steps = "userName" | "look" | "choice" | "done";
export default function UserMessage() {
const router = useRouter();
// 步骤
const [steps, setSteps] = useState<Steps>("userName")
const [username, setUsername] = useState('')
@ -23,6 +25,10 @@ export default function UserMessage() {
const params = useLocalSearchParams();
const { username: usernameParam } = params;
useEffect(() => {
checkAuthStatus(router);
}, []);
// 获取用户信息
const getUserInfo = async () => {
const res = await fetchApi<User>("/iam/user-info");

View File

@ -1,6 +1,6 @@
import { registerBackgroundUploadTask, triggerManualUpload } from '@/lib/background-uploader';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { registerBackgroundUploadTask, triggerManualUpload } from './backgroundUploader';
export default function AutoUploadScreen() {
const [timeRange, setTimeRange] = useState('day');
@ -13,8 +13,8 @@ export default function AutoUploadScreen() {
const registered = await registerBackgroundUploadTask();
setIsRegistered(registered);
};
registerTask();
console.log("register background upload task");
// registerTask();
}, []);
// 处理手动上传

View File

@ -1,639 +0,0 @@
/**
* @file backgroundUploader.ts
* @description
* Expo React Native
*
* 使
*
* @features
* - 后台任务处理: 利用 `expo-task-manager` `expo-background-fetch`
* 15使退
* - 媒体文件检索: 使用 `expo-media-library` 访
* EXIF GPS位置
* - :
* - 图片压缩: 通过 `expo-image-manipulator`
* - HEIC 格式转换: 自动将苹果设备上常见的 HEIC/HEIF JPEG
* - 视频缩略图: 提取视频的第一帧作为预览图进行上传
* - 高效并发上传: 使用 `p-limit` 10
* - 手动触发机制: 提供 `triggerManualUpload`
*
* @workflow
* 1. 触发: 上传流程可以通过两种方式启动
* 2. 查询媒体文件: 根据指定的日期范围
* 3. ():
* a. 权限检查: 确保应用拥有访问媒体库的权限
* b. 上传原始文件: 首先上传完整分辨率的原始文件 HEIC URL
* XMLHttpRequest
* c. 上传派生文件: 接着
* d. 关联素材: 当原始文件和派生文件/API将两者进行关联
* 4. 完成: 所有文件处理完毕后
*/
import { fetchApi } from '@/lib/server-api-util';
import * as BackgroundFetch from 'expo-background-task';
import * as FileSystem from 'expo-file-system';
import * as ImageManipulator from 'expo-image-manipulator';
import * as MediaLibrary from 'expo-media-library';
import * as TaskManager from 'expo-task-manager';
import pLimit from 'p-limit';
import { Alert } from 'react-native';
import { transformData } from '../utils/objectFlat';
type ExtendedAsset = MediaLibrary.Asset & {
exif?: Record<string, any>;
};
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
// 设置最大并发数
const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件
// 在 CONCURRENCY_LIMIT 定义后添加
const limit = pLimit(CONCURRENCY_LIMIT);
// 获取文件扩展名
const getFileExtension = (filename: string) => {
return filename.split('.').pop()?.toLowerCase() || '';
};
// 获取 MIME 类型
const getMimeType = (filename: string, isVideo: boolean) => {
if (!isVideo) return 'image/jpeg';
const ext = getFileExtension(filename);
switch (ext) {
case 'mov':
return 'video/quicktime';
case 'mp4':
return 'video/mp4';
case 'm4v':
return 'video/x-m4v';
default:
return 'video/mp4'; // 默认值
}
};
// 将 HEIC 图片转化
const convertHeicToJpeg = async (uri: string): Promise<File> => {
try {
console.log('Starting HEIC to JPEG conversion for:', uri);
// 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');
console.log('Successfully converted HEIC to JPEG:', filename);
// 清理临时文件
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) {
console.error('Detailed HEIC conversion error:', {
error: error instanceof Error ? {
message: error.message,
name: error.name,
stack: error.stack
} : error,
uri: uri
});
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
}
};
// 获取指定时间范围内的媒体文件(包含 EXIF 信息)
export const getMediaByDateRange = async (startDate: Date, endDate: Date) => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Media library permission not granted');
return [];
}
const media = await MediaLibrary.getAssetsAsync({
mediaType: ['photo', 'video'],
first: 100,
sortBy: [MediaLibrary.SortBy.creationTime],
createdAfter: startDate.getTime(),
createdBefore: endDate.getTime(),
});
// 为每个资源获取完整的 EXIF 信息
const assetsWithExif = await Promise.all(
media.assets.map(async (asset) => {
try {
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id);
return {
...asset,
exif: assetInfo.exif || null,
location: assetInfo.location || null
};
} catch (error) {
console.warn(`Failed to get EXIF for asset ${asset.id}:`, error);
return asset;
}
})
);
return assetsWithExif;
} catch (error) {
console.error('Error in getMediaByDateRange:', error);
return [];
}
};
// 压缩图片
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;
}
};
// 获取上传URL
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: file.type.startsWith('video/') ? 'video' : 'image',
isCompressed: 'true',
},
};
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 确认上传
const confirmUpload = async (file_id: string) => {
return await fetchApi('/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) => {
})
}
// 上传文件到URL
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.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
}
};
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 checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
try {
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
// 如果已经授权,直接返回
if (status === 'granted' && accessPrivileges === 'all') {
return { hasPermission: true, status };
}
// 如果没有授权,请求权限
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
if (!isGranted) {
console.log('Media library permission not granted or limited access');
}
return {
hasPermission: isGranted,
status: newStatus
};
} catch (error) {
return { hasPermission: false };
}
};
// 提取视频的首帧进行压缩并上传
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);
console.log('视频首帧文件上传成功:', {
fileId: file_id,
filename: compressedFile.name,
type: compressedFile.type
});
return { success: true, file_id };
} catch (error) {
return { success: false, error };
}
};
// 处理单个媒体文件上传
const processMediaUpload = async (asset: ExtendedAsset) => {
try {
// 检查权限
const { hasPermission } = await checkMediaLibraryPermission();
if (!hasPermission) {
throw new Error('No media library permission');
}
const isVideo = asset.mediaType === 'video';
// 上传原始文件
const uploadOriginalFile = async () => {
try {
let fileToUpload: File;
const isVideo = asset.mediaType === 'video';
const mimeType = getMimeType(asset.filename || '', isVideo);
// 生成文件名,保留原始扩展名
let filename = asset.filename ||
`${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? (getFileExtension(asset.filename || 'mp4') || 'mp4') : 'jpg'}`;
// 处理 HEIC 格式
if (filename.toLowerCase().endsWith('.heic') || filename.toLowerCase().endsWith('.heif')) {
fileToUpload = await convertHeicToJpeg(asset.uri);
filename = filename.replace(/\.(heic|heif)$/i, '.jpg');
} else {
// 获取资源信息
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, {
shouldDownloadFromNetwork: true
});
if (!assetInfo.localUri) {
throw new Error('无法获取资源的本地路径');
}
// 获取文件扩展名
const fileExtension = getFileExtension(assetInfo.filename || '') ||
(isVideo ? 'mp4' : 'jpg');
// 确保文件名有正确的扩展名
if (!filename.toLowerCase().endsWith(`.${fileExtension}`)) {
const baseName = filename.split('.')[0];
filename = `${baseName}.${fileExtension}`;
}
// 获取文件内容
const response = await fetch(assetInfo.localUri);
const blob = await response.blob();
// 创建文件对象
fileToUpload = new File([blob], filename, { type: mimeType });
console.log('文件准备上传:', {
name: fileToUpload.name,
type: fileToUpload.type,
size: fileToUpload.size
});
}
// 准备元数据
let exifData = {};
if (asset.exif) {
try {
exifData = transformData({
...asset,
exif: {
...asset.exif,
'{MakerApple}': undefined
}
});
} catch (exifError) {
console.warn('处理 EXIF 数据时出错:', exifError);
}
}
// 获取上传 URL
const { upload_url, file_id } = await getUploadUrl(fileToUpload, {
originalUri: asset.uri,
creationTime: asset.creationTime,
mediaType: isVideo ? 'video' : 'image',
isCompressed: false,
...exifData,
GPSVersionID: undefined
});
// 上传文件
await uploadFile(fileToUpload, upload_url);
await confirmUpload(file_id);
console.log('文件上传成功:', {
fileId: file_id,
filename: fileToUpload.name,
type: fileToUpload.type
});
return {
success: true,
file_id,
filename: fileToUpload.name
};
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
console.error('上传原始文件时出错:', {
error: errorMessage,
assetId: asset.id,
filename: asset.filename,
uri: asset.uri
});
throw new Error(`上传失败: ${errorMessage}`);
}
};
// 上传压缩文件(仅图片)
const uploadCompressedFile = async () => {
if (isVideo) return { success: true, file_id: null }; // 视频不压缩
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 };
}
};
// 先上传原始文件
const originalResult = await uploadOriginalFile();
// 如果是图片,再上传压缩文件
let compressedResult = { success: true, file_id: null };
if (!isVideo) {
compressedResult = await uploadCompressedFile();
// 添加素材
addMaterial(originalResult.file_id, compressedResult?.file_id || '');
} else {
// 上传压缩首帧
uploadVideoThumbnail(asset)
}
return {
originalSuccess: originalResult.success,
compressedSuccess: compressedResult.success,
fileIds: {
original: originalResult.file_id,
compressed: compressedResult.file_id
}
};
} catch (error: any) {
if (error.message === 'No media library permission') {
throw error;
}
return {
originalSuccess: false,
compressedSuccess: false,
error: error.message
};
}
};
// 注册后台任务
export const registerBackgroundUploadTask = async () => {
try {
// 检查是否已经注册了任务
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
if (isRegistered) {
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK);
}
// 注册后台任务
await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
minimumInterval: 15 * 60, // 15 分钟
stopOnTerminate: false, // 应用退出后继续运行
startOnBoot: true, // 设备启动后自动启动
});
console.log('Background task registered');
return true;
} catch (error) {
console.error('Error registering background task:', error);
return false;
}
};
// 定义后台任务
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
try {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// 获取最近24小时的媒体文件
const media = await getMediaByDateRange(oneDayAgo, now);
if (media.length === 0) {
console.log('No media files to upload');
return BackgroundFetch.BackgroundTaskResult.Success;
}
// 处理媒体文件上传
const results = await triggerManualUpload(oneDayAgo, now);
const successCount = results.filter(r => r.originalSuccess).length;
console.log(`Background upload completed. Success: ${successCount}/${results.length}`);
return successCount > 0
? BackgroundFetch.BackgroundTaskResult.Success
: BackgroundFetch.BackgroundTaskResult.Failed;
} catch (error) {
console.error('Background task error:', error);
return BackgroundFetch.BackgroundTaskResult.Failed;
}
});
// 手动触发上传
export const triggerManualUpload = async (startDate: Date, endDate: Date) => {
try {
const media = await getMediaByDateRange(startDate, endDate);
if (media.length === 0) {
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
return [];
}
// 分离图片和视频
const photos = media.filter(item => item.mediaType === 'photo');
const videos = media.filter(item => item.mediaType === 'video');
console.log('videos11111111', videos);
const results: any[] = [];
// 处理所有图片(带并发控制)
const processPhoto = async (item: any) => {
try {
const result = await processMediaUpload(item);
results.push({
id: item.id,
...result
});
} catch (error: any) {
results.push({
id: item.id,
originalSuccess: false,
compressedSuccess: false,
error: error.message
});
}
};
// 处理所有视频(带并发控制)
const processVideo = async (item: any) => {
try {
const result = await processMediaUpload(item);
results.push({
id: item.id,
...result
});
} catch (error: any) {
results.push({
id: item.id,
originalSuccess: false,
compressedSuccess: false,
error: error.message
});
}
};
// 并发处理图片和视频
await Promise.all([
...photos.map(photo => limit(() => processPhoto(photo))),
...videos.map(video => limit(() => processVideo(video)))
]);
return results;
} catch (error) {
Alert.alert('错误', '上传过程中出现错误');
throw error;
}
};

View File

@ -186,6 +186,7 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
type: mimeType,
});
console.log("压缩后的文件", file);
return { file, uri: manipResult.uri };
} catch (error) {

View File

@ -43,7 +43,7 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
const res = await fetchApi<User>('/iam/login/password-login', {
method: 'POST',
body: JSON.stringify(body),
});
}, true, false);
login({ ...res, email: res?.account }, res.access_token || '');
const userInfo = await fetchApi<User>("/iam/user-info");
if (userInfo?.nickname) {

42
lib/auth.ts Normal file
View File

@ -0,0 +1,42 @@
import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
import { API_ENDPOINT } from './server-api-util';
export async function identityCheck(token: string) {
const res = await fetch(`${API_ENDPOINT}/v1/iam/identity-check`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
});
const data = await res.json();
return data.code == 0;
}
/**
* /login
* @param onAuthed
*/
export async function checkAuthStatus(router: ReturnType<typeof useRouter>, onAuthed?: () => Promise<void> | void) {
let token: string | null = '';
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || '';
} else {
token = await SecureStore.getItemAsync('token') || '';
}
const loggedIn = !!token && await identityCheck(token);
console.log('token', token);
console.log('loggedIn', loggedIn);
if (!loggedIn) {
console.log('未登录');
router.replace('/login');
return false;
}
if (onAuthed) {
await onAuthed();
}
return true;
}

View File

@ -0,0 +1,41 @@
import { fetchApi } from '@/lib/server-api-util';
// 获取上传URL
export const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: file.type.startsWith('video/') ? 'video' : 'image',
isCompressed: 'true',
},
};
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 确认上传
export const confirmUpload = async (file_id: string) => {
return await fetchApi('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({ file_id })
});
};
// 新增素材
export const addMaterial = async (file: string, compressFile: string) => {
await fetchApi('/material', {
method: 'POST',
body: JSON.stringify([{
"file_id": file,
"preview_file_id": compressFile
}])
});
}

View File

@ -0,0 +1,37 @@
// import * as SQLite from 'expo-sqlite';
// const db = SQLite.openDatabase('upload_status.db');
// // 初始化表
// export function initUploadTable() {
// db.transaction(tx => {
// tx.executeSql(
// `CREATE TABLE IF NOT EXISTS uploaded_files (
// uri TEXT PRIMARY KEY NOT NULL
// );`
// );
// });
// }
// // 检查文件是否已上传
// export function isFileUploaded(uri: string): Promise<boolean> {
// return new Promise(resolve => {
// db.transaction(tx => {
// tx.executeSql(
// 'SELECT uri FROM uploaded_files WHERE uri = ?;',
// [uri],
// (_, { rows }) => resolve(rows.length > 0)
// );
// });
// });
// }
// // 记录文件已上传
// export function markFileAsUploaded(uri: string) {
// db.transaction(tx => {
// tx.executeSql(
// 'INSERT OR IGNORE INTO uploaded_files (uri) VALUES (?);',
// [uri]
// );
// });
// }

View File

@ -0,0 +1,141 @@
import * as FileSystem from 'expo-file-system';
import * as ImageManipulator from 'expo-image-manipulator';
import { ExtendedAsset } from './types';
import { getUploadUrl, confirmUpload } from './api';
import { uploadFile } from './uploader';
// 将 HEIC 图片转化
export const convertHeicToJpeg = async (uri: string): Promise<File> => {
try {
console.log('Starting HEIC to JPEG conversion for:', uri);
// 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');
console.log('Successfully converted HEIC to JPEG:', filename);
// 清理临时文件
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) {
console.error('Detailed HEIC conversion error:', {
error: error instanceof Error ? {
message: error.message,
name: error.name,
stack: error.stack
} : error,
uri: uri
});
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
}
};
// 压缩图片
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;
}
};
// 提取视频的首帧进行压缩并上传
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);
console.log('视频首帧文件上传成功:', {
fileId: file_id,
filename: compressedFile.name,
type: compressedFile.type
});
return { success: true, file_id };
} catch (error) {
return { success: false, error };
}
};

View File

@ -0,0 +1,259 @@
import pLimit from 'p-limit';
import { Alert } from 'react-native';
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 { getUploadUrl, confirmUpload, addMaterial } from './api';
import { uploadFile } from './uploader';
import * as MediaLibrary from 'expo-media-library';
// 设置最大并发数
const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件
const limit = pLimit(CONCURRENCY_LIMIT);
// 处理单个媒体文件上传
export const processMediaUpload = async (asset: ExtendedAsset) => {
try {
// 检查权限
const { hasPermission } = await checkMediaLibraryPermission();
if (!hasPermission) {
throw new Error('No media library permission');
}
const isVideo = asset.mediaType === 'video';
// 上传原始文件
const uploadOriginalFile = async () => {
try {
let fileToUpload: File;
const isVideo = asset.mediaType === 'video';
const mimeType = getMimeType(asset.filename || '', isVideo);
// 生成文件名,保留原始扩展名
let filename = asset.filename ||
`${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? (getFileExtension(asset.filename || 'mp4') || 'mp4') : 'jpg'}`;
// 处理 HEIC 格式
if (filename.toLowerCase().endsWith('.heic') || filename.toLowerCase().endsWith('.heif')) {
fileToUpload = await convertHeicToJpeg(asset.uri);
filename = filename.replace(/\.(heic|heif)$/i, '.jpg');
} else {
// 获取资源信息
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, {
shouldDownloadFromNetwork: true
});
if (!assetInfo.localUri) {
throw new Error('无法获取资源的本地路径');
}
// 获取文件扩展名
const fileExtension = getFileExtension(assetInfo.filename || '') ||
(isVideo ? 'mp4' : 'jpg');
// 确保文件名有正确的扩展名
if (!filename.toLowerCase().endsWith(`.${fileExtension}`)) {
const baseName = filename.split('.')[0];
filename = `${baseName}.${fileExtension}`;
}
// 获取文件内容
const response = await fetch(assetInfo.localUri);
const blob = await response.blob();
// 创建文件对象
fileToUpload = new File([blob], filename, { type: mimeType });
console.log('文件准备上传:', {
name: fileToUpload.name,
type: fileToUpload.type,
size: fileToUpload.size
});
}
// 准备元数据
let exifData = {};
if (asset.exif) {
try {
exifData = transformData({
...asset,
exif: {
...asset.exif,
'{MakerApple}': undefined
}
});
} catch (exifError) {
console.warn('处理 EXIF 数据时出错:', exifError);
}
}
// 获取上传 URL
const { upload_url, file_id } = await getUploadUrl(fileToUpload, {
originalUri: asset.uri,
creationTime: asset.creationTime,
mediaType: isVideo ? 'video' : 'image',
isCompressed: false,
...exifData,
GPSVersionID: undefined
});
// 上传文件
await uploadFile(fileToUpload, upload_url);
await confirmUpload(file_id);
console.log('文件上传成功:', {
fileId: file_id,
filename: fileToUpload.name,
type: fileToUpload.type
});
return {
success: true,
file_id,
filename: fileToUpload.name
};
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
console.error('上传原始文件时出错:', {
error: errorMessage,
assetId: asset.id,
filename: asset.filename,
uri: asset.uri
});
throw new Error(`上传失败: ${errorMessage}`);
}
};
// 上传压缩文件(仅图片)
const uploadCompressedFile = async () => {
if (isVideo) return { success: true, file_id: null }; // 视频不压缩
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 };
}
};
// 先上传原始文件
const originalResult = await uploadOriginalFile();
// 如果是图片,再上传压缩文件
let compressedResult = { success: true, file_id: null };
if (!isVideo) {
compressedResult = await uploadCompressedFile();
// 添加素材
addMaterial(originalResult.file_id, compressedResult?.file_id || '');
} else {
// 上传压缩首帧
const thumbnailResult = await uploadVideoThumbnail(asset);
if (thumbnailResult.success) {
addMaterial(originalResult.file_id, thumbnailResult.file_id || '');
}
}
return {
originalSuccess: originalResult.success,
compressedSuccess: compressedResult.success,
fileIds: {
original: originalResult.file_id,
compressed: compressedResult.file_id
}
};
} catch (error: any) {
if (error.message === 'No media library permission') {
throw error;
}
return {
originalSuccess: false,
compressedSuccess: false,
error: error.message
};
}
};
// 手动触发上传
export const triggerManualUpload = async (startDate: Date, endDate: Date) => {
try {
const media = await getMediaByDateRange(startDate, endDate);
if (media.length === 0) {
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
return [];
}
// 分离图片和视频
const photos = media.filter(item => item.mediaType === 'photo');
const videos = media.filter(item => item.mediaType === 'video');
console.log('videos11111111', videos);
const results: any[] = [];
// 处理所有图片(带并发控制)
const processPhoto = async (item: any) => {
try {
const result = await processMediaUpload(item);
results.push({
id: item.id,
...result
});
} catch (error: any) {
results.push({
id: item.id,
originalSuccess: false,
compressedSuccess: false,
error: error.message
});
}
};
// 处理所有视频(带并发控制)
const processVideo = async (item: any) => {
try {
const result = await processMediaUpload(item);
results.push({
id: item.id,
...result
});
} catch (error: any) {
results.push({
id: item.id,
originalSuccess: false,
compressedSuccess: false,
error: error.message
});
}
};
// 并发处理图片和视频
await Promise.all([
...photos.map(photo => limit(() => processPhoto(photo))),
...videos.map(video => limit(() => processVideo(video)))
]);
return results;
} catch (error) {
Alert.alert('错误', '上传过程中出现错误');
throw error;
}
};
export { registerBackgroundUploadTask } from './task';

View File

@ -0,0 +1,89 @@
import * as MediaLibrary from 'expo-media-library';
import { ExtendedAsset } from './types'; // Assuming ExtendedAsset is defined in types.ts
// Helper to fetch assets with pagination and EXIF info
const fetchAssetsWithExif = async (
mediaType: MediaLibrary.MediaType[],
createdAfter?: number,
createdBefore?: number,
descending: boolean = true // Default to descending
): Promise<ExtendedAsset[]> => {
let allAssets: MediaLibrary.Asset[] = [];
let hasNextPage = true;
let after: string | undefined = undefined;
while (hasNextPage) {
const media = await MediaLibrary.getAssetsAsync({
mediaType,
first: 500, // Fetch in batches of 500
sortBy: [MediaLibrary.SortBy.creationTime],
createdAfter,
createdBefore,
after,
descending,
});
allAssets = allAssets.concat(media.assets);
hasNextPage = media.hasNextPage;
after = media.endCursor;
}
// For each asset, get full EXIF information
const assetsWithExif = await Promise.all(
allAssets.map(async (asset) => {
try {
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id);
return {
...asset,
exif: assetInfo.exif || null,
location: assetInfo.location || null
};
} catch (error) {
console.warn(`Failed to get EXIF for asset ${asset.id}:`, error);
return asset;
}
})
);
return assetsWithExif;
};
// 获取指定时间范围内的媒体文件(包含 EXIF 信息),并按日期倒序
export const getMediaByDateRange = async (startDate: Date, endDate: Date): Promise<ExtendedAsset[]> => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Media library permission not granted');
return [];
}
return await fetchAssetsWithExif(
['photo', 'video'],
startDate.getTime(),
endDate.getTime(),
true // Always descending for this function
);
} catch (error) {
console.error('Error in getMediaByDateRange:', error);
return [];
}
};
// 获取所有需要上传的媒体文件(从某个时间点之后,按日期倒序,包含 EXIF 信息)
export const getMediaForUpload = async (lastUploadTimestamp?: number): Promise<ExtendedAsset[]> => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Media library permission not granted');
return [];
}
return await fetchAssetsWithExif(
['photo', 'video'],
lastUploadTimestamp, // Only fetch assets created after this timestamp
undefined, // No end date
true // Always descending
);
} catch (error) {
console.error('Error in getMediaForUpload:', error);
return [];
}
};

View File

@ -0,0 +1,28 @@
### `lib/background-uploader` 模块概要
该模块负责将用户设备中的媒体文件(图片和视频)上传到服务器,并支持后台处理。
**核心功能:**
* **媒体选择:** 从设备的媒体库中获取指定日期范围内的照片和视频(见 `media.ts`),同时获取相关的元数据,如 EXIF 和位置信息。
* **文件处理:**
* 处理 `HEIC` 格式图片转为 `JPEG` 格式(见 `fileProcessor.ts`)。
* 将图片压缩到标准的宽高和质量(见 `fileProcessor.ts`)。
* 对于视频,提取首帧、压缩后作为缩略图上传(见 `fileProcessor.ts`)。
* **API 交互:**
* 与后端服务器通信,获取用于上传文件的安全临时 URL`api.ts`)。
* 上传完成后与后端确认(见 `api.ts`)。
* 文件及其预览/缩略图上传后,将元数据发送到另一个接口以创建“素材”记录(见 `api.ts`)。
* **上传引擎:**
* 主要上传逻辑位于 `index.ts`,负责整体流程的编排:检查权限、处理文件、调用 API。
* 使用并发限制(`p-limit`),防止同时上传过多文件,提高可靠性和性能。
* 同时处理原始高质量文件和压缩版本(或视频缩略图)的上传。
* **后台任务:**
* 可注册后台任务,定期(如每 15 分钟)自动上传过去 24 小时内的新媒体文件(见 `task.ts`)。
* 即使应用不在前台,也能持续上传文件,提升无缝体验。
* **工具与类型定义:**
* 包含用于检查媒体库权限、获取文件扩展名和 MIME 类型的辅助函数(见 `utils.ts`)。
* 定义了自定义的 `ExtendedAsset` 类型,包含 `exif` 数据和标准的 `MediaLibrary.Asset` 属性(见 `types.ts`)。
* 实际上传文件使用 `XMLHttpRequest`,以支持进度追踪,并通过 Promise 封装(见 `uploader.ts`)。
简而言之,这是一个为移动应用设计的、健壮高效的后台上传系统。

View File

@ -0,0 +1,79 @@
import * as BackgroundFetch from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import { isFileUploaded, markFileAsUploaded } from './db';
import { getMediaByDateRange } from './media';
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
// 注册后台任务
export const registerBackgroundUploadTask = async () => {
try {
// 初始化数据库表
// initUploadTable();
// 检查是否已经注册了任务
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
if (isRegistered) {
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK);
}
// 注册后台任务
await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
minimumInterval: 15 * 60, // 15 分钟
});
console.log('Background task registered');
return true;
} catch (error) {
console.error('Error registering background task:', error);
return false;
}
};
// 定义后台任务
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
try {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// 获取最近24小时的媒体文件
const media = await getMediaByDateRange(oneDayAgo, now);
// 过滤已上传文件(以 uri 为唯一标识)
const filesToUpload = [];
for (const file of media) {
const uploaded = await isFileUploaded(file.uri);
if (!uploaded) filesToUpload.push(file);
}
if (filesToUpload.length === 0) {
console.log('No media files to upload');
return BackgroundFetch.BackgroundTaskResult.Success;
}
// 上传未上传文件
let successCount = 0;
for (const file of filesToUpload) {
// 这里假设有 uploadSingleMedia 函数,或直接使用 index.ts 的 processMediaUpload
try {
// 你可以根据实际情况替换为自己的上传逻辑
const { processMediaUpload } = await import('./index');
const result = await processMediaUpload(file);
if (result.originalSuccess) {
markFileAsUploaded(file.uri);
successCount++;
}
} catch (e) {
console.error('Upload failed for', file.uri, e);
}
}
console.log(`Background upload completed. Success: ${successCount}/${filesToUpload.length}`);
return successCount > 0
? BackgroundFetch.BackgroundTaskResult.Success
: BackgroundFetch.BackgroundTaskResult.Failed;
} catch (error) {
console.error('Background task error:', error);
return BackgroundFetch.BackgroundTaskResult.Failed;
}
});

View File

@ -0,0 +1,5 @@
import * as MediaLibrary from 'expo-media-library';
export type ExtendedAsset = MediaLibrary.Asset & {
exif?: Record<string, any>;
};

View File

@ -0,0 +1,30 @@
// 上传文件到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.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
}
};
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);
});
};

View File

@ -0,0 +1,50 @@
import * as MediaLibrary from 'expo-media-library';
// 检查并请求媒体库权限
export const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
try {
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
// 如果已经授权,直接返回
if (status === 'granted' && accessPrivileges === 'all') {
return { hasPermission: true, status };
}
// 如果没有授权,请求权限
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
if (!isGranted) {
console.log('Media library permission not granted or limited access');
}
return {
hasPermission: isGranted,
status: newStatus
};
} catch (error) {
return { hasPermission: false };
}
};
// 获取文件扩展名
export const getFileExtension = (filename: string) => {
return filename.split('.').pop()?.toLowerCase() || '';
};
// 获取 MIME 类型
export const getMimeType = (filename: string, isVideo: boolean) => {
if (!isVideo) return 'image/jpeg';
const ext = getFileExtension(filename);
switch (ext) {
case 'mov':
return 'video/quicktime';
case 'mp4':
return 'video/mp4';
case 'm4v':
return 'video/x-m4v';
default:
return 'video/mp4'; // 默认值
}
};

23
package-lock.json generated
View File

@ -36,6 +36,7 @@
"expo-router": "~5.1.0",
"expo-secure-store": "~14.2.3",
"expo-splash-screen": "~0.30.9",
"expo-sqlite": "~15.2.14",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.9",
@ -5468,6 +5469,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
"license": "MIT"
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -7803,7 +7810,7 @@
},
"node_modules/expo-background-task": {
"version": "0.2.8",
"resolved": "http://192.168.31.115:8081/repository/npm/expo-background-task/-/expo-background-task-0.2.8.tgz",
"resolved": "https://registry.npmjs.org/expo-background-task/-/expo-background-task-0.2.8.tgz",
"integrity": "sha512-dePyskpmyDZeOtbr9vWFh+Nrse0TvF6YitJqnKcd+3P7pDMiDr1V2aT6zHdNOc5iV9vPaDJoH/zdmlarp1uHMQ==",
"license": "MIT",
"dependencies": {
@ -8240,6 +8247,20 @@
"expo": "*"
}
},
"node_modules/expo-sqlite": {
"version": "15.2.14",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-15.2.14.tgz",
"integrity": "sha512-6tWnEE0fcir30/e7eVwjeC7eKdncfVnIgo2JvnKpRndedyiFMXLMyOQWNVGnuhnSrPV2BHvGGjLByS/j5VgH4w==",
"license": "MIT",
"dependencies": {
"await-lock": "^2.2.2"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-status-bar": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.2.3.tgz",

View File

@ -39,6 +39,7 @@
"expo-router": "~5.1.0",
"expo-secure-store": "~14.2.3",
"expo-splash-screen": "~0.30.9",
"expo-sqlite": "~15.2.14",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.9",