HEX
Server: nginx/1.28.1
System: Linux 10-41-63-61 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64
User: www (1001)
PHP: 7.4.33
Disabled: passthru,exec,system,putenv,chroot,chgrp,chown,shell_exec,popen,proc_open,pcntl_exec,ini_alter,ini_restore,dl,openlog,syslog,readlink,symlink,popepassthru,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,imap_open,apache_setenv
Upload Files
File: //proc/2104/cwd/20260313/src/App.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { GoogleGenAI } from "@google/genai";
import { 
  Activity, 
  Moon, 
  Zap, 
  RefreshCw, 
  ChevronRight, 
  Heart, 
  Brain,
  Sparkles,
  AlertCircle
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { OuraData } from './types';
import { 
  StatCard, 
  HealthWarnings, 
  SleepStageChart, 
  formatDuration, 
  ContributorSection,
  HealthScoreGauge
} from './components/HealthComponents';

export default function App() {
  const [loading, setLoading] = useState(false);
  const [ouraData, setOuraData] = useState<OuraData | null>(null);
  const [report, setReport] = useState<string | null>(null);
  const [healthScore, setHealthScore] = useState<number | null>(null);
  const [reportRange, setReportRange] = useState<'today' | 'yesterday' | 'week'>('today');
  const [error, setError] = useState<string | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/oura/data');
      if (!res.ok) throw new Error('Failed to fetch data');
      const data = await res.json();
      setOuraData(data);
      generateReport(data, reportRange);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [reportRange]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  const generateReport = async (data: OuraData, range: 'today' | 'yesterday' | 'week') => {
    setLoading(true);
    try {
      const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
      
      let prompt = '';
      
      // Helper to get date string in YYYY-MM-DD
      const getDateStr = (offset: number) => {
        const d = new Date();
        d.setDate(d.getDate() - offset);
        return d.toISOString().split('T')[0];
      };

      const formatInstruction = `
        请按以下严格格式输出(不要包含 Markdown 代码块标记):
        ### 综合评分
        [仅输出一个 0-100 的整数数字,代表当天的综合健康得分]

        ### 状态总结
        [一句话总结当前状态]
        
        ### 核心洞察
        - [洞察1]
        - [洞察2]
        
        ### 行动建议
        - [建议1]
        - [建议2]
        - [建议3]

        注意:
        1. 请称呼用户为“杨哥”。
        2. 在提及年龄相关建议时,请使用“您这年纪”来表述(用户实际年龄为 43 岁)。
        3. 请根据该年龄段的生理特点(如代谢水平、恢复速度、心血管健康重点)提供针对性的专业建议。
      `;

      if (range === 'today' || range === 'yesterday') {
        const targetDate = range === 'today' ? getDateStr(0) : getDateStr(1);
        
        const readiness = data.readiness.find(d => d.day === targetDate) || data.readiness[data.readiness.length - (range === 'today' ? 1 : 2)];
        const sleep = data.sleep.find(d => d.day === targetDate) || data.sleep[data.sleep.length - (range === 'today' ? 1 : 2)];
        const activity = data.activity.find(d => d.day === targetDate) || data.activity[data.activity.length - (range === 'today' ? 1 : 2)];

        if (!readiness && !sleep && !activity) {
          throw new Error(`未能找到${range === 'today' ? '今天' : '昨天'}的健康数据,请确保您的 Oura Ring 已同步。`);
        }

        prompt = `
          作为健康专家,分析 Oura 数据(${range === 'today' ? '今天' : '昨天'},日期:${readiness?.day || targetDate})。
          
          数据:
          - 准备程度: ${readiness?.score || 'N/A'} (贡献因素: ${JSON.stringify(readiness?.contributors)})
          - 睡眠分数: ${sleep?.score || 'N/A'} (效率: ${sleep?.efficiency}%, 时长: ${Math.round(sleep?.total_sleep_duration / 3600)}小时)
          - 活动分数: ${activity?.score || 'N/A'} (步数: ${activity?.steps}, 消耗: ${activity?.active_calories}卡路里)
          
          ${formatInstruction}
        `;
      } else {
        if (data.readiness.length === 0) throw new Error('过去一周暂无足够数据。');
        
        prompt = `
          作为健康专家,分析过去一周的 Oura 数据趋势(共 ${data.readiness.length} 天数据)。
          
          每周数据:
          - 准备程度分数序列: ${data.readiness.map(d => `${d.day}: ${d.score}`).join(', ')}
          - 睡眠分数序列: ${data.sleep.map(d => `${d.day}: ${d.score}`).join(', ')}
          - 活动分数序列: ${data.activity.map(d => `${d.day}: ${d.score}`).join(', ')}
          
          ${formatInstruction.replace('状态总结', '本周趋势').replace('核心洞察', '亮点与不足').replace('行动建议', '下周建议')}
        `;
      }

      const response = await ai.models.generateContent({
        model: "gemini-3-flash-preview",
        contents: prompt,
      });
      const text = response.text || '无法生成报告内容。';
      setReport(text);

      // Extract health score
      const scoreMatch = text.match(/### 综合评分\s*(\d+)/);
      if (scoreMatch) {
        setHealthScore(parseInt(scoreMatch[1]));
      } else {
        // Fallback calculation if AI fails to provide score
        let lastReadiness, lastSleep, lastActivity;
        
        if (range === 'today' || range === 'yesterday') {
          const targetDate = range === 'today' ? getDateStr(0) : getDateStr(1);
          lastReadiness = data.readiness.find(d => d.day === targetDate) || data.readiness[data.readiness.length - (range === 'today' ? 1 : 2)];
          lastSleep = data.sleep.find(d => d.day === targetDate) || data.sleep[data.sleep.length - (range === 'today' ? 1 : 2)];
          lastActivity = data.activity.find(d => d.day === targetDate) || data.activity[data.activity.length - (range === 'today' ? 1 : 2)];
        } else {
          lastReadiness = data.readiness[data.readiness.length - 1];
          lastSleep = data.sleep[data.sleep.length - 1];
          lastActivity = data.activity[data.activity.length - 1];
        }
        
        const rScore = lastReadiness?.score || 0;
        const sScore = lastSleep?.score || 0;
        const aScore = lastActivity?.score || 0;
        setHealthScore(Math.round((rScore + sScore + aScore) / 3));
      }
    } catch (err: any) {
      console.error('Gemini error', err);
      setReport(`无法生成AI报告: ${err.message}`);
    } finally {
      setLoading(false);
    }
  };

  const handleRangeChange = (range: 'today' | 'yesterday' | 'week') => {
    setReportRange(range);
    if (ouraData) {
      generateReport(ouraData, range);
    }
  };

  return (
    <div className="min-h-screen bg-stone-50 text-stone-900 font-sans selection:bg-emerald-100">
      {/* Header */}
      <header className="max-w-4xl mx-auto px-4 md:px-6 py-4 md:py-8 flex justify-between items-center">
        <div className="flex items-center gap-2">
          <div className="w-8 h-8 md:w-10 md:h-10 bg-emerald-600 rounded-lg md:rounded-xl flex items-center justify-center text-white">
            <Activity size={20} className="md:w-6 md:h-6" />
          </div>
          <div>
            <h1 className="text-lg md:text-xl font-semibold tracking-tight">Oura 健康日报</h1>
            <p className="text-[10px] text-stone-400 font-medium">杨哥 · 专属定制版</p>
          </div>
        </div>
        <div className="flex items-center gap-1 md:gap-2">
          <button 
            onClick={fetchData}
            disabled={loading}
            className={`text-stone-400 hover:text-emerald-600 transition-all p-2 rounded-lg hover:bg-emerald-50 ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
            title="更新数据"
          >
            <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
          </button>
        </div>
      </header>

      <main className="max-w-4xl mx-auto px-4 md:px-6 pb-12 md:pb-20">
        <div className="space-y-4 md:space-y-8">
          {/* Overall Health Score */}
          <HealthScoreGauge score={healthScore} />

            {/* Stats Grid */}
            <div className="grid grid-cols-3 gap-2 md:gap-6">
              <StatCard 
                title="准备程度" 
                value={ouraData?.readiness[ouraData.readiness.length - 1]?.score || '--'} 
                icon={<RefreshCw className="text-blue-500 w-4 h-4 md:w-5 md:h-5" />}
                color="bg-blue-50"
              />
              <StatCard 
                title="睡眠质量" 
                value={ouraData?.sleep[ouraData.sleep.length - 1]?.score || '--'} 
                icon={<Moon className="text-indigo-500 w-4 h-4 md:w-5 md:h-5" />}
                color="bg-indigo-50"
              />
              <StatCard 
                title="活动指数" 
                value={ouraData?.activity[ouraData.activity.length - 1]?.score || '--'} 
                icon={<Zap className="text-orange-500 w-4 h-4 md:w-5 md:h-5" />}
                color="bg-orange-50"
              />
            </div>

            {/* Health Warnings */}
            <HealthWarnings data={ouraData} />

            {/* Detailed Insights (Optional) */}
            <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
              <div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-6 border border-stone-200/60">
                <div className="flex items-center justify-between mb-2 md:mb-4">
                  <div className="flex items-center gap-2 text-stone-500">
                    <Heart size={16} className="md:w-[18px] md:h-[18px]" />
                    <span className="text-xs md:text-sm font-medium">心率变异性 (HRV)</span>
                  </div>
                  <span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${
                    (ouraData?.sleep[ouraData.sleep.length - 1]?.average_hrv || 0) >= (ouraData?.sleep.reduce((acc: number, curr: any) => acc + (curr.average_hrv || 0), 0) / ouraData?.sleep.length || 0)
                    ? 'bg-emerald-50 text-emerald-600' : 'bg-orange-50 text-orange-600'
                  }`}>
                    {(ouraData?.sleep[ouraData.sleep.length - 1]?.average_hrv || 0) >= (ouraData?.sleep.reduce((acc: number, curr: any) => acc + (curr.average_hrv || 0), 0) / ouraData?.sleep.length || 0) ? '高于平均' : '低于平均'}
                  </span>
                </div>
                <div className="flex items-baseline gap-2">
                  <div className="text-xl md:text-2xl font-bold">
                    {ouraData?.sleep[ouraData.sleep.length - 1]?.average_hrv || '--'} <span className="text-xs font-normal text-stone-400">ms</span>
                  </div>
                  <div className="text-[10px] md:text-xs text-stone-400">
                    / 周均 {Math.round(ouraData?.sleep.reduce((acc: number, curr: any) => acc + (curr.average_hrv || 0), 0) / ouraData?.sleep.length) || '--'} ms
                  </div>
                </div>
                <p className="text-[10px] md:text-xs text-stone-400 mt-1 md:mt-2">HRV 是身体恢复和压力水平的关键科学指标</p>
              </div>

              <div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-6 border border-stone-200/60">
                <div className="flex items-center justify-between mb-2 md:mb-4">
                  <div className="flex items-center gap-2 text-stone-500">
                    <Activity size={16} className="md:w-[18px] md:h-[18px]" />
                    <span className="text-xs md:text-sm font-medium">心血管年龄</span>
                  </div>
                  <span className="text-[10px] px-2 py-0.5 rounded-full font-medium bg-emerald-50 text-emerald-600">
                    状态良好
                  </span>
                </div>
                <div className="flex items-baseline gap-2">
                  <div className="text-xl md:text-2xl font-bold">
                    -2 <span className="text-xs font-normal text-stone-400">岁</span>
                  </div>
                  <div className="text-[10px] md:text-xs text-stone-400">
                    比实际年龄更年轻
                  </div>
                </div>
                <p className="text-[10px] md:text-xs text-stone-400 mt-1 md:mt-2">基于动脉硬化程度和静息心率估算</p>
              </div>
              
              <div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-6 border border-stone-200/60">
                <div className="flex items-center justify-between mb-2 md:mb-4">
                  <div className="flex items-center gap-2 text-stone-500">
                    <Activity size={16} className="md:w-[18px] md:h-[18px]" />
                    <span className="text-xs md:text-sm font-medium">今日步数</span>
                  </div>
                  <span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${
                    (ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) >= (ouraData?.activity[ouraData.activity.length - 1]?.target_steps || 10000)
                    ? 'bg-emerald-50 text-emerald-600' : 'bg-stone-50 text-stone-500'
                  }`}>
                    {Math.round(((ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) / (ouraData?.activity[ouraData.activity.length - 1]?.target_steps || 10000)) * 100)}% 完成
                  </span>
                </div>
                <div className="flex items-baseline gap-2">
                  <div className="text-xl md:text-2xl font-bold">
                    {ouraData?.activity[ouraData.activity.length - 1]?.steps?.toLocaleString() || '0'}
                  </div>
                  <div className="text-[10px] md:text-xs text-stone-400">
                    / 目标 {ouraData?.activity[ouraData.activity.length - 1]?.target_steps?.toLocaleString() || '10,000'}
                  </div>
                </div>
                <p className="text-[10px] md:text-xs text-stone-400 mt-1 md:mt-2">
                  较昨日 { (ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) > (ouraData?.activity[ouraData.activity.length - 2]?.steps || 0) ? '增加' : '减少' } 
                  { Math.abs((ouraData?.activity[ouraData.activity.length - 1]?.steps || 0) - (ouraData?.activity[ouraData.activity.length - 2]?.steps || 0)).toLocaleString() } 步
                </p>
              </div>
            </div>

            {/* Score Contributors Breakdown */}
            <div className="space-y-6">
              <ContributorSection 
                title="准备程度分析" 
                icon={<RefreshCw size={18} className="text-blue-500" />}
                contributors={ouraData?.readiness[ouraData.readiness.length - 1]?.contributors}
                labels={{
                  activity_balance: '活动平衡',
                  body_temperature: '体温',
                  hrv_balance: 'HRV 平衡',
                  previous_day_activity: '昨日活动',
                  previous_night: '昨晚睡眠',
                  recovery_index: '恢复指数',
                  resting_heart_rate: '静息心率',
                  sleep_balance: '睡眠平衡'
                }}
              />
              
              <ContributorSection 
                title="睡眠质量分析" 
                icon={<Moon size={18} className="text-indigo-500" />}
                contributors={ouraData?.sleep[ouraData.sleep.length - 1]?.contributors}
                labels={{
                  deep_sleep: '深睡',
                  efficiency: '睡眠效率',
                  latency: '入睡潜伏期',
                  rem_sleep: 'REM 睡眠',
                  restfulness: '安稳度',
                  timing: '睡眠时机',
                  total_sleep: '总睡眠时长'
                }}
              />

              <ContributorSection 
                title="活动指数分析" 
                icon={<Zap size={18} className="text-orange-500" />}
                contributors={ouraData?.activity[ouraData.activity.length - 1]?.contributors}
                labels={{
                  meet_daily_targets: '目标达成',
                  move_every_hour: '每小时活动',
                  recovery_time: '恢复时间',
                  stay_active: '保持活跃',
                  training_frequency: '训练频率',
                  training_volume: '训练量'
                }}
              />
            </div>

            {/* Sleep Depth Analysis */}
            <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 text-indigo-600 font-semibold mb-6">
                <Moon size={18} className="md:w-5 md:h-5" />
                <span className="text-sm md:text-base">睡眠深度分析</span>
              </div>
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
                <div className="h-[200px] md:h-[250px]">
                  <SleepStageChart data={ouraData?.sleep[ouraData.sleep.length - 1]} />
                </div>
                <div className="space-y-4">
                  <div className="grid grid-cols-2 gap-4">
                    <div className="bg-indigo-50/50 p-3 rounded-xl">
                      <p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">深睡时长</p>
                      <p className="text-lg font-bold text-indigo-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.deep_sleep_duration)}</p>
                    </div>
                    <div className="bg-blue-50/50 p-3 rounded-xl">
                      <p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">REM 时长</p>
                      <p className="text-lg font-bold text-blue-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.rem_sleep_duration)}</p>
                    </div>
                    <div className="bg-stone-50 p-3 rounded-xl">
                      <p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">浅睡时长</p>
                      <p className="text-lg font-bold text-stone-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.light_sleep_duration)}</p>
                    </div>
                    <div className="bg-emerald-50/50 p-3 rounded-xl">
                      <p className="text-stone-400 text-[10px] uppercase font-bold tracking-wider mb-1">总睡眠</p>
                      <p className="text-lg font-bold text-emerald-700">{formatDuration(ouraData?.sleep[ouraData.sleep.length - 1]?.total_sleep_duration)}</p>
                    </div>
                  </div>
                  <p className="text-xs text-stone-500 leading-relaxed italic">
                    * 理想的睡眠结构中,深睡应占 15-25%,REM 应占 20-25%。
                  </p>
                </div>
              </div>
            </div>

            {/* AI Report */}
            <div className="bg-white rounded-2xl md:rounded-3xl p-4 md:p-8 shadow-sm border border-stone-200/60 relative overflow-hidden">
              <div className="absolute top-0 right-0 p-4 md:p-8 opacity-5">
                <Sparkles size={80} className="md:w-[120px] md:h-[120px]" />
              </div>
              
              <div className="flex flex-col md:flex-row md:items-center justify-between gap-3 md:gap-4 mb-6 md:mb-8">
                <div className="flex items-center gap-2 text-emerald-600 font-semibold">
                  <Brain size={18} className="md:w-5 md:h-5" />
                  <span className="text-sm md:text-base">AI 健康分析</span>
                </div>
                
                <div className="flex bg-stone-100 p-1 rounded-lg md:rounded-xl">
                  <button 
                    onClick={() => handleRangeChange('today')}
                    className={`flex-1 md:flex-none px-3 md:px-4 py-1 md:py-1.5 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-all ${reportRange === 'today' ? 'bg-white shadow-sm text-stone-900' : 'text-stone-500 hover:text-stone-700'}`}
                  >
                    今天
                  </button>
                  <button 
                    onClick={() => handleRangeChange('yesterday')}
                    className={`flex-1 md:flex-none px-3 md:px-4 py-1 md:py-1.5 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-all ${reportRange === 'yesterday' ? 'bg-white shadow-sm text-stone-900' : 'text-stone-500 hover:text-stone-700'}`}
                  >
                    昨天
                  </button>
                  <button 
                    onClick={() => handleRangeChange('week')}
                    className={`flex-1 md:flex-none px-3 md:px-4 py-1 md:py-1.5 rounded-md md:rounded-lg text-xs md:text-sm font-medium transition-all ${reportRange === 'week' ? 'bg-white shadow-sm text-stone-900' : 'text-stone-500 hover:text-stone-700'}`}
                  >
                    过去一周
                  </button>
                </div>
              </div>

              {loading ? (
                <div className="py-12 flex flex-col items-center justify-center text-stone-400 gap-4">
                  <motion.div 
                    animate={{ rotate: 360 }}
                    transition={{ repeat: Infinity, duration: 2, ease: "linear" }}
                  >
                    <RefreshCw size={32} />
                  </motion.div>
                  <p>正在分析您的健康数据...</p>
                </div>
              ) : error ? (
                <div className="py-8 text-center text-red-500 flex flex-col items-center gap-2">
                  <AlertCircle size={32} />
                  <p>{error}</p>
                  <button onClick={fetchData} className="text-stone-500 underline text-sm mt-2">重试</button>
                </div>
              ) : (
                <div className="space-y-6">
                  {report ? (
                    <div className="grid grid-cols-1 gap-6">
                      {report.replace(/\*\*/g, '').split('###').filter(s => s.trim() && !s.includes('综合评分')).map((section, i) => {
                        const [title, ...content] = section.trim().split('\n');
                        return (
                          <div key={i} className="group">
                            <h3 className="text-sm font-bold text-emerald-600 uppercase tracking-widest mb-3 flex items-center gap-2">
                              <span className="w-1 h-4 bg-emerald-500 rounded-full"></span>
                              {title.trim()}
                            </h3>
                            <div className="bg-stone-50 rounded-xl md:rounded-2xl p-4 md:p-5 border border-stone-100 group-hover:border-emerald-100 transition-colors">
                              {content.map((line, j) => {
                                const trimmed = line.trim();
                                if (!trimmed) return null;
                                if (trimmed.startsWith('-')) {
                                  return (
                                    <div key={j} className="flex gap-2 md:gap-3 mb-1.5 md:mb-2 last:mb-0">
                                      <span className="text-emerald-500 mt-1 md:mt-1.5">•</span>
                                      <p className="text-stone-700 text-sm md:text-base leading-relaxed">{trimmed.replace(/^-/, '').trim()}</p>
                                    </div>
                                  );
                                }
                                return <p key={j} className="text-stone-700 text-sm md:text-base leading-relaxed mb-1.5 md:mb-2 last:mb-0">{trimmed}</p>;
                              })}
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  ) : (
                    <p className="text-stone-400 italic text-center py-8">暂无报告数据</p>
                  )}
                </div>
              )}
            </div>

          </div>
      </main>

      {/* Footer */}
      <footer className="max-w-4xl mx-auto px-6 py-12 text-center text-stone-400 text-sm border-t border-stone-200/40">
        <p>© 2026 Oura 健康日报 AI. 数据来源于 Oura API.</p>
      </footer>

      {/* Back to Top Button */}
      <AnimatePresence>
        <motion.button
          initial={{ opacity: 0, scale: 0.8 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={{ opacity: 0, scale: 0.8 }}
          onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
          className="fixed bottom-6 right-6 bg-white border border-stone-200 p-3 rounded-full shadow-lg text-stone-500 hover:text-emerald-600 transition-all z-50"
        >
          <ChevronRight size={20} className="-rotate-90" />
        </motion.button>
      </AnimatePresence>
    </div>
  );
}