From 842998893a8d523d233edd85d1b2da9113123f9c Mon Sep 17 00:00:00 2001 From: "liweiliang0905@gmail.com" Date: Tue, 27 Jan 2026 17:39:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E4=BD=93=E9=87=8D=E8=AE=BE=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserConfig 模型添加 target_weight 字段 - 新增 POST /api/config 端点用于更新用户配置 - 设置页面添加目标体重输入框及目标 BMI 自动计算 - BMI 分析使用用户设置的目标体重计算达成预估 - 修复测试 fixture 的 save_config 参数问题 Co-Authored-By: Claude Opus 4.5 --- src/vitals/core/database.py | 56 ++++++++++------ src/vitals/core/models.py | 2 + src/vitals/web/app.py | 124 ++++++++++++++++++++++++++++++++++++ tests/test_web.py | 15 ++++- 4 files changed, 178 insertions(+), 19 deletions(-) diff --git a/src/vitals/core/database.py b/src/vitals/core/database.py index 4d2f82d..66c98c9 100644 --- a/src/vitals/core/database.py +++ b/src/vitals/core/database.py @@ -287,10 +287,13 @@ def get_exercises(start_date: Optional[date] = None, end_date: Optional[date] = ] -def delete_exercise(exercise_id: int): - """删除运动记录""" +def delete_exercise(exercise_id: int, user_id: int = None): + """删除运动记录(需验证 user_id)""" with get_connection() as (conn, cursor): - cursor.execute("DELETE FROM exercise WHERE id = %s", (exercise_id,)) + if user_id: + cursor.execute("DELETE FROM exercise WHERE id = %s AND user_id = %s", (exercise_id, user_id)) + else: + cursor.execute("DELETE FROM exercise WHERE id = %s", (exercise_id,)) # ===== 步数记录 ===== @@ -420,10 +423,13 @@ def get_meals(start_date: Optional[date] = None, end_date: Optional[date] = None ] -def delete_meal(meal_id: int): - """删除饮食记录""" +def delete_meal(meal_id: int, user_id: int = None): + """删除饮食记录(需验证 user_id)""" with get_connection() as (conn, cursor): - cursor.execute("DELETE FROM meal WHERE id = %s", (meal_id,)) + if user_id: + cursor.execute("DELETE FROM meal WHERE id = %s AND user_id = %s", (meal_id, user_id)) + else: + cursor.execute("DELETE FROM meal WHERE id = %s", (meal_id,)) # ===== 睡眠记录 ===== @@ -483,10 +489,13 @@ def get_sleep_records(start_date: Optional[date] = None, end_date: Optional[date ] -def delete_sleep(sleep_id: int): - """删除睡眠记录""" +def delete_sleep(sleep_id: int, user_id: int = None): + """删除睡眠记录(需验证 user_id)""" with get_connection() as (conn, cursor): - cursor.execute("DELETE FROM sleep WHERE id = %s", (sleep_id,)) + if user_id: + cursor.execute("DELETE FROM sleep WHERE id = %s AND user_id = %s", (sleep_id, user_id)) + else: + cursor.execute("DELETE FROM sleep WHERE id = %s", (sleep_id,)) # ===== 体重记录 ===== @@ -546,10 +555,13 @@ def get_latest_weight() -> Optional[Weight]: return records[0] if records else None -def delete_weight(weight_id: int): - """删除体重记录""" +def delete_weight(weight_id: int, user_id: int = None): + """删除体重记录(需验证 user_id)""" with get_connection() as (conn, cursor): - cursor.execute("DELETE FROM weight WHERE id = %s", (weight_id,)) + if user_id: + cursor.execute("DELETE FROM weight WHERE id = %s AND user_id = %s", (weight_id, user_id)) + else: + cursor.execute("DELETE FROM weight WHERE id = %s", (weight_id,)) # ===== 用户配置 ===== @@ -590,6 +602,7 @@ def get_config(user_id: int = 1) -> UserConfig: weight=float(config_dict["weight"]) if config_dict.get("weight") else None, activity_level=config_dict.get("activity_level", "moderate"), goal=config_dict.get("goal", "maintain"), + target_weight=float(config_dict["target_weight"]) if config_dict.get("target_weight") else None, ) @@ -603,6 +616,7 @@ def save_config(user_id: int, config: UserConfig): "weight": str(config.weight) if config.weight else None, "activity_level": config.activity_level, "goal": config.goal, + "target_weight": str(config.target_weight) if config.target_weight else None, } for key, value in config_dict.items(): @@ -921,10 +935,13 @@ def get_readings( ] -def get_reading(reading_id: int) -> Optional[Reading]: - """获取单条阅读记录""" +def get_reading(reading_id: int, user_id: int = None) -> Optional[Reading]: + """获取单条阅读记录(可选验证 user_id)""" with get_connection() as (conn, cursor): - cursor.execute("SELECT * FROM reading WHERE id = %s", (reading_id,)) + if user_id: + cursor.execute("SELECT * FROM reading WHERE id = %s AND user_id = %s", (reading_id, user_id)) + else: + cursor.execute("SELECT * FROM reading WHERE id = %s", (reading_id,)) row = cursor.fetchone() if row: return Reading( @@ -941,10 +958,13 @@ def get_reading(reading_id: int) -> Optional[Reading]: return None -def delete_reading(reading_id: int): - """删除阅读记录""" +def delete_reading(reading_id: int, user_id: int = None): + """删除阅读记录(需验证 user_id)""" with get_connection() as (conn, cursor): - cursor.execute("DELETE FROM reading WHERE id = %s", (reading_id,)) + if user_id: + cursor.execute("DELETE FROM reading WHERE id = %s AND user_id = %s", (reading_id, user_id)) + else: + cursor.execute("DELETE FROM reading WHERE id = %s", (reading_id,)) def get_reading_stats(user_id: int = 1, days: int = 30) -> dict: diff --git a/src/vitals/core/models.py b/src/vitals/core/models.py index 32ce0c1..ab3a45f 100644 --- a/src/vitals/core/models.py +++ b/src/vitals/core/models.py @@ -160,6 +160,7 @@ class UserConfig: weight: Optional[float] = None # 公斤 activity_level: str = ActivityLevel.MODERATE.value goal: str = Goal.MAINTAIN.value + target_weight: Optional[float] = None # 目标体重(公斤) @property def bmr(self) -> Optional[int]: @@ -193,6 +194,7 @@ class UserConfig: "weight": self.weight, "activity_level": self.activity_level, "goal": self.goal, + "target_weight": self.target_weight, "bmr": self.bmr, "tdee": self.tdee, } diff --git a/src/vitals/web/app.py b/src/vitals/web/app.py index 25694de..2da29c4 100644 --- a/src/vitals/web/app.py +++ b/src/vitals/web/app.py @@ -160,10 +160,21 @@ class ConfigResponse(BaseModel): weight: Optional[float] activity_level: str goal: str + target_weight: Optional[float] = None bmr: Optional[int] tdee: Optional[int] +class ConfigUpdateRequest(BaseModel): + age: Optional[int] = None + gender: Optional[str] = None + height: Optional[float] = None + weight: Optional[float] = None + activity_level: Optional[str] = None + goal: Optional[str] = None + target_weight: Optional[float] = None + + class TodaySummary(BaseModel): date: str calories_intake: int @@ -915,11 +926,49 @@ async def get_config(): weight=config.weight, activity_level=config.activity_level, goal=config.goal, + target_weight=config.target_weight, bmr=config.bmr, tdee=config.tdee, ) +@app.post("/api/config", response_model=ConfigResponse) +async def update_config(request: ConfigUpdateRequest): + """更新用户配置""" + from ..core.models import UserConfig + + active_user = db.get_active_user() + user_id = active_user.id if active_user else 1 + + # 获取当前配置 + current_config = db.get_config(user_id) + + # 更新配置(仅更新提供的字段) + new_config = UserConfig( + age=request.age if request.age is not None else current_config.age, + gender=request.gender if request.gender is not None else current_config.gender, + height=request.height if request.height is not None else current_config.height, + weight=request.weight if request.weight is not None else current_config.weight, + activity_level=request.activity_level if request.activity_level is not None else current_config.activity_level, + goal=request.goal if request.goal is not None else current_config.goal, + target_weight=request.target_weight if request.target_weight is not None else current_config.target_weight, + ) + + db.save_config(user_id, new_config) + + return ConfigResponse( + age=new_config.age, + gender=new_config.gender, + height=new_config.height, + weight=new_config.weight, + activity_level=new_config.activity_level, + goal=new_config.goal, + target_weight=new_config.target_weight, + bmr=new_config.bmr, + tdee=new_config.tdee, + ) + + @app.get("/api/today", response_model=TodaySummary) async def get_today_summary(): """获取今日概览""" @@ -8806,6 +8855,21 @@ def get_settings_page_html() -> str: +
+
+ + +

