Files
DDUp/docs/plans/2026-01-27-health-module-implementation.md
liweiliang0905@gmail.com 295bc6e736 docs: 添加健康模块增强实现计划
包含 6 个任务的详细实现步骤:
- Task 1-3: 新增 BMI/营养/睡眠分析 API
- Task 4-6: 运动/饮食/睡眠页面 UI 增强

采用 TDD 方式,每个步骤包含完整代码和测试命令

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:05:04 +08:00

40 KiB

健康模块增强实现计划

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 为运动、饮食、睡眠三个模块添加健康分析功能,包括 BMI 分析、营养建议区间、睡眠预警提示。

Architecture:

  • 新增 3 个 API 端点提供分析数据
  • 修改 3 个页面 HTML 模板添加新 UI 组件
  • 复用现有 UserConfig.tdeeUser.bmi 计算逻辑

Tech Stack: FastAPI, Chart.js, 原生 HTML/CSS/JS


任务概览

任务 模块 预估步骤
Task 1 API: BMI 分析端点 5 步
Task 2 API: 营养建议端点 5 步
Task 3 API: 睡眠评估端点 5 步
Task 4 UI: 运动页面 BMI 卡片 5 步
Task 5 UI: 饮食页面营养建议 5 步
Task 6 UI: 睡眠页面预警卡片 5 步

Task 1: API - BMI 分析端点

Files:

  • Modify: src/vitals/web/app.py:1163 (在 /api/exercises/stats 后添加)
  • Test: tests/test_web.py

Step 1: 写失败测试

tests/test_web.py 末尾添加:

def test_bmi_analysis_endpoint(client):
    """测试 BMI 分析 API"""
    response = client.get("/api/bmi/analysis")
    assert response.status_code == 200
    data = response.json()
    assert "current_weight" in data
    assert "current_bmi" in data
    assert "bmi_status" in data
    assert "target_weight" in data
    assert "estimated_days" in data

Step 2: 运行测试确认失败

cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_bmi_analysis_endpoint -v

预期: FAIL - 404 Not Found

Step 3: 实现 API 端点

src/vitals/web/app.py 第 1163 行后添加:

@app.get("/api/bmi/analysis")
async def get_bmi_analysis():
    """获取 BMI 分析数据"""
    active_user = db.get_active_user()
    if not active_user:
        raise HTTPException(status_code=400, detail="没有激活的用户")

    config = db.get_config(active_user.id)

    # 获取最新体重记录
    today = date.today()
    weight_records = db.get_weight_records(
        start_date=today - timedelta(days=30),
        end_date=today,
        user_id=active_user.id
    )
    current_weight = weight_records[0].weight_kg if weight_records else config.weight

    # 计算 BMI
    height_m = (config.height or 170) / 100
    current_bmi = round(current_weight / (height_m * height_m), 1) if current_weight else None

    # BMI 状态
    def get_bmi_status(bmi):
        if bmi is None:
            return None
        if bmi < 18.5:
            return {"status": "偏瘦", "color": "#60A5FA", "range": "<18.5"}
        elif bmi < 24:
            return {"status": "正常", "color": "#34D399", "range": "18.5-24"}
        elif bmi < 28:
            return {"status": "偏重", "color": "#FBBF24", "range": "24-28"}
        else:
            return {"status": "肥胖", "color": "#F87171", "range": ">28"}

    bmi_info = get_bmi_status(current_bmi)

    # 目标体重(从用户配置或默认 BMI 22 计算)
    target_weight = getattr(config, 'target_weight', None)
    if not target_weight:
        # 默认目标 BMI 22
        target_weight = round(22 * height_m * height_m, 1)

    target_bmi = round(target_weight / (height_m * height_m), 1)
    weight_diff = round(current_weight - target_weight, 1) if current_weight else None

    # 计算预估达成天数
    estimated_days = None
    daily_deficit = None
    calculation_basis = None

    if current_weight and target_weight and config.tdee:
        # 获取近30天饮食数据
        meals = db.get_meals(
            start_date=today - timedelta(days=30),
            end_date=today,
            user_id=active_user.id
        )
        # 获取近30天运动数据
        exercises = db.get_exercises(
            start_date=today - timedelta(days=30),
            end_date=today,
            user_id=active_user.id
        )

        # 计算平均每日摄入
        total_intake = sum(m.calories for m in meals)
        days_with_data = len(set(m.date for m in meals)) or 1
        avg_daily_intake = total_intake / days_with_data

        # 计算平均每日运动消耗
        total_exercise = sum(e.calories for e in exercises)
        avg_daily_exercise = total_exercise / 30

        # 每日净消耗 = TDEE - 摄入 + 运动
        daily_deficit = round(config.tdee - avg_daily_intake + avg_daily_exercise)

        if daily_deficit > 0 and weight_diff > 0:
            # 7700 卡 ≈ 1 kg
            estimated_days = round(weight_diff * 7700 / daily_deficit)
            calculation_basis = {
                "tdee": config.tdee,
                "avg_intake": round(avg_daily_intake),
                "avg_exercise": round(avg_daily_exercise),
                "daily_deficit": daily_deficit
            }

    return {
        "current_weight": current_weight,
        "current_bmi": current_bmi,
        "bmi_status": bmi_info,
        "height": config.height,
        "target_weight": target_weight,
        "target_bmi": target_bmi,
        "weight_diff": weight_diff,
        "estimated_days": estimated_days,
        "calculation_basis": calculation_basis,
        "has_sufficient_data": bool(calculation_basis)
    }

