File: //proc/2131/cwd/20260313/src/components/HealthComponents.tsx
import React from 'react';
import { motion } from 'motion/react';
import {
Heart,
Thermometer,
Wind,
RefreshCw
} from 'lucide-react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
import { OuraData } from '../types';
export function HealthScoreGauge({ score }: { score: number | null }) {
if (score === null) return null;
const getScoreColor = (s: number) => {
if (s >= 85) return 'text-emerald-500';
if (s >= 70) return 'text-blue-500';
if (s >= 50) return 'text-orange-500';
return 'text-red-500';
};
const getScoreBg = (s: number) => {
if (s >= 85) return 'bg-emerald-50';
if (s >= 70) return 'bg-blue-50';
if (s >= 50) return 'bg-orange-50';
return 'bg-red-50';
};
const getScoreLabel = (s: number) => {
if (s >= 85) return '极佳';
if (s >= 70) return '良好';
if (s >= 50) return '一般';
return '需注意';
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-2xl md:rounded-3xl p-6 md:p-8 shadow-sm border border-stone-200/60 flex flex-col md:flex-row items-center gap-6 md:gap-10"
>
<div className="relative w-32 h-32 md:w-40 md:h-40 flex items-center justify-center">
<svg className="w-full h-full -rotate-90">
<circle
cx="50%"
cy="50%"
r="45%"
className="fill-none stroke-stone-100 stroke-[8]"
/>
<motion.circle
cx="50%"
cy="50%"
r="45%"
initial={{ strokeDasharray: "0 1000" }}
animate={{ strokeDasharray: `${score * 2.82} 1000` }}
transition={{ duration: 1.5, ease: "easeOut" }}
className={`fill-none stroke-[8] stroke-linecap-round ${getScoreColor(score).replace('text-', 'stroke-')}`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl md:text-5xl font-black ${getScoreColor(score)}`}>{score}</span>
<span className="text-[10px] md:text-xs text-stone-400 font-bold uppercase tracking-widest">Health Score</span>
</div>
</div>
<div className="flex-1 text-center md:text-left">
<div className="flex items-center justify-center md:justify-start gap-2 mb-2">
<h2 className="text-xl md:text-2xl font-bold text-stone-800">今日综合健康指数</h2>
<span className={`px-2 py-0.5 rounded-full text-[10px] md:text-xs font-bold ${getScoreBg(score)} ${getScoreColor(score)}`}>
{getScoreLabel(score)}
</span>
</div>
<p className="text-sm md:text-base text-stone-500 leading-relaxed max-w-md">
基于您的准备程度、睡眠质量及活动强度综合计算。{score >= 85 ? '您现在的身体状态非常出色,适合迎接高强度挑战!' : score >= 70 ? '状态平稳,保持规律作息是维持健康的关键。' : '建议今天适当减压,关注身体发出的疲劳信号。'}
</p>
</div>
</motion.div>
);
}
export function StatCard({ title, value, icon, color }: { title: string, value: any, icon: React.ReactNode, color: string }) {
return (
<motion.div
whileHover={{ y: -4 }}
className="bg-white rounded-2xl md:rounded-3xl p-3 md:p-6 shadow-sm border border-stone-200/60 flex flex-col md:flex-row items-center md:justify-between text-center md:text-left gap-2 md:gap-0"
>
<div className="order-2 md:order-1">
<p className="text-stone-400 text-[10px] md:text-sm font-medium mb-0.5 md:mb-1">{title}</p>
<p className="text-lg md:text-3xl font-bold">{value}</p>
</div>
<div className={`w-8 h-8 md:w-12 md:h-12 ${color} rounded-lg md:rounded-2xl flex items-center justify-center order-1 md:order-2`}>
{icon}
</div>
</motion.div>
);
}
export function HealthWarnings({ data }: { data: OuraData | null }) {
if (!data) return null;
const lastReadiness = data.readiness[data.readiness.length - 1];
const lastSleep = data.sleep[data.sleep.length - 1];
const warnings = [];
// Temperature check
const tempDev = lastReadiness?.contributors?.temperature_deviation || 0;
if (tempDev > 0.5) {
warnings.push({
type: 'warning',
icon: <Thermometer className="text-orange-500" size={18} />,
title: '体温异常升高',
message: `体温偏离基准线 +${tempDev.toFixed(1)}°C,可能预示身体正在应对压力或潜在不适。`
});
}
// RHR check
const currentRHR = lastReadiness?.contributors?.resting_heart_rate || 0;
const avgRHR = data.readiness.reduce((acc, curr) => acc + (curr.contributors?.resting_heart_rate || 0), 0) / data.readiness.length;
if (currentRHR > avgRHR + 5) {
warnings.push({
type: 'info',
icon: <Heart className="text-red-500" size={18} />,
title: '静息心率偏高',
message: `当前 RHR (${currentRHR} bpm) 显著高于您的周平均水平 (${Math.round(avgRHR)} bpm),建议增加休息。`
});
}
// Respiratory Rate check
const respRate = lastSleep?.average_breath || 0;
const avgResp = data.sleep.reduce((acc, curr) => acc + (curr.average_breath || 0), 0) / data.sleep.length;
if (Math.abs(respRate - avgResp) > 1.5) {
warnings.push({
type: 'info',
icon: <Wind className="text-blue-500" size={18} />,
title: '呼吸频率波动',
message: `睡眠呼吸频率 (${respRate.toFixed(1)}) 出现波动,请留意睡眠环境或身体状态。`
});
}
if (warnings.length === 0) return null;
return (
<div className="space-y-3">
{warnings.map((w, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={`flex items-start gap-3 p-4 rounded-xl md:rounded-2xl border ${
w.type === 'warning' ? 'bg-orange-50 border-orange-100' : 'bg-blue-50 border-blue-100'
}`}
>
<div className="mt-0.5">{w.icon}</div>
<div>
<h4 className={`text-sm font-bold ${w.type === 'warning' ? 'text-orange-800' : 'text-blue-800'}`}>
{w.title}
</h4>
<p className={`text-xs mt-1 ${w.type === 'warning' ? 'text-orange-700' : 'text-blue-700'}`}>
{w.message}
</p>
</div>
</motion.div>
))}
</div>
);
}
export function SleepStageChart({ data }: { data: any }) {
if (!data) return null;
const chartData = [
{ name: '深睡', value: data.deep_sleep_duration || 0, color: '#4338ca' },
{ name: 'REM', value: data.rem_sleep_duration || 0, color: '#3b82f6' },
{ name: '浅睡', value: data.light_sleep_duration || 0, color: '#94a3b8' },
{ name: '清醒', value: data.awake_duration || 0, color: '#f1f5f9' },
];
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => formatDuration(value)}
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
/>
<Legend verticalAlign="bottom" height={36}/>
</PieChart>
</ResponsiveContainer>
);
}
export function formatDuration(seconds: number | undefined) {
if (!seconds) return '0h 0m';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
export function ContributorSection({ title, icon, contributors, labels }: { title: string, icon: React.ReactNode, contributors: any, labels: Record<string, string> }) {
if (!contributors) return null;
return (
<div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-8 shadow-sm border border-stone-200/60">
<div className="flex items-center gap-2 font-semibold mb-6">
{icon}
<span className="text-sm md:text-base">{title}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(labels).map(([key, label]) => {
const value = contributors[key];
if (value === undefined) return null;
return (
<div key={key} className="space-y-1.5">
<div className="flex justify-between items-center">
<span className="text-[10px] md:text-xs text-stone-400 font-medium">{label}</span>
<span className="text-[10px] md:text-xs font-bold text-stone-600">{value}</span>
</div>
<div className="h-1.5 bg-stone-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${value}%` }}
transition={{ duration: 1, ease: "easeOut" }}
className={`h-full rounded-full ${
value >= 85 ? 'bg-emerald-500' :
value >= 70 ? 'bg-blue-500' :
value >= 50 ? 'bg-orange-400' : 'bg-red-400'
}`}
/>
</div>
</div>
);
})}
</div>
</div>
);
}