Compare commits

..

9 Commits

Author SHA1 Message Date
26c0baf975 feat: 上传进度条组件 2025-07-17 12:28:33 +08:00
c8fbbe64b4 feat: 后台任务并发 2025-07-17 00:45:33 +08:00
6b50af7444 feat: 文件进度 2025-07-17 00:39:29 +08:00
a91f493f02 chore 2025-07-16 20:32:43 +08:00
c76252e568 enhance: 类型问题 2025-07-16 19:30:03 +08:00
a1f1b59143 chore: 优化检索效率 2025-07-16 19:26:10 +08:00
1c35548e0d chore: 重构 2025-07-16 19:22:08 +08:00
57a00c00ca chore 2025-07-16 18:07:43 +08:00
906be26aaa chore: 添加一些注释 2025-07-16 15:10:53 +08:00
47 changed files with 517 additions and 1632 deletions

View File

@ -1,164 +1,14 @@
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
@ -270,29 +120,6 @@ export default function TabLayout() {
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 对话详情页 */}
<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 >
</Tabs>
);
}

View File

@ -7,16 +7,8 @@ import { checkAuthStatus } from '@/lib/auth';
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router";
import React, { useEffect, useRef, useState } from 'react';
import {
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View
} from 'react-native';
import { useCallback, useEffect, useRef, useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
@ -26,137 +18,99 @@ export default function AskScreen() {
}, []);
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
// 用于控制是否显示问候页面
const [isHello, setIsHello] = useState(true);
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;
// 获取对话id
const [conversationId, setConversationId] = useState<string | null>(null);
// 用户对话信息收集
const [userMessages, setUserMessages] = useState<Message[]>([]);
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();
// }
}, [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();
if (newSession) {
setIsHello(false)
createNewConversation()
}
}, [isHello, fadeAnim, fadeAnimChat]);
}, [sessionId, newSession])
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<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 页面 */}
<TouchableOpacity
style={styles.backButton}
onPress={() => router.push('/memo-list')}
onPress={() => {
router.replace('/memo-list');
}}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={styles.title}>MemoWake</ThemedText>
<View style={styles.placeholder} />
<ThemedText className={`!text-textSecondary font-semibold text-3xl w-full text-center flex-1 ${isHello ? "opacity-0" : ""}`}>MemoWake</ThemedText>
<View />
</View>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
>
<View style={styles.contentContainer}>
{/* 欢迎页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
]}
>
<AskHello />
</Animated.View>
<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>
{/* 聊天页面 */}
<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}
/>
{/* 功能区 - 放在 KeyboardAvoidingView 内但在 ScrollView 外 */}
<View className="w-full px-[1.5rem] mb-[2rem]">
<SendMessage setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} conversationId={conversationId} />
</View>
</KeyboardAvoidingView>
</View>
@ -164,66 +118,83 @@ 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',
},
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,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 60
},
backButton: {
padding: 8,
marginRight: 8,
marginLeft: 16,
padding: 12
},
title: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
flex: 1,
},
placeholder: {
width: 40,
},
// 更新 keyboardAvoidingView 和 contentContainer 样式
keyboardAvoidingView: {
flex: 1,
},
contentContainer: {
content: {
flex: 1,
padding: 20,
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 20,
},
absoluteView: {
position: 'absolute', // 保持绝对定位
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
description: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 40,
paddingHorizontal: 20,
lineHeight: 24,
},
chipsContainer: {
width: "100%",
flexDirection: 'row',
flexWrap: 'nowrap',
justifyContent: 'center',
marginBottom: 40,
display: "flex",
alignItems: "center",
overflow: "scroll",
},
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: 24,
paddingBottom: 30,
backgroundColor: 'white',
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
},
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, // 添加一点右边距
},
});

View File

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

View File

