345 lines
10 KiB
TypeScript
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',
|
|
},
|
|
}); |