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:
@@ -287,9 +287,12 @@ 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):
|
||||
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,9 +423,12 @@ 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):
|
||||
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,9 +489,12 @@ 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):
|
||||
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,9 +555,12 @@ 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):
|
||||
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,9 +935,12 @@ 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):
|
||||
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:
|
||||
@@ -941,9 +958,12 @@ 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):
|
||||
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,))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// 移动端更多菜单
|
||||
|
||||
@@ -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:
|
||||
"""今日概览接口测试"""
|
||||
|
||||
Reference in New Issue
Block a user