@ -1,9 +1,10 @@
import { checkAuthStatus } from '@/lib/auth';
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
import * as MediaLibrary from 'expo-media-library';
import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, TouchableOpacity, View } from 'react-native';
import { Text, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
import MemoList from './memo-list';
@ -13,75 +14,28 @@ 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 {
const loggedIn = !!token;
setIsLoggedIn(loggedIn);
if (loggedIn) {
// 已登录,请求必要的权限
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status === 'granted') {
console.log('Media library permission granted in HomeScreen.');
}
router.replace('/ask');
const doCheck = async () => {
setIsLoading(true);
const authed = await checkAuthStatus(router, async () => {
setIsLoggedIn(true);
// 已登录,请求必要的权限及上传
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);
}
setIsLoading(false);
} catch (error) {
console.error('检查登录状态出错:', error);
setIsLoading(false);
} finally {
setIsLoading(false);
}
router.replace('/ask');
});
if (!authed) setIsLoggedIn(false);
setIsLoading(false);
};
checkAuthStatus();
doCheck();
}, []);
// 轮询获取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">
@ -92,49 +46,7 @@ export default function HomeScreen() {
return (
<View className="flex-1">
{
isLoggedIn ? <MemoList /> :
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] " style={{ paddingTop: insets.top + 48 }}>
{/* 标题区域 */}
<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"}
{t('auth.welcomeAwaken.your', { ns: 'login' })}
{"\n"}
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
</Text>
<Text className="text-white/85 text-base text-left">
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
</Text>
</View>
{/* Memo 形象区域 */}
<View className="items-center">
<IP />
</View>
{/* 介绍文本 */}
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
{"\n"}
{t('auth.welcomeAwaken.back', { ns: 'login' })}
</Text>
{/* 唤醒按钮 */}
<TouchableOpacity
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
onPress={async () => {
router.push('/login')
}}
activeOpacity={0.8}
>
<Text className="text-[#4C320C] font-bold text-lg">
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
</Text>
</TouchableOpacity>
</View>
}
</View >
{isLoggedIn && <MemoList />}
</View>
);
}

View File

