upload-jh #7
11
.eas/workflows/create-production-builds.yml
Normal file
11
.eas/workflows/create-production-builds.yml
Normal file
@ -0,0 +1,11 @@
|
||||
name: Create Production Builds
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
type: build # This job type creates a production build for Android
|
||||
params:
|
||||
platform: android
|
||||
build_ios:
|
||||
type: build # This job type creates a production build for iOS
|
||||
params:
|
||||
platform: ios
|
||||
62
README.md
62
README.md
@ -1,50 +1,18 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
## Dependencies
|
||||
### SQLite
|
||||
- [SQLite](https://sqlite.org/index.html)
|
||||
```shell
|
||||
expo install expo-sqlite
|
||||
cp node_modules/wa-sqlite/dist/wa-sqlite.wasm node_modules/expo-sqlite/web/wa-sqlite/
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
## Build
|
||||
### EAS Build
|
||||
```shell
|
||||
eas build --platform android --profile development
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
### EAS Workflow
|
||||
```shell
|
||||
npx eas-cli@latest workflow:run create-production-builds.yml
|
||||
```
|
||||
23
app.json
23
app.json
@ -15,7 +15,11 @@
|
||||
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
|
||||
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIBackgroundModes": ["fetch", "location", "audio"]
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"location",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
"bundleIdentifier": "com.memowake.app"
|
||||
},
|
||||
@ -48,8 +52,9 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store", [
|
||||
"expo-background-fetch",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-background-task",
|
||||
{
|
||||
"minimumInterval": 15
|
||||
}
|
||||
@ -68,15 +73,6 @@
|
||||
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置"
|
||||
}
|
||||
],
|
||||
// [
|
||||
// "expo-notifications",
|
||||
// {
|
||||
// "color": "#ffffff",
|
||||
// "defaultChannel": "default",
|
||||
// "enableBackgroundRemoteNotifications": false,
|
||||
// "mode": "client"
|
||||
// }
|
||||
// ],
|
||||
[
|
||||
"expo-audio",
|
||||
{
|
||||
@ -90,7 +86,8 @@
|
||||
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-sqlite"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { HapticTab } from '@/components/HapticTab';
|
||||
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@ -279,6 +280,19 @@ export default function TabLayout() {
|
||||
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 >
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import Chat from "@/components/ask/chat";
|
||||
import AskHello from "@/components/ask/hello";
|
||||
import SendMessage from "@/components/ask/send";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { checkAuthStatus } from '@/lib/auth';
|
||||
import { fetchApi } from "@/lib/server-api-util";
|
||||
import { Message } from "@/types/ask";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
@ -20,6 +21,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function AskScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
checkAuthStatus(router);
|
||||
}, []);
|
||||
// 在组件内部添加 ref
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [isHello, setIsHello] = useState(true);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
|
||||
132
app/(tabs)/debug.tsx
Normal file
132
app/(tabs)/debug.tsx
Normal 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;
|
||||
@ -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 { useRouter } from 'expo-router';
|
||||
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 { Platform, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@ -40,10 +38,7 @@ export default function HomeScreen() {
|
||||
// 已登录,请求必要的权限
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
await registerBackgroundUploadTask();
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
await triggerManualUpload(oneDayAgo, now);
|
||||
console.log('Media library permission granted in HomeScreen.');
|
||||
}
|
||||
router.replace('/ask');
|
||||
}
|
||||
@ -101,7 +96,7 @@ export default function HomeScreen() {
|
||||
isLoggedIn ? <MemoList /> :
|
||||
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] " style={{ paddingTop: insets.top + 48 }}>
|
||||
{/* 标题区域 */}
|
||||
<View className="items-start mb-10 w-full px-5">
|
||||
<View className="items-center mb-10 w-full px-5">
|
||||
<Text className="text-white text-3xl font-bold mb-3 text-left">
|
||||
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
|
||||
{"\n"}
|
||||
@ -115,9 +110,9 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
|
||||
{/* Memo 形象区域 */}
|
||||
<View className="items-center">
|
||||
{/* <View className="items-center">
|
||||
<IP />
|
||||
</View>
|
||||
</View> */}
|
||||
|
||||
{/* 介绍文本 */}
|
||||
<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>
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
</View >
|
||||
);
|
||||
}
|
||||
@ -53,7 +53,7 @@ const LoginScreen = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setError('123')
|
||||
// setError('123')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
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 { 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 { useAppDispatch, useAppSelector } from "@/store";
|
||||
import { Chat } from "@/types/ask";
|
||||
import { router } from "expo-router";
|
||||
import React, { useEffect } from 'react';
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const MemoList = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const dispatch = useAppDispatch();
|
||||
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime);
|
||||
|
||||
// 历史消息
|
||||
const [historyList, setHistoryList] = React.useState<Chat[]>([]);
|
||||
@ -39,15 +47,125 @@ const MemoList = () => {
|
||||
getHistoryList()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
const [progressInfo, setProgressInfo] = useState({ total: 0, completed: 0, image: '' });
|
||||
|
||||
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}>
|
||||
<Text style={styles.title}>Memo List</Text>
|
||||
</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
|
||||
ListHeaderComponent={renderHeader}
|
||||
data={historyList}
|
||||
keyExtractor={(item) => item.session_id}
|
||||
ItemSeparatorComponent={() => (
|
||||
|
||||
@ -12,15 +12,29 @@ import ResourceComponent from '@/components/owner/resource';
|
||||
import SettingModal from '@/components/owner/setting';
|
||||
import UserInfo from '@/components/owner/userName';
|
||||
import { formatDuration } from '@/components/utils/time';
|
||||
import { checkAuthStatus } from '@/lib/auth';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { CountData, UserInfoDetails } from '@/types/user';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function OwnerPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const authStatus = await checkAuthStatus(router);
|
||||
if (!authStatus) {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
// 设置弹窗
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
@ -2,14 +2,16 @@ import Choice from '@/components/user-message.tsx/choice';
|
||||
import Done from '@/components/user-message.tsx/done';
|
||||
import Look from '@/components/user-message.tsx/look';
|
||||
import UserName from '@/components/user-message.tsx/userName';
|
||||
import { checkAuthStatus } from '@/lib/auth';
|
||||
import { FileUploadItem } from '@/lib/background-uploader/types';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { FileUploadItem } from '@/types/upload';
|
||||
import { User } from '@/types/user';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
|
||||
export type Steps = "userName" | "look" | "choice" | "done";
|
||||
export default function UserMessage() {
|
||||
const router = useRouter();
|
||||
// 步骤
|
||||
const [steps, setSteps] = useState<Steps>("userName")
|
||||
const [username, setUsername] = useState('')
|
||||
@ -23,6 +25,10 @@ export default function UserMessage() {
|
||||
const params = useLocalSearchParams();
|
||||
const { username: usernameParam } = params;
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus(router);
|
||||
}, []);
|
||||
|
||||
// 获取用户信息
|
||||
const getUserInfo = async () => {
|
||||
const res = await fetchApi<User>("/iam/user-info");
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import '../global.css';
|
||||
import { Provider } from "../provider";
|
||||
@ -9,6 +12,22 @@ import { Provider } from "../provider";
|
||||
export default function RootLayout() {
|
||||
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 (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Provider>
|
||||
|
||||
@ -1,27 +1,38 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { registerBackgroundUploadTask, triggerManualUpload } from './backgroundUploader';
|
||||
import { triggerManualUpload } from '@/lib/background-uploader/manual';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import UploaderProgressBar from './upload-progress/progress-bar';
|
||||
|
||||
export default function AutoUploadScreen() {
|
||||
const [timeRange, setTimeRange] = useState('day');
|
||||
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 () => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Upload error:', error);
|
||||
} finally {
|
||||
@ -114,23 +125,28 @@ export default function AutoUploadScreen() {
|
||||
{isLoading ? '上传中...' : '开始上传'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className='mt-2'
|
||||
style={[styles.uploadButton]}
|
||||
onPress={() => router.push('/debug')}
|
||||
>
|
||||
<Text style={styles.uploadButtonText}>
|
||||
进入db调试页面
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</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" />
|
||||
<Text style={styles.loadingText}>正在上传,请稍候...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{
|
||||
// isLoading &&
|
||||
(
|
||||
<UploaderProgressBar
|
||||
imageUrl={uploadProgress.currentFileUrl}
|
||||
uploadedCount={uploadProgress.uploadedCount}
|
||||
totalCount={uploadProgress.totalCount}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,608 +0,0 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import * as BackgroundFetch from 'expo-background-fetch';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import pLimit from 'p-limit';
|
||||
import { Alert } from 'react-native';
|
||||
import { transformData } from '../utils/objectFlat';
|
||||
|
||||
type ExtendedAsset = MediaLibrary.Asset & {
|
||||
exif?: Record<string, any>;
|
||||
};
|
||||
|
||||
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
|
||||
// 设置最大并发数
|
||||
const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件
|
||||
// 在 CONCURRENCY_LIMIT 定义后添加
|
||||
const limit = pLimit(CONCURRENCY_LIMIT);
|
||||
|
||||
// 获取文件扩展名
|
||||
const getFileExtension = (filename: string) => {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
};
|
||||
|
||||
// 获取 MIME 类型
|
||||
const getMimeType = (filename: string, isVideo: boolean) => {
|
||||
if (!isVideo) return 'image/jpeg';
|
||||
|
||||
const ext = getFileExtension(filename);
|
||||
switch (ext) {
|
||||
case 'mov':
|
||||
return 'video/quicktime';
|
||||
case 'mp4':
|
||||
return 'video/mp4';
|
||||
case 'm4v':
|
||||
return 'video/x-m4v';
|
||||
default:
|
||||
return 'video/mp4'; // 默认值
|
||||
}
|
||||
};
|
||||
|
||||
// 将 HEIC 图片转化
|
||||
const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||||
try {
|
||||
console.log('Starting HEIC to JPEG conversion for:', uri);
|
||||
|
||||
// 1. 将文件复制到缓存目录
|
||||
const cacheDir = FileSystem.cacheDirectory;
|
||||
if (!cacheDir) {
|
||||
throw new Error('Cache directory not available');
|
||||
}
|
||||
|
||||
// 创建唯一的文件名
|
||||
const tempUri = `${cacheDir}${Date.now()}.heic`;
|
||||
|
||||
// 复制文件到缓存目录
|
||||
await FileSystem.copyAsync({
|
||||
from: uri,
|
||||
to: tempUri
|
||||
});
|
||||
|
||||
// 2. 检查文件是否存在
|
||||
const fileInfo = await FileSystem.getInfoAsync(tempUri);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Temporary file was not created');
|
||||
}
|
||||
|
||||
// 3. 读取文件为 base64
|
||||
const base64 = await FileSystem.readAsStringAsync(tempUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
if (!base64) {
|
||||
throw new Error('Failed to read file as base64');
|
||||
}
|
||||
|
||||
// 4. 创建 Blob
|
||||
const response = await fetch(`data:image/jpeg;base64,${base64}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error('Failed to create blob from base64');
|
||||
}
|
||||
|
||||
// 5. 创建文件名
|
||||
const originalName = uri.split('/').pop() || 'converted';
|
||||
const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg');
|
||||
|
||||
console.log('Successfully converted HEIC to JPEG:', filename);
|
||||
|
||||
// 清理临时文件
|
||||
try {
|
||||
await FileSystem.deleteAsync(tempUri, { idempotent: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary file:', cleanupError);
|
||||
}
|
||||
|
||||
return new File([blob], filename, { type: 'image/jpeg' });
|
||||
} catch (error: unknown) {
|
||||
console.error('Detailed HEIC conversion error:', {
|
||||
error: error instanceof Error ? {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
} : error,
|
||||
uri: uri
|
||||
});
|
||||
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取指定时间范围内的媒体文件(包含 EXIF 信息)
|
||||
export const getMediaByDateRange = async (startDate: Date, endDate: Date) => {
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
console.warn('Media library permission not granted');
|
||||
return [];
|
||||
}
|
||||
|
||||
const media = await MediaLibrary.getAssetsAsync({
|
||||
mediaType: ['photo', 'video'],
|
||||
first: 100,
|
||||
sortBy: [MediaLibrary.SortBy.creationTime],
|
||||
createdAfter: startDate.getTime(),
|
||||
createdBefore: endDate.getTime(),
|
||||
});
|
||||
|
||||
// 为每个资源获取完整的 EXIF 信息
|
||||
const assetsWithExif = await Promise.all(
|
||||
media.assets.map(async (asset) => {
|
||||
try {
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id);
|
||||
return {
|
||||
...asset,
|
||||
exif: assetInfo.exif || null,
|
||||
location: assetInfo.location || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get EXIF for asset ${asset.id}:`, error);
|
||||
return asset;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return assetsWithExif;
|
||||
} catch (error) {
|
||||
console.error('Error in getMediaByDateRange:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 压缩图片
|
||||
const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => {
|
||||
try {
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: 0.7,
|
||||
format: ImageManipulator.SaveFormat.JPEG,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`;
|
||||
const file = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取上传URL
|
||||
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
|
||||
|
||||
const body = {
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
file_size: file.size,
|
||||
metadata: {
|
||||
...metadata,
|
||||
originalName: file.name,
|
||||
fileType: file.type.startsWith('video/') ? 'video' : 'image',
|
||||
isCompressed: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
};
|
||||
|
||||
// 确认上传
|
||||
const confirmUpload = async (file_id: string) => {
|
||||
return await fetchApi('/file/confirm-upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_id })
|
||||
});
|
||||
};
|
||||
|
||||
// 新增素材
|
||||
const addMaterial = async (file: string, compressFile: string) => {
|
||||
await fetchApi('/material', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{
|
||||
"file_id": file,
|
||||
"preview_file_id": compressFile
|
||||
}])
|
||||
}).catch((error) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 上传文件到URL
|
||||
const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
// 进度监听
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('Network error during upload'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 检查并请求媒体库权限
|
||||
const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
|
||||
try {
|
||||
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
|
||||
|
||||
// 如果已经授权,直接返回
|
||||
if (status === 'granted' && accessPrivileges === 'all') {
|
||||
return { hasPermission: true, status };
|
||||
}
|
||||
|
||||
// 如果没有授权,请求权限
|
||||
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
|
||||
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
|
||||
|
||||
if (!isGranted) {
|
||||
console.log('Media library permission not granted or limited access');
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermission: isGranted,
|
||||
status: newStatus
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasPermission: false };
|
||||
}
|
||||
};
|
||||
// 提取视频的首帧进行压缩并上传
|
||||
const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
|
||||
try {
|
||||
const manipResult = await compressImage(asset.uri);
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = asset.filename ?
|
||||
`compressed_${asset.filename}` :
|
||||
`image_${Date.now()}_compressed.jpg`;
|
||||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: 'image',
|
||||
isCompressed: true
|
||||
});
|
||||
|
||||
await uploadFile(compressedFile, upload_url);
|
||||
await confirmUpload(file_id);
|
||||
|
||||
console.log('视频首帧文件上传成功:', {
|
||||
fileId: file_id,
|
||||
filename: compressedFile.name,
|
||||
type: compressedFile.type
|
||||
});
|
||||
return { success: true, file_id };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
// 处理单个媒体文件上传
|
||||
const processMediaUpload = async (asset: ExtendedAsset) => {
|
||||
try {
|
||||
// 检查权限
|
||||
const { hasPermission } = await checkMediaLibraryPermission();
|
||||
if (!hasPermission) {
|
||||
throw new Error('No media library permission');
|
||||
}
|
||||
|
||||
const isVideo = asset.mediaType === 'video';
|
||||
|
||||
// 上传原始文件
|
||||
const uploadOriginalFile = async () => {
|
||||
try {
|
||||
let fileToUpload: File;
|
||||
const isVideo = asset.mediaType === 'video';
|
||||
const mimeType = getMimeType(asset.filename || '', isVideo);
|
||||
|
||||
// 生成文件名,保留原始扩展名
|
||||
let filename = asset.filename ||
|
||||
`${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? (getFileExtension(asset.filename || 'mp4') || 'mp4') : 'jpg'}`;
|
||||
|
||||
// 处理 HEIC 格式
|
||||
if (filename.toLowerCase().endsWith('.heic') || filename.toLowerCase().endsWith('.heif')) {
|
||||
fileToUpload = await convertHeicToJpeg(asset.uri);
|
||||
filename = filename.replace(/\.(heic|heif)$/i, '.jpg');
|
||||
} else {
|
||||
// 获取资源信息
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, {
|
||||
shouldDownloadFromNetwork: true
|
||||
});
|
||||
|
||||
if (!assetInfo.localUri) {
|
||||
throw new Error('无法获取资源的本地路径');
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
const fileExtension = getFileExtension(assetInfo.filename || '') ||
|
||||
(isVideo ? 'mp4' : 'jpg');
|
||||
|
||||
// 确保文件名有正确的扩展名
|
||||
if (!filename.toLowerCase().endsWith(`.${fileExtension}`)) {
|
||||
const baseName = filename.split('.')[0];
|
||||
filename = `${baseName}.${fileExtension}`;
|
||||
}
|
||||
|
||||
// 获取文件内容
|
||||
const response = await fetch(assetInfo.localUri);
|
||||
const blob = await response.blob();
|
||||
|
||||
// 创建文件对象
|
||||
fileToUpload = new File([blob], filename, { type: mimeType });
|
||||
console.log('文件准备上传:', {
|
||||
name: fileToUpload.name,
|
||||
type: fileToUpload.type,
|
||||
size: fileToUpload.size
|
||||
});
|
||||
}
|
||||
|
||||
// 准备元数据
|
||||
let exifData = {};
|
||||
if (asset.exif) {
|
||||
try {
|
||||
exifData = transformData({
|
||||
...asset,
|
||||
exif: {
|
||||
...asset.exif,
|
||||
'{MakerApple}': undefined
|
||||
}
|
||||
});
|
||||
} catch (exifError) {
|
||||
console.warn('处理 EXIF 数据时出错:', exifError);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取上传 URL
|
||||
const { upload_url, file_id } = await getUploadUrl(fileToUpload, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: isVideo ? 'video' : 'image',
|
||||
isCompressed: false,
|
||||
...exifData,
|
||||
GPSVersionID: undefined
|
||||
});
|
||||
|
||||
// 上传文件
|
||||
await uploadFile(fileToUpload, upload_url);
|
||||
await confirmUpload(file_id);
|
||||
|
||||
console.log('文件上传成功:', {
|
||||
fileId: file_id,
|
||||
filename: fileToUpload.name,
|
||||
type: fileToUpload.type
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
file_id,
|
||||
filename: fileToUpload.name
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
console.error('上传原始文件时出错:', {
|
||||
error: errorMessage,
|
||||
assetId: asset.id,
|
||||
filename: asset.filename,
|
||||
uri: asset.uri
|
||||
});
|
||||
throw new Error(`上传失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传压缩文件(仅图片)
|
||||
const uploadCompressedFile = async () => {
|
||||
if (isVideo) return { success: true, file_id: null }; // 视频不压缩
|
||||
|
||||
try {
|
||||
const manipResult = await compressImage(asset.uri);
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = asset.filename ?
|
||||
`compressed_${asset.filename}` :
|
||||
`image_${Date.now()}_compressed.jpg`;
|
||||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: 'image',
|
||||
isCompressed: true
|
||||
});
|
||||
|
||||
await uploadFile(compressedFile, upload_url);
|
||||
await confirmUpload(file_id);
|
||||
return { success: true, file_id };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
// 先上传原始文件
|
||||
const originalResult = await uploadOriginalFile();
|
||||
|
||||
// 如果是图片,再上传压缩文件
|
||||
let compressedResult = { success: true, file_id: null };
|
||||
if (!isVideo) {
|
||||
compressedResult = await uploadCompressedFile();
|
||||
// 添加素材
|
||||
addMaterial(originalResult.file_id, compressedResult?.file_id || '');
|
||||
} else {
|
||||
// 上传压缩首帧
|
||||
uploadVideoThumbnail(asset)
|
||||
}
|
||||
|
||||
return {
|
||||
originalSuccess: originalResult.success,
|
||||
compressedSuccess: compressedResult.success,
|
||||
fileIds: {
|
||||
original: originalResult.file_id,
|
||||
compressed: compressedResult.file_id
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.message === 'No media library permission') {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
originalSuccess: false,
|
||||
compressedSuccess: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 注册后台任务
|
||||
export const registerBackgroundUploadTask = async () => {
|
||||
try {
|
||||
// 检查是否已经注册了任务
|
||||
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
|
||||
if (isRegistered) {
|
||||
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK);
|
||||
}
|
||||
|
||||
// 注册后台任务
|
||||
await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
|
||||
minimumInterval: 15 * 60, // 15 分钟
|
||||
stopOnTerminate: false, // 应用退出后继续运行
|
||||
startOnBoot: true, // 设备启动后自动启动
|
||||
});
|
||||
|
||||
console.log('Background task registered');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error registering background task:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// 获取最近24小时的媒体文件
|
||||
const media = await getMediaByDateRange(oneDayAgo, now);
|
||||
|
||||
if (media.length === 0) {
|
||||
console.log('No media files to upload');
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
}
|
||||
|
||||
// 处理媒体文件上传
|
||||
const results = await triggerManualUpload(oneDayAgo, now);
|
||||
const successCount = results.filter(r => r.originalSuccess).length;
|
||||
|
||||
console.log(`Background upload completed. Success: ${successCount}/${results.length}`);
|
||||
|
||||
return successCount > 0
|
||||
? BackgroundFetch.BackgroundFetchResult.NewData
|
||||
: BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
} catch (error) {
|
||||
console.error('Background task error:', error);
|
||||
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
// 手动触发上传
|
||||
export const triggerManualUpload = async (startDate: Date, endDate: Date) => {
|
||||
try {
|
||||
const media = await getMediaByDateRange(startDate, endDate);
|
||||
if (media.length === 0) {
|
||||
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 分离图片和视频
|
||||
const photos = media.filter(item => item.mediaType === 'photo');
|
||||
const videos = media.filter(item => item.mediaType === 'video');
|
||||
console.log('videos11111111', videos);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
// 处理所有图片(带并发控制)
|
||||
const processPhoto = async (item: any) => {
|
||||
try {
|
||||
const result = await processMediaUpload(item);
|
||||
results.push({
|
||||
id: item.id,
|
||||
...result
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
id: item.id,
|
||||
originalSuccess: false,
|
||||
compressedSuccess: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理所有视频(带并发控制)
|
||||
const processVideo = async (item: any) => {
|
||||
try {
|
||||
const result = await processMediaUpload(item);
|
||||
results.push({
|
||||
id: item.id,
|
||||
...result
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
id: item.id,
|
||||
originalSuccess: false,
|
||||
compressedSuccess: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 并发处理图片和视频
|
||||
await Promise.all([
|
||||
...photos.map(photo => limit(() => processPhoto(photo))),
|
||||
...videos.map(video => limit(() => processVideo(video)))
|
||||
]);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '上传过程中出现错误');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,8 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { defaultExifData, ExifData, ImagesuploaderProps, UploadUrlResponse } from '@/types/upload';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api';
|
||||
import { ConfirmUpload, ExifData, FileUploadItem, ImagesuploaderProps, UploadResult, UploadTask, defaultExifData } from '@/lib/background-uploader/types';
|
||||
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 Location from 'expo-location';
|
||||
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 UploadPreview from './preview';
|
||||
|
||||
// 在文件顶部添加这些类型
|
||||
type UploadTask = {
|
||||
file: File;
|
||||
metadata: {
|
||||
isCompressed: string;
|
||||
type: string;
|
||||
isThumbnail?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
type FileUploadItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
error: string | null;
|
||||
type: 'image' | 'video';
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
type ConfirmUpload = {
|
||||
file_id: string;
|
||||
upload_url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
file_path: string;
|
||||
};
|
||||
|
||||
type UploadResult = {
|
||||
originalUrl?: string;
|
||||
compressedUrl: string;
|
||||
file: File | null;
|
||||
exif: any;
|
||||
originalFile: ConfirmUpload;
|
||||
compressedFile: ConfirmUpload;
|
||||
thumbnail: string;
|
||||
thumbnailFile: File;
|
||||
};
|
||||
export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
children,
|
||||
style,
|
||||
@ -61,7 +23,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
fileType = ['images'],
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [files, setFiles] = useState<FileUploadItem[]>([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
|
||||
|
||||
// 请求权限
|
||||
@ -81,191 +43,6 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取上传URL
|
||||
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
|
||||
const body = {
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
file_size: file.size,
|
||||
metadata: {
|
||||
...metadata,
|
||||
originalName: file.name,
|
||||
fileType: 'image',
|
||||
isCompressed: metadata.isCompressed || 'false',
|
||||
},
|
||||
};
|
||||
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
};
|
||||
|
||||
// 向服务端confirm上传
|
||||
const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
file_id
|
||||
})
|
||||
});
|
||||
|
||||
// 新增素材
|
||||
const addMaterial = async (file: string, compressFile: string) => {
|
||||
await fetchApi('/material', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{
|
||||
"file_id": file,
|
||||
"preview_file_id": compressFile
|
||||
}])
|
||||
}).catch((error) => {
|
||||
// console.log(error);
|
||||
})
|
||||
}
|
||||
|
||||
// 上传文件到URL
|
||||
const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
// 进度监听
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('Network error during upload'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 压缩并处理图片
|
||||
const processImage = async (uri: string, fileName: string, mimeType: string) => {
|
||||
try {
|
||||
// 压缩图片
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: compressQuality,
|
||||
format: ImageManipulator.SaveFormat.JPEG,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
// 获取压缩后的图片数据
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
// 创建文件对象
|
||||
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
return { file, uri: manipResult.uri };
|
||||
} catch (error) {
|
||||
// console.error('图片压缩失败:', error);
|
||||
throw new Error('图片处理失败');
|
||||
}
|
||||
};
|
||||
const uploadWithProgress = async (file: File, metadata: any): Promise<ConfirmUpload> => {
|
||||
let timeoutId: number
|
||||
console.log("uploadWithProgress", metadata);
|
||||
|
||||
try {
|
||||
console.log("Starting upload for file:", file.name, "size:", file.size, "type:", file.type);
|
||||
|
||||
// 检查文件大小
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error(`文件大小超过限制 (${(MAX_FILE_SIZE / 1024 / 1024).toFixed(1)}MB)`);
|
||||
}
|
||||
|
||||
const uploadUrlData = await getUploadUrl(file, { ...metadata, GPSVersionID: undefined });
|
||||
console.log("Got upload URL for:", file.name);
|
||||
|
||||
return new Promise<ConfirmUpload>((resolve, reject) => {
|
||||
try {
|
||||
// 设置超时
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error('上传超时,请检查网络连接'));
|
||||
}, 30000);
|
||||
|
||||
// 上传文件
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrlData.upload_url, true);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
console.log(`Upload progress for ${file.name}: ${progress}%`);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = async () => {
|
||||
clearTimeout(timeoutId!);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const result = await confirmUpload(uploadUrlData.file_id);
|
||||
resolve({
|
||||
...result,
|
||||
file_id: uploadUrlData.file_id,
|
||||
upload_url: uploadUrlData.upload_url,
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`上传失败,状态码: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
clearTimeout(timeoutId!);
|
||||
reject(new Error('网络错误,请检查网络连接'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId!);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in uploadWithProgress:', {
|
||||
error,
|
||||
fileName: file?.name,
|
||||
fileSize: file?.size,
|
||||
fileType: file?.type
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单个资源
|
||||
const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise<UploadResult | null> => {
|
||||
console.log("asset111111", asset);
|
||||
@ -285,12 +62,14 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
// 创建上传项
|
||||
const newFileItem: FileUploadItem = {
|
||||
id: fileId,
|
||||
uri: asset.uri,
|
||||
previewUrl: asset.uri, // 使用 asset.uri 作为初始预览
|
||||
name: asset.fileName || 'file',
|
||||
progress: 0,
|
||||
status: 'uploading' as const,
|
||||
error: null,
|
||||
status: 'uploading',
|
||||
error: undefined,
|
||||
type: isVideo ? 'video' : 'image',
|
||||
thumbnail: null,
|
||||
thumbnail: undefined,
|
||||
};
|
||||
|
||||
setUploadQueue(prev => [...prev, newFileItem]);
|
||||
@ -316,28 +95,14 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
|
||||
// 生成视频缩略图
|
||||
const thumbnailResult = await ImageManipulator.manipulateAsync(
|
||||
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' }
|
||||
);
|
||||
// 使用复用函数生成视频缩略图
|
||||
thumbnailFile = await createVideoThumbnailFile(asset, 300);
|
||||
} else {
|
||||
// 处理图片
|
||||
const [originalResponse, compressedFileResult] = await Promise.all([
|
||||
fetch(asset.uri),
|
||||
ImageManipulator.manipulateAsync(
|
||||
asset.uri,
|
||||
[{ resize: { width: 800 } }],
|
||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
|
||||
)
|
||||
]);
|
||||
// 处理图片,主图和缩略图都用 compressImage 方法
|
||||
// 主图压缩(按 maxWidth/maxHeight/compressQuality)
|
||||
const { file: compressedFile } = await compressImage(asset.uri, maxWidth);
|
||||
// 缩略图压缩(宽度800)
|
||||
const { file: thumbFile } = await compressImage(asset.uri, 800);
|
||||
|
||||
// 如果保留 EXIF 数据,则获取
|
||||
if (preserveExif && asset.exif) {
|
||||
@ -358,20 +123,10 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const originalBlob = await originalResponse.blob();
|
||||
const compressedBlob = await compressedFileResult.file;
|
||||
|
||||
file = new File(
|
||||
[originalBlob],
|
||||
`original_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
|
||||
{ type: asset.mimeType || 'image/jpeg' }
|
||||
);
|
||||
|
||||
thumbnailFile = new File(
|
||||
[compressedBlob],
|
||||
`compressed_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
|
||||
{ type: 'image/jpeg' }
|
||||
);
|
||||
// 用压缩后主图作为上传主文件
|
||||
file = compressedFile as File;
|
||||
// 用缩略图文件作为预览
|
||||
thumbnailFile = thumbFile as File;
|
||||
}
|
||||
|
||||
// 准备上传任务
|
||||
@ -401,8 +156,23 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
const uploadResultsList = [];
|
||||
for (const task of uploadTasks) {
|
||||
try {
|
||||
const result = await uploadWithProgress(task.file, task.metadata);
|
||||
uploadResultsList.push(result);
|
||||
// 统一通过 lib 的 uploadFileWithProgress 实现上传
|
||||
const uploadUrlData = await getUploadUrl(task.file, { ...task.metadata, GPSVersionID: undefined });
|
||||
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) {
|
||||
console.error('Upload failed:', error);
|
||||
throw error;
|
||||
@ -423,7 +193,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
item.id === fileId
|
||||
? {
|
||||
...item,
|
||||
status: 'done' as const,
|
||||
status: 'success' as const,
|
||||
progress: 100,
|
||||
thumbnail: uploadResults.thumbnail
|
||||
}
|
||||
@ -435,7 +205,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
if (uploadResults.originalFile?.file_id) {
|
||||
await addMaterial(
|
||||
uploadResults.originalFile.file_id,
|
||||
uploadResults.thumbnail
|
||||
uploadResults.compressedFile?.file_id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -50,85 +50,67 @@ const MediaStatsScreen = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 设置时间范围
|
||||
// 2. 设置时间范围,直接使用Date对象
|
||||
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条
|
||||
let hasNextPage = true;
|
||||
let after = undefined;
|
||||
// 3. 分页获取媒体资源
|
||||
let allAssets: MediaLibrary.Asset[] = [];
|
||||
const pageSize = 10; // 每次获取10条
|
||||
let hasNextPage = true;
|
||||
let after: MediaLibrary.AssetRef | undefined = undefined;
|
||||
const pageSize = 100; // 增加每次获取的数量以提高效率
|
||||
|
||||
while (hasNextPage) {
|
||||
const media = await MediaLibrary.getAssetsAsync({
|
||||
first: pageSize,
|
||||
after,
|
||||
mediaType: ['photo', 'video', 'audio', 'unknown'],
|
||||
sortBy: 'creationTime', // 按创建时间降序,最新的在前面
|
||||
createdAfter: Date.now() - 24 * 30 * 12 * 60 * 60 * 1000, // 时间戳(毫秒)
|
||||
createdBefore: Date.now(), // 时间戳(毫秒)
|
||||
sortBy: ['creationTime'],
|
||||
mediaType: ['photo', 'video', 'audio'],
|
||||
createdAfter: dateRange?.start,
|
||||
createdBefore: dateRange?.end,
|
||||
});
|
||||
|
||||
// 如果没有数据,直接退出
|
||||
if (media.assets.length === 0) {
|
||||
break;
|
||||
if (media.assets.length > 0) {
|
||||
allAssets.push(...media.assets);
|
||||
}
|
||||
|
||||
// 检查每条记录是否在时间范围内
|
||||
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;
|
||||
hasNextPage = media.hasNextPage;
|
||||
after = media.endCursor;
|
||||
|
||||
// 如果没有更多数据或者已经获取了足够的数据
|
||||
if (!hasNextPage || allAssets.length >= 1000) {
|
||||
// 可选:增加一个最大获取上限,防止无限循环
|
||||
if (allAssets.length > 2000) {
|
||||
console.warn('已达到2000个媒体文件的上限');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`总共获取到 ${allAssets.length} 个媒体文件`);
|
||||
|
||||
// 4. 统计不同类型媒体的数量
|
||||
const stats: MediaStats = {
|
||||
total: allAssets.length,
|
||||
photos: 0,
|
||||
videos: 0,
|
||||
audios: 0,
|
||||
others: 0,
|
||||
byMonth: {},
|
||||
};
|
||||
// 4. 使用 reduce 进行统计,更高效
|
||||
const stats = allAssets.reduce<MediaStats>((acc, asset) => {
|
||||
acc.total++;
|
||||
switch (asset.mediaType) {
|
||||
case 'photo':
|
||||
acc.photos++;
|
||||
break;
|
||||
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) {
|
||||
const date = new Date(asset.creationTime);
|
||||
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);
|
||||
|
||||
@ -186,6 +186,7 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
|
||||
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
|
||||
type: mimeType,
|
||||
});
|
||||
console.log("压缩后的文件", file);
|
||||
|
||||
return { file, uri: manipResult.uri };
|
||||
} catch (error) {
|
||||
|
||||
107
components/file-upload/upload-progress/progress-bar.tsx
Normal file
107
components/file-upload/upload-progress/progress-bar.tsx
Normal 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;
|
||||
33
components/file-upload/upload-progress/uploader-progress.tsx
Normal file
33
components/file-upload/upload-progress/uploader-progress.tsx
Normal 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);
|
||||
@ -1,39 +0,0 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as VideoThumbnail from 'expo-video-thumbnails';
|
||||
|
||||
export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
|
||||
try {
|
||||
// 获取视频的第一帧
|
||||
const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
|
||||
videoUri,
|
||||
{
|
||||
time: 1000, // 1秒的位置
|
||||
quality: 0.8,
|
||||
}
|
||||
);
|
||||
|
||||
// 转换为 WebP 格式
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
thumbnailUri,
|
||||
[{ resize: { width: 800 } }], // 调整大小以提高性能
|
||||
{
|
||||
compress: 0.8,
|
||||
format: ImageManipulator.SaveFormat.WEBP
|
||||
}
|
||||
);
|
||||
|
||||
// 转换为 File 对象
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const file = new File(
|
||||
[blob],
|
||||
`thumb_${Date.now()}.webp`,
|
||||
{ type: 'image/webp' }
|
||||
);
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
console.error('Error generating video thumbnail:', error);
|
||||
throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
};
|
||||
@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 从视频文件中提取第一帧并返回为File对象
|
||||
* @param videoFile 视频文件
|
||||
* @returns 包含视频第一帧的File对象
|
||||
*/
|
||||
export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const videoUrl = URL.createObjectURL(videoFile);
|
||||
const video = document.createElement('video');
|
||||
video.src = videoUrl;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
|
||||
video.onloadeddata = () => {
|
||||
try {
|
||||
// 设置视频时间到第一帧
|
||||
video.currentTime = 0.1;
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取canvas上下文');
|
||||
}
|
||||
|
||||
// 绘制视频帧到canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 将canvas转换为DataURL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
|
||||
// 将DataURL转换为Blob
|
||||
const byteString = atob(dataUrl.split(',')[1]);
|
||||
const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
|
||||
// 创建File对象
|
||||
const frameFile = new File(
|
||||
[blob],
|
||||
`${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
|
||||
{ type: 'image/jpeg' }
|
||||
);
|
||||
|
||||
// 清理URL对象
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
resolve(frameFile);
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(new Error('视频加载失败'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 获取视频时长
|
||||
export const getVideoDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(video.duration);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(0); // Return 0 if we can't get the duration
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 根据 mp4 的url来获取视频时长
|
||||
/**
|
||||
* 根据视频URL获取视频时长
|
||||
* @param videoUrl 视频的URL
|
||||
* @returns 返回一个Promise,解析为视频时长(秒)
|
||||
*/
|
||||
export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// 创建临时的video元素
|
||||
const video = document.createElement('video');
|
||||
|
||||
// 设置为只加载元数据,不加载整个视频
|
||||
video.preload = 'metadata';
|
||||
|
||||
// 处理加载成功
|
||||
video.onloadedmetadata = () => {
|
||||
// 释放URL对象
|
||||
URL.revokeObjectURL(video.src);
|
||||
// 返回视频时长(秒)
|
||||
resolve(video.duration);
|
||||
};
|
||||
|
||||
// 处理加载错误
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
reject(new Error('无法加载视频'));
|
||||
};
|
||||
|
||||
// 处理网络错误
|
||||
video.onabort = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
reject(new Error('视频加载被中止'));
|
||||
};
|
||||
|
||||
// 设置视频源
|
||||
video.src = videoUrl;
|
||||
|
||||
// 添加跨域属性(如果需要)
|
||||
video.setAttribute('crossOrigin', 'anonymous');
|
||||
|
||||
// 开始加载元数据
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
@ -43,7 +43,7 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
|
||||
const res = await fetchApi<User>('/iam/login/password-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, true, false);
|
||||
login({ ...res, email: res?.account }, res.access_token || '');
|
||||
const userInfo = await fetchApi<User>("/iam/user-info");
|
||||
if (userInfo?.nickname) {
|
||||
|
||||
8
components/navigation/TabBarIcon.tsx
Normal file
8
components/navigation/TabBarIcon.tsx
Normal 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} />;
|
||||
}
|
||||
@ -2,12 +2,10 @@ import { Steps } from '@/app/(tabs)/user-message';
|
||||
import ChoicePhoto from '@/assets/icons/svg/choicePhoto.svg';
|
||||
import LookSvg from '@/assets/icons/svg/look.svg';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { FileUploadItem } from '@/types/upload';
|
||||
import { FileUploadItem } from '@/lib/background-uploader/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
|
||||
import AutoUploadScreen from '../file-upload/autoUploadScreen';
|
||||
import FilesUploader from '../file-upload/files-uploader';
|
||||
import MediaStatsScreen from '../file-upload/getTotal';
|
||||
|
||||
interface Props {
|
||||
setSteps?: (steps: Steps) => void;
|
||||
@ -34,11 +32,11 @@ export default function Look(props: Props) {
|
||||
{t('auth.userMessage.avatorText2', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
{
|
||||
fileData[0]?.preview
|
||||
fileData[0]?.previewUrl
|
||||
?
|
||||
<Image
|
||||
className='rounded-full w-[10rem] h-[10rem]'
|
||||
source={{ uri: fileData[0].preview }}
|
||||
source={{ uri: fileData[0].previewUrl }}
|
||||
/>
|
||||
:
|
||||
avatar
|
||||
@ -64,8 +62,8 @@ export default function Look(props: Props) {
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
<AutoUploadScreen />
|
||||
<MediaStatsScreen />
|
||||
{/* <AutoUploadScreen /> */}
|
||||
{/* <MediaStatsScreen /> */}
|
||||
</View>
|
||||
|
||||
<View className="w-full">
|
||||
|
||||
54
features/appState/appStateSlice.ts
Normal file
54
features/appState/appStateSlice.ts
Normal 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;
|
||||
@ -15,7 +15,8 @@
|
||||
"task": "Task",
|
||||
"taskName": "dynamic family portrait",
|
||||
"taskStatus": "processing",
|
||||
"noName": "No Name"
|
||||
"noName": "No Name",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
"task": "任务",
|
||||
"taskName": "动态全家福",
|
||||
"taskStatus": "正在处理中",
|
||||
"noName": "未命名作品"
|
||||
"noName": "未命名作品",
|
||||
"uploading": "上传中"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
11
jest.config.ts
Normal file
11
jest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
const { createDefaultPreset } = require("ts-jest");
|
||||
|
||||
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||
|
||||
/** @type {import("jest").Config} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
...tsJestTransformCfg,
|
||||
},
|
||||
};
|
||||
39
lib/auth.ts
Normal file
39
lib/auth.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
import { API_ENDPOINT } from './server-api-util';
|
||||
|
||||
|
||||
export async function identityCheck(token: string) {
|
||||
const res = await fetch(`${API_ENDPOINT}/v1/iam/identity-check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.code == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录态,未登录自动跳转到 /login,已登录可执行回调。
|
||||
* @param onAuthed 已登录时的回调(可选)
|
||||
*/
|
||||
export async function checkAuthStatus(router: ReturnType<typeof useRouter>, onAuthed?: () => Promise<void> | void) {
|
||||
let token: string | null = '';
|
||||
if (Platform.OS === 'web') {
|
||||
token = localStorage.getItem('token') || '';
|
||||
} else {
|
||||
token = await SecureStore.getItemAsync('token') || '';
|
||||
}
|
||||
|
||||
const loggedIn = !!token && await identityCheck(token);
|
||||
if (!loggedIn) {
|
||||
router.replace('/login');
|
||||
return false;
|
||||
}
|
||||
if (onAuthed) {
|
||||
await onAuthed();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
41
lib/background-uploader/api.ts
Normal file
41
lib/background-uploader/api.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { ConfirmUpload, UploadUrlResponse } from './types';
|
||||
|
||||
// 获取上传URL
|
||||
export const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
|
||||
const body = {
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
file_size: file.size,
|
||||
metadata: {
|
||||
...metadata,
|
||||
originalName: file.name,
|
||||
fileType: file.type.startsWith('video/') ? 'video' : 'image',
|
||||
isCompressed: metadata.isCompressed || 'false',
|
||||
},
|
||||
};
|
||||
return await fetchApi<UploadUrlResponse>('/file/generate-upload-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
};
|
||||
|
||||
// 确认上传
|
||||
// 确认上传
|
||||
export const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => {
|
||||
return await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_id })
|
||||
});
|
||||
};
|
||||
|
||||
// 新增素材
|
||||
export const addMaterial = async (file: string, compressFile: string) => {
|
||||
await fetchApi('/material', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{
|
||||
"file_id": file,
|
||||
"preview_file_id": compressFile
|
||||
}])
|
||||
});
|
||||
}
|
||||
123
lib/background-uploader/automatic.ts
Normal file
123
lib/background-uploader/automatic.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
82
lib/background-uploader/manual.ts
Normal file
82
lib/background-uploader/manual.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
90
lib/background-uploader/media.ts
Normal file
90
lib/background-uploader/media.ts
Normal 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 [];
|
||||
}
|
||||
};
|
||||
27
lib/background-uploader/summary.md
Normal file
27
lib/background-uploader/summary.md
Normal 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. 提供上传的**进度跟踪**。
|
||||
|
||||
该模块通过必要的预处理和错误处理,确保高效、可靠的媒体上传,无论是自动还是按需上传。
|
||||
135
lib/background-uploader/types.ts
Normal file
135
lib/background-uploader/types.ts
Normal 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;
|
||||
};
|
||||
239
lib/background-uploader/uploader.ts
Normal file
239
lib/background-uploader/uploader.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
50
lib/background-uploader/utils.ts
Normal file
50
lib/background-uploader/utils.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
// 检查并请求媒体库权限
|
||||
export const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
|
||||
try {
|
||||
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
|
||||
|
||||
// 如果已经授权,直接返回
|
||||
if (status === 'granted' && accessPrivileges === 'all') {
|
||||
return { hasPermission: true, status };
|
||||
}
|
||||
|
||||
// 如果没有授权,请求权限
|
||||
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
|
||||
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
|
||||
|
||||
if (!isGranted) {
|
||||
console.log('Media library permission not granted or limited access');
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermission: isGranted,
|
||||
status: newStatus
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasPermission: false };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件扩展名
|
||||
export const getFileExtension = (filename: string) => {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
};
|
||||
|
||||
// 获取 MIME 类型
|
||||
export const getMimeType = (filename: string, isVideo: boolean) => {
|
||||
if (!isVideo) return 'image/jpeg';
|
||||
|
||||
const ext = getFileExtension(filename);
|
||||
switch (ext) {
|
||||
case 'mov':
|
||||
return 'video/quicktime';
|
||||
case 'mp4':
|
||||
return 'video/mp4';
|
||||
case 'm4v':
|
||||
return 'video/x-m4v';
|
||||
default:
|
||||
return 'video/mp4'; // 默认值
|
||||
}
|
||||
};
|
||||
176
lib/db.ts
Normal file
176
lib/db.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
||||
47
lib/image-process/heicConvert.ts
Normal file
47
lib/image-process/heicConvert.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
// 将 HEIC/HEIF 图片转为 JPEG
|
||||
export const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||||
try {
|
||||
// 1. 将文件复制到缓存目录
|
||||
const cacheDir = FileSystem.cacheDirectory;
|
||||
if (!cacheDir) {
|
||||
throw new Error('Cache directory not available');
|
||||
}
|
||||
const tempUri = `${cacheDir}${Date.now()}.heic`;
|
||||
await FileSystem.copyAsync({ from: uri, to: tempUri });
|
||||
// 2. 检查文件是否存在
|
||||
const fileInfo = await FileSystem.getInfoAsync(tempUri);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Temporary file was not created');
|
||||
}
|
||||
// 3. 读取文件为 base64
|
||||
const base64 = await FileSystem.readAsStringAsync(tempUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (!base64) {
|
||||
throw new Error('Failed to read file as base64');
|
||||
}
|
||||
// 4. 创建 Blob
|
||||
const response = await fetch(`data:image/jpeg;base64,${base64}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed with status ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error('Failed to create blob from base64');
|
||||
}
|
||||
// 5. 创建文件名
|
||||
const originalName = uri.split('/').pop() || 'converted';
|
||||
const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg');
|
||||
// 清理临时文件
|
||||
try {
|
||||
await FileSystem.deleteAsync(tempUri, { idempotent: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary file:', cleanupError);
|
||||
}
|
||||
return new File([blob], filename, { type: 'image/jpeg' });
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
|
||||
}
|
||||
};
|
||||
56
lib/image-process/imageCompress.ts
Normal file
56
lib/image-process/imageCompress.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { Image } from 'react-native';
|
||||
|
||||
// 压缩图片,自动等比缩放,最大边不超过 800
|
||||
export const compressImage = async (uri: string, maxSize = 800): Promise<{ uri: string; file: File }> => {
|
||||
// 获取原图尺寸
|
||||
const getImageSize = (uri: string): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => resolve({ width, height }),
|
||||
reject
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const { width, height } = await getImageSize(uri);
|
||||
let targetWidth = width;
|
||||
let targetHeight = height;
|
||||
if (width > maxSize || height > maxSize) {
|
||||
if (width > height) {
|
||||
targetWidth = maxSize;
|
||||
targetHeight = Math.round((height / width) * maxSize);
|
||||
} else {
|
||||
targetHeight = maxSize;
|
||||
targetWidth = Math.round((width / height) * maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: 0.7,
|
||||
format: ImageManipulator.SaveFormat.WEBP,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = uri.split('/').pop() || `image_${Date.now()}.webp`;
|
||||
const file = new File([blob], filename, { type: 'image/webp' });
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
51
lib/video-process/videoThumbnail.ts
Normal file
51
lib/video-process/videoThumbnail.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { confirmUpload, getUploadUrl } from '../background-uploader/api';
|
||||
import { ExtendedAsset } from '../background-uploader/types';
|
||||
import { uploadFile } from '../background-uploader/uploader';
|
||||
import { compressImage } from '../image-process/imageCompress';
|
||||
|
||||
/**
|
||||
* @description 从视频资源创建缩略图文件
|
||||
* @param asset 媒体资源
|
||||
* @param width 缩略图宽度,默认为 300
|
||||
* @returns 返回一个包含缩略图的 File 对象
|
||||
*/
|
||||
export const createVideoThumbnailFile = async (asset: { uri: string }, width: number = 300): Promise<File> => {
|
||||
const thumbnailResult = await ImageManipulator.manipulateAsync(
|
||||
asset.uri,
|
||||
[{ resize: { width } }],
|
||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.WEBP }
|
||||
);
|
||||
|
||||
const response = await fetch(thumbnailResult.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
return new File([blob], `thumb_${Date.now()}.webp`, { type: 'image/webp' });
|
||||
};
|
||||
|
||||
// 提取视频的首帧进行压缩并上传
|
||||
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
|
||||
try {
|
||||
const manipResult = await compressImage(asset.uri);
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = asset.filename ?
|
||||
`compressed_${asset.filename}` :
|
||||
`image_${Date.now()}_compressed.jpg`;
|
||||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: 'image',
|
||||
isCompressed: true
|
||||
});
|
||||
|
||||
await uploadFile(compressedFile, upload_url);
|
||||
await confirmUpload(file_id);
|
||||
|
||||
return { success: true, file_id };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
5637
package-lock.json
generated
5637
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -10,7 +10,8 @@
|
||||
"web": "expo start --web --port 5173",
|
||||
"lint": "expo lint",
|
||||
"prebuild": "npm run generate:translations",
|
||||
"generate:translations": "tsx i18n/generate-imports.ts"
|
||||
"generate:translations": "tsx i18n/generate-imports.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@ -18,10 +19,11 @@
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/p-limit": "^2.2.0",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"expo": "~53.0.12",
|
||||
"expo-audio": "~0.4.7",
|
||||
"expo-background-fetch": "^13.1.6",
|
||||
"expo-background-task": "^0.2.8",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-dev-client": "~5.2.1",
|
||||
@ -39,6 +41,7 @@
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-sqlite": "~15.2.14",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.9",
|
||||
@ -51,6 +54,7 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"nativewind": "^4.1.23",
|
||||
"p-limit": "^6.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
@ -73,13 +77,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "~19.0.10",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~9.2.0",
|
||||
"jest": "^30.0.4",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.4.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.8.3"
|
||||
@ -92,4 +99,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
scripts/android_local_build.sh
Normal file
1
scripts/android_local_build.sh
Normal file
@ -0,0 +1 @@
|
||||
eas build --platform android --profile development --local
|
||||
12
store.ts
12
store.ts
@ -1,15 +1,21 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import { counterSlice } from './components/steps';
|
||||
import authReducer from './features/auth/authSlice';
|
||||
import appStateReducer from './features/appState/appStateSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
counter: counterSlice.reducer,
|
||||
auth: authReducer
|
||||
auth: authReducer,
|
||||
appState: appStateReducer
|
||||
},
|
||||
});
|
||||
|
||||
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
|
||||
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;
|
||||
@ -2,6 +2,7 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"],
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
210
types/upload.ts
210
types/upload.ts
@ -1,210 +0,0 @@
|
||||
import { MediaType } from "expo-image-picker";
|
||||
import { ReactNode } from "react";
|
||||
import { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
export interface MaterialFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface OutputVideoFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ManualTask {
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
status: 'Created' | 'Processing' | 'Completed' | 'Failed';
|
||||
created_at: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
failure_reason: string | null;
|
||||
template_id: number;
|
||||
source_files: MaterialFile[];
|
||||
output_video_file?: OutputVideoFile;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
value: number;
|
||||
unit: 'B' | 'KB' | 'MB' | 'GB' | 'TB';
|
||||
}
|
||||
|
||||
export interface ContentType {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FilePath {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type UploadStatus = 'Pending' | 'Uploading' | 'Completed' | 'Failed';
|
||||
export type DeletionStatus = 'Active' | 'PendingDeletion' | 'Deleted';
|
||||
|
||||
export interface ConfirmUpload {
|
||||
file_id: string;
|
||||
upload_url: string
|
||||
name: string;
|
||||
size: Size;
|
||||
content_type: ContentType;
|
||||
upload_time: string; // ISO date string
|
||||
storage_medium: string;
|
||||
file_path: FilePath;
|
||||
uploader_id: number;
|
||||
upload_status: UploadStatus;
|
||||
deletion_status: DeletionStatus;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
// 定义 EXIF 数据类型
|
||||
export type ExifData = {
|
||||
GPSLatitude?: number | undefined;
|
||||
GPSLongitude?: number | undefined;
|
||||
GPSAltitude?: number | undefined;
|
||||
DateTimeOriginal?: string | undefined;
|
||||
Make?: string | undefined;
|
||||
Model?: string | undefined;
|
||||
ExposureTime?: number | undefined;
|
||||
FNumber?: number | undefined;
|
||||
ISOSpeedRatings?: number | undefined;
|
||||
FocalLength?: number | undefined;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// 默认的 EXIF 数据结构
|
||||
export const defaultExifData: ExifData = {
|
||||
GPSLatitude: undefined,
|
||||
GPSLongitude: undefined,
|
||||
GPSAltitude: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
Make: undefined,
|
||||
Model: undefined,
|
||||
ExposureTime: undefined,
|
||||
FNumber: undefined,
|
||||
ISOSpeedRatings: undefined,
|
||||
FocalLength: undefined,
|
||||
};
|
||||
|
||||
// 压缩图片可配置参数
|
||||
export interface ImagesuploaderProps {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onPickImage?: (file: File, exifData: ExifData) => void;
|
||||
/** 压缩质量,0-1 之间的数字,默认为 0.8 */
|
||||
compressQuality?: number;
|
||||
/** 最大宽度,图片会被等比例缩放 */
|
||||
maxWidth?: number;
|
||||
/** 最大高度,图片会被等比例缩放 */
|
||||
maxHeight?: number;
|
||||
/** 是否保留 EXIF 数据,默认为 true */
|
||||
preserveExif?: boolean;
|
||||
/** 是否上传原图,默认为 false */
|
||||
uploadOriginal?: boolean;
|
||||
/** 上传完成回调 */
|
||||
onUploadComplete?: UploadCompleteCallback;
|
||||
/** 进度 */
|
||||
onProgress?: (progress: FileStatus) => void;
|
||||
/** 多选单选 默认单选*/
|
||||
multipleChoice?: boolean;
|
||||
/** 文件类型 默认图片*/
|
||||
fileType?: MediaType[];
|
||||
/** 是否展示预览 默认展示*/
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
// 定义上传结果类型
|
||||
export interface UploadResult {
|
||||
originalUrl?: string;
|
||||
compressedUrl: string;
|
||||
file: File;
|
||||
exifData: ExifData;
|
||||
originalFile: ConfirmUpload;
|
||||
compressedFile: ConfirmUpload;
|
||||
thumbnail: string;
|
||||
thumbnailFile: File;
|
||||
}
|
||||
|
||||
// 定义上传完成回调类型
|
||||
export type UploadCompleteCallback = (result: FileUploadItem[]) => void;
|
||||
|
||||
// 单张图片上传完成回调类型
|
||||
export type UploadSingleCompleteCallback = (result: FileUploadItem) => void;
|
||||
|
||||
// 定义上传 URL 响应类型
|
||||
export interface UploadUrlResponse {
|
||||
expires_in: number;
|
||||
file_id: string;
|
||||
file_path: string;
|
||||
upload_url: string;
|
||||
}
|
||||
interface FileSize {
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
originalName: string;
|
||||
type: string;
|
||||
isCompressed: string;
|
||||
fileType: string;
|
||||
}
|
||||
interface FileInfo {
|
||||
file_id: number;
|
||||
name: string;
|
||||
size: FileSize;
|
||||
content_type: ContentType;
|
||||
upload_time: string;
|
||||
storage_medium: string;
|
||||
file_path: FilePath;
|
||||
uploader_id: number;
|
||||
upload_status: string;
|
||||
deletion_status: string;
|
||||
metadata: FileMetadata;
|
||||
}
|
||||
export interface FileUploadItem {
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
previewUrl: string;
|
||||
file?: File;
|
||||
type: 'image' | 'video';
|
||||
thumbnail?: string; // 缩略图URL
|
||||
thumbnailFile?: File; // 缩略图文件对象
|
||||
originalFile?: FileInfo
|
||||
}
|
||||
|
||||
// 压缩图片可配置参数
|
||||
export interface ImagesPickerProps {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onPickImage?: (file: File, exifData: ExifData) => void;
|
||||
/** 压缩质量,0-1 之间的数字,默认为 0.8 */
|
||||
compressQuality?: number;
|
||||
/** 最大宽度,图片会被等比例缩放 */
|
||||
maxWidth?: number;
|
||||
/** 最大高度,图片会被等比例缩放 */
|
||||
maxHeight?: number;
|
||||
/** 是否保留 EXIF 数据,默认为 true */
|
||||
preserveExif?: boolean;
|
||||
/** 是否上传原图,默认为 false */
|
||||
uploadOriginal?: boolean;
|
||||
/** 上传完成回调 */
|
||||
onUploadComplete?: UploadSingleCompleteCallback;
|
||||
/** 进度 */
|
||||
onProgress?: (progress: FileStatus) => void;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user