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,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):
|
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):
|
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):
|
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
|
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):
|
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,
|
weight=float(config_dict["weight"]) if config_dict.get("weight") else None,
|
||||||
activity_level=config_dict.get("activity_level", "moderate"),
|
activity_level=config_dict.get("activity_level", "moderate"),
|
||||||
goal=config_dict.get("goal", "maintain"),
|
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,
|
"weight": str(config.weight) if config.weight else None,
|
||||||
"activity_level": config.activity_level,
|
"activity_level": config.activity_level,
|
||||||
"goal": config.goal,
|
"goal": config.goal,
|
||||||
|
"target_weight": str(config.target_weight) if config.target_weight else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value in config_dict.items():
|
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):
|
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()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
return Reading(
|
return Reading(
|
||||||
@@ -941,10 +958,13 @@ def get_reading(reading_id: int) -> Optional[Reading]:
|
|||||||
return None
|
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):
|
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:
|
def get_reading_stats(user_id: int = 1, days: int = 30) -> dict:
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ class UserConfig:
|
|||||||
weight: Optional[float] = None # 公斤
|
weight: Optional[float] = None # 公斤
|
||||||
activity_level: str = ActivityLevel.MODERATE.value
|
activity_level: str = ActivityLevel.MODERATE.value
|
||||||
goal: str = Goal.MAINTAIN.value
|
goal: str = Goal.MAINTAIN.value
|
||||||
|
target_weight: Optional[float] = None # 目标体重(公斤)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bmr(self) -> Optional[int]:
|
def bmr(self) -> Optional[int]:
|
||||||
@@ -193,6 +194,7 @@ class UserConfig:
|
|||||||
"weight": self.weight,
|
"weight": self.weight,
|
||||||
"activity_level": self.activity_level,
|
"activity_level": self.activity_level,
|
||||||
"goal": self.goal,
|
"goal": self.goal,
|
||||||
|
"target_weight": self.target_weight,
|
||||||
"bmr": self.bmr,
|
"bmr": self.bmr,
|
||||||
"tdee": self.tdee,
|
"tdee": self.tdee,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,10 +160,21 @@ class ConfigResponse(BaseModel):
|
|||||||
weight: Optional[float]
|
weight: Optional[float]
|
||||||
activity_level: str
|
activity_level: str
|
||||||
goal: str
|
goal: str
|
||||||
|
target_weight: Optional[float] = None
|
||||||
bmr: Optional[int]
|
bmr: Optional[int]
|
||||||
tdee: 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):
|
class TodaySummary(BaseModel):
|
||||||
date: str
|
date: str
|
||||||
calories_intake: int
|
calories_intake: int
|
||||||
@@ -915,11 +926,49 @@ async def get_config():
|
|||||||
weight=config.weight,
|
weight=config.weight,
|
||||||
activity_level=config.activity_level,
|
activity_level=config.activity_level,
|
||||||
goal=config.goal,
|
goal=config.goal,
|
||||||
|
target_weight=config.target_weight,
|
||||||
bmr=config.bmr,
|
bmr=config.bmr,
|
||||||
tdee=config.tdee,
|
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)
|
@app.get("/api/today", response_model=TodaySummary)
|
||||||
async def get_today_summary():
|
async def get_today_summary():
|
||||||
"""获取今日概览"""
|
"""获取今日概览"""
|
||||||
@@ -8806,6 +8855,21 @@ def get_settings_page_html() -> str:
|
|||||||
</div>
|
</div>
|
||||||
</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;">
|
<div style="margin-top: 20px;">
|
||||||
<button type="submit" class="btn btn-primary">保存个人信息</button>
|
<button type="submit" class="btn btn-primary">保存个人信息</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -9099,6 +9163,45 @@ def get_settings_page_html() -> str:
|
|||||||
bmiStatus.textContent = '';
|
bmiStatus.textContent = '';
|
||||||
bmiStatus.className = 'bmi-status';
|
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-height').value = user.height_cm || '';
|
||||||
document.getElementById('profile-weight').value = user.weight_kg || '';
|
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();
|
calculateBMI();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 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 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 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) {
|
if (!name) {
|
||||||
showAlert('姓名不能为空', 'error');
|
showAlert('姓名不能为空', 'error');
|
||||||
@@ -9142,12 +9255,22 @@ def get_settings_page_html() -> str:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 保存用户基本信息
|
||||||
const response = await fetch(`/api/users/${activeUser.id}`, {
|
const response = await fetch(`/api/users/${activeUser.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, gender, height_cm, weight_kg, age })
|
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) {
|
if (response.ok) {
|
||||||
showAlert('个人信息已保存', 'success');
|
showAlert('个人信息已保存', 'success');
|
||||||
loadProfile();
|
loadProfile();
|
||||||
@@ -9683,6 +9806,7 @@ def get_settings_page_html() -> str:
|
|||||||
// BMI 实时计算
|
// BMI 实时计算
|
||||||
document.getElementById('profile-height').addEventListener('input', calculateBMI);
|
document.getElementById('profile-height').addEventListener('input', calculateBMI);
|
||||||
document.getElementById('profile-weight').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,
|
age=28, gender="male", height=175.0, weight=72.0,
|
||||||
activity_level="moderate", goal="maintain",
|
activity_level="moderate", goal="maintain",
|
||||||
)
|
)
|
||||||
db.save_config(config)
|
db.save_config(1, config)
|
||||||
|
|
||||||
# 今日数据
|
# 今日数据
|
||||||
db.add_exercise(Exercise(
|
db.add_exercise(Exercise(
|
||||||
@@ -111,6 +111,19 @@ class TestConfigEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["activity_level"] == "moderate"
|
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:
|
class TestTodayEndpoint:
|
||||||
"""今日概览接口测试"""
|
"""今日概览接口测试"""
|
||||||
|
|||||||
Reference in New Issue
Block a user