feat: 新增 CommandPalette 和 HealthScoreModal 组件,优化应用布局并引入 Budget、Home 页面。

This commit is contained in:
2026-01-26 21:58:14 +08:00
parent bd3e2ba6a5
commit adef473fe9
8 changed files with 970 additions and 19 deletions

View File

@@ -0,0 +1,204 @@
/* CommandPalette.css */
.command-palette-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
opacity: 0;
animation: fadeIn 0.2s ease-out forwards;
}
.command-palette-modal {
width: 100%;
max-width: 600px;
background: var(--glass-panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-2xl);
overflow: hidden;
display: flex;
flex-direction: column;
transform: translateY(-20px) scale(0.95);
animation: slideIn 0.2s ease-out forwards;
}
.command-palette-header {
padding: var(--space-4);
border-bottom: 1px solid var(--glass-border);
display: flex;
align-items: center;
gap: var(--space-3);
}
.command-palette-search-icon {
color: var(--text-secondary);
flex-shrink: 0;
}
.command-palette-input {
flex: 1;
background: transparent;
border: none;
font-size: var(--text-lg);
color: var(--text-primary);
outline: none;
font-family: var(--font-sans);
}
.command-palette-input::placeholder {
color: var(--text-muted);
}
.command-palette-results {
max-height: 400px;
overflow-y: auto;
padding: var(--space-2);
}
/* Scrollbar styling */
.command-palette-results::-webkit-scrollbar {
width: 6px;
}
.command-palette-results::-webkit-scrollbar-track {
background: transparent;
}
.command-palette-results::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
.command-palette-group-title {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-2);
}
.command-palette-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.1s ease;
color: var(--text-primary);
text-decoration: none;
}
.command-palette-item:hover,
.command-palette-item.active {
background-color: var(--primary-light);
/* Or a subtle background */
background: rgba(var(--color-primary-rgb), 0.1);
/* Fallback or specific opacity */
}
/* Ensure we use defined variables */
.command-palette-item:hover,
.command-palette-item.active {
background-color: var(--bg-hover);
}
.command-palette-item.active {
background-color: var(--primary-lighter);
color: var(--color-primary);
}
.command-palette-item-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.command-palette-item.active .command-palette-item-icon {
color: var(--color-primary);
}
.command-palette-item-content {
flex: 1;
min-width: 0;
}
.command-palette-item-title {
font-weight: var(--font-medium);
font-size: var(--text-sm);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.command-palette-item-subtitle {
font-size: var(--text-xs);
color: var(--text-muted);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.command-palette-item-action {
font-size: var(--text-xs);
color: var(--text-muted);
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.command-palette-footer {
padding: var(--space-2) var(--space-4);
background: rgba(0, 0, 0, 0.02);
border-top: 1px solid var(--glass-border);
display: flex;
justify-content: flex-end;
gap: var(--space-4);
font-size: var(--text-xs);
color: var(--text-muted);
}
.command-palette-key {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0 4px;
font-family: var(--font-mono);
box-shadow: 0 1px 0 var(--border-color);
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
@keyframes slideIn {
to {
transform: translateY(0) scale(1);
}
}
/* Empty state */
.command-palette-empty {
padding: var(--space-8);
text-align: center;
color: var(--text-muted);
font-size: var(--text-sm);
}

View File

@@ -0,0 +1,278 @@
/**
* CommandPalette Component
* Global search and command execution using Ctrl+K
*/
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../../../hooks';
import { Icon } from '@iconify/react';
import { useKey } from 'react-use';
import { getTransactions } from '../../../services/transactionService';
import type { Transaction } from '../../../types';
import { formatCurrency } from '../../../utils/format';
import './CommandPalette.css';
interface CommandItem {
id: string;
title: string;
subtitle?: string;
icon: string;
group: 'navigation' | 'actions' | 'transactions';
action: () => void;
}
export const CommandPalette: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [transactionResults, setTransactionResults] = useState<Transaction[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { toggleTheme, isDark } = useTheme();
// Toggle open with Ctrl+K or Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setIsOpen((prev) => !prev);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Focus input when opened
useEffect(() => {
if (isOpen) {
setQuery('');
setTransactionResults([]);
setActiveIndex(0);
// Small timeout to ensure render
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
// Static items
const staticItems: CommandItem[] = [
{
id: 'nav-home',
title: '首页',
subtitle: 'Dashboard & Briefing',
icon: 'solar:home-smile-bold-duotone',
group: 'navigation',
action: () => navigate('/'),
},
{
id: 'nav-transactions',
title: '交易明细',
subtitle: 'View all transactions',
icon: 'solar:bill-list-bold-duotone',
group: 'navigation',
action: () => navigate('/transactions'),
},
{
id: 'nav-budget',
title: '预算 & 存钱罐',
subtitle: 'Manage budgets',
icon: 'solar:pie-chart-2-bold-duotone',
group: 'navigation',
action: () => navigate('/budget'),
},
{
id: 'nav-reports',
title: '报表分析',
subtitle: 'Financial insights',
icon: 'solar:chart-square-bold-duotone',
group: 'navigation',
action: () => navigate('/reports'),
},
{
id: 'nav-settings',
title: '设置',
subtitle: 'App preferences',
icon: 'solar:settings-bold-duotone',
group: 'navigation',
action: () => navigate('/settings'),
},
{
id: 'act-theme',
title: isDark ? '切换到亮色模式' : '切换到暗色模式',
subtitle: 'Toggle global theme',
icon: isDark ? 'solar:sun-bold-duotone' : 'solar:moon-bold-duotone',
group: 'actions',
action: toggleTheme,
},
{
id: 'act-create',
title: '记一笔',
subtitle: 'Create new transaction',
icon: 'solar:add-circle-bold-duotone',
group: 'actions',
action: () => navigate('/transactions'), // Ideally trigger modal
},
];
// Fetch transactions on query change
useEffect(() => {
if (!query || query.length < 2) {
setTransactionResults([]);
return;
}
const timer = setTimeout(async () => {
try {
const res = await getTransactions({ search: query, pageSize: 5 });
setTransactionResults(res.items);
} catch (err) {
console.error('Search transactions failed', err);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
// Combined items
const filteredStatic = staticItems.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.subtitle?.toLowerCase().includes(query.toLowerCase())
);
const transactionItems: CommandItem[] = transactionResults.map((t) => ({
id: `tx-${t.id}`,
title: t.note || '无备注交易',
subtitle: `${formatCurrency(t.amount, t.currency)}${t.transactionDate.split('T')[0]}`,
icon: t.type === 'expense' ? 'solar:minus-circle-bold-duotone' : 'solar:add-circle-bold-duotone',
group: 'transactions',
action: () => navigate('/transactions'), // Ideally scroll to transaction?
}));
const allItems = [...filteredStatic, ...transactionItems];
// Key navigation
useKey(
(e) => isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter'),
(e) => {
if (allItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % allItems.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + allItems.length) % allItems.length);
} else if (e.key === 'Enter') {
e.preventDefault();
const item = allItems[activeIndex];
if (item) {
item.action();
setIsOpen(false);
}
}
},
{ event: 'keydown' },
[isOpen, allItems, activeIndex]
);
// Keep active item in view
useEffect(() => {
if (resultsRef.current) {
const activeEl = resultsRef.current.querySelector('.active');
if (activeEl) {
activeEl.scrollIntoView({ block: 'nearest' });
}
}
}, [activeIndex]);
if (!isOpen) return null;
return (
<div className="command-palette-overlay" onClick={() => setIsOpen(false)}>
<div
className="command-palette-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="command-palette-header">
<Icon icon="solar:magnifer-bold-duotone" width="24" className="command-palette-search-icon" />
<input
ref={inputRef}
type="text"
className="command-palette-input"
placeholder="Search commands or transactions..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
/>
<div className="command-palette-key">ESC</div>
</div>
<div className="command-palette-results" ref={resultsRef}>
{allItems.length === 0 ? (
<div className="command-palette-empty">No results found</div>
) : (
<>
{Object.entries(
allItems.reduce((acc, item) => {
acc[item.group] = [...(acc[item.group] || []), item];
return acc;
}, {} as Record<string, CommandItem[]>)
).map(([group, items]) => (
<div key={group}>
<div className="command-palette-group-title">{group}</div>
{items.map((item) => {
const index = allItems.indexOf(item);
return (
<div
key={item.id}
className={`command-palette-item ${index === activeIndex ? 'active' : ''}`}
onClick={() => {
item.action();
setIsOpen(false);
}}
onMouseEnter={() => setActiveIndex(index)}
>
<div className="command-palette-item-icon">
<Icon icon={item.icon} width="20" />
</div>
<div className="command-palette-item-content">
<span className="command-palette-item-title">{item.title}</span>
<span className="command-palette-item-subtitle">{item.subtitle}</span>
</div>
{index === activeIndex && (
<div className="command-palette-item-action">
<span className="command-palette-key">Enter</span>
</div>
)}
</div>
);
})}
</div>
))}
</>
)}
</div>
<div className="command-palette-footer">
<span>Search transactions with 2+ characters</span>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span className="command-palette-key"></span>
<span className="command-palette-key"></span>
<span>to navigate</span>
<span className="command-palette-key"></span>
<span>to select</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { CommandPalette } from './CommandPalette';

View File

@@ -3,6 +3,7 @@ import { Outlet, useNavigate } from 'react-router-dom';
import { useTheme } from '../../../hooks';
import { Icon } from '@iconify/react';
import Navigation from '../Navigation';
import { CommandPalette } from '../CommandPalette';
import authService from '../../../services/authService';
import type { User } from '../../../services/authService';
import './Layout.css';
@@ -74,6 +75,7 @@ function Layout() {
<Outlet />
</main>
</div>
<CommandPalette />
</div>
);
}

View File

@@ -0,0 +1,256 @@
/**
* HealthScoreModal Styles
*/
.health-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
opacity: 0;
animation: fadeIn 0.3s forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.health-modal-content {
background: var(--glass-panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
width: 100%;
max-width: 400px;
border-radius: 32px;
padding: 2.5rem 2rem 2rem;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
transform: scale(0.9) translateY(20px);
opacity: 0;
transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1);
}
.health-modal-content.animate-in {
transform: scale(1) translateY(0);
opacity: 1;
}
.health-modal-close {
position: absolute;
top: 1.25rem;
right: 1.25rem;
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 0.25rem;
transition: all 0.2s ease;
border-radius: 50%;
}
.health-modal-close:hover {
color: var(--text-primary);
background: rgba(0, 0, 0, 0.05);
}
.health-modal-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
}
.health-score-ring-large {
position: relative;
width: 160px;
height: 160px;
margin-bottom: 1.5rem;
}
.health-ring-progress {
transition: stroke-dasharray 2s cubic-bezier(0.19, 1, 0.22, 1);
transform-origin: center;
}
.health-score-value-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
}
.health-score-value {
font-size: 3.5rem;
font-weight: 800;
font-family: 'Outfit', sans-serif;
line-height: 1;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.health-score-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
margin-top: 4px;
letter-spacing: 0.05em;
}
.health-level-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
font-size: 0.95rem;
font-weight: 700;
}
.health-metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 2rem;
}
.health-metric-card {
background: rgba(255, 255, 255, 0.5);
border-radius: 20px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.metric-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.25rem;
}
.metric-icon.debt {
background: rgba(239, 68, 68, 0.1);
color: var(--color-error);
}
.metric-icon.spend {
background: rgba(245, 158, 11, 0.1);
color: var(--color-warning);
}
.metric-info {
display: flex;
flex-direction: column;
}
.metric-label {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 0.25rem;
}
.metric-value-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.metric-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
font-family: 'Outfit', sans-serif;
}
.metric-status {
font-size: 0.75rem;
font-weight: 600;
}
.metric-trend {
font-size: 0.7rem;
margin-top: 4px;
}
.metric-trend.up {
color: var(--color-error);
/* Higher spend is usually bad */
}
.metric-trend.down {
color: var(--color-success);
/* Lower spend is usually good */
}
.health-suggestion-box {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(37, 99, 235, 0.05));
border-radius: 20px;
padding: 1.25rem;
margin-bottom: 2rem;
border: 1px solid rgba(59, 130, 246, 0.1);
}
.suggestion-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
margin-top: 0;
margin-bottom: 0.5rem;
}
.suggestion-text {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.health-actions {
display: flex;
justify-content: center;
}
.health-action-btn {
width: 100%;
padding: 1rem;
border-radius: 16px;
font-weight: 600;
font-size: 1rem;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.health-action-btn.primary {
background: var(--text-primary);
color: var(--bg-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.health-action-btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}

View File

@@ -0,0 +1,162 @@
/**
* HealthScoreModal Component
* Displays detailed financial health analysis
* Phase 3 Requirement: Emotional interface & Smart feedback
*/
import React, { useEffect, useState } from 'react';
import { Icon } from '@iconify/react';
import { formatCurrency } from '../../../utils/format';
import './HealthScoreModal.css';
interface HealthScoreModalProps {
isOpen: boolean;
onClose: () => void;
score: number;
totalAssets: number;
totalLiabilities: number;
todaySpend: number;
yesterdaySpend: number;
}
export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
isOpen,
onClose,
score,
totalAssets,
totalLiabilities,
todaySpend,
yesterdaySpend,
}) => {
const [animate, setAnimate] = useState(false);
useEffect(() => {
if (isOpen) {
setTimeout(() => setAnimate(true), 100);
} else {
setAnimate(false);
}
}, [isOpen]);
if (!isOpen) return null;
// Analysis Logic
const debtRatio = totalAssets > 0 ? (totalLiabilities / totalAssets) * 100 : 0;
let debtLevel = '优秀';
let debtColor = 'var(--color-success)';
if (debtRatio > 30) {
debtLevel = '一般';
debtColor = 'var(--color-warning)';
}
if (debtRatio > 60) {
debtLevel = '危险';
debtColor = 'var(--color-error)';
}
const spendDiff = todaySpend - yesterdaySpend;
const spendTrend = spendDiff > 0 ? 'up' : 'down';
const getLevel = (s: number) => {
if (s >= 90) return { label: '卓越', color: '#10b981', icon: 'solar:cup-star-bold-duotone' };
if (s >= 80) return { label: '优秀', color: '#3b82f6', icon: 'solar:medal-star-bold-duotone' };
if (s >= 60) return { label: '良好', color: '#f59e0b', icon: 'solar:check-circle-bold-duotone' };
return { label: '需努力', color: '#ef4444', icon: 'solar:danger-circle-bold-duotone' };
};
const level = getLevel(score);
return (
<div className="health-modal-overlay" onClick={onClose}>
<div
className={`health-modal-content ${animate ? 'animate-in' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<button className="health-modal-close" onClick={onClose}>
<Icon icon="solar:close-circle-bold-duotone" width="24" />
</button>
<div className="health-modal-header">
<div className="health-score-ring-large">
<svg viewBox="0 0 100 100">
<circle
cx="50" cy="50" r="45"
fill="none"
stroke="var(--bg-tertiary)"
strokeWidth="8"
/>
<circle
cx="50" cy="50" r="45"
fill="none"
stroke={level.color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${283 * (score / 100)} 283`}
transform="rotate(-90 50 50)"
className="health-ring-progress"
/>
</svg>
<div className="health-score-value-container">
<span className="health-score-value" style={{ color: level.color }}>{score}</span>
<span className="health-score-label"></span>
</div>
</div>
<div className="health-level-badge" style={{ backgroundColor: `${level.color}20`, color: level.color }}>
<Icon icon={level.icon} width="20" />
<span>{level.label}</span>
</div>
</div>
<div className="health-metrics-grid">
<div className="health-metric-card">
<div className="metric-icon debt">
<Icon icon="solar:card-2-bold-duotone" width="24" />
</div>
<div className="metric-info">
<span className="metric-label"></span>
<div className="metric-value-row">
<span className="metric-value">{debtRatio.toFixed(1)}%</span>
<span className="metric-status" style={{ color: debtColor }}>{debtLevel}</span>
</div>
</div>
</div>
<div className="health-metric-card">
<div className="metric-icon spend">
<Icon icon="solar:wallet-money-bold-duotone" width="24" />
</div>
<div className="metric-info">
<span className="metric-label"></span>
<div className="metric-value-row">
<span className="metric-value">{formatCurrency(todaySpend)}</span>
</div>
<span className={`metric-trend ${spendTrend}`}>
{spendTrend === 'up' ? '比昨天多' : '比昨天少'} {formatCurrency(Math.abs(spendDiff))}
</span>
</div>
</div>
</div>
<div className="health-suggestion-box">
<h4 className="suggestion-title">
<Icon icon="solar:lightbulb-bolt-bold-duotone" width="20" className="text-primary" />
</h4>
<p className="suggestion-text">
{score >= 80
? '您的财务状况非常健康!建议继续保持低负债率,并考虑适当增加投资比例以抵抗通胀。'
: score >= 60
? '财务状况良好,但还有提升空间。试着控制非必要支出,提高每月的储蓄比例。'
: '请注意控制支出!建议优先偿还高息债务,并审视近期的消费习惯。'}
</p>
</div>
<div className="health-actions">
<button className="health-action-btn primary" onClick={onClose}>
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { Confetti } from '../../components/common/Confetti';
import {
BudgetCard,
BudgetForm,
@@ -51,6 +52,8 @@ function Budget() {
type: 'deposit' | 'withdraw';
} | null>(null);
const [showConfetti, setShowConfetti] = useState(false);
// Load budgets, categories, and accounts
useEffect(() => {
loadData();
@@ -262,6 +265,12 @@ function Budget() {
...updated,
progress: updated.progress ?? 0,
} : pb)));
// Trigger celebration if goal reached or exceeded on deposit
if (type === 'deposit' && piggyBank.currentAmount < piggyBank.targetAmount && updated.currentAmount >= updated.targetAmount) {
setShowConfetti(true);
}
setTransactionModal(null);
} catch (err) {
console.error('Failed to process transaction:', err);
@@ -398,6 +407,11 @@ function Budget() {
isLoading={isSubmitting}
/>
)}
<Confetti
active={showConfetti}
onComplete={() => setShowConfetti(false)}
/>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './Home.css';
import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '../../services/accountService';
import { getTransactions } from '../../services/transactionService';
import { getTransactions, calculateTotalExpense } from '../../services/transactionService';
import { getCategories } from '../../services/categoryService';
import { getLedgers, reorderLedgers } from '../../services/ledgerService';
import { getSettings, updateSettings } from '../../services/settingsService';
@@ -15,6 +15,7 @@ import { CreateFirstAccountModal } from '../../components/account/CreateFirstAcc
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 type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
@@ -38,6 +39,9 @@ function Home() {
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);
@@ -51,13 +55,23 @@ function Home() {
setLoading(true);
setError(null);
// Load accounts, recent transactions, categories, ledgers, and settings in parallel
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData] = await Promise.all([
// 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] = await Promise.all([
getAccounts(),
getTransactions({ page: 1, pageSize: 5 }), // Get 5 most recent transactions
getTransactions({ page: 1, pageSize: 5 }), // Recent transactions
getCategories(),
getLedgers().catch(() => []), // Gracefully handle if ledgers not available
getSettings().catch(() => null), // Gracefully handle if settings not available
getLedgers().catch(() => []),
getSettings().catch(() => null),
getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }),
getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }),
]);
setAccounts(accountsData || []);
@@ -65,6 +79,10 @@ function Home() {
setCategories(categoriesData || []);
setLedgers(ledgersData || []);
setSettings(settingsData);
// 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);
@@ -176,17 +194,6 @@ function Home() {
// Phase 3: Daily Briefing Logic
const getDailyBriefing = () => {
const hour = new Date().getHours();
const todaySpend = recentTransactions
.filter(t => {
const tDate = new Date(t.transactionDate);
const today = new Date();
return tDate.getDate() === today.getDate() &&
tDate.getMonth() === today.getMonth() &&
tDate.getFullYear() === today.getFullYear() &&
t.type === 'expense';
})
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
let greeting = '你好';
if (hour < 5) greeting = '夜深了';
else if (hour < 11) greeting = '早上好';
@@ -196,8 +203,25 @@ function Home() {
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 };
};
@@ -282,7 +306,7 @@ function Home() {
</p>
</div>
<div className="header-actions animate-slide-up delay-200">
<button className="health-score-btn" onClick={() => setShowConfetti(true)}>
<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" />
@@ -479,6 +503,16 @@ function Home() {
)}
<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>
);