feat: ask优化 #16

Merged
txcjh merged 1 commits from ask into dev/1.0.0 2025-07-29 20:39:43 +08:00
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 TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { prefetchChats } from '@/lib/prefetch';
import { fetchApi } from '@/lib/server-api-util'; import { fetchApi } from '@/lib/server-api-util';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
@ -163,6 +164,10 @@ export default function TabLayout() {
}; };
}, [token]); // 添加token作为依赖 }, [token]); // 添加token作为依赖
useEffect(() => {
prefetchChats().catch(console.error);
}, []);
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{

View File

@ -3,17 +3,18 @@ import Chat from "@/components/ask/chat";
import AskHello from "@/components/ask/hello"; import AskHello from "@/components/ask/hello";
import SendMessage from "@/components/ask/send"; import SendMessage from "@/components/ask/send";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { checkAuthStatus } from '@/lib/auth';
import { fetchApi } from "@/lib/server-api-util"; import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask"; import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router"; 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 { import {
Animated, Animated,
FlatList,
Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
ScrollView,
StyleSheet, StyleSheet,
TextInput,
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
@ -21,17 +22,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() { export default function AskScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useEffect(() => {
checkAuthStatus(router); const chatListRef = useRef<FlatList>(null);
}, []);
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
const [isHello, setIsHello] = useState(true); const [isHello, setIsHello] = useState(true);
const [conversationId, setConversationId] = useState<string | null>(null); const [conversationId, setConversationId] = useState<string | null>(null);
const [userMessages, setUserMessages] = useState<Message[]>([]); const [userMessages, setUserMessages] = useState<Message[]>([]);
// 选择图片
const [selectedImages, setSelectedImages] = useState<string[]>([]); const [selectedImages, setSelectedImages] = useState<string[]>([]);
// 动画值
const fadeAnim = useRef(new Animated.Value(1)).current; const fadeAnim = useRef(new Animated.Value(1)).current;
const fadeAnimChat = useRef(new Animated.Value(0)).current; const fadeAnimChat = useRef(new Animated.Value(0)).current;
@ -40,14 +36,48 @@ export default function AskScreen() {
newSession: string; newSession: string;
}>(); }>();
// 处理滚动到底部 // 创建一个可复用的滚动函数
useEffect(() => { const scrollToEnd = useCallback((animated = true) => {
if (scrollViewRef.current && !isHello) { if (chatListRef.current) {
scrollViewRef.current.scrollToEnd({ animated: true }); 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(() => { useEffect(() => {
if (sessionId) { if (sessionId) {
setConversationId(sessionId); setConversationId(sessionId);
@ -56,16 +86,14 @@ export default function AskScreen() {
setUserMessages(res); setUserMessages(res);
}); });
} }
// if (newSession) { if (newSession) {
// setIsHello(false); setIsHello(true);
// createNewConversation(); setConversationId(null);
// } }
}, [sessionId]); }, [sessionId, newSession]);
// 动画效果
useEffect(() => { useEffect(() => {
if (isHello) { if (isHello) {
// 显示欢迎页,隐藏聊天页
Animated.parallel([ Animated.parallel([
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 1, toValue: 1,
@ -79,29 +107,74 @@ export default function AskScreen() {
}) })
]).start(); ]).start();
} else { } else {
// 显示聊天页,隐藏欢迎页
Animated.parallel([ Animated.parallel([
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 0, toValue: 0,
duration: 1000, duration: 300,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(fadeAnimChat, { Animated.timing(fadeAnimChat, {
toValue: 1, toValue: 1,
duration: 1000, duration: 300,
duration: 300,
useNativeDriver: true, useNativeDriver: true,
}) })
]).start(); ]).start(() => {
setTimeout(() => {
if (!isHello) {
scrollToEnd(false);
}
}, 50);
});
} }
}, [isHello, fadeAnim, fadeAnimChat]); }, [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 ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */} {/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}> <View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} 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 /> <ReturnArrow />
</TouchableOpacity> </TouchableOpacity>
@ -109,12 +182,6 @@ export default function AskScreen() {
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{/* 欢迎页面 */} {/* 欢迎页面 */}
<Animated.View <Animated.View
@ -122,7 +189,6 @@ export default function AskScreen() {
styles.absoluteView, styles.absoluteView,
{ {
opacity: fadeAnim, opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none', pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1 zIndex: 1
} }
@ -137,23 +203,35 @@ export default function AskScreen() {
styles.absoluteView, styles.absoluteView,
{ {
opacity: fadeAnimChat, opacity: fadeAnimChat,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'none' : 'auto', pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0 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> </Animated.View>
</View> </View>
{/* 输入框 */} {/* 输入框区域 */}
<View style={styles.inputContainer}> <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={0} >
<View style={styles.inputContainer} key={conversationId}>
<SendMessage <SendMessage
setIsHello={setIsHello} setIsHello={setIsHello}
setUserMessages={setUserMessages}
setConversationId={setConversationId}
conversationId={conversationId} conversationId={conversationId}
setConversationId={setConversationId}
setUserMessages={setUserMessages}
selectedImages={selectedImages} selectedImages={selectedImages}
setSelectedImages={setSelectedImages} setSelectedImages={setSelectedImages}
/> />
@ -166,7 +244,7 @@ export default function AskScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: 'white', backgroundColor: '#f8f8f8',
}, },
navbar: { navbar: {
flexDirection: 'row', flexDirection: 'row',
@ -174,11 +252,8 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 16, paddingVertical: 16,
paddingHorizontal: 16, paddingHorizontal: 16,
backgroundColor: 'white',
// 使用 border 替代阴影
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)', borderBottomColor: 'rgba(0,0,0,0.1)',
// 如果需要更柔和的边缘,可以添加一个微妙的阴影
elevation: 1, // Android elevation: 1, // Android
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
@ -202,23 +277,24 @@ const styles = StyleSheet.create({
placeholder: { placeholder: {
width: 40, width: 40,
}, },
// 更新 keyboardAvoidingView 和 contentContainer 样式
keyboardAvoidingView: {
flex: 1,
},
contentContainer: { contentContainer: {
flex: 1, flex: 1,
justifyContent: 'center', position: 'relative'
paddingBottom: 20,
}, },
absoluteView: { absoluteView: {
position: 'absolute', // 保持绝对定位 position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
backgroundColor: 'white', backgroundColor: 'white',
}, },
chatContainer: {
flex: 1,
},
chatContentContainer: {
paddingBottom: 20,
},
inputContainer: { inputContainer: {
padding: 16, padding: 16,
paddingBottom: 24, paddingBottom: 24,

View File

@ -1,128 +1,157 @@
import ChatSvg from "@/assets/icons/svg/chat.svg"; import { Asset } from 'expo-asset';
import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress"; import { useRouter } from 'expo-router';
import AskNavbar from "@/components/layout/ask"; import * as SplashScreen from 'expo-splash-screen';
import { useUploadManager } from "@/hooks/useUploadManager"; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { fetchApi } from "@/lib/server-api-util"; import { FlatList, InteractionManager, PixelRatio, Platform, RefreshControl, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
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 { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MemoList = () => { // 懒加载组件
const insets = useSafeAreaInsets(); const ChatSvg = React.lazy(() => import('@/assets/icons/svg/chat.svg'));
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime); 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[]>([]); import { useUploadManager } from '@/hooks/useUploadManager';
// 刷新状态 import { getCachedData, prefetchChatDetail, prefetchChats } from '@/lib/prefetch';
const [refreshing, setRefreshing] = React.useState(false); import { fetchApi } from '@/lib/server-api-util';
import { Chat } from '@/types/ask';
import { useTranslation } from 'react-i18next';
// 获取历史消息 // 预加载资源
const getHistoryList = async () => { const preloadAssets = async () => {
try { try {
setRefreshing(true); await Asset.loadAsync([
const res = await fetchApi<Chat[]>(`/chats`); require('@/assets/icons/svg/chat.svg'),
setHistoryList(res); ]);
} catch (error) { } catch (error) {
console.error('Failed to fetch history:', error); // console.error('资源预加载失败:', error);
} finally {
setRefreshing(false);
} }
};
// 骨架屏占位
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({ router.push({
pathname: '/ask', pathname: '/ask',
params: { params: { sessionId: item.session_id },
sessionId: 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} />
)} initialize();
getItemLayout={(data, index) => (
{ length: 80, offset: 80 * index, index } return () => {
)} isActive = false;
renderItem={({ item }) => ( };
}, [fetchHistoryList]);
// 渲染列表项
const renderItem = useCallback(({ item }: { item: Chat }) => (
<TouchableOpacity <TouchableOpacity
style={styles.memoItem} style={styles.memoItem}
onPress={() => handleMemoPress(item)} onPress={() => handleMemoPress(item)}
activeOpacity={0.7}
> >
<View className="w-[3rem] h-[3rem] z-1"> <View className="w-[3rem] h-[3rem] z-1">
<React.Suspense fallback={<View style={styles.placeholderIcon} />}>
<ChatSvg <ChatSvg
width="100%" width="100%"
height="100%" height="100%"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
/> />
</React.Suspense>
</View> </View>
<View style={styles.memoContent}> <View style={styles.memoContent}>
<Text <Text
@ -130,211 +159,206 @@ const MemoList = () => {
numberOfLines={1} numberOfLines={1}
ellipsizeMode="tail" ellipsizeMode="tail"
> >
{item.title || 'memo list 历史消息'} {item.title || t('ask:ask.unNamed')}
</Text> </Text>
<Text <Text
style={styles.memoTitle} style={styles.memoSubtitle}
numberOfLines={1} numberOfLines={1}
ellipsizeMode="tail" ellipsizeMode="tail"
> >
{item.latest_message?.content?.text || 'memo list 历史消息'} {item.latest_message?.content?.text || t('ask:ask.noMessage')}
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
)} ), [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}
/> />
{/* 底部导航栏 */}
<AskNavbar />
</View> </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({ const styles = StyleSheet.create({
separator: {
height: 1,
backgroundColor: '#f0f0f0',
marginLeft: 60, // 与头像对齐
},
container: { container: {
flex: 1, flex: 1,
backgroundColor: 'white', backgroundColor: '#fff',
}, },
header: { headerContainer: {
flexDirection: 'row', paddingBottom: 16,
alignItems: 'center', backgroundColor: '#fff',
justifyContent: 'center',
padding: 16,
}, },
title: { title: {
fontSize: 24, fontSize: 20,
fontWeight: 'bold', fontWeight: 'bold',
color: '#4C320C', color: '#4C320C',
textAlign: 'center',
marginBottom: 16,
}, },
uploadButton: { listContent: {
padding: 8, paddingBottom: Platform.select({
ios: 30,
android: 20,
}),
}, },
searchContainer: { skeletonContainer: {
flexDirection: 'row', flex: 1,
alignItems: 'center', backgroundColor: '#fff',
backgroundColor: '#FFF', paddingTop: 20,
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,
}, },
memoItem: { memoItem: {
flexDirection: 'row', flexDirection: 'row',
borderRadius: 0, // 移除圆角
padding: 16,
marginBottom: 0, // 移除底部边距
alignItems: 'center', alignItems: 'center',
gap: 16, padding: 16,
backgroundColor: 'white', backgroundColor: '#fff',
}, },
avatar: { placeholderIcon: {
width: 60, width: 48,
height: 60, height: 48,
borderRadius: 30, backgroundColor: '#f0f0f0',
marginRight: 16, borderRadius: 24,
}, },
memoContent: { memoContent: {
flex: 1, flex: 1,
marginLeft: 12, marginLeft: 12,
gap: 6,
justifyContent: 'center', justifyContent: 'center',
minWidth: 0, // 这行很重要,确保文本容器可以收缩到比内容更小
}, },
memoTitle: { memoTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '500', fontWeight: '500',
color: '#333', color: '#4C320C',
flex: 1, // 或者 flexShrink: 1 marginBottom: 4,
marginLeft: 12,
}, },
memoSubtitle: { memoSubtitle: {
fontSize: 14, fontSize: 14,
color: '#666', color: '#AC7E35',
}, },
tabBar: { separator: {
flexDirection: 'row', height: 1 / PixelRatio.get(),
justifyContent: 'space-around', backgroundColor: '#f0f0f0',
alignItems: 'center', marginLeft: 60,
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#EEE',
paddingVertical: 12,
}, },
tabBarSvg: { emptyContainer: {
color: 'red',
},
tabItem: {
flex: 1, flex: 1,
alignItems: 'center',
},
tabCenter: {
width: 60,
height: 60,
alignItems: 'center',
justifyContent: 'center',
},
centerTabIcon: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#FF9500',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginTop: -30, padding: 40,
}, },
centerTabImage: { emptyText: {
width: 40, fontSize: 16,
height: 40, color: '#999',
marginBottom: 20,
},
refreshButton: {
backgroundColor: '#FFB645',
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20, borderRadius: 20,
}, },
// 在 tabBarContainer 样式中添加 refreshText: {
tabBarContainer: { color: '#fff',
position: 'relative', fontSize: 14,
paddingBottom: 0, fontWeight: '500',
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',
}, },
}); });
export default MemoList;

View File

@ -1,76 +1,55 @@
import { Message, Video } from '@/types/ask'; import { Message, Video } from '@/types/ask';
import { MaterialItem } from '@/types/personal-info'; 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 { useTranslation } from 'react-i18next';
import { import {
FlatList, FlatList,
FlatListProps,
SafeAreaView SafeAreaView
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import MessageItem from './aiChat'; import MessageItem from './aiChat';
interface ChatProps { // 继承 FlatListProps 来接收所有 FlatList 的属性
interface ChatProps extends Omit<FlatListProps<Message>, 'data' | 'renderItem'> {
userMessages: Message[]; userMessages: Message[];
sessionId: string; sessionId: string;
setSelectedImages: Dispatch<SetStateAction<string[]>>; setSelectedImages: Dispatch<SetStateAction<string[]>>;
selectedImages: string[]; selectedImages: string[];
} }
function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) { function ChatComponent(
const flatListRef = useRef<FlatList>(null); { userMessages, sessionId, setSelectedImages, selectedImages, ...restProps }: ChatProps,
ref: ForwardedRef<FlatList<Message>>
) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem }); const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
const { t } = useTranslation(); const { t } = useTranslation();
// 使用 useCallback 缓存 keyExtractor 函数
const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []); const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []);
// 使用 useMemo 缓存样式对象 const contentContainerStyle = useMemo(() => ({ padding: 16, flexGrow: 1 }), []);
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
// 详情弹窗
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false); 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 ( return (
<SafeAreaView className='flex-1'> <SafeAreaView style={{ flex: 1 }}>
<FlatList <FlatList
ref={flatListRef} ref={ref}
data={userMessages} data={userMessages}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
removeClippedSubviews={true} removeClippedSubviews={true}
maxToRenderPerBatch={10} maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
initialNumToRender={10} initialNumToRender={10}
windowSize={11} windowSize={11}
getItemLayout={getItemLayout} {...restProps} // 将所有其他属性传递给 FlatList
renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
/> />
</SafeAreaView> </SafeAreaView>
); );
} }
// 使用 React.memo 包装组件,避免不必要的重渲染 export default memo(forwardRef(ChatComponent));
export default memo(ChatComponent);

View File

@ -85,12 +85,15 @@ export default function SendMessage(props: Props) {
} }
// 将输入框清空 // 将输入框清空
setInputValue(''); setInputValue('');
// 关闭键盘
Keyboard.dismiss();
} }
} }
useEffect(() => { useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener( const keyboardWillShowListener = Keyboard.addListener(
'keyboardWillShow', 'keyboardWillShow',
() => { () => {
if (!conversationId) {
console.log('Keyboard will show'); console.log('Keyboard will show');
setIsHello(false); setIsHello(false);
setUserMessages([{ setUserMessages([{
@ -100,13 +103,16 @@ export default function SendMessage(props: Props) {
role: 'Assistant', role: 'Assistant',
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}]) }])
} else {
}
} }
); );
return () => { return () => {
keyboardWillShowListener.remove(); keyboardWillShowListener.remove();
}; };
}, []); }, [conversationId]);
return ( return (
<View style={styles.container}> <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 PersonInSvg from "@/assets/icons/svg/personIn.svg";
import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg"; import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import React from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native'; import { Dimensions, Image, StyleSheet, TouchableOpacity, View } from 'react-native';
import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg'; import Svg, { Circle, Ellipse, G, Mask, Path, Rect } from "react-native-svg";
const AskNavbar = () => { // 使用 React.memo 包装 SVG 组件,避免不必要的重渲染
// 获取设备尺寸 const TabIcon = React.memo(({ isActive, ActiveIcon, InactiveIcon }: {
const { width } = Dimensions.get('window'); isActive: boolean;
// 获取路由 ActiveIcon: React.FC<{ width: number; height: number }>;
const pathname = usePathname(); InactiveIcon: React.FC<{ width: number; height: number }>;
}) => {
return ( const Icon = isActive ? ActiveIcon : InactiveIcon;
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{ return <Icon width={24} height={24} />;
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]'}`} // 提取 SVG 组件,避免重复渲染
> const CenterButtonSvg = React.memo(() => (
<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 width="100%" height="100%" viewBox="0 0 85 85" fill="none"> <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"> <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" /> <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="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" /> <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" /> <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" /> <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" /> <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" /> <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" /> <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> </G>
</Svg> </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>
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
<View> <TouchableOpacity
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />} onPress={() => navigateTo('/ask')}
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */} style={styles.centerButton}
</View> >
<CenterButtonSvg />
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigateTo('/owner')}
style={styles.navButton}
>
<TabIcon
isActive={pathname === "/owner"}
ActiveIcon={PersonInSvg}
InactiveIcon={PersonNotInSvg}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</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?", "ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!", "justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo", "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?", "ready": "Ready to wake up your memories?",
"justAsk": "Just ask MeMo, let me bring them back to life!", "justAsk": "Just ask MeMo, let me bring them back to life!",
"selectPhoto": "Select Photo", "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;
};