diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 1482286..843e35c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( { - checkAuthStatus(router); - }, []); - // 在组件内部添加 ref - const scrollViewRef = useRef(null); + + const chatListRef = useRef(null); const [isHello, setIsHello] = useState(true); const [conversationId, setConversationId] = useState(null); const [userMessages, setUserMessages] = useState([]); - // 选择图片 const [selectedImages, setSelectedImages] = useState([]); - // 动画值 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 ( {/* 导航栏 */} 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'); + }} > @@ -109,64 +182,69 @@ export default function AskScreen() { + + {/* 欢迎页面 */} + + + + + {/* 聊天页面 */} + + scrollToEnd()} + /> + + + + {/* 输入框区域 */} - - {/* 欢迎页面 */} - - - - - {/* 聊天页面 */} - - - - - - {/* 输入框 */} - + keyboardVerticalOffset={0} > + - + ); } 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, diff --git a/app/(tabs)/memo-list.tsx b/app/(tabs)/memo-list.tsx index 5ceb55a..6c02fb9 100644 --- a/app/(tabs)/memo-list.tsx +++ b/app/(tabs)/memo-list.tsx @@ -1,340 +1,364 @@ -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([]); - // 刷新状态 - 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 () => { - try { - setRefreshing(true); - const res = await fetchApi(`/chats`); - setHistoryList(res); - } catch (error) { - console.error('Failed to fetch history:', error); - } finally { - setRefreshing(false); - } +// 预加载资源 +const preloadAssets = async () => { + try { + await Asset.loadAsync([ + require('@/assets/icons/svg/chat.svg'), + ]); + } catch (error) { + // console.error('资源预加载失败:', error); } +}; - // 获取对话历史消息 - const getChatHistory = async (id: string) => { - // 跳转到聊天页面,并携带参数 +// 骨架屏占位 +const SkeletonList = () => ( + + {Array(5).fill(0).map((_, index) => ( + + + + ))} + +); + +const MemoList = () => { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [isMounted, setIsMounted] = useState(false); + const [historyList, setHistoryList] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const flatListRef = useRef(null); + const { t } = useTranslation(); + + // 从缓存或API获取数据 + const fetchHistoryList = useCallback(async (forceRefresh = false) => { + try { + setIsLoading(true); + + // 先检查缓存 + const cachedData = getCachedData('/chats'); + if (cachedData && !forceRefresh) { + setHistoryList(cachedData); + } + + // 总是从服务器获取最新数据 + const data = await fetchApi('/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 handleMemoPress = (item: Chat) => { - getChatHistory(item.session_id) - } + const initialize = async () => { + try { + // 并行预加载资源和数据 + await Promise.all([ + preloadAssets(), + prefetchChats().then((data) => { + if (isActive && data) { + setHistoryList(data as Chat[]); + } + }), + ]); - useFocusEffect( - React.useCallback(() => { - getHistoryList() - }, []) - ); + // 主数据加载 + await fetchHistoryList(); + } catch (error) { + console.error('初始化失败:', error); + } finally { + if (isActive) { + setIsMounted(true); + // 延迟隐藏启动画面 + setTimeout(SplashScreen.hideAsync, 500); + } + } + }; + initialize(); - const { progressInfo, uploadSessionStartTime: uploadSessionStartTimeFromHook } = useUploadManager(); + return () => { + isActive = false; + }; + }, [fetchHistoryList]); - const renderHeader = () => ( - <> - {/* {process.env.NODE_ENV === 'development' && router.push('/debug')} - > - - 进入db调试页面 - - } */} - - {/* 顶部标题和上传按钮 */} - - Memo List - - - {/* 上传进度展示区域 */} - {uploadSessionStartTime && progressInfo.total > 0 && ( - - ( + handleMemoPress(item)} + activeOpacity={0.7} + > + + }> + - - )} - - ); + + + + + {item.title || t('ask:ask.unNamed')} + + + {item.latest_message?.content?.text || t('ask:ask.noMessage')} + + + + ), [handleMemoPress]); + + // 渲染列表头部 + const renderHeader = useCallback(() => ( + + + {t('ask:ask.memoList')} + + + {/* 上传进度 */} + + + + + ), [insets.top]); + + // 上传进度组件 + const UploadProgressSection = () => { + const { progressInfo, uploadSessionStartTime } = useUploadManager(); + + if (!uploadSessionStartTime || progressInfo.total <= 0) { + return null; + } + + return ( + + + + ); + }; + + // 空状态 + const renderEmptyComponent = useCallback(() => ( + + {t('ask:ask.noChat')} + + + {isRefreshing ? t('ask:ask.loading') : t('ask:ask.refresh')} + + + + ), [handleRefresh, isRefreshing]); + + // 如果组件未完全加载,显示骨架屏 + if (!isMounted) { + return ( + + + + + + + ); + } return ( - - {/* - - */} + + + item.session_id} + ListHeaderComponent={renderHeader} + ListEmptyComponent={!isLoading ? renderEmptyComponent : null} + refreshControl={ + + } + 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={() => } + /> - {/* 历史对话 */} - item.session_id} - // 下拉刷新 - refreshControl={ - - } - ItemSeparatorComponent={() => ( - - )} - getItemLayout={(data, index) => ( - { length: 80, offset: 80 * index, index } - )} - renderItem={({ item }) => ( - handleMemoPress(item)} - > - - - - - - {item.title || 'memo list 历史消息'} - - - {item.latest_message?.content?.text || 'memo list 历史消息'} - - - - )} - /> - {/* 底部导航栏 */} - - + {/* 底部导航栏 */} + + + + + ); }; +// 使用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, // 添加一些上边距 + refreshText: { + color: '#fff', + fontSize: 14, + fontWeight: '500', }, - 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', - }, -}); - -export default MemoList; \ No newline at end of file +}); \ No newline at end of file diff --git a/components/ask/chat.tsx b/components/ask/chat.tsx index 8fd14a0..9a65dc5 100644 --- a/components/ask/chat.tsx +++ b/components/ask/chat.tsx @@ -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, 'data' | 'renderItem'> { userMessages: Message[]; sessionId: string; setSelectedImages: Dispatch>; selectedImages: string[]; } -function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) { - const flatListRef = useRef(null); +function ChatComponent( + { userMessages, sessionId, setSelectedImages, selectedImages, ...restProps }: ChatProps, + ref: ForwardedRef> +) { 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(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 ( - + 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 /> ); } -// 使用 React.memo 包装组件,避免不必要的重渲染 -export default memo(ChatComponent); \ No newline at end of file +export default memo(forwardRef(ChatComponent)); \ No newline at end of file diff --git a/components/ask/send.tsx b/components/ask/send.tsx index 6dea33e..ab9793e 100644 --- a/components/ask/send.tsx +++ b/components/ask/send.tsx @@ -85,28 +85,34 @@ export default function SendMessage(props: Props) { } // 将输入框清空 setInputValue(''); + // 关闭键盘 + Keyboard.dismiss(); } } useEffect(() => { const keyboardWillShowListener = Keyboard.addListener( 'keyboardWillShow', () => { - console.log('Keyboard will show'); - setIsHello(false); - setUserMessages([{ - content: { - text: "快来寻找你的记忆吧。。。" - }, - role: 'Assistant', - timestamp: new Date().toISOString() - }]) + if (!conversationId) { + console.log('Keyboard will show'); + setIsHello(false); + setUserMessages([{ + content: { + text: "快来寻找你的记忆吧。。。" + }, + role: 'Assistant', + timestamp: new Date().toISOString() + }]) + } else { + + } } ); return () => { keyboardWillShowListener.remove(); }; - }, []); + }, [conversationId]); return ( diff --git a/components/common/ErrorBoundary.tsx b/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..d3111af --- /dev/null +++ b/components/common/ErrorBoundary.tsx @@ -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 { + 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 ( + + {translations.error} + + {this.state.error?.message || translations.issue} + + + {translations.retry} + + + ); + } + + 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; diff --git a/components/layout/ask.tsx b/components/layout/ask.tsx index 3a8445b..95e9716 100644 --- a/components/layout/ask.tsx +++ b/components/layout/ask.tsx @@ -3,87 +3,169 @@ 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"; + +// 使用 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 ; +}); + +// 提取 SVG 组件,避免重复渲染 +const CenterButtonSvg = React.memo(() => ( + + + + + + + + + + + + + + + + + + + + +)); const AskNavbar = () => { // 获取设备尺寸 - const { width } = Dimensions.get('window'); - // 获取路由 + const { width } = useMemo(() => Dimensions.get('window'), []); const pathname = usePathname(); - return ( - { + 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 }, // Negative height for bottom shadow + shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.1, shadowRadius: 8, - elevation: 10, // For Android - }}> - {/* */} + 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 ( + - - router.push('/memo-list')} style={{ padding: 16 }}> - {pathname === "/memo-list" ? : } + + navigateTo('/memo-list')} + style={[styles.navButton, { alignItems: "flex-start", paddingLeft: 16 }]} + > + { - router.push({ - pathname: '/ask', - params: { newSession: "true" } - }); - }} - className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`} + onPress={() => navigateTo('/ask')} + style={styles.centerButton} > - - - - - - - - - - - - - - - - - - - - - - - - - - + - router.push('/owner')} style={{ padding: 16 }}> - - {pathname === "/owner" ? : } - {/* */} - + + navigateTo('/owner')} + style={styles.navButton} + > + ); }; -export default AskNavbar; \ No newline at end of file +export default React.memo(AskNavbar); \ No newline at end of file diff --git a/components/memo/SkeletonItem.tsx b/components/memo/SkeletonItem.tsx new file mode 100644 index 0000000..39c144f --- /dev/null +++ b/components/memo/SkeletonItem.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +const SkeletonItem = () => { + return ( + + + + + + + + ); +}; + +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); diff --git a/i18n/locales/en/ask.json b/i18n/locales/en/ask.json index 3031390..2d7159c 100644 --- a/i18n/locales/en/ask.json +++ b/i18n/locales/en/ask.json @@ -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" } } \ No newline at end of file diff --git a/i18n/locales/zh/ask.json b/i18n/locales/zh/ask.json index 3031390..bc9a814 100644 --- a/i18n/locales/zh/ask.json +++ b/i18n/locales/zh/ask.json @@ -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": "发生了一些问题" } } \ No newline at end of file diff --git a/lib/prefetch.ts b/lib/prefetch.ts new file mode 100644 index 0000000..002e863 --- /dev/null +++ b/lib/prefetch.ts @@ -0,0 +1,76 @@ +import { fetchApi } from './server-api-util'; + +// 全局缓存对象 +const cache: Record = {}; +const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存时间 + +/** + * 预取数据并缓存 + * @param url API地址 + * @param forceRefresh 是否强制刷新缓存 + * @returns 返回Promise,解析为获取到的数据 + */ +export const prefetchData = async (url: string, forceRefresh = false): Promise => { + 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(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>('/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 = (url: string): T | null => { + const cached = cache[url]; + if (cached && (Date.now() - cached.timestamp < CACHE_DURATION)) { + return cached.data as T; + } + return null; +};