Files
Novault-Frontend-web/src/components/home/HealthScoreModal/HealthScoreModal.tsx

289 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* HealthScoreModal Component
* Displays detailed financial health analysis with premium glassmorphism design
*/
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 {
isOpen: boolean;
onClose: () => void;
score: number;
totalAssets: number;
totalLiabilities: number;
todaySpend: number;
yesterdaySpend: number;
breakdown: {
solvency: number;
budget: number;
habit: number;
activity: number;
};
streakDays: number;
metrics: {
debtRatio: number;
survivalMonths: number;
budgetHealth: number;
};
tips: string[];
}
export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
isOpen,
onClose,
score,
totalAssets,
totalLiabilities,
// 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);
// --- Logic & Calculations ---
const debtRatio = totalAssets > 0 ? (totalLiabilities / totalAssets) * 100 : 0;
// 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 && !loadingAdvice) { // Only fetch if not already fetched or loading
setLoadingAdvice(true);
getFinancialAdvice({
score,
totalAssets,
totalLiabilities,
metrics,
tips
})
.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, score, totalAssets, totalLiabilities, metrics, tips]);
if (!isOpen) return null;
// Status Levels
const getLevel = (s: number) => {
if (s >= 95) return { label: 'SSS', title: '财务自由', color: '#10b981', icon: 'solar:crown-star-bold-duotone', desc: '完美无瑕的资产结构' };
if (s >= 90) return { label: 'S', title: '卓越', color: '#34d399', icon: 'solar:cup-star-bold-duotone', desc: '财务状况极佳' };
if (s >= 80) return { label: 'A', title: '优秀', color: '#3b82f6', icon: 'solar:medal-star-bold-duotone', desc: '资产配置健康' };
if (s >= 70) return { label: 'B', title: '良好', color: '#6366f1', icon: 'solar:check-circle-bold-duotone', desc: '继续保持记账习惯' };
if (s >= 60) return { label: 'C', title: '及格', color: '#f59e0b', icon: 'solar:info-circle-bold-duotone', desc: '需注意控制负债' };
return { label: 'D', title: '危险', color: '#ef4444', icon: 'solar:danger-circle-bold-duotone', desc: '建议立即调整开支' };
};
const level = getLevel(score);
// --- Render Helpers ---
return (
<div className="health-modal-overlay" onClick={onClose}>
<div
className={`health-modal-content ${animate ? 'animate-in' : ''}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header Controls */}
<div className="health-modal-controls">
<button
className="health-icon-btn"
onClick={() => setShowRules(!showRules)}
title={showRules ? "返回评分" : "查看规则"}
>
<Icon icon={showRules ? "solar:arrow-left-bold-duotone" : "solar:question-circle-bold-duotone"} width="20" />
</button>
<button className="health-icon-btn" onClick={onClose}>
<Icon icon="solar:close-circle-bold-duotone" width="20" />
</button>
</div>
<div className="health-scroll-container">
{showRules ? (
// --- Rules View ---
<div className="health-rules-panel">
<h3 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '0 0 1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Icon icon="solar:ruler-pen-bold-duotone" className="text-primary" />
</h3>
<div className="rule-item">
<div className="rule-score rule-score--blue">{breakdown.solvency}/40</div>
<div className="rule-desc-group">
<h4> (40)</h4>
<p>3-6<br /></p>
</div>
</div>
<div className="rule-item">
<div className="rule-score rule-score--green">{breakdown.budget}/30</div>
<div className="rule-desc-group">
<h4> (30)</h4>
<p>50%50%<br />{breakdown.budget === 30 ? '当前预算控制极佳。' : '需注意平滑消费曲线。'}</p>
</div>
</div>
<div className="rule-item">
<div className="rule-score rule-score--orange">{breakdown.habit}/20</div>
<div className="rule-desc-group">
<h4> (20)</h4>
<p>(Streak)<br />: {streakDays} </p>
</div>
</div>
<div className="rule-item">
<div className="rule-score rule-score--purple">{breakdown.activity}/10</div>
<div className="rule-desc-group">
<h4> (10)</h4>
<p>48</p>
</div>
</div>
</div>
) : (
// --- Score View ---
<>
<div className="health-score-section">
<div className="health-score-ring-container">
<svg className="health-score-ring-svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="scoreGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4ade80" />
<stop offset="100%" stopColor="#22c55e" />
</linearGradient>
</defs>
{/* Background Ring */}
<circle
cx="50" cy="50" r="45"
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="8"
/>
{/* Progress Ring */}
<circle
cx="50" cy="50" r="45"
fill="none"
stroke={level.color} /* Use level color or gradient */
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${283 * (score / 100)} 283`}
className="health-ring-progress"
/>
</svg>
<div className="health-score-content">
<span className="health-score-number">{score}</span>
<span className="health-score-label"></span>
</div>
</div>
<div className="health-level-badge" style={{ borderColor: `${level.color}40`, background: `${level.color}15` }}>
<Icon icon={level.icon} width="20" color={level.color} />
<span style={{ color: level.color }}>{level.title} · {level.desc}</span>
</div>
</div>
<div className="health-metrics-grid">
{/* Debt Card */}
<div className="health-metric-card">
<div className="metric-icon-wrapper" style={{ background: 'rgba(239, 68, 68, 0.15)' }}>
<Icon icon="solar:card-2-bold-duotone" width="20" color="#ef4444" />
</div>
<span className="metric-title"></span>
<div className="metric-value-group">
<span className="metric-number">{debtRatio.toFixed(1)}%</span>
<span className="metric-tag" style={{
backgroundColor: debtRatio > 30 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)',
color: debtRatio > 30 ? '#ef4444' : '#10b981'
}}>
{debtRatio > 30 ? '一般' : '优秀'}
</span>
</div>
<span className="metric-subtitle">
{debtRatio === 0 ? '无负债一身轻' : `负债 ${formatCurrency(totalLiabilities)}`}
</span>
</div>
{/* Survival Time Card (Replacing Spend) */}
<div className="health-metric-card">
<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>
<div className="metric-value-group">
<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: (metrics?.survivalMonths || 0) < 3 ? '#ef4444' : '#10b981' }}>
{(metrics?.survivalMonths || 0) >= 6 ? '资金充裕' : (metrics?.survivalMonths || 0) >= 3 ? '健康' : '急需储备'}
</span>
</div>
</div>
{/* Suggestion Box */}
<div className="health-suggestion-box">
<div className="suggestion-header">
<Icon icon="solar:lightbulb-bolt-bold-duotone" width="20" />
<span>AI </span>
</div>
<div className="suggestion-content">
{/* Static Tips */}
{tips && tips.length > 0 && tips.map((tip, i) => (
<div key={i} style={{ marginBottom: '8px', fontSize: '0.9rem', color: 'rgba(255,255,255,0.9)' }}>{tip}</div>
))}
{/* AI Dynamic Advice */}
{loadingAdvice ? (
<div style={{ marginTop: '12px', fontSize: '0.85rem', opacity: 0.7, fontStyle: 'italic', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className="loading-dots">CFO ...</span>
</div>
) : aiAdvice && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: '0.9rem', lineHeight: '1.6', color: '#60a5fa' }}>
<Icon icon="solar:chat-round-line-duotone" style={{ marginRight: '6px', verticalAlign: 'text-bottom' }} />
{aiAdvice}
</div>
)}
</div>
</div>
</>
)}
</div>
<div className="health-actions-bottom">
<button className="health-confirm-btn" onClick={onClose}>
{showRules ? '返回' : '明白了'}
</button>
</div>
</div>
</div>
);
};