Step 4: 运行测试确认通过

cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_bmi_analysis_endpoint -v

预期: PASS

Step 5: 提交

git add src/vitals/web/app.py tests/test_web.py
git commit -m "feat(api): 添加 BMI 分析端点 /api/bmi/analysis"

Task 2: API - 营养建议端点

Files:

  • Modify: src/vitals/web/app.py (在 BMI API 后添加)
  • Test: tests/test_web.py

Step 1: 写失败测试

def test_nutrition_recommendations_endpoint(client):
    """测试营养建议 API"""
    response = client.get("/api/nutrition/recommendations")
    assert response.status_code == 200
    data = response.json()
    assert "daily_targets" in data
    assert "today_intake" in data
    assert "gaps" in data
    assert "suggestions" in data

Step 2: 运行测试确认失败

cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_nutrition_recommendations_endpoint -v

预期: FAIL - 404 Not Found

Step 3: 实现 API 端点

@app.get("/api/nutrition/recommendations")
async def get_nutrition_recommendations():
    """获取营养建议数据"""
    active_user = db.get_active_user()
    if not active_user:
        raise HTTPException(status_code=400, detail="没有激活的用户")

    config = db.get_config(active_user.id)
    tdee = config.tdee or 1800

    # 根据目标调整热量目标
    goal_multipliers = {
        "lose": 0.8,      # 减重: 80% TDEE
        "maintain": 1.0,  # 维持: 100% TDEE
        "gain": 1.15      # 增重: 115% TDEE
    }
    calorie_target = int(tdee * goal_multipliers.get(config.goal, 1.0))

    # 营养素目标 (基于 TDEE)
    # 蛋白质 25%, 碳水 50%, 脂肪 25%
    daily_targets = {
        "calories": calorie_target,
        "protein": round(calorie_target * 0.25 / 4),   # 每克蛋白质 4 卡
        "carbs": round(calorie_target * 0.50 / 4),     # 每克碳水 4 卡
        "fat": round(calorie_target * 0.25 / 9),       # 每克脂肪 9 卡
    }

    # 建议区间
    recommended_ranges = {
        "protein": {"min": round(calorie_target * 0.20 / 4), "max": round(calorie_target * 0.30 / 4)},
        "carbs": {"min": round(calorie_target * 0.45 / 4), "max": round(calorie_target * 0.55 / 4)},
        "fat": {"min": round(calorie_target * 0.20 / 9), "max": round(calorie_target * 0.30 / 9)},
    }

    # 今日摄入
    today = date.today()
    today_meals = db.get_meals(start_date=today, end_date=today, user_id=active_user.id)

    today_intake = {
        "calories": sum(m.calories for m in today_meals),
        "protein": sum(m.protein or 0 for m in today_meals),
        "carbs": sum(m.carbs or 0 for m in today_meals),
        "fat": sum(m.fat or 0 for m in today_meals),
    }

    # 计算差距
    gaps = {
        "calories": daily_targets["calories"] - today_intake["calories"],
        "protein": daily_targets["protein"] - today_intake["protein"],
        "carbs": daily_targets["carbs"] - today_intake["carbs"],
        "fat": daily_targets["fat"] - today_intake["fat"],
    }

    # 餐次分布
    meal_type_calories = {}
    for m in today_meals:
        meal_type_calories[m.meal_type] = meal_type_calories.get(m.meal_type, 0) + m.calories

    meal_distribution = {
        "早餐": {"actual": meal_type_calories.get("早餐", 0), "recommended_pct": "25-30%"},
        "午餐": {"actual": meal_type_calories.get("午餐", 0), "recommended_pct": "35-40%"},
        "晚餐": {"actual": meal_type_calories.get("晚餐", 0), "recommended_pct": "25-30%"},
        "加餐": {"actual": meal_type_calories.get("加餐", 0), "recommended_pct": "0-10%"},
    }

    # 生成建议
    suggestions = []
    if gaps["protein"] > 20:
        suggestions.append({
            "type": "protein",
            "message": f"蛋白质缺口较大 ({gaps['protein']}g),建议增加:",
            "options": [
                "鸡胸肉 150g (+46g 蛋白质, 165 卡)",
                "鸡蛋 3 个 (+18g 蛋白质, 210 卡)",
                "豆腐 200g (+16g 蛋白质, 160 卡)",
            ]
        })
    if gaps["calories"] > 300:
        suggestions.append({
            "type": "calories",
            "message": f"热量摄入不足 ({gaps['calories']} 卡),注意补充能量",
            "options": []
        })

    # 近 7 天趋势
    week_start = today - timedelta(days=6)
    week_meals = db.get_meals(start_date=week_start, end_date=today, user_id=active_user.id)

    weekly_trend = []
    for i in range(7):
        day = week_start + timedelta(days=i)
        day_meals = [m for m in week_meals if m.date == day]
        weekly_trend.append({
            "date": day.isoformat(),
            "calories": sum(m.calories for m in day_meals),
            "protein": sum(m.protein or 0 for m in day_meals),
            "carbs": sum(m.carbs or 0 for m in day_meals),
            "fat": sum(m.fat or 0 for m in day_meals),
        })

    return {
        "tdee": tdee,
        "goal": config.goal,
        "daily_targets": daily_targets,
        "recommended_ranges": recommended_ranges,
        "today_intake": today_intake,
        "gaps": gaps,
        "meal_distribution": meal_distribution,
        "suggestions": suggestions,
        "weekly_trend": weekly_trend,
    }

