包含 6 个任务的详细实现步骤: - Task 1-3: 新增 BMI/营养/睡眠分析 API - Task 4-6: 运动/饮食/睡眠页面 UI 增强 采用 TDD 方式,每个步骤包含完整代码和测试命令 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1020 lines
40 KiB
Markdown
1020 lines
40 KiB
Markdown
# 健康模块增强实现计划
|
|
|
|
> **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.tdee` 和 `User.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` 末尾添加:
|
|
|
|
```python
|
|
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: 运行测试确认失败
|
|
|
|
```bash
|
|
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 行后添加:
|
|
|
|
```python
|
|
@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: 运行测试确认通过
|
|
|
|
```bash
|
|
cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_bmi_analysis_endpoint -v
|
|
```
|
|
预期: PASS
|
|
|
|
### Step 5: 提交
|
|
|
|
```bash
|
|
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: 写失败测试
|
|
|
|
```python
|
|
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: 运行测试确认失败
|
|
|
|
```bash
|
|
cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_nutrition_recommendations_endpoint -v
|
|
```
|
|
预期: FAIL - 404 Not Found
|
|
|
|
### Step 3: 实现 API 端点
|
|
|
|
```python
|
|
@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: 运行测试确认通过
|
|
|
|
```bash
|
|
cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_nutrition_recommendations_endpoint -v
|
|
```
|
|
预期: PASS
|
|
|
|
### Step 5: 提交
|
|
|
|
```bash
|
|
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: 写失败测试
|
|
|
|
```python
|
|
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: 运行测试确认失败
|
|
|
|
```bash
|
|
cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_sleep_assessment_endpoint -v
|
|
```
|
|
预期: FAIL - 404 Not Found
|
|
|
|
### Step 3: 实现 API 端点
|
|
|
|
```python
|
|
@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: 运行测试确认通过
|
|
|
|
```bash
|
|
cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_sleep_assessment_endpoint -v
|
|
```
|
|
预期: PASS
|
|
|
|
### Step 5: 提交
|
|
|
|
```bash
|
|
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 行后插入:
|
|
|
|
```html
|
|
<!-- 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()` 函数:
|
|
|
|
```javascript
|
|
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;">偏瘦 <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;">肥胖 >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` 事件中添加调用:
|
|
|
|
```javascript
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadExerciseStats();
|
|
loadExerciseRecords();
|
|
loadTodaySteps();
|
|
loadBmiAnalysis(); // 添加这行
|
|
});
|
|
```
|
|
|
|
### Step 5: 提交
|
|
|
|
```bash
|
|
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 行)添加:
|
|
|
|
```html
|
|
<!-- 今日营养状况卡片 -->
|
|
<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 加载逻辑
|
|
|
|
```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 的配置:
|
|
|
|
```javascript
|
|
// 近 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: 提交
|
|
|
|
```bash
|
|
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 行)添加:
|
|
|
|
```html
|
|
<!-- 睡眠健康评估卡片 -->
|
|
<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 加载逻辑
|
|
|
|
```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;">偏少 <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;">过多 >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 配置:
|
|
|
|
```javascript
|
|
// 添加理想睡眠区间背景
|
|
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: 在页面加载时调用
|
|
|
|
```javascript
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadSleepData();
|
|
loadSleepAssessment(); // 添加这行
|
|
});
|
|
```
|
|
|
|
### Step 5: 提交
|
|
|
|
```bash
|
|
git add src/vitals/web/app.py
|
|
git commit -m "feat(ui): 睡眠页面添加健康评估与预警卡片"
|
|
```
|
|
|
|
---
|
|
|
|
## 最终验收
|
|
|
|
### 功能测试
|
|
|
|
1. **运动页面**: 访问 `/exercise`,确认 BMI 分析卡片正确显示
|
|
2. **饮食页面**: 访问 `/meal`,确认营养状况卡片和趋势参考线
|
|
3. **睡眠页面**: 访问 `/sleep`,确认睡眠评估和警告/益处卡片
|
|
|
|
### 运行全部测试
|
|
|
|
```bash
|
|
cd /Users/rocky/Projects/vitals && python -m pytest tests/ -v
|
|
```
|
|
|
|
### 最终提交
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "feat: 完成健康模块增强 - BMI分析/营养建议/睡眠预警"
|
|
```
|