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