feat: 新增核心页面和通用 UI 组件。
This commit is contained in:
@@ -1,47 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
|
||||
import './Home.css';
|
||||
// ... rest of imports
|
||||
|
||||
// ... inside component
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
// ... state declarations
|
||||
|
||||
// Scroll Parallax Effect for Header
|
||||
const { scrollY } = useScroll();
|
||||
const headerOpacity = useTransform(scrollY, [0, 100], [1, 0.4]);
|
||||
const headerY = useTransform(scrollY, [0, 100], [0, 20]);
|
||||
const headerScale = useTransform(scrollY, [0, 100], [1, 0.95]);
|
||||
|
||||
// ... rest of logic
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<motion.header
|
||||
className="home-header"
|
||||
style={{ opacity: headerOpacity, y: headerY, scale: headerScale }}
|
||||
>
|
||||
<div className="home-greeting animate-slide-up">
|
||||
{/* ... */}
|
||||
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 {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 {LikeButton} from '../../components/common/MicroInteraction/LikeButton';
|
||||
import {HealthScoreModal} from '../../components/home/HealthScoreModal/HealthScoreModal';
|
||||
import type {Account, Transaction, Category, Ledger, UserSettings} from '../../types';
|
||||
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 { 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 { LikeButton } from '../../components/common/MicroInteraction/LikeButton';
|
||||
import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal';
|
||||
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
|
||||
|
||||
|
||||
/**
|
||||
@@ -52,498 +29,497 @@ function Home() {
|
||||
* Requirements:
|
||||
* - 8.1: Quick transaction entry (3 steps or less)
|
||||
* - 8.2: Fast loading (< 2 seconds)
|
||||
*/
|
||||
function Home() {
|
||||
*/
|
||||
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 [todaySpend, setTodaySpend] = useState(0);
|
||||
const [yesterdaySpend, setYesterdaySpend] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 [todaySpend, setTodaySpend] = useState(0);
|
||||
const [yesterdaySpend, setYesterdaySpend] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Calculate dates for today and yesterday
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
// 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];
|
||||
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] = await Promise.all([
|
||||
getAccounts(),
|
||||
getTransactions({page: 1, pageSize: 5 }), // Recent transactions
|
||||
getCategories(),
|
||||
// Load accounts, recent transactions, today/yesterday stats
|
||||
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData] = 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 }),
|
||||
]);
|
||||
getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }),
|
||||
getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }),
|
||||
]);
|
||||
|
||||
setAccounts(accountsData || []);
|
||||
setRecentTransactions(transactionsData?.items || []);
|
||||
setCategories(categoriesData || []);
|
||||
setLedgers(ledgersData || []);
|
||||
setSettings(settingsData);
|
||||
setAccounts(accountsData || []);
|
||||
setRecentTransactions(transactionsData?.items || []);
|
||||
setCategories(categoriesData || []);
|
||||
setLedgers(ledgersData || []);
|
||||
setSettings(settingsData);
|
||||
|
||||
// Calculate daily spends
|
||||
setTodaySpend(calculateTotalExpense(todayData.items));
|
||||
setYesterdaySpend(calculateTotalExpense(yesterdayData.items));
|
||||
// Calculate daily spends
|
||||
setTodaySpend(calculateTotalExpense(todayData.items));
|
||||
setYesterdaySpend(calculateTotalExpense(yesterdayData.items));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载数据失败');
|
||||
console.error('Failed to load home page data:', err);
|
||||
setError(err instanceof Error ? err.message : '加载数据失败');
|
||||
console.error('Failed to load home page data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickTransaction = () => {
|
||||
navigate('/transactions?action=new');
|
||||
navigate('/transactions?action=new');
|
||||
};
|
||||
|
||||
const handleAIBookkeeping = () => {
|
||||
setVoiceModalOpen(true);
|
||||
setVoiceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAIConfirm = () => {
|
||||
// 确认后刷新数据
|
||||
loadData();
|
||||
setVoiceModalOpen(false);
|
||||
// 确认后刷新数据
|
||||
loadData();
|
||||
setVoiceModalOpen(false);
|
||||
};
|
||||
|
||||
const handleViewAllAccounts = () => {
|
||||
navigate('/accounts');
|
||||
navigate('/accounts');
|
||||
};
|
||||
|
||||
const handleViewAllTransactions = () => {
|
||||
navigate('/transactions');
|
||||
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 });
|
||||
await updateSettings({ ...settings, currentLedgerId: ledgerId });
|
||||
setSettings({ ...settings, currentLedgerId: ledgerId });
|
||||
}
|
||||
setLedgerSelectorOpen(false);
|
||||
// Reload data to show transactions from selected ledger
|
||||
await loadData();
|
||||
setLedgerSelectorOpen(false);
|
||||
// Reload data to show transactions from selected ledger
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to switch ledger:', err);
|
||||
setError('切换账本失败');
|
||||
console.error('Failed to switch ledger:', err);
|
||||
setError('切换账本失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLedgerReorder = async (reorderedLedgers: Ledger[]) => {
|
||||
try {
|
||||
setLedgers(reorderedLedgers);
|
||||
setLedgers(reorderedLedgers);
|
||||
const ledgerIds = reorderedLedgers.map(l => l.id);
|
||||
await reorderLedgers(ledgerIds);
|
||||
await reorderLedgers(ledgerIds);
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder ledgers:', err);
|
||||
// Revert on error
|
||||
await loadData();
|
||||
console.error('Failed to reorder ledgers:', err);
|
||||
// Revert on error
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLedger = () => {
|
||||
setLedgerSelectorOpen(false);
|
||||
navigate('/ledgers/new');
|
||||
setLedgerSelectorOpen(false);
|
||||
navigate('/ledgers/new');
|
||||
};
|
||||
|
||||
const handleManageLedgers = () => {
|
||||
setLedgerSelectorOpen(false);
|
||||
navigate('/ledgers');
|
||||
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 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',
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Lock body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (showAccountForm) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
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);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
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' });
|
||||
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 greeting = '你好';
|
||||
if (hour < 5) greeting = '夜深了';
|
||||
else if (hour < 11) greeting = '早上好';
|
||||
else if (hour < 13) greeting = '中午好';
|
||||
else if (hour < 18) greeting = '下午好';
|
||||
else greeting = '晚上好';
|
||||
|
||||
let insight = '今天还没有记账哦';
|
||||
let insight = '今天还没有记账哦';
|
||||
if (todaySpend > 0) {
|
||||
if (yesterdaySpend > 0) {
|
||||
const diff = todaySpend - yesterdaySpend;
|
||||
const diffPercent = Math.abs((diff / yesterdaySpend) * 100);
|
||||
const diffPercent = Math.abs((diff / yesterdaySpend) * 100);
|
||||
|
||||
if (Math.abs(diff) < 5) {
|
||||
insight = `今日支出 ${formatCurrency(todaySpend)},与昨天持平`;
|
||||
if (Math.abs(diff) < 5) {
|
||||
insight = `今日支出 ${formatCurrency(todaySpend)},与昨天持平`;
|
||||
} else if (diff < 0) {
|
||||
insight = `今日支出 ${formatCurrency(todaySpend)},比昨天节省了 ${diffPercent.toFixed(0)}%`;
|
||||
insight = `今日支出 ${formatCurrency(todaySpend)},比昨天节省了 ${diffPercent.toFixed(0)}%`;
|
||||
} else {
|
||||
insight = `今日支出 ${formatCurrency(todaySpend)},比昨天多 ${diffPercent.toFixed(0)}%`;
|
||||
insight = `今日支出 ${formatCurrency(todaySpend)},比昨天多 ${diffPercent.toFixed(0)}%`;
|
||||
}
|
||||
} else {
|
||||
insight = `今天已支出 ${formatCurrency(todaySpend)}`;
|
||||
insight = `今天已支出 ${formatCurrency(todaySpend)}`;
|
||||
}
|
||||
} else {
|
||||
if (yesterdaySpend > 0) {
|
||||
insight = '新的一天,保持理智消费';
|
||||
insight = '新的一天,保持理智消费';
|
||||
}
|
||||
}
|
||||
|
||||
return {greeting, insight};
|
||||
return { greeting, insight };
|
||||
};
|
||||
|
||||
const {greeting, insight} = getDailyBriefing();
|
||||
const { greeting, insight } = getDailyBriefing();
|
||||
|
||||
// Phase 3: Financial Health Score (Mock Logic)
|
||||
// In a real app, this would be complex. Here we use a simple placeholder derived from net worth/assets ratio
|
||||
const calculateHealthScore = () => {
|
||||
if (totalAssets === 0) return 60; // Baseline
|
||||
const ratio = (totalAssets - totalLiabilities) / totalAssets;
|
||||
let score = Math.round(ratio * 100);
|
||||
if (score < 40) score = 40;
|
||||
const ratio = (totalAssets - totalLiabilities) / totalAssets;
|
||||
let score = Math.round(ratio * 100);
|
||||
if (score < 40) score = 40;
|
||||
if (score > 98) score = 98;
|
||||
return score;
|
||||
return score;
|
||||
};
|
||||
const healthScore = calculateHealthScore();
|
||||
const healthScore = calculateHealthScore();
|
||||
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
<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) {
|
||||
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 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>
|
||||
<p className="greeting-insight animate-slide-up delay-100">
|
||||
<Icon icon="solar:bell-bing-bold-duotone" width="16" className="insight-icon" />
|
||||
{insight}
|
||||
<div className="insight-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
<LikeButton size="sm" initialCount={Math.floor(Math.random() * 20)} />
|
||||
</div>
|
||||
</p>
|
||||
</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">
|
||||
<span className="currency-symbol">¥</span>
|
||||
<span className="card-value-main privacy-mask">{formatCurrency(netWorth).replace(/[^0-9.,-]/g, '')}</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<p className="greeting-insight animate-slide-up delay-100">
|
||||
<Icon icon="solar:bell-bing-bold-duotone" width="16" className="insight-icon" />
|
||||
{insight}
|
||||
<div className="insight-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
<LikeButton size="small" initialCount={Math.floor(Math.random() * 20)} />
|
||||
</div>
|
||||
</p>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
<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">
|
||||
<span className="currency-symbol">¥</span>
|
||||
<span className="card-value-main privacy-mask">{formatCurrency(netWorth).replace(/[^0-9.,-]/g, '')}</span>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<span className="trend-neutral">总览所有账户</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-bg-decoration"></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
export default Home;
|
||||
|
||||
Reference in New Issue
Block a user