diff --git a/docs/CODEMAPS.md b/docs/CODEMAPS.md index 23901e8..3da6471 100644 --- a/docs/CODEMAPS.md +++ b/docs/CODEMAPS.md @@ -153,6 +153,9 @@ class DeepSeekVisionAnalyzer: | GET | `/api/meals/nutrition` | 营养统计 | | POST | `/api/exercise` | 添加运动记录 | | GET | `/api/exercises` | 查询运动记录 | +| POST | `/api/steps` | 同步步数(iOS 快捷指令调用) | +| GET | `/api/steps` | 查询步数记录 | +| GET | `/api/steps/today` | 获取今日步数 | | POST | `/api/sleep` | 添加睡眠记录 | | POST | `/api/weight` | 添加体重记录 | | GET | `/api/config` | 获取用户配置 | @@ -216,6 +219,9 @@ weight (id, user_id, date, weight, body_fat, notes) -- 阅读记录 reading (id, user_id, date, book_title, pages, duration, notes) +-- 步数记录 +steps (id, user_id, date, steps, distance, calories, source, synced_at) + -- 用户配置 user_config (id, user_id, height, weight, age, gender, activity_level, goal) @@ -258,6 +264,11 @@ invite (id, code, created_by, used_by, used_at, expires_at) ## 最近更新 +- **Apple Health 步数同步**:支持通过 iOS 快捷指令同步 Apple Health 步数数据 + - 数据库:新增 `steps` 表存储每日步数 + - API:`/api/steps` (POST 同步, GET 查询), `/api/steps/today` (今日步数) + - 前端:运动页面显示今日步数卡片,设置页面提供 iOS 快捷指令配置说明 + - **文字 AI 识别功能**:在饮食页面添加"文字AI识别"按钮,支持输入文字描述后自动识别卡路里 - 前端:`web/app.py` (第 5041 行按钮,第 5166 行函数) - 后端:`/api/meal/recognize` 支持 `text` 参数 diff --git a/src/vitals/core/database.py b/src/vitals/core/database.py index 9d07ee3..4d2f82d 100644 --- a/src/vitals/core/database.py +++ b/src/vitals/core/database.py @@ -200,6 +200,22 @@ def init_db(): ) """) + # 步数记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS steps ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT DEFAULT 1, + date DATE NOT NULL, + steps INT NOT NULL DEFAULT 0, + distance FLOAT, + calories INT, + source VARCHAR(50) DEFAULT 'apple_health', + synced_at DATETIME NOT NULL, + UNIQUE KEY unique_user_date (user_id, date), + INDEX idx_steps_date (date) + ) + """) + # 创建默认管理员用户(仅在用户表为空时) cursor.execute("SELECT COUNT(*) as count FROM users") if cursor.fetchone()["count"] == 0: @@ -277,6 +293,74 @@ def delete_exercise(exercise_id: int): cursor.execute("DELETE FROM exercise WHERE id = %s", (exercise_id,)) +# ===== 步数记录 ===== + +def add_or_update_steps(user_id: int, record_date: date, steps: int, distance: float = None, calories: int = None, source: str = "apple_health") -> int: + """添加或更新步数记录(同一天只保留一条记录)""" + with get_connection() as (conn, cursor): + cursor.execute(""" + INSERT INTO steps (user_id, date, steps, distance, calories, source, synced_at) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + steps = VALUES(steps), + distance = VALUES(distance), + calories = VALUES(calories), + source = VALUES(source), + synced_at = VALUES(synced_at) + """, ( + user_id, + record_date.isoformat(), + steps, + distance, + calories, + source, + datetime.now().isoformat(), + )) + return cursor.lastrowid + + +def get_steps(start_date: Optional[date] = None, end_date: Optional[date] = None, user_id: Optional[int] = None) -> list[dict]: + """查询步数记录""" + with get_connection() as (conn, cursor): + query = "SELECT * FROM steps WHERE 1=1" + params = [] + + if user_id: + query += " AND user_id = %s" + params.append(user_id) + if start_date: + query += " AND date >= %s" + params.append(start_date.isoformat()) + if end_date: + query += " AND date <= %s" + params.append(end_date.isoformat()) + + query += " ORDER BY date DESC" + cursor.execute(query, params) + + return [ + { + "id": row["id"], + "date": _parse_date(row["date"]).isoformat() if isinstance(row["date"], str) else row["date"].isoformat(), + "steps": row["steps"], + "distance": row["distance"], + "calories": row["calories"], + "source": row["source"], + "synced_at": row["synced_at"].isoformat() if row["synced_at"] else None, + } + for row in cursor.fetchall() + ] + + +def get_today_steps(user_id: int) -> dict: + """获取今日步数""" + today = date.today() + records = get_steps(start_date=today, end_date=today, user_id=user_id) + if records: + return records[0] + return {"date": today.isoformat(), "steps": 0, "distance": None, "calories": None} + + # ===== 饮食记录 ===== def add_meal(meal: Meal, user_id: int = 1) -> int: diff --git a/src/vitals/web/app.py b/src/vitals/web/app.py index fde5d03..2d95127 100644 --- a/src/vitals/web/app.py +++ b/src/vitals/web/app.py @@ -1220,6 +1220,62 @@ async def delete_exercise_api(exercise_id: int): raise HTTPException(status_code=500, detail=str(e)) +# ===== 步数同步 API ===== + +class StepsInput(BaseModel): + date: str + steps: int + distance: Optional[float] = None + calories: Optional[int] = None + source: Optional[str] = "apple_health" + + +@app.post("/api/steps") +async def sync_steps_api(data: StepsInput): + """同步步数数据(供 iOS 快捷指令调用)""" + active_user = db.get_active_user() + if not active_user: + raise HTTPException(status_code=400, detail="没有激活的用户") + + try: + record_date = date.fromisoformat(data.date) + except ValueError as exc: + raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc + + record_id = db.add_or_update_steps( + user_id=active_user.id, + record_date=record_date, + steps=data.steps, + distance=data.distance, + calories=data.calories, + source=data.source or "apple_health", + ) + return {"success": True, "id": record_id, "message": f"已同步 {data.steps} 步"} + + +@app.get("/api/steps") +async def get_steps_api(days: int = Query(default=30)): + """查询步数记录""" + active_user = db.get_active_user() + if not active_user: + raise HTTPException(status_code=400, detail="没有激活的用户") + + end = date.today() + start = end - timedelta(days=days) + records = db.get_steps(start_date=start, end_date=end, user_id=active_user.id) + return records + + +@app.get("/api/steps/today") +async def get_today_steps_api(): + """获取今日步数""" + active_user = db.get_active_user() + if not active_user: + raise HTTPException(status_code=400, detail="没有激活的用户") + + return db.get_today_steps(active_user.id) + + @app.post("/api/meal") async def add_meal_api( date_str: str = Form(...), @@ -3518,521 +3574,470 @@ def get_admin_page_html() -> str: def get_dashboard_html() -> str: - """生成仪表盘 HTML""" + """生成仪表盘 HTML - 深色科技风格""" return """ - +