Files
Novault-Frontend-app/src/screens/Transactions/TransactionsScreen.tsx

255 lines
8.3 KiB
TypeScript

/**
* 交易列表页
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTheme } from '../../contexts';
import { transactionService } from '../../services';
import { spacing, borderRadius, typography } from '../../theme';
import type { Transaction } from '../../types';
import type { RootStackParamList } from '../../navigation/types';
import { AppIcon } from '../../components/common/AppIcon';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export default function TransactionsScreen() {
const insets = useSafeAreaInsets();
const navigation = useNavigation<NavigationProp>();
const { colors, isDark } = useTheme();
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// 刷新数据的回调
const refreshData = useCallback(() => {
loadTransactions(1);
}, []);
useEffect(() => {
refreshData();
}, [refreshData]);
// 每次页面聚焦时刷新数据
useFocusEffect(refreshData);
const loadTransactions = async (pageNum: number, append = false) => {
try {
const response = await transactionService.getTransactions({
page: pageNum,
pageSize: 20,
});
if (append) {
setTransactions(prev => [...prev, ...response.items]);
} else {
setTransactions(response.items);
}
setHasMore(pageNum < response.totalPages);
setPage(pageNum);
} catch (error) {
console.error('加载交易列表失败:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const onRefresh = () => {
setRefreshing(true);
loadTransactions(1);
};
const onEndReached = () => {
if (!loading && hasMore) {
loadTransactions(page + 1, true);
}
};
const formatAmount = (amount: number, type: string) => {
const sign = type === 'expense' ? '-' : type === 'income' ? '+' : '';
return `${sign}¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return `${date.getMonth() + 1}${date.getDate()}`;
};
const getTypeColor = (type: string) => {
switch (type) {
case 'income': return colors.semantic.income;
case 'expense': return colors.semantic.expense;
default: return colors.semantic.transfer;
}
};
const renderItem = useCallback(({ item }: { item: Transaction }) => (
<TouchableOpacity
style={styles.transactionItem}
activeOpacity={0.7}
onPress={() => navigation.navigate('TransactionDetail', { transactionId: item.id })}
>
<View style={[styles.iconContainer, { backgroundColor: getTypeColor(item.type) + '20' }]}>
<AppIcon
name={item.categoryIcon || (item.type === 'income' ? 'cash-plus' : item.type === 'expense' ? 'cash-minus' : 'swap-horizontal')}
size={22}
color={getTypeColor(item.type)}
/>
</View>
<View style={styles.transactionInfo}>
<Text style={styles.transactionNote} numberOfLines={1}>
{item.categoryName || item.note || '未分类'}
</Text>
<Text style={styles.transactionDate}>{formatDate(item.transactionDate)}</Text>
</View>
<Text style={[styles.transactionAmount, { color: getTypeColor(item.type) }]}>
{formatAmount(item.amount, item.type)}
</Text>
</TouchableOpacity>
), [colors, navigation]);
const styles = createStyles(colors, isDark, insets);
if (loading && transactions.length === 0) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={colors.primary[500]} />
</View>
);
}
return (
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<Text style={styles.headerTitle}></Text>
</View>
{/* 列表 */}
<FlatList
data={transactions}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.primary[500]}
/>
}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
ListEmptyComponent={
<View style={styles.emptyState}>
<AppIcon name="clipboard-text-outline" size={48} color={colors.textTertiary} style={{ marginBottom: 16 }} />
<Text style={styles.emptyText}></Text>
</View>
}
ListFooterComponent={
hasMore && transactions.length > 0 ? (
<View style={styles.loadingMore}>
<ActivityIndicator size="small" color={colors.textTertiary} />
</View>
) : null
}
/>
</View>
);
}
const createStyles = (colors: any, isDark: boolean, insets: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
centerContent: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
paddingTop: insets.top + spacing.md,
paddingHorizontal: spacing.pagePadding,
paddingBottom: spacing.md,
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.cardBorder,
},
headerTitle: {
...typography.h3,
color: colors.text,
},
listContent: {
paddingHorizontal: spacing.pagePadding,
paddingTop: spacing.md,
paddingBottom: spacing.tabBarHeight + 40,
},
transactionItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: isDark ? colors.surfaceLight : colors.surface,
padding: spacing.md,
borderRadius: borderRadius.md,
marginBottom: spacing.sm,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
iconText: {
fontSize: 20,
},
transactionInfo: {
flex: 1,
marginLeft: spacing.md,
},
transactionNote: {
...typography.body,
color: colors.text,
},
transactionDate: {
...typography.caption,
color: colors.textSecondary,
marginTop: 2,
},
transactionAmount: {
...typography.amountSmall,
},
emptyState: {
alignItems: 'center',
paddingVertical: 64,
},
emptyIcon: {
fontSize: 48,
marginBottom: 16,
},
emptyText: {
...typography.body,
color: colors.textSecondary,
},
loadingMore: {
paddingVertical: spacing.lg,
alignItems: 'center',
},
});