feat: 新增 CommandPalette 和 HealthScoreModal 组件,优化应用布局并引入 Budget、Home 页面。
This commit is contained in:
204
src/components/common/CommandPalette/CommandPalette.css
Normal file
204
src/components/common/CommandPalette/CommandPalette.css
Normal 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);
|
||||||
|
}
|
||||||
278
src/components/common/CommandPalette/CommandPalette.tsx
Normal file
278
src/components/common/CommandPalette/CommandPalette.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/common/CommandPalette/index.ts
Normal file
1
src/components/common/CommandPalette/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CommandPalette } from './CommandPalette';
|
||||||
@@ -3,6 +3,7 @@ import { Outlet, useNavigate } from 'react-router-dom';
|
|||||||
import { useTheme } from '../../../hooks';
|
import { useTheme } from '../../../hooks';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Navigation from '../Navigation';
|
import Navigation from '../Navigation';
|
||||||
|
import { CommandPalette } from '../CommandPalette';
|
||||||
import authService from '../../../services/authService';
|
import authService from '../../../services/authService';
|
||||||
import type { User } from '../../../services/authService';
|
import type { User } from '../../../services/authService';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
@@ -74,6 +75,7 @@ function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
256
src/components/home/HealthScoreModal/HealthScoreModal.css
Normal file
256
src/components/home/HealthScoreModal/HealthScoreModal.css
Normal 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);
|
||||||
|
}
|
||||||
162
src/components/home/HealthScoreModal/HealthScoreModal.tsx
Normal file
162
src/components/home/HealthScoreModal/HealthScoreModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Confetti } from '../../components/common/Confetti';
|
||||||
import {
|
import {
|
||||||
BudgetCard,
|
BudgetCard,
|
||||||
BudgetForm,
|
BudgetForm,
|
||||||
@@ -51,6 +52,8 @@ function Budget() {
|
|||||||
type: 'deposit' | 'withdraw';
|
type: 'deposit' | 'withdraw';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
// Load budgets, categories, and accounts
|
// Load budgets, categories, and accounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -262,6 +265,12 @@ function Budget() {
|
|||||||
...updated,
|
...updated,
|
||||||
progress: updated.progress ?? 0,
|
progress: updated.progress ?? 0,
|
||||||
} : pb)));
|
} : 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);
|
setTransactionModal(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to process transaction:', err);
|
console.error('Failed to process transaction:', err);
|
||||||
@@ -398,6 +407,11 @@ function Budget() {
|
|||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Confetti
|
||||||
|
active={showConfetti}
|
||||||
|
onComplete={() => setShowConfetti(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import './Home.css';
|
import './Home.css';
|
||||||
import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '../../services/accountService';
|
import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '../../services/accountService';
|
||||||
import { getTransactions } from '../../services/transactionService';
|
import { getTransactions, calculateTotalExpense } from '../../services/transactionService';
|
||||||
import { getCategories } from '../../services/categoryService';
|
import { getCategories } from '../../services/categoryService';
|
||||||
import { getLedgers, reorderLedgers } from '../../services/ledgerService';
|
import { getLedgers, reorderLedgers } from '../../services/ledgerService';
|
||||||
import { getSettings, updateSettings } from '../../services/settingsService';
|
import { getSettings, updateSettings } from '../../services/settingsService';
|
||||||
@@ -15,6 +15,7 @@ import { CreateFirstAccountModal } from '../../components/account/CreateFirstAcc
|
|||||||
import { AccountForm } from '../../components/account/AccountForm/AccountForm';
|
import { AccountForm } from '../../components/account/AccountForm/AccountForm';
|
||||||
import { createAccount } from '../../services/accountService';
|
import { createAccount } from '../../services/accountService';
|
||||||
import { Confetti } from '../../components/common/Confetti';
|
import { Confetti } from '../../components/common/Confetti';
|
||||||
|
import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal';
|
||||||
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
|
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ function Home() {
|
|||||||
const [voiceModalOpen, setVoiceModalOpen] = useState(false);
|
const [voiceModalOpen, setVoiceModalOpen] = useState(false);
|
||||||
const [showAccountForm, setShowAccountForm] = useState(false);
|
const [showAccountForm, setShowAccountForm] = useState(false);
|
||||||
const [showConfetti, setShowConfetti] = 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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -51,13 +55,23 @@ function Home() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Load accounts, recent transactions, categories, ledgers, and settings in parallel
|
// Calculate dates for today and yesterday
|
||||||
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData] = await Promise.all([
|
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(),
|
getAccounts(),
|
||||||
getTransactions({ page: 1, pageSize: 5 }), // Get 5 most recent transactions
|
getTransactions({ page: 1, pageSize: 5 }), // Recent transactions
|
||||||
getCategories(),
|
getCategories(),
|
||||||
getLedgers().catch(() => []), // Gracefully handle if ledgers not available
|
getLedgers().catch(() => []),
|
||||||
getSettings().catch(() => null), // Gracefully handle if settings not available
|
getSettings().catch(() => null),
|
||||||
|
getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }),
|
||||||
|
getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setAccounts(accountsData || []);
|
setAccounts(accountsData || []);
|
||||||
@@ -65,6 +79,10 @@ function Home() {
|
|||||||
setCategories(categoriesData || []);
|
setCategories(categoriesData || []);
|
||||||
setLedgers(ledgersData || []);
|
setLedgers(ledgersData || []);
|
||||||
setSettings(settingsData);
|
setSettings(settingsData);
|
||||||
|
|
||||||
|
// Calculate daily spends
|
||||||
|
setTodaySpend(calculateTotalExpense(todayData.items));
|
||||||
|
setYesterdaySpend(calculateTotalExpense(yesterdayData.items));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '加载数据失败');
|
setError(err instanceof Error ? err.message : '加载数据失败');
|
||||||
console.error('Failed to load home page data:', err);
|
console.error('Failed to load home page data:', err);
|
||||||
@@ -176,17 +194,6 @@ function Home() {
|
|||||||
// Phase 3: Daily Briefing Logic
|
// Phase 3: Daily Briefing Logic
|
||||||
const getDailyBriefing = () => {
|
const getDailyBriefing = () => {
|
||||||
const hour = new Date().getHours();
|
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 = '你好';
|
let greeting = '你好';
|
||||||
if (hour < 5) greeting = '夜深了';
|
if (hour < 5) greeting = '夜深了';
|
||||||
else if (hour < 11) greeting = '早上好';
|
else if (hour < 11) greeting = '早上好';
|
||||||
@@ -196,8 +203,25 @@ function Home() {
|
|||||||
|
|
||||||
let insight = '今天还没有记账哦';
|
let insight = '今天还没有记账哦';
|
||||||
if (todaySpend > 0) {
|
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)}`;
|
insight = `今天已支出 ${formatCurrency(todaySpend)}`;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (yesterdaySpend > 0) {
|
||||||
|
insight = '新的一天,保持理智消费';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { greeting, insight };
|
return { greeting, insight };
|
||||||
};
|
};
|
||||||
@@ -282,7 +306,7 @@ function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions animate-slide-up delay-200">
|
<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}>
|
<div className="health-ring" style={{ '--score': `${healthScore}%`, '--color': 'var(--accent-success)' } as any}>
|
||||||
<svg viewBox="0 0 36 36">
|
<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-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)} />
|
<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>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user