feat: 自动上传组件

feat: 自动上传组件
Co-authored-by: Junhui Chen <chenjunhui@fairclip.cn>
Co-committed-by: Junhui Chen <chenjunhui@fairclip.cn>
This commit is contained in:
Junhui Chen 2025-07-17 15:55:27 +08:00 committed by txcjh
parent 521a4d0a51
commit d31b587330
47 changed files with 7503 additions and 1459 deletions

View File

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

View File

@ -1,50 +1,18 @@
# Welcome to your Expo app 👋 ## 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
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
``` ```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. ## Build
### EAS Build
```shell
eas build --platform android --profile development
```
## Learn more ### EAS Workflow
```shell
To learn more about developing your project with Expo, look at the following resources: npx eas-cli@latest workflow:run create-production-builds.yml
```
- [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.

View File

@ -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

View File

@ -1,4 +1,5 @@
import { HapticTab } from '@/components/HapticTab'; import { HapticTab } from '@/components/HapticTab';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import TabBarBackground from '@/components/ui/TabBarBackground'; import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@ -279,6 +280,19 @@ export default function TabLayout() {
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}} }}
/> />
</Tabs>
{/* Debug Screen - only in development */}
{process.env.NODE_ENV === 'development' && (
<Tabs.Screen
name="debug"
options={{
title: 'Debug',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'bug' : 'bug-outline'} color={color} />
),
}}
/>
)}
</Tabs >
); );
} }

View File

@ -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";
@ -20,6 +21,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);
}, []);
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const [isHello, setIsHello] = useState(true); const [isHello, setIsHello] = useState(true);
const [conversationId, setConversationId] = useState<string | null>(null); const [conversationId, setConversationId] = useState<string | null>(null);

132
app/(tabs)/debug.tsx Normal file
View File

@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { View, TextInput, Button, Text, StyleSheet, ScrollView, SafeAreaView, ActivityIndicator, KeyboardAvoidingView, Platform } from 'react-native';
import { executeSql } from '@/lib/db';
import { ThemedView } from '@/components/ThemedView';
import { ThemedText } from '@/components/ThemedText';
const DebugScreen = () => {
const [sql, setSql] = useState('SELECT * FROM upload_tasks;');
const [results, setResults] = useState<any>(null);
const [loading, setLoading] = useState(false);
const handleExecuteSql = async (query: string) => {
if (!query) return;
setLoading(true);
setResults(null);
try {
const result = await executeSql(query);
setResults(result);
} catch (error) {
setResults({ error: (error as Error).message });
} finally {
setLoading(false);
}
};
const presetQueries = [
{ title: 'All Uploads', query: 'SELECT * FROM upload_tasks;' },
{ title: 'Delete All Uploads', query: 'DELETE FROM upload_tasks;' },
{ title: 'Show Tables', query: "SELECT name FROM sqlite_master WHERE type='table';" },
];
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<ThemedText type="title" style={styles.title}>SQL Debugger</ThemedText>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
onChangeText={setSql}
value={sql}
placeholder="Enter SQL query"
multiline
autoCapitalize='none'
autoCorrect={false}
/>
<Button title="Execute" onPress={() => handleExecuteSql(sql)} disabled={loading} />
</View>
<View style={styles.presetsContainer}>
{presetQueries.map((item, index) => (
<View key={index} style={styles.presetButton}>
<Button title={item.title} onPress={() => {
setSql(item.query);
handleExecuteSql(item.query);
}} disabled={loading} />
</View>
))}
</View>
<ThemedText type="subtitle" style={styles.resultTitle}>Results:</ThemedText>
<ScrollView style={styles.resultsContainer}>
{loading ? (
<ActivityIndicator size="large" />
) : (
<Text selectable style={styles.resultsText}>
{results ? JSON.stringify(results, null, 2) : 'No results yet.'}
</Text>
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</ThemedView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
},
keyboardAvoidingView: {
flex: 1,
padding: 15,
},
title: {
marginBottom: 15,
textAlign: 'center',
},
inputContainer: {
marginBottom: 10,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
padding: 10,
marginBottom: 10,
minHeight: 100,
textAlignVertical: 'top',
backgroundColor: 'white',
fontSize: 16,
},
presetsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
marginBottom: 15,
},
presetButton: {
marginRight: 10,
marginBottom: 10,
},
resultTitle: {
marginBottom: 5,
},
resultsContainer: {
flex: 1,
borderWidth: 1,
borderColor: '#ccc',
padding: 10,
backgroundColor: '#f5f5f5',
},
resultsText: {
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
color: '#333',
},
});
export default DebugScreen;

View File