Step 4: 运行测试确认通过

cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_nutrition_recommendations_endpoint -v

预期: PASS

Step 5: 提交

git add src/vitals/web/app.py tests/test_web.py
git commit -m "feat(api): 添加营养建议端点 /api/nutrition/recommendations"

Task 3: API - 睡眠评估端点

Files:

  • Modify: src/vitals/web/app.py (在营养 API 后添加)
  • Test: tests/test_web.py

Step 1: 写失败测试

def test_sleep_assessment_endpoint(client):
    """测试睡眠评估 API"""
    response = client.get("/api/sleep/assessment")
    assert response.status_code == 200
    data = response.json()
    assert "avg_duration" in data
    assert "status" in data
    assert "health_impacts" in data
    assert "ideal_days_count" in data

Step 2: 运行测试确认失败

cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_sleep_assessment_endpoint -v

预期: FAIL - 404 Not Found

Step 3: 实现 API 端点

@app.get("/api/sleep/assessment")
async def get_sleep_assessment():
    """获取睡眠评估数据"""
    active_user = db.get_active_user()
    if not active_user:
        raise HTTPException(status_code=400, detail="没有激活的用户")

    today = date.today()
    week_start = today - timedelta(days=6)
    month_start = today - timedelta(days=29)

    # 获取近 7 天和近 30 天睡眠数据
    week_records = db.get_sleep_records(start_date=week_start, end_date=today, user_id=active_user.id)
    month_records = db.get_sleep_records(start_date=month_start, end_date=today, user_id=active_user.id)

    # 计算平均睡眠时长
    week_durations = [r.duration for r in week_records]
    month_durations = [r.duration for r in month_records]

    avg_week = sum(week_durations) / len(week_durations) if week_durations else 0
    avg_month = sum(month_durations) / len(month_durations) if month_durations else 0

    # 判断睡眠状态
    def get_sleep_status(avg_hours):
        if avg_hours < 5:
            return {
                "level": "severe",
                "label": "严重不足",
                "color": "#EF4444",
                "icon": "alert-circle"
            }
        elif avg_hours < 6:
            return {
                "level": "insufficient",
                "label": "睡眠不足",
                "color": "#F59E0B",
                "icon": "alert-triangle"
            }
        elif avg_hours <= 9:
            return {
                "level": "ideal",
                "label": "睡眠充足",
                "color": "#10B981",
                "icon": "check-circle"
            }
        else:
            return {
                "level": "excessive",
                "label": "睡眠过多",
                "color": "#3B82F6",
                "icon": "info"
            }

    status = get_sleep_status(avg_week)

    # 统计理想天数
    ideal_days = sum(1 for d in week_durations if 7 <= d <= 9)
    insufficient_days = sum(1 for d in week_durations if d < 7)

    month_ideal_days = sum(1 for d in month_durations if 7 <= d <= 9)
    month_insufficient_days = sum(1 for d in month_durations if d < 7)

    # 健康影响信息
    warning_impacts = {
        "cognitive": {
            "title": "认知能力",
            "effects": ["注意力下降 40%", "记忆力减退", "决策能力受损"]
        },
        "physical": {
            "title": "身体健康",
            "effects": ["免疫力下降", "肥胖风险增加 33%", "心血管疾病风险上升"]
        },
        "emotional": {
            "title": "情绪状态",
            "effects": ["焦虑抑郁风险增加", "情绪波动大", "压力耐受力降低"]
        },
        "exercise": {
            "title": "运动表现",
            "effects": ["反应速度下降", "肌肉恢复减慢", "受伤风险增加"]
        }
    }

    benefit_impacts = {
        "cognitive": {
            "title": "认知提升",
            "effects": ["记忆巩固增强", "专注力提高", "创造力活跃"]
        },
        "physical": {
            "title": "身体修复",
            "effects": ["免疫系统强化", "肌肉组织修复", "激素分泌平衡"]
        },
        "emotional": {
            "title": "情绪稳定",
            "effects": ["情绪调节能力增强", "压力抵抗力提升", "心态积极"]
        },
        "exercise": {
            "title": "运动增益",
            "effects": ["运动表现提升 15%", "恢复速度加快", "耐力增强"]
        }
    }

    health_impacts = warning_impacts if status["level"] in ["severe", "insufficient"] else benefit_impacts

    # 建议
    if status["level"] == "severe":
        suggestion = "建议立即调整作息,每天提前 1 小时入睡"
    elif status["level"] == "insufficient":
        suggestion = "尝试提前 30 分钟入睡,逐步调整作息"
    elif status["level"] == "ideal":
        suggestion = "继续保持,规律作息是健康的基石!"
    else:
        suggestion = "睡眠时间过长也可能影响健康,建议控制在 7-9 小时"

    # 距理想的差距
    ideal_target = 7.5
    gap_hours = round(ideal_target - avg_week, 1) if avg_week < 7 else 0

    return {
        "avg_duration": round(avg_week, 1),
        "avg_duration_month": round(avg_month, 1),
        "status": status,
        "gap_hours": gap_hours,
        "ideal_days_count": ideal_days,
        "insufficient_days_count": insufficient_days,
        "total_days": len(week_durations),
        "month_stats": {
            "ideal_days": month_ideal_days,
            "insufficient_days": month_insufficient_days,
            "total_days": len(month_durations)
        },
        "health_impacts": health_impacts,
        "suggestion": suggestion,
        "thresholds": {
            "severe": 5,
            "insufficient": 6,
            "ideal_min": 7,
            "ideal_max": 9
        }
    }

