feat: 新增首页,并集成财务健康评分功能、相关组件和计算逻辑

This commit is contained in:
2026-01-30 16:10:40 +08:00
parent 6ac838b651
commit dea24a1297
3 changed files with 170 additions and 81 deletions

View File

@@ -6,7 +6,6 @@
import React, { useEffect, useState } from 'react';
import { Icon } from '@iconify/react';
import { formatCurrency } from '../../../utils/format';
import { getFinancialAdvice } from '../../../services/aiService';
import './HealthScoreModal.css';
interface HealthScoreModalProps {
@@ -24,6 +23,12 @@ interface HealthScoreModalProps {
activity: number;
};
streakDays: number;
metrics: {
debtRatio: number;
survivalMonths: number;
budgetHealth: number;
};
tips: string[];
}
export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
@@ -32,53 +37,34 @@ export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
score,
totalAssets,
totalLiabilities,
todaySpend,
yesterdaySpend,
// todaySpend, // Unused
// yesterdaySpend, // Unused
breakdown,
streakDays,
metrics,
tips
}) => {
const [showRules, setShowRules] = useState(false);
const [animate, setAnimate] = useState(false);
const [aiAdvice, setAiAdvice] = useState<string>('');
const [loadingAdvice, setLoadingAdvice] = useState(false);
// AI Advice state can be removed if strictly using tips, or kept as fallback
// --- Logic & Calculations ---
const debtRatio = totalAssets > 0 ? (totalLiabilities / totalAssets) * 100 : 0;
const spendDiff = todaySpend - yesterdaySpend;
// const spendDiff = todaySpend - yesterdaySpend; // Unused now
useEffect(() => {
let isMounted = true;
if (isOpen) {
setTimeout(() => setAnimate(true), 50);
// Fetch AI Advice if not already fetched
if (!aiAdvice) {
setLoadingAdvice(true);
getFinancialAdvice({
score,
totalAssets,
totalLiabilities,
todaySpend,
yesterdaySpend
})
.then(advice => {
if (isMounted) setAiAdvice(advice);
})
.catch(err => {
console.error("Failed to load AI advice", err);
})
.finally(() => {
if (isMounted) setLoadingAdvice(false);
});
}
} else {
setAnimate(false);
setShowRules(false); // Reset view on close
}
return () => { isMounted = false; };
}, [isOpen]);
const aiAdvice = ''; // Fallback placeholder if needed
if (!isOpen) return null;
// Status Levels
@@ -135,8 +121,8 @@ export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
<div className="rule-item">
<div className="rule-score rule-score--blue">{breakdown.solvency}/40</div>
<div className="rule-desc-group">
<h4> (40)</h4>
<p>1 - ( / )<br />{debtRatio > 50 ? '当前负债率过高,严重影响得分。' : '当前资产结构健康。'}</p>
<h4> (40)</h4>
<p>3-6<br /></p>
</div>
</div>
@@ -227,17 +213,20 @@ export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
</span>
</div>
{/* Spending Card */}
{/* Survival Time Card (Replacing Spend) */}
<div className="health-metric-card">
<div className="metric-icon-wrapper" style={{ background: 'rgba(245, 158, 11, 0.15)' }}>
<Icon icon="solar:wallet-money-bold-duotone" width="20" color="#f59e0b" />
<div className="metric-icon-wrapper" style={{ background: 'rgba(16, 185, 129, 0.15)' }}>
<Icon icon="solar:shield-check-bold-duotone" width="20" color="#10b981" />
</div>
<span className="metric-title"></span>
<span className="metric-title"></span>
<div className="metric-value-group">
<span className="metric-number">{formatCurrency(todaySpend)}</span>
<span className="metric-number">
{metrics?.survivalMonths > 99 ? '>99' : (metrics?.survivalMonths || 0).toFixed(1)}
</span>
<span style={{ fontSize: '1rem', fontWeight: 600, marginLeft: '2px' }}></span>
</div>
<span className="metric-subtitle" style={{ color: spendDiff > 0 ? '#ef4444' : '#10b981' }}>
{spendDiff > 0 ? '多' : '少'} {formatCurrency(Math.abs(spendDiff))}
<span className="metric-subtitle" style={{ color: (metrics?.survivalMonths || 0) < 3 ? '#ef4444' : '#10b981' }}>
{(metrics?.survivalMonths || 0) >= 6 ? '资金充裕' : (metrics?.survivalMonths || 0) >= 3 ? '健康' : '急需储备'}
</span>
</div>
</div>
@@ -247,15 +236,16 @@ export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
<div className="suggestion-header">
<Icon icon="solar:lightbulb-bolt-bold-duotone" width="20" />
<span>AI </span>
{loadingAdvice && <span style={{ fontSize: '0.8rem', opacity: 0.7, marginLeft: 'auto' }}>...</span>}
</div>
<p className="suggestion-content">
{loadingAdvice ? (
<span className="loading-dots">...</span>
<div className="suggestion-content">
{tips && tips.length > 0 ? (
tips.map((tip, i) => (
<div key={i} style={{ marginBottom: '8px', fontSize: '0.9rem' }}>{tip}</div>
))
) : (
aiAdvice || getStaticSuggestion()
<p>{aiAdvice || getStaticSuggestion()}</p>
)}
</p>
</div>
</div>
</>
)}

View File

@@ -257,7 +257,8 @@ function Home() {
monthlyBudget: monthlyBudgetTotal,
monthlySpent: monthlyBudgetSpentTotal,
streakDays: streakInfo?.currentStreak || 0,
hasRecentActivity: todaySpend > 0 || yesterdaySpend > 0
hasRecentActivity: todaySpend > 0 || yesterdaySpend > 0,
accounts: accounts || []
});
};
const healthStatus = calculateHealthScore();
@@ -430,6 +431,8 @@ function Home() {
yesterdaySpend={yesterdaySpend}
breakdown={healthStatus.breakdown}
streakDays={streakInfo?.currentStreak || 0}
metrics={healthStatus.metrics}
tips={healthStatus.tips}
/>
<button className="fab-voice-btn" onClick={handleVoiceBookkeeping} title="语音记账">
<Icon icon="solar:microphone-bold-duotone" width="28" />

View File

@@ -8,6 +8,8 @@
* 4. Recent Activity (10%): Engagement
*/
import type { Account } from '../types';
interface HealthScoreDetails {
totalAssets: number;
totalLiabilities: number;
@@ -15,6 +17,7 @@ interface HealthScoreDetails {
monthlySpent: number;
streakDays: number;
hasRecentActivity: boolean; // Spent today or yesterday
accounts: Account[];
}
interface FinancialStatus {
@@ -28,6 +31,12 @@ interface FinancialStatus {
habit: number;
activity: number;
};
metrics: {
debtRatio: number;
survivalMonths: number;
budgetHealth: number; // -1 (Over), 0 (On Track), 1 (Saving)
};
tips: string[];
}
export const calculateFinancialHealth = (data: HealthScoreDetails): FinancialStatus => {
@@ -36,52 +45,91 @@ export const calculateFinancialHealth = (data: HealthScoreDetails): FinancialSta
let habitScore = 0;
let activityScore = 0;
// 1. Solvency (Max 40)
// --- 1. SOLVENCY (Max 40) ---
// A. Debt Ratio (20 pts)
let debtScore = 0;
let debtRatio = 0;
if (data.totalAssets > 0) {
const debtRatio = data.totalLiabilities / data.totalAssets;
if (debtRatio === 0) solvencyScore = 40;
else if (debtRatio < 0.3) solvencyScore = 35; // < 30% debt
else if (debtRatio < 0.5) solvencyScore = 25; // < 50% debt
else if (debtRatio < 0.8) solvencyScore = 10; // < 80% debt
else solvencyScore = 0; // High debt
debtRatio = data.totalLiabilities / data.totalAssets;
if (debtRatio === 0) debtScore = 20;
else if (debtRatio < 0.3) debtScore = 18;
else if (debtRatio < 0.5) debtScore = 12;
else if (debtRatio < 0.8) debtScore = 5;
else debtScore = 0;
} else {
// No assets
solvencyScore = data.totalLiabilities === 0 ? 20 : 0; // Blank slate is better than debt only
debtScore = data.totalLiabilities === 0 ? 10 : 0;
}
// 2. Budget Adherence (Max 30)
// B. Liquidity / Runway (20 pts)
// Liquid Assets: Cash, Debit Card, E-Wallet
const liquidAssets = data.accounts
.filter(a => ['cash', 'debit_card', 'e_wallet'].includes(a.type))
.reduce((sum, a) => sum + (a.balance > 0 ? a.balance : 0), 0);
let liquidityScore = 0;
// Estimate monthly burn: Use Budget if set, otherwise assume Spent is typical (use fallback min 2000 to avoid divide by zero)
const monthlyBurn = data.monthlyBudget > 0 ? data.monthlyBudget : Math.max(data.monthlySpent, 2000);
const monthsRunway = liquidAssets / monthlyBurn;
if (monthsRunway >= 6) liquidityScore = 20; // Excellent safety net
else if (monthsRunway >= 3) liquidityScore = 15; // Healthy
else if (monthsRunway >= 1) liquidityScore = 8; // Basic suvival
else liquidityScore = 0; // Living paycheck to paycheck
solvencyScore = Math.round(debtScore + liquidityScore);
// --- 2. BUDGET CONTROL (Max 30) ---
// A. Adherence (20 pts) - Tracking against time
if (data.monthlyBudget > 0) {
const today = new Date();
const dayOfMonth = today.getDate();
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
const progress = dayOfMonth / daysInMonth; // 0.1 to 1.0
const timeProgress = dayOfMonth / daysInMonth;
const budgetUsage = data.monthlySpent / data.monthlyBudget;
// Ideal usage matches time progress
// Example: Day 15/30 (50%), Spent 50% -> Good. Spent 80% -> Bad.
const usage = data.monthlySpent / data.monthlyBudget;
// Allowed deviation: +/- 10% is fine.
// Usage should be roughly equal to TimeProgress.
// If Usage << TimeProgress, it's SAVINGS (Bonus).
// If Usage >> TimeProgress, it's DANGER (Penalty).
if (usage <= progress) budgetScore = 30; // Under spending curve
else if (usage <= progress * 1.2) budgetScore = 20; // Slightly over (+20%)
else if (usage <= progress * 1.5) budgetScore = 10; // Over (+50%)
else budgetScore = 0; // Severely over
const diff = budgetUsage - timeProgress;
if (diff <= 0) budgetScore += 20; // Spending less than time passed -> Great
else if (diff <= 0.1) budgetScore += 15; // Slightly over (+10% ahead of schedule)
else if (diff <= 0.2) budgetScore += 10; // Moderately over (+20%)
else if (diff <= 0.3) budgetScore += 5;
else budgetScore += 0;
// B. Savings Bonus (Max 10 pts)
// If usage is significantly lower than time progress (e.g. usage is 50% of time), reward saving behavior
if (budgetUsage < timeProgress * 0.8) budgetScore += 10;
else if (budgetUsage < timeProgress * 0.9) budgetScore += 5;
// Cap Budget score at 30
budgetScore = Math.min(budgetScore, 30);
// Hard fail: Over 100% budget is 0 points regardless
if (budgetUsage > 1.0) budgetScore = 0;
// Hard cap: If total over 100%, score 0 regardless of day
if (usage > 1) budgetScore = 0;
} else {
budgetScore = 15; // Neutral if no budget set
// No Budget Set - Neutral Score
budgetScore = 15;
}
// 3. Habit Consistency (Max 20)
if (data.streakDays >= 30) habitScore = 20;
// --- 3. HABIT CONSISTENCY (Max 20) ---
// Curved grading for streaks
if (data.streakDays >= 21) habitScore = 20; // 3 weeks -> Habit formed
else if (data.streakDays >= 14) habitScore = 15;
else if (data.streakDays >= 7) habitScore = 10;
else if (data.streakDays >= 3) habitScore = 5;
else habitScore = 0;
// 4. Activity (Max 10)
// --- 4. RECENT ACTIVITY (Max 10) ---
if (data.hasRecentActivity) activityScore = 10;
const totalScore = Math.round(solvencyScore + budgetScore + habitScore + activityScore);
// --- TOTAL ---
const totalScore = Math.min(Math.round(solvencyScore + budgetScore + habitScore + activityScore), 100);
// Determine Status
let label = '';
@@ -90,24 +138,66 @@ export const calculateFinancialHealth = (data: HealthScoreDetails): FinancialSta
if (totalScore >= 90) {
label = '财务卓越';
color = '#10B981'; // Emerald 500
description = '资产极其健康,习惯优秀';
color = '#10B981';
description = '资产结构完美,抗风险能力极强';
} else if (totalScore >= 75) {
label = '非常健康';
color = '#3B82F6'; // Blue 500
description = '财务状况良好,继续保持';
color = '#3B82F6';
description = '现金流充足,债务控制良好';
} else if (totalScore >= 60) {
label = '亚健康';
color = '#F59E0B'; // Amber 500
description = '存在潜在风险,需注意收支';
color = '#F59E0B';
description = '需关注应急资金储备与消费控制';
} else if (totalScore >= 40) {
label = '脆弱';
color = '#F97316'; // Orange 500
description = '风险能力弱,建议储蓄';
color = '#F97316';
description = '风险!建议优先建立应急储备金';
} else {
label = '危机';
color = '#EF4444'; // Red 500
description = '财务状况堪忧,急需调整';
color = '#EF4444';
description = '严重的财务风险,请立即停止非必要支出';
}
// --- TIPS GENERATION ---
const tips: string[] = [];
// Solvency Tips
if (monthsRunway < 3) {
tips.push(`⚠️ 应急资金不足 (仅够维持 ${monthsRunway.toFixed(1)} 个月)。建议储备至少 3-6 个月的生活费。`);
} else if (monthsRunway > 12) {
tips.push(`💡 现金储备充足 (${monthsRunway.toFixed(1)} 个月)。建议将多余资金进行投资以跑赢通胀。`);
} else {
tips.push(`✅ 应急储备健康 (${monthsRunway.toFixed(1)} 个月)。`);
}
if (debtRatio > 0.5) {
tips.push(`⚠️ 负债率偏高 (${(debtRatio * 100).toFixed(0)}%)。请优先偿还高息债务。`);
}
// Budget Tips
let budgetHealth = 0;
if (data.monthlyBudget > 0) {
const today = new Date();
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
const timeProgress = today.getDate() / daysInMonth;
const budgetUsage = data.monthlySpent / data.monthlyBudget;
if (budgetUsage > timeProgress * 1.2) {
tips.push(`⚠️ 消费速度过快 (超前 ${((budgetUsage - timeProgress) * 100).toFixed(0)}%)。请控制非必要支出。`);
budgetHealth = -1;
} else if (budgetUsage < timeProgress * 0.8) {
tips.push(`👏 预算控制极佳 (结余 ${((timeProgress - budgetUsage) * 100).toFixed(0)}%)。继续保持!`);
budgetHealth = 1;
} else {
budgetHealth = 0;
}
} else {
tips.push(`💡 尚未设置预算。建议设定月度预算以获得更准确的健康评估。`);
}
// Habit Tips
if (data.streakDays < 3) {
tips.push(`🔥 坚持记账是理财的第一步。加油!`);
}
return {
@@ -120,6 +210,12 @@ export const calculateFinancialHealth = (data: HealthScoreDetails): FinancialSta
budget: budgetScore,
habit: habitScore,
activity: activityScore
}
},
metrics: {
debtRatio,
survivalMonths: monthsRunway,
budgetHealth
},
tips
};
};