From adef473fe944a6d955aec731d121ad387591feb2 Mon Sep 17 00:00:00 2001
From: 12975 <1297598740@qq.com>
Date: Mon, 26 Jan 2026 21:58:14 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20CommandPalette=20?=
=?UTF-8?q?=E5=92=8C=20HealthScoreModal=20=E7=BB=84=E4=BB=B6=EF=BC=8C?=
=?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BA=94=E7=94=A8=E5=B8=83=E5=B1=80=E5=B9=B6?=
=?UTF-8?q?=E5=BC=95=E5=85=A5=20Budget=E3=80=81Home=20=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../common/CommandPalette/CommandPalette.css | 204 +++++++++++++
.../common/CommandPalette/CommandPalette.tsx | 278 ++++++++++++++++++
src/components/common/CommandPalette/index.ts | 1 +
src/components/common/Layout/Layout.tsx | 2 +
.../HealthScoreModal/HealthScoreModal.css | 256 ++++++++++++++++
.../HealthScoreModal/HealthScoreModal.tsx | 162 ++++++++++
src/pages/Budget/Budget.tsx | 14 +
src/pages/Home/Home.tsx | 72 +++--
8 files changed, 970 insertions(+), 19 deletions(-)
create mode 100644 src/components/common/CommandPalette/CommandPalette.css
create mode 100644 src/components/common/CommandPalette/CommandPalette.tsx
create mode 100644 src/components/common/CommandPalette/index.ts
create mode 100644 src/components/home/HealthScoreModal/HealthScoreModal.css
create mode 100644 src/components/home/HealthScoreModal/HealthScoreModal.tsx
diff --git a/src/components/common/CommandPalette/CommandPalette.css b/src/components/common/CommandPalette/CommandPalette.css
new file mode 100644
index 0000000..a761484
--- /dev/null
+++ b/src/components/common/CommandPalette/CommandPalette.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/common/CommandPalette/CommandPalette.tsx b/src/components/common/CommandPalette/CommandPalette.tsx
new file mode 100644
index 0000000..2e5bb98
--- /dev/null
+++ b/src/components/common/CommandPalette/CommandPalette.tsx
@@ -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([]);
+ const inputRef = useRef(null);
+ const resultsRef = useRef(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 (
+ setIsOpen(false)}>
+
e.stopPropagation()}
+ >
+
+
+
{
+ setQuery(e.target.value);
+ setActiveIndex(0);
+ }}
+ />
+
ESC
+
+
+
+ {allItems.length === 0 ? (
+
No results found
+ ) : (
+ <>
+ {Object.entries(
+ allItems.reduce((acc, item) => {
+ acc[item.group] = [...(acc[item.group] || []), item];
+ return acc;
+ }, {} as Record
)
+ ).map(([group, items]) => (
+
+
{group}
+ {items.map((item) => {
+ const index = allItems.indexOf(item);
+ return (
+
{
+ item.action();
+ setIsOpen(false);
+ }}
+ onMouseEnter={() => setActiveIndex(index)}
+ >
+
+
+
+
+ {item.title}
+ {item.subtitle}
+
+ {index === activeIndex && (
+
+ Enter
+
+ )}
+
+ );
+ })}
+
+ ))}
+ >
+ )}
+
+
+
+
Search transactions with 2+ characters
+
+ ↑
+ ↓
+ to navigate
+ ↵
+ to select
+
+
+
+
+ );
+};
diff --git a/src/components/common/CommandPalette/index.ts b/src/components/common/CommandPalette/index.ts
new file mode 100644
index 0000000..95c1305
--- /dev/null
+++ b/src/components/common/CommandPalette/index.ts
@@ -0,0 +1 @@
+export { CommandPalette } from './CommandPalette';
diff --git a/src/components/common/Layout/Layout.tsx b/src/components/common/Layout/Layout.tsx
index 46712d9..2f93210 100644
--- a/src/components/common/Layout/Layout.tsx
+++ b/src/components/common/Layout/Layout.tsx
@@ -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() {
+
);
}
diff --git a/src/components/home/HealthScoreModal/HealthScoreModal.css b/src/components/home/HealthScoreModal/HealthScoreModal.css
new file mode 100644
index 0000000..1688fec
--- /dev/null
+++ b/src/components/home/HealthScoreModal/HealthScoreModal.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/home/HealthScoreModal/HealthScoreModal.tsx b/src/components/home/HealthScoreModal/HealthScoreModal.tsx
new file mode 100644
index 0000000..ac71c61
--- /dev/null
+++ b/src/components/home/HealthScoreModal/HealthScoreModal.tsx
@@ -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 = ({
+ 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 (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+ {score}
+ 健康分
+
+
+
+
+
+ {level.label}状态
+
+
+
+
+
+
+
+
+
+
负债率
+
+ {debtRatio.toFixed(1)}%
+ {debtLevel}
+
+
+
+
+
+
+
+
+
+
今日消费
+
+ {formatCurrency(todaySpend)}
+
+
+ {spendTrend === 'up' ? '比昨天多' : '比昨天少'} {formatCurrency(Math.abs(spendDiff))}
+
+
+
+
+
+
+
+
+ 智能建议
+
+
+ {score >= 80
+ ? '您的财务状况非常健康!建议继续保持低负债率,并考虑适当增加投资比例以抵抗通胀。'
+ : score >= 60
+ ? '财务状况良好,但还有提升空间。试着控制非必要支出,提高每月的储蓄比例。'
+ : '请注意控制支出!建议优先偿还高息债务,并审视近期的消费习惯。'}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/pages/Budget/Budget.tsx b/src/pages/Budget/Budget.tsx
index d8f9b5a..f5a9c5d 100644
--- a/src/pages/Budget/Budget.tsx
+++ b/src/pages/Budget/Budget.tsx
@@ -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}
/>
)}
+
+ setShowConfetti(false)}
+ />
);
}
diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx
index d08cf42..ea659b3 100644
--- a/src/pages/Home/Home.tsx
+++ b/src/pages/Home/Home.tsx
@@ -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(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() {
-