feat: 添加目标体重设置功能

- UserConfig 模型添加 target_weight 字段
- 新增 POST /api/config 端点用于更新用户配置
- 设置页面添加目标体重输入框及目标 BMI 自动计算
- BMI 分析使用用户设置的目标体重计算达成预估
- 修复测试 fixture 的 save_config 参数问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 17:39:24 +08:00
parent 50c33451ef
commit 842998893a
4 changed files with 178 additions and 19 deletions

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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:
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="profile-target-weight">目标体重 (kg)</label>
<input type="number" id="profile-target-weight" name="target_weight" min="30" max="300" step="0.1" placeholder="设置目标体重">
<p style="font-size: 0.85rem; color: #94A3B8; margin-top: 4px;">用于计算达成目标所需时间</p>
</div>
<div class="form-group">
<label>目标 BMI (自动计算)</label>
<div class="bmi-display">
<span id="target-bmi-value">--</span>
<span id="target-bmi-status" class="bmi-status"></span>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<button type="submit" class="btn btn-primary">保存个人信息</button>
</div>
@@ -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);
});
// 移动端更多菜单

View File

@@ -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:
"""今日概览接口测试"""