Step 4: 运行测试确认通过

cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_sleep_assessment_endpoint -v

预期: PASS

Step 5: 提交

git add src/vitals/web/app.py tests/test_web.py
git commit -m "feat(api): 添加睡眠评估端点 /api/sleep/assessment"

Task 4: UI - 运动页面 BMI 分析卡片

Files:

  • Modify: src/vitals/web/app.py:4104 (get_exercise_page_html 函数)

Step 1: 定位插入位置

get_exercise_page_html() 函数中,找到统计卡片 grid 结束位置(约第 4404 行 </div> 之后),插入 BMI 分析卡片。

Step 2: 添加 BMI 分析卡片 HTML

在第 4404 行后插入:

        <!-- BMI 健康分析卡片 -->
        <div class="chart-container" id="bmi-analysis-card" style="background: linear-gradient(135deg, rgba(16,185,129,0.1) 0%, rgba(59,130,246,0.1) 100%); border: 1px solid rgba(16,185,129,0.2);">
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
                <h3 style="margin: 0;">BMI 健康分析</h3>
                <a href="/settings" style="color: #60A5FA; font-size: 14px; text-decoration: none;">设置目标 →</a>
            </div>
            <div id="bmi-content">
                <div class="empty">加载中...</div>
            </div>
        </div>

Step 3: 添加 JavaScript 加载逻辑

