import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import './Home.css'; import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '../../services/accountService'; import { getTransactions, calculateTotalExpense } from '../../services/transactionService'; import { getCategories } from '../../services/categoryService'; import { getLedgers, reorderLedgers } from '../../services/ledgerService'; import { getStreakInfo, type StreakInfoFormatted } from '../../services/streakService'; import { getSettings, updateSettings } from '../../services/settingsService'; import { Icon } from '@iconify/react'; import { SpendingTrendChart } from '../../components/charts/SpendingTrendChart'; import { Skeleton } from '../../components/common/Skeleton/Skeleton'; import { LedgerSelector } from '../../components/ledger/LedgerSelector/LedgerSelector'; import VoiceInputModal from '../../components/ai/VoiceInputModal/VoiceInputModal'; import { CreateFirstAccountModal } from '../../components/account/CreateFirstAccountModal/CreateFirstAccountModal'; import { AccountForm } from '../../components/account/AccountForm/AccountForm'; import { createAccount } from '../../services/accountService'; import { Confetti } from '../../components/common/Confetti'; import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal'; import { ContributionModal } from '../../components/common/ContributionGraph/ContributionModal'; // Import Component import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types'; /** * Home Page Component * Displays account balance overview and recent transactions * Provides quick access to create new transactions * * Requirements: * - 8.1: Quick transaction entry (3 steps or less) * - 8.2: Fast loading (< 2 seconds) */ function Home() { const navigate = useNavigate(); const [accounts, setAccounts] = useState([]); const [recentTransactions, setRecentTransactions] = useState([]); const [categories, setCategories] = useState([]); const [ledgers, setLedgers] = useState([]); const [settings, setSettings] = useState(null); const [ledgerSelectorOpen, setLedgerSelectorOpen] = useState(false); const [voiceModalOpen, setVoiceModalOpen] = useState(false); const [showAccountForm, setShowAccountForm] = useState(false); const [showConfetti, setShowConfetti] = useState(false); const [showHealthModal, setShowHealthModal] = useState(false); const [showContributionModal, setShowContributionModal] = useState(false); // Add State const [todaySpend, setTodaySpend] = useState(0); const [yesterdaySpend, setYesterdaySpend] = useState(0); const [streakInfo, setStreakInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { loadData(); }, []); const loadData = async () => { try { setLoading(true); setError(null); // Calculate dates for today and yesterday const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const todayStr = today.toISOString().split('T')[0]; const yesterdayStr = yesterday.toISOString().split('T')[0]; // Load accounts, recent transactions, today/yesterday stats const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData, streakData] = await Promise.all([ getAccounts(), getTransactions({ page: 1, pageSize: 5 }), // Recent transactions getCategories(), getLedgers().catch(() => []), getSettings().catch(() => null), getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }), getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }), getStreakInfo().catch(() => null), ]); setAccounts(accountsData || []); setRecentTransactions(transactionsData?.items || []); setCategories(categoriesData || []); setLedgers(ledgersData || []); setSettings(settingsData); // Calculate daily spends setTodaySpend(calculateTotalExpense(todayData.items)); setYesterdaySpend(calculateTotalExpense(yesterdayData.items)); // Set streak info setStreakInfo(streakData); } catch (err) { setError(err instanceof Error ? err.message : '加载数据失败'); console.error('Failed to load home page data:', err); } finally { setLoading(false); } }; const handleQuickTransaction = () => { navigate('/transactions?action=new'); }; const handleAIBookkeeping = () => { setVoiceModalOpen(true); }; const handleAIConfirm = () => { // 确认后刷新数据 loadData(); setVoiceModalOpen(false); }; const handleViewAllAccounts = () => { navigate('/accounts'); }; const handleViewAllTransactions = () => { navigate('/transactions'); }; const handleLedgerSelect = async (ledgerId: number) => { try { // Update settings with new current ledger if (settings) { await updateSettings({ ...settings, currentLedgerId: ledgerId }); setSettings({ ...settings, currentLedgerId: ledgerId }); } setLedgerSelectorOpen(false); // Reload data to show transactions from selected ledger await loadData(); } catch (err) { console.error('Failed to switch ledger:', err); setError('切换账本失败'); } }; const handleLedgerReorder = async (reorderedLedgers: Ledger[]) => { try { setLedgers(reorderedLedgers); const ledgerIds = reorderedLedgers.map(l => l.id); await reorderLedgers(ledgerIds); } catch (err) { console.error('Failed to reorder ledgers:', err); // Revert on error await loadData(); } }; const handleAddLedger = () => { setLedgerSelectorOpen(false); navigate('/ledgers/new'); }; const handleManageLedgers = () => { setLedgerSelectorOpen(false); navigate('/ledgers'); }; const currentLedger = ledgers.find(l => l.id === settings?.currentLedgerId) || ledgers.find(l => l.isDefault) || ledgers[0]; const totalAssets = calculateTotalAssets(accounts); const totalLiabilities = calculateTotalLiabilities(accounts); const netWorth = totalAssets - totalLiabilities; // 标准金额格式化(带完整小数) const formatCurrency = (amount: number): string => { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', minimumFractionDigits: 0, maximumFractionDigits: amount % 1 === 0 ? 0 : 2, // 整数不显示小数 }).format(amount); }; // 大数字简洁显示(用于净资产等主要展示) const formatLargeNumber = (amount: number): { value: string; suffix: string } => { const absAmount = Math.abs(amount); const sign = amount < 0 ? '-' : ''; if (absAmount >= 100000000) { // 亿 const value = absAmount / 100000000; return { value: sign + value.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), suffix: '亿' }; } else if (absAmount >= 10000) { // 万 const value = absAmount / 10000; return { value: sign + value.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), suffix: '万' }; } else { // 直接显示 return { value: sign + absAmount.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: absAmount % 1 === 0 ? 0 : 2 }), suffix: '' }; } }; // Lock body scroll when modal is open useEffect(() => { if (showAccountForm) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [showAccountForm]); const formatDate = (dateString: string): string => { const date = new Date(dateString); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (date.toDateString() === today.toDateString()) { return '今天'; } else if (date.toDateString() === yesterday.toDateString()) { return '昨天'; } else { return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }); } }; // Phase 3: Daily Briefing Logic const getDailyBriefing = () => { const hour = new Date().getHours(); let greeting = '你好'; if (hour < 5) greeting = '夜深了'; else if (hour < 11) greeting = '早上好'; else if (hour < 13) greeting = '中午好'; else if (hour < 18) greeting = '下午好'; else greeting = '晚上好'; let insight = '今天还没有记账哦'; if (todaySpend > 0) { if (yesterdaySpend > 0) { const diff = todaySpend - yesterdaySpend; const diffPercent = Math.abs((diff / yesterdaySpend) * 100); if (Math.abs(diff) < 5) { insight = `今日支出 ${formatCurrency(todaySpend)},与昨天持平`; } else if (diff < 0) { insight = `今日支出 ${formatCurrency(todaySpend)},比昨天节省了 ${diffPercent.toFixed(0)}%`; } else { insight = `今日支出 ${formatCurrency(todaySpend)},比昨天多 ${diffPercent.toFixed(0)}%`; } } else { insight = `今天已支出 ${formatCurrency(todaySpend)}`; } } else { if (yesterdaySpend > 0) { insight = '新的一天,保持理智消费'; } } return { greeting, insight }; }; const { greeting, insight } = getDailyBriefing(); // Phase 3: Financial Health Score (Enhanced Logic) const calculateHealthScore = () => { // 1. Solvency Score (70% weight): Net Worth / Total Assets // If no assets, assume baseline 60 if no debt, else lower if (totalAssets === 0) return totalLiabilities > 0 ? 40 : 60; const solvencyRatio = (totalAssets - totalLiabilities) / totalAssets; // 1.0 = perfect, 0.0 = bankrupt // 2. Streak Bonus (10% weight): Encourages habit const streakBonus = (streakInfo?.currentStreak || 0) > 3 ? 5 : 0; // 3. Activity Bonus (20% weight): Scored if todaySpend > 0 or yesterdaySpend > 0 (active user) const activityBonus = (todaySpend > 0 || yesterdaySpend > 0) ? 5 : 0; // Weights: Base (might be high) -> normalized // Strategy: Map 0-1 solvency to 40-90 range, then add bonuses // Solvency 1.0 -> 90 // Solvency 0.5 -> 65 // Solvency 0.0 -> 40 let finalScore = 40 + (solvencyRatio * 50); finalScore += streakBonus + activityBonus; return Math.min(Math.max(Math.round(finalScore), 40), 99); }; const healthScore = calculateHealthScore(); if (loading) { return (
); } if (error) { return (

{error}

); } return (
setLedgerSelectorOpen(true)}> {currentLedger && ( <> {currentLedger.name} )}
{new Date().toLocaleDateString('zh-CN', { weekday: 'short', month: 'long', day: 'numeric' })}