@ -1,9 +1,7 @@
import IP from '@/assets/icons/svg/ip.svg';
import { registerBackgroundUploadTask, triggerManualUpload } from '@/components/file-upload/backgroundUploader';
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 * as SecureStore from 'expo-secure-store';
import { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Platform, Text, TouchableOpacity, View } from 'react-native'; import { Platform, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@ -40,10 +38,7 @@ export default function HomeScreen() {
// 已登录,请求必要的权限 // 已登录,请求必要的权限
const { status } = await MediaLibrary.requestPermissionsAsync(); const { status } = await MediaLibrary.requestPermissionsAsync();
if (status === 'granted') { if (status === 'granted') {
await registerBackgroundUploadTask(); console.log('Media library permission granted in HomeScreen.');
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
await triggerManualUpload(oneDayAgo, now);
} }
router.replace('/ask'); router.replace('/ask');
} }
@ -101,7 +96,7 @@ export default function HomeScreen() {
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="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"> <View className="items-center mb-10 w-full px-5">
<Text className="text-white text-3xl font-bold mb-3 text-left"> <Text className="text-white text-3xl font-bold mb-3 text-left">
{t('auth.welcomeAwaken.awaken', { ns: 'login' })} {t('auth.welcomeAwaken.awaken', { ns: 'login' })}
{"\n"} {"\n"}
@ -115,9 +110,9 @@ export default function HomeScreen() {
</View> </View>
{/* Memo 形象区域 */} {/* Memo 形象区域 */}
<View className="items-center"> {/* <View className="items-center">
<IP /> <IP />
</View> </View> */}
{/* 介绍文本 */} {/* 介绍文本 */}
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]"> <Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
@ -140,6 +135,6 @@ export default function HomeScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
} }
</View> </View >
); );
} }

View File

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

View File