loadExerciseStats() 函数后添加 loadBmiAnalysis() 函数:

        async function loadBmiAnalysis() {
            try {
                const res = await fetch('/api/bmi/analysis');
                const data = await res.json();
                const container = document.getElementById('bmi-content');

                if (!data.current_bmi) {
                    container.innerHTML = `
                        <div style="text-align: center; padding: 20px; color: #94A3B8;">
                            <p style="margin-bottom: 12px;">⚠️ 数据不足</p>
                            <p style="font-size: 14px;">请完善身高、体重信息以启用 BMI 分析</p>
                            <a href="/settings" style="display: inline-block; margin-top: 12px; padding: 8px 16px; background: #3B82F6; color: white; border-radius: 6px; text-decoration: none;">去设置</a>
                        </div>
                    `;
                    return;
                }

                const statusColor = data.bmi_status?.color || '#94A3B8';
                const bmiPercent = Math.min(Math.max((data.current_bmi - 15) / 20 * 100, 0), 100);

                container.innerHTML = `
                    <div class="grid" style="grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px;">
                        <div style="text-align: center; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 8px;">
                            <div style="font-size: 12px; color: #94A3B8;">当前体重</div>
                            <div style="font-size: 24px; font-weight: 600; color: white;">${data.current_weight || '--'}<span style="font-size: 14px; color: #94A3B8;"> kg</span></div>
                        </div>
                        <div style="text-align: center; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 8px;">
                            <div style="font-size: 12px; color: #94A3B8;">当前 BMI</div>
                            <div style="font-size: 24px; font-weight: 600; color: white;">${data.current_bmi || '--'}</div>
                        </div>
                        <div style="text-align: center; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 8px;">
                            <div style="font-size: 12px; color: #94A3B8;">BMI 状态</div>
                            <div style="font-size: 18px; font-weight: 600; color: ${statusColor};">● ${data.bmi_status?.status || '--'}</div>
                            <div style="font-size: 11px; color: #64748B;">(${data.bmi_status?.range || ''})</div>
                        </div>
                    </div>

                    <!-- BMI 刻度条 -->
                    <div style="margin-bottom: 20px;">
                        <div style="display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 4px;">
                            <div style="flex: 18.5; background: #60A5FA;"></div>
                            <div style="flex: 5.5; background: #34D399;"></div>
                            <div style="flex: 4; background: #FBBF24;"></div>
                            <div style="flex: 7; background: #F87171;"></div>
                        </div>
                        <div style="position: relative; height: 12px;">
                            <div style="position: absolute; left: ${bmiPercent}%; transform: translateX(-50%); color: white; font-size: 10px;">▲</div>
                        </div>
                        <div style="display: flex; font-size: 10px; color: #64748B;">
                            <div style="flex: 18.5; text-align: center;">偏瘦 &lt;18.5</div>
                            <div style="flex: 5.5; text-align: center;">正常</div>
                            <div style="flex: 4; text-align: center;">偏重</div>
                            <div style="flex: 7; text-align: center;">肥胖 &gt;28</div>
                        </div>
                    </div>

                    <!-- 目标达成预估 -->
                    <div style="background: rgba(255,255,255,0.05); border-radius: 8px; padding: 16px;">
                        <div style="font-size: 14px; font-weight: 500; color: #E2E8F0; margin-bottom: 12px;">🎯 目标达成预估</div>
                        <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 13px; color: #CBD5E1;">
                            <div>目标体重: <span style="color: white; font-weight: 500;">${data.target_weight} kg</span></div>
                            <div>目标 BMI: <span style="color: #34D399; font-weight: 500;">${data.target_bmi} (正常)</span></div>
                            <div>距离目标: <span style="color: ${data.weight_diff > 0 ? '#FBBF24' : '#34D399'}; font-weight: 500;">${data.weight_diff > 0 ? '-' : '+'}${Math.abs(data.weight_diff)} kg</span></div>
                            <div>预估达成: <span style="color: white; font-weight: 500;">${data.estimated_days ? '约 ' + data.estimated_days + ' 天' : '数据不足'}</span></div>
                        </div>
                        ${data.calculation_basis ? `
                        <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.1); font-size: 11px; color: #64748B;">
                            计算依据: 近30天平均每日净消耗 ${data.calculation_basis.daily_deficit} 卡<br>
                            (TDEE ${data.calculation_basis.tdee} - 摄入 ${data.calculation_basis.avg_intake} + 运动 ${data.calculation_basis.avg_exercise})
                        </div>
                        ` : ''}
                    </div>

                    <div style="margin-top: 12px; font-size: 12px; color: #64748B; text-align: center;">
                        💡 健康减重建议每周 0.5-1 kg
                    </div>
                `;
            } catch (e) {
                console.error('加载 BMI 分析失败:', e);
            }
        }