{greeting},保持节奏

{streakInfo?.message || insight}
{streakInfo && streakInfo.currentStreak > 0 && (
setShowContributionModal(true)} title={`最长连续: ${streakInfo.longestStreak} 天\n累计记账: ${streakInfo.totalRecordDays} 天\n点击查看详情`} > {streakInfo.currentStreak}
)}
{/* Asset Dashboard - Requirement 8.1 */}
{/* Net Worth Card - Main Hero */}
净资产
¥ {formatLargeNumber(netWorth).value} {formatLargeNumber(netWorth).suffix && ( {formatLargeNumber(netWorth).suffix} )}
总览所有账户
{/* Assets Card */}
总资产 {formatCurrency(totalAssets)}
{/* Liabilities Card */}
总负债 {formatCurrency(totalLiabilities)}
{/* Quick Actions Section */}
{/* Spending Trend Chart */}
{/* Recent Transactions List */}

最近交易

{recentTransactions.length > 0 ? (
{recentTransactions.map((transaction) => (
navigate('/transactions')}>
{transaction.type === 'income' ? : }
{categories.find(c => c.id === transaction.categoryId)?.name || '无分类'} {transaction.note || '无备注'}
{transaction.type === 'income' ? '+' : '-'}{formatCurrency(Math.abs(transaction.amount))} {formatDate(transaction.transactionDate)}
))}
) : (

暂无交易记录

)}
{/* Ledger Selector Modal - Requirements 3.2, 3.3 */} {ledgers.length > 0 && ( setLedgerSelectorOpen(false)} /> )} {/* AI Voice Input Modal */} setVoiceModalOpen(false)} onConfirm={handleAIConfirm} /> {/* First Account Guide Modal */} setShowAccountForm(true)} /> {/* Account Creation Modal */} {showAccountForm && (
{ try { setLoading(true); await createAccount(data); setShowAccountForm(false); await loadData(); } catch (err) { setError('创建账户失败,请重试'); console.error(err); setLoading(false); } }} onCancel={() => { // Should not allow cancel if it's the first account? // Let's allow it but the Guide Modal will pop up again immediately because accounts.length is still 0 setShowAccountForm(false); }} loading={loading} />
)} setShowConfetti(false)} /> setShowHealthModal(false)} score={healthScore} totalAssets={totalAssets} totalLiabilities={totalLiabilities} todaySpend={todaySpend} yesterdaySpend={yesterdaySpend} /> {/* Contribution Heatmap Modal */} setShowContributionModal(false)} streakInfo={streakInfo} />
); } export default Home;