2026-01-26 21:58:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* HealthScoreModal Component
|
2026-01-28 15:41:16 +08:00
|
|
|
|
* Displays detailed financial health analysis with premium glassmorphism design
|
2026-01-26 21:58:14 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
|
|
import { Icon } from '@iconify/react';
|
|
|
|
|
|
import { formatCurrency } from '../../../utils/format';
|
2026-01-30 16:18:00 +08:00
|
|
|
|
import { getFinancialAdvice } from '../../../services/aiService';
|
2026-01-26 21:58:14 +08:00
|
|
|
|
import './HealthScoreModal.css';
|
|
|
|
|
|
|
|
|
|
|
|
interface HealthScoreModalProps {
|
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
score: number;
|
|
|
|
|
|
totalAssets: number;
|
|
|
|
|
|
totalLiabilities: number;
|
|
|
|
|
|
todaySpend: number;
|
|
|
|
|
|
yesterdaySpend: number;
|
2026-01-30 16:03:50 +08:00
|
|
|
|
breakdown: {
|
|
|
|
|
|
solvency: number;
|
|
|
|
|
|
budget: number;
|
|
|
|
|
|
habit: number;
|
|
|
|
|
|
activity: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
streakDays: number;
|
2026-01-30 16:10:40 +08:00
|
|
|
|
metrics: {
|
|
|
|
|
|
debtRatio: number;
|
|
|
|
|
|
survivalMonths: number;
|
|
|
|
|
|
budgetHealth: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
tips: string[];
|
2026-01-26 21:58:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const HealthScoreModal: React.FC<HealthScoreModalProps> = ({
|
|
|
|
|
|
isOpen,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
score,
|
|
|
|
|
|
totalAssets,
|
|
|
|
|
|
totalLiabilities,
|
2026-01-30 16:10:40 +08:00
|
|
|
|
// todaySpend, // Unused
|
|
|
|
|
|
// yesterdaySpend, // Unused
|
2026-01-30 16:03:50 +08:00
|
|
|
|
breakdown,
|
|
|
|
|
|
streakDays,
|
2026-01-30 16:10:40 +08:00
|
|
|
|
metrics,
|
|
|
|
|
|
tips
|
2026-01-26 21:58:14 +08:00
|
|
|
|
}) => {
|
2026-01-28 15:41:16 +08:00
|
|
|
|
const [showRules, setShowRules] = useState(false);
|
2026-01-26 21:58:14 +08:00
|
|
|
|
const [animate, setAnimate] = useState(false);
|
2026-01-30 16:18:00 +08:00
|
|
|
|
const [aiAdvice, setAiAdvice] = useState<string>('');
|
|
|
|
|
|
const [loadingAdvice, setLoadingAdvice] = useState(false);
|
2026-01-28 15:41:16 +08:00
|
|
|
|
|
|
|
|
|
|
// --- Logic & Calculations ---
|
2026-01-28 17:01:32 +08:00
|
|
|
|
|
2026-01-28 15:41:16 +08:00
|
|
|
|
const debtRatio = totalAssets > 0 ? (totalLiabilities / totalAssets) * 100 : 0;
|
2026-01-30 16:10:40 +08:00
|
|
|
|
// const spendDiff = todaySpend - yesterdaySpend; // Unused now
|
2026-01-26 21:58:14 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-01-30 16:18:00 +08:00
|
|
|
|
let isMounted = true;
|
2026-01-26 21:58:14 +08:00
|
|
|
|
if (isOpen) {
|
2026-01-28 15:41:16 +08:00
|
|
|
|
setTimeout(() => setAnimate(true), 50);
|
2026-01-30 16:18:00 +08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-26 21:58:14 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
setAnimate(false);
|
2026-01-28 15:41:16 +08:00
|
|
|
|
setShowRules(false); // Reset view on close
|
2026-01-26 21:58:14 +08:00
|
|
|
|
}
|
2026-01-30 16:18:00 +08:00
|
|
|
|
return () => { isMounted = false; };
|
|
|
|
|
|
}, [isOpen, aiAdvice, loadingAdvice, score, totalAssets, totalLiabilities, metrics, tips]);
|
2026-01-30 16:10:40 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-01-26 21:58:14 +08:00
|
|
|
|
if (!isOpen) return null;
|
|
|
|
|
|
|
2026-01-28 15:41:16 +08:00
|
|
|
|
// Status Levels
|
2026-01-26 21:58:14 +08:00
|
|
|
|
const getLevel = (s: number) => {
|
2026-01-28 15:41:16 +08:00
|
|
|
|
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: '建议立即调整开支' };
|
2026-01-26 21:58:14 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const level = getLevel(score);
|
|
|
|
|
|
|
2026-01-30 16:18:00 +08:00
|
|
|
|
|
2026-01-28 15:41:16 +08:00
|
|
|
|
|
|
|
|
|
|
// --- Render Helpers ---
|
|
|
|
|
|
|
2026-01-26 21:58:14 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="health-modal-overlay" onClick={onClose}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`health-modal-content ${animate ? 'animate-in' : ''}`}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
{/* 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>
|
2026-01-26 21:58:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<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" />
|
2026-01-30 16:03:50 +08:00
|
|
|
|
评分细则
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rule-item">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<div className="rule-score rule-score--blue">{breakdown.solvency}/40</div>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="rule-desc-group">
|
2026-01-30 16:10:40 +08:00
|
|
|
|
<h4>财务结构 (40分)</h4>
|
|
|
|
|
|
<p>由“负债率”与“应急流动性”共同决定。不仅关注负债水平,更看重现金流能否支撑至少3-6个月的生活开支。<br />这是抵御风险的基石。</p>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
2026-01-26 21:58:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="rule-item">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<div className="rule-score rule-score--green">{breakdown.budget}/30</div>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="rule-desc-group">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<h4>收支控制 (30分)</h4>
|
|
|
|
|
|
<p>考察预算执行率与时间进度的关系。月中花费50%是健康的,月初花费50%则会扣分。<br />{breakdown.budget === 30 ? '当前预算控制极佳。' : '需注意平滑消费曲线。'}</p>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rule-item">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<div className="rule-score rule-score--orange">{breakdown.habit}/20</div>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="rule-desc-group">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<h4>记账习惯 (20分)</h4>
|
|
|
|
|
|
<p>连续记账天数(Streak)奖励。坚持越久得分越高。<br />当前连续: {streakDays} 天</p>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rule-item">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<div className="rule-score rule-score--purple">{breakdown.activity}/10</div>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="rule-desc-group">
|
2026-01-30 16:03:50 +08:00
|
|
|
|
<h4>活跃度 (10分)</h4>
|
|
|
|
|
|
<p>近48小时内是否有记账行为。保持关注有助于及时发现财务风险。</p>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
2026-01-26 21:58:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
// --- 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>
|
2026-01-26 21:58:14 +08:00
|
|
|
|
|
2026-01-30 16:10:40 +08:00
|
|
|
|
{/* Survival Time Card (Replacing Spend) */}
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="health-metric-card">
|
2026-01-30 16:10:40 +08:00
|
|
|
|
<div className="metric-icon-wrapper" style={{ background: 'rgba(16, 185, 129, 0.15)' }}>
|
|
|
|
|
|
<Icon icon="solar:shield-check-bold-duotone" width="20" color="#10b981" />
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
2026-01-30 16:10:40 +08:00
|
|
|
|
<span className="metric-title">生存期</span>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="metric-value-group">
|
2026-01-30 16:10:40 +08:00
|
|
|
|
<span className="metric-number">
|
|
|
|
|
|
{metrics?.survivalMonths > 99 ? '>99' : (metrics?.survivalMonths || 0).toFixed(1)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ fontSize: '1rem', fontWeight: 600, marginLeft: '2px' }}>个月</span>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
2026-01-30 16:10:40 +08:00
|
|
|
|
<span className="metric-subtitle" style={{ color: (metrics?.survivalMonths || 0) < 3 ? '#ef4444' : '#10b981' }}>
|
|
|
|
|
|
{(metrics?.survivalMonths || 0) >= 6 ? '资金充裕' : (metrics?.survivalMonths || 0) >= 3 ? '健康' : '急需储备'}
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</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>
|
2026-01-30 16:10:40 +08:00
|
|
|
|
<div className="suggestion-content">
|
2026-01-30 16:18:00 +08:00
|
|
|
|
{/* 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>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
)}
|
2026-01-30 16:10:40 +08:00
|
|
|
|
</div>
|
2026-01-28 15:41:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-01-26 21:58:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-28 15:41:16 +08:00
|
|
|
|
<div className="health-actions-bottom">
|
|
|
|
|
|
<button className="health-confirm-btn" onClick={onClose}>
|
|
|
|
|
|
{showRules ? '返回' : '明白了'}
|
2026-01-26 21:58:14 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|