@ -1,13 +1,21 @@
import ChatSvg from "@/assets/icons/svg/chat.svg"; import ChatSvg from "@/assets/icons/svg/chat.svg";
import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress";
import AskNavbar from "@/components/layout/ask"; import AskNavbar from "@/components/layout/ask";
import { endUploadSessionInDb, syncUploadSessionState } from "@/features/appState/appStateSlice";
import { triggerManualUpload } from "@/lib/background-uploader/automatic";
import { exist_pending_tasks, getUploadTasksSince, UploadTask } from "@/lib/db";
import { fetchApi } from "@/lib/server-api-util"; import { fetchApi } from "@/lib/server-api-util";
import { useAppDispatch, useAppSelector } from "@/store";
import { Chat } from "@/types/ask"; import { Chat } from "@/types/ask";
import { router } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import React, { useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MemoList = () => { const MemoList = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const dispatch = useAppDispatch();
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime);
// 历史消息 // 历史消息
const [historyList, setHistoryList] = React.useState<Chat[]>([]); const [historyList, setHistoryList] = React.useState<Chat[]>([]);
@ -39,15 +47,125 @@ const MemoList = () => {
getHistoryList() getHistoryList()
}, []) }, [])
return ( const [progressInfo, setProgressInfo] = useState({ total: 0, completed: 0, image: '' });
<View style={[styles.container, { paddingTop: insets.top }]}>
useFocusEffect(
useCallback(() => {
let isActive = true;
let interval: any = null;
const manageUploadState = async (restore_session: boolean = false) => {
if (!isActive) {
console.log('MemoList manageUploadState is not active');
return;
}
// 首先同步Redux中的会话开始时间
const action = await dispatch(syncUploadSessionState());
const sessionStartTime = action.payload as number | null;
if (sessionStartTime) {
// 如果会话存在,则获取任务进度
const allTasks = await getUploadTasksSince(sessionStartTime);
const total = allTasks.length;
const completed = allTasks.filter((t: UploadTask) => t.status === 'success' || t.status === 'failed' || t.status === 'skipped').length;
const pending = allTasks.filter((t: UploadTask) => t.status === 'pending' || t.status === 'uploading');
if (isActive) {
setProgressInfo({ total, completed, image: allTasks[0]?.uri || '' });
}
// 如果任务完成,则结束会话并清除定时器
if (total > 0 && pending.length === 0) {
console.log('MemoList detects all tasks are complete. Ending session.');
if (interval) clearInterval(interval);
dispatch(endUploadSessionInDb());
}
} else {
// 如果没有会话,确保本地状态被重置
if (isActive) {
setProgressInfo({ total: 0, completed: 0, image: '' });
}
}
};
const initializeUploadProcess = async () => {
// First, check if a session is already active.
const action = await dispatch(syncUploadSessionState());
const existingSessionStartTime = action.payload as number | null;
const existPendingTasks = await exist_pending_tasks();
if (existingSessionStartTime && existPendingTasks) {
console.log('MemoList focused, existing session found. Monitoring progress.');
// If a session exists, just start monitoring.
manageUploadState(true); // Initial check
interval = setInterval(manageUploadState, 2000);
} else {
// If no session, then try to trigger a new upload.
console.log('MemoList focused, no existing session. Triggering foreground media upload check.');
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const newSessionStartTimeStr = await triggerManualUpload(oneDayAgo, now);
if (newSessionStartTimeStr) {
console.log(`New upload session started with time: ${newSessionStartTimeStr}, beginning to monitor...`);
// A new session was started, so start monitoring.
manageUploadState(); // Initial check
interval = setInterval(manageUploadState, 2000);
}
}
};
initializeUploadProcess();
return () => {
isActive = false;
if (interval) {
clearInterval(interval);
}
};
}, [dispatch])
);
const renderHeader = () => (
<>
{/* {process.env.NODE_ENV === 'development' && <TouchableOpacity
className='mt-2 bg-red-500 items-center h-10 justify-center'
onPress={() => router.push('/debug')}
>
<Text className="text-white">
db调试页面
</Text>
</TouchableOpacity>} */}
{/* 顶部标题和上传按钮 */} {/* 顶部标题和上传按钮 */}
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Memo List</Text> <Text style={styles.title}>Memo List</Text>
</View> </View>
{/* 上传进度展示区域 */}
{uploadSessionStartTime && progressInfo.total > 0 && (
<View className="h-10 mt-6 mb-2 mx-4">
<UploaderProgress
imageUrl={progressInfo.image}
uploadedCount={progressInfo.completed}
totalCount={progressInfo.total}
/>
</View>
)}
</>
);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* <View className="w-full h-full">
<AutoUploadScreen />
</View> */}
{/* 历史对话 */} {/* 历史对话 */}
<FlatList <FlatList
ListHeaderComponent={renderHeader}
data={historyList} data={historyList}
keyExtractor={(item) => item.session_id} keyExtractor={(item) => item.session_id}
ItemSeparatorComponent={() => ( ItemSeparatorComponent={() => (

View File

@ -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 { FlatList, ScrollView, StyleSheet, View } from 'react-native'; import { FlatList, 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);

View File

@ -2,14 +2,16 @@ import Choice from '@/components/user-message.tsx/choice';
import Done from '@/components/user-message.tsx/done'; import Done from '@/components/user-message.tsx/done';
import Look from '@/components/user-message.tsx/look'; import Look from '@/components/user-message.tsx/look';
import UserName from '@/components/user-message.tsx/userName'; import UserName from '@/components/user-message.tsx/userName';
import { checkAuthStatus } from '@/lib/auth';
import { FileUploadItem } from '@/lib/background-uploader/types';
import { fetchApi } from '@/lib/server-api-util'; import { fetchApi } from '@/lib/server-api-util';
import { FileUploadItem } from '@/types/upload';
import { User } from '@/types/user'; import { User } from '@/types/user';
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { 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';
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('')
@ -23,6 +25,10 @@ export default function UserMessage() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { username: usernameParam } = params; const { username: usernameParam } = params;
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");

View File

@ -1,7 +1,10 @@
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as MediaLibrary from 'expo-media-library';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import 'react-native-reanimated'; import 'react-native-reanimated';
import '../global.css'; import '../global.css';
import { Provider } from "../provider"; import { Provider } from "../provider";
@ -9,6 +12,22 @@ import { Provider } from "../provider";
export default function RootLayout() { export default function RootLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
useEffect(() => {
const setupBackgroundUpload = async () => {
const { status } = await MediaLibrary.getPermissionsAsync();
if (status !== 'granted') {
console.log('Media library permission not granted. Background task registered but will wait for permission.');
}
const registered = await registerBackgroundUploadTask();
if (registered) {
console.log('Background upload task setup finished in RootLayout.');
} else {
console.error('Failed to register background upload task in RootLayout.');
}
};
setupBackgroundUpload();
}, []);
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Provider> <Provider>

View File

@ -1,27 +1,38 @@
import React, { useEffect, useState } from 'react'; import { triggerManualUpload } from '@/lib/background-uploader/manual';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { router } from 'expo-router';
import { registerBackgroundUploadTask, triggerManualUpload } from './backgroundUploader'; import React, { useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import UploaderProgressBar from './upload-progress/progress-bar';
export default function AutoUploadScreen() { export default function AutoUploadScreen() {
const [timeRange, setTimeRange] = useState('day'); const [timeRange, setTimeRange] = useState('day');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false); const [uploadProgress, setUploadProgress] = useState({
totalCount: 0,
uploadedCount: 0,
currentFileUrl: '',
uploadedSize: 0,
totalSize: 0,
});
// 注册后台任务
useEffect(() => {
const registerTask = async () => {
const registered = await registerBackgroundUploadTask();
setIsRegistered(registered);
};
registerTask();
}, []);
// 处理手动上传 // 处理手动上传
const handleManualUpload = async () => { const handleManualUpload = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await triggerManualUpload(getDateRange(timeRange)[0], getDateRange(timeRange)[1]); await triggerManualUpload(
getDateRange(timeRange)[0],
getDateRange(timeRange)[1],
(progress) => {
setUploadProgress({
totalCount: progress.totalCount,
uploadedCount: progress.uploadedCount,
currentFileUrl: progress.currentAsset.uri,
uploadedSize: progress.uploadedBytes,
totalSize: progress.totalBytes,
});
}
);
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
} finally { } finally {
@ -114,22 +125,27 @@ export default function AutoUploadScreen() {
{isLoading ? '上传中...' : '开始上传'} {isLoading ? '上传中...' : '开始上传'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
className='mt-2'
style={[styles.uploadButton]}
onPress={() => router.push('/debug')}
>
<Text style={styles.uploadButtonText}>
db调试页面
</Text>
</TouchableOpacity>
</View> </View>
<View style={styles.statusContainer}>
<Text style={styles.statusText}>
: {isRegistered ? '已启用' : '未启用'}
</Text>
<Text style={styles.hintText}>
24
</Text>
</View>
{isLoading && (
<View style={styles.loadingContainer}> {
<ActivityIndicator size="large" color="#0000ff" /> // isLoading &&
<Text style={styles.loadingText}>...</Text> (
</View> <UploaderProgressBar
imageUrl={uploadProgress.currentFileUrl}
uploadedCount={uploadProgress.uploadedCount}
totalCount={uploadProgress.totalCount}
/>
)} )}
</View> </View>
); );

View File

@ -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;
}
};

View File

@ -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,23 @@ 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 });
const taskIndex = uploadTasks.indexOf(task);
const totalTasks = uploadTasks.length;
const baseProgress = (taskIndex / totalTasks) * 100;
await uploadFileWithProgress(
task.file,
uploadUrlData.upload_url,
(progress) => {
const taskProgress = progress.total > 0 ? (progress.loaded / progress.total) * (100 / totalTasks) : 0;
updateProgress(baseProgress + taskProgress);
},
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 +193,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 +205,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
if (uploadResults.originalFile?.file_id) { if (uploadResults.originalFile?.file_id) {
await addMaterial( await addMaterial(
uploadResults.originalFile.file_id, uploadResults.originalFile.file_id,
uploadResults.thumbnail uploadResults.compressedFile?.file_id
); );
} }

View File

@ -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);

View File

@ -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) {

View File

@ -0,0 +1,107 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
import * as Progress from 'react-native-progress';
interface UploaderProgressBarProps {
imageUrl: string;
uploadedCount: number;
totalCount: number;
}
const UploaderProgressBar: React.FC<UploaderProgressBarProps> = ({
imageUrl,
uploadedCount,
totalCount,
}) => {
const progress = totalCount > 0 ? (uploadedCount / totalCount) * 100 : 0;
const { t } = useTranslation();
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<Image source={{ uri: imageUrl }} style={styles.thumbnail} />
</View>
<View style={styles.progressSection}>
<View style={styles.progressInfo}>
<Text style={styles.statusText}>{t('upload.uploading', { ns: 'upload' })}</Text>
<ActivityIndicator size={12} color="#4A4A4A" className='ml-2' />
</View>
<Progress.Bar
progress={progress / 100}
width={null} // Fills the container
height={4}
color={'#4A4A4A'}
unfilledColor={'rgba(255, 255, 255, 0.5)'}
borderWidth={0}
style={styles.progressBar}
/>
</View>
<Text style={styles.countText}>{`${uploadedCount}/${totalCount}`}</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5B941', // From image
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
marginHorizontal: 0,
height: 60,
},
imageContainer: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
// This container helps with the skewed frame effect
transform: [{ rotate: '-5deg' }],
marginRight: 8,
},
thumbnail: {
width: 36,
height: 36,
borderRadius: 8,
borderWidth: 2,
borderColor: 'white',
transform: [{ rotate: '5deg' }], // Counter-rotate to keep image straight
},
progressSection: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
marginRight: 15,
},
progressInfo: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
activityIndicator: {
marginLeft: 5,
},
progressText: {
color: '#4A4A4A',
fontWeight: 'bold',
fontSize: 12,
marginRight: 8,
},
statusText: {
color: '#4A4A4A',
fontSize: 12,
},
progressBar: {
width: '100%',
},
countText: {
color: '#4A4A4A',
fontWeight: 'bold',
fontSize: 16,
},
});
export default UploaderProgressBar;

View File

@ -0,0 +1,33 @@
import React from 'react';
import { View } from 'react-native';
import UploaderProgressBar from './progress-bar';
interface UploaderProgressProps {
imageUrl: string;
uploadedCount: number;
totalCount: number;
}
const UploaderProgress: React.FC<UploaderProgressProps> = ({ imageUrl, uploadedCount, totalCount }) => {
// This is now a 'dumb' component. It only displays the progress based on props.
// All logic for fetching and state management is handled by the parent component (MemoList).
if (totalCount === 0) {
// Don't render anything if there's no session or tasks.
// The parent component's logic (`uploadSessionStartTime && ...`) already handles this,
// but this is an extra safeguard.
return null;
}
return (
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'transparent' }}>
<UploaderProgressBar
imageUrl={imageUrl}
uploadedCount={uploadedCount}
totalCount={totalCount}
/>
</View>
);
};
export default React.memo(UploaderProgress);