Step 4: 在页面加载时调用

DOMContentLoaded 事件中添加调用:

        document.addEventListener('DOMContentLoaded', () => {
            loadExerciseStats();
            loadExerciseRecords();
            loadTodaySteps();
            loadBmiAnalysis();  // 添加这行
        });

Step 5: 提交

git add src/vitals/web/app.py
git commit -m "feat(ui): 运动页面添加 BMI 健康分析卡片"

Task 5: UI - 饮食页面营养建议

Files:

  • Modify: src/vitals/web/app.py:4745 (get_meal_page_html 函数)

Step 1: 添加今日营养状况卡片

在饮食页面统计卡片后(约第 5060 行)添加:

        <!-- 今日营养状况卡片 -->
        <div class="chart-container" id="nutrition-status-card">
            <h3>🍽 今日营养状况</h3>
            <div id="nutrition-status-content">
                <div class="empty">加载中...</div>
            </div>
        </div>

Step 2: 修改近 7 天趋势图

更新趋势图以包含参考线,在 Chart.js 配置中添加 annotation 插件支持。

Step 3: 添加 JavaScript 加载逻辑

        async function loadNutritionRecommendations() {
            try {
                const res = await fetch('/api/nutrition/recommendations');
                const data = await res.json();
                const container = document.getElementById('nutrition-status-content');

                const calcPercent = (actual, target) => Math.min(Math.round(actual / target * 100), 100);
                const getColor = (percent) => percent >= 80 ? '#10B981' : percent >= 50 ? '#F59E0B' : '#EF4444';

                const nutrients = [
                    { key: 'calories', label: '热量', unit: '卡', target: data.daily_targets.calories, actual: data.today_intake.calories },
                    { key: 'protein', label: '蛋白质', unit: 'g', target: data.daily_targets.protein, actual: data.today_intake.protein },
                    { key: 'carbs', label: '碳水', unit: 'g', target: data.daily_targets.carbs, actual: data.today_intake.carbs },
                    { key: 'fat', label: '脂肪', unit: 'g', target: data.daily_targets.fat, actual: data.today_intake.fat },
                ];

                container.innerHTML = `
                    <div class="grid" style="grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;">
                        ${nutrients.map(n => {
                            const pct = calcPercent(n.actual, n.target);
                            const color = getColor(pct);
                            return `
                            <div style="text-align: center; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 8px;">
                                <div style="font-size: 12px; color: #94A3B8;">${n.label}</div>
                                <div style="font-size: 20px; font-weight: 600; color: white;">${n.actual}</div>
                                <div style="font-size: 11px; color: #64748B;">/${n.target}${n.unit}</div>
                                <div style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin: 8px 0;">
                                    <div style="height: 100%; width: ${pct}%; background: ${color}; border-radius: 2px;"></div>
                                </div>
                                <div style="font-size: 11px; color: ${color};">${pct}%</div>
                            </div>
                            `;
                        }).join('')}
                    </div>

                    ${data.suggestions.length > 0 ? `
                    <div style="background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.3); border-radius: 8px; padding: 12px;">
                        <div style="font-size: 13px; font-weight: 500; color: #FBBF24; margin-bottom: 8px;">💡 晚餐建议</div>
                        ${data.suggestions.map(s => `
                            <div style="font-size: 12px; color: #E2E8F0; margin-bottom: 4px;">${s.message}</div>
                            ${s.options.length > 0 ? `
                            <ul style="margin: 4px 0 0 16px; padding: 0; font-size: 11px; color: #94A3B8;">
                                ${s.options.map(o => `<li style="margin: 2px 0;">${o}</li>`).join('')}
                            </ul>
                            ` : ''}
                        `).join('')}
                    </div>
                    ` : `
                    <div style="text-align: center; padding: 12px; color: #10B981; font-size: 13px;">
                        ✅ 今日营养摄入均衡
                    </div>
                    `}
                `;

                // 更新趋势图参考线
                updateIntakeChartWithReference(data);
            } catch (e) {
                console.error('加载营养建议失败:', e);
            }
        }

        function updateIntakeChartWithReference(data) {
            // 在 loadMealStats 中的 intake-chart 添加参考线
            const target = data.daily_targets.calories;
            window.nutritionTarget = target;
        }

Step 4: 修改趋势图添加参考线