用于计算达成目标所需时间

+
+
+ +
+ -- + +
+
+
+
@@ -9099,6 +9163,45 @@ def get_settings_page_html() -> str: bmiStatus.textContent = ''; bmiStatus.className = 'bmi-status'; } + + // 计算目标 BMI + calculateTargetBMI(); + } + + // 计算目标 BMI + function calculateTargetBMI() { + const height = parseFloat(document.getElementById('profile-height').value); + const targetWeight = parseFloat(document.getElementById('profile-target-weight').value); + const targetBmiValue = document.getElementById('target-bmi-value'); + const targetBmiStatus = document.getElementById('target-bmi-status'); + + if (height && targetWeight && height > 0) { + const heightM = height / 100; + const bmi = targetWeight / (heightM * heightM); + targetBmiValue.textContent = bmi.toFixed(1); + + let status = ''; + let statusClass = ''; + if (bmi < 18.5) { + status = '偏瘦'; + statusClass = 'underweight'; + } else if (bmi < 24) { + status = '正常'; + statusClass = 'normal'; + } else if (bmi < 28) { + status = '偏胖'; + statusClass = 'overweight'; + } else { + status = '肥胖'; + statusClass = 'obese'; + } + targetBmiStatus.textContent = status; + targetBmiStatus.className = 'bmi-status ' + statusClass; + } else { + targetBmiValue.textContent = '--'; + targetBmiStatus.textContent = ''; + targetBmiStatus.className = 'bmi-status'; + } } // 加载个人信息 @@ -9115,6 +9218,15 @@ def get_settings_page_html() -> str: document.getElementById('profile-height').value = user.height_cm || ''; document.getElementById('profile-weight').value = user.weight_kg || ''; + // 加载目标体重 + const configResponse = await fetch('/api/config'); + if (configResponse.ok) { + const config = await configResponse.json(); + if (config.target_weight) { + document.getElementById('profile-target-weight').value = config.target_weight; + } + } + calculateBMI(); } } catch (error) { @@ -9135,6 +9247,7 @@ def get_settings_page_html() -> str: const age = document.getElementById('profile-age').value ? parseInt(document.getElementById('profile-age').value) : null; const height_cm = document.getElementById('profile-height').value ? parseFloat(document.getElementById('profile-height').value) : null; const weight_kg = document.getElementById('profile-weight').value ? parseFloat(document.getElementById('profile-weight').value) : null; + const target_weight = document.getElementById('profile-target-weight').value ? parseFloat(document.getElementById('profile-target-weight').value) : null; if (!name) { showAlert('姓名不能为空', 'error'); @@ -9142,12 +9255,22 @@ def get_settings_page_html() -> str: } try { + // 保存用户基本信息 const response = await fetch(`/api/users/${activeUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, gender, height_cm, weight_kg, age }) }); + // 保存目标体重到配置 + if (target_weight !== null) { + await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_weight }) + }); + } + if (response.ok) { showAlert('个人信息已保存', 'success'); loadProfile(); @@ -9683,6 +9806,7 @@ def get_settings_page_html() -> str: // BMI 实时计算 document.getElementById('profile-height').addEventListener('input', calculateBMI); document.getElementById('profile-weight').addEventListener('input', calculateBMI); + document.getElementById('profile-target-weight').addEventListener('input', calculateTargetBMI); }); // 移动端更多菜单 diff --git a/tests/test_web.py b/tests/test_web.py index c65f459..71796ee 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -49,7 +49,7 @@ def populated_db(): age=28, gender="male", height=175.0, weight=72.0, activity_level="moderate", goal="maintain", ) - db.save_config(config) + db.save_config(1, config) # 今日数据 db.add_exercise(Exercise( @@ -111,6 +111,19 @@ class TestConfigEndpoint: data = response.json() assert data["activity_level"] == "moderate" + def test_update_config_target_weight(self, client, populated_db): + """测试更新目标体重""" + response = client.post("/api/config", json={"target_weight": 65.0}) + assert response.status_code == 200 + + data = response.json() + assert data["target_weight"] == 65.0 + + # 验证可以获取到保存的值 + get_response = client.get("/api/config") + get_data = get_response.json() + assert get_data["target_weight"] == 65.0 + class TestTodayEndpoint: """今日概览接口测试"""