View File

@ -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)));
}
};

View File

@ -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();
});
};

View File

@ -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 || '');
const userInfo = await fetchApi<User>("/iam/user-info"); const userInfo = await fetchApi<User>("/iam/user-info");
if (userInfo?.nickname) { if (userInfo?.nickname) {

View File

@ -0,0 +1,8 @@
// This file is based on the default template provided by Expo.
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
import Ionicons from '@expo/vector-icons/Ionicons';
import { type ComponentProps } from 'react';
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@ -2,12 +2,10 @@ import { Steps } from '@/app/(tabs)/user-message';
import ChoicePhoto from '@/assets/icons/svg/choicePhoto.svg'; import ChoicePhoto from '@/assets/icons/svg/choicePhoto.svg';
import LookSvg from '@/assets/icons/svg/look.svg'; import LookSvg from '@/assets/icons/svg/look.svg';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { FileUploadItem } from '@/types/upload'; import { FileUploadItem } from '@/lib/background-uploader/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native'; import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
import AutoUploadScreen from '../file-upload/autoUploadScreen';
import FilesUploader from '../file-upload/files-uploader'; import FilesUploader from '../file-upload/files-uploader';
import MediaStatsScreen from '../file-upload/getTotal';
interface Props { interface Props {
setSteps?: (steps: Steps) => void; setSteps?: (steps: Steps) => void;
@ -34,11 +32,11 @@ export default function Look(props: Props) {
{t('auth.userMessage.avatorText2', { ns: 'login' })} {t('auth.userMessage.avatorText2', { ns: 'login' })}
</ThemedText> </ThemedText>
{ {
fileData[0]?.preview fileData[0]?.previewUrl
? ?
<Image <Image
className='rounded-full w-[10rem] h-[10rem]' className='rounded-full w-[10rem] h-[10rem]'
source={{ uri: fileData[0].preview }} source={{ uri: fileData[0].previewUrl }}
/> />
: :
avatar avatar
@ -64,8 +62,8 @@ export default function Look(props: Props) {
</View> </View>
} }
/> />
<AutoUploadScreen /> {/* <AutoUploadScreen /> */}
<MediaStatsScreen /> {/* <MediaStatsScreen /> */}
</View> </View>
<View className="w-full"> <View className="w-full">

View File

@ -0,0 +1,54 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { getAppState, setAppState } from '../../lib/db';
interface AppState {
uploadSessionStartTime: number | null;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
}
const initialState: AppState = {
uploadSessionStartTime: null,
status: 'idle',
};
// Thunk to fetch the session start time from the database
export const syncUploadSessionState = createAsyncThunk(
'appState/syncUploadSessionState',
async () => {
const startTimeStr = await getAppState('uploadSessionStartTime');
return startTimeStr ? parseInt(startTimeStr, 10) : null;
}
);
// Thunk to clear the session state in the database, which will then be reflected in the store
export const endUploadSessionInDb = createAsyncThunk(
'appState/endUploadSessionInDb',
async () => {
await setAppState('uploadSessionStartTime', null);
return null;
}
);
const appStateSlice = createSlice({
name: 'appState',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(syncUploadSessionState.pending, (state) => {
state.status = 'loading';
})
.addCase(syncUploadSessionState.fulfilled, (state, action: PayloadAction<number | null>) => {
state.status = 'succeeded';
state.uploadSessionStartTime = action.payload;
})
.addCase(syncUploadSessionState.rejected, (state) => {
state.status = 'failed';
})
.addCase(endUploadSessionInDb.fulfilled, (state, action: PayloadAction<null>) => {
state.uploadSessionStartTime = action.payload;
});
},
});
export default appStateSlice.reducer;

View File

@ -15,7 +15,8 @@
"task": "Task", "task": "Task",
"taskName": "dynamic family portrait", "taskName": "dynamic family portrait",
"taskStatus": "processing", "taskStatus": "processing",
"noName": "No Name" "noName": "No Name",
"uploading": "Uploading"
}, },
"library": { "library": {
"title": "My Memory", "title": "My Memory",

View File

@ -15,7 +15,8 @@
"task": "任务", "task": "任务",
"taskName": "动态全家福", "taskName": "动态全家福",
"taskStatus": "正在处理中", "taskStatus": "正在处理中",
"noName": "未命名作品" "noName": "未命名作品",
"uploading": "上传中"
}, },
"library": { "library": {
"title": "My Memory", "title": "My Memory",

11
jest.config.ts Normal file
View File

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

39
lib/auth.ts Normal file
View 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;
}

View 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
}])
});
}