在原有的 loadMealStats() 函数中修改 intake-chart 的配置:

                // 近 7 天趋势图 - 添加参考线
                const intakeCtx = document.getElementById('intake-chart').getContext('2d');
                const targetLine = window.nutritionTarget || 1800;
                new Chart(intakeCtx, {
                    type: 'line',
                    data: {
                        labels: last7Days.map(d => d.slice(5)),
                        datasets: [
                            {
                                label: '实际摄入',
                                data: dailyCalories,
                                borderColor: '#10B981',
                                backgroundColor: 'rgba(16, 185, 129, 0.1)',
                                fill: true,
                                tension: 0.3,
                            },
                            {
                                label: '建议摄入',
                                data: Array(7).fill(targetLine),
                                borderColor: '#F59E0B',
                                borderDash: [5, 5],
                                pointRadius: 0,
                                fill: false,
                            }
                        ]
                    },
                    options: {
                        responsive: true,
                        scales: { y: { beginAtZero: true } },
                        plugins: {
                            annotation: {
                                annotations: {
                                    targetZone: {
                                        type: 'box',
                                        yMin: targetLine * 0.9,
                                        yMax: targetLine * 1.1,
                                        backgroundColor: 'rgba(16, 185, 129, 0.1)',
                                        borderWidth: 0
                                    }
                                }
                            }
                        }
                    }
                });

Step 5: 提交

git add src/vitals/web/app.py
git commit -m "feat(ui): 饮食页面添加营养建议区间与配餐建议"

Task 6: UI - 睡眠页面预警卡片

Files:

  • Modify: src/vitals/web/app.py:5602 (get_sleep_page_html 函数)

Step 1: 添加睡眠评估卡片

在统计卡片后(约第 5835 行)添加:

        <!-- 睡眠健康评估卡片 -->
        <div id="sleep-assessment-card" class="chart-container">
            <h3>😴 睡眠健康评估</h3>
            <div id="sleep-assessment-content">
                <div class="empty">加载中...</div>
            </div>
        </div>

        <!-- 睡眠影响卡片(动态显示警告或益处) -->
        <div id="sleep-impact-card" class="chart-container" style="display: none;">
            <div id="sleep-impact-content"></div>
        </div>

Step 2: 添加 JavaScript 加载逻辑

        async function loadSleepAssessment() {
            try {
                const res = await fetch('/api/sleep/assessment');
                const data = await res.json();

                // 渲染评估卡片
                const assessmentContainer = document.getElementById('sleep-assessment-content');
                const statusColor = data.status.color;
                const avgPercent = Math.min(Math.max((data.avg_duration - 4) / 6 * 100, 0), 100);

                assessmentContainer.innerHTML = `
                    <div style="text-align: center; margin-bottom: 16px;">
                        <div style="font-size: 14px; color: #94A3B8;">近 7 天平均睡眠</div>
                        <div style="font-size: 36px; font-weight: 600; color: white;">${data.avg_duration}<span style="font-size: 18px; color: #94A3B8;"> 小时</span></div>
                    </div>

                    <!-- 睡眠刻度条 -->
                    <div style="margin-bottom: 16px;">
                        <div style="display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 4px;">
                            <div style="flex: 1; background: #EF4444;"></div>
                            <div style="flex: 1; background: #F59E0B;"></div>
                            <div style="flex: 2; background: #10B981;"></div>
                            <div style="flex: 1; background: #3B82F6;"></div>
                        </div>
                        <div style="position: relative; height: 12px;">
                            <div style="position: absolute; left: ${avgPercent}%; transform: translateX(-50%); color: ${statusColor}; font-size: 12px;">▲</div>
                        </div>
                        <div style="display: flex; font-size: 10px; color: #64748B;">
                            <div style="flex: 1; text-align: center;">偏少 &lt;5h</div>
                            <div style="flex: 1; text-align: center;">不足 5-6h</div>
                            <div style="flex: 2; text-align: center;">理想 7-9h</div>
                            <div style="flex: 1; text-align: center;">过多 &gt;9h</div>
                        </div>
                    </div>

                    <div style="display: flex; justify-content: space-between; align-items: center; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 8px;">
                        <div>
                            <span style="color: ${statusColor}; font-weight: 600;">● ${data.status.label}</span>
                            ${data.gap_hours > 0 ? `<span style="color: #94A3B8; font-size: 12px; margin-left: 8px;">距理想还差 ${data.gap_hours} 小时/天</span>` : ''}
                        </div>
                        <div style="font-size: 12px; color: #64748B;">
                            本周: 理想 ${data.ideal_days_count}/${data.total_days}                        </div>
                    </div>
                `;

                // 渲染影响卡片
                const impactCard = document.getElementById('sleep-impact-card');
                const impactContainer = document.getElementById('sleep-impact-content');

                const isWarning = data.status.level === 'severe' || data.status.level === 'insufficient';
                const bgColor = isWarning ? 'rgba(127, 29, 29, 0.2)' : 'rgba(6, 95, 70, 0.2)';
                const borderColor = isWarning ? 'rgba(239, 68, 68, 0.3)' : 'rgba(16, 185, 129, 0.3)';
                const titleColor = isWarning ? '#F87171' : '#34D399';
                const title = isWarning ? '⚠️ 睡眠不足警告' : '✅ 睡眠状态良好';
                const intro = isWarning
                    ? `您近 7 天平均睡眠仅 ${data.avg_duration} 小时,低于健康标准 7 小时。<br>长期睡眠不足会对身体造成以下影响:`
                    : `您近 7 天平均睡眠 ${data.avg_duration} 小时,处于健康范围内!<br>充足睡眠为您带来以下益处:`;

                impactCard.style.display = 'block';
                impactCard.style.background = bgColor;
                impactCard.style.border = `1px solid ${borderColor}`;

                const impacts = data.health_impacts;
                impactContainer.innerHTML = `
                    <h3 style="color: ${titleColor}; margin-bottom: 12px;">${title}</h3>
                    <p style="font-size: 13px; color: #CBD5E1; margin-bottom: 16px;">${intro}</p>

                    <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;">
                        ${Object.values(impacts).map(impact => `
                            <div style="background: rgba(255,255,255,0.05); border-radius: 8px; padding: 12px;">
                                <div style="font-size: 13px; font-weight: 500; color: #E2E8F0; margin-bottom: 8px;">${impact.title}</div>
                                <ul style="margin: 0; padding-left: 16px; font-size: 11px; color: #94A3B8;">
                                    ${impact.effects.map(e => `<li style="margin: 4px 0;">${e}</li>`).join('')}
                                </ul>
                            </div>
                        `).join('')}
                    </div>

                    <div style="margin-top: 16px; padding: 12px; background: rgba(255,255,255,0.05); border-radius: 8px; text-align: center;">
                        <span style="font-size: 13px; color: #E2E8F0;">💡 ${data.suggestion}</span>
                    </div>
                `;
            } catch (e) {
                console.error('加载睡眠评估失败:', e);
            }
        }

