feat: 键盘唤起bug

This commit is contained in:
jinyaqiu 2025-07-25 19:45:10 +08:00
parent 714001f6ac
commit 479eecdc95
11 changed files with 879 additions and 474 deletions

View File

@ -4,6 +4,7 @@ import { requestNotificationPermission } from '@/components/owner/utils';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { prefetchChats } from '@/lib/prefetch';
import { fetchApi } from '@/lib/server-api-util';
import * as Notifications from 'expo-notifications';
import { Tabs } from 'expo-router';
@ -163,6 +164,10 @@ export default function TabLayout() {
};
}, [token]); // 添加token作为依赖
useEffect(() => {
prefetchChats().catch(console.error);
}, []);
return (
<Tabs
screenOptions={{

View File

@ -3,17 +3,18 @@ import Chat from "@/components/ask/chat";
import AskHello from "@/components/ask/hello";
import SendMessage from "@/components/ask/send";
import { ThemedText } from "@/components/ThemedText";
import { checkAuthStatus } from '@/lib/auth';
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router";
import React, { useEffect, useRef, useState } from 'react';
import { default as React, default as React, useCallback, useEffect, useRef, useState } from 'react';
import {
Animated,
FlatList,
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View
} from 'react-native';
@ -21,17 +22,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
const insets = useSafeAreaInsets();
useEffect(() => {
checkAuthStatus(router);
}, []);
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
const chatListRef = useRef<FlatList>(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;
@ -40,14 +36,48 @@ export default function AskScreen() {
newSession: string;
}>();
// 处理滚动到底部
useEffect(() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
// 创建一个可复用的滚动函数
const scrollToEnd = useCallback((animated = true) => {
if (chatListRef.current) {
setTimeout(() => chatListRef.current?.scrollToEnd({ animated }), 100);
}
}, [userMessages, isHello]);
}, []);
useEffect(() => {
if (!isHello && userMessages.length > 0) {
scrollToEnd();
}
}, [userMessages, isHello, scrollToEnd]);
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener(
'keyboardDidShow',
(e) => {
setTimeout(() => {
if (!isHello) {
scrollToEnd();
}
}, 100);
}
);
const keyboardDidHideListener = Keyboard.addListener(
'keyboardDidHide',
() => {
setTimeout(() => {
if (!isHello) {
scrollToEnd(false);
}
}, 100);
}
);
return () => {
keyboardDidShowListener.remove();
keyboardDidHideListener.remove();
};
}, [isHello]);
// 处理路由参数
useEffect(() => {
if (sessionId) {
setConversationId(sessionId);
@ -56,16 +86,14 @@ export default function AskScreen() {
setUserMessages(res);
});
}
// if (newSession) {
// setIsHello(false);
// createNewConversation();
// }
}, [sessionId]);
if (newSession) {
setIsHello(true);
setConversationId(null);
}
}, [sessionId, newSession]);
// 动画效果
useEffect(() => {
if (isHello) {
// 显示欢迎页,隐藏聊天页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
@ -79,29 +107,74 @@ export default function AskScreen() {
})
]).start();
} else {
// 显示聊天页,隐藏欢迎页
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 1000,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnimChat, {
toValue: 1,
duration: 1000,
duration: 300,
duration: 300,
useNativeDriver: true,
})
]).start();
]).start(() => {
setTimeout(() => {
if (!isHello) {
scrollToEnd(false);
}
}, 50);
});
}
}, [isHello, fadeAnim, fadeAnimChat]);
useEffect(() => {
if (!isHello) {
const timer = setTimeout(() => {
scrollToEnd(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isHello]);
useEffect(() => {
const timer = setTimeout(() => {
if (!isHello) {
try {
if (TextInput.State?.currentlyFocusedInput) {
const input = TextInput.State.currentlyFocusedInput();
if (input) input.blur();
}
} catch (error) {
console.log('失去焦点失败:', error);
}
scrollToEnd(false);
}
}, 200);
return () => clearTimeout(timer);
}, [isHello]);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.push('/memo-list')}
onPress={() => {
try {
if (TextInput.State?.currentlyFocusedInput) {
const input = TextInput.State.currentlyFocusedInput();
if (input) input.blur();
}
} catch (error) {
console.log('失去焦点失败:', error);
}
Keyboard.dismiss();
router.push('/memo-list');
}}
>
<ReturnArrow />
</TouchableOpacity>
@ -109,12 +182,6 @@ export default function AskScreen() {
<View style={styles.placeholder} />
</View>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
>
<View style={styles.contentContainer}>
{/* 欢迎页面 */}
<Animated.View
@ -122,7 +189,6 @@ export default function AskScreen() {
styles.absoluteView,
{
opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
@ -137,36 +203,48 @@ export default function AskScreen() {
styles.absoluteView,
{
opacity: fadeAnimChat,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0
}
]}
>
<Chat userMessages={userMessages} sessionId={sessionId} setSelectedImages={setSelectedImages} selectedImages={selectedImages} />
<Chat
ref={chatListRef}
userMessages={userMessages}
sessionId={sessionId}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
style={styles.chatContainer}
contentContainerStyle={styles.chatContentContainer}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollToEnd()}
/>
</Animated.View>
</View>
{/* 输入框 */}
<View style={styles.inputContainer}>
{/* 输入框区域 */}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={0} >
<View style={styles.inputContainer} key={conversationId}>
<SendMessage
setIsHello={setIsHello}
setUserMessages={setUserMessages}
setConversationId={setConversationId}
conversationId={conversationId}
setConversationId={setConversationId}
setUserMessages={setUserMessages}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
/>
</View>
</KeyboardAvoidingView>
</View>
</View >
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
backgroundColor: '#f8f8f8',
},
navbar: {
flexDirection: 'row',
@ -174,11 +252,8 @@ const styles = StyleSheet.create({
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 },
@ -202,23 +277,24 @@ const styles = StyleSheet.create({
placeholder: {
width: 40,
},
// 更新 keyboardAvoidingView 和 contentContainer 样式
keyboardAvoidingView: {
flex: 1,
},
contentContainer: {
flex: 1,
justifyContent: 'center',
paddingBottom: 20,
position: 'relative'
},
absoluteView: {
position: 'absolute', // 保持绝对定位
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
},
chatContainer: {
flex: 1,
},
chatContentContainer: {
paddingBottom: 20,
},
inputContainer: {
padding: 16,
paddingBottom: 24,

View File

@ -1,128 +1,157 @@
import ChatSvg from "@/assets/icons/svg/chat.svg";
import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress";
import AskNavbar from "@/components/layout/ask";
import { useUploadManager } from "@/hooks/useUploadManager";
import { fetchApi } from "@/lib/server-api-util";
import { useAppSelector } from "@/store";
import { Chat } from "@/types/ask";
import { router, useFocusEffect } from "expo-router";
import React from 'react';
import { FlatList, Platform, RefreshControl, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Asset } from 'expo-asset';
import { useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { FlatList, InteractionManager, PixelRatio, Platform, RefreshControl, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MemoList = () => {
const insets = useSafeAreaInsets();
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime);
// 懒加载组件
const ChatSvg = React.lazy(() => import('@/assets/icons/svg/chat.svg'));
const AskNavbar = React.lazy(() => import('@/components/layout/ask'));
const UploaderProgress = React.lazy(() => import('@/components/file-upload/upload-progress/uploader-progress'));
const SkeletonItem = React.lazy(() => import('@/components/memo/SkeletonItem'));
const ErrorBoundary = React.lazy(() => import('@/components/common/ErrorBoundary'));
// 历史消息
const [historyList, setHistoryList] = React.useState<Chat[]>([]);
// 刷新状态
const [refreshing, setRefreshing] = React.useState(false);
// 类型定义
import { useUploadManager } from '@/hooks/useUploadManager';
import { getCachedData, prefetchChatDetail, prefetchChats } from '@/lib/prefetch';
import { fetchApi } from '@/lib/server-api-util';
import { Chat } from '@/types/ask';
import { useTranslation } from 'react-i18next';
// 获取历史消息
const getHistoryList = async () => {
// 预加载资源
const preloadAssets = async () => {
try {
setRefreshing(true);
const res = await fetchApi<Chat[]>(`/chats`);
setHistoryList(res);
await Asset.loadAsync([
require('@/assets/icons/svg/chat.svg'),
]);
} catch (error) {
console.error('Failed to fetch history:', error);
} finally {
setRefreshing(false);
// console.error('资源预加载失败:', error);
}
};
// 骨架屏占位
const SkeletonList = () => (
<View style={styles.skeletonContainer}>
{Array(5).fill(0).map((_, index) => (
<React.Suspense key={`skeleton-${index}`} fallback={null}>
<SkeletonItem />
</React.Suspense>
))}
</View>
);
const MemoList = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const [isMounted, setIsMounted] = useState(false);
const [historyList, setHistoryList] = useState<Chat[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const flatListRef = useRef<FlatList>(null);
const { t } = useTranslation();
// 从缓存或API获取数据
const fetchHistoryList = useCallback(async (forceRefresh = false) => {
try {
setIsLoading(true);
// 先检查缓存
const cachedData = getCachedData<Chat[]>('/chats');
if (cachedData && !forceRefresh) {
setHistoryList(cachedData);
}
// 获取对话历史消息
const getChatHistory = async (id: string) => {
// 跳转到聊天页面,并携带参数
// 总是从服务器获取最新数据
const data = await fetchApi<Chat[]>('/chats');
setHistoryList(data);
// 预加载第一个聊天的详情
if (data.length > 0) {
InteractionManager.runAfterInteractions(() => {
prefetchChatDetail(data[0].session_id).catch(console.error);
});
}
return data;
} catch (error) {
console.error('获取历史记录失败:', error);
throw error;
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
// 处理下拉刷新
const handleRefresh = useCallback(() => {
if (isRefreshing) return;
setIsRefreshing(true);
fetchHistoryList(true).finally(() => {
setIsRefreshing(false);
});
}, [fetchHistoryList, isRefreshing]);
// 处理聊天项点击
const handleMemoPress = useCallback((item: Chat) => {
router.push({
pathname: '/ask',
params: {
sessionId: id,
}
params: { sessionId: item.session_id },
});
}, [router]);
// 初始加载和预加载
useEffect(() => {
let isActive = true;
const initialize = async () => {
try {
// 并行预加载资源和数据
await Promise.all([
preloadAssets(),
prefetchChats().then((data) => {
if (isActive && data) {
setHistoryList(data as Chat[]);
}
}),
]);
const handleMemoPress = (item: Chat) => {
getChatHistory(item.session_id)
// 主数据加载
await fetchHistoryList();
} catch (error) {
console.error('初始化失败:', error);
} finally {
if (isActive) {
setIsMounted(true);
// 延迟隐藏启动画面
setTimeout(SplashScreen.hideAsync, 500);
}
useFocusEffect(
React.useCallback(() => {
getHistoryList()
}, [])
);
const { progressInfo, uploadSessionStartTime: uploadSessionStartTimeFromHook } = useUploadManager();
const renderHeader = () => (
<>
{/* {process.env.NODE_ENV === 'development' && <TouchableOpacity
className='mt-2 bg-red-500 items-center h-10 justify-center'
onPress={() => router.push('/debug')}
>
<Text className="text-white">
db调试页面
</Text>
</TouchableOpacity>} */}
{/* 顶部标题和上传按钮 */}
<View style={styles.header}>
<Text style={styles.title}>Memo List</Text>
</View>
{/* 上传进度展示区域 */}
{uploadSessionStartTime && progressInfo.total > 0 && (
<View className="h-10 mt-6 mb-2 mx-4">
<UploaderProgress
imageUrl={progressInfo.image}
uploadedCount={progressInfo.completed}
totalCount={progressInfo.total}
/>
</View>
)}
</>
);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* <View className="w-full h-full">
<AutoUploadScreen />
</View> */}
{/* 历史对话 */}
<FlatList
ListHeaderComponent={renderHeader}
data={historyList}
keyExtractor={(item) => item.session_id}
// 下拉刷新
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={getHistoryList}
colors={['#FFB645']} // Android
tintColor="#FFB645" // iOS
/>
}
ItemSeparatorComponent={() => (
<View style={styles.separator} />
)}
getItemLayout={(data, index) => (
{ length: 80, offset: 80 * index, index }
)}
renderItem={({ item }) => (
};
initialize();
return () => {
isActive = false;
};
}, [fetchHistoryList]);
// 渲染列表项
const renderItem = useCallback(({ item }: { item: Chat }) => (
<TouchableOpacity
style={styles.memoItem}
onPress={() => handleMemoPress(item)}
activeOpacity={0.7}
>
<View className="w-[3rem] h-[3rem] z-1">
<React.Suspense fallback={<View style={styles.placeholderIcon} />}>
<ChatSvg
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
/>
</React.Suspense>
</View>
<View style={styles.memoContent}>
<Text
@ -130,211 +159,206 @@ const MemoList = () => {
numberOfLines={1}
ellipsizeMode="tail"
>
{item.title || 'memo list 历史消息'}
{item.title || t('ask:ask.unNamed')}
</Text>
<Text
style={styles.memoTitle}
style={styles.memoSubtitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.latest_message?.content?.text || 'memo list 历史消息'}
{item.latest_message?.content?.text || t('ask:ask.noMessage')}
</Text>
</View>
</TouchableOpacity>
)}
/>
{/* 底部导航栏 */}
<AskNavbar />
), [handleMemoPress]);
// 渲染列表头部
const renderHeader = useCallback(() => (
<View style={styles.headerContainer}>
<Text style={styles.title}>
{t('ask:ask.memoList')}
</Text>
{/* 上传进度 */}
<React.Suspense fallback={null}>
<UploadProgressSection />
</React.Suspense>
</View>
), [insets.top]);
// 上传进度组件
const UploadProgressSection = () => {
const { progressInfo, uploadSessionStartTime } = useUploadManager();
if (!uploadSessionStartTime || progressInfo.total <= 0) {
return null;
}
return (
<View className="h-10 mt-2 mb-2 mx-4">
<UploaderProgress
imageUrl={progressInfo.image}
uploadedCount={progressInfo.completed}
totalCount={progressInfo.total}
/>
</View>
);
};
// 空状态
const renderEmptyComponent = useCallback(() => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t('ask:ask.noChat')}</Text>
<TouchableOpacity
style={styles.refreshButton}
onPress={handleRefresh}
disabled={isRefreshing}
>
<Text style={styles.refreshText}>
{isRefreshing ? t('ask:ask.loading') : t('ask:ask.refresh')}
</Text>
</TouchableOpacity>
</View>
), [handleRefresh, isRefreshing]);
// 如果组件未完全加载,显示骨架屏
if (!isMounted) {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<SkeletonList />
<React.Suspense fallback={null}>
<AskNavbar />
</React.Suspense>
</View>
);
}
return (
<ErrorBoundary>
<View style={[styles.container, { paddingTop: insets.top }]}>
<FlatList
ref={flatListRef}
data={historyList}
renderItem={renderItem}
keyExtractor={(item) => item.session_id}
ListHeaderComponent={renderHeader}
ListEmptyComponent={!isLoading ? renderEmptyComponent : null}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
colors={['#FFB645']}
tintColor="#FFB645"
progressBackgroundColor="#ffffff"
/>
}
initialNumToRender={10}
maxToRenderPerBatch={5}
updateCellsBatchingPeriod={50}
windowSize={11} // 5 screens in each direction (5 + 1 + 5)
removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({
length: 80,
offset: 80 * index,
index,
})}
contentContainerStyle={styles.listContent}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
{/* 底部导航栏 */}
<React.Suspense fallback={null}>
<AskNavbar />
</React.Suspense>
</View>
</ErrorBoundary>
);
};
// 使用React.memo优化组件
const MemoizedMemoList = React.memo(MemoList);
export default MemoizedMemoList;
const styles = StyleSheet.create({
separator: {
height: 1,
backgroundColor: '#f0f0f0',
marginLeft: 60, // 与头像对齐
},
container: {
flex: 1,
backgroundColor: 'white',
backgroundColor: '#fff',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
headerContainer: {
paddingBottom: 16,
backgroundColor: '#fff',
},
title: {
fontSize: 24,
fontSize: 20,
fontWeight: 'bold',
color: '#4C320C',
textAlign: 'center',
marginBottom: 16,
},
uploadButton: {
padding: 8,
listContent: {
paddingBottom: Platform.select({
ios: 30,
android: 20,
}),
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF',
borderRadius: 20,
marginHorizontal: 16,
paddingHorizontal: 16,
height: 48,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
searchIcon: {
marginRight: 8,
skeletonContainer: {
flex: 1,
backgroundColor: '#fff',
paddingTop: 20,
},
memoItem: {
flexDirection: 'row',
borderRadius: 0, // 移除圆角
padding: 16,
marginBottom: 0, // 移除底部边距
alignItems: 'center',
gap: 16,
backgroundColor: 'white',
padding: 16,
backgroundColor: '#fff',
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
marginRight: 16,
placeholderIcon: {
width: 48,
height: 48,
backgroundColor: '#f0f0f0',
borderRadius: 24,
},
memoContent: {
flex: 1,
marginLeft: 12,
gap: 6,
justifyContent: 'center',
minWidth: 0, // 这行很重要,确保文本容器可以收缩到比内容更小
},
memoTitle: {
fontSize: 16,
fontWeight: '500',
color: '#333',
flex: 1, // 或者 flexShrink: 1
marginLeft: 12,
color: '#4C320C',
marginBottom: 4,
},
memoSubtitle: {
fontSize: 14,
color: '#666',
color: '#AC7E35',
},
tabBar: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#EEE',
paddingVertical: 12,
separator: {
height: 1 / PixelRatio.get(),
backgroundColor: '#f0f0f0',
marginLeft: 60,
},
tabBarSvg: {
color: 'red',
},
tabItem: {
emptyContainer: {
flex: 1,
alignItems: 'center',
},
tabCenter: {
width: 60,
height: 60,
alignItems: 'center',
justifyContent: 'center',
},
centerTabIcon: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginTop: -30,
padding: 40,
},
centerTabImage: {
width: 40,
height: 40,
emptyText: {
fontSize: 16,
color: '#999',
marginBottom: 20,
},
refreshButton: {
backgroundColor: '#FFB645',
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20,
},
// 在 tabBarContainer 样式中添加
tabBarContainer: {
position: 'relative',
paddingBottom: 0,
overflow: 'visible',
marginTop: 10, // 添加一些上边距
},
tabBarContent: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
height: 60,
position: 'relative',
backgroundColor: 'rgba(255, 255, 255, 0.7)', // 半透明白色背景
borderRadius: 30, // 圆角
marginHorizontal: 16, // 左右边距
// 添加边框效果
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
// 添加阴影
...Platform.select({
ios: {
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.8,
shadowRadius: 4,
},
android: {
elevation: 8,
},
}),
},
// 移除之前的 tabBarBackground 样式
// 修改 centerTabShadow 样式
centerTabShadow: {
position: 'absolute',
bottom: 15,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'white',
...Platform.select({
ios: {
shadowColor: 'rgba(0, 0, 0, 0.2)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 6,
},
android: {
elevation: 10,
},
}),
},
centerTabContainer: {
flex: 1,
alignItems: 'center',
position: 'relative',
height: '100%',
},
centerTabButton: {
width: '100%',
height: '100%',
borderRadius: 30,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
},
notificationDot: {
position: 'absolute',
top: -2,
right: -4,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FF3B30',
refreshText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
});
export default MemoList;

View File

@ -1,76 +1,55 @@
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, { Dispatch, ForwardedRef, forwardRef, memo, SetStateAction, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
FlatList,
FlatListProps,
SafeAreaView
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import MessageItem from './aiChat';
interface ChatProps {
// 继承 FlatListProps 来接收所有 FlatList 的属性
interface ChatProps extends Omit<FlatListProps<Message>, 'data' | 'renderItem'> {
userMessages: Message[];
sessionId: string;
setSelectedImages: Dispatch<SetStateAction<string[]>>;
selectedImages: string[];
}
function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) {
const flatListRef = useRef<FlatList>(null);
function ChatComponent(
{ userMessages, sessionId, setSelectedImages, selectedImages, ...restProps }: ChatProps,
ref: ForwardedRef<FlatList<Message>>
) {
const insets = useSafeAreaInsets();
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
const { t } = useTranslation();
// 使用 useCallback 缓存 keyExtractor 函数
const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []);
// 使用 useMemo 缓存样式对象
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
const contentContainerStyle = useMemo(() => ({ padding: 16, flexGrow: 1 }), []);
// 详情弹窗
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
// 自动滚动到底部
useEffect(() => {
if (userMessages.length > 0) {
// 延迟滚动以确保渲染完成
const timer = setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 150);
return () => clearTimeout(timer);
}
}, [userMessages]);
// 优化 FlatList 性能 - 提供 getItemLayout 方法
const getItemLayout = useCallback((data: Message[] | null | undefined, index: number) => {
// 假设每个消息项的高度大约为 100可根据实际情况调整
const averageItemHeight = 100;
return {
length: averageItemHeight,
offset: averageItemHeight * index,
index,
};
}, []);
return (
<SafeAreaView className='flex-1'>
<SafeAreaView style={{ flex: 1 }}>
<FlatList
ref={flatListRef}
ref={ref}
data={userMessages}
keyExtractor={keyExtractor}
renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
contentContainerStyle={contentContainerStyle}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
removeClippedSubviews={true}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
initialNumToRender={10}
windowSize={11}
getItemLayout={getItemLayout}
renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
{...restProps} // 将所有其他属性传递给 FlatList
/>
</SafeAreaView>
);
}
// 使用 React.memo 包装组件,避免不必要的重渲染
export default memo(ChatComponent);
export default memo(forwardRef(ChatComponent));

View File

@ -85,12 +85,15 @@ export default function SendMessage(props: Props) {
}
// 将输入框清空
setInputValue('');
// 关闭键盘
Keyboard.dismiss();
}
}
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
'keyboardWillShow',
() => {
if (!conversationId) {
console.log('Keyboard will show');
setIsHello(false);
setUserMessages([{
@ -100,13 +103,16 @@ export default function SendMessage(props: Props) {
role: 'Assistant',
timestamp: new Date().toISOString()
}])
} else {
}
}
);
return () => {
keyboardWillShowListener.remove();
};
}, []);
}, [conversationId]);
return (
<View style={styles.container}>

View File

@ -0,0 +1,92 @@
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
const translations = {
error: 'Error',
issue: 'An issue occurred',
retry: 'Retry'
};
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<View style={styles.container}>
<Text style={styles.title}>{translations.error}</Text>
<Text style={styles.error}>
{this.state.error?.message || translations.issue}
</Text>
<TouchableOpacity style={styles.retryButton} onPress={this.handleRetry}>
<Text style={styles.retryText}>{translations.retry}</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
backgroundColor: '#fff',
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 10,
color: '#333',
},
error: {
color: '#ff4d4f',
marginBottom: 20,
textAlign: 'center',
},
retryButton: {
backgroundColor: '#1890ff',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 4,
},
retryText: {
color: '#fff',
fontSize: 16,
},
});
export default ErrorBoundary;

View File

@ -3,52 +3,22 @@ import ChatNotInSvg from "@/assets/icons/svg/chatNotIn.svg";
import PersonInSvg from "@/assets/icons/svg/personIn.svg";
import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg";
import { router, usePathname } from "expo-router";
import React from 'react';
import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native';
import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg';
import React, { useCallback, useEffect, useMemo } from 'react';
import { Dimensions, Image, StyleSheet, TouchableOpacity, View } from 'react-native';
import Svg, { Circle, Ellipse, G, Mask, Path, Rect } from "react-native-svg";
const AskNavbar = () => {
// 获取设备尺寸
const { width } = Dimensions.get('window');
// 获取路由
const pathname = usePathname();
// 使用 React.memo 包装 SVG 组件,避免不必要的重渲染
const TabIcon = React.memo(({ isActive, ActiveIcon, InactiveIcon }: {
isActive: boolean;
ActiveIcon: React.FC<{ width: number; height: number }>;
InactiveIcon: React.FC<{ width: number; height: number }>;
}) => {
const Icon = isActive ? ActiveIcon : InactiveIcon;
return <Icon width={24} height={24} />;
});
return (
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 }, // Negative height for bottom shadow
shadowOpacity: 0.1,
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' }} />
<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>
<TouchableOpacity
onPress={() => {
router.push({
pathname: '/ask',
params: { newSession: "true" }
});
}}
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`}
>
<View style={{
shadowColor: '#FFB645',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderRadius: 50,
backgroundColor: 'transparent',
zIndex: 10,
width: 85,
height: 85
}}>
// 提取 SVG 组件,避免重复渲染
const CenterButtonSvg = React.memo(() => (
<Svg width="100%" height="100%" viewBox="0 0 85 85" fill="none">
<Mask id="mask0_1464_1669" maskUnits="userSpaceOnUse" x="0" y="0" width="85" height="85">
<Circle cx="42.5" cy="42.5" r="42.5" fill="#FFC959" />
@ -63,27 +33,139 @@ const AskNavbar = () => {
<Rect x="38.5571" y="46.2812" width="2.62922" height="3.68091" rx="1.31461" transform="rotate(-180 38.5571 46.2812)" fill="#4C320C" />
<Rect x="48.0205" y="46.2812" width="2.62922" height="3.68091" rx="1.31461" transform="rotate(-180 48.0205 46.2812)" fill="#4C320C" />
<Path d="M4.8084 73.2062C22.9876 46.7781 62.0132 46.7782 80.1924 73.2062L100.897 103.306C121.776 133.659 100.046 174.982 63.2051 174.982H21.7957C-15.0453 174.982 -36.7756 133.659 -15.8963 103.306L4.8084 73.2062Z" fill="#FFF8DE" />
<G>
<Ellipse cx="79.047" cy="68.6298" rx="43.1193" ry="30.7619" fill="#FFF8DE" />
</G>
<G>
<Ellipse cx="5.69032" cy="68.6298" rx="42.8563" ry="30.7619" fill="#FFF8DE" />
</G>
<Ellipse cx="42.2365" cy="53.3803" rx="3.15507" ry="2.3663" transform="rotate(180 42.2365 53.3803)" fill="#FFB8B9" />
<Path d="M41.7813 56.0095C41.9837 55.6589 42.4897 55.6589 42.6921 56.0095L43.1475 56.7982C43.3499 57.1488 43.0969 57.587 42.6921 57.587H41.7813C41.3765 57.587 41.1235 57.1488 41.3259 56.7982L41.7813 56.0095Z" fill="#4C320C" />
</G>
</Svg>
</View>
));
const AskNavbar = () => {
// 获取设备尺寸
const { width } = useMemo(() => Dimensions.get('window'), []);
const pathname = usePathname();
// 预加载目标页面
useEffect(() => {
const preloadPages = async () => {
try {
await Promise.all([
router.prefetch('/memo-list'),
router.prefetch('/ask'),
router.prefetch('/owner')
]);
} catch (error) {
console.warn('预加载页面失败:', error);
}
};
preloadPages();
}, []);
// 使用 useCallback 缓存导航函数
const navigateTo = useCallback((route: string) => {
if (route === '/ask') {
router.push({
pathname: '/ask',
params: { newSession: "true" }
});
} else {
router.push(route as any);
}
}, []);
// 使用 useMemo 缓存样式对象
const styles = useMemo(() => StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 10,
},
backgroundImage: {
width,
height: 80,
resizeMode: 'cover'
},
navButton: {
width: width / 2, // 半屏宽度
height: 80, // 与 navbar 高度相同
justifyContent: 'center',
alignItems: 'center'
},
navContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 80, // Set a fixed height for the navbar
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 32,
backgroundColor: 'transparent', // Make sure it's transparent
},
centerButton: {
position: 'absolute',
left: width / 2,
top: -42.5, // Adjust this value to move the button up or down
marginLeft: -42.5, // Half of the button width (85/2)
width: 85,
height: 85,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#FFB645',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderRadius: 50,
backgroundColor: 'transparent',
zIndex: 10,
}
}), [width]);
return (
<View style={styles.container}>
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width, height: 80, resizeMode: 'cover' }} />
<View style={styles.navContainer}>
<TouchableOpacity
onPress={() => navigateTo('/memo-list')}
style={[styles.navButton, { alignItems: "flex-start", paddingLeft: 16 }]}
>
<TabIcon
isActive={pathname === "/memo-list"}
ActiveIcon={ChatInSvg}
InactiveIcon={ChatNotInSvg}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
<View>
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />}
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */}
</View>
<TouchableOpacity
onPress={() => navigateTo('/ask')}
style={styles.centerButton}
>
<CenterButtonSvg />
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigateTo('/owner')}
style={styles.navButton}
>
<TabIcon
isActive={pathname === "/owner"}
ActiveIcon={PersonInSvg}
InactiveIcon={PersonNotInSvg}
/>
</TouchableOpacity>
</View>
</View>
);
};
export default AskNavbar;
export default React.memo(AskNavbar);

View File

@ -0,0 +1,49 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
const SkeletonItem = () => {
return (
<View style={styles.container}>
<View style={styles.avatar} />
<View style={styles.content}>
<View style={styles.title} />
<View style={styles.subtitle} />
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#fff',
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#f0f0f0',
},
content: {
flex: 1,
marginLeft: 12,
justifyContent: 'center',
},
title: {
height: 16,
width: '60%',
backgroundColor: '#f0f0f0',
marginBottom: 8,
borderRadius: 4,
},
subtitle: {
height: 14,
width: '80%',
backgroundColor: '#f5f5f5',
borderRadius: 4,
},
});
export default React.memo(SkeletonItem);

View File

@ -5,6 +5,14 @@
"ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo",
"continueAsking": "Continue Asking"
"continueAsking": "Continue Asking",
"unNamed": "UnNamed",
"noMessage": "No Message",
"memoList": "Memo List",
"noChat": "No Chat",
"loading": "Loading...",
"refresh": "Refresh",
"error": "have some error",
"issue": "have some issue"
}
}

View File

@ -5,6 +5,14 @@
"ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo",
"continueAsking": "Continue Asking"
"continueAsking": "Continue Asking",
"unNamed": "未命名对话",
"noMessage": "暂无消息",
"memoList": "对话记录",
"noChat": "暂无对话记录",
"loading": "加载中...",
"refresh": "刷新",
"error": "出错了",
"issue": "发生了一些问题"
}
}

76
lib/prefetch.ts Normal file
View File

@ -0,0 +1,76 @@
import { fetchApi } from './server-api-util';
// 全局缓存对象
const cache: Record<string, { data: any; timestamp: number }> = {};
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存时间
/**
*
* @param url API地址
* @param forceRefresh
* @returns Promise
*/
export const prefetchData = async <T>(url: string, forceRefresh = false): Promise<T> => {
const now = Date.now();
// 检查缓存是否存在且未过期
if (!forceRefresh && cache[url] && (now - cache[url].timestamp < CACHE_DURATION)) {
return cache[url].data as T;
}
try {
const data = await fetchApi<T>(url);
// 缓存数据
cache[url] = {
data,
timestamp: now,
};
return data;
} catch (error) {
console.error(`Prefetch failed for ${url}:`, error);
// 如果缓存中有旧数据,返回旧数据
if (cache[url]) {
return cache[url].data as T;
}
throw error;
}
};
/**
*
*/
export const prefetchChats = async () => {
return prefetchData<Array<{ session_id: string; title: string; latest_message?: any }>>('/chats');
};
/**
*
*/
export const prefetchChatDetail = async (sessionId: string) => {
return prefetchData(`/chats/${sessionId}/message-history`);
};
/**
* URL的缓存
*/
export const clearCache = (url?: string) => {
if (url) {
delete cache[url];
} else {
// 清除所有缓存
Object.keys(cache).forEach(key => {
delete cache[key];
});
}
};
/**
*
*/
export const getCachedData = <T>(url: string): T | null => {
const cached = cache[url];
if (cached && (Date.now() - cached.timestamp < CACHE_DURATION)) {
return cached.data as T;
}
return null;
};