View File

@ -0,0 +1,123 @@
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import pLimit from 'p-limit';
import { filterExistingFiles, initUploadTable, insertUploadTask, setAppState, updateUploadTaskStatus } from '../db';
import { getMediaByDateRange } from './media';
import { processAndUploadMedia } from './uploader';
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
const CONCURRENCY_LIMIT = 1; // 后台上传并发数例如同时上传3个文件
const limit = pLimit(CONCURRENCY_LIMIT);
// 注册后台任务
export async function registerBackgroundUploadTask() {
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;
}
};
// 触发手动或后台上传的通用函数
export async function triggerManualUpload(startDate: Date, endDate: Date): Promise<string | null> {
try {
console.log(`Triggering upload for range: ${startDate.toISOString()} to ${endDate.toISOString()}`);
const allMedia = await getMediaByDateRange(startDate, endDate);
if (allMedia.length === 0) {
console.log('No media files found in the specified range.');
return null;
}
// 1. Batch filter out files that have already been successfully uploaded.
const allFileUris = allMedia.map(a => a.uri);
const newFileUris = await filterExistingFiles(allFileUris);
if (newFileUris.length === 0) {
console.log('No new files to upload. All are already synced.');
return null;
}
const filesToUpload = allMedia.filter(a => newFileUris.includes(a.uri));
// 2. Batch pre-register all new files in the database as 'pending'.
console.log(`Registering ${filesToUpload.length} new files as 'pending' in the database.`);
for (const file of filesToUpload) {
await insertUploadTask({ uri: file.uri, filename: file.filename, status: 'pending', progress: 0 });
}
// 3. Start the upload session.
const startTime = Math.floor(Date.now() / 1000).toString();
await setAppState('uploadSessionStartTime', startTime);
// 4. Create upload promises for the new files.
const uploadPromises = filesToUpload.map((file) =>
limit(async () => {
try {
// 5. Mark the task as 'uploading' right before the upload starts.
await updateUploadTaskStatus(file.uri, 'uploading');
const result = await processAndUploadMedia(file);
if (result === null) { // Skipped case
await updateUploadTaskStatus(file.uri, 'skipped');
return { status: 'skipped' };
}
if (result.originalSuccess) {
await updateUploadTaskStatus(file.uri, 'success', result.fileIds?.original);
return { status: 'success' };
} else {
await updateUploadTaskStatus(file.uri, 'failed');
return { status: 'failed' };
}
} catch (e) {
console.error('Upload failed for', file.uri, e);
await updateUploadTaskStatus(file.uri, 'failed');
return { status: 'failed' };
}
})
);
// We don't wait for all uploads to complete. The function returns after starting them.
// The UI will then poll for progress.
Promise.allSettled(uploadPromises).then((results) => {
console.log('All upload tasks have been settled.');
const successfulUploads = results.filter(
(result) => result.status === 'fulfilled' && result.value.status === 'success'
).length;
console.log(`${successfulUploads} files uploaded successfully.`);
});
return startTime;
} catch (error) {
console.error('Error during upload trigger:', error);
return null;
}
}
// 定义后台任务
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);
await triggerManualUpload(oneDayAgo, now);
return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) {
console.error('Background task error:', error);
return BackgroundTask.BackgroundTaskResult.Failed;
}
});

