feat: 秒开

This commit is contained in:
jinyaqiu 2025-07-29 17:18:12 +08:00
parent 2a9db5b070
commit 0a7301ae69
7 changed files with 535 additions and 286 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

@ -1,340 +1,361 @@
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 () => {
try {
setRefreshing(true);
const res = await fetchApi<Chat[]>(`/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 = () => (
<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 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 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' && <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}
// 渲染列表项
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"
/>
</View>
)}
</>
);
</React.Suspense>
</View>
<View style={styles.memoContent}>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.title || t('ask:ask.unNamed')}
</Text>
<Text
style={styles.memoSubtitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.latest_message?.content?.text || t('ask:ask.noMessage')}
</Text>
</View>
</TouchableOpacity>
), [handleMemoPress]);
// 渲染列表头部
const renderHeader = useCallback(() => (
<View style={styles.headerContainer}>
<Text style={[styles.title, { paddingTop: insets.top + 10 }]}>
{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 />
</View>
);
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* <View className="w-full h-full">
<AutoUploadScreen />
</View> */}
<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} />}
/>
{/* 历史对话 */}
<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 }) => (
<TouchableOpacity
style={styles.memoItem}
onPress={() => handleMemoPress(item)}
>
<View className="w-[3rem] h-[3rem] z-1">
<ChatSvg
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
/>
</View>
<View style={styles.memoContent}>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.title || 'memo list 历史消息'}
</Text>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.latest_message?.content?.text || 'memo list 历史消息'}
</Text>
</View>
</TouchableOpacity>
)}
/>
{/* 底部导航栏 */}
<AskNavbar />
</View>
{/* 底部导航栏 */}
<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,
fontSize: 14,
fontWeight: '500',
color: '#333',
flex: 1, // 或者 flexShrink: 1
marginLeft: 12,
color: '#4C320C',
marginBottom: 4,
},
memoSubtitle: {
fontSize: 14,
color: '#666',
fontSize: 12,
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;
});

View File

@ -0,0 +1,86 @@
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;
}
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}></Text>
<Text style={styles.error}>
{this.state.error?.message || '发生了一些问题'}
</Text>
<TouchableOpacity style={styles.retryButton} onPress={this.handleRetry}>
<Text style={styles.retryText}></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

@ -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,12 @@
"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"
}
}

View File

@ -5,6 +5,12 @@
"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": "刷新"
}
}

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;
};