memowake-front/app/(tabs)/memo-list.tsx
2025-08-07 19:04:46 +08:00

345 lines
10 KiB
TypeScript

import { Asset } from 'expo-asset';
import { useFocusEffect, 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';
// 懒加载组件
import ChatSvg from '@/assets/icons/svg/chat.svg';
import ErrorBoundary from '@/components/common/ErrorBoundary';
import UploaderProgress from '@/components/file-upload/upload-progress/uploader-progress';
import SkeletonItem from '@/components/memo/SkeletonItem';
// 类型定义
import { useUploadManager } from '@/hooks/useUploadManager';
import { getCachedData, prefetchChatDetail } from '@/lib/prefetch';
import { fetchApi } from '@/lib/server-api-util';
import { Chat, getMessageText } from '@/types/ask';
import { useTranslation } from 'react-i18next';
// 预加载资源
const preloadAssets = async () => {
try {
await Asset.loadAsync([
require('@/assets/icons/svg/chat.svg'),
]);
} catch (error) {
// console.error('资源预加载失败:', error);
}
};
// 骨架屏占位
const SkeletonList = () => (
<View style={styles.skeletonContainer}>
{Array(5).fill(0).map((_, index) => (
<SkeletonItem key={`skeleton-${index}`} />
))}
</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: item.session_id },
});
}, [router]);
// 初始加载和预加载
useEffect(() => {
let isActive = true;
const initialize = async () => {
try {
// 并行预加载资源和主数据
await Promise.all([
preloadAssets(),
fetchHistoryList()
]);
} catch (error) {
console.error('初始化失败:', error);
} finally {
if (isActive) {
setIsMounted(true);
// 延迟隐藏启动画面
setTimeout(SplashScreen.hideAsync, 500);
}
}
};
initialize();
return () => {
isActive = false;
};
}, [fetchHistoryList]);
// 每次进入页面就刷新
useFocusEffect(
useCallback(() => {
handleRefresh();
}, [])
);
// 渲染列表项
const renderItem = useCallback(({ item }: { item: Chat }) => (
<TouchableOpacity
style={styles.memoItem}
onPress={() => handleMemoPress(item)}
activeOpacity={0.7}
>
<ChatSvg />
<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 && getMessageText(item.latest_message)) || t('ask:ask.noMessage')}
</Text>
</View>
</TouchableOpacity>
), [handleMemoPress]);
// 渲染列表头部
const renderHeader = useCallback(() => (
<View style={styles.headerContainer}>
<Text style={styles.title}>
{t('ask:ask.memoList')}
</Text>
{/* 上传进度 */}
{/* <UploadProgressSection /> */}
</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 (
<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} />}
/>
</View>
</ErrorBoundary>
);
};
// 使用React.memo优化组件
const MemoizedMemoList = React.memo(MemoList);
export default MemoizedMemoList;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
headerContainer: {
paddingBottom: 16,
backgroundColor: '#fff',
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#4C320C',
textAlign: 'center',
marginBottom: 16,
},
listContent: {
paddingBottom: Platform.select({
ios: 30,
android: 20,
}),
},
skeletonContainer: {
flex: 1,
backgroundColor: '#fff',
paddingTop: 20,
},
memoItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#fff',
},
placeholderIcon: {
width: 48,
height: 48,
backgroundColor: '#f0f0f0',
borderRadius: 24,
},
memoContent: {
flex: 1,
marginLeft: 12,
justifyContent: 'center',
gap: 2
},
memoTitle: {
fontSize: 16,
fontWeight: '500',
color: '#4C320C',
marginBottom: 4,
},
memoSubtitle: {
fontSize: 14,
color: '#AC7E35',
},
separator: {
height: 1 / PixelRatio.get(),
backgroundColor: '#f0f0f0',
marginLeft: 60,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 40,
},
emptyText: {
fontSize: 16,
color: '#999',
marginBottom: 20,
},
refreshButton: {
backgroundColor: '#FFB645',
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20,
},
refreshText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
});