View File

@ -0,0 +1,82 @@
import pLimit from 'p-limit';
import { Alert } from 'react-native';
import { getUploadTaskStatus, insertUploadTask } from '../db';
import { getMediaByDateRange } from './media';
import { ExtendedAsset } from './types';
import { processAndUploadMedia } from './uploader';
// 设置最大并发数
const CONCURRENCY_LIMIT = 1; // 同时最多上传10个文件
const limit = pLimit(CONCURRENCY_LIMIT);
// 手动触发上传
export const triggerManualUpload = async (
startDate: Date,
endDate: Date,
onProgress?: (progress: {
totalCount: number;
uploadedCount: number;
totalBytes: number; // Overall total size
uploadedBytes: number; // Overall uploaded size
currentAsset: ExtendedAsset; // To show a thumbnail
}) => void
) => {
try {
const media = await getMediaByDateRange(startDate, endDate);
if (media.length === 0) {
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
return [];
}
const progressMap = new Map<string, { loaded: number; total: number }>();
const results = [];
let uploadedCount = 0;
for (const asset of media) {
const existingTask = await getUploadTaskStatus(asset.uri);
if (!existingTask) {
await insertUploadTask({ uri: asset.uri, filename: asset.filename, status: 'pending', progress: 0 });
} else if (existingTask.status === 'success' || existingTask.status === 'skipped') {
console.log(`File ${asset.uri} already ${existingTask.status}, skipping processing.`);
uploadedCount++; // Also count skipped files as 'processed'
continue;
}
const result = await limit(() => processAndUploadMedia(asset, (fileProgress) => {
progressMap.set(asset.uri, fileProgress);
const uploadedBytes = Array.from(progressMap.values()).reduce((sum, p) => sum + p.loaded, 0);
const totalBytes = Array.from(progressMap.values()).reduce((sum, p) => sum + p.total, 0);
onProgress?.({
totalCount: media.length,
uploadedCount,
totalBytes,
uploadedBytes,
currentAsset: asset,
});
}));
if (result) {
results.push(result);
if (result.originalSuccess) {
uploadedCount++;
}
}
}
// 过滤掉因为已上传而返回 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;
}
};

View File

@ -0,0 +1,90 @@
import * as MediaLibrary from 'expo-media-library';
import { MediaTypeValue } 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: MediaTypeValue[],
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,
});
allAssets = allAssets.concat(media.assets);
hasNextPage = media.hasNextPage;
after = media.endCursor;
}
// For each asset, get full EXIF information
const assetsWithExif = await Promise.all(
allAssets.map(async (asset) => {
try {
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id);
return {
...asset,
exif: assetInfo.exif || null,
location: assetInfo.location || null
};
} catch (error) {
console.warn(`Failed to get EXIF for asset ${asset.id}:`, error);
return asset;
}
})
);
return assetsWithExif;
};
// 获取指定时间范围内的媒体文件(包含 EXIF 信息),并按日期倒序
export const getMediaByDateRange = async (startDate: Date, endDate: Date): Promise<ExtendedAsset[]> => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Media library permission not granted');
return [];
}
return await fetchAssetsWithExif(
['photo', 'video'],
startDate.getTime(),
endDate.getTime(),
true // Always descending for this function
);
} catch (error) {
console.error('Error in getMediaByDateRange:', error);
return [];
}
};
// 获取所有需要上传的媒体文件(从某个时间点之后,按日期倒序,包含 EXIF 信息)
export const getMediaForUpload = async (lastUploadTimestamp?: number): Promise<ExtendedAsset[]> => {
try {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Media library permission not granted');
return [];
}
return await fetchAssetsWithExif(
['photo', 'video'],
lastUploadTimestamp, // Only fetch assets created after this timestamp
undefined, // No end date
true // Always descending
);
} catch (error) {
console.error('Error in getMediaForUpload:', error);
return [];
}
};

View File

