Files
Novault-Frontend-web/src/pages/Home/Home.tsx

603 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Account[]>([]);
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [settings, setSettings] = useState<UserSettings | null>(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<StreakInfoFormatted | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="home-page">
<header className="home-header">
<Skeleton width={120} height={32} />
</header>
<main className="home-content">
<section className="quick-actions">
<Skeleton width="100%" height={60} variant="rectangular" />
</section>
<section className="account-overview">
<div className="section-header">
<Skeleton width={100} height={24} />
<Skeleton width={60} height={24} />
</div>
<div className="balance-summary">
<Skeleton width="100%" height={120} variant="card" style={{ marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '1rem' }}>
<Skeleton width="50%" height={80} variant="card" />
<Skeleton width="50%" height={80} variant="card" />
</div>
</div>
</section>
</main>
</div>
);
}
if (error) {
return (
<div className="home-page">
<div className="error-state">
<Icon icon="solar:danger-circle-bold" width="48" color="#ef4444" />
<p>{error}</p>
<button className="retry-btn" onClick={loadData}></button>
</div>
</div>
);
}
return (
<div className="home-page">
<header className="home-header">
<div className="home-greeting animate-slide-up">
<div className="greeting-top-row">
<div className="greeting-pill" onClick={() => setLedgerSelectorOpen(true)}>
{currentLedger && (
<>
<Icon icon="solar:notebook-minimalistic-bold-duotone" width="14" />
<span>{currentLedger.name}</span>
<Icon icon="solar:alt-arrow-down-bold" width="10" className="chevron-icon" />
</>
)}
</div>
<span className="home-date">{new Date().toLocaleDateString('zh-CN', { weekday: 'short', month: 'long', day: 'numeric' })}</span>
</div>
<h1 className="greeting-text">
{greeting}<span className="greeting-highlight"></span>
</h1>
<div className="greeting-insight animate-slide-up delay-100">
<Icon icon="solar:bell-bing-bold-duotone" width="16" className="insight-icon" />
<span>{streakInfo?.message || insight}</span>
<div className="insight-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
{streakInfo && streakInfo.currentStreak > 0 && (
<div
className="streak-badge"
onClick={() => setShowContributionModal(true)}
title={`最长连续: ${streakInfo.longestStreak}\n累计记账: ${streakInfo.totalRecordDays}\n点击查看详情`}
>
<Icon icon="solar:heart-bold" width="16" className="streak-icon" />
<span className="streak-count">{streakInfo.currentStreak}</span>
</div>
)}
</div>
</div>
</div>
<div className="header-actions animate-slide-up delay-200">
<button className="health-score-btn" onClick={() => setShowHealthModal(true)}>
<div className="health-ring" style={{ '--score': `${healthScore}%`, '--color': 'var(--accent-success)' } as any}>
<svg viewBox="0 0 36 36">
<path className="ring-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path className="ring-fill" strokeDasharray={`${healthScore}, 100`} d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<span className="health-val">{healthScore}</span>
</div>
<span className="health-label"></span>
</button>
<button className="quick-action-btn-small" onClick={handleQuickTransaction}>
<Icon icon="solar:add-circle-bold-duotone" width="20" />
<span></span>
</button>
</div>
</header>
<main className="home-content">
{/* Asset Dashboard - Requirement 8.1 */}
<section className="dashboard-grid">
{/* Net Worth Card - Main Hero */}
<div className="dashboard-card home-net-worth-card">
<div className="card-content">
<span className="card-label"></span>
<div className="card-value-group" title={`¥${netWorth.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`}>
<span className="currency-symbol">¥</span>
<span className="card-value-main privacy-mask">
{formatLargeNumber(netWorth).value}
</span>
{formatLargeNumber(netWorth).suffix && (
<span className="card-value-suffix">{formatLargeNumber(netWorth).suffix}</span>
)}
</div>
<div className="card-footer">
<span className="trend-neutral"></span>
</div>
</div>
<div className="card-bg-decoration"></div>
</div>
{/* Assets Card */}
<div className="dashboard-card assets-card" onClick={handleViewAllAccounts}>
<div className="card-icon-wrapper income">
<Icon icon="solar:graph-up-bold-duotone" width="20" />
</div>
<div className="card-content">
<span className="card-label"></span>
<span className="card-value-sub privacy-mask">{formatCurrency(totalAssets)}</span>
</div>
</div>
{/* Liabilities Card */}
<div className="dashboard-card liabilities-card" onClick={handleViewAllAccounts}>
<div className="card-icon-wrapper expense">
<Icon icon="solar:graph-down-bold-duotone" width="20" />
</div>
<div className="card-content">
<span className="card-label"></span>
<span className="card-value-sub privacy-mask">{formatCurrency(totalLiabilities)}</span>
</div>
</div>
</section>
{/* Quick Actions Section */}
<section className="quick-actions-section">
<button className="action-card primary" onClick={handleQuickTransaction}>
<div className="action-icon blur-bg">
<Icon icon="solar:add-circle-bold-duotone" width="24" />
</div>
<div className="action-info">
<span className="action-title"></span>
<span className="action-desc"></span>
</div>
</button>
<button className="action-card ai" onClick={handleAIBookkeeping}>
<div className="action-icon blur-bg">
<Icon icon="solar:microphone-3-bold-duotone" width="24" />
</div>
<div className="action-info">
<span className="action-title">AI </span>
<span className="action-desc"></span>
</div>
</button>
<button className="action-card secondary" onClick={handleViewAllAccounts}>
<div className="action-icon blur-bg">
<Icon icon="solar:wallet-bold-duotone" width="24" />
</div>
<div className="action-info">
<span className="action-title"></span>
<span className="action-desc"> {accounts.length} </span>
</div>
</button>
</section>
<div className="content-columns">
{/* Spending Trend Chart */}
<div className="chart-container privacy-mask">
<SpendingTrendChart transactions={recentTransactions} />
</div>
{/* Recent Transactions List */}
<section className="recent-transactions-section">
<div className="section-header">
<h2></h2>
<button className="view-all-link" onClick={handleViewAllTransactions}>
</button>
</div>
{recentTransactions.length > 0 ? (
<div className="transaction-list-compact">
{recentTransactions.map((transaction) => (
<div key={transaction.id} className="transaction-row" onClick={() => navigate('/transactions')}>
<div className={`transaction-icon ${transaction.type}`}>
{transaction.type === 'income' ? <Icon icon="solar:graph-up-bold-duotone" width="16" /> : <Icon icon="solar:graph-down-bold-duotone" width="16" />}
</div>
<div className="transaction-details">
<span className="transaction-category">
{categories.find(c => c.id === transaction.categoryId)?.name || '无分类'}
</span>
<span className="transaction-note-compact">{transaction.note || '无备注'}</span>
</div>
<div className="transaction-meta">
<span className={`transaction-amount-compact ${transaction.type} privacy-mask`}>
{transaction.type === 'income' ? '+' : '-'}{formatCurrency(Math.abs(transaction.amount))}
</span>
<span className="transaction-time">{formatDate(transaction.transactionDate)}</span>
</div>
</div>
))}
</div>
) : (
<div className="empty-state-compact">
<Icon icon="solar:document-text-bold-duotone" width="32" />
<p></p>
</div>
)}
</section>
</div>
</main>
{/* Ledger Selector Modal - Requirements 3.2, 3.3 */}
{ledgers.length > 0 && (
<LedgerSelector
ledgers={ledgers}
currentLedgerId={currentLedger?.id || 0}
onSelect={handleLedgerSelect}
onAdd={handleAddLedger}
onManage={handleManageLedgers}
onReorder={handleLedgerReorder}
open={ledgerSelectorOpen}
onClose={() => setLedgerSelectorOpen(false)}
/>
)}
{/* AI Voice Input Modal */}
<VoiceInputModal
isOpen={voiceModalOpen}
onClose={() => setVoiceModalOpen(false)}
onConfirm={handleAIConfirm}
/>
{/* First Account Guide Modal */}
<CreateFirstAccountModal
isOpen={!loading && !error && accounts.length === 0 && !showAccountForm}
onCreate={() => setShowAccountForm(true)}
/>
{/* Account Creation Modal */}
{showAccountForm && (
<div className="modal-overlay">
<div className="modal-content">
<AccountForm
onSubmit={async (data) => {
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}
/>
</div>
</div>
)}
<Confetti active={showConfetti} recycle={false} onComplete={() => setShowConfetti(false)} />
<HealthScoreModal
isOpen={showHealthModal}
onClose={() => setShowHealthModal(false)}
score={healthScore}
totalAssets={totalAssets}
totalLiabilities={totalLiabilities}
todaySpend={todaySpend}
yesterdaySpend={yesterdaySpend}
/>
{/* Contribution Heatmap Modal */}
<ContributionModal
isOpen={showContributionModal}
onClose={() => setShowContributionModal(false)}
streakInfo={streakInfo}
/>
</div>
);
}
export default Home;