@ -1,22 +1,16 @@
import ChatSvg from "@/assets/icons/svg/chat.svg";
import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress";
import AutoUploadScreen from "@/components/file-upload/autoUploadScreen";
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 { getUploadTasks, UploadTask } from "@/lib/db";
import { fetchApi } from "@/lib/server-api-util";
import { useAppDispatch, useAppSelector } from "@/store";
import { Chat } from "@/types/ask";
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 [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]); // 新增上传任务状态
// 历史消息
const [historyList, setHistoryList] = React.useState<Chat[]>([]);
@ -47,125 +41,44 @@ const MemoList = () => {
getHistoryList()
}, [])
const [progressInfo, setProgressInfo] = useState({ total: 0, completed: 0, image: '' });
useFocusEffect(
useCallback(() => {
let isActive = true;
let interval: any = null;
// 设置定时器,每秒查询一次上传进度
const intervalId = setInterval(async () => {
const tasks = await getUploadTasks();
setUploadTasks(tasks);
}, 1000);
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])
return () => clearInterval(intervalId); // 清理定时器
}, [])
);
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>}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 上传进度展示区域 */}
<View className="w-full h-80">
{uploadTasks.length >= 0 && (
<View style={{ padding: 10, backgroundColor: '#f0f0f0', borderBottomWidth: 1, borderBottomColor: '#ccc' }}>
<Text style={{ fontWeight: 'bold', marginBottom: 5 }}></Text>
{uploadTasks.map((task) => (
<Text key={task.uri}>
{task.filename}: {task.status} ({task.progress}%)
</Text>
))}
</View>
)}
<AutoUploadScreen />
</View>
{/* 顶部标题和上传按钮 */}
<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={() => (

View File

@ -18,7 +18,7 @@ import { CountData, UserInfoDetails } from '@/types/user';
import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
import { ScrollView, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function OwnerPage() {
@ -73,57 +73,54 @@ export default function OwnerPage() {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<FlatList
data={[]} // 空数据,因为我们只需要渲染一次
renderItem={null} // 不需要渲染项目
contentContainerStyle={styles.contentContainer}
<ScrollView
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={{ gap: 16 }}>
{/* 用户信息 */}
<UserInfo userInfo={userInfoDetails} />
contentContainerStyle={{ flexGrow: 1, gap: 16, marginHorizontal: 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>
{/* 作品数据 */}
<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>
{/* 排行榜 */}
<Ranking data={userInfoDetails.title_rankings} />
</View>
}
/>
{/* 设置弹窗 */}
<SettingModal modalVisible={modalVisible} setModalVisible={setModalVisible} userInfo={userInfoDetails.user_info} />
@ -139,9 +136,6 @@ const styles = StyleSheet.create({
backgroundColor: 'white',
paddingBottom: 86,
},
contentContainer: {
paddingHorizontal: 16,
},
resourceContainer: {
flexDirection: 'row',
gap: 16

View File

@ -6,13 +6,13 @@ import { checkAuthStatus } from '@/lib/auth';
import { FileUploadItem } from '@/lib/background-uploader/types';
import { fetchApi } from '@/lib/server-api-util';
import { User } from '@/types/user';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { 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('')
@ -21,10 +21,6 @@ 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);
}, []);
@ -55,7 +51,7 @@ export default function UserMessage() {
useEffect(() => {
getUserInfo();
setSteps("userName")
}, [usernameParam]);
}, []);
return (
<KeyboardAvoidingView

View File

@ -1,10 +1,7 @@
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";
@ -12,22 +9,6 @@ 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>

View File

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

Before

Width:  |  Height:  |  Size: 780 B

View File

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

Before

Width:  |  Height:  |  Size: 717 B

View File

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

Before

Width:  |  Height:  |  Size: 775 B

View File

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

Before

Width:  |  Height:  |  Size: 344 B

View File

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

Before

Width:  |  Height:  |  Size: 350 B

View File

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

Before

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,15 +1,10 @@
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,
@ -25,23 +20,18 @@ 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 = ({ insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: 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,
@ -100,28 +90,25 @@ const renderMessage = ({ insets, item, sessionId, setModalVisible, modalVisible,
}
</Text>
{(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 0) && (
{(item.content.image_material_infos && item.content.image_material_infos.length > 0 || item.content.video_material_infos && item.content.video_material_infos.length > 0) && (
<View className="relative">
<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) => (
<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) => (
<Pressable
key={image?.id || image?.video?.id}
key={`${image.role}-${image.timestamp}`}
onPress={() => {
setModalVisible({ visible: true, data: image });
}}
style={{
width: '32%',
aspectRatio: 1,
marginBottom: 8,
}}
style={({ pressed }) => [
array.length === 1 ? styles.fullWidthImage : styles.gridImage,
array.length === 2 && { width: '49%' },
array.length >= 3 && { width: '32%' },
{ opacity: pressed ? 0.8 : 1 } // 添加按下效果
]}
>
<Image
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
style={{
width: '100%',
height: '100%',
borderRadius: 12,
}}
className="rounded-xl w-full h-full"
resizeMode="cover"
/>
</Pressable>
@ -129,14 +116,12 @@ const renderMessage = ({ insets, item, sessionId, setModalVisible, modalVisible,
</View>
{
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
&& <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);
}}>
&& <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">
<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>
</TouchableOpacity>
</View>
}
</View>
)}
@ -209,96 +194,6 @@ const renderMessage = ({ insets, item, sessionId, setModalVisible, modalVisible,
</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>
);
@ -307,12 +202,6 @@ const renderMessage = ({ insets, item, sessionId, setModalVisible, modalVisible,
export default renderMessage;
const styles = StyleSheet.create({
imageGridContainer: {
flexDirection: 'row',
flexWrap: 'nowrap',
width: '100%',
marginTop: 8,
},
video: {
width: '100%',
height: '100%',
@ -387,125 +276,4 @@ 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',
}
});

View File

@ -1,23 +1,20 @@
import { Message, Video } from '@/types/ask';
import { MaterialItem } from '@/types/personal-info';
import React, { Dispatch, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } 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, setSelectedImages, selectedImages }: ChatProps) {
function ChatComponent({ userMessages, sessionId }: ChatProps) {
const flatListRef = useRef<FlatList>(null);
const insets = useSafeAreaInsets();
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
// 使用 useCallback 缓存 keyExtractor 函数
@ -26,9 +23,6 @@ function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedIma
// 使用 useMemo 缓存样式对象
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
// 详情弹窗
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
// 自动滚动到底部
useEffect(() => {
if (userMessages.length > 0) {
@ -51,7 +45,7 @@ function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedIma
updateCellsBatchingPeriod={50}
initialNumToRender={10}
windowSize={11}
renderItem={({ item }) => renderMessage({ setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
renderItem={({ item }) => renderMessage({ item, sessionId, modalVisible, setModalVisible })}
/>
</SafeAreaView>
);

View File

@ -1,39 +1,28 @@
import IP from "@/assets/icons/svg/ip.svg";
import { ThemedText } from "@/components/ThemedText";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from 'react-native';
import { View } from 'react-native';
export default function AskHello() {
const { t } = useTranslation();
return (
<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 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>
);
}

View File

@ -1,8 +1,7 @@
'use client';
import VoiceSvg from '@/assets/icons/svg/vioce.svg';
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import React, { Dispatch, SetStateAction, useCallback, useState } from 'react';
import {
Keyboard,
StyleSheet,
TextInput,
TouchableOpacity,
@ -13,15 +12,13 @@ import { fetchApi } from '@/lib/server-api-util';
import { Message } from '@/types/ask';
interface Props {
setIsHello: Dispatch<SetStateAction<boolean>>,
setIsHello: (isHello: boolean) => void,
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, selectedImages, setSelectedImages } = props;
const { setIsHello, conversationId, setUserMessages, setConversationId } = props;
// 用户询问
const [inputValue, setInputValue] = useState('');
@ -32,23 +29,20 @@ export default function SendMessage(props: Props) {
method: "POST",
});
setConversationId(data);
await getConversation({ session_id: data, user_text, material_ids: [] });
await getConversation({ session_id: data, user_text });
}, []);
// 获取对话信息
const getConversation = useCallback(async ({ session_id, user_text, material_ids }: { session_id: string, user_text: string, material_ids: string[] }) => {
const getConversation = useCallback(async ({ session_id, user_text }: { session_id: string, user_text: string }) => {
// 获取对话信息必须要有对话id
if (!session_id) return;
const response = await fetchApi<Message>(`/chat`, {
method: "POST",
body: JSON.stringify({
session_id,
user_text,
material_ids
user_text
})
});
setSelectedImages([]);
setUserMessages((prev: Message[]) => [...prev, response]?.filter((item: Message) => item.content.text !== '正在寻找,请稍等...'));
}, []);
@ -76,37 +70,17 @@ export default function SendMessage(props: Props) {
// 如果没有对话ID创建新对话并获取消息否则直接获取消息
if (!conversationId) {
createNewConversation(text);
setIsHello(false);
} else {
getConversation({
session_id: conversationId,
user_text: text,
material_ids: selectedImages
user_text: text
});
}
// 将输入框清空
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}>

View File

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

View File

@ -1,12 +1,13 @@
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
import { triggerManualUpload } from '@/lib/background-uploader/manual';
import { router } from 'expo-router';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import UploaderProgressBar from './upload-progress/progress-bar';
import UploaderProgressBar from './uploader-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,
@ -15,6 +16,15 @@ export default function AutoUploadScreen() {
totalSize: 0,
});
// 注册后台任务
useEffect(() => {
const registerTask = async () => {
const registered = await registerBackgroundUploadTask();
setIsRegistered(registered);
};
console.log("register background upload task");
registerTask();
}, []);
// 处理手动上传
const handleManualUpload = async () => {
@ -125,28 +135,26 @@ 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 &&
(
<UploaderProgressBar
imageUrl={uploadProgress.currentFileUrl}
uploadedCount={uploadProgress.uploadedCount}
totalCount={uploadProgress.totalCount}
/>
)}
{isLoading && (
<UploaderProgressBar
imageUrl={uploadProgress.currentFileUrl}
uploadedSize={uploadProgress.uploadedSize}
totalSize={uploadProgress.totalSize}
uploadedCount={uploadProgress.uploadedCount}
totalCount={uploadProgress.totalCount}
/>
)}
</View>
);
}

View File

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

View File

@ -1,21 +1,36 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
import { Image, StyleSheet, Text, View } from 'react-native';
import * as Progress from 'react-native-progress';
// Helper to format bytes into a readable string (e.g., KB, MB)
const formatBytes = (bytes: number, decimals = 1) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
interface UploaderProgressBarProps {
imageUrl: string;
uploadedSize: number; // in bytes
totalSize: number; // in bytes
uploadedCount: number;
totalCount: number;
}
const UploaderProgressBar: React.FC<UploaderProgressBarProps> = ({
imageUrl,
uploadedSize,
totalSize,
uploadedCount,
totalCount,
}) => {
const progress = totalCount > 0 ? (uploadedCount / totalCount) * 100 : 0;
const { t } = useTranslation();
const progress = totalSize > 0 ? uploadedSize / totalSize : 0;
// The image shows 1.1M/6.3M, so we format the bytes
const formattedUploadedSize = formatBytes(uploadedSize, 1).replace(' ', '');
const formattedTotalSize = formatBytes(totalSize, 1).replace(' ', '');
return (
<View style={styles.container}>
@ -24,11 +39,11 @@ const UploaderProgressBar: React.FC<UploaderProgressBarProps> = ({
</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' />
<Text style={styles.progressText}>{`${formattedUploadedSize}/${formattedTotalSize}`}</Text>
<Text style={styles.statusText}>Uploading...</Text>
</View>
<Progress.Bar
progress={progress / 100}
progress={progress}
width={null} // Fills the container
height={4}
color={'#4A4A4A'}
@ -47,11 +62,11 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5B941', // From image
borderRadius: 10,
paddingHorizontal: 12,
borderRadius: 25,
paddingHorizontal: 8,
paddingVertical: 8,
marginHorizontal: 0,
height: 60,
marginHorizontal: 15,
height: 50,
},
imageContainer: {
width: 40,
@ -78,12 +93,9 @@ const styles = StyleSheet.create({
},
progressInfo: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'baseline',
marginBottom: 4,
},
activityIndicator: {
marginLeft: 5,
},
progressText: {
color: '#4A4A4A',
fontWeight: 'bold',

View File

@ -1,18 +1,11 @@
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 NavbarSvg from "@/assets/icons/svg/navbar.svg";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import React from 'react';
import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native';
import { 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',
@ -21,11 +14,10 @@ const AskNavbar = () => {
shadowRadius: 8,
elevation: 10, // For Android
}}>
{/* <NavbarSvg className="w-[150%] h-full" /> */}
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width, height: 80, resizeMode: 'cover' }} />
<NavbarSvg className="w-full h-full" />
<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')} style={{ padding: 16 }}>
{pathname === "/memo-list" ? <ChatInSvg width={24} height={24} /> : <ChatNotInSvg width={24} height={24} />}
<TouchableOpacity onPress={() => router.push('/memo-list')} >
<Ionicons name="chatbubbles-outline" size={24} color="#4C320C" />
</TouchableOpacity>
<TouchableOpacity
@ -35,7 +27,7 @@ const AskNavbar = () => {
params: { newSession: "true" }
});
}}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : '-mt-[5rem] ml-[0.8rem]'}`}
>
<View style={{
shadowColor: '#FFB645',
@ -75,9 +67,9 @@ const AskNavbar = () => {
</Svg>
</View>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
<TouchableOpacity onPress={() => router.push('/owner')}>
<View>
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />}
<Ionicons name="person-outline" size={24} color="#4C320C" />
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */}
</View>
</TouchableOpacity>

View File

@ -45,13 +45,9 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
body: JSON.stringify(body),
}, true, false);
login({ ...res, email: res?.account }, res.access_token || '');
const userInfo = await fetchApi<User>("/iam/user-info");
if (userInfo?.nickname) {
router.replace('/ask');
} else {
router.replace('/user-message');
}
router.replace('/user-message');
} catch (error) {
// console.error('Login failed', error);
} finally {
setIsLoading(false);
}

View File

@ -7,7 +7,6 @@ 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;
@ -26,9 +25,7 @@ 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;
@ -266,10 +263,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agree", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('terms');
setPrivacyModalVisible(true);
}}>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'service' }
} as any)}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.terms", { ns: 'login' })}
</ThemedText>
@ -277,10 +274,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('privacy');
setPrivacyModalVisible(true);
}}>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'privacy' }
} as any)}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
</ThemedText>
@ -288,25 +285,14 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('user');
setPrivacyModalVisible(true);
}}>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'user' }
} as any)}>
<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>
@ -333,9 +319,6 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
</ThemedText>
</TouchableOpacity>
</View>
{/* 协议弹窗 */}
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
</View>
}

View File

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

View File

@ -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, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Modal, Pressable, 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,8 +62,12 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
onRequestClose={() => {
setModalVisible(!modalVisible);
}}>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Pressable
style={styles.centeredView}
onPress={() => setModalVisible(false)}>
<Pressable
style={styles.modalView}
onPress={(e) => e.stopPropagation()}>
<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>
@ -81,8 +85,9 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
}}
/>
</ScrollView>
</View>
</View>
</Pressable>
</Pressable>
</Modal>
);
};

View File

@ -125,13 +125,7 @@ const UserInfo = (props: UserInfoProps) => {
}
<TouchableOpacity style={styles.edit} onPress={() => {
setModalVisible(false);
// 携带参数跳转
router.push({
pathname: '/user-message',
params: {
username: "true"
}
});
router.push('/user-message')
}}>
<EditSvg />
</TouchableOpacity>

View File

@ -1,26 +1,25 @@
import UserSvg from '@/assets/icons/svg/ataver.svg';
import { ThemedText } from '@/components/ThemedText';
import { UserInfoDetails } from '@/types/user';
import { useState } from 'react';
// import { Image } from 'expo-image';
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 justify-between items-center mt-[1rem] gap-[1rem] w-full'>
<View className='flex flex-row items-center mt-[1rem] gap-[1rem]'>
{/* 用户名 */}
<View className='flex flex-col gap-4 w-[75%]'>
<View className='flex flex-col gap-4 w-[68vw]'>
<View className='flex flex-row items-center justify-between w-full'>
<View className='flex flex-row items-center gap-2 w-full'>
<View className='flex flex-row items-center gap-2'>
<ThemedText
className='max-w-[80%] !text-textSecondary !font-semibold !text-2xl'
className='max-w-[36vw] !text-textSecondary !font-semibold !text-2xl'
numberOfLines={1} // 限制为1行
ellipsizeMode="tail"
>
{userInfo?.user_info?.nickname}
</ThemedText>
<ScrollView
className='max-w-[20%]'
className='max-w-[26vw] '
horizontal // 水平滚动
showsHorizontalScrollIndicator={false} // 隐藏滚动条
contentContainerStyle={{
@ -41,39 +40,29 @@ export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
</ScrollView>
</View>
</View>
<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>
<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>
{/* 头像 */}
<View className='w-auto'>
{userInfo?.user_info?.avatar_file_url && !imageError ? (
<View>
{userInfo?.user_info?.avatar_file_url
?
<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 >
);

View File

@ -2,7 +2,7 @@ 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 '@/lib/background-uploader/types';
import { FileUploadItem } from '@/types/upload';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
import FilesUploader from '../file-upload/files-uploader';
@ -32,11 +32,11 @@ export default function Look(props: Props) {
{t('auth.userMessage.avatorText2', { ns: 'login' })}
</ThemedText>
{
fileData[0]?.previewUrl
fileData[0]?.preview
?
<Image
className='rounded-full w-[10rem] h-[10rem]'
source={{ uri: fileData[0].previewUrl }}
source={{ uri: fileData[0].preview }}
/>
:
avatar

View File

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

View File

@ -3,8 +3,6 @@
"hi": "Hi,",
"iAmMemo": "I'm Memo!",
"ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo",
"continueAsking": "Continue Asking"
"justAsk": "Just ask MeMo, let me bring them back to life!"
}
}

View File

@ -48,8 +48,7 @@
"codeVaild": "The code you entered is invalid",
"sendAgain": "Didnt receive a code?",
"resend": "Resend",
"goBack": "Go Back",
"aiAgreement": "AI Function Usage Norms"
"goBack": "Go Back"
},
"login": {
"title": "Log in",

View File

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

View File

@ -3,8 +3,6 @@
"hi": "Hi,",
"iAmMemo": "I'm Memo!",
"ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo",
"continueAsking": "Continue Asking"
"justAsk": "Just ask MeMo, let me bring them back to life!"
}
}

View File

@ -48,8 +48,7 @@
"codeValid": "您输入的验证码无效",
"sendAgain": "没有收到验证码?",
"resend": "重新发送",
"goBack": "返回",
"aiAgreement": "《AI功能使用规范》"
"goBack": "返回"
},
"login": {
"title": "登录",

View File

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

View File

@ -1,17 +1,17 @@
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 { getUploadTaskStatus, initUploadTable, insertUploadTask } from '../db';
import { getMediaByDateRange } from './media';
import { processAndUploadMedia } from './uploader';
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
const CONCURRENCY_LIMIT = 1; // 后台上传并发数例如同时上传3个文件
const CONCURRENCY_LIMIT = 3; // 后台上传并发数例如同时上传3个文件
const limit = pLimit(CONCURRENCY_LIMIT);
// 注册后台任务
export async function registerBackgroundUploadTask() {
export const registerBackgroundUploadTask = async () => {
try {
// 初始化数据库表
await initUploadTable();
@ -32,89 +32,68 @@ export async function registerBackgroundUploadTask() {
}
};
// 触发手动或后台上传的通用函数
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);
// 获取最近24小时的媒体文件
const media = await getMediaByDateRange(oneDayAgo, now);
if (media.length === 0) {
console.log('No new media files to upload in the last 24 hours.');
return BackgroundTask.BackgroundTaskResult.Success;
}
console.log(`Found ${media.length} media files to potentially upload.`);
// 并发上传文件
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
const uploadPromises = media.map((file) =>
limit(async () => {
try {
const existingTask = await getUploadTaskStatus(file.uri);
if (!existingTask) {
await insertUploadTask(file.uri, file.filename);
} else if (existingTask.status === 'success' || existingTask.status === 'skipped') {
console.log(`File ${file.uri} already ${existingTask.status}, skipping processing.`);
return { status: 'skipped' }; // 返回状态以便统计
}
const result = await processAndUploadMedia(file);
if (result === null) {
return { status: 'skipped' };
} else if (result.originalSuccess) {
return { status: 'success' };
} else {
return { status: 'failed' };
}
} catch (e) {
console.error('Upload failed for', file.uri, e);
return { status: 'failed' };
}
})
);
const results = await Promise.all(uploadPromises);
results.forEach(result => {
if (result.status === 'success') {
successCount++;
} else if (result.status === 'skipped') {
skippedCount++;
} else if (result.status === 'failed') {
failedCount++;
}
});
console.log(`Background upload task finished. Successful: ${successCount}, Skipped: ${skippedCount}, Failed: ${failedCount}, Total: ${media.length}`);
return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) {
console.error('Background task error:', error);

View File

@ -1,12 +1,12 @@
import pLimit from 'p-limit';
import { Alert } from 'react-native';
import { getUploadTaskStatus, insertUploadTask } from '../db';
import pLimit from 'p-limit';
import { getMediaByDateRange } from './media';
import { ExtendedAsset } from './types';
import { processAndUploadMedia } from './uploader';
import { ExtendedAsset } from './types';
import { insertUploadTask, getUploadTaskStatus } from '../db';
// 设置最大并发数
const CONCURRENCY_LIMIT = 1; // 同时最多上传10个文件
const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件
const limit = pLimit(CONCURRENCY_LIMIT);
// 手动触发上传
@ -36,7 +36,7 @@ export const triggerManualUpload = async (
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 });
await insertUploadTask(asset.uri, asset.filename);
} 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'
@ -58,7 +58,7 @@ export const triggerManualUpload = async (
if (result) {
results.push(result);
if (result.originalSuccess) {
if(result.originalSuccess) {
uploadedCount++;
}
}
@ -67,10 +67,10 @@ export const triggerManualUpload = async (
// 过滤掉因为已上传而返回 null 的结果
const finalResults = results.filter(result => result !== null);
console.log('Manual upload completed.', {
total: media.length,
uploaded: finalResults.length,
skipped: media.length - finalResults.length
console.log('Manual upload completed.', {
total: media.length,
uploaded: finalResults.length,
skipped: media.length - finalResults.length
});
return finalResults;

View File

@ -17,7 +17,7 @@ const fetchAssetsWithExif = async (
const media = await MediaLibrary.getAssetsAsync({
mediaType,
first: 500, // Fetch in batches of 500
sortBy: [MediaLibrary.SortBy.creationTime],
sortBy: [MediaLibrary.SortBy.creationTime, descending],
createdAfter,
createdBefore,
after,

View File

@ -103,10 +103,10 @@ export const processAndUploadMedia = async (
GPSVersionID: undefined
});
await uploadFileWithProgress(fileToUpload, upload_url, async (progress) => {
await uploadFileWithProgress(fileToUpload, upload_url, (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%
updateUploadTaskProgress(asset.uri, Math.round(percentage * 0.5)); // 原始文件占总进度的50%
});
await confirmUpload(file_id);
@ -131,11 +131,11 @@ export const processAndUploadMedia = async (
isCompressed: true
});
await uploadFileWithProgress(compressedFile, upload_url, async (progress) => {
await uploadFileWithProgress(compressedFile, upload_url, (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%
updateUploadTaskProgress(asset.uri, 50 + Math.round(percentage * 0.5)); // 压缩文件占总进度的后50%
});
await confirmUpload(file_id);
return { success: true, file_id };
@ -156,12 +156,12 @@ export const processAndUploadMedia = async (
if (!isVideo) {
compressedResult = await uploadCompressedFile();
if (originalResult.file_id && compressedResult.file_id) {
await addMaterial(originalResult.file_id, compressedResult.file_id);
addMaterial(originalResult.file_id, compressedResult.file_id);
}
} else {
const thumbnailResult = await uploadVideoThumbnail(asset);
if (thumbnailResult.success && originalResult.file_id && thumbnailResult.file_id) {
await addMaterial(originalResult.file_id, thumbnailResult.file_id);
addMaterial(originalResult.file_id, thumbnailResult.file_id);
}
}

153
lib/db.ts
View File

@ -2,94 +2,85 @@ 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() {
export function initUploadTable() {
console.log('Initializing upload tasks table...');
await db.execAsync(`
db.execSync(`
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'))
file_id TEXT
);
`);
// 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 insertUploadTask(uri: string, filename: string): Promise<void> {
console.log('Inserting upload task:', uri, filename);
db.runSync(
'INSERT OR IGNORE INTO upload_tasks (uri, filename, status, progress) VALUES (?, ?, ?, ?);',
uri,
filename,
'pending',
0
);
}
// 检查文件是否已上传或正在上传
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 = ?;',
const result = db.getFirstSync<UploadTask>(
'SELECT uri, filename, status, progress, file_id FROM upload_tasks WHERE uri = ?;',
uri
);
return result || null;
}
// 更新上传任务的状态
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string) {
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void> {
console.log('Updating upload task status:', uri, status, file_id);
if (file_id) {
await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]);
db.runSync(
'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]);
db.runSync(
'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 updateUploadTaskProgress(uri: string, progress: number): Promise<void> {
console.log('Updating upload task progress:', uri, progress);
db.runSync(
'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;'
const results = db.getAllSync<UploadTask>(
'SELECT uri, filename, status, progress, file_id FROM upload_tasks;'
);
return results;
}
@ -97,80 +88,8 @@ export async function getUploadTasks(): Promise<UploadTask[]> {
// 清理已完成或失败的任务 (可选,根据需求添加)
export async function cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...');
await db.runAsync(
db.runSync(
"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 };
}
}

12
package-lock.json generated
View File

@ -19,7 +19,6 @@
"expo-audio": "~0.4.7",
"expo-background-task": "^0.2.8",
"expo-blur": "~14.1.5",
"expo-clipboard": "~7.1.5",
"expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.1",
"expo-device": "~7.1.4",
@ -9778,17 +9777,6 @@
"react-native": "*"
}
},
"node_modules/expo-clipboard": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.1.5.tgz",
"integrity": "sha512-TCANUGOxouoJXxKBW5ASJl2WlmQLGpuZGemDCL2fO5ZMl57DGTypUmagb0CVUFxDl0yAtFIcESd78UsF9o64aw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "17.1.7",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.7.tgz",

View File

@ -31,6 +31,7 @@
"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",
@ -72,8 +73,7 @@
"react-native-uuid": "^2.0.3",
"react-native-web": "~0.20.0",
"react-native-webview": "13.13.5",
"react-redux": "^9.2.0",
"expo-clipboard": "~7.1.5"
"react-redux": "^9.2.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View File

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

View File

@ -1,21 +1,15 @@
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,
appState: appStateReducer
auth: authReducer
},
});
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// 在整个应用中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// 推断类型:{posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;