@ -0,0 +1,27 @@
此目录包含后台媒体上传的逻辑,包括 API 交互、自动和手动上传过程、媒体库访问以及实用功能。
**主要组件:**
- `api.ts`:处理与文件上传相关的 API 调用,例如获取上传 URL、确认上传和添加素材记录。
- `automatic.ts`:实现后台任务的注册和定义,用于自动媒体上传,通常定期运行(例如,每 15 分钟)。它获取最近的媒体并处理它们以上传。
- `manual.ts`:提供在指定日期范围内手动触发媒体上传的功能,并带有并发控制。
- `media.ts`:管理与设备媒体库的交互(使用 `expo-media-library`),包括请求权限、获取带有 EXIF 数据的资产以及按日期范围过滤。
- `types.ts`:定义后台上传模块中使用的 TypeScript 接口和类型,包括 `ExtendedAsset``UploadTask``ConfirmUpload``UploadUrlResponse`
- `uploader.ts`:包含处理和上传单个媒体文件的核心逻辑。这包括处理 HEIC 转换、图像压缩、视频缩略图生成、重复检查以及与上传 API 的交互。它还包括一个 `uploadFileWithProgress` 函数,用于跟踪上传进度。
- `utils.ts`:提供实用功能,例如检查媒体库权限、提取文件扩展名和确定 MIME 类型。
**整体功能:**
`background-uploader` 模块使应用程序能够:
1. 在后台**自动上传**新创建的媒体文件(照片和视频)。
2. 允许在指定日期范围内**手动上传**媒体文件。
3. 在上传前**处理媒体文件**,包括:
* 将 HEIC 图像转换为 JPEG。
* 压缩图像。
* 为视频生成缩略图。
4. **与后端 API 交互**以管理上传生命周期(获取 URL、上传、确认
5. **管理媒体库权限**并获取带有详细 EXIF 信息的媒体资产。
6. 通过检查本地数据库**防止重复上传**。
7. 提供上传的**进度跟踪**。
该模块通过必要的预处理和错误处理,确保高效、可靠的媒体上传,无论是自动还是按需上传。

View File

@ -0,0 +1,135 @@
import * as MediaLibrary from 'expo-media-library';
export type ExtendedAsset = MediaLibrary.Asset & {
size?: number;
exif?: Record<string, any> | null;
};
// 上传任务类型
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;
};

View File

@ -0,0 +1,239 @@
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 { getUploadTaskStatus, updateUploadTaskStatus, updateUploadTaskProgress } 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,
onProgress?: (progress: { loaded: number; total: number }) => void
) => {
try {
// 1. 文件去重检查 (从数据库获取状态)
const existingTask = await getUploadTaskStatus(asset.uri);
if (existingTask && (existingTask.status === 'success' || existingTask.status === 'skipped')) {
console.log(`File ${asset.uri} already ${existingTask.status}, skipping processing.`);
return null; // 返回 null 表示已上传或已跳过,调用方可以据此过滤
}
// 标记为正在上传
await updateUploadTaskStatus(asset.uri, 'uploading');
// 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 uploadFileWithProgress(fileToUpload, upload_url, async (progress) => {
if (onProgress) onProgress(progress);
const percentage = progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0;
await updateUploadTaskProgress(asset.uri, Math.round(percentage * 0.5)); // 原始文件占总进度的50%
});
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 uploadFileWithProgress(compressedFile, upload_url, async (progress) => {
// For compressed files, we can't easily report byte progress relative to the whole process,
// as we don't know the compressed size in advance. We'll just update the DB progress.
const percentage = progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0;
await updateUploadTaskProgress(asset.uri, 50 + Math.round(percentage * 0.5)); // 压缩文件占总进度的后50%
});
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) {
await addMaterial(originalResult.file_id, compressedResult.file_id);
}
} else {
const thumbnailResult = await uploadVideoThumbnail(asset);
if (thumbnailResult.success && originalResult.file_id && thumbnailResult.file_id) {
await addMaterial(originalResult.file_id, thumbnailResult.file_id);
}
}
// 标记为已上传
await updateUploadTaskStatus(asset.uri, 'success', originalResult.file_id);
await updateUploadTaskProgress(asset.uri, 100);
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);
// 标记为失败
await updateUploadTaskStatus(asset.uri, 'failed');
await updateUploadTaskProgress(asset.uri, 0);
return {
id: asset.id,
originalSuccess: false,
compressedSuccess: false,
error: error.message
};
}
};
export const uploadFileWithProgress = async (
file: File,
uploadUrl: string,
onProgress?: (progress: { loaded: number; total: 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) {
onProgress({ loaded: event.loaded, total: event.total });
}
};
}
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);
});
};

View File

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

176
lib/db.ts Normal file
View File

