Compare commits
19 Commits
26c0baf975
...
18f7fab613
| Author | SHA1 | Date | |
|---|---|---|---|
| 18f7fab613 | |||
| d1d7fbbe30 | |||
| c7df8a66d0 | |||
| d35ee35bad | |||
| e2c5493c8c | |||
| e7e2c05bcd | |||
| a65f88e9a9 | |||
| f63497f3a1 | |||
| 2505df0182 | |||
| 5abb5a6836 | |||
| 56d8737bc9 | |||
| 49012df68e | |||
| 521a4d0a51 | |||
| 006db2af07 | |||
| 6c270302f5 | |||
| 153838aec0 | |||
| 125da0e660 | |||
| b8d00ef850 | |||
| 1c02968071 |
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
@ -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
@ -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,14 +1,164 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
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';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Tabs } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
interface PollingData {
|
||||
title: string;
|
||||
id: string;
|
||||
content: string;
|
||||
extra: any;
|
||||
}
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [pollingData, setPollingData] = useState<PollingData[]>([]);
|
||||
const pollingInterval = useRef<NodeJS.Timeout | number>(null);
|
||||
const tokenInterval = useRef<NodeJS.Timeout | number>(null);
|
||||
const isMounted = useRef(true);
|
||||
const [token, setToken] = useState('');
|
||||
const sendNotification = async (item: PollingData) => {
|
||||
// 请求通知权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
alert('请先允许通知权限');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调度本地通知
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: item.title,
|
||||
body: item.content,
|
||||
data: { screen: 'ask', extra: item.extra, id: item.id },
|
||||
priority: 'high', // 关键:设置 high 或 max
|
||||
},
|
||||
trigger: {
|
||||
seconds: 2, // 延迟2秒显示
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL // 添加 type 字段
|
||||
}, // 延迟2秒显示
|
||||
});
|
||||
};
|
||||
|
||||
// 监听通知点击事件
|
||||
useEffect(() => {
|
||||
const notificationListener = Notifications.addNotificationResponseReceivedListener(response => {
|
||||
const data = response.notification.request.content.data;
|
||||
console.log('通知被点击,数据:', data);
|
||||
pollingData?.filter((item) => item.id !== data.id);
|
||||
});
|
||||
|
||||
// 清理监听器
|
||||
return () => {
|
||||
Notifications.removeNotificationSubscription(notificationListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 轮询获取推送消息
|
||||
const startPolling = useCallback(async (interval: number = 5000) => {
|
||||
|
||||
// 设置轮询
|
||||
pollingInterval.current = setInterval(async () => {
|
||||
if (isMounted.current) {
|
||||
await getMessageData();
|
||||
}
|
||||
}, interval);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取推送消息
|
||||
const getMessageData = async () => {
|
||||
try {
|
||||
const response = await fetchApi<PollingData[]>("/notice/push/message", {
|
||||
method: "POST"
|
||||
});
|
||||
setPollingData((prev) => ([...prev, ...response]));
|
||||
} catch (error) {
|
||||
console.error('获取轮询数据时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取认证token
|
||||
const getAuthToken = async (): Promise<string> => {
|
||||
let tokenValue = '';
|
||||
if (Platform.OS === 'web') {
|
||||
tokenValue = localStorage.getItem('token') || '';
|
||||
} else {
|
||||
tokenValue = (await SecureStore.getItemAsync('token')) || '';
|
||||
}
|
||||
setToken(tokenValue); // 只在获取到新token时更新状态
|
||||
return tokenValue;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
if (token) {
|
||||
// 启动轮询
|
||||
startPolling(5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取推送消息出错:', error);
|
||||
}
|
||||
};
|
||||
checkAuthStatus();
|
||||
|
||||
return () => {
|
||||
// 清理函数
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// 本地推送
|
||||
useEffect(() => {
|
||||
pollingData?.map((item) => {
|
||||
sendNotification(item)
|
||||
})
|
||||
}, [pollingData])
|
||||
|
||||
// 轮询获取token
|
||||
useEffect(() => {
|
||||
// 如果已经有token,直接返回
|
||||
if (token) {
|
||||
if (tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!tokenInterval.current) return;
|
||||
// 设置轮询
|
||||
tokenInterval.current = setInterval(async () => {
|
||||
if (isMounted.current) {
|
||||
const currentToken = await getAuthToken();
|
||||
// 如果获取到token,清除定时器
|
||||
if (currentToken && tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
};
|
||||
}, [token]); // 添加token作为依赖
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@ -120,6 +270,29 @@ export default function TabLayout() {
|
||||
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
{/* 对话详情页 */}
|
||||
<Tabs.Screen
|
||||
name="chat-details"
|
||||
options={{
|
||||
title: 'chat-details',
|
||||
tabBarButton: () => null, // 隐藏底部标签栏
|
||||
headerShown: false, // 隐藏导航栏
|
||||
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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,110 +3,160 @@ 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";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
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);
|
||||
|
||||
// 获取对话id
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
|
||||
// 用户对话信息收集
|
||||
const [userMessages, setUserMessages] = useState<Message[]>([]);
|
||||
// 选择图片
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnimChat = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const createNewConversation = useCallback(async () => {
|
||||
// TODO 用户未输入时,显示提示信息
|
||||
setUserMessages([{
|
||||
content: {
|
||||
text: "请输入您的问题,寻找,请稍等..."
|
||||
},
|
||||
role: 'Assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
}]);
|
||||
const data = await fetchApi<string>("/chat/new", {
|
||||
method: "POST",
|
||||
});
|
||||
setConversationId(data);
|
||||
}, []);
|
||||
|
||||
// 获取路由参数
|
||||
const { sessionId, newSession } = useLocalSearchParams<{
|
||||
sessionId: string;
|
||||
newSession: string;
|
||||
}>();
|
||||
// 添加自动滚动到底部的效果
|
||||
|
||||
// 处理滚动到底部
|
||||
useEffect(() => {
|
||||
if (scrollViewRef.current && !isHello) {
|
||||
scrollViewRef.current.scrollToEnd({ animated: true });
|
||||
}
|
||||
}, [userMessages, isHello]);
|
||||
|
||||
// 处理路由参数
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
setConversationId(sessionId)
|
||||
setIsHello(false)
|
||||
setConversationId(sessionId);
|
||||
setIsHello(false);
|
||||
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
|
||||
setUserMessages(res)
|
||||
})
|
||||
setUserMessages(res);
|
||||
});
|
||||
}
|
||||
if (newSession) {
|
||||
setIsHello(false)
|
||||
createNewConversation()
|
||||
// if (newSession) {
|
||||
// setIsHello(false);
|
||||
// createNewConversation();
|
||||
// }
|
||||
}, [sessionId]);
|
||||
|
||||
// 动画效果
|
||||
useEffect(() => {
|
||||
if (isHello) {
|
||||
// 显示欢迎页,隐藏聊天页
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnimChat, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
} else {
|
||||
// 显示聊天页,隐藏欢迎页
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnimChat, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
}
|
||||
}, [sessionId, newSession])
|
||||
}, [isHello, fadeAnim, fadeAnimChat]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: 'white', paddingTop: insets.top }}>
|
||||
{/* 导航栏 - 保持在顶部 */}
|
||||
<View style={isHello ? "" : styles.navbar} className="relative w-full flex flex-row items-center justify-between pb-3 pt-[2rem]">
|
||||
{/* 点击去memo list 页面 */}
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* 导航栏 */}
|
||||
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => {
|
||||
router.replace('/memo-list');
|
||||
}}
|
||||
onPress={() => router.push('/memo-list')}
|
||||
>
|
||||
<ReturnArrow />
|
||||
</TouchableOpacity>
|
||||
<ThemedText className={`!text-textSecondary font-semibold text-3xl w-full text-center flex-1 ${isHello ? "opacity-0" : ""}`}>MemoWake</ThemedText>
|
||||
<View />
|
||||
<ThemedText style={styles.title}>MemoWake</ThemedText>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
|
||||
enabled={!isHello}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
onContentSizeChange={() => {
|
||||
if (scrollViewRef.current && !isHello) {
|
||||
scrollViewRef.current.scrollToEnd({ animated: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<View className="flex-1">
|
||||
{isHello ? <AskHello /> : <Chat userMessages={userMessages} sessionId={sessionId} />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={styles.contentContainer}>
|
||||
{/* 欢迎页面 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.absoluteView,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
// 使用 pointerEvents 控制交互
|
||||
pointerEvents: isHello ? 'auto' : 'none',
|
||||
zIndex: 1
|
||||
}
|
||||
]}
|
||||
>
|
||||
<AskHello />
|
||||
</Animated.View>
|
||||
|
||||
{/* 功能区 - 放在 KeyboardAvoidingView 内但在 ScrollView 外 */}
|
||||
<View className="w-full px-[1.5rem] mb-[2rem]">
|
||||
<SendMessage setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} conversationId={conversationId} />
|
||||
{/* 聊天页面 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.absoluteView,
|
||||
{
|
||||
opacity: fadeAnimChat,
|
||||
// 使用 pointerEvents 控制交互
|
||||
pointerEvents: isHello ? 'none' : 'auto',
|
||||
zIndex: 0
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Chat userMessages={userMessages} sessionId={sessionId} setSelectedImages={setSelectedImages} selectedImages={selectedImages} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<SendMessage
|
||||
setIsHello={setIsHello}
|
||||
setUserMessages={setUserMessages}
|
||||
setConversationId={setConversationId}
|
||||
conversationId={conversationId}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
@ -114,83 +164,66 @@ export default function AskScreen() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
navbar: {
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: 'white',
|
||||
zIndex: 10,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingTop: 60
|
||||
},
|
||||
navbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: 'white',
|
||||
// 使用 border 替代阴影
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
// 如果需要更柔和的边缘,可以添加一个微妙的阴影
|
||||
elevation: 1, // Android
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
hiddenNavbar: {
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
backButton: {
|
||||
marginLeft: 16,
|
||||
padding: 12
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 40,
|
||||
paddingHorizontal: 20,
|
||||
lineHeight: 24,
|
||||
flex: 1,
|
||||
},
|
||||
chipsContainer: {
|
||||
width: "100%",
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
// 更新 keyboardAvoidingView 和 contentContainer 样式
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
overflow: "scroll",
|
||||
paddingBottom: 20,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFF5E6',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
margin: 5,
|
||||
},
|
||||
chipText: {
|
||||
marginLeft: 6,
|
||||
color: '#FF9500',
|
||||
fontSize: 14,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
paddingBottom: 30,
|
||||
absoluteView: {
|
||||
position: 'absolute', // 保持绝对定位
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderColor: '#FF9500',
|
||||
borderWidth: 1,
|
||||
borderRadius: 25,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
width: '100%', // 确保输入框宽度撑满
|
||||
},
|
||||
voiceButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#FF9500',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 8, // 添加一点右边距
|
||||
inputContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
backgroundColor: 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f0f0f0',
|
||||
},
|
||||
});
|
||||
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,11 +1,9 @@
|
||||
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, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Platform, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import MemoList from './memo-list';
|
||||
|
||||
@ -15,17 +13,24 @@ export default function HomeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [token, setToken] = useState('');
|
||||
const tokenInterval = useRef<NodeJS.Timeout | number>(null);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const getAuthToken = async (): Promise<string> => {
|
||||
let tokenValue = '';
|
||||
if (Platform.OS === 'web') {
|
||||
tokenValue = localStorage.getItem('token') || '';
|
||||
} else {
|
||||
tokenValue = (await SecureStore.getItemAsync('token')) || '';
|
||||
}
|
||||
setToken(tokenValue); // 只在获取到新token时更新状态
|
||||
return tokenValue;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
let token;
|
||||
if (Platform.OS === 'web') {
|
||||
token = localStorage.getItem('token') || '';
|
||||
} else {
|
||||
token = await SecureStore.getItemAsync('token') || '';
|
||||
}
|
||||
|
||||
const loggedIn = !!token;
|
||||
setIsLoggedIn(loggedIn);
|
||||
|
||||
@ -33,23 +38,50 @@ 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');
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('检查登录状态出错:', error);
|
||||
setIsLoading(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
// 轮询获取token
|
||||
useEffect(() => {
|
||||
// 如果已经有token,直接返回
|
||||
if (token) {
|
||||
if (tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!tokenInterval.current) return;
|
||||
// 设置轮询
|
||||
tokenInterval.current = setInterval(async () => {
|
||||
if (isMounted.current) {
|
||||
const currentToken = await getAuthToken();
|
||||
// 如果获取到token,清除定时器
|
||||
if (currentToken && tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
};
|
||||
}, [token]); // 添加token作为依赖
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-bgPrimary justify-center items-center">
|
||||
@ -64,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"}
|
||||
@ -88,7 +120,7 @@ export default function HomeScreen() {
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.back', { ns: 'login' })}
|
||||
</Text>
|
||||
{/* <MessagePush /> */}
|
||||
|
||||
{/* 唤醒按钮 */}
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
|
||||
@ -103,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 { ScrollView, StyleSheet, View } from 'react-native';
|
||||
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);
|
||||
@ -59,54 +73,57 @@ export default function OwnerPage() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
<FlatList
|
||||
data={[]} // 空数据,因为我们只需要渲染一次
|
||||
renderItem={null} // 不需要渲染项目
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ flexGrow: 1, gap: 16, marginHorizontal: 16 }}
|
||||
>
|
||||
{/* 用户信息 */}
|
||||
<UserInfo userInfo={userInfoDetails} />
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: 16 }}>
|
||||
{/* 用户信息 */}
|
||||
<UserInfo userInfo={userInfoDetails} />
|
||||
|
||||
{/* 设置栏 */}
|
||||
<AlbumComponent setModalVisible={setModalVisible} />
|
||||
{/* 设置栏 */}
|
||||
<AlbumComponent setModalVisible={setModalVisible} />
|
||||
|
||||
{/* 资源数据 */}
|
||||
<View style={styles.resourceContainer}>
|
||||
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} subtitle={`${countData?.counter?.total_count?.video_count || 0}videos/${countData?.counter?.total_count?.photo_count || 0}photos`} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} style={{ flex: 1 }} isFormatBytes={true} />
|
||||
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} style={{ flex: 1 }} />
|
||||
</View>
|
||||
{/* 数据统计 */}
|
||||
<CountComponent
|
||||
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
|
||||
/>
|
||||
{/* 资源数据 */}
|
||||
<View style={styles.resourceContainer}>
|
||||
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} subtitle={`${countData?.counter?.total_count?.video_count || 0}videos/${countData?.counter?.total_count?.photo_count || 0}photos`} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} style={{ flex: 1 }} isFormatBytes={true} />
|
||||
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} style={{ flex: 1 }} />
|
||||
</View>
|
||||
{/* 数据统计 */}
|
||||
<CountComponent
|
||||
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
|
||||
/>
|
||||
|
||||
{/* 分类 */}
|
||||
<View style={{ height: 145 }}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
|
||||
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
|
||||
return (
|
||||
<CategoryComponent
|
||||
key={index}
|
||||
title={key}
|
||||
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
|
||||
bgSvg={value.cover_url}
|
||||
style={{ aspectRatio: 1, flex: 1 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{/* 分类 */}
|
||||
<View style={{ height: 145 }}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
|
||||
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
|
||||
return (
|
||||
<CategoryComponent
|
||||
key={index}
|
||||
title={key}
|
||||
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
|
||||
bgSvg={value.cover_url}
|
||||
style={{ aspectRatio: 1, flex: 1 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 作品数据 */}
|
||||
<View className='flex flex-row justify-between gap-[1rem]'>
|
||||
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
|
||||
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
|
||||
</View>
|
||||
|
||||
{/* 排行榜 */}
|
||||
<Ranking data={userInfoDetails.title_rankings} />
|
||||
|
||||
</ScrollView>
|
||||
{/* 作品数据 */}
|
||||
<View className='flex flex-row justify-between gap-[1rem]'>
|
||||
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
|
||||
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
|
||||
</View>
|
||||
|
||||
{/* 排行榜 */}
|
||||
<Ranking data={userInfoDetails.title_rankings} />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
{/* 设置弹窗 */}
|
||||
<SettingModal modalVisible={modalVisible} setModalVisible={setModalVisible} userInfo={userInfoDetails.user_info} />
|
||||
|
||||
@ -122,6 +139,9 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'white',
|
||||
paddingBottom: 86,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
resourceContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 16
|
||||
|
||||
@ -2,14 +2,17 @@ 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, 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('')
|
||||
const [avatar, setAvatar] = useState('')
|
||||
@ -18,6 +21,14 @@ export default function UserMessage() {
|
||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||
const statusBarHeight = StatusBar?.currentHeight ?? 0;
|
||||
|
||||
// 获取路由参数
|
||||
const params = useLocalSearchParams();
|
||||
const { username: usernameParam } = params;
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus(router);
|
||||
}, []);
|
||||
|
||||
// 获取用户信息
|
||||
const getUserInfo = async () => {
|
||||
const res = await fetchApi<User>("/iam/user-info");
|
||||
@ -44,7 +55,7 @@ export default function UserMessage() {
|
||||
useEffect(() => {
|
||||
getUserInfo();
|
||||
setSteps("userName")
|
||||
}, []);
|
||||
}, [usernameParam]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
|
||||
@ -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>
|
||||
|
||||
4
assets/icons/svg/chatIn.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
||||
<path d="M4.913 2.658c2.075-.27 4.19-.408 6.337-.408 2.147 0 4.262.139 6.337.408 1.922.25 3.291 1.861 3.405 3.727a4.403 4.403 0 0 0-1.032-.211 50.89 50.89 0 0 0-8.42 0c-2.358.196-4.04 2.19-4.04 4.434v4.286a4.47 4.47 0 0 0 2.433 3.984L7.28 21.53A.75.75 0 0 1 6 21v-4.03a48.527 48.527 0 0 1-1.087-.128C2.905 16.58 1.5 14.833 1.5 12.862V6.638c0-1.97 1.405-3.718 3.413-3.979Z" />
|
||||
<path d="M15.75 7.5c-1.376 0-2.739.057-4.086.169C10.124 7.797 9 9.103 9 10.609v4.285c0 1.507 1.128 2.814 2.67 2.94 1.243.102 2.5.157 3.768.165l2.782 2.781a.75.75 0 0 0 1.28-.53v-2.39l.33-.026c1.542-.125 2.67-1.433 2.67-2.94v-4.286c0-1.505-1.125-2.811-2.664-2.94A49.392 49.392 0 0 0 15.75 7.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 780 B |
3
assets/icons/svg/chatNotIn.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
3
assets/icons/svg/folder.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 3V13.8C1 14.9201 1 15.4798 1.21799 15.9076C1.40973 16.2839 1.71547 16.5905 2.0918 16.7822C2.5192 17 3.07899 17 4.19691 17H15.8031C16.921 17 17.48 17 17.9074 16.7822C18.2837 16.5905 18.5905 16.2841 18.7822 15.9078C19.0002 15.48 19.0002 14.9199 19.0002 13.7998L19.0002 6.19978C19.0002 5.07967 19.0002 4.51962 18.7822 4.0918C18.5905 3.71547 18.2839 3.40973 17.9076 3.21799C17.4798 3 16.9201 3 15.8 3H10M1 3H10M1 3C1 1.89543 1.89543 1 3 1H6.67452C7.1637 1 7.40886 1 7.63904 1.05526C7.84311 1.10425 8.03785 1.18526 8.2168 1.29492C8.41857 1.41857 8.59181 1.59182 8.9375 1.9375L10 3" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 775 B |
3
assets/icons/svg/personIn.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
||||
<path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
3
assets/icons/svg/personNotIn.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
3
assets/icons/svg/yes.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 4.00001L4.33357 7L11 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 218 B |
BIN
assets/images/png/owner/ask.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
@ -1,10 +1,15 @@
|
||||
import ChatSvg from "@/assets/icons/svg/chat.svg";
|
||||
import FolderSvg from "@/assets/icons/svg/folder.svg";
|
||||
import MoreSvg from "@/assets/icons/svg/more.svg";
|
||||
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
|
||||
import YesSvg from "@/assets/icons/svg/yes.svg";
|
||||
import { Message, Video } from "@/types/ask";
|
||||
import { MaterialItem } from "@/types/personal-info";
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import React from 'react';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Modal,
|
||||
Pressable,
|
||||
@ -20,18 +25,23 @@ import TypewriterText from "./typewriterText";
|
||||
import { mergeArrays } from "./utils";
|
||||
|
||||
interface RenderMessageProps {
|
||||
insets: { top: number };
|
||||
item: Message;
|
||||
sessionId: string;
|
||||
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
|
||||
modalVisible: { visible: boolean, data: Video | MaterialItem };
|
||||
setModalDetailsVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
modalDetailsVisible: boolean;
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedImages: string[];
|
||||
}
|
||||
|
||||
const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: RenderMessageProps) => {
|
||||
const renderMessage = ({ insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
|
||||
const isUser = item.role === 'User';
|
||||
const isVideo = (data: Video | MaterialItem): data is Video => {
|
||||
return 'video' in data;
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
// 创建一个新的 VideoPlayer 组件
|
||||
const VideoPlayer = ({
|
||||
videoUrl,
|
||||
@ -90,25 +100,28 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
}
|
||||
</Text>
|
||||
|
||||
{(item.content.image_material_infos && item.content.image_material_infos.length > 0 || item.content.video_material_infos && item.content.video_material_infos.length > 0) && (
|
||||
{(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 0) && (
|
||||
<View className="relative">
|
||||
<View className="mt-2 flex flex-row gap-2 w-full">
|
||||
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image, index, array) => (
|
||||
<View style={[styles.imageGridContainer, { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' }]}>
|
||||
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image) => (
|
||||
<Pressable
|
||||
key={`${image.role}-${image.timestamp}`}
|
||||
key={image?.id || image?.video?.id}
|
||||
onPress={() => {
|
||||
setModalVisible({ visible: true, data: image });
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
array.length === 1 ? styles.fullWidthImage : styles.gridImage,
|
||||
array.length === 2 && { width: '49%' },
|
||||
array.length >= 3 && { width: '32%' },
|
||||
{ opacity: pressed ? 0.8 : 1 } // 添加按下效果
|
||||
]}
|
||||
style={{
|
||||
width: '32%',
|
||||
aspectRatio: 1,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
|
||||
className="rounded-xl w-full h-full"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</Pressable>
|
||||
@ -116,12 +129,14 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
</View>
|
||||
{
|
||||
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
|
||||
&& <View className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full">
|
||||
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
|
||||
setModalDetailsVisible(true);
|
||||
}}>
|
||||
<ThemedText className="!text-white font-semibold">{((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))}</ThemedText>
|
||||
<View className="bg-white rounded-full p-2">
|
||||
<MoreSvg />
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
</View>
|
||||
)}
|
||||
@ -194,6 +209,96 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
<Modal
|
||||
animationType="fade"
|
||||
visible={modalDetailsVisible}
|
||||
transparent={false}
|
||||
statusBarTranslucent={true}
|
||||
onRequestClose={() => {
|
||||
setModalDetailsVisible(false);
|
||||
}}
|
||||
>
|
||||
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
|
||||
<View style={detailsStyles.header}>
|
||||
<TouchableOpacity onPress={() => setModalDetailsVisible(false)}>
|
||||
<ReturnArrow />
|
||||
</TouchableOpacity>
|
||||
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
|
||||
<FolderSvg />
|
||||
</View>
|
||||
<View style={{ overflow: 'scroll', height: "100%" }}>
|
||||
<FlatList
|
||||
data={mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])}
|
||||
numColumns={3}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={detailsStyles.flatListContent}
|
||||
initialNumToRender={12}
|
||||
maxToRenderPerBatch={12}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={detailsStyles.gridItemContainer}
|
||||
key={item.id}
|
||||
>
|
||||
<View style={detailsStyles.gridItem}>
|
||||
<ThemedText style={detailsStyles.imageNumber}>
|
||||
{selectedImages?.map((image, index) => {
|
||||
if (image === item.id || image === item.video?.id) {
|
||||
return index + 1
|
||||
}
|
||||
})}
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
|
||||
style={detailsStyles.image}
|
||||
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
|
||||
onLoad={() => console.log('Image loaded successfully')}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[detailsStyles.circleMarker, selectedImages.includes(item?.id || item?.video?.id) ? detailsStyles.circleMarkerSelected : ""]}
|
||||
onPress={() => {
|
||||
setSelectedImages((prev) => {
|
||||
if (prev.includes(item?.id || item?.video?.id)) {
|
||||
return prev.filter((id) => id !== (item.id || item?.video?.id));
|
||||
} else {
|
||||
return [...prev, item.id || item.video?.id];
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selectedImages.includes(item?.id || item?.video?.id) ? <YesSvg width={16} height={16} /> : ""}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={detailsStyles.footer}>
|
||||
<TouchableOpacity
|
||||
style={detailsStyles.continueButton}
|
||||
onPress={async () => {
|
||||
// 如果用户没有选择 则为选择全部
|
||||
if (selectedImages?.length < 0) {
|
||||
setSelectedImages(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.map((item) => {
|
||||
return item.id || item.video?.id
|
||||
}))
|
||||
}
|
||||
setModalDetailsVisible(false)
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={detailsStyles.continueButtonText}>
|
||||
{t('ask.continueAsking', { ns: 'ask' })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@ -202,6 +307,12 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
export default renderMessage;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageGridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
},
|
||||
video: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@ -276,4 +387,125 @@ const styles = StyleSheet.create({
|
||||
color: '#000',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
const detailsStyles = StyleSheet.create({
|
||||
gridItemContainer: {
|
||||
flex: 1, // 使用 flex 布局使项目平均分配空间
|
||||
maxWidth: '33.33%', // 每行最多4个项目
|
||||
aspectRatio: 1, // 保持1:1的宽高比
|
||||
},
|
||||
flatListContent: {
|
||||
paddingBottom: 100, // 为底部按钮留出更多空间
|
||||
paddingHorizontal: 8, // 添加水平内边距
|
||||
paddingTop: 8,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: "#4C320C"
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
backgroundColor: '#fff',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
imageNumber: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
zIndex: 10, // 确保数字显示在图片上方
|
||||
},
|
||||
imageNumberText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
numberText: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.9)', // 使用半透明蓝色背景
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
gridItem: {
|
||||
flex: 1, // 填充父容器
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderWidth: 1,
|
||||
borderColor: '#eee',
|
||||
height: '100%', // 确保高度填满容器
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
circleMarker: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 3,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
circleMarkerSelected: {
|
||||
backgroundColor: '#FFB645',
|
||||
},
|
||||
markerText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: 16,
|
||||
zIndex: 10,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
continueButton: {
|
||||
backgroundColor: '#E2793F',
|
||||
borderRadius: 32,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
zIndex: 10,
|
||||
},
|
||||
continueButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
});
|
||||
@ -1,20 +1,23 @@
|
||||
import { Message, Video } from '@/types/ask';
|
||||
import { MaterialItem } from '@/types/personal-info';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { Dispatch, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
SafeAreaView
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import renderMessage from "./aiChat";
|
||||
|
||||
interface ChatProps {
|
||||
userMessages: Message[];
|
||||
sessionId: string;
|
||||
setSelectedImages: Dispatch<SetStateAction<string[]>>;
|
||||
selectedImages: string[];
|
||||
}
|
||||
|
||||
function ChatComponent({ userMessages, sessionId }: ChatProps) {
|
||||
function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
|
||||
|
||||
// 使用 useCallback 缓存 keyExtractor 函数
|
||||
@ -23,6 +26,9 @@ function ChatComponent({ userMessages, sessionId }: ChatProps) {
|
||||
// 使用 useMemo 缓存样式对象
|
||||
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
|
||||
|
||||
// 详情弹窗
|
||||
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (userMessages.length > 0) {
|
||||
@ -45,7 +51,7 @@ function ChatComponent({ userMessages, sessionId }: ChatProps) {
|
||||
updateCellsBatchingPeriod={50}
|
||||
initialNumToRender={10}
|
||||
windowSize={11}
|
||||
renderItem={({ item }) => renderMessage({ item, sessionId, modalVisible, setModalVisible })}
|
||||
renderItem={({ item }) => renderMessage({ setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@ -1,28 +1,39 @@
|
||||
import IP from "@/assets/icons/svg/ip.svg";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from 'react-native';
|
||||
import { ScrollView, View } from 'react-native';
|
||||
|
||||
export default function AskHello() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white overflow-auto w-full">
|
||||
{/* 内容区域 IP与介绍文本*/}
|
||||
<View className="items-center flex-1">
|
||||
<ThemedText className="text-3xl font-bold text-center">
|
||||
{t('ask.hi', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
<View className="justify-center items-center"><IP /></View>
|
||||
|
||||
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
|
||||
{t('ask.ready', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.justAsk', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View className="flex-1 bg-white w-full">
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20
|
||||
}}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="items-center">
|
||||
<ThemedText className="text-3xl font-bold text-center">
|
||||
{t('ask.hi', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
<View className="justify-center items-center my-4">
|
||||
<IP />
|
||||
</View>
|
||||
<ThemedText className="!text-textPrimary text-center">
|
||||
{t('ask.ready', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.justAsk', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
import VoiceSvg from '@/assets/icons/svg/vioce.svg';
|
||||
import React, { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
@ -12,13 +13,15 @@ import { fetchApi } from '@/lib/server-api-util';
|
||||
import { Message } from '@/types/ask';
|
||||
|
||||
interface Props {
|
||||
setIsHello: (isHello: boolean) => void,
|
||||
setIsHello: Dispatch<SetStateAction<boolean>>,
|
||||
conversationId: string | null,
|
||||
setUserMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
setConversationId: (conversationId: string) => void,
|
||||
selectedImages: string[];
|
||||
setSelectedImages: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
export default function SendMessage(props: Props) {
|
||||
const { setIsHello, conversationId, setUserMessages, setConversationId } = props;
|
||||
const { setIsHello, conversationId, setUserMessages, setConversationId, selectedImages, setSelectedImages } = props;
|
||||
|
||||
// 用户询问
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@ -29,20 +32,23 @@ export default function SendMessage(props: Props) {
|
||||
method: "POST",
|
||||
});
|
||||
setConversationId(data);
|
||||
await getConversation({ session_id: data, user_text });
|
||||
await getConversation({ session_id: data, user_text, material_ids: [] });
|
||||
}, []);
|
||||
|
||||
// 获取对话信息
|
||||
const getConversation = useCallback(async ({ session_id, user_text }: { session_id: string, user_text: string }) => {
|
||||
const getConversation = useCallback(async ({ session_id, user_text, material_ids }: { session_id: string, user_text: string, material_ids: string[] }) => {
|
||||
// 获取对话信息必须要有对话id
|
||||
if (!session_id) return;
|
||||
|
||||
const response = await fetchApi<Message>(`/chat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
session_id,
|
||||
user_text
|
||||
user_text,
|
||||
material_ids
|
||||
})
|
||||
});
|
||||
setSelectedImages([]);
|
||||
setUserMessages((prev: Message[]) => [...prev, response]?.filter((item: Message) => item.content.text !== '正在寻找,请稍等...'));
|
||||
}, []);
|
||||
|
||||
@ -70,17 +76,37 @@ export default function SendMessage(props: Props) {
|
||||
// 如果没有对话ID,创建新对话并获取消息,否则直接获取消息
|
||||
if (!conversationId) {
|
||||
createNewConversation(text);
|
||||
setIsHello(false);
|
||||
} else {
|
||||
getConversation({
|
||||
session_id: conversationId,
|
||||
user_text: text
|
||||
user_text: text,
|
||||
material_ids: selectedImages
|
||||
});
|
||||
}
|
||||
// 将输入框清空
|
||||
setInputValue('');
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const keyboardWillShowListener = Keyboard.addListener(
|
||||
'keyboardWillShow',
|
||||
() => {
|
||||
console.log('Keyboard will show');
|
||||
setIsHello(false);
|
||||
setUserMessages([{
|
||||
content: {
|
||||
text: "快来寻找你的记忆吧。。。"
|
||||
},
|
||||
role: 'Assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
}])
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
keyboardWillShowListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
38
components/copy.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await Clipboard.setStringAsync(textToCopy);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleCopy} style={styles.button}>
|
||||
{isCopied ? (
|
||||
<Ionicons name="checkmark-circle" size={12} color="#FFB645" />
|
||||
) : (
|
||||
<Ionicons name="copy-outline" size={12} color="#333" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
},
|
||||
text: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default CopyButton;
|
||||
@ -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
@ -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
@ -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();
|
||||
});
|
||||
};
|
||||
@ -1,11 +1,18 @@
|
||||
import NavbarSvg from "@/assets/icons/svg/navbar.svg";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import ChatInSvg from "@/assets/icons/svg/chatIn.svg";
|
||||
import ChatNotInSvg from "@/assets/icons/svg/chatNotIn.svg";
|
||||
import PersonInSvg from "@/assets/icons/svg/personIn.svg";
|
||||
import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import React from 'react';
|
||||
import { Platform, TouchableOpacity, View } from 'react-native';
|
||||
import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native';
|
||||
import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg';
|
||||
|
||||
const AskNavbar = () => {
|
||||
// 获取设备尺寸
|
||||
const { width } = Dimensions.get('window');
|
||||
// 获取路由
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{
|
||||
shadowColor: '#000',
|
||||
@ -14,10 +21,11 @@ const AskNavbar = () => {
|
||||
shadowRadius: 8,
|
||||
elevation: 10, // For Android
|
||||
}}>
|
||||
<NavbarSvg className="w-full h-full" />
|
||||
{/* <NavbarSvg className="w-[150%] h-full" /> */}
|
||||
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width, height: 80, resizeMode: 'cover' }} />
|
||||
<View className="absolute bottom-0 top-0 left-0 right-0 flex flex-row justify-between items-center px-[2rem]">
|
||||
<TouchableOpacity onPress={() => router.push('/memo-list')} >
|
||||
<Ionicons name="chatbubbles-outline" size={24} color="#4C320C" />
|
||||
<TouchableOpacity onPress={() => router.push('/memo-list')} style={{ padding: 16 }}>
|
||||
{pathname === "/memo-list" ? <ChatInSvg width={24} height={24} /> : <ChatNotInSvg width={24} height={24} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@ -27,7 +35,7 @@ const AskNavbar = () => {
|
||||
params: { newSession: "true" }
|
||||
});
|
||||
}}
|
||||
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : '-mt-[5rem] ml-[0.8rem]'}`}
|
||||
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`}
|
||||
>
|
||||
<View style={{
|
||||
shadowColor: '#FFB645',
|
||||
@ -67,9 +75,9 @@ const AskNavbar = () => {
|
||||
</Svg>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/owner')}>
|
||||
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
|
||||
<View>
|
||||
<Ionicons name="person-outline" size={24} color="#4C320C" />
|
||||
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />}
|
||||
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -43,11 +43,15 @@ 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 || '');
|
||||
router.replace('/user-message');
|
||||
const userInfo = await fetchApi<User>("/iam/user-info");
|
||||
if (userInfo?.nickname) {
|
||||
router.replace('/ask');
|
||||
} else {
|
||||
router.replace('/user-message');
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('Login failed', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { useAuth } from "../../contexts/auth-context";
|
||||
import { fetchApi } from "../../lib/server-api-util";
|
||||
import { User } from "../../types/user";
|
||||
import { ThemedText } from "../ThemedText";
|
||||
import PrivacyModal from "../owner/qualification/privacy";
|
||||
|
||||
interface LoginProps {
|
||||
updateUrlParam: (status: string, value: string) => void;
|
||||
@ -25,7 +26,9 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
const [passwordsMatch, setPasswordsMatch] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user'>('ai');
|
||||
// 协议弹窗
|
||||
const [privacyModalVisible, setPrivacyModalVisible] = useState(false);
|
||||
// 从 URL 参数中获取 task_id 和 steps
|
||||
const params = useLocalSearchParams<{ task_id?: string; steps?: string }>();
|
||||
const taskId = params.task_id;
|
||||
@ -263,10 +266,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.agree", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push({
|
||||
pathname: '/agreement',
|
||||
params: { type: 'service' }
|
||||
} as any)}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('terms');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.terms", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
@ -274,10 +277,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.and", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push({
|
||||
pathname: '/agreement',
|
||||
params: { type: 'privacy' }
|
||||
} as any)}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('privacy');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
@ -285,14 +288,25 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.and", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push({
|
||||
pathname: '/agreement',
|
||||
params: { type: 'user' }
|
||||
} as any)}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('user');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.userAgreement", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.and", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('ai');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.aiAgreement", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.agreement", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
@ -319,6 +333,9 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 协议弹窗 */}
|
||||
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
|
||||
</View>
|
||||
}
|
||||
|
||||
|
||||
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} />;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { Policy } from '@/types/personal-info';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import RenderHtml from 'react-native-render-html';
|
||||
|
||||
const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, type: string }) => {
|
||||
@ -62,12 +62,8 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
onRequestClose={() => {
|
||||
setModalVisible(!modalVisible);
|
||||
}}>
|
||||
<Pressable
|
||||
style={styles.centeredView}
|
||||
onPress={() => setModalVisible(false)}>
|
||||
<Pressable
|
||||
style={styles.modalView}
|
||||
onPress={(e) => e.stopPropagation()}>
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={{ opacity: 0 }}>Settings</Text>
|
||||
<Text style={styles.modalTitle}>{type === 'ai' ? 'AI Policy' : type === 'terms' ? 'Terms of Service' : type === 'privacy' ? 'Privacy Policy' : 'User Agreement'}</Text>
|
||||
@ -85,9 +81,8 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@ -125,7 +125,13 @@ const UserInfo = (props: UserInfoProps) => {
|
||||
}
|
||||
<TouchableOpacity style={styles.edit} onPress={() => {
|
||||
setModalVisible(false);
|
||||
router.push('/user-message')
|
||||
// 携带参数跳转
|
||||
router.push({
|
||||
pathname: '/user-message',
|
||||
params: {
|
||||
username: "true"
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<EditSvg />
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -1,25 +1,26 @@
|
||||
import UserSvg from '@/assets/icons/svg/ataver.svg';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { UserInfoDetails } from '@/types/user';
|
||||
// import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Image, ScrollView, View } from 'react-native';
|
||||
export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
|
||||
|
||||
// 添加状态来跟踪图片加载状态
|
||||
const [imageError, setImageError] = useState(false);
|
||||
return (
|
||||
<View className='flex flex-row items-center mt-[1rem] gap-[1rem]'>
|
||||
<View className='flex flex-row justify-between items-center mt-[1rem] gap-[1rem] w-full'>
|
||||
{/* 用户名 */}
|
||||
<View className='flex flex-col gap-4 w-[68vw]'>
|
||||
<View className='flex flex-col gap-4 w-[75%]'>
|
||||
<View className='flex flex-row items-center justify-between w-full'>
|
||||
<View className='flex flex-row items-center gap-2'>
|
||||
<View className='flex flex-row items-center gap-2 w-full'>
|
||||
<ThemedText
|
||||
className='max-w-[36vw] !text-textSecondary !font-semibold !text-2xl'
|
||||
className='max-w-[80%] !text-textSecondary !font-semibold !text-2xl'
|
||||
numberOfLines={1} // 限制为1行
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{userInfo?.user_info?.nickname}
|
||||
</ThemedText>
|
||||
<ScrollView
|
||||
className='max-w-[26vw] '
|
||||
className='max-w-[20%]'
|
||||
horizontal // 水平滚动
|
||||
showsHorizontalScrollIndicator={false} // 隐藏滚动条
|
||||
contentContainerStyle={{
|
||||
@ -40,29 +41,39 @@ export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView
|
||||
className='max-w-[68vw]'
|
||||
horizontal // 水平滚动
|
||||
showsHorizontalScrollIndicator={false} // 隐藏滚动条
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
gap: 8 // 间距
|
||||
}}
|
||||
>
|
||||
<ThemedText style={{ color: '#AC7E35', fontSize: 12, fontWeight: '600' }}>User ID:{userInfo?.user_info?.user_id}</ThemedText>
|
||||
</ScrollView>
|
||||
<View>
|
||||
<ScrollView
|
||||
className='max-w-[85%]'
|
||||
horizontal // 水平滚动
|
||||
showsHorizontalScrollIndicator={false} // 隐藏滚动条
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
gap: 8 // 间距
|
||||
}}
|
||||
>
|
||||
<ThemedText style={{ color: '#AC7E35', fontSize: 12, fontWeight: '600' }}>User ID: {userInfo?.user_info?.user_id}</ThemedText>
|
||||
</ScrollView>
|
||||
{/* <CopyButton textToCopy={userInfo?.user_info?.user_id || ""} /> */}
|
||||
</View>
|
||||
|
||||
</View>
|
||||
{/* 头像 */}
|
||||
<View>
|
||||
{userInfo?.user_info?.avatar_file_url
|
||||
?
|
||||
<View className='w-auto'>
|
||||
{userInfo?.user_info?.avatar_file_url && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: userInfo?.user_info?.avatar_file_url }}
|
||||
source={{ uri: userInfo.user_info.avatar_file_url }}
|
||||
style={{ width: 80, height: 80, borderRadius: 40 }}
|
||||
onError={() => {
|
||||
console.log('图片加载失败:', userInfo.user_info.avatar_file_url);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('图片加载成功');
|
||||
}}
|
||||
/>
|
||||
:
|
||||
) : (
|
||||
<UserSvg width={80} height={80} />
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
</View >
|
||||
);
|
||||
|
||||
@ -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
@ -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;
|
||||
@ -3,6 +3,8 @@
|
||||
"hi": "Hi,",
|
||||
"iAmMemo": "I'm Memo!",
|
||||
"ready": "Ready to wake up your memories?",
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!"
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!",
|
||||
"selectPhoto": "Select Photo",
|
||||
"continueAsking": "Continue Asking"
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,8 @@
|
||||
"codeVaild": "The code you entered is invalid",
|
||||
"sendAgain": "Did’nt receive a code?",
|
||||
"resend": "Resend",
|
||||
"goBack": "Go Back"
|
||||
"goBack": "Go Back",
|
||||
"aiAgreement": "AI Function Usage Norms"
|
||||
},
|
||||
"login": {
|
||||
"title": "Log in",
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
"task": "Task",
|
||||
"taskName": "dynamic family portrait",
|
||||
"taskStatus": "processing",
|
||||
"noName": "No Name"
|
||||
"noName": "No Name",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"hi": "Hi,",
|
||||
"iAmMemo": "I'm Memo!",
|
||||
"ready": "Ready to wake up your memories?",
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!"
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!",
|
||||
"selectPhoto": "Select Photo",
|
||||
"continueAsking": "Continue Asking"
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,8 @@
|
||||
"codeValid": "您输入的验证码无效",
|
||||
"sendAgain": "没有收到验证码?",
|
||||
"resend": "重新发送",
|
||||
"goBack": "返回"
|
||||
"goBack": "返回",
|
||||
"aiAgreement": "《AI功能使用规范》"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
"task": "任务",
|
||||
"taskName": "动态全家福",
|
||||
"taskStatus": "正在处理中",
|
||||
"noName": "未命名作品"
|
||||
"noName": "未命名作品",
|
||||
"uploading": "上传中"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 };
|
||||
}
|
||||
};
|
||||
5684
package-lock.json
generated
17
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",
|
||||
@ -29,7 +31,6 @@
|
||||
"expo-file-system": "~18.1.10",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.3.2",
|
||||
"expo-image-manipulator": "~13.1.7",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-linking": "~7.1.5",
|
||||
@ -40,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",
|
||||
@ -52,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",
|
||||
@ -69,17 +72,21 @@
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-redux": "^9.2.0"
|
||||
"react-redux": "^9.2.0",
|
||||
"expo-clipboard": "~7.1.5"
|
||||
},
|
||||
"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
@ -0,0 +1 @@
|
||||
eas build --platform android --profile development --local
|
||||
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
@ -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;
|
||||
}
|
||||