Step 3: 修改睡眠趋势图添加理想区间

在原有的 loadSleepData() 函数中修改 duration-chart 配置:

                // 添加理想睡眠区间背景
                new Chart(durationCtx, {
                    type: 'line',
                    data: {
                        labels: labels,
                        datasets: [
                            {
                                label: '睡眠时长(小时)',
                                data: durationData,
                                borderColor: '#6d28d9',
                                backgroundColor: 'rgba(109, 40, 217, 0.2)',
                                fill: true,
                                tension: 0.3,
                                pointBackgroundColor: durationData.map(d =>
                                    d >= 7 && d <= 9 ? '#10B981' : d < 6 ? '#EF4444' : '#F59E0B'
                                ),
                            }
                        ]
                    },
                    options: {
                        responsive: true,
                        scales: { y: { beginAtZero: true, max: 12 } },
                        plugins: {
                            annotation: {
                                annotations: {
                                    idealZone: {
                                        type: 'box',
                                        yMin: 7,
                                        yMax: 9,
                                        backgroundColor: 'rgba(16, 185, 129, 0.1)',
                                        borderWidth: 0,
                                        label: {
                                            content: '理想区间',
                                            display: true,
                                            position: 'start'
                                        }
                                    }
                                }
                            }
                        }
                    }
                });

Step 4: 在页面加载时调用

        document.addEventListener('DOMContentLoaded', () => {
            loadSleepData();
            loadSleepAssessment();  // 添加这行
        });

Step 5: 提交

git add src/vitals/web/app.py
git commit -m "feat(ui): 睡眠页面添加健康评估与预警卡片"

最终验收

功能测试

  1. 运动页面: 访问 /exercise,确认 BMI 分析卡片正确显示
  2. 饮食页面: 访问 /meal,确认营养状况卡片和趋势参考线
  3. 睡眠页面: 访问 /sleep,确认睡眠评估和警告/益处卡片

运行全部测试

cd /Users/rocky/Projects/vitals && python -m pytest tests/ -v

最终提交

git add .
git commit -m "feat: 完成健康模块增强 - BMI分析/营养建议/睡眠预警"