@ -0,0 +1,176 @@
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('upload_status.db');
// Set a busy timeout to handle concurrent writes and avoid "database is locked" errors.
// This will make SQLite wait for 5 seconds if the database is locked by another process.
db.execSync('PRAGMA busy_timeout = 5000;');
export type UploadTask = {
uri: string;
filename: string;
status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped';
progress: number; // 0-100
file_id?: string; // 后端返回的文件ID
created_at: number; // unix timestamp
};
// 初始化表
export async function initUploadTable() {
console.log('Initializing upload tasks table...');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS upload_tasks (
uri TEXT PRIMARY KEY NOT NULL,
filename TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
file_id TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`);
// Add created_at column to existing table if it doesn't exist
const columns = await db.getAllAsync('PRAGMA table_info(upload_tasks);');
const columnExists = columns.some((column: any) => column.name === 'created_at');
if (!columnExists) {
console.log('Adding created_at column to upload_tasks table...');
// SQLite doesn't support non-constant DEFAULT values on ALTER TABLE.
// So we add the column, then update existing rows.
await db.execAsync(`ALTER TABLE upload_tasks ADD COLUMN created_at INTEGER;`);
await db.execAsync(`UPDATE upload_tasks SET created_at = (strftime('%s', 'now')) WHERE created_at IS NULL;`);
console.log('created_at column added and populated.');
}
console.log('Upload tasks table initialized');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
`);
console.log('App state table initialized');
}
// 插入新的上传任务
export async function insertUploadTask(task: Omit<UploadTask, 'created_at'>) {
console.log('Inserting upload task:', task.uri);
await db.runAsync(
'INSERT OR REPLACE INTO upload_tasks (uri, filename, status, progress, file_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[task.uri, task.filename, task.status, task.progress, task.file_id ?? null, Math.floor(Date.now() / 1000)]
);
}
// 检查文件是否已上传或正在上传
export async function getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
console.log('Checking upload task status for:', uri);
const result = await db.getFirstAsync<UploadTask>(
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;',
uri
);
return result || null;
}
// 更新上传任务的状态
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string) {
if (file_id) {
await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]);
} else {
await db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]);
}
}
// 更新上传任务的进度
export async function updateUploadTaskProgress(uri: string, progress: number) {
await db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]);
}
// 获取所有上传任务
export async function getUploadTasks(): Promise<UploadTask[]> {
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
const results = await db.getAllAsync<UploadTask>(
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;'
);
return results;
}
// 清理已完成或失败的任务 (可选,根据需求添加)
export async function cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...');
await db.runAsync(
"DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';"
);
}
// 获取某个时间点之后的所有上传任务
export async function getUploadTasksSince(timestamp: number): Promise<UploadTask[]> {
const rows = await db.getAllAsync<UploadTask>(
'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC',
[timestamp]
);
return rows;
}
export async function exist_pending_tasks(): Promise<boolean> {
const rows = await db.getAllAsync<UploadTask>(
'SELECT * FROM upload_tasks WHERE status = "pending" OR status = "uploading"'
);
return rows.length > 0;
}
// 检查一组文件URI返回那些在数据库中不存在或是未成功上传的文件的URI
export async function filterExistingFiles(fileUris: string[]): Promise<string[]> {
if (fileUris.length === 0) {
return [];
}
// 创建占位符字符串 '?,?,?'
const placeholders = fileUris.map(() => '?').join(',');
// 查询已经存在且状态为 'success' 的任务
const query = `SELECT uri FROM upload_tasks WHERE uri IN (${placeholders}) AND status = 'success'`;
const existingFiles = await db.getAllAsync<{ uri: string }>(query, fileUris);
const existingUris = new Set(existingFiles.map(f => f.uri));
// 过滤出新文件
const newFileUris = fileUris.filter(uri => !existingUris.has(uri));
console.log(`[DB] Total files: ${fileUris.length}, Existing successful files: ${existingUris.size}, New files to upload: ${newFileUris.length}`);
return newFileUris;
}
// 设置全局状态值
export async function setAppState(key: string, value: string | null): Promise<void> {
console.log(`Setting app state: ${key} = ${value}`);
await db.runAsync('INSERT OR REPLACE INTO app_state (key, value) VALUES (?, ?)', [key, value]);
}
// 获取全局状态值
export async function getAppState(key: string): Promise<string | null> {
const result = await db.getFirstAsync<{ value: string }>('SELECT value FROM app_state WHERE key = ?;', key);
return result?.value ?? null;
}
// for debug page
export async function executeSql(sql: string, params: any[] = []): Promise<any> {
try {
// Trim and check if it's a SELECT query
const isSelect = sql.trim().toLowerCase().startsWith('select');
if (isSelect) {
const results = db.getAllSync(sql, params);
return results;
} else {
const result = db.runSync(sql, params);
return {
changes: result.changes,
lastInsertRowId: result.lastInsertRowId,
};
}
} catch (error: any) {
console.error("Error executing SQL:", error);
return { error: error.message };
}
}

View File

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

View File

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

View File

@ -0,0 +1,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 };
}
};

5637
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,8 @@
"web": "expo start --web --port 5173", "web": "expo start --web --port 5173",
"lint": "expo lint", "lint": "expo lint",
"prebuild": "npm run generate:translations", "prebuild": "npm run generate:translations",
"generate:translations": "tsx i18n/generate-imports.ts" "generate:translations": "tsx i18n/generate-imports.ts",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
@ -18,10 +19,11 @@
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@types/p-limit": "^2.2.0",
"@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",
@ -39,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",
@ -51,6 +54,7 @@
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"lottie-react-native": "7.2.2", "lottie-react-native": "7.2.2",
"nativewind": "^4.1.23", "nativewind": "^4.1.23",
"p-limit": "^6.2.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-i18next": "^15.5.3", "react-i18next": "^15.5.3",
@ -73,13 +77,16 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.3", "@types/node": "^24.0.3",
"@types/react": "~19.0.10", "@types/react": "~19.0.10",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0", "eslint-config-expo": "~9.2.0",
"jest": "^30.0.4",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.5.14",
"react-native-svg-transformer": "^1.5.1", "react-native-svg-transformer": "^1.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"ts-jest": "^29.4.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "~5.8.3" "typescript": "~5.8.3"

View File

@ -0,0 +1 @@
eas build --platform android --profile development --local

View File

@ -1,15 +1,21 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { counterSlice } from './components/steps'; import { counterSlice } from './components/steps';
import authReducer from './features/auth/authSlice'; import authReducer from './features/auth/authSlice';
import appStateReducer from './features/appState/appStateSlice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
counter: counterSlice.reducer, counter: counterSlice.reducer,
auth: authReducer auth: authReducer,
appState: appStateReducer
}, },
}); });
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型 // 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;
// 推断类型:{posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;
// 在整个应用中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

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

View File

@ -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;
}