Compare commits
6 Commits
c022e7f92f
...
a91f493f02
| Author | SHA1 | Date | |
|---|---|---|---|
| a91f493f02 | |||
| c76252e568 | |||
| a1f1b59143 | |||
| 1c35548e0d | |||
| 57a00c00ca | |||
| 906be26aaa |
11
.eas/workflows/create-production-builds.yml
Normal file
11
.eas/workflows/create-production-builds.yml
Normal 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
|
||||||
58
README.md
58
README.md
@ -1,50 +1,18 @@
|
|||||||
# Welcome to your Expo app 👋
|
## Dependencies
|
||||||
|
### SQLite
|
||||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
- [SQLite](https://sqlite.org/index.html)
|
||||||
|
```shell
|
||||||
## Get started
|
expo install expo-sqlite
|
||||||
|
cp node_modules/wa-sqlite/dist/wa-sqlite.wasm node_modules/expo-sqlite/web/wa-sqlite/
|
||||||
1. Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Start the app
|
## Build
|
||||||
|
### EAS Build
|
||||||
```bash
|
```shell
|
||||||
npx expo start
|
eas build --platform android --profile development
|
||||||
```
|
```
|
||||||
|
|
||||||
In the output, you'll find options to open the app in a
|
### EAS Workflow
|
||||||
|
```shell
|
||||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
npx eas-cli@latest workflow:run create-production-builds.yml
|
||||||
- [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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
23
app.json
23
app.json
@ -15,7 +15,11 @@
|
|||||||
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
|
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
|
||||||
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
|
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
"UIBackgroundModes": ["fetch", "location", "audio"]
|
"UIBackgroundModes": [
|
||||||
|
"fetch",
|
||||||
|
"location",
|
||||||
|
"audio"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "com.memowake.app"
|
"bundleIdentifier": "com.memowake.app"
|
||||||
},
|
},
|
||||||
@ -48,8 +52,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-secure-store", [
|
"expo-secure-store",
|
||||||
"expo-background-fetch",
|
[
|
||||||
|
"expo-background-task",
|
||||||
{
|
{
|
||||||
"minimumInterval": 15
|
"minimumInterval": 15
|
||||||
}
|
}
|
||||||
@ -68,15 +73,6 @@
|
|||||||
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置"
|
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// [
|
|
||||||
// "expo-notifications",
|
|
||||||
// {
|
|
||||||
// "color": "#ffffff",
|
|
||||||
// "defaultChannel": "default",
|
|
||||||
// "enableBackgroundRemoteNotifications": false,
|
|
||||||
// "mode": "client"
|
|
||||||
// }
|
|
||||||
// ],
|
|
||||||
[
|
[
|
||||||
"expo-audio",
|
"expo-audio",
|
||||||
{
|
{
|
||||||
@ -90,7 +86,8 @@
|
|||||||
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
|
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
|
||||||
"isAccessMediaLocationEnabled": true
|
"isAccessMediaLocationEnabled": true
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-sqlite"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Chat from "@/components/ask/chat";
|
|||||||
import AskHello from "@/components/ask/hello";
|
import AskHello from "@/components/ask/hello";
|
||||||
import SendMessage from "@/components/ask/send";
|
import SendMessage from "@/components/ask/send";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
|
import { checkAuthStatus } from '@/lib/auth';
|
||||||
import { fetchApi } from "@/lib/server-api-util";
|
import { fetchApi } from "@/lib/server-api-util";
|
||||||
import { Message } from "@/types/ask";
|
import { Message } from "@/types/ask";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
@ -12,6 +13,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
|
|
||||||
export default function AskScreen() {
|
export default function AskScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus(router);
|
||||||
|
router.replace('/login');
|
||||||
|
}, []);
|
||||||
// 在组件内部添加 ref
|
// 在组件内部添加 ref
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
// 用于控制是否显示问候页面
|
// 用于控制是否显示问候页面
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import IP from '@/assets/icons/svg/ip.svg';
|
import { checkAuthStatus } from '@/lib/auth';
|
||||||
import { registerBackgroundUploadTask, triggerManualUpload } from '@/components/file-upload/backgroundUploader';
|
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Platform, Text, TouchableOpacity, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import MemoList from './memo-list';
|
import MemoList from './memo-list';
|
||||||
|
|
||||||
@ -17,37 +16,24 @@ export default function HomeScreen() {
|
|||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuthStatus = async () => {
|
const doCheck = async () => {
|
||||||
try {
|
setIsLoading(true);
|
||||||
let token;
|
const authed = await checkAuthStatus(router, async () => {
|
||||||
if (Platform.OS === 'web') {
|
setIsLoggedIn(true);
|
||||||
token = localStorage.getItem('token') || '';
|
// 已登录,请求必要的权限及上传
|
||||||
} else {
|
|
||||||
token = await SecureStore.getItemAsync('token') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const loggedIn = !!token;
|
|
||||||
setIsLoggedIn(loggedIn);
|
|
||||||
|
|
||||||
if (loggedIn) {
|
|
||||||
// 已登录,请求必要的权限
|
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||||
if (status === 'granted') {
|
if (status === 'granted') {
|
||||||
await registerBackgroundUploadTask();
|
await registerBackgroundUploadTask();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
await triggerManualUpload(oneDayAgo, now);
|
// await triggerManualUpload(oneDayAgo, now);
|
||||||
}
|
}
|
||||||
router.replace('/ask');
|
router.replace('/ask');
|
||||||
}
|
});
|
||||||
} catch (error) {
|
if (!authed) setIsLoggedIn(false);
|
||||||
console.error('检查登录状态出错:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
doCheck();
|
||||||
checkAuthStatus();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -60,49 +46,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
{
|
{isLoggedIn && <MemoList />}
|
||||||
isLoggedIn ? <MemoList /> :
|
|
||||||
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] " style={{ paddingTop: insets.top + 48 }}>
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<View className="items-start mb-10 w-full px-5">
|
|
||||||
<Text className="text-white text-3xl font-bold mb-3 text-left">
|
|
||||||
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
|
|
||||||
{"\n"}
|
|
||||||
{t('auth.welcomeAwaken.your', { ns: 'login' })}
|
|
||||||
{"\n"}
|
|
||||||
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-white/85 text-base text-left">
|
|
||||||
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Memo 形象区域 */}
|
|
||||||
<View className="items-center">
|
|
||||||
<IP />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 介绍文本 */}
|
|
||||||
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
|
|
||||||
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
|
|
||||||
{"\n"}
|
|
||||||
{t('auth.welcomeAwaken.back', { ns: 'login' })}
|
|
||||||
</Text>
|
|
||||||
{/* <MessagePush /> */}
|
|
||||||
{/* 唤醒按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
|
|
||||||
onPress={async () => {
|
|
||||||
router.push('/login')
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text className="text-[#4C320C] font-bold text-lg">
|
|
||||||
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -53,7 +53,7 @@ const LoginScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError('123')
|
// setError('123')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -12,15 +12,29 @@ import ResourceComponent from '@/components/owner/resource';
|
|||||||
import SettingModal from '@/components/owner/setting';
|
import SettingModal from '@/components/owner/setting';
|
||||||
import UserInfo from '@/components/owner/userName';
|
import UserInfo from '@/components/owner/userName';
|
||||||
import { formatDuration } from '@/components/utils/time';
|
import { formatDuration } from '@/components/utils/time';
|
||||||
|
import { checkAuthStatus } from '@/lib/auth';
|
||||||
import { fetchApi } from '@/lib/server-api-util';
|
import { fetchApi } from '@/lib/server-api-util';
|
||||||
import { CountData, UserInfoDetails } from '@/types/user';
|
import { CountData, UserInfoDetails } from '@/types/user';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function OwnerPage() {
|
export default function OwnerPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
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);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
|||||||
@ -7,8 +7,11 @@ import { FileUploadItem } from '@/types/upload';
|
|||||||
import { User } from '@/types/user';
|
import { User } from '@/types/user';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
|
import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { checkAuthStatus } from '@/lib/auth';
|
||||||
export type Steps = "userName" | "look" | "choice" | "done";
|
export type Steps = "userName" | "look" | "choice" | "done";
|
||||||
export default function UserMessage() {
|
export default function UserMessage() {
|
||||||
|
const router = useRouter();
|
||||||
// 步骤
|
// 步骤
|
||||||
const [steps, setSteps] = useState<Steps>("userName")
|
const [steps, setSteps] = useState<Steps>("userName")
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
@ -18,6 +21,10 @@ export default function UserMessage() {
|
|||||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||||
const statusBarHeight = StatusBar?.currentHeight ?? 0;
|
const statusBarHeight = StatusBar?.currentHeight ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus(router);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
const getUserInfo = async () => {
|
const getUserInfo = async () => {
|
||||||
const res = await fetchApi<User>("/iam/user-info");
|
const res = await fetchApi<User>("/iam/user-info");
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
|
||||||
|
import { triggerManualUpload } from '@/lib/background-uploader/manual';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { registerBackgroundUploadTask, triggerManualUpload } from './backgroundUploader';
|
|
||||||
|
|
||||||
export default function AutoUploadScreen() {
|
export default function AutoUploadScreen() {
|
||||||
const [timeRange, setTimeRange] = useState('day');
|
const [timeRange, setTimeRange] = useState('day');
|
||||||
@ -13,7 +14,7 @@ export default function AutoUploadScreen() {
|
|||||||
const registered = await registerBackgroundUploadTask();
|
const registered = await registerBackgroundUploadTask();
|
||||||
setIsRegistered(registered);
|
setIsRegistered(registered);
|
||||||
};
|
};
|
||||||
|
console.log("register background upload task");
|
||||||
registerTask();
|
registerTask();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,608 +0,0 @@
|
|||||||
import { fetchApi } from '@/lib/server-api-util';
|
|
||||||
import * as BackgroundFetch from 'expo-background-fetch';
|
|
||||||
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.BackgroundFetchResult.NoData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理媒体文件上传
|
|
||||||
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.BackgroundFetchResult.NewData
|
|
||||||
: BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Background task error:', error);
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,6 +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, ExifData, FileUploadItem, ImagesuploaderProps, UploadResult, UploadTask, defaultExifData } from '@/lib/background-uploader/types';
|
||||||
import * as ImageManipulator from 'expo-image-manipulator';
|
import { uploadFileWithProgress } from '@/lib/background-uploader/uploader';
|
||||||
|
import { compressImage } from '@/lib/image-process/imageCompress';
|
||||||
|
import { createVideoThumbnailFile } from '@/lib/video-process/videoThumbnail';
|
||||||
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';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
@ -8,46 +10,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,
|
||||||
@ -61,7 +23,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
|||||||
fileType = ['images'],
|
fileType = ['images'],
|
||||||
}) => {
|
}) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [files, setFiles] = useState<FileUploadItem[]>([]);
|
||||||
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
|
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
|
||||||
|
|
||||||
// 请求权限
|
// 请求权限
|
||||||
@ -81,191 +43,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);
|
||||||
@ -285,12 +62,14 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
|||||||
// 创建上传项
|
// 创建上传项
|
||||||
const newFileItem: FileUploadItem = {
|
const newFileItem: FileUploadItem = {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
|
uri: asset.uri,
|
||||||
|
previewUrl: asset.uri, // 使用 asset.uri 作为初始预览
|
||||||
name: asset.fileName || 'file',
|
name: asset.fileName || 'file',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: 'uploading' as const,
|
status: 'uploading',
|
||||||
error: null,
|
error: undefined,
|
||||||
type: isVideo ? 'video' : 'image',
|
type: isVideo ? 'video' : 'image',
|
||||||
thumbnail: null,
|
thumbnail: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setUploadQueue(prev => [...prev, newFileItem]);
|
setUploadQueue(prev => [...prev, newFileItem]);
|
||||||
@ -316,28 +95,14 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
|||||||
{ type: 'video/mp4' }
|
{ type: 'video/mp4' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 生成视频缩略图
|
// 使用复用函数生成视频缩略图
|
||||||
const thumbnailResult = await ImageManipulator.manipulateAsync(
|
thumbnailFile = await createVideoThumbnailFile(asset, 300);
|
||||||
asset.uri,
|
|
||||||
[{ resize: { width: 300 } }],
|
|
||||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
|
|
||||||
);
|
|
||||||
|
|
||||||
thumbnailFile = new File(
|
|
||||||
[await (await fetch(thumbnailResult.uri)).blob()],
|
|
||||||
`thumb_${Date.now()}.jpg`,
|
|
||||||
{ 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 +123,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 +156,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;
|
||||||
@ -423,7 +181,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
|||||||
item.id === fileId
|
item.id === fileId
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
status: 'done' as const,
|
status: 'success' as const,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
thumbnail: uploadResults.thumbnail
|
thumbnail: uploadResults.thumbnail
|
||||||
}
|
}
|
||||||
@ -435,7 +193,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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,85 +50,67 @@ const MediaStatsScreen = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 设置时间范围
|
// 2. 设置时间范围,直接使用Date对象
|
||||||
const dateRange = getDateRange(timeRange);
|
const dateRange = getDateRange(timeRange);
|
||||||
const createdAfter = dateRange ? Math.floor(dateRange.start.getTime() / 1000) : 0;
|
|
||||||
const endTime = dateRange?.end ? Math.floor(dateRange.end.getTime() / 1000) : undefined;
|
|
||||||
|
|
||||||
// 3. 分页获取媒体资源,每次10条
|
// 3. 分页获取媒体资源
|
||||||
let hasNextPage = true;
|
|
||||||
let after = undefined;
|
|
||||||
let allAssets: MediaLibrary.Asset[] = [];
|
let allAssets: MediaLibrary.Asset[] = [];
|
||||||
const pageSize = 10; // 每次获取10条
|
let hasNextPage = true;
|
||||||
|
let after: MediaLibrary.AssetRef | undefined = undefined;
|
||||||
|
const pageSize = 100; // 增加每次获取的数量以提高效率
|
||||||
|
|
||||||
while (hasNextPage) {
|
while (hasNextPage) {
|
||||||
const media = await MediaLibrary.getAssetsAsync({
|
const media = await MediaLibrary.getAssetsAsync({
|
||||||
first: pageSize,
|
first: pageSize,
|
||||||
after,
|
after,
|
||||||
mediaType: ['photo', 'video', 'audio', 'unknown'],
|
sortBy: ['creationTime'],
|
||||||
sortBy: 'creationTime', // 按创建时间降序,最新的在前面
|
mediaType: ['photo', 'video', 'audio'],
|
||||||
createdAfter: Date.now() - 24 * 30 * 12 * 60 * 60 * 1000, // 时间戳(毫秒)
|
createdAfter: dateRange?.start,
|
||||||
createdBefore: Date.now(), // 时间戳(毫秒)
|
createdBefore: dateRange?.end,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果没有数据,直接退出
|
if (media.assets.length > 0) {
|
||||||
if (media.assets.length === 0) {
|
allAssets.push(...media.assets);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查每条记录是否在时间范围内
|
hasNextPage = media.hasNextPage;
|
||||||
for (const asset of media.assets) {
|
|
||||||
const assetTime = asset.creationTime ? new Date(asset.creationTime).getTime() / 1000 : 0;
|
|
||||||
|
|
||||||
// 如果设置了结束时间,并且当前记录的时间早于开始时间,则停止
|
|
||||||
if (endTime && assetTime > endTime) {
|
|
||||||
continue; // 跳过这条记录
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果设置了开始时间,并且当前记录的时间早于开始时间,则停止
|
|
||||||
if (createdAfter && assetTime < createdAfter) {
|
|
||||||
hasNextPage = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
allAssets.push(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新游标和是否还有下一页
|
|
||||||
hasNextPage = media.hasNextPage && media.assets.length === pageSize;
|
|
||||||
after = media.endCursor;
|
after = media.endCursor;
|
||||||
|
|
||||||
// 如果没有更多数据或者已经获取了足够的数据
|
// 可选:增加一个最大获取上限,防止无限循环
|
||||||
if (!hasNextPage || allAssets.length >= 1000) {
|
if (allAssets.length > 2000) {
|
||||||
|
console.warn('已达到2000个媒体文件的上限');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`总共获取到 ${allAssets.length} 个媒体文件`);
|
console.log(`总共获取到 ${allAssets.length} 个媒体文件`);
|
||||||
|
|
||||||
// 4. 统计不同类型媒体的数量
|
// 4. 使用 reduce 进行统计,更高效
|
||||||
const stats: MediaStats = {
|
const stats = allAssets.reduce<MediaStats>((acc, asset) => {
|
||||||
total: allAssets.length,
|
acc.total++;
|
||||||
photos: 0,
|
switch (asset.mediaType) {
|
||||||
videos: 0,
|
case 'photo':
|
||||||
audios: 0,
|
acc.photos++;
|
||||||
others: 0,
|
break;
|
||||||
byMonth: {},
|
case 'video':
|
||||||
};
|
acc.videos++;
|
||||||
|
break;
|
||||||
|
case 'audio':
|
||||||
|
acc.audios++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
acc.others++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
allAssets.forEach(asset => {
|
|
||||||
// 统计类型
|
|
||||||
if (asset.mediaType === 'photo') stats.photos++;
|
|
||||||
else if (asset.mediaType === 'video') stats.videos++;
|
|
||||||
else if (asset.mediaType === 'audio') stats.audios++;
|
|
||||||
else stats.others++;
|
|
||||||
|
|
||||||
// 按月份统计
|
|
||||||
if (asset.creationTime) {
|
if (asset.creationTime) {
|
||||||
const date = new Date(asset.creationTime);
|
const date = new Date(asset.creationTime);
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
stats.byMonth[monthKey] = (stats.byMonth[monthKey] || 0) + 1;
|
acc.byMonth[monthKey] = (acc.byMonth[monthKey] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
total: 0, photos: 0, videos: 0, audios: 0, others: 0, byMonth: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
setStats(stats);
|
setStats(stats);
|
||||||
|
|||||||
@ -186,6 +186,7 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
|
|||||||
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
|
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
|
console.log("压缩后的文件", file);
|
||||||
|
|
||||||
return { file, uri: manipResult.uri };
|
return { file, uri: manipResult.uri };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 转换为 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' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return { uri: manipResult.uri, file };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating video thumbnail:', error);
|
|
||||||
throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* 从视频文件中提取第一帧并返回为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.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上下文');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绘制视频帧到canvas
|
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// 将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 });
|
|
||||||
|
|
||||||
// 创建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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
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.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');
|
|
||||||
|
|
||||||
// 设置为只加载元数据,不加载整个视频
|
|
||||||
video.preload = 'metadata';
|
|
||||||
|
|
||||||
// 处理加载成功
|
|
||||||
video.onloadedmetadata = () => {
|
|
||||||
// 释放URL对象
|
|
||||||
URL.revokeObjectURL(video.src);
|
|
||||||
// 返回视频时长(秒)
|
|
||||||
resolve(video.duration);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理加载错误
|
|
||||||
video.onerror = () => {
|
|
||||||
URL.revokeObjectURL(video.src);
|
|
||||||
reject(new Error('无法加载视频'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理网络错误
|
|
||||||
video.onabort = () => {
|
|
||||||
URL.revokeObjectURL(video.src);
|
|
||||||
reject(new Error('视频加载被中止'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置视频源
|
|
||||||
video.src = videoUrl;
|
|
||||||
|
|
||||||
// 添加跨域属性(如果需要)
|
|
||||||
video.setAttribute('crossOrigin', 'anonymous');
|
|
||||||
|
|
||||||
// 开始加载元数据
|
|
||||||
video.load();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -43,7 +43,7 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
|
|||||||
const res = await fetchApi<User>('/iam/login/password-login', {
|
const res = await fetchApi<User>('/iam/login/password-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
}, true, false);
|
||||||
login({ ...res, email: res?.account }, res.access_token || '');
|
login({ ...res, email: res?.account }, res.access_token || '');
|
||||||
router.replace('/user-message');
|
router.replace('/user-message');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
11
jest.config.ts
Normal file
11
jest.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const { createDefaultPreset } = require("ts-jest");
|
||||||
|
|
||||||
|
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||||
|
|
||||||
|
/** @type {import("jest").Config} **/
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: "node",
|
||||||
|
transform: {
|
||||||
|
...tsJestTransformCfg,
|
||||||
|
},
|
||||||
|
};
|
||||||
39
lib/auth.ts
Normal file
39
lib/auth.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
if (!loggedIn) {
|
||||||
|
router.replace('/login');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (onAuthed) {
|
||||||
|
await onAuthed();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
41
lib/background-uploader/api.ts
Normal file
41
lib/background-uploader/api.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { fetchApi } from '@/lib/server-api-util';
|
||||||
|
import { ConfirmUpload, UploadUrlResponse } from './types';
|
||||||
|
|
||||||
|
// 获取上传URL
|
||||||
|
export 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: file.type.startsWith('video/') ? 'video' : 'image',
|
||||||
|
isCompressed: metadata.isCompressed || 'false',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await fetchApi<UploadUrlResponse>('/file/generate-upload-url', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认上传
|
||||||
|
// 确认上传
|
||||||
|
export const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => {
|
||||||
|
return await fetchApi<ConfirmUpload>('/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
|
||||||
|
}])
|
||||||
|
});
|
||||||
|
}
|
||||||
73
lib/background-uploader/automatic.ts
Normal file
73
lib/background-uploader/automatic.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import * as BackgroundTask from 'expo-background-task';
|
||||||
|
import * as TaskManager from 'expo-task-manager';
|
||||||
|
import { initUploadTable } from '../db';
|
||||||
|
import { getMediaByDateRange } from './media';
|
||||||
|
import { processAndUploadMedia } from './uploader';
|
||||||
|
|
||||||
|
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
|
||||||
|
|
||||||
|
// 注册后台任务
|
||||||
|
export const registerBackgroundUploadTask = async () => {
|
||||||
|
try {
|
||||||
|
// 初始化数据库表
|
||||||
|
await initUploadTable();
|
||||||
|
|
||||||
|
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
|
||||||
|
if (isRegistered) {
|
||||||
|
console.log('Background task already registered.');
|
||||||
|
} else {
|
||||||
|
await BackgroundTask.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
|
||||||
|
minimumInterval: 15 * 60, // 15 分钟
|
||||||
|
});
|
||||||
|
console.log('Background task registered successfully.');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering background task:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义后台任务
|
||||||
|
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
|
||||||
|
try {
|
||||||
|
console.log('Running background upload task...');
|
||||||
|
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 new media files to upload in the last 24 hours.');
|
||||||
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${media.length} media files to potentially upload.`);
|
||||||
|
|
||||||
|
// 串行上传文件
|
||||||
|
let successCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const file of media) {
|
||||||
|
try {
|
||||||
|
const result = await processAndUploadMedia(file);
|
||||||
|
if (result === null) {
|
||||||
|
// 文件已上传,被跳过
|
||||||
|
skippedCount++;
|
||||||
|
} else if (result.originalSuccess) {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Upload failed for', file.uri, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Background upload task finished. Successful: ${successCount}, Skipped: ${skippedCount}, Total: ${media.length}`);
|
||||||
|
|
||||||
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background task error:', error);
|
||||||
|
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||||
|
}
|
||||||
|
});
|
||||||
41
lib/background-uploader/manual.ts
Normal file
41
lib/background-uploader/manual.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
import { getMediaByDateRange } from './media';
|
||||||
|
import { processAndUploadMedia } from './uploader';
|
||||||
|
import { ExtendedAsset } from './types';
|
||||||
|
|
||||||
|
// 设置最大并发数
|
||||||
|
const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件
|
||||||
|
const limit = pLimit(CONCURRENCY_LIMIT);
|
||||||
|
|
||||||
|
// 手动触发上传
|
||||||
|
export const triggerManualUpload = async (startDate: Date, endDate: Date) => {
|
||||||
|
try {
|
||||||
|
const media = await getMediaByDateRange(startDate, endDate);
|
||||||
|
if (media.length === 0) {
|
||||||
|
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadPromises = media.map((asset: ExtendedAsset) =>
|
||||||
|
limit(() => processAndUploadMedia(asset))
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
|
// 过滤掉因为已上传而返回 null 的结果
|
||||||
|
const finalResults = results.filter(result => result !== null);
|
||||||
|
|
||||||
|
console.log('Manual upload completed.', {
|
||||||
|
total: media.length,
|
||||||
|
uploaded: finalResults.length,
|
||||||
|
skipped: media.length - finalResults.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalResults;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('手动上传过程中出现错误:', error);
|
||||||
|
Alert.alert('错误', '上传过程中出现错误');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
89
lib/background-uploader/media.ts
Normal file
89
lib/background-uploader/media.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
};
|
||||||
28
lib/background-uploader/summary.md
Normal file
28
lib/background-uploader/summary.md
Normal 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`)。
|
||||||
|
|
||||||
|
简而言之,这是一个为移动应用设计的、健壮高效的后台上传系统。
|
||||||
134
lib/background-uploader/types.ts
Normal file
134
lib/background-uploader/types.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件元数据信息
|
||||||
|
interface FileSize {
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileMetadata {
|
||||||
|
originalName: string;
|
||||||
|
type: string;
|
||||||
|
isCompressed: string;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端返回的文件信息
|
||||||
|
interface FileInfo {
|
||||||
|
file_id: number;
|
||||||
|
name: string;
|
||||||
|
size: FileSize;
|
||||||
|
content_type: string; // 这里与 ConfirmUpload 的 content_type 定义不同,需要注意
|
||||||
|
upload_time: string;
|
||||||
|
storage_medium: string;
|
||||||
|
file_path: string; // 这里与 ConfirmUpload 的 file_path 定义不同
|
||||||
|
uploader_id: number;
|
||||||
|
upload_status: string;
|
||||||
|
deletion_status: string;
|
||||||
|
metadata: FileMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传队列项 - 作为唯一的类型定义
|
||||||
|
// 定义 EXIF 数据类型
|
||||||
|
export type ExifData = {
|
||||||
|
GPSLatitude?: number | undefined;
|
||||||
|
GPSLongitude?: number | undefined;
|
||||||
|
GPSAltitude?: number | undefined;
|
||||||
|
DateTimeOriginal?: string | undefined;
|
||||||
|
Make?: string | undefined;
|
||||||
|
Model?: string | undefined;
|
||||||
|
ExposureTime?: number | undefined;
|
||||||
|
FNumber?: number | undefined;
|
||||||
|
ISOSpeedRatings?: number | undefined;
|
||||||
|
FocalLength?: number | undefined;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认的 EXIF 数据结构
|
||||||
|
export const defaultExifData: ExifData = {
|
||||||
|
GPSLatitude: undefined,
|
||||||
|
GPSLongitude: undefined,
|
||||||
|
GPSAltitude: undefined,
|
||||||
|
DateTimeOriginal: undefined,
|
||||||
|
Make: undefined,
|
||||||
|
Model: undefined,
|
||||||
|
ExposureTime: undefined,
|
||||||
|
FNumber: undefined,
|
||||||
|
ISOSpeedRatings: undefined,
|
||||||
|
FocalLength: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 压缩图片可配置参数
|
||||||
|
export interface ImagesuploaderProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: import('react-native').StyleProp<import('react-native').ViewStyle>;
|
||||||
|
onPickImage?: (file: File, exifData: ExifData) => void;
|
||||||
|
compressQuality?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
preserveExif?: boolean;
|
||||||
|
uploadOriginal?: boolean;
|
||||||
|
onUploadComplete?: (result: FileUploadItem[]) => void;
|
||||||
|
onProgress?: (progress: any) => void; // TODO: Define a proper type for progress
|
||||||
|
multipleChoice?: boolean;
|
||||||
|
fileType?: any[]; // TODO: Use MediaType from expo-image-picker
|
||||||
|
showPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadItem {
|
||||||
|
id: string;
|
||||||
|
uri: string; // 用于本地展示的资源URI
|
||||||
|
name: string;
|
||||||
|
progress: number;
|
||||||
|
status: 'pending' | 'uploading' | 'success' | 'error'; // 统一状态
|
||||||
|
error?: string | null;
|
||||||
|
previewUrl: string; // 预览URL
|
||||||
|
file?: File;
|
||||||
|
type: 'image' | 'video';
|
||||||
|
thumbnail?: string; // 缩略图URL
|
||||||
|
thumbnailFile?: File; // 缩略图文件对象
|
||||||
|
originalFile?: FileInfo // 上传后返回的文件信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认上传返回
|
||||||
|
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;
|
||||||
|
};
|
||||||
221
lib/background-uploader/uploader.ts
Normal file
221
lib/background-uploader/uploader.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { transformData } from '@/components/utils/objectFlat';
|
||||||
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
|
import { convertHeicToJpeg } from '../image-process/heicConvert';
|
||||||
|
import { compressImage } from '../image-process/imageCompress';
|
||||||
|
import { uploadVideoThumbnail } from '../video-process/videoThumbnail';
|
||||||
|
import { addMaterial, confirmUpload, getUploadUrl } from './api';
|
||||||
|
import { ExtendedAsset } from './types';
|
||||||
|
import { checkMediaLibraryPermission, getFileExtension, getMimeType } from './utils';
|
||||||
|
import { isFileUploaded, markFileAsUploaded } from '../db';
|
||||||
|
|
||||||
|
// 基础文件上传实现
|
||||||
|
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 processAndUploadMedia = async (asset: ExtendedAsset) => {
|
||||||
|
try {
|
||||||
|
// 1. 文件去重检查
|
||||||
|
const uploaded = await isFileUploaded(asset.uri);
|
||||||
|
if (uploaded) {
|
||||||
|
console.log('File already uploaded, skipping:', asset.uri);
|
||||||
|
return null; // 返回 null 表示已上传,调用方可以据此过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查权限
|
||||||
|
const { hasPermission } = await checkMediaLibraryPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw new Error('No media library permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = asset.mediaType === 'video';
|
||||||
|
|
||||||
|
// 3. 上传原始文件
|
||||||
|
const uploadOriginalFile = async () => {
|
||||||
|
let fileToUpload: File;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let exifData = {};
|
||||||
|
if (asset.exif) {
|
||||||
|
try {
|
||||||
|
exifData = transformData({
|
||||||
|
...asset,
|
||||||
|
exif: { ...asset.exif, '{MakerApple}': undefined }
|
||||||
|
});
|
||||||
|
} catch (exifError) {
|
||||||
|
console.warn('处理 EXIF 数据时出错:', exifError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return { success: true, file_id, filename: fileToUpload.name };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 上传压缩文件(仅图片)
|
||||||
|
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) {
|
||||||
|
console.error('Error uploading compressed file:', error);
|
||||||
|
return { success: false, error, file_id: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行上传
|
||||||
|
const originalResult = await uploadOriginalFile();
|
||||||
|
|
||||||
|
if (!originalResult.success) {
|
||||||
|
throw new Error('Original file upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let compressedResult: { success: boolean; file_id?: string | null; error?: any } = { success: true, file_id: null };
|
||||||
|
if (!isVideo) {
|
||||||
|
compressedResult = await uploadCompressedFile();
|
||||||
|
if (originalResult.file_id && compressedResult.file_id) {
|
||||||
|
addMaterial(originalResult.file_id, compressedResult.file_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const thumbnailResult = await uploadVideoThumbnail(asset);
|
||||||
|
if (thumbnailResult.success && originalResult.file_id && thumbnailResult.file_id) {
|
||||||
|
addMaterial(originalResult.file_id, thumbnailResult.file_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 标记为已上传
|
||||||
|
await markFileAsUploaded(asset.uri);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
originalSuccess: originalResult.success,
|
||||||
|
compressedSuccess: compressedResult.success,
|
||||||
|
fileIds: {
|
||||||
|
original: originalResult.file_id,
|
||||||
|
compressed: compressedResult.file_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error processing media upload for asset:', asset.uri, error);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
originalSuccess: false,
|
||||||
|
compressedSuccess: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 {
|
||||||
|
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(new Error('Network error during upload'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 超时处理
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
xhr.abort();
|
||||||
|
reject(new Error('上传超时,请检查网络连接'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
50
lib/background-uploader/utils.ts
Normal file
50
lib/background-uploader/utils.ts
Normal 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'; // 默认值
|
||||||
|
}
|
||||||
|
};
|
||||||
30
lib/db.ts
Normal file
30
lib/db.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as SQLite from 'expo-sqlite';
|
||||||
|
|
||||||
|
const db = SQLite.openDatabaseSync('upload_status.db');
|
||||||
|
|
||||||
|
// 初始化表
|
||||||
|
export function initUploadTable() {
|
||||||
|
console.log('Initializing upload table...');
|
||||||
|
db.execSync(`
|
||||||
|
CREATE TABLE IF NOT EXISTS uploaded_files (
|
||||||
|
uri TEXT PRIMARY KEY NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('Upload table initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否已上传 (使用同步API,但保持接口为Promise以减少外部重构)
|
||||||
|
export async function isFileUploaded(uri: string): Promise<boolean> {
|
||||||
|
console.log('Checking if file is uploaded:', uri)
|
||||||
|
const result = db.getFirstSync<{ uri: string }>(
|
||||||
|
'SELECT uri FROM uploaded_files WHERE uri = ?;',
|
||||||
|
uri
|
||||||
|
);
|
||||||
|
console.log('File uploaded result:', result)
|
||||||
|
return !!result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录文件已上传
|
||||||
|
export function markFileAsUploaded(uri: string) {
|
||||||
|
db.runSync('INSERT OR IGNORE INTO uploaded_files (uri) VALUES (?);', uri);
|
||||||
|
}
|
||||||
47
lib/image-process/heicConvert.ts
Normal file
47
lib/image-process/heicConvert.ts
Normal 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'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
56
lib/image-process/imageCompress.ts
Normal file
56
lib/image-process/imageCompress.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
51
lib/video-process/videoThumbnail.ts
Normal file
51
lib/video-process/videoThumbnail.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
|
import { confirmUpload, getUploadUrl } from '../background-uploader/api';
|
||||||
|
import { ExtendedAsset } from '../background-uploader/types';
|
||||||
|
import { uploadFile } from '../background-uploader/uploader';
|
||||||
|
import { compressImage } from '../image-process/imageCompress';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 从视频资源创建缩略图文件
|
||||||
|
* @param asset 媒体资源
|
||||||
|
* @param width 缩略图宽度,默认为 300
|
||||||
|
* @returns 返回一个包含缩略图的 File 对象
|
||||||
|
*/
|
||||||
|
export const createVideoThumbnailFile = async (asset: { uri: string }, width: number = 300): Promise<File> => {
|
||||||
|
const thumbnailResult = await ImageManipulator.manipulateAsync(
|
||||||
|
asset.uri,
|
||||||
|
[{ resize: { width } }],
|
||||||
|
{ compress: 0.7, format: ImageManipulator.SaveFormat.WEBP }
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(thumbnailResult.uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
return new File([blob], `thumb_${Date.now()}.webp`, { type: 'image/webp' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取视频的首帧进行压缩并上传
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
5497
package-lock.json
generated
5497
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
@ -21,7 +22,7 @@
|
|||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
"expo": "~53.0.12",
|
"expo": "~53.0.12",
|
||||||
"expo-audio": "~0.4.7",
|
"expo-audio": "~0.4.7",
|
||||||
"expo-background-fetch": "^13.1.6",
|
"expo-background-task": "^0.2.8",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
"expo-constants": "~17.1.6",
|
"expo-constants": "~17.1.6",
|
||||||
"expo-dev-client": "~5.2.1",
|
"expo-dev-client": "~5.2.1",
|
||||||
@ -40,6 +41,7 @@
|
|||||||
"expo-router": "~5.1.0",
|
"expo-router": "~5.1.0",
|
||||||
"expo-secure-store": "~14.2.3",
|
"expo-secure-store": "~14.2.3",
|
||||||
"expo-splash-screen": "~0.30.9",
|
"expo-splash-screen": "~0.30.9",
|
||||||
|
"expo-sqlite": "~15.2.14",
|
||||||
"expo-status-bar": "~2.2.3",
|
"expo-status-bar": "~2.2.3",
|
||||||
"expo-symbols": "~0.4.5",
|
"expo-symbols": "~0.4.5",
|
||||||
"expo-system-ui": "~5.0.9",
|
"expo-system-ui": "~5.0.9",
|
||||||
@ -73,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"
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
210
types/upload.ts
210
types/upload.ts
@ -1,210 +0,0 @@
|
|||||||
import { MediaType } from "expo-image-picker";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { StyleProp, ViewStyle } from "react-native";
|
|
||||||
|
|
||||||
export interface FileStatus {
|
|
||||||
file: File;
|
|
||||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
||||||
progress: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
export interface MaterialFile {
|
|
||||||
id: string;
|
|
||||||
file_name: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OutputVideoFile {
|
|
||||||
id: string;
|
|
||||||
file_name: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ManualTask {
|
|
||||||
task_id: string;
|
|
||||||
user_id: string;
|
|
||||||
status: 'Created' | 'Processing' | 'Completed' | 'Failed';
|
|
||||||
created_at: string;
|
|
||||||
started_at: string;
|
|
||||||
completed_at: string;
|
|
||||||
failure_reason: string | null;
|
|
||||||
template_id: number;
|
|
||||||
source_files: MaterialFile[];
|
|
||||||
output_video_file?: OutputVideoFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Size {
|
|
||||||
value: number;
|
|
||||||
unit: 'B' | 'KB' | 'MB' | 'GB' | 'TB';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContentType {
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilePath {
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Metadata {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UploadStatus = 'Pending' | 'Uploading' | 'Completed' | 'Failed';
|
|
||||||
export type DeletionStatus = 'Active' | 'PendingDeletion' | 'Deleted';
|
|
||||||
|
|
||||||
export interface ConfirmUpload {
|
|
||||||
file_id: string;
|
|
||||||
upload_url: string
|
|
||||||
name: string;
|
|
||||||
size: Size;
|
|
||||||
content_type: ContentType;
|
|
||||||
upload_time: string; // ISO date string
|
|
||||||
storage_medium: string;
|
|
||||||
file_path: FilePath;
|
|
||||||
uploader_id: number;
|
|
||||||
upload_status: UploadStatus;
|
|
||||||
deletion_status: DeletionStatus;
|
|
||||||
metadata: Metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义 EXIF 数据类型
|
|
||||||
export type ExifData = {
|
|
||||||
GPSLatitude?: number | undefined;
|
|
||||||
GPSLongitude?: number | undefined;
|
|
||||||
GPSAltitude?: number | undefined;
|
|
||||||
DateTimeOriginal?: string | undefined;
|
|
||||||
Make?: string | undefined;
|
|
||||||
Model?: string | undefined;
|
|
||||||
ExposureTime?: number | undefined;
|
|
||||||
FNumber?: number | undefined;
|
|
||||||
ISOSpeedRatings?: number | undefined;
|
|
||||||
FocalLength?: number | undefined;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 默认的 EXIF 数据结构
|
|
||||||
export const defaultExifData: ExifData = {
|
|
||||||
GPSLatitude: undefined,
|
|
||||||
GPSLongitude: undefined,
|
|
||||||
GPSAltitude: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
Make: undefined,
|
|
||||||
Model: undefined,
|
|
||||||
ExposureTime: undefined,
|
|
||||||
FNumber: undefined,
|
|
||||||
ISOSpeedRatings: undefined,
|
|
||||||
FocalLength: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 压缩图片可配置参数
|
|
||||||
export interface ImagesuploaderProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
onPickImage?: (file: File, exifData: ExifData) => void;
|
|
||||||
/** 压缩质量,0-1 之间的数字,默认为 0.8 */
|
|
||||||
compressQuality?: number;
|
|
||||||
/** 最大宽度,图片会被等比例缩放 */
|
|
||||||
maxWidth?: number;
|
|
||||||
/** 最大高度,图片会被等比例缩放 */
|
|
||||||
maxHeight?: number;
|
|
||||||
/** 是否保留 EXIF 数据,默认为 true */
|
|
||||||
preserveExif?: boolean;
|
|
||||||
/** 是否上传原图,默认为 false */
|
|
||||||
uploadOriginal?: boolean;
|
|
||||||
/** 上传完成回调 */
|
|
||||||
onUploadComplete?: UploadCompleteCallback;
|
|
||||||
/** 进度 */
|
|
||||||
onProgress?: (progress: FileStatus) => void;
|
|
||||||
/** 多选单选 默认单选*/
|
|
||||||
multipleChoice?: boolean;
|
|
||||||
/** 文件类型 默认图片*/
|
|
||||||
fileType?: MediaType[];
|
|
||||||
/** 是否展示预览 默认展示*/
|
|
||||||
showPreview?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义上传结果类型
|
|
||||||
export interface UploadResult {
|
|
||||||
originalUrl?: string;
|
|
||||||
compressedUrl: string;
|
|
||||||
file: File;
|
|
||||||
exifData: ExifData;
|
|
||||||
originalFile: ConfirmUpload;
|
|
||||||
compressedFile: ConfirmUpload;
|
|
||||||
thumbnail: string;
|
|
||||||
thumbnailFile: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义上传完成回调类型
|
|
||||||
export type UploadCompleteCallback = (result: FileUploadItem[]) => void;
|
|
||||||
|
|
||||||
// 单张图片上传完成回调类型
|
|
||||||
export type UploadSingleCompleteCallback = (result: FileUploadItem) => void;
|
|
||||||
|
|
||||||
// 定义上传 URL 响应类型
|
|
||||||
export interface UploadUrlResponse {
|
|
||||||
expires_in: number;
|
|
||||||
file_id: string;
|
|
||||||
file_path: string;
|
|
||||||
upload_url: string;
|
|
||||||
}
|
|
||||||
interface FileSize {
|
|
||||||
value: number;
|
|
||||||
unit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileMetadata {
|
|
||||||
originalName: string;
|
|
||||||
type: string;
|
|
||||||
isCompressed: string;
|
|
||||||
fileType: string;
|
|
||||||
}
|
|
||||||
interface FileInfo {
|
|
||||||
file_id: number;
|
|
||||||
name: string;
|
|
||||||
size: FileSize;
|
|
||||||
content_type: ContentType;
|
|
||||||
upload_time: string;
|
|
||||||
storage_medium: string;
|
|
||||||
file_path: FilePath;
|
|
||||||
uploader_id: number;
|
|
||||||
upload_status: string;
|
|
||||||
deletion_status: string;
|
|
||||||
metadata: FileMetadata;
|
|
||||||
}
|
|
||||||
export interface FileUploadItem {
|
|
||||||
id: string;
|
|
||||||
uri: string;
|
|
||||||
name: string;
|
|
||||||
progress: number;
|
|
||||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
||||||
error?: string;
|
|
||||||
previewUrl: string;
|
|
||||||
file?: File;
|
|
||||||
type: 'image' | 'video';
|
|
||||||
thumbnail?: string; // 缩略图URL
|
|
||||||
thumbnailFile?: File; // 缩略图文件对象
|
|
||||||
originalFile?: FileInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
// 压缩图片可配置参数
|
|
||||||
export interface ImagesPickerProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
onPickImage?: (file: File, exifData: ExifData) => void;
|
|
||||||
/** 压缩质量,0-1 之间的数字,默认为 0.8 */
|
|
||||||
compressQuality?: number;
|
|
||||||
/** 最大宽度,图片会被等比例缩放 */
|
|
||||||
maxWidth?: number;
|
|
||||||
/** 最大高度,图片会被等比例缩放 */
|
|
||||||
maxHeight?: number;
|
|
||||||
/** 是否保留 EXIF 数据,默认为 true */
|
|
||||||
preserveExif?: boolean;
|
|
||||||
/** 是否上传原图,默认为 false */
|
|
||||||
uploadOriginal?: boolean;
|
|
||||||
/** 上传完成回调 */
|
|
||||||
onUploadComplete?: UploadSingleCompleteCallback;
|
|
||||||
/** 进度 */
|
|
||||||
onProgress?: (progress: FileStatus) => void;
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user