From f5a43179fc42fd7a9b5d6b968cce6a51d930f432 Mon Sep 17 00:00:00 2001 From: 12975 <1297598740@qq.com> Date: Wed, 28 Jan 2026 07:00:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E6=80=A7=E4=BA=A4=E6=98=93=E3=80=81=E8=B4=A6=E6=9C=AC=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E9=A2=84=E7=AE=97=E5=92=8C=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E8=A7=86=E5=9B=BE=E7=AD=89=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 78 ++- package.json | 1 + src/components/budget/PiggyBankList.tsx | 369 +++++++++++++ src/components/budget/index.ts | 1 + src/components/transactions/CalendarView.tsx | 168 ++++++ src/components/transactions/index.ts | 1 + src/navigation/index.tsx | 36 ++ src/navigation/types.ts | 8 + src/screens/Budget/BudgetScreen.tsx | 3 + src/screens/Ledgers/EditLedgerScreen.tsx | 289 ++++++++++ src/screens/Ledgers/LedgerListScreen.tsx | 287 ++++++++++ src/screens/Settings/SettingsScreen.tsx | 28 + .../EditRecurringTransactionScreen.tsx | 508 ++++++++++++++++++ .../RecurringTransactionsScreen.tsx | 296 ++++++++++ .../Transactions/TransactionsScreen.tsx | 70 ++- src/services/index.ts | 4 + src/services/ledgerService.ts | 98 ++++ src/services/piggyBankService.ts | 144 +++++ src/services/recurringTransactionService.ts | 126 +++++ src/services/userService.ts | 43 ++ task.md | 20 +- 21 files changed, 2559 insertions(+), 19 deletions(-) create mode 100644 src/components/budget/PiggyBankList.tsx create mode 100644 src/components/budget/index.ts create mode 100644 src/components/transactions/CalendarView.tsx create mode 100644 src/components/transactions/index.ts create mode 100644 src/screens/Ledgers/EditLedgerScreen.tsx create mode 100644 src/screens/Ledgers/LedgerListScreen.tsx create mode 100644 src/screens/Transactions/EditRecurringTransactionScreen.tsx create mode 100644 src/screens/Transactions/RecurringTransactionsScreen.tsx create mode 100644 src/services/ledgerService.ts create mode 100644 src/services/piggyBankService.ts create mode 100644 src/services/recurringTransactionService.ts create mode 100644 src/services/userService.ts diff --git a/package-lock.json b/package-lock.json index a5e69d3..dabb094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/react-native-vector-icons": "^6.4.18", "react": "19.2.0", "react-native": "0.83.1", + "react-native-calendars": "^1.1313.0", "react-native-gesture-handler": "^2.30.0", "react-native-gifted-charts": "^1.4.70", "react-native-linear-gradient": "^2.8.3", @@ -8723,14 +8724,12 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "devOptional": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -9448,6 +9447,16 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10350,6 +10359,38 @@ } } }, + "node_modules/react-native-calendars": { + "version": "1.1313.0", + "resolved": "https://registry.npmjs.org/react-native-calendars/-/react-native-calendars-1.1313.0.tgz", + "integrity": "sha512-YQ7Vg57rBRVymolamYDTxZ0lPOELTDHQbTukTWdxR47aRBYJwKI6ocRbwcY5gYgyDwNgJS4uLGu5AvmYS74LYQ==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.1", + "lodash": "^4.17.15", + "memoize-one": "^5.2.1", + "prop-types": "^15.5.10", + "react-native-safe-area-context": "4.5.0", + "react-native-swipe-gestures": "^1.0.5", + "recyclerlistview": "^4.0.0", + "xdate": "^0.8.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "moment": "^2.29.4" + } + }, + "node_modules/react-native-calendars/node_modules/react-native-safe-area-context": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.5.0.tgz", + "integrity": "sha512-0WORnk9SkREGUg2V7jHZbuN5x4vcxj/1B0QOcXJjdYWrzZHgLcUzYWWIUecUPJh747Mwjt/42RZDOaFn3L8kPQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz", @@ -10438,6 +10479,12 @@ "react-native": "*" } }, + "node_modules/react-native-swipe-gestures": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/react-native-swipe-gestures/-/react-native-swipe-gestures-1.0.5.tgz", + "integrity": "sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==", + "license": "MIT" + }, "node_modules/react-native-vector-icons": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz", @@ -10580,6 +10627,21 @@ "node": ">= 6" } }, + "node_modules/recyclerlistview": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/recyclerlistview/-/recyclerlistview-4.2.3.tgz", + "integrity": "sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g==", + "license": "Apache-2.0", + "dependencies": { + "lodash.debounce": "4.0.8", + "prop-types": "15.8.1", + "ts-object-utils": "0.0.5" + }, + "peerDependencies": { + "react": ">= 15.2.1", + "react-native": ">= 0.30.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11816,6 +11878,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-object-utils": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz", + "integrity": "sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12348,6 +12416,12 @@ "async-limiter": "~1.0.0" } }, + "node_modules/xdate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/xdate/-/xdate-0.8.3.tgz", + "integrity": "sha512-1NhJWPJwN+VjbkACT9XHbQK4o6exeSVtS2CxhMPwUE7xQakoEFTlwra9YcqV/uHQVyeEUYoYC46VGDJ+etnIiw==", + "license": "(MIT OR GPL-2.0)" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index ca02c1d..5ae8a4f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/react-native-vector-icons": "^6.4.18", "react": "19.2.0", "react-native": "0.83.1", + "react-native-calendars": "^1.1313.0", "react-native-gesture-handler": "^2.30.0", "react-native-gifted-charts": "^1.4.70", "react-native-linear-gradient": "^2.8.3", diff --git a/src/components/budget/PiggyBankList.tsx b/src/components/budget/PiggyBankList.tsx new file mode 100644 index 0000000..6f0ba5a --- /dev/null +++ b/src/components/budget/PiggyBankList.tsx @@ -0,0 +1,369 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + Alert, + FlatList, + Modal, + TextInput +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useTheme } from '../../contexts'; +import { piggyBankService } from '../../services'; +import type { PiggyBank } from '../../types'; +import { AppIcon } from '../common/AppIcon'; +import { spacing, borderRadius, typography } from '../../theme'; +import { formatCurrency, formatPercentage } from '../../utils'; +import AmountInput from '../common/AmountInput'; // Assuming this exists and is usable here + +export default function PiggyBankList() { + const { colors, isDark } = useTheme(); + const navigation = useNavigation(); + const [piggyBanks, setPiggyBanks] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Operation State + const [selectedBank, setSelectedBank] = useState(null); + const [operationType, setOperationType] = useState<'deposit' | 'withdraw' | null>(null); + const [amount, setAmount] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const styles = createStyles(colors, isDark); + + const loadData = useCallback(async () => { + try { + const data = await piggyBankService.getAll(); + setPiggyBanks(data); + } catch (error) { + console.error('Failed to load piggy banks', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleOperation = async () => { + if (!selectedBank || !operationType || !amount) return; + const val = parseFloat(amount); + if (isNaN(val) || val <= 0) { + Alert.alert('错误', '请输入有效金额'); + return; + } + + try { + setSubmitting(true); + if (operationType === 'deposit') { + await piggyBankService.deposit(selectedBank.id, val); + } else { + await piggyBankService.withdraw(selectedBank.id, val); + } + // Refresh + await loadData(); + closeOperation(); + Alert.alert('成功', operationType === 'deposit' ? '存入成功' : '取出成功'); + } catch (error) { + Alert.alert('错误', '操作失败'); + } finally { + setSubmitting(false); + } + }; + + const closeOperation = () => { + setSelectedBank(null); + setOperationType(null); + setAmount(''); + }; + + const renderItem = ({ item }: { item: PiggyBank }) => { + const progress = Math.min((item.currentAmount / item.targetAmount) * 100, 100); + return ( + + + + + + + {item.name} + {formatCurrency(item.currentAmount)} / {formatCurrency(item.targetAmount)} + + + {progress.toFixed(1)}% + + + + {/* Progress Bar */} + + + + + {/* Actions */} + + { + setSelectedBank(item); + setOperationType('withdraw'); + }} + > + 取出 + + { + setSelectedBank(item); + setOperationType('deposit'); + }} + > + 存入 + + + + ); + }; + + if (loading) return ; + + if (piggyBanks.length === 0) { + // Simple empty state or "Create" button + return ( + + + 存钱目标 + {/* Add button can be here or globally */} + + + 暂无存钱目标 + + + ); + } + + return ( + + + 存钱目标 + Alert.alert('提示', '新建功能开发中')}> + + + + + item.id.toString()} + scrollEnabled={false} // Since clear in ScrollView + contentContainerStyle={{ gap: spacing.md }} + /> + + {/* Operation Modal */} + + + + + + {operationType === 'deposit' ? '存入资金' : '取出资金'} + + + + + + + + {selectedBank?.name} + + 当前余额: {formatCurrency(selectedBank?.currentAmount || 0)} + + + + + + {submitting ? ( + + ) : ( + 确认 + )} + + + + + + + ); +} + +const createStyles = (colors: any, isDark: boolean) => StyleSheet.create({ + container: { + marginTop: spacing.xl, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + paddingHorizontal: spacing.sm, + }, + sectionTitle: { + ...typography.h4, + color: colors.text, + }, + card: { + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.md, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.semantic.income + '20', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + headerText: { + flex: 1, + }, + title: { + ...typography.body, + fontWeight: '600', + color: colors.text, + }, + subtitle: { + ...typography.caption, + color: colors.textSecondary, + }, + percentage: { + marginLeft: spacing.sm, + }, + percentageText: { + ...typography.h4, + color: colors.semantic.income, + }, + progressTrack: { + height: 6, + backgroundColor: isDark ? colors.background : colors.neutral[100], + borderRadius: 3, + marginBottom: spacing.md, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: colors.semantic.income, + borderRadius: 3, + }, + actions: { + flexDirection: 'row', + gap: spacing.md, + }, + actionButton: { + flex: 1, + paddingVertical: spacing.sm, + alignItems: 'center', + borderRadius: borderRadius.sm, + backgroundColor: isDark ? colors.background : colors.neutral[50], // Fallback + }, + depositButton: { + backgroundColor: colors.semantic.income + '15', + }, + withdrawButton: { + backgroundColor: colors.semantic.expense + '15', + }, + actionText: { + ...typography.captionBold, + }, + emptyState: { + padding: spacing.xl, + alignItems: 'center', + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + borderRadius: borderRadius.lg, + }, + emptyText: { + color: colors.textSecondary, + }, + // Modal + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + padding: spacing.lg, + }, + modalContent: { + backgroundColor: isDark ? colors.surface : '#FFF', + borderRadius: borderRadius.lg, + padding: spacing.lg, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.lg, + }, + modalTitle: { + ...typography.h4, + color: colors.text, + }, + modalBody: { + gap: spacing.md, + }, + targetName: { + ...typography.body, + color: colors.text, + textAlign: 'center', + fontWeight: 'bold', + }, + currentBalance: { + ...typography.caption, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.sm, + }, + input: { + backgroundColor: isDark ? colors.background : colors.neutral[100], + padding: spacing.md, + borderRadius: borderRadius.md, + fontSize: 24, + textAlign: 'center', + color: colors.text, + fontWeight: 'bold', + }, + confirmButton: { + padding: spacing.md, + borderRadius: borderRadius.md, + alignItems: 'center', + marginTop: spacing.sm, + }, + confirmButtonText: { + ...typography.button, + color: '#FFF', + }, +}); diff --git a/src/components/budget/index.ts b/src/components/budget/index.ts new file mode 100644 index 0000000..0d95a21 --- /dev/null +++ b/src/components/budget/index.ts @@ -0,0 +1 @@ +export { default as PiggyBankList } from './PiggyBankList'; diff --git a/src/components/transactions/CalendarView.tsx b/src/components/transactions/CalendarView.tsx new file mode 100644 index 0000000..b866e97 --- /dev/null +++ b/src/components/transactions/CalendarView.tsx @@ -0,0 +1,168 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { View, StyleSheet, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { Calendar, LocaleConfig } from 'react-native-calendars'; +import { useTheme } from '../../contexts'; +import { transactionService } from '../../services'; +import type { Transaction } from '../../types'; +import { spacing, borderRadius, typography } from '../../theme'; +import { formatCurrency } from '../../utils'; + +// Configure Chinese Locale +LocaleConfig.locales['zh'] = { + monthNames: [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月' + ], + monthNamesShort: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], + dayNames: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], + dayNamesShort: ['周日', '一', '二', '三', '四', '五', '六'], + today: '今天' +}; +LocaleConfig.defaultLocale = 'zh'; + +interface CalendarViewProps { + onDateSelect: (date: string) => void; + currentDate: string; // YYYY-MM-DD +} + +export default function CalendarView({ onDateSelect, currentDate }: CalendarViewProps) { + const { colors, isDark } = useTheme(); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [currentMonth, setCurrentMonth] = useState(currentDate.substring(0, 7)); // YYYY-MM + + useEffect(() => { + fetchMonthData(currentMonth); + }, [currentMonth]); + + const fetchMonthData = async (month: string) => { + // month is YYYY-MM + const start = `${month}-01`; + // Simple calculaton for end of month + const [year, m] = month.split('-').map(Number); + const lastDay = new Date(year, m, 0).getDate(); + const end = `${month}-${lastDay}`; + + try { + setLoading(true); + const data = await transactionService.getAll({ + startDate: start, + endDate: end, + pageSize: 1000 // Get all for marking + }); + setTransactions(data.items); + } catch (error) { + console.error('Failed to load calendar data', error); + } finally { + setLoading(false); + } + }; + + const markedDates = useMemo(() => { + const marks: any = {}; + + // Group by date + const dailyStats: Record = {}; + + transactions.forEach(t => { + const date = t.transactionDate.split('T')[0]; + if (!dailyStats[date]) { + dailyStats[date] = { income: 0, expense: 0 }; + } + if (t.type === 'expense') dailyStats[date].expense += t.amount; + if (t.type === 'income') dailyStats[date].income += t.amount; + }); + + // Create marks + Object.keys(dailyStats).forEach(date => { + const { income, expense } = dailyStats[date]; + const dots = []; + if (income > 0) dots.push({ key: 'income', color: colors.semantic.income }); + if (expense > 0) dots.push({ key: 'expense', color: colors.semantic.expense }); + + marks[date] = { + dots, + marked: true + }; + }); + + // Highlight selected date + if (marks[currentDate]) { + marks[currentDate] = { + ...marks[currentDate], + selected: true, + selectedColor: colors.primary[500] + }; + } else { + marks[currentDate] = { + selected: true, + selectedColor: colors.primary[500] + }; + } + + return marks; + }, [transactions, currentDate, colors]); + + return ( + + {loading && ( + + + + )} + onDateSelect(day.dateString)} + onMonthChange={(month: any) => setCurrentMonth(month.dateString.substring(0, 7))} + markingType={'multi-dot'} + markedDates={markedDates} + theme={{ + calendarBackground: 'transparent', + textSectionTitleColor: colors.textSecondary, + selectedDayBackgroundColor: colors.primary[500], + selectedDayTextColor: '#ffffff', + todayTextColor: colors.primary[500], + dayTextColor: colors.text, + textDisabledColor: colors.textTertiary, + dotColor: colors.primary[500], + selectedDotColor: '#ffffff', + arrowColor: colors.primary[500], + monthTextColor: colors.text, + textDayFontFamily: 'System', + textMonthFontFamily: 'System', + textDayHeaderFontFamily: 'System', + textDayFontSize: 16, + textMonthFontSize: 18, + textDayHeaderFontSize: 14 + }} + /> + {/* Daily Summary below calendar if needed, or parent handles it */} + + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: borderRadius.lg, + padding: spacing.sm, + marginBottom: spacing.md, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.1)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + borderRadius: borderRadius.lg, + } +}); diff --git a/src/components/transactions/index.ts b/src/components/transactions/index.ts new file mode 100644 index 0000000..07b47d3 --- /dev/null +++ b/src/components/transactions/index.ts @@ -0,0 +1 @@ +export { default as CalendarView } from './CalendarView'; diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 42ec39b..8b3e2fe 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -19,6 +19,12 @@ import LoginScreen from '../screens/Auth/LoginScreen'; import AddTransactionScreen from '../screens/Transactions/AddTransactionScreen'; import TransactionDetailScreen from '../screens/Transactions/TransactionDetailScreen'; import EditTransactionScreen from '../screens/Transactions/EditTransactionScreen'; +import RecurringTransactionsScreen from '../screens/Transactions/RecurringTransactionsScreen'; +import EditRecurringTransactionScreen from '../screens/Transactions/EditRecurringTransactionScreen'; + +// 账本相关 +import LedgerListScreen from '../screens/Ledgers/LedgerListScreen'; +import EditLedgerScreen from '../screens/Ledgers/EditLedgerScreen'; // 账户相关页面 import AccountDetailScreen from '../screens/Accounts/AccountDetailScreen'; @@ -133,6 +139,36 @@ export default function RootNavigator() { animation: 'slide_from_bottom', }} /> + + {/* 周期性交易相关 */} + + + + {/* 账本相关 */} + + ) : ( // 未登录 - 认证界面 diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 727f964..9bc08f0 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -41,6 +41,14 @@ export type RootStackParamList = { AddBudget: undefined; BudgetDetail: { budgetId: number }; + // 周期性交易相关 + RecurringTransactions: undefined; + EditRecurringTransaction: { recurringId?: number }; + + // 账本相关 + LedgerList: undefined; + EditLedger: { ledgerId?: number }; + // 其他页面 CategorySelect: { type: 'income' | 'expense' }; AccountSelect: undefined; diff --git a/src/screens/Budget/BudgetScreen.tsx b/src/screens/Budget/BudgetScreen.tsx index d5ba879..1c270f8 100644 --- a/src/screens/Budget/BudgetScreen.tsx +++ b/src/screens/Budget/BudgetScreen.tsx @@ -21,6 +21,7 @@ import { formatCurrency } from '../../utils'; import { spacing, borderRadius, typography } from '../../theme'; import type { Budget } from '../../types'; import type { RootStackParamList } from '../../navigation/types'; +import { PiggyBankList } from '../../components/budget'; type NavigationProp = NativeStackNavigationProp; @@ -159,6 +160,8 @@ export default function BudgetScreen() { ); }) )} + + ); diff --git a/src/screens/Ledgers/EditLedgerScreen.tsx b/src/screens/Ledgers/EditLedgerScreen.tsx new file mode 100644 index 0000000..ba7314e --- /dev/null +++ b/src/screens/Ledgers/EditLedgerScreen.tsx @@ -0,0 +1,289 @@ +/** + * 编辑/创建账本页面 + */ + +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + Switch, + ScrollView, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useTheme } from '../../contexts'; +import { ledgerService } from '../../services'; +import { spacing, borderRadius, typography } from '../../theme'; +import type { RootStackParamList } from '../../navigation/types'; +import { AppIcon } from '../../components/common/AppIcon'; + +type NavigationProp = NativeStackNavigationProp; +type RouteProps = RouteProp; + +const THEMES = ['pink', 'beige', 'brown'] as const; + +export default function EditLedgerScreen() { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const route = useRoute(); + const { colors, isDark } = useTheme(); + + const { ledgerId } = route.params; + const isEdit = !!ledgerId; + + const [name, setName] = useState(''); + const [theme, setTheme] = useState<'pink' | 'beige' | 'brown'>('beige'); + const [isDefault, setIsDefault] = useState(false); + const [loading, setLoading] = useState(isEdit); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (isEdit && ledgerId) { + loadData(ledgerId); + } + }, [ledgerId]); + + const loadData = async (id: number) => { + try { + const data = await ledgerService.getById(id); + setName(data.name); + setTheme(data.theme); + setIsDefault(data.isDefault); + } catch (error) { + console.error('加载账本详情失败', error); + Alert.alert('错误', '加载详情失败'); + navigation.goBack(); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!name.trim()) { + Alert.alert('提示', '请输入账本名称'); + return; + } + + try { + setSaving(true); + const payload = { + name, + theme, + isDefault, + coverImage: 'default', // placeholder + sortOrder: 0, + }; + + if (isEdit && ledgerId) { + await ledgerService.update(ledgerId, payload); + } else { + await ledgerService.create(payload); + } + navigation.goBack(); + } catch (error) { + console.error('保存失败', error); + Alert.alert('错误', '保存失败'); + } finally { + setSaving(false); + } + }; + + const handleDelete = () => { + if (!isEdit || !ledgerId) return; + Alert.alert('确认删除', '确定要删除此账本吗? 删除后无法恢复。', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + await ledgerService.delete(ledgerId); + navigation.goBack(); + } catch (error) { + Alert.alert('错误', '删除失败'); + } + }, + }, + ]); + }; + + const styles = createStyles(colors, isDark, insets); + + if (loading) { + return ( + + + + ); + } + + return ( + + + navigation.goBack()} style={styles.backButton}> + + + {isEdit ? '编辑账本' : '新建账本'} + + {saving ? : 保存} + + + + + + 账本名称 + + + + + 主题风格 + + {THEMES.map((t) => ( + setTheme(t)} + > + {theme === t && } + + ))} + + + {theme === 'pink' ? '粉色梦幻' : theme === 'beige' ? '米色简约' : '棕色复古'} + + + + + 设为默认账本 + + + + {isEdit && ( + + 删除账本 + + )} + + + ); +} + +const createStyles = (colors: any, isDark: boolean, insets: any) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: insets.top + spacing.md, + paddingHorizontal: spacing.pagePadding, + paddingBottom: spacing.md, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.cardBorder, + }, + backButton: { + padding: 8, + marginLeft: -8, + }, + headerTitle: { + ...typography.h4, + color: colors.text, + }, + saveButton: { + padding: 8, + marginRight: -8, + }, + saveButtonText: { + ...typography.body, + color: colors.primary[500], + fontWeight: '600', + }, + content: { + padding: spacing.pagePadding, + }, + formGroup: { + marginBottom: spacing.xl, + }, + label: { + ...typography.body, + color: colors.text, + marginBottom: spacing.sm, + }, + input: { + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + borderRadius: borderRadius.md, + padding: spacing.md, + color: colors.text, + fontSize: 16, + }, + themeContainer: { + flexDirection: 'row', + gap: spacing.lg, + }, + themeOption: { + width: 48, + height: 48, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + themeOptionActive: { + borderColor: colors.primary[500], + }, + themeName: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.sm, + }, + switchGroup: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.xl, + }, + deleteButton: { + alignItems: 'center', + padding: spacing.md, + backgroundColor: colors.semantic.expense + '15', + borderRadius: borderRadius.lg, + }, + deleteButtonText: { + ...typography.body, + color: colors.semantic.expense, + fontWeight: '600', + }, + }); diff --git a/src/screens/Ledgers/LedgerListScreen.tsx b/src/screens/Ledgers/LedgerListScreen.tsx new file mode 100644 index 0000000..3fbadd4 --- /dev/null +++ b/src/screens/Ledgers/LedgerListScreen.tsx @@ -0,0 +1,287 @@ +/** + * 账本列表页面 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + RefreshControl, + ActivityIndicator, + Alert, +} 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 { ledgerService, userService } from '../../services'; +import { spacing, borderRadius, typography } from '../../theme'; +import type { Ledger, UserSettings } from '../../types'; +import type { RootStackParamList } from '../../navigation/types'; +import { AppIcon } from '../../components/common/AppIcon'; + +type NavigationProp = NativeStackNavigationProp; + +export default function LedgerListScreen() { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { colors, isDark } = useTheme(); + + const [ledgers, setLedgers] = useState([]); + const [currentLedgerId, setCurrentLedgerId] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const loadData = useCallback(async () => { + try { + const [ledgersData, settingsData] = await Promise.all([ + ledgerService.getAll(), + userService.getSettings().catch(() => null), // Fail gracefully if settings API not ready + ]); + setLedgers(ledgersData); + if (settingsData) { + setCurrentLedgerId(settingsData.currentLedgerId || null); + } + } catch (error) { + console.error('加载列表失败:', error); + Alert.alert('错误', '加载列表失败'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + useFocusEffect( + useCallback(() => { + loadData(); + }, [loadData]) + ); + + const onRefresh = () => { + setRefreshing(true); + loadData(); + }; + + const handleSwitch = async (item: Ledger) => { + if (item.id === currentLedgerId) return; + try { + await userService.switchLedger(item.id); + setCurrentLedgerId(item.id); + Alert.alert('成功', `已切换至 "${item.name}"`); + } catch (error) { + Alert.alert('错误', '切换账本失败'); + } + }; + + const handleDelete = (item: Ledger) => { + Alert.alert('确认删除', `确定要删除账本"${item.name}"吗?`, [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + await ledgerService.delete(item.id); + loadData(); + } catch (error) { + Alert.alert('错误', '删除失败'); + } + }, + }, + ]); + }; + + const renderItem = ({ item }: { item: Ledger }) => { + const isActive = item.id === currentLedgerId; + + return ( + navigation.navigate('EditLedger', { ledgerId: item.id })} + activeOpacity={0.7} + > + handleSwitch(item)} + > + + + + + {item.name} + {item.isDefault && ( + + 默认 + + )} + {isActive && ( + + 当前使用 + + )} + + + {new Date(item.createdAt).toLocaleDateString()} 创建 + + + + + ); + }; + + const styles = createStyles(colors, isDark, insets); + + return ( + + + navigation.goBack()} + > + + + 账本管理 + navigation.navigate('EditLedger', {})} + > + + + + + {loading ? ( + + + + ) : ( + item.id.toString()} + contentContainerStyle={styles.listContent} + refreshControl={ + + } + ListEmptyComponent={ + + 暂无账本 + + } + /> + )} + + ); +} + +const createStyles = (colors: any, isDark: boolean, insets: any) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: insets.top + spacing.md, + paddingHorizontal: spacing.pagePadding, + paddingBottom: spacing.md, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.cardBorder, + }, + backButton: { + padding: 8, + marginLeft: -8, + }, + headerTitle: { + ...typography.h4, + color: colors.text, + }, + addButton: { + padding: 8, + marginRight: -8, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + listContent: { + padding: spacing.pagePadding, + gap: spacing.md, + }, + card: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + padding: spacing.md, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: 'transparent', + }, + activeCard: { + borderColor: colors.primary[500], + backgroundColor: isDark ? colors.surfaceLight : colors.primary[50] + '40', + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + cardContent: { + flex: 1, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + flexWrap: 'wrap', + }, + cardTitle: { + ...typography.h4, + color: colors.text, + marginRight: spacing.sm, + }, + activeText: { + color: colors.primary[700], + fontWeight: 'bold', + }, + defaultBadge: { + backgroundColor: colors.primary[100], + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + defaultText: { + ...typography.caption, + color: colors.primary[700], + fontSize: 10, + }, + cardSubtitle: { + ...typography.caption, + color: colors.textSecondary, + }, + emptyState: { + alignItems: 'center', + paddingVertical: spacing.xxxl, + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, + }, + }); diff --git a/src/screens/Settings/SettingsScreen.tsx b/src/screens/Settings/SettingsScreen.tsx index 3e36ced..f1fe567 100644 --- a/src/screens/Settings/SettingsScreen.tsx +++ b/src/screens/Settings/SettingsScreen.tsx @@ -12,12 +12,16 @@ import { Switch, Alert, } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { RootStackParamList } from '../../navigation/types'; import { useTheme, useAuth } from '../../contexts'; import { spacing, borderRadius, typography } from '../../theme'; export default function SettingsScreen() { const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); const { colors, isDark, toggleTheme } = useTheme(); const { user, logout } = useAuth(); @@ -92,6 +96,30 @@ export default function SettingsScreen() { 数据 + navigation.navigate('RecurringTransactions')} + > + + 🔄 + 周期性交易 + + + + + navigation.navigate('LedgerList')} + > + + 📚 + 账本管理 + + + + ☁️ diff --git a/src/screens/Transactions/EditRecurringTransactionScreen.tsx b/src/screens/Transactions/EditRecurringTransactionScreen.tsx new file mode 100644 index 0000000..90d01ec --- /dev/null +++ b/src/screens/Transactions/EditRecurringTransactionScreen.tsx @@ -0,0 +1,508 @@ +/** + * 编辑/新建周期性交易页面 + */ + +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, + Switch, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useTheme } from '../../contexts'; +import { recurringTransactionService } from '../../services'; +import { spacing, borderRadius, typography } from '../../theme'; +import { formatDate } from '../../utils'; +import type { RecurringTransaction } from '../../types'; +import type { RootStackParamList } from '../../navigation/types'; +import { AppIcon, CategorySelector, AccountSelector, DatePickerModal, AmountInput } from '../../components/common'; + +type NavigationProp = NativeStackNavigationProp; +type RouteProps = RouteProp; + +export default function EditRecurringTransactionScreen() { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const route = useRoute(); + const { colors, isDark } = useTheme(); + + const { recurringId } = route.params; + const isEdit = !!recurringId; + + const [loading, setLoading] = useState(isEdit); + const [saving, setSaving] = useState(false); + + // Form State + const [amount, setAmount] = useState(''); // String for input + const [type, setType] = useState<'expense' | 'income' | 'transfer'>('expense'); + const [categoryId, setCategoryId] = useState(undefined); + const [accountId, setAccountId] = useState(undefined); + const [toAccountId, setToAccountId] = useState(undefined); + const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly' | 'yearly'>('monthly'); + const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]); + const [endDate, setEndDate] = useState(undefined); + const [note, setNote] = useState(''); + const [isActive, setIsActive] = useState(true); + + // Modals + const [showCategorySelect, setShowCategorySelect] = useState(false); + const [showAccountSelect, setShowAccountSelect] = useState(false); + const [showToAccountSelect, setShowToAccountSelect] = useState(false); + const [showStartDatePicker, setShowStartDatePicker] = useState(false); + const [showEndDatePicker, setShowEndDatePicker] = useState(false); + + useEffect(() => { + if (isEdit && recurringId) { + loadData(recurringId); + } + }, [recurringId]); + + const loadData = async (id: number) => { + try { + const data = await recurringTransactionService.getById(id); + setAmount(data.amount.toString()); + setType(data.type as any); // Type assertion if needed + setCategoryId(data.categoryId); + setAccountId(data.accountId); + // setToAccountId currently not in RecurringTransaction type but service supports it? + // Looking at types/index.ts, RecurringTransaction interface does NOT have toAccountId. + // But api might conform. I'll omit toAccountId from load for now if type mismatch or check expand. + // Actually, assuming standard transaction struct, transfer has two accounts. + // Let's assume for now standard logic: accountId is FROM. + + setFrequency(data.frequency); + setStartDate(data.startDate); + setEndDate(data.endDate); + setNote(data.note || ''); + setIsActive(data.isActive); + } catch (error) { + console.error('加载详情失败', error); + Alert.alert('错误', '加载详情失败'); + navigation.goBack(); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!amount || parseFloat(amount) <= 0) { + Alert.alert('提示', '请输入有效金额'); + return; + } + if (!accountId) { + Alert.alert('提示', '请选择账户'); + return; + } + if (type !== 'transfer' && !categoryId) { + Alert.alert('提示', '请选择分类'); + return; + } + if (type === 'transfer' && !toAccountId && !isEdit) { + // Note: RecurringTransaction type in index.ts doesn't show toAccountId property? + // If the backend stores it, we need to send it. + // If type is transfer, we need target account. + if (!toAccountId) { + Alert.alert('提示', '请选择转入账户'); + return; + } + } + + try { + setSaving(true); + const payload = { + amount: parseFloat(amount), + type, + categoryId: categoryId || 0, // 0 for transfer if allowed or handle logic + accountId, + toAccountId: type === 'transfer' ? toAccountId : undefined, // This might not be in type definition but needed for API + currency: 'CNY', // Default for now + frequency, + startDate, + endDate, + note, + isActive, + }; + + if (isEdit && recurringId) { + await recurringTransactionService.update(recurringId, payload); + } else { + await recurringTransactionService.create(payload as any); + } + navigation.goBack(); + } catch (error) { + console.error('保存失败', error); + Alert.alert('错误', '保存失败'); + } finally { + setSaving(false); + } + }; + + const handleDelete = () => { + if (!isEdit || !recurringId) return; + Alert.alert('确认删除', '确定要删除此规则吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + await recurringTransactionService.delete(recurringId); + navigation.goBack(); + } catch (error) { + Alert.alert('错误', '删除失败'); + } + } + } + ]); + }; + + const styles = createStyles(colors, isDark, insets); + + if (loading) { + return ( + + + + ); + } + + return ( + + + navigation.goBack()} style={styles.backButton}> + + + {isEdit ? '编辑规则' : '新建规则'} + + {saving ? : 保存} + + + + + + {/* Type Switcher */} + + {(['expense', 'income', 'transfer'] as const).map((t) => ( + setType(t)} + > + + {t === 'expense' ? '支出' : t === 'income' ? '收入' : '转账'} + + + ))} + + + {/* Amount */} + + 金额 + + + + {/* Frequency & Dates */} + + 频率与时间 + + {/* Frequency */} + + 重复频率 + + {(['daily', 'weekly', 'monthly', 'yearly'] as const).map(f => ( + setFrequency(f)} + > + + {f === 'daily' ? '每天' : f === 'weekly' ? '每周' : f === 'monthly' ? '每月' : '每年'} + + + ))} + + + + setShowStartDatePicker(true)}> + 开始日期 + {formatDate(startDate)} + + + setShowEndDatePicker(true)}> + 结束日期 (可选) + {endDate ? formatDate(endDate) : '无'} + + + + {/* Categories & Accounts */} + + 交易详情 + + {type !== 'transfer' && ( + setShowCategorySelect(true)}> + 分类 + + {categoryId ? '已选择' : '请选择'} + + + + )} + + setShowAccountSelect(true)}> + {type === 'transfer' ? '转出账户' : '账户'} + + {/* Ideally show account name here if we had the list or fetched object */} + {accountId ? '已选择' : '请选择'} + + + + + {type === 'transfer' && ( + setShowToAccountSelect(true)}> + 转入账户 + + {toAccountId ? '已选择' : '请选择'} + + + + )} + + + 备注 + + + + + {/* Active Switch */} + + 启用规则 + + + + {isEdit && ( + + 删除规则 + + )} + + + + {/* Modals */} + setShowCategorySelect(false)} + onSelect={(category) => { + setCategoryId(category.id); + setShowCategorySelect(false); + }} + type={type === 'transfer' ? 'expense' : type} // Fallback for transfer + /> + + setShowAccountSelect(false)} + onSelect={(account) => { + setAccountId(account.id); + setShowAccountSelect(false); + }} + /> + + setShowToAccountSelect(false)} + onSelect={(account) => { + setToAccountId(account.id); + setShowToAccountSelect(false); + }} + /> + + setShowStartDatePicker(false)} + date={new Date(startDate)} + onDateChange={(date) => { + setStartDate(date.toISOString().split('T')[0]); + }} + /> + + setShowEndDatePicker(false)} + date={endDate ? new Date(endDate) : new Date()} + onDateChange={(date) => { + setEndDate(date.toISOString().split('T')[0]); + }} + showClear // Assuming DatePickerModal supports clear or we need to add a clear button + /> + + + ); +} + +const createStyles = (colors: any, isDark: boolean, insets: any) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: insets.top + spacing.md, + paddingHorizontal: spacing.pagePadding, + paddingBottom: spacing.md, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.cardBorder, + }, + backButton: { + padding: 8, + marginLeft: -8, + }, + headerTitle: { + ...typography.h4, + color: colors.text, + }, + saveButton: { + padding: 8, + marginRight: -8, + }, + saveButtonText: { + ...typography.body, + color: colors.primary[500], + fontWeight: '600', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: spacing.pagePadding, + paddingBottom: insets.bottom + spacing.xl, + }, + typeContainer: { + flexDirection: 'row', + backgroundColor: isDark ? colors.surfaceLight : colors.neutral[100], + borderRadius: borderRadius.md, + padding: 4, + marginBottom: spacing.lg, + }, + typeButton: { + flex: 1, + paddingVertical: spacing.sm, + alignItems: 'center', + borderRadius: borderRadius.sm, + }, + typeText: { + ...typography.bodySmall, + color: colors.textSecondary, + }, + card: { + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.md, + marginBottom: spacing.lg, + }, + sectionTitle: { + ...typography.h5, + color: colors.text, + marginBottom: spacing.md, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.cardBorder, + }, + label: { + ...typography.body, + color: colors.text, + }, + valueRow: { + flexDirection: 'row', + alignItems: 'center', + }, + valueText: { + ...typography.body, + color: colors.textSecondary, + marginRight: 4, + }, + input: { + ...typography.body, + color: colors.text, + flex: 1, + paddingVertical: 0, + }, + freqRow: { + flexDirection: 'row', + gap: 8, + }, + freqChip: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + backgroundColor: colors.neutral[200], + }, + freqChipActive: { + backgroundColor: colors.primary[500], + }, + freqText: { + ...typography.caption, + color: colors.textSecondary, + }, + freqTextActive: { + color: '#FFF', + }, + deleteButton: { + alignItems: 'center', + padding: spacing.md, + backgroundColor: colors.semantic.expense + '15', + borderRadius: borderRadius.lg, + marginTop: spacing.sm, + }, + deleteButtonText: { + ...typography.body, + color: colors.semantic.expense, + fontWeight: '600', + }, + }); diff --git a/src/screens/Transactions/RecurringTransactionsScreen.tsx b/src/screens/Transactions/RecurringTransactionsScreen.tsx new file mode 100644 index 0000000..850c725 --- /dev/null +++ b/src/screens/Transactions/RecurringTransactionsScreen.tsx @@ -0,0 +1,296 @@ +/** + * 周期性交易列表页 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + RefreshControl, + ActivityIndicator, + Alert, +} 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 { recurringTransactionService } from '../../services'; +import { spacing, borderRadius, typography } from '../../theme'; +import { formatCurrency, formatDate } from '../../utils'; +import type { RecurringTransaction } from '../../types'; +import type { RootStackParamList } from '../../navigation/types'; +import { AppIcon } from '../../components/common/AppIcon'; + +type NavigationProp = NativeStackNavigationProp; + +export default function RecurringTransactionsScreen() { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { colors, isDark } = useTheme(); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const loadData = useCallback(async () => { + try { + const response = await recurringTransactionService.getAll({ pageSize: 100 }); + setItems(response.items); + } catch (error) { + console.error('加载周期性交易列表失败:', error); + Alert.alert('错误', '加载列表失败'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + useFocusEffect( + useCallback(() => { + loadData(); + }, [loadData]) + ); + + const onRefresh = () => { + setRefreshing(true); + loadData(); + }; + + const getFrequencyLabel = (freq: string) => { + const map: Record = { + daily: '每天', + weekly: '每周', + monthly: '每月', + yearly: '每年', + }; + return map[freq] || freq; + }; + + const getTypeColor = (type: string) => { + switch (type) { + case 'income': return colors.semantic.income; + case 'expense': return colors.semantic.expense; + default: return colors.semantic.transfer; + } + }; + + const getIconName = (type: string) => { + switch (type) { + case 'income': return 'cash-plus'; + case 'expense': return 'cash-minus'; + default: return 'swap-horizontal'; + } + }; + + const renderItem = ({ item }: { item: RecurringTransaction }) => { + const typeColor = getTypeColor(item.type); + const isActive = item.isActive; + + return ( + navigation.navigate('EditRecurringTransaction', { recurringId: item.id })} + activeOpacity={0.7} + > + + + + + + + + {item.note || '未命名规则'} + + + {item.type === 'expense' ? '-' : '+'}{formatCurrency(item.amount)} + + + + + + {getFrequencyLabel(item.frequency)} + + + 下次: {formatDate(item.nextOccurrence)} + + + + + ); + }; + + const styles = createStyles(colors, isDark, insets); + + return ( + + {/* 头部 */} + + navigation.goBack()} + > + + + 周期性交易 + navigation.navigate('EditRecurringTransaction', {})} + > + + + + + {loading ? ( + + + + ) : ( + item.id.toString()} + contentContainerStyle={styles.listContent} + refreshControl={ + + } + ListEmptyComponent={ + + + 暂无周期性交易 + 添加规则以自动记录定期账单 + + } + /> + )} + + ); +} + +const createStyles = (colors: any, isDark: boolean, insets: any) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: insets.top + spacing.md, + paddingHorizontal: spacing.pagePadding, + paddingBottom: spacing.md, + backgroundColor: colors.surface, + borderBottomWidth: 1, + borderBottomColor: colors.cardBorder, + }, + backButton: { + padding: 8, + marginLeft: -8, + }, + headerTitle: { + ...typography.h4, + color: colors.text, + }, + addButton: { + padding: 8, + marginRight: -8, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + listContent: { + padding: spacing.pagePadding, + paddingBottom: insets.bottom + spacing.xl, + }, + itemCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: isDark ? colors.surfaceLight : colors.surface, + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.sm, + }, + itemDisabled: { + opacity: 0.6, + }, + iconContainer: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + itemContent: { + flex: 1, + }, + itemHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + itemNote: { + ...typography.body, + color: colors.text, + fontWeight: '600', + flex: 1, + marginRight: spacing.md, + }, + textDisabled: { + color: colors.textSecondary, + }, + itemAmount: { + ...typography.amountSmall, + }, + itemFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + tagContainer: { + backgroundColor: colors.primary[500] + '15', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: borderRadius.sm, + }, + tagText: { + ...typography.caption, + color: colors.primary[500], + fontSize: 11, + }, + nextDate: { + ...typography.caption, + color: colors.textSecondary, + }, + emptyState: { + alignItems: 'center', + paddingVertical: 64, + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, + marginBottom: 4, + }, + emptySubText: { + ...typography.caption, + color: colors.textTertiary, + }, + }); diff --git a/src/screens/Transactions/TransactionsScreen.tsx b/src/screens/Transactions/TransactionsScreen.tsx index c1df2cd..8c9ec86 100644 --- a/src/screens/Transactions/TransactionsScreen.tsx +++ b/src/screens/Transactions/TransactionsScreen.tsx @@ -21,6 +21,7 @@ import { spacing, borderRadius, typography } from '../../theme'; import type { Transaction } from '../../types'; import type { RootStackParamList } from '../../navigation/types'; import { AppIcon } from '../../components/common/AppIcon'; +import { CalendarView } from '../../components/transactions'; type NavigationProp = NativeStackNavigationProp; @@ -35,24 +36,35 @@ export default function TransactionsScreen() { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); + // View Mode State + const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list'); + const [selectedDate, setSelectedDate] = useState(null); + // 刷新数据的回调 const refreshData = useCallback(() => { - loadTransactions(1); - }, []); + loadTransactions(1, false, selectedDate); + }, [selectedDate]); // Add selectedDate dependency useEffect(() => { refreshData(); - }, [refreshData]); + }, [refreshData, viewMode]); // Reload when mode changes (e.g. clearing date filter) // 每次页面聚焦时刷新数据 useFocusEffect(refreshData); - const loadTransactions = async (pageNum: number, append = false) => { + const loadTransactions = async (pageNum: number, append = false, dateFilter: string | null = null) => { try { - const response = await transactionService.getTransactions({ + const params: any = { page: pageNum, pageSize: 20, - }); + }; + + if (dateFilter) { + params.startDate = dateFilter; + params.endDate = dateFilter; + } + + const response = await transactionService.getTransactions(params); if (append) { setTransactions(prev => [...prev, ...response.items]); @@ -70,6 +82,21 @@ export default function TransactionsScreen() { } }; + const toggleViewMode = () => { + if (viewMode === 'list') { + setViewMode('calendar'); + const today = new Date().toISOString().split('T')[0]; + setSelectedDate(today); + } else { + setViewMode('list'); + setSelectedDate(null); + } + }; + + const onDateSelect = (date: string) => { + setSelectedDate(date); + }; + const onRefresh = () => { setRefreshing(true); loadTransactions(1); @@ -139,8 +166,25 @@ export default function TransactionsScreen() { {/* 头部 */} 账单 + + + + {/* 日历视图 */} + {viewMode === 'calendar' && ( + + + + )} + {/* 列表 */} - 暂无交易记录 + + {selectedDate ? '该日期无交易记录' : '暂无交易记录'} + } ListFooterComponent={ @@ -185,6 +231,9 @@ const createStyles = (colors: any, isDark: boolean, insets: any) => alignItems: 'center', }, header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', paddingTop: insets.top + spacing.md, paddingHorizontal: spacing.pagePadding, paddingBottom: spacing.md, @@ -196,6 +245,13 @@ const createStyles = (colors: any, isDark: boolean, insets: any) => ...typography.h3, color: colors.text, }, + viewToggle: { + padding: 4, + }, + calendarContainer: { + padding: spacing.md, + backgroundColor: colors.background, // or surface? + }, listContent: { paddingHorizontal: spacing.pagePadding, paddingTop: spacing.md, diff --git a/src/services/index.ts b/src/services/index.ts index 76ffa10..afd4fe6 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -9,6 +9,10 @@ export { accountService } from './accountService'; export { categoryService } from './categoryService'; export { budgetService, calculateBudgetStatus, getPeriodTypeLabel } from './budgetService'; export { reportService } from './reportService'; +export { recurringTransactionService } from './recurringTransactionService'; +export { ledgerService } from './ledgerService'; +export { userService } from './userService'; +export { piggyBankService } from './piggyBankService'; export type { TransactionSummaryResponse, CategorySummaryItem, diff --git a/src/services/ledgerService.ts b/src/services/ledgerService.ts new file mode 100644 index 0000000..c2cdf53 --- /dev/null +++ b/src/services/ledgerService.ts @@ -0,0 +1,98 @@ +/** + * 账本服务 + */ + +import { api } from './api'; +import type { ApiResponse, Ledger } from '../types'; + +export interface CreateLedgerInput { + name: string; + theme: 'pink' | 'beige' | 'brown'; + coverImage: string; + isDefault: boolean; + sortOrder: number; +} + +export interface UpdateLedgerInput { + name?: string; + theme?: 'pink' | 'beige' | 'brown'; + coverImage?: string; + isDefault?: boolean; + sortOrder?: number; +} + +/** + * 获取所有账本 + */ +export async function getLedgers(): Promise { + const response = await api.get>('/ledgers'); + if (!response.success || !response.data) { + throw new Error(response.error || '获取账本列表失败'); + } + return response.data; +} + +/** + * 获取单个账本详情 + */ +export async function getLedger(id: number): Promise { + const response = await api.get>(`/ledgers/${id}`); + if (!response.success || !response.data) { + throw new Error(response.error || '获取账本详情失败'); + } + return response.data; +} + +/** + * 创建账本 + */ +export async function createLedger(data: CreateLedgerInput): Promise { + const response = await api.post>('/ledgers', { + name: data.name, + theme: data.theme, + cover_image: data.coverImage, + is_default: data.isDefault, + sort_order: data.sortOrder, + }); + if (!response.success || !response.data) { + throw new Error(response.error || '创建账本失败'); + } + return response.data; +} + +/** + * 更新账本 + */ +export async function updateLedger(id: number, data: UpdateLedgerInput): Promise { + const response = await api.put>(`/ledgers/${id}`, { + name: data.name, + theme: data.theme, + cover_image: data.coverImage, + is_default: data.isDefault, + sort_order: data.sortOrder, + }); + if (!response.success || !response.data) { + throw new Error(response.error || '更新账本失败'); + } + return response.data; +} + +/** + * 删除账本 + */ +export async function deleteLedger(id: number): Promise { + const response = await api.delete>(`/ledgers/${id}`); + if (!response.success) { + throw new Error(response.error || '删除账本失败'); + } +} + +export const ledgerService = { + getAll: getLedgers, + getById: getLedger, + create: createLedger, + update: updateLedger, + delete: deleteLedger, +}; + +export default ledgerService; diff --git a/src/services/piggyBankService.ts b/src/services/piggyBankService.ts new file mode 100644 index 0000000..77d5470 --- /dev/null +++ b/src/services/piggyBankService.ts @@ -0,0 +1,144 @@ +/** + * 存钱罐服务 + */ + +import { api } from './api'; +import type { ApiResponse, PiggyBank, PiggyBankType } from '../types'; + +export interface CreatePiggyBankInput { + name: string; + targetAmount: number; + type: PiggyBankType; + targetDate?: string; + linkedAccountId?: number; + autoRule?: string; +} + +export interface UpdatePiggyBankInput { + name?: string; + targetAmount?: number; + type?: PiggyBankType; + targetDate?: string; + linkedAccountId?: number; + autoRule?: string; +} + +/** + * 获取所有存钱罐 + */ +export async function getPiggyBanks(): Promise { + const response = await api.get>('/piggy-banks'); + if (!response.success || !response.data) { + throw new Error(response.error || '获取存钱罐列表失败'); + } + return response.data; +} + +/** + * 获取单个存钱罐详情 + */ +export async function getPiggyBank(id: number): Promise { + const response = await api.get>(`/piggy-banks/${id}`); + if (!response.success || !response.data) { + throw new Error(response.error || '获取存钱罐详情失败'); + } + return response.data; +} + +/** + * 创建存钱罐 + */ +export async function createPiggyBank(data: CreatePiggyBankInput): Promise { + // API expects snake_case normally, but our types are camelCase. + // Assuming the API service layer (api.ts) doesn't auto-convert keys, + // we should send snake_case if the backend expects it. + // However, looking at previous services (ledgerService, etc.), + // I used properties matching the input interface which were camelCase, + // AND I transformed them to snake_case in the request body usually? + // Let's check ledgerService again. + // In ledgerService.ts: + // cover_image: data.coverImage, + // is_default: data.isDefault, + // So I should map to snake_case. + + const response = await api.post>('/piggy-banks', { + name: data.name, + target_amount: data.targetAmount, + type: data.type, + target_date: data.targetDate, + linked_account_id: data.linkedAccountId, + auto_rule: data.autoRule, + }); + + if (!response.success || !response.data) { + throw new Error(response.error || '创建存钱罐失败'); + } + return response.data; +} + +/** + * 更新存钱罐 + */ +export async function updatePiggyBank(id: number, data: UpdatePiggyBankInput): Promise { + const response = await api.put>(`/piggy-banks/${id}`, { + name: data.name, + target_amount: data.targetAmount, + type: data.type, + target_date: data.targetDate, + linked_account_id: data.linkedAccountId, + auto_rule: data.autoRule, + }); + + if (!response.success || !response.data) { + throw new Error(response.error || '更新存钱罐失败'); + } + return response.data; +} + +/** + * 删除存钱罐 + */ +export async function deletePiggyBank(id: number): Promise { + const response = await api.delete>(`/piggy-banks/${id}`); + if (!response.success) { + throw new Error(response.error || '删除存钱罐失败'); + } +} + +/** + * 存入金额 + */ +export async function depositToPiggyBank(id: number, amount: number): Promise { + const response = await api.post>(`/piggy-banks/${id}/deposit`, { + amount, + }); + if (!response.success || !response.data) { + throw new Error(response.error || '存入失败'); + } + return response.data; +} + +/** + * 取出金额 + */ +export async function withdrawFromPiggyBank(id: number, amount: number): Promise { + const response = await api.post>(`/piggy-banks/${id}/withdraw`, { + amount, + }); + if (!response.success || !response.data) { + throw new Error(response.error || '取出失败'); + } + return response.data; +} + +export const piggyBankService = { + getAll: getPiggyBanks, + getById: getPiggyBank, + create: createPiggyBank, + update: updatePiggyBank, + delete: deletePiggyBank, + deposit: depositToPiggyBank, + withdraw: withdrawFromPiggyBank, +}; + +export default piggyBankService; diff --git a/src/services/recurringTransactionService.ts b/src/services/recurringTransactionService.ts new file mode 100644 index 0000000..6c731fb --- /dev/null +++ b/src/services/recurringTransactionService.ts @@ -0,0 +1,126 @@ +/** + * 周期性交易服务 + */ + +import { api } from './api'; +import type { ApiResponse, PaginatedResponse, RecurringTransaction, FrequencyType } from '../types'; + +// 创建/更新周期性交易参数 +export interface RecurringTransactionInput { + amount: number; + type: 'income' | 'expense' | 'transfer'; + categoryId: number; + accountId: number; + currency: string; + note?: string; + frequency: FrequencyType; + startDate: string; + endDate?: string; + isActive?: boolean; + toAccountId?: number; // 转账目标账户 +} + +/** + * 获取周期性交易列表 + */ +export async function getRecurringTransactions( + params: { page?: number; pageSize?: number } = {} +): Promise> { + const response = await api.get>>('/recurring-transactions', { + page: params.page || 1, + page_size: params.pageSize || 20, + }); + + if (!response.success || !response.data) { + throw new Error(response.error || '获取周期性交易列表失败'); + } + + return response.data; +} + +/** + * 获取单个周期性交易详情 + */ +export async function getRecurringTransaction(id: number): Promise { + const response = await api.get>(`/recurring-transactions/${id}`); + + if (!response.success || !response.data) { + throw new Error(response.error || '获取周期性交易详情失败'); + } + + return response.data; +} + +/** + * 创建周期性交易 + */ +export async function createRecurringTransaction(data: RecurringTransactionInput): Promise { + const response = await api.post>('/recurring-transactions', { + amount: data.amount, + type: data.type, + category_id: data.categoryId, + account_id: data.accountId, + currency: data.currency, + note: data.note, + frequency: data.frequency, + start_date: data.startDate, + end_date: data.endDate, + is_active: data.isActive, + to_account_id: data.toAccountId, + }); + + if (!response.success || !response.data) { + throw new Error(response.error || '创建周期性交易失败'); + } + + return response.data; +} + +/** + * 更新周期性交易 + */ +export async function updateRecurringTransaction( + id: number, + data: Partial +): Promise { + const response = await api.put>(`/recurring-transactions/${id}`, { + amount: data.amount, + type: data.type, + category_id: data.categoryId, + account_id: data.accountId, + currency: data.currency, + note: data.note, + frequency: data.frequency, + start_date: data.startDate, + end_date: data.endDate, + is_active: data.isActive, + to_account_id: data.toAccountId, + }); + + if (!response.success || !response.data) { + throw new Error(response.error || '更新周期性交易失败'); + } + + return response.data; +} + +/** + * 删除周期性交易 + */ +export async function deleteRecurringTransaction(id: number): Promise { + const response = await api.delete>(`/recurring-transactions/${id}`); + + if (!response.success) { + throw new Error(response.error || '删除周期性交易失败'); + } +} + +export const recurringTransactionService = { + getAll: getRecurringTransactions, + getById: getRecurringTransaction, + create: createRecurringTransaction, + update: updateRecurringTransaction, + delete: deleteRecurringTransaction, +}; + +export default recurringTransactionService; diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 0000000..228ab60 --- /dev/null +++ b/src/services/userService.ts @@ -0,0 +1,43 @@ +/** + * 用户服务 + */ + +import { api } from './api'; +import type { ApiResponse, UserSettings } from '../types'; + +/** + * 获取用户设置 + */ +export async function getSettings(): Promise { + const response = await api.get>('/user/settings'); + if (!response.success || !response.data) { + throw new Error(response.error || '获取设置失败'); + } + return response.data; +} + +/** + * 更新用户设置 + */ +export async function updateSettings(data: Partial): Promise { + const response = await api.put>('/user/settings', data); + if (!response.success || !response.data) { + throw new Error(response.error || '更新设置失败'); + } + return response.data; +} + +/** + * 切换当前账本 + */ +export async function switchLedger(ledgerId: number): Promise { + await updateSettings({ currentLedgerId: ledgerId }); +} + +export const userService = { + getSettings, + updateSettings, + switchLedger, +}; + +export default userService; diff --git a/task.md b/task.md index b1d6bbe..de96cd8 100644 --- a/task.md +++ b/task.md @@ -113,27 +113,27 @@ > > 目标: 补齐 Web 端定义的 P1 核心记账能力 -- [ ] 10.1 周期性交易模块 (Recurring Transactions) - - [ ] 10.1.1 周期交易服务 (recurringTransactionService) - - [ ] 10.1.2 周期交易列表页 - - [ ] 10.1.3 创建/编辑周期交易 +- [x] 10.1 周期性交易模块 (Recurring Transactions) + - [x] 10.1.1 周期交易服务 (recurringTransactionService) + - [x] 10.1.2 周期交易列表页 + - [x] 10.1.3 创建/编辑周期交易 - [ ] 10.2 多账本系统 (Ledger System) - - [ ] 10.2.1 账本服务 (ledgerService) - - [ ] 10.2.2 账本管理页 (新建/切换/编辑) + - [x] 10.2.1 账本服务 (ledgerService) + - [x] 10.2.2 账本管理页 (新建/切换/编辑) ## 🐷 Phase 11: 财务目标 (Savings & Goals) > > 目标: 增强预算模块,增加存钱目标 -- [ ] 11.1 存钱罐服务 (piggyBankService) -- [ ] 11.2 存钱罐列表组件 (BudgetScreen 集成) -- [ ] 11.3 存入/取出操作逻辑 +- [x] 11.1 存钱罐服务 (piggyBankService) +- [x] 11.2 存钱罐列表组件 (BudgetScreen 集成) +- [x] 11.3 存入/取出操作逻辑 ## 🛠️ Phase 12: 工具与生态 > > 目标: 完善 P2 功能,对齐 Web 端体验 -- [ ] 12.1 交易日历视图 (Calendar View) +- [x] 12.1 交易日历视图 (Calendar View) - [ ] 12.2 数据导出功能 (Export to CSV/Excel) - [ ] 12.3 汇率换算工具 (Exchange Rate) - [ ] 12.4 消息通知中心 (Notifications)