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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +203,24 @@ function Home() {
|
||||
|
||||
let insight = '今天还没有记账哦';
|
||||
if (todaySpend > 0) {
|
||||
insight = `今天已支出 ${formatCurrency(todaySpend)}`;
|
||||
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>
|
||||
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user