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

603 lines
23 KiB
TypeScript
Raw Normal View History

2026-01-25 20:12:33 +08:00
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';
2026-01-25 20:12:33 +08:00
2026-01-25 20:12:33 +08:00
/**
* Home Page Component
* Displays account balance overview and recent transactions
* Provides quick access to create new transactions
*
2026-01-25 20:12:33 +08:00
* Requirements:
* - 8.1: Quick transaction entry (3 steps or less)
* - 8.2: Fast loading (< 2 seconds)
*/
function Home() {
2026-01-25 20:12:33 +08:00
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);
2026-01-25 20:12:33 +08:00
2026-01-25 20:12:33 +08:00
useEffect(() => {
loadData();
2026-01-25 20:12:33 +08:00
}, []);
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);
2026-01-25 20:12:33 +08:00
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败');
console.error('Failed to load home page data:', err);
2026-01-25 20:12:33 +08:00
} finally {
setLoading(false);
2026-01-25 20:12:33 +08:00
}
};
const handleQuickTransaction = () => {
navigate('/transactions?action=new');
2026-01-25 20:12:33 +08:00
};
const handleAIBookkeeping = () => {
setVoiceModalOpen(true);
2026-01-25 20:12:33 +08:00
};
const handleAIConfirm = () => {
// 确认后刷新数据
loadData();
setVoiceModalOpen(false);
2026-01-25 20:12:33 +08:00
};
const handleViewAllAccounts = () => {
navigate('/accounts');
2026-01-25 20:12:33 +08:00
};
const handleViewAllTransactions = () => {
navigate('/transactions');
2026-01-25 20:12:33 +08:00
};
const handleLedgerSelect = async (ledgerId: number) => {
try {
// Update settings with new current ledger
if (settings) {
await updateSettings({ ...settings, currentLedgerId: ledgerId });
setSettings({ ...settings, currentLedgerId: ledgerId });
2026-01-25 20:12:33 +08:00
}
setLedgerSelectorOpen(false);
// Reload data to show transactions from selected ledger
await loadData();
2026-01-25 20:12:33 +08:00
} catch (err) {
console.error('Failed to switch ledger:', err);
setError('切换账本失败');
2026-01-25 20:12:33 +08:00
}
};
const handleLedgerReorder = async (reorderedLedgers: Ledger[]) => {
try {
setLedgers(reorderedLedgers);
2026-01-25 20:12:33 +08:00
const ledgerIds = reorderedLedgers.map(l => l.id);
await reorderLedgers(ledgerIds);
2026-01-25 20:12:33 +08:00
} catch (err) {
console.error('Failed to reorder ledgers:', err);
// Revert on error
await loadData();
2026-01-25 20:12:33 +08:00
}
};
const handleAddLedger = () => {
setLedgerSelectorOpen(false);
navigate('/ledgers/new');
2026-01-25 20:12:33 +08:00
};
const handleManageLedgers = () => {
setLedgerSelectorOpen(false);
navigate('/ledgers');
2026-01-25 20:12:33 +08:00
};
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;
2026-01-25 20:12:33 +08:00
// 标准金额格式化(带完整小数)
2026-01-25 20:12:33 +08:00
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: amount % 1 === 0 ? 0 : 2, // 整数不显示小数
2026-01-25 20:12:33 +08:00
}).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: ''
};
}
};
2026-01-25 20:12:33 +08:00
// Lock body scroll when modal is open
useEffect(() => {
if (showAccountForm) {
document.body.style.overflow = 'hidden';
2026-01-25 20:12:33 +08:00
} else {
document.body.style.overflow = '';
2026-01-25 20:12:33 +08:00
}
return () => {
document.body.style.overflow = '';
2026-01-25 20:12:33 +08:00
};
}, [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);
2026-01-25 20:12:33 +08:00
if (date.toDateString() === today.toDateString()) {
2026-01-25 20:12:33 +08:00
return '今天';
} else if (date.toDateString() === yesterday.toDateString()) {
return '昨天';
} else {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
2026-01-25 20:12:33 +08:00
}
};
// Phase 3: Daily Briefing Logic
const getDailyBriefing = () => {
2026-01-25 20:12:33 +08:00
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 };
2026-01-25 20:12:33 +08:00
};
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) {
2026-01-25 20:12:33 +08:00
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>
);
2026-01-25 20:12:33 +08:00
}
if (error) {
2026-01-25 20:12:33 +08:00
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>
)}
2026-01-25 20:12:33 +08:00
</div>
<div className="card-footer">
<span className="trend-neutral"></span>
2026-01-25 20:12:33 +08:00
</div>
</div>
<div className="card-bg-decoration"></div>
</div>
2026-01-25 20:12:33 +08:00
{/* 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>
2026-01-25 20:12:33 +08:00
{/* 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>
2026-01-25 20:12:33 +08:00
{/* 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>
2026-01-25 20:12:33 +08:00
</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}
2026-01-25 20:12:33 +08:00
/>
</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>
);
2026-01-25 20:12:33 +08:00
}
export default Home;