Compare commits
9 Commits
18f7fab613
...
26c0baf975
| Author | SHA1 | Date | |
|---|---|---|---|
| 26c0baf975 | |||
| c8fbbe64b4 | |||
| 6b50af7444 | |||
| a91f493f02 | |||
| c76252e568 | |||
| a1f1b59143 | |||
| 1c35548e0d | |||
| 57a00c00ca | |||
| 906be26aaa |
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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, // 添加一点右边距
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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={() => (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
|
Before Width: | Height: | Size: 2.7 KiB |
@ -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',
|
||||
}
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
@ -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',
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 >
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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!"
|
||||
}
|
||||
}
|
||||
@ -48,8 +48,7 @@
|
||||
"codeVaild": "The code you entered is invalid",
|
||||
"sendAgain": "Did’nt receive a code?",
|
||||
"resend": "Resend",
|
||||
"goBack": "Go Back",
|
||||
"aiAgreement": "AI Function Usage Norms"
|
||||
"goBack": "Go Back"
|
||||
},
|
||||
"login": {
|
||||
"title": "Log in",
|
||||
|
||||
@ -15,8 +15,7 @@
|
||||
"task": "Task",
|
||||
"taskName": "dynamic family portrait",
|
||||
"taskStatus": "processing",
|
||||
"noName": "No Name",
|
||||
"uploading": "Uploading"
|
||||
"noName": "No Name"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
@ -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!"
|
||||
}
|
||||
}
|
||||
@ -48,8 +48,7 @@
|
||||
"codeValid": "您输入的验证码无效",
|
||||
"sendAgain": "没有收到验证码?",
|
||||
"resend": "重新发送",
|
||||
"goBack": "返回",
|
||||
"aiAgreement": "《AI功能使用规范》"
|
||||
"goBack": "返回"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
|
||||
@ -15,8 +15,7 @@
|
||||
"task": "任务",
|
||||
"taskName": "动态全家福",
|
||||
"taskStatus": "正在处理中",
|
||||
"noName": "未命名作品",
|
||||
"uploading": "上传中"
|
||||
"noName": "未命名作品"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1 +0,0 @@
|
||||
eas build --platform android --profile development --local
|
||||
12
store.ts
@ -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;
|
||||