Files
DDUp/docs/plans/task.md
2026-01-22 12:57:26 +08:00

66 KiB
Raw Permalink Blame History

Vitals 开发任务清单

生成日期: 2026-01-18
基于: 2025-01-17-vitals-design.md


📋 任务概览

本清单包含 Vitals 项目的后续开发任务,按优先级排序,每项任务包含具体的实现步骤和验收标准。

总工作量估算:约 26-36 小时1-1.5 周全职开发)


🎯 P1 - Web 功能增强(最高优先级)

目标: 完善 Web 仪表盘,从单一首页扩展为完整的健康管理平台

任务 P1-1添加运动页面

优先级:
预计工作量: 4-6 小时
依赖: 无

实现步骤

1. 后端路由(src/vitals/web/app.py

@app.get("/exercise")
async def exercise_page():
    """运动页面"""
    return HTMLResponse(content=get_exercise_page_html())

# API 已存在,可能需要增强
@app.get("/api/exercises/stats")
async def get_exercise_stats(days: int = 30):
    """获取运动统计数据"""
    # 按类型分组
    # 时长趋势
    # 卡路里分布

2. 前端页面实现

app.py 新增 get_exercise_page_html() 函数,或创建 src/vitals/web/templates/exercise.html

页面包含以下模块

  • 顶部导航栏:首页、运动、饮食、睡眠、体重、报告
  • 统计卡片
    • 本月运动次数
    • 本月总时长
    • 本月消耗卡路里
    • 最常运动类型
  • 图表区域
    • 运动时长趋势Chart.js 折线图,近 30 天)
    • 按类型分组柱状图(跑步/游泳/骑行...
    • 卡路里消耗饼图
  • 记录列表
    • 表格展示最近 50 条运动记录
    • 支持按日期/类型筛选
  • 快速添加按钮:浮动按钮 "+" 打开添加表单P1-4 实现)

3. 响应式设计

  • 桌面端:双列布局
  • 移动端:单列堆叠
  • 使用 CSS Grid/Flexbox

验收标准

  • 访问 http://localhost:8080/exercise 可正常显示
  • 图表正确展示近 30 天数据
  • 筛选器可按日期范围和类型过滤
  • 在手机端(宽度 <768px布局正常
  • 数据为空时显示友好的空状态提示

任务 P1-2添加饮食页面

优先级:
预计工作量: 4-6 小时
依赖: 无

实现步骤

1. 后端路由(src/vitals/web/app.py

@app.get("/meal")
async def meal_page():
    """饮食页面"""
    return HTMLResponse(content=get_meal_page_html())

@app.get("/api/meals/nutrition")
async def get_nutrition_stats(days: int = 30):
    """获取营养统计"""
    # 蛋白质/碳水/脂肪总量
    # 按餐次分组

2. 前端页面实现

页面包含以下模块

  • 日历视图
    • 月历展示每日摄入卡路里
    • 点击日期查看详细餐次
    • 颜色编码(低于 TDEE 绿色,超出红色)
  • 营养统计
    • 蛋白质/碳水/脂肪饼图
    • 近 7 天营养摄入趋势
  • 餐次分布
    • 柱状图:早餐/午餐/晚餐/加餐各占比
  • 照片墙(如有照片):
    • Grid 布局展示食物照片缩略图
    • 点击放大查看
  • 记录列表
    • 按日期分组显示
    • 展示食物描述、营养成分

3. 照片静态文件访问

如果 Meal 记录中有 photo_path,需要配置静态文件访问:

# 在 app.py 中
photos_dir = Path.home() / ".vitals" / "photos"
if photos_dir.exists():
    app.mount("/photos", StaticFiles(directory=photos_dir), name="photos")

验收标准

  • 日历视图可正常展示当月数据
  • 点击日期可查看该日所有餐次
  • 营养成分饼图准确显示比例
  • 照片可正常加载和预览(如有)
  • 空状态提示友好

任务 P1-3添加睡眠和体重页面

优先级:
预计工作量: 3-4 小时
依赖: 无

实现步骤

1. 睡眠页面(/sleep

后端路由

@app.get("/sleep")
async def sleep_page():
    """睡眠页面"""
    return HTMLResponse(content=get_sleep_page_html())

前端模块

  • 睡眠时长趋势折线图(近 30 天)
  • 质量评分热力图7x4 周视图)
  • 入睡时间分布散点图
  • 平均睡眠时长/质量统计卡片
  • 最佳/最差睡眠记录

2. 体重页面(/weight

后端路由

@app.get("/weight")
async def weight_page():
    """体重页面"""
    return HTMLResponse(content=get_weight_page_html())

@app.get("/api/weight/goal")
async def get_weight_goal():
    """获取目标体重(从用户配置)"""

前端模块

  • 体重曲线Chart.js支持缩放
  • 体脂率曲线(双 Y 轴)
  • 目标线(用户配置中的目标体重)
  • BMI 计算器
  • 统计卡片:当前体重、起始体重、变化量、距目标
  • 日期范围选择器7天/30天/90天/全部)

验收标准

  • 睡眠页面图表准确展示数据
  • 体重页面支持日期范围切换
  • 目标线正确显示(如用户配置了目标)
  • BMI 计算准确BMI = 体重kg / (身高m)²)
  • 数据点可悬停查看详细信息

任务 P1-4Web 数据录入功能

优先级:
预计工作量: 6-8 小时
依赖: P1-1、P1-2、P1-3

实现步骤

1. 后端 POST APIsrc/vitals/web/app.py

from pydantic import BaseModel, validator
from fastapi import File, UploadFile

# Pydantic 输入模型
class ExerciseInput(BaseModel):
    date: str  # YYYY-MM-DD
    type: str
    duration: int
    calories: Optional[int] = None
    distance: Optional[float] = None
    heart_rate_avg: Optional[int] = None
    notes: Optional[str] = None

    @validator('duration')
    def validate_duration(cls, v):
        if v <= 0 or v > 1440:
            raise ValueError('时长必须在 1-1440 分钟之间')
        return v

class MealInput(BaseModel):
    date: str
    meal_type: str
    description: str
    calories: Optional[int] = None
    protein: Optional[float] = None
    carbs: Optional[float] = None
    fat: Optional[float] = None

class SleepInput(BaseModel):
    date: str
    bedtime: Optional[str] = None  # HH:MM
    wake_time: Optional[str] = None
    duration: float
    quality: int
    notes: Optional[str] = None

    @validator('quality')
    def validate_quality(cls, v):
        if v < 1 or v > 5:
            raise ValueError('质量评分必须在 1-5 之间')
        return v

class WeightInput(BaseModel):
    date: str
    weight_kg: float
    body_fat_pct: Optional[float] = None
    muscle_mass: Optional[float] = None
    notes: Optional[str] = None

    @validator('weight_kg')
    def validate_weight(cls, v):
        if v < 20 or v > 300:
            raise ValueError('体重必须在 20-300 kg 之间')
        return v

# POST 路由
@app.post("/api/exercise")
async def add_exercise_api(data: ExerciseInput):
    """添加运动记录"""
    exercise = Exercise(
        date=date.fromisoformat(data.date),
        type=data.type,
        duration=data.duration,
        calories=data.calories or estimate_exercise_calories(data.type, data.duration),
        distance=data.distance,
        heart_rate_avg=data.heart_rate_avg,
        notes=data.notes,
        source="web",
    )
    record_id = db.add_exercise(exercise)
    return {"success": True, "id": record_id}

@app.post("/api/meal")
async def add_meal_api(
    data: MealInput,
    photo: Optional[UploadFile] = File(None)
):
    """添加饮食记录(支持照片上传)"""
    photo_path = None
    if photo:
        # 保存照片
        photos_dir = Path.home() / ".vitals" / "photos" / data.date[:7]  # YYYY-MM
        photos_dir.mkdir(parents=True, exist_ok=True)
        
        # 生成文件名
        timestamp = datetime.now().strftime("%H%M%S")
        photo_path = photos_dir / f"{data.date}_{data.meal_type}_{timestamp}.jpg"
        
        # 保存文件
        with open(photo_path, "wb") as f:
            f.write(await photo.read())
    
    # 如果没有提供卡路里,尝试估算
    if data.calories is None and data.description:
        from ..core.calories import estimate_meal_calories
        result = estimate_meal_calories(data.description)
        calories = result["total_calories"]
        protein = result["total_protein"]
        carbs = result["total_carbs"]
        fat = result["total_fat"]
    else:
        calories = data.calories or 0
        protein = data.protein
        carbs = data.carbs
        fat = data.fat
    
    meal = Meal(
        date=date.fromisoformat(data.date),
        meal_type=data.meal_type,
        description=data.description,
        calories=calories,
        protein=protein,
        carbs=carbs,
        fat=fat,
        photo_path=str(photo_path) if photo_path else None,
    )
    record_id = db.add_meal(meal)
    return {"success": True, "id": record_id, "calories": calories}

@app.post("/api/sleep")
async def add_sleep_api(data: SleepInput):
    """添加睡眠记录"""
    sleep = Sleep(
        date=date.fromisoformat(data.date),
        bedtime=time.fromisoformat(data.bedtime) if data.bedtime else None,
        wake_time=time.fromisoformat(data.wake_time) if data.wake_time else None,
        duration=data.duration,
        quality=data.quality,
        notes=data.notes,
        source="web",
    )
    record_id = db.add_sleep(sleep)
    return {"success": True, "id": record_id}

@app.post("/api/weight")
async def add_weight_api(data: WeightInput):
    """添加体重记录"""
    weight = Weight(
        date=date.fromisoformat(data.date),
        weight_kg=data.weight_kg,
        body_fat_pct=data.body_fat_pct,
        muscle_mass=data.muscle_mass,
        notes=data.notes,
    )
    record_id = db.add_weight(weight)
    return {"success": True, "id": record_id}

2. 前端表单实现

设计方案使用模态框Modal

在各页面添加浮动按钮:

<button class="fab" onclick="openAddModal()">+</button>

<style>
.fab {
    position: fixed;
    bottom: 30px;
    right: 30px;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: linear-gradient(135deg, #667eea, #764ba2);
    color: white;
    font-size: 24px;
    border: none;
    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
    cursor: pointer;
    transition: transform 0.2s;
}
.fab:hover {
    transform: scale(1.1);
}
</style>

模态框表单

<div id="addModal" class="modal">
    <div class="modal-content">
        <span class="close" onclick="closeModal()">&times;</span>
        <h2>添加运动记录</h2>
        
        <form id="addExerciseForm">
            <label>日期</label>
            <input type="date" name="date" required>
            
            <label>运动类型</label>
            <select name="type" required>
                <option>跑步</option>
                <option>游泳</option>
                <option>骑行</option>
                <option>力量训练</option>
                <option>其他</option>
            </select>
            
            <label>时长(分钟)</label>
            <input type="number" name="duration" min="1" max="1440" required>
            
            <label>距离(公里,可选)</label>
            <input type="number" name="distance" step="0.1" min="0">
            
            <label>卡路里(可选,留空自动估算)</label>
            <input type="number" name="calories" min="0">
            
            <label>备注</label>
            <textarea name="notes" rows="3"></textarea>
            
            <button type="submit">保存</button>
        </form>
    </div>
</div>

<script>
async function submitForm(event) {
    event.preventDefault();
    const formData = new FormData(event.target);
    const data = Object.fromEntries(formData.entries());
    
    try {
        const response = await fetch('/api/exercise', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify(data)
        });
        
        const result = await response.json();
        
        if (result.success) {
            alert('添加成功!');
            closeModal();
            location.reload();  // 刷新页面
        } else {
            alert('添加失败:' + result.error);
        }
    } catch (error) {
        alert('添加失败:' + error.message);
    }
}

document.getElementById('addExerciseForm').addEventListener('submit', submitForm);
</script>

3. 照片上传(饮食表单)

<form id="addMealForm" enctype="multipart/form-data">
    <!-- ...其他字段... -->
    
    <label>食物照片(可选)</label>
    <input type="file" name="photo" accept="image/*">
    <p class="hint">支持 JPG/PNG最大 5MB</p>
    
    <button type="submit">保存</button>
</form>

<script>
async function submitMealForm(event) {
    event.preventDefault();
    const formData = new FormData(event.target);
    
    // 文件大小校验
    const photo = formData.get('photo');
    if (photo && photo.size > 5 * 1024 * 1024) {
        alert('照片不能超过 5MB');
        return;
    }
    
    try {
        const response = await fetch('/api/meal', {
            method: 'POST',
            body: formData  // 直接发送 FormData浏览器自动设置 Content-Type
        });
        
        const result = await response.json();
        
        if (result.success) {
            alert(`添加成功!估算热量: ${result.calories} 卡`);
            closeModal();
            location.reload();
        }
    } catch (error) {
        alert('添加失败:' + error.message);
    }
}
</script>

验收标准

  • 所有 4 个 POST API 可正常工作
  • 表单校验正确(必填项、数值范围)
  • 照片上传成功后可在饮食页面查看
  • 饮食表单留空卡路里时自动估算
  • 提交成功后页面刷新并显示新记录
  • 错误提示友好(网络错误、校验失败)
  • 表单样式与页面整体风格一致

任务 P1-5DeepSeek 智能食物识别

优先级:
预计工作量: 6-8 小时
依赖: P1-2 饮食页面

功能概述

在饮食记录时,用户可通过以下两种方式智能识别食物并自动估算卡路里:

  1. 上传照片DeepSeek Vision 识别图片中的食物
  2. 文字描述DeepSeek 解析自然语言描述(如"一碗米饭加红烧肉"

系统自动调用 DeepSeek API返回食物列表和营养估算用户确认后保存。

实现步骤

1. 创建 DeepSeek 适配器(src/vitals/vision/providers/deepseek.py

"""DeepSeek Vision API 适配器"""

import base64
import os
from pathlib import Path
from typing import Optional

import httpx

from ...core.calories import estimate_meal_calories


class DeepSeekVisionAnalyzer:
    """DeepSeek Vision 食物识别分析器"""

    def __init__(self, api_key: Optional[str] = None):
        self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY")
        self.base_url = "https://api.deepseek.com/v1"

    def analyze_image(self, image_path: Path) -> dict:
        """
        分析食物图片
        
        返回:
        {
            "description": "米饭、红烧排骨、西兰花",
            "total_calories": 680,
            "items": [...]
        }
        """
        if not self.api_key:
            raise ValueError("需要设置 DEEPSEEK_API_KEY 环境变量")

        # 读取并编码图片
        image_data = self._encode_image(image_path)
        media_type = self._get_media_type(image_path)

        # 调用 DeepSeek Vision API
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

        payload = {
            "model": "deepseek-chat",  # 使用支持 vision 的模型
            "messages": [
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:{media_type};base64,{image_data}"
                            }
                        },
                        {
                            "type": "text",
                            "text": """请分析这张食物图片,识别出所有食物。

要求:
1. 列出所有可识别的食物,用中文名称
2. 估计每种食物的大致份量一碗米饭、100g红烧肉
3. 按照 "食物1+食物2+食物3" 的格式返回

例如返回格式:一碗米饭+红烧排骨+西兰花

只返回食物列表,不需要其他解释。"""
                        }
                    ]
                }
            ],
            "temperature": 0.3,
        }

        with httpx.Client(timeout=30.0) as client:
            response = client.post(
                f"{self.base_url}/chat/completions",
                headers=headers,
                json=payload,
            )
            response.raise_for_status()
            result = response.json()

        # 解析响应
        description = result["choices"][0]["message"]["content"].strip()

        # 使用卡路里计算模块估算营养成分
        nutrition = estimate_meal_calories(description)
        nutrition["description"] = description
        nutrition["provider"] = "deepseek"

        return nutrition

    def analyze_text(self, text_description: str) -> dict:
        """
        分析文字描述的食物
        
        输入: "今天吃了一碗米饭、两个鸡蛋还有一杯牛奶"
        返回: 结构化的食物列表和营养估算
        """
        if not self.api_key:
            raise ValueError("需要设置 DEEPSEEK_API_KEY 环境变量")

        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

        payload = {
            "model": "deepseek-chat",
            "messages": [
                {
                    "role": "system",
                    "content": """你是专业的营养分析助手。用户会告诉你吃了什么,你需要:
1. 识别所有食物
2. 标准化食物名称(如"面"→"面条""肉"→"猪肉"
3. 提取数量(如"两个"、"一碗"、"100g"
4. 按照 "食物1+食物2+食物3" 格式返回

示例:
用户输入:"今天吃了一碗米饭、两个鸡蛋还有一杯牛奶"
你返回:"一碗米饭+两个鸡蛋+一杯牛奶"

只返回标准化后的食物列表,不需要其他解释。"""
                },
                {
                    "role": "user",
                    "content": text_description
                }
            ],
            "temperature": 0.3,
        }

        with httpx.Client(timeout=30.0) as client:
            response = client.post(
                f"{self.base_url}/chat/completions",
                headers=headers,
                json=payload,
            )
            response.raise_for_status()
            result = response.json()

        # 解析响应
        standardized = result["choices"][0]["message"]["content"].strip()

        # 使用卡路里计算模块估算营养成分
        nutrition = estimate_meal_calories(standardized)
        nutrition["description"] = standardized
        nutrition["provider"] = "deepseek"
        nutrition["original_input"] = text_description

        return nutrition

    def _encode_image(self, image_path: Path) -> str:
        """将图片编码为 base64"""
        with open(image_path, "rb") as f:
            return base64.standard_b64encode(f.read()).decode("utf-8")

    def _get_media_type(self, image_path: Path) -> str:
        """获取图片的 MIME 类型"""
        suffix = image_path.suffix.lower()
        media_types = {
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".png": "image/png",
            ".gif": "image/gif",
            ".webp": "image/webp",
        }
        return media_types.get(suffix, "image/jpeg")


def get_deepseek_analyzer(api_key: Optional[str] = None) -> DeepSeekVisionAnalyzer:
    """获取 DeepSeek 分析器"""
    return DeepSeekVisionAnalyzer(api_key)

2. 更新分析器工厂(src/vitals/vision/analyzer.py

在现有的 get_analyzer() 函数中增加 DeepSeek 选项:

def get_analyzer(
    provider: str = "deepseek",  # 默认使用 DeepSeek
    api_key: Optional[str] = None
) -> FoodAnalyzer:
    """
    获取食物分析器
    
    provider: "deepseek" | "claude" | "local"
    """
    if provider == "deepseek":
        from .providers.deepseek import get_deepseek_analyzer
        return get_deepseek_analyzer(api_key)
    elif provider == "claude":
        return ClaudeFoodAnalyzer(api_key)
    else:
        return LocalFoodAnalyzer()

3. 后端 API 增强(src/vitals/web/app.py

新增智能识别接口:

from pydantic import BaseModel

class FoodRecognitionRequest(BaseModel):
    """食物识别请求"""
    text: Optional[str] = None  # 文字描述
    image_base64: Optional[str] = None  # 图片 base64前端传
    provider: str = "deepseek"  # 默认 DeepSeek


@app.post("/api/meal/recognize")
async def recognize_food(
    text: Optional[str] = Form(None),
    image: Optional[UploadFile] = File(None),
    provider: str = Form("deepseek"),
):
    """
    智能识别食物(支持照片或文字)
    
    返回: {
        "success": true,
        "description": "一碗米饭+红烧排骨",
        "total_calories": 680,
        "items": [...],
        "provider": "deepseek"
    }
    """
    try:
        from ..vision.analyzer import get_analyzer
        
        # 获取 API Key优先环境变量备选配置文件
        api_key = os.environ.get("DEEPSEEK_API_KEY")
        if not api_key:
            # 从配置文件读取(可选实现)
            config_path = Path.home() / ".vitals" / "config.json"
            if config_path.exists():
                import json
                config = json.loads(config_path.read_text())
                api_key = config.get("deepseek_api_key")
        
        if not api_key:
            raise HTTPException(
                status_code=400, 
                detail="未配置 DEEPSEEK_API_KEY请先设置vitals config set-api-key --deepseek YOUR_KEY"
            )
        
        analyzer = get_analyzer(provider=provider, api_key=api_key)
        
        # 图片识别
        if image:
            # 保存临时文件
            temp_dir = Path.home() / ".vitals" / "temp"
            temp_dir.mkdir(parents=True, exist_ok=True)
            temp_path = temp_dir / f"temp_{datetime.now().strftime('%Y%m%d%H%M%S')}.jpg"
            
            content = await image.read()
            temp_path.write_bytes(content)
            
            try:
                result = analyzer.analyze_image(temp_path)
            finally:
                temp_path.unlink(missing_ok=True)  # 清理临时文件
            
            return {
                "success": True,
                "description": result["description"],
                "total_calories": result["total_calories"],
                "total_protein": result.get("total_protein", 0),
                "total_carbs": result.get("total_carbs", 0),
                "total_fat": result.get("total_fat", 0),
                "items": result.get("items", []),
                "provider": provider,
            }
        
        # 文字识别
        elif text:
            if hasattr(analyzer, 'analyze_text'):
                result = analyzer.analyze_text(text)
            else:
                # Fallback: 直接用 estimate_meal_calories
                from ..core.calories import estimate_meal_calories
                result = estimate_meal_calories(text)
                result["description"] = text
                result["provider"] = "local"
            
            return {
                "success": True,
                "description": result["description"],
                "total_calories": result["total_calories"],
                "total_protein": result.get("total_protein", 0),
                "total_carbs": result.get("total_carbs", 0),
                "total_fat": result.get("total_fat", 0),
                "items": result.get("items", []),
                "provider": result.get("provider", "local"),
            }
        
        else:
            raise HTTPException(status_code=400, detail="请提供照片或文字描述")
    
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")

4. 前端表单改进(饮食页面)

/meal 的添加表单中增加"智能识别"功能:

<form id="addMealForm" enctype="multipart/form-data">
    <label>日期</label>
    <input type="date" name="date_str" required>

    <label>餐次</label>
    <select name="meal_type" required>
        <option>早餐</option>
        <option>午餐</option>
        <option>晚餐</option>
        <option>加餐</option>
    </select>

    <!-- 智能识别区域 -->
    <div class="recognition-section">
        <h4>智能识别(可选)</h4>
        <div class="tabs">
            <button type="button" class="tab active" onclick="switchTab('photo')">📷 拍照识别</button>
            <button type="button" class="tab" onclick="switchTab('text')">✏️ 文字识别</button>
        </div>
        
        <div id="photoTab" class="tab-content active">
            <label>上传食物照片</label>
            <input type="file" id="recognizePhoto" accept="image/*">
            <button type="button" onclick="recognizeFromPhoto()">🔍 识别食物</button>
        </div>
        
        <div id="textTab" class="tab-content" style="display:none;">
            <label>描述吃了什么</label>
            <textarea id="recognizeText" rows="3" placeholder="例如:今天午餐吃了一碗米饭、红烧肉和炒青菜"></textarea>
            <button type="button" onclick="recognizeFromText()">🔍 识别食物</button>
        </div>
        
        <div id="recognitionResult" class="recognition-result" style="display:none;">
            <h5>识别结果</h5>
            <p class="description"></p>
            <p class="calories"></p>
            <ul class="items-list"></ul>
            <button type="button" onclick="applyRecognition()">✓ 使用此结果</button>
        </div>
    </div>

    <hr>

    <!-- 手动输入区域 -->
    <label>食物描述</label>
    <textarea name="description" rows="3" placeholder="也可手动输入"></textarea>

    <label>卡路里(可选,留空自动估算)</label>
    <input type="number" name="calories" min="0">

    <label>蛋白质/碳水/脂肪(可选)</label>
    <div class="grid">
        <input type="number" name="protein" step="0.1" placeholder="蛋白质 g">
        <input type="number" name="carbs" step="0.1" placeholder="碳水 g">
        <input type="number" name="fat" step="0.1" placeholder="脂肪 g">
    </div>

    <label>食物照片(可选,<5MB</label>
    <input type="file" name="photo" accept="image/*">

    <button type="submit">保存</button>
</form>

<style>
.recognition-section {
    background: #f8f9fa;
    padding: 16px;
    border-radius: 8px;
    margin-bottom: 16px;
}
.recognition-section h4 {
    margin-bottom: 12px;
    color: #333;
}
.tabs {
    display: flex;
    gap: 8px;
    margin-bottom: 12px;
}
.tab {
    padding: 8px 16px;
    border: none;
    background: white;
    border-radius: 6px;
    cursor: pointer;
    font-size: 0.9rem;
}
.tab.active {
    background: #667eea;
    color: white;
}
.tab-content {
    padding: 12px;
    background: white;
    border-radius: 6px;
}
.recognition-result {
    margin-top: 16px;
    padding: 12px;
    background: #e0f2fe;
    border-radius: 6px;
    border-left: 4px solid #0284c7;
}
.recognition-result h5 {
    margin-bottom: 8px;
    color: #0369a1;
}
.recognition-result .description {
    font-weight: 600;
    margin-bottom: 4px;
}
.recognition-result .calories {
    color: #10b981;
    font-size: 1.1rem;
    font-weight: 700;
    margin-bottom: 8px;
}
.recognition-result .items-list {
    list-style: none;
    font-size: 0.9rem;
    color: #666;
    margin-bottom: 12px;
}
.recognition-result .items-list li {
    padding: 4px 0;
}
</style>

<script>
let lastRecognitionResult = null;

function switchTab(tab) {
    // 切换 tab 样式
    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
    event.target.classList.add('active');
    
    // 切换内容
    document.getElementById('photoTab').style.display = tab === 'photo' ? 'block' : 'none';
    document.getElementById('textTab').style.display = tab === 'text' ? 'block' : 'none';
}

async function recognizeFromPhoto() {
    const fileInput = document.getElementById('recognizePhoto');
    const file = fileInput.files[0];
    
    if (!file) {
        alert('请先选择照片');
        return;
    }
    
    if (file.size > 5 * 1024 * 1024) {
        alert('照片不能超过 5MB');
        return;
    }
    
    const formData = new FormData();
    formData.append('image', file);
    formData.append('provider', 'deepseek');
    
    try {
        showLoading('识别中,请稍候...');
        const response = await fetch('/api/meal/recognize', {
            method: 'POST',
            body: formData
        });
        
        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.detail || '识别失败');
        }
        
        const result = await response.json();
        displayRecognitionResult(result);
    } catch (error) {
        alert('识别失败:' + error.message);
    } finally {
        hideLoading();
    }
}

async function recognizeFromText() {
    const text = document.getElementById('recognizeText').value.trim();
    
    if (!text) {
        alert('请输入食物描述');
        return;
    }
    
    const formData = new FormData();
    formData.append('text', text);
    formData.append('provider', 'deepseek');
    
    try {
        showLoading('分析中,请稍候...');
        const response = await fetch('/api/meal/recognize', {
            method: 'POST',
            body: formData
        });
        
        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.detail || '识别失败');
        }
        
        const result = await response.json();
        displayRecognitionResult(result);
    } catch (error) {
        alert('识别失败:' + error.message);
    } finally {
        hideLoading();
    }
}

function displayRecognitionResult(result) {
    lastRecognitionResult = result;
    
    const resultDiv = document.getElementById('recognitionResult');
    resultDiv.style.display = 'block';
    
    resultDiv.querySelector('.description').textContent = result.description;
    resultDiv.querySelector('.calories').textContent = `总热量: ${result.total_calories} 卡`;
    
    const itemsList = resultDiv.querySelector('.items-list');
    if (result.items && result.items.length) {
        itemsList.innerHTML = result.items.map(item => 
            `<li>${item.name}: ${item.calories} 卡 (蛋白质 ${item.protein}g, 碳水 ${item.carbs}g, 脂肪 ${item.fat}g)</li>`
        ).join('');
    } else {
        itemsList.innerHTML = '';
    }
}

function applyRecognition() {
    if (!lastRecognitionResult) return;
    
    // 填充表单
    document.querySelector('textarea[name="description"]').value = lastRecognitionResult.description;
    document.querySelector('input[name="calories"]').value = lastRecognitionResult.total_calories;
    document.querySelector('input[name="protein"]').value = lastRecognitionResult.total_protein;
    document.querySelector('input[name="carbs"]').value = lastRecognitionResult.total_carbs;
    document.querySelector('input[name="fat"]').value = lastRecognitionResult.total_fat;
    
    alert('已自动填入识别结果,请检查后提交');
    document.getElementById('recognitionResult').style.display = 'none';
}

function showLoading(message) {
    // 简单实现,可以用更好的 loading 组件
    const loading = document.createElement('div');
    loading.id = 'loadingOverlay';
    loading.innerHTML = `<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;color:white;font-size:1.2rem;z-index:9999;">${message}</div>`;
    document.body.appendChild(loading);
}

function hideLoading() {
    document.getElementById('loadingOverlay')?.remove();
}
</script>

5. CLI 配置管理(src/vitals/cli.py

增加 API Key 配置命令:

@config_app.command("set-api-key")
def config_set_api_key(
    deepseek: Optional[str] = typer.Option(None, "--deepseek", help="DeepSeek API Key"),
    claude: Optional[str] = typer.Option(None, "--claude", help="Claude API Key"),
):
    """设置 AI 服务 API Key"""
    import json
    
    config_path = Path.home() / ".vitals" / "config.json"
    
    # 读取现有配置
    if config_path.exists():
        config = json.loads(config_path.read_text())
    else:
        config = {}
    
    # 更新
    if deepseek:
        config["deepseek_api_key"] = deepseek
        console.print("[green]✓[/green] DeepSeek API Key 已保存")
    
    if claude:
        config["claude_api_key"] = claude
        console.print("[green]✓[/green] Claude API Key 已保存")
    
    # 保存
    config_path.write_text(json.dumps(config, indent=2))
    console.print(f"   配置文件: {config_path}")


@config_app.command("show-api-keys")
def config_show_api_keys():
    """显示已配置的 API Keys仅显示前缀"""
    import json
    
    config_path = Path.home() / ".vitals" / "config.json"
    if not config_path.exists():
        console.print("暂无 API Key 配置")
        return
    
    config = json.loads(config_path.read_text())
    
    table = Table(title="API Keys")
    table.add_column("服务", style="cyan")
    table.add_column("状态", style="green")
    
    for service in ["deepseek_api_key", "claude_api_key"]:
        key = config.get(service)
        if key:
            masked = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
            table.add_row(service.replace("_api_key", ""), f"✓ 已配置 ({masked})")
        else:
            table.add_row(service.replace("_api_key", ""), "✗ 未配置")
    
    console.print(table)

6. 依赖更新(pyproject.toml

在 dependencies 中添加:

dependencies = [
    # ...existing...
    "httpx>=0.27.0",  # DeepSeek API 调用
]

7. 测试(tests/test_deepseek.py

"""DeepSeek 食物识别测试"""

from pathlib import Path
from unittest.mock import Mock, patch

import pytest

from src.vitals.vision.providers.deepseek import DeepSeekVisionAnalyzer


class TestDeepSeekAnalyzer:
    """DeepSeek 分析器测试"""

    def test_requires_api_key(self):
        """测试需要 API Key"""
        analyzer = DeepSeekVisionAnalyzer(api_key=None)
        with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
            analyzer.analyze_text("米饭")

    @patch('httpx.Client.post')
    def test_analyze_text(self, mock_post):
        """测试文字识别"""
        # Mock API 响应
        mock_response = Mock()
        mock_response.json.return_value = {
            "choices": [{
                "message": {
                    "content": "一碗米饭+两个鸡蛋"
                }
            }]
        }
        mock_post.return_value = mock_response
        
        analyzer = DeepSeekVisionAnalyzer(api_key="test-key")
        result = analyzer.analyze_text("今天吃了一碗米饭和两个鸡蛋")
        
        assert result["description"] is not None
        assert result["total_calories"] > 0
        assert result["provider"] == "deepseek"

    @patch('httpx.Client.post')
    def test_analyze_image(self, mock_post, tmp_path):
        """测试图片识别"""
        # 创建测试图片
        test_image = tmp_path / "test.jpg"
        test_image.write_bytes(b"fake image data")
        
        # Mock API 响应
        mock_response = Mock()
        mock_response.json.return_value = {
            "choices": [{
                "message": {
                    "content": "米饭+红烧肉+青菜"
                }
            }]
        }
        mock_post.return_value = mock_response
        
        analyzer = DeepSeekVisionAnalyzer(api_key="test-key")
        result = analyzer.analyze_image(test_image)
        
        assert result["description"] is not None
        assert result["total_calories"] > 0

使用流程

配置 API Key

# 方式 1: 环境变量(推荐)
export DEEPSEEK_API_KEY="sk-..."

# 方式 2: CLI 配置
vitals config set-api-key --deepseek sk-...

# 查看配置
vitals config show-api-keys

Web 界面使用

  1. 访问 /meal 页面,点击右下角 "+"
  2. 选择"📷 拍照识别"或"✏️ 文字识别"
  3. 上传照片或输入文字描述
  4. 点击"🔍 识别食物"
  5. 系统调用 DeepSeek API 返回结果
  6. 确认后点"✓ 使用此结果"自动填入表单
  7. 可手动调整后提交保存

验收标准

  • DeepSeek Vision 适配器实现完成
  • /api/meal/recognize 接口可正常工作
  • 饮食表单新增"智能识别"区域(照片/文字切换)
  • 照片识别成功返回食物列表和卡路里
  • 文字识别成功解析自然语言描述
  • 识别结果可一键填入表单
  • API Key 可通过 CLI 配置和查看
  • 未配置 API Key 时有友好提示
  • 测试覆盖主要流程(使用 mock

技术细节

DeepSeek API 文档: https://platform.deepseek.com/api-docs/

计费:

  • DeepSeek-V3 (文字): ¥1/M tokens (输入), ¥2/M tokens (输出)
  • DeepSeek-Vision: 约 ¥0.5-1/次(图片)

优化建议:

  • 缓存识别结果(同图片/文字不重复调用)
  • 添加识别历史(用户可复用之前识别的食物)
  • 支持用户修正识别结果(加入个人食物库)

📦 P2 - 数据管理功能(第二优先级)

目标: 增强数据的可维护性和可移植性

任务 P2-1数据导出功能

优先级:
预计工作量: 3-4 小时
依赖: 无

实现步骤

1. 创建导出模块(src/vitals/core/export.py

"""数据导出模块"""

import csv
import json
from datetime import date
from pathlib import Path
from typing import Literal, Optional

from . import database as db


def export_all_data_json(output_path: Path) -> None:
    """导出所有数据为 JSON"""
    data = {
        "version": "1.0",
        "export_date": date.today().isoformat(),
        "exercises": [e.to_dict() for e in db.get_exercises()],
        "meals": [m.to_dict() for m in db.get_meals()],
        "sleep": [s.to_dict() for s in db.get_sleep_records()],
        "weight": [w.to_dict() for w in db.get_weight_records()],
        "config": db.get_config().to_dict(),
    }
    
    output_path.write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )


def export_to_csv(
    data_type: Literal["exercise", "meal", "sleep", "weight"],
    output_path: Path,
    start_date: Optional[date] = None,
    end_date: Optional[date] = None,
) -> None:
    """导出指定类型数据为 CSV"""
    
    if data_type == "exercise":
        records = db.get_exercises(start_date, end_date)
        fieldnames = ["date", "type", "duration", "calories", "distance", 
                     "heart_rate_avg", "source", "notes"]
    elif data_type == "meal":
        records = db.get_meals(start_date, end_date)
        fieldnames = ["date", "meal_type", "description", "calories", 
                     "protein", "carbs", "fat", "photo_path"]
    elif data_type == "sleep":
        records = db.get_sleep_records(start_date, end_date)
        fieldnames = ["date", "bedtime", "wake_time", "duration", 
                     "quality", "deep_sleep_mins", "source", "notes"]
    elif data_type == "weight":
        records = db.get_weight_records(start_date, end_date)
        fieldnames = ["date", "weight_kg", "body_fat_pct", "muscle_mass", "notes"]
    else:
        raise ValueError(f"不支持的数据类型: {data_type}")
    
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        
        for record in records:
            data = record.to_dict()
            # 只保留 CSV 需要的字段
            row = {k: data.get(k, "") for k in fieldnames}
            writer.writerow(row)


def import_from_json(input_path: Path) -> dict:
    """从 JSON 导入数据"""
    data = json.loads(input_path.read_text(encoding="utf-8"))
    
    stats = {
        "exercise": 0,
        "meal": 0,
        "sleep": 0,
        "weight": 0,
    }
    
    # 导入运动记录
    for item in data.get("exercises", []):
        from .models import Exercise
        exercise = Exercise(**item)
        db.add_exercise(exercise)
        stats["exercise"] += 1
    
    # 导入饮食记录
    for item in data.get("meals", []):
        from .models import Meal
        meal = Meal(**item)
        db.add_meal(meal)
        stats["meal"] += 1
    
    # 导入睡眠记录
    for item in data.get("sleep", []):
        from .models import Sleep
        sleep = Sleep(**item)
        db.add_sleep(sleep)
        stats["sleep"] += 1
    
    # 导入体重记录
    for item in data.get("weight", []):
        from .models import Weight
        weight = Weight(**item)
        db.add_weight(weight)
        stats["weight"] += 1
    
    return stats

2. CLI 接入(src/vitals/cli.py

# 新增子命令组
export_app = typer.Typer(help="数据导出")
app.add_typer(export_app, name="export")

@export_app.command("json")
def export_json(
    output: Path = typer.Argument(..., help="输出文件路径"),
):
    """导出所有数据为 JSON"""
    from .core.export import export_all_data_json
    
    try:
        export_all_data_json(output)
        console.print(f"[green]✓[/green] 数据已导出: {output}")
    except Exception as e:
        console.print(f"[red]✗[/red] 导出失败: {e}")
        raise typer.Exit(1)

@export_app.command("csv")
def export_csv(
    data_type: str = typer.Option(..., "--type", "-t", help="数据类型 (exercise/meal/sleep/weight)"),
    output: Path = typer.Argument(..., help="输出文件路径"),
    start_date: Optional[str] = typer.Option(None, "--start", help="起始日期 (YYYY-MM-DD)"),
    end_date: Optional[str] = typer.Option(None, "--end", help="结束日期 (YYYY-MM-DD)"),
):
    """导出指定类型数据为 CSV"""
    from .core.export import export_to_csv
    
    start = date.fromisoformat(start_date) if start_date else None
    end = date.fromisoformat(end_date) if end_date else None
    
    try:
        export_to_csv(data_type, output, start, end)
        console.print(f"[green]✓[/green] {data_type} 数据已导出: {output}")
    except Exception as e:
        console.print(f"[red]✗[/red] 导出失败: {e}")
        raise typer.Exit(1)

@export_app.command("import-json")
def import_json_cmd(
    input_file: Path = typer.Argument(..., help="JSON 文件路径"),
):
    """从 JSON 导入数据"""
    from .core.export import import_from_json
    
    if not input_file.exists():
        console.print(f"[red]✗[/red] 文件不存在: {input_file}")
        raise typer.Exit(1)
    
    try:
        stats = import_from_json(input_file)
        console.print(f"[green]✓[/green] 数据导入完成:")
        console.print(f"   运动记录: {stats['exercise']} 条")
        console.print(f"   饮食记录: {stats['meal']} 条")
        console.print(f"   睡眠记录: {stats['sleep']} 条")
        console.print(f"   体重记录: {stats['weight']} 条")
    except Exception as e:
        console.print(f"[red]✗[/red] 导入失败: {e}")
        raise typer.Exit(1)

3. 测试(tests/test_export.py

"""数据导出测试"""

from datetime import date
from pathlib import Path

import pytest

from src.vitals.core import database as db
from src.vitals.core.models import Exercise, Weight
from src.vitals.core.export import (
    export_all_data_json,
    export_to_csv,
    import_from_json,
)


def test_export_all_data_json(tmp_path):
    """测试导出所有数据为 JSON"""
    # 添加测试数据
    db.add_exercise(Exercise(date=date(2026, 1, 18), type="跑步", duration=30, calories=240))
    db.add_weight(Weight(date=date(2026, 1, 18), weight_kg=72.5))
    
    output_path = tmp_path / "export.json"
    export_all_data_json(output_path)
    
    assert output_path.exists()
    data = json.loads(output_path.read_text())
    assert "exercises" in data
    assert "weight" in data
    assert len(data["exercises"]) > 0


def test_export_to_csv(tmp_path):
    """测试导出 CSV"""
    db.add_exercise(Exercise(date=date(2026, 1, 18), type="跑步", duration=30, calories=240))
    
    output_path = tmp_path / "exercise.csv"
    export_to_csv("exercise", output_path)
    
    assert output_path.exists()
    content = output_path.read_text()
    assert "date,type,duration" in content


def test_import_from_json(tmp_path):
    """测试从 JSON 导入"""
    # 先导出
    db.add_exercise(Exercise(date=date(2026, 1, 18), type="跑步", duration=30, calories=240))
    json_path = tmp_path / "export.json"
    export_all_data_json(json_path)
    
    # 清空数据库
    # (测试环境自动隔离,这里仅验证导入逻辑)
    
    # 重新导入
    stats = import_from_json(json_path)
    assert stats["exercise"] > 0

验收标准

  • vitals export json ~/backup.json 成功导出
  • vitals export csv --type exercise ~/exercise.csv 成功导出
  • JSON 文件可被 import-json 重新导入
  • CSV 格式与 vitals import csv 兼容
  • 测试覆盖导出和导入的往返一致性

任务 P2-2自动备份数据库

优先级:
预计工作量: 2-3 小时
依赖: 无

实现步骤

1. 创建备份模块(src/vitals/core/backup.py

"""数据库备份模块"""

import shutil
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

from . import database as db


def get_backup_dir() -> Path:
    """获取备份目录"""
    backup_dir = Path.home() / ".vitals" / "backups"
    backup_dir.mkdir(parents=True, exist_ok=True)
    return backup_dir


def backup_database(backup_dir: Optional[Path] = None) -> Path:
    """
    备份数据库
    
    返回: 备份文件路径
    """
    if not backup_dir:
        backup_dir = get_backup_dir()
    
    db_path = db.get_db_path()
    if not db_path.exists():
        raise FileNotFoundError("数据库文件不存在")
    
    # 生成备份文件名(包含时间戳)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_path = backup_dir / f"vitals_{timestamp}.db"
    
    # 复制数据库文件
    shutil.copy2(db_path, backup_path)
    
    # 清理旧备份
    cleanup_old_backups(backup_dir, keep_days=7)
    
    return backup_path


def cleanup_old_backups(backup_dir: Path, keep_days: int = 7) -> int:
    """
    清理旧备份(保留最近 N 天)
    
    返回: 删除的文件数量
    """
    cutoff_date = datetime.now() - timedelta(days=keep_days)
    deleted = 0
    
    for backup_file in backup_dir.glob("vitals_*.db"):
        try:
            # 从文件名提取日期
            timestamp_str = backup_file.stem.replace("vitals_", "")
            file_date = datetime.strptime(timestamp_str[:8], "%Y%m%d")
            
            if file_date < cutoff_date:
                backup_file.unlink()
                deleted += 1
        except (ValueError, IndexError):
            # 文件名格式不对,跳过
            continue
    
    return deleted


def list_backups(backup_dir: Optional[Path] = None) -> list[dict]:
    """
    列出所有备份
    
    返回: [{"path": Path, "date": datetime, "size": int}, ...]
    """
    if not backup_dir:
        backup_dir = get_backup_dir()
    
    backups = []
    for backup_file in sorted(backup_dir.glob("vitals_*.db"), reverse=True):
        try:
            timestamp_str = backup_file.stem.replace("vitals_", "")
            file_date = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
            size = backup_file.stat().st_size
            
            backups.append({
                "path": backup_file,
                "date": file_date,
                "size": size,
            })
        except (ValueError, IndexError):
            continue
    
    return backups


def restore_database(backup_path: Path) -> bool:
    """
    恢复数据库
    
    注意:会覆盖当前数据库!
    """
    if not backup_path.exists():
        raise FileNotFoundError(f"备份文件不存在: {backup_path}")
    
    # 校验备份文件
    if backup_path.stat().st_size == 0:
        raise ValueError("备份文件为空")
    
    db_path = db.get_db_path()
    
    # 先备份当前数据库
    if db_path.exists():
        current_backup = db_path.parent / f"vitals_before_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
        shutil.copy2(db_path, current_backup)
    
    # 恢复备份
    shutil.copy2(backup_path, db_path)
    
    return True

2. CLI 接入(src/vitals/cli.py

# 新增子命令组
backup_app = typer.Typer(help="数据备份")
app.add_typer(backup_app, name="backup")

@backup_app.command("create")
def backup_create():
    """创建数据库备份"""
    from .core.backup import backup_database
    
    try:
        backup_path = backup_database()
        size_mb = backup_path.stat().st_size / 1024 / 1024
        console.print(f"[green]✓[/green] 备份已创建: {backup_path}")
        console.print(f"   大小: {size_mb:.2f} MB")
    except Exception as e:
        console.print(f"[red]✗[/red] 备份失败: {e}")
        raise typer.Exit(1)

@backup_app.command("list")
def backup_list():
    """列出所有备份"""
    from .core.backup import list_backups
    
    backups = list_backups()
    
    if not backups:
        console.print("暂无备份")
        return
    
    table = Table(title="数据库备份")
    table.add_column("日期时间", style="cyan")
    table.add_column("大小", style="green")
    table.add_column("路径", style="yellow")
    
    for backup in backups:
        size_mb = backup["size"] / 1024 / 1024
        table.add_row(
            backup["date"].strftime("%Y-%m-%d %H:%M:%S"),
            f"{size_mb:.2f} MB",
            str(backup["path"]),
        )
    
    console.print(table)

@backup_app.command("restore")
def backup_restore(
    backup_file: Path = typer.Argument(..., help="备份文件路径"),
    force: bool = typer.Option(False, "--force", "-f", help="跳过确认"),
):
    """恢复数据库(会覆盖当前数据库!)"""
    from .core.backup import restore_database
    
    if not force:
        confirm = typer.confirm("⚠️  恢复操作会覆盖当前数据库,是否继续?")
        if not confirm:
            console.print("已取消")
            raise typer.Exit(0)
    
    try:
        restore_database(backup_file)
        console.print(f"[green]✓[/green] 数据库已恢复: {backup_file}")
    except Exception as e:
        console.print(f"[red]✗[/red] 恢复失败: {e}")
        raise typer.Exit(1)

@backup_app.command("cleanup")
def backup_cleanup(
    days: int = typer.Option(7, "--days", "-d", help="保留最近多少天的备份"),
):
    """清理旧备份"""
    from .core.backup import cleanup_old_backups, get_backup_dir
    
    deleted = cleanup_old_backups(get_backup_dir(), keep_days=days)
    console.print(f"[green]✓[/green] 已清理 {deleted} 个旧备份")

3. 自动备份策略

cli.py 的主回调函数中添加自动备份:

@app.callback()
def main(
    ctx: typer.Context,
):
    """Vitals - 本地优先的健康管理应用"""
    db.init_db()
    
    # 自动备份(仅在数据写入命令后执行)
    # 检查是否是写入命令
    write_commands = ["log", "import"]
    if ctx.invoked_subcommand in write_commands:
        # 注册退出时备份
        import atexit
        from .core.backup import backup_database, get_backup_dir
        
        def auto_backup():
            try:
                # 检查今天是否已备份
                backups = list(get_backup_dir().glob(f"vitals_{date.today().strftime('%Y%m%d')}_*.db"))
                if not backups:
                    backup_database()
            except Exception:
                pass  # 静默失败,不影响主流程
        
        atexit.register(auto_backup)

4. 测试(tests/test_backup.py

"""数据库备份测试"""

from datetime import datetime, timedelta
from pathlib import Path

import pytest

from src.vitals.core.backup import (
    backup_database,
    cleanup_old_backups,
    list_backups,
    restore_database,
)


def test_backup_database(tmp_path):
    """测试备份数据库"""
    backup_path = backup_database(tmp_path)
    
    assert backup_path.exists()
    assert backup_path.name.startswith("vitals_")
    assert backup_path.suffix == ".db"


def test_cleanup_old_backups(tmp_path):
    """测试清理旧备份"""
    # 创建模拟备份文件
    old_date = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
    new_date = datetime.now().strftime("%Y%m%d")
    
    old_backup = tmp_path / f"vitals_{old_date}_120000.db"
    new_backup = tmp_path / f"vitals_{new_date}_120000.db"
    
    old_backup.write_text("old")
    new_backup.write_text("new")
    
    # 清理 7 天前的备份
    deleted = cleanup_old_backups(tmp_path, keep_days=7)
    
    assert deleted == 1
    assert not old_backup.exists()
    assert new_backup.exists()


def test_list_backups(tmp_path):
    """测试列出备份"""
    # 创建测试备份
    backup_path = backup_database(tmp_path)
    
    backups = list_backups(tmp_path)
    
    assert len(backups) > 0
    assert backups[0]["path"] == backup_path


def test_restore_database(tmp_path, test_db):
    """测试恢复数据库"""
    # 先备份
    backup_path = backup_database(tmp_path)
    
    # 模拟修改数据库
    # ...
    
    # 恢复
    result = restore_database(backup_path)
    assert result is True

验收标准

  • vitals backup create 成功创建备份
  • vitals backup list 正确显示所有备份
  • vitals backup restore <file> 可恢复数据库
  • vitals backup cleanup --days 7 正确清理旧备份
  • 自动备份在数据写入后执行(每天首次)
  • 恢复前会自动备份当前数据库
  • 测试覆盖所有备份和恢复场景

🎁 P3 - 高级功能(第三优先级)

目标: 锦上添花,提升用户粘性

任务 P3-1定时提醒功能

优先级:
预计工作量: 4-5 小时
依赖: 无

实现步骤

1. 创建提醒模块(src/vitals/core/reminder.py

"""定时提醒模块"""

import json
import subprocess
import sys
from datetime import datetime, time
from pathlib import Path
from typing import Literal, Optional


def get_reminder_config_path() -> Path:
    """获取提醒配置文件路径"""
    config_dir = Path.home() / ".vitals"
    config_dir.mkdir(exist_ok=True)
    return config_dir / "reminders.json"


def load_reminder_config() -> dict:
    """加载提醒配置"""
    config_path = get_reminder_config_path()
    if not config_path.exists():
        return {"reminders": []}
    
    return json.loads(config_path.read_text())


def save_reminder_config(config: dict):
    """保存提醒配置"""
    config_path = get_reminder_config_path()
    config_path.write_text(json.dumps(config, indent=2))


def add_reminder(
    reminder_type: Literal["weight", "sleep", "exercise", "meal"],
    reminder_time: str,  # HH:MM
) -> bool:
    """添加提醒"""
    config = load_reminder_config()
    
    # 检查是否已存在
    for reminder in config["reminders"]:
        if reminder["type"] == reminder_type:
            reminder["time"] = reminder_time
            reminder["enabled"] = True
            save_reminder_config(config)
            return True
    
    # 新增
    config["reminders"].append({
        "type": reminder_type,
        "time": reminder_time,
        "enabled": True,
    })
    save_reminder_config(config)
    return True


def remove_reminder(reminder_type: str) -> bool:
    """删除提醒"""
    config = load_reminder_config()
    original_len = len(config["reminders"])
    
    config["reminders"] = [
        r for r in config["reminders"]
        if r["type"] != reminder_type
    ]
    
    if len(config["reminders"]) < original_len:
        save_reminder_config(config)
        return True
    
    return False


def disable_reminder(reminder_type: str) -> bool:
    """禁用提醒"""
    config = load_reminder_config()
    
    for reminder in config["reminders"]:
        if reminder["type"] == reminder_type:
            reminder["enabled"] = False
            save_reminder_config(config)
            return True
    
    return False


def enable_reminder(reminder_type: str) -> bool:
    """启用提醒"""
    config = load_reminder_config()
    
    for reminder in config["reminders"]:
        if reminder["type"] == reminder_type:
            reminder["enabled"] = True
            save_reminder_config(config)
            return True
    
    return False


def send_notification(title: str, message: str, sound: bool = True):
    """发送系统通知"""
    try:
        if sys.platform == "darwin":
            # macOS
            script = f'display notification "{message}" with title "{title}"'
            if sound:
                script += ' sound name "default"'
            subprocess.run(["osascript", "-e", script], check=True)
        
        elif sys.platform == "linux":
            # Linux (需要安装 libnotify-bin)
            subprocess.run(["notify-send", title, message], check=True)
        
        elif sys.platform == "win32":
            # Windows (使用 plyer 库)
            try:
                from plyer import notification
                notification.notify(
                    title=title,
                    message=message,
                    app_name="Vitals",
                    timeout=10,
                )
            except ImportError:
                print(f"{title}: {message}")
        
        else:
            print(f"{title}: {message}")
    
    except Exception as e:
        print(f"通知发送失败: {e}")


def check_reminders():
    """检查是否需要提醒(由定时任务调用)"""
    config = load_reminder_config()
    now = datetime.now()
    current_time = now.strftime("%H:%M")
    
    for reminder in config.get("reminders", []):
        if not reminder.get("enabled", True):
            continue
        
        if reminder["time"] == current_time:
            reminder_type = reminder["type"]
            
            # 根据类型定制提醒内容
            messages = {
                "weight": "早安!该称体重了 ⚖️",
                "sleep": "该记录今天的睡眠了 😴",
                "exercise": "记得记录今天的运动哦 🏃",
                "meal": "别忘了记录这餐吃了什么 🍽️",
            }
            
            send_notification(
                "Vitals 提醒",
                messages.get(reminder_type, f"该记录{reminder_type}了!")
            )

2. CLI 接入(src/vitals/cli.py

# 新增子命令组
remind_app = typer.Typer(help="定时提醒")
app.add_typer(remind_app, name="remind")

@remind_app.command("set")
def remind_set(
    reminder_type: str = typer.Argument(..., help="提醒类型 (weight/sleep/exercise/meal)"),
    reminder_time: str = typer.Option(..., "--time", "-t", help="提醒时间 (HH:MM)"),
):
    """设置提醒"""
    from .core.reminder import add_reminder
    
    try:
        add_reminder(reminder_type, reminder_time)
        console.print(f"[green]✓[/green] 已设置提醒: {reminder_type} 每天 {reminder_time}")
    except Exception as e:
        console.print(f"[red]✗[/red] 设置失败: {e}")
        raise typer.Exit(1)

@remind_app.command("list")
def remind_list():
    """列出所有提醒"""
    from .core.reminder import load_reminder_config
    
    config = load_reminder_config()
    reminders = config.get("reminders", [])
    
    if not reminders:
        console.print("暂无提醒")
        return
    
    table = Table(title="定时提醒")
    table.add_column("类型", style="cyan")
    table.add_column("时间", style="green")
    table.add_column("状态", style="yellow")
    
    for reminder in reminders:
        status = "✓ 启用" if reminder.get("enabled", True) else "✗ 禁用"
        table.add_row(
            reminder["type"],
            reminder["time"],
            status,
        )
    
    console.print(table)

@remind_app.command("disable")
def remind_disable(
    reminder_type: str = typer.Argument(..., help="提醒类型"),
):
    """禁用提醒"""
    from .core.reminder import disable_reminder
    
    if disable_reminder(reminder_type):
        console.print(f"[green]✓[/green] 已禁用 {reminder_type} 提醒")
    else:
        console.print(f"[yellow]⚠[/yellow] 未找到该提醒")

@remind_app.command("enable")
def remind_enable(
    reminder_type: str = typer.Argument(..., help="提醒类型"),
):
    """启用提醒"""
    from .core.reminder import enable_reminder
    
    if enable_reminder(reminder_type):
        console.print(f"[green]✓[/green] 已启用 {reminder_type} 提醒")
    else:
        console.print(f"[yellow]⚠[/yellow] 未找到该提醒")

@remind_app.command("remove")
def remind_remove(
    reminder_type: str = typer.Argument(..., help="提醒类型"),
):
    """删除提醒"""
    from .core.reminder import remove_reminder
    
    if remove_reminder(reminder_type):
        console.print(f"[green]✓[/green] 已删除 {reminder_type} 提醒")
    else:
        console.print(f"[yellow]⚠[/yellow] 未找到该提醒")

@remind_app.command("test")
def remind_test():
    """测试系统通知"""
    from .core.reminder import send_notification
    
    send_notification("Vitals 测试", "如果你看到这条消息,说明通知功能正常工作!")
    console.print("[green]✓[/green] 测试通知已发送")

3. 设置定时任务macOS launchd

创建 ~/.vitals/setup_reminders.sh

#!/bin/bash

# Vitals 提醒设置脚本macOS

PLIST_PATH="$HOME/Library/LaunchAgents/com.vitals.reminder.plist"

cat > "$PLIST_PATH" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.vitals.reminder</string>
    
    <key>ProgramArguments</key>
    <array>
        <string>$(which python3)</string>
        <string>-c</string>
        <string>from vitals.core.reminder import check_reminders; check_reminders()</string>
    </array>
    
    <key>StartInterval</key>
    <integer>60</integer>
    
    <key>RunAtLoad</key>
    <true/>
    
    <key>StandardOutPath</key>
    <string>$HOME/.vitals/reminder.log</string>
    
    <key>StandardErrorPath</key>
    <string>$HOME/.vitals/reminder_error.log</string>
</dict>
</plist>
EOF

# 加载定时任务
launchctl unload "$PLIST_PATH" 2>/dev/null
launchctl load "$PLIST_PATH"

echo "✓ 提醒定时任务已设置"
echo "  日志位置: ~/.vitals/reminder.log"

在 CLI 中添加快捷设置命令:

@remind_app.command("setup")
def remind_setup():
    """设置定时任务macOS"""
    if sys.platform != "darwin":
        console.print("[yellow]⚠[/yellow] 此功能目前仅支持 macOS")
        return
    
    script_path = Path.home() / ".vitals" / "setup_reminders.sh"
    
    # 创建脚本
    script_path.write_text("""#!/bin/bash
# ... (脚本内容)
""")
    script_path.chmod(0o755)
    
    # 执行脚本
    import subprocess
    subprocess.run([str(script_path)], check=True)
    
    console.print("[green]✓[/green] 定时任务已设置")
    console.print("  使用 'vitals remind set' 配置具体提醒时间")

验收标准

  • vitals remind set weight --time 07:00 设置成功
  • vitals remind list 正确显示所有提醒
  • vitals remind test 可收到系统通知
  • vitals remind setup 在 macOS 上设置定时任务
  • 通知在设定时间准时触发
  • 配置持久化(重启后仍有效)
  • 支持启用/禁用单个提醒

📅 开发计划建议

Week 1: Web 增强

天数 任务 预计产出
Day 1-2 P1-1 运动页面 + P1-2 饮食页面 两个新页面上线
Day 3 P1-3 睡眠/体重页面 两个新页面上线
Day 4-6 P1-4 Web 数据录入 所有表单可用
Day 7 集成测试 + Bug 修复 Web 功能完整可用

Week 2: 数据管理 + 打磨

天数 任务 预计产出
Day 8 P2-1 数据导出 导出/导入功能
Day 9 P2-2 自动备份 备份/恢复功能
Day 10 P3-1 定时提醒(可选) 提醒功能
Day 11-12 文档更新 + 演示视频 README + DEMO

总体验收清单

完成所有任务后,应达到以下标准:

Web 功能

  • 首页、运动、饮食、睡眠、体重、报告页面全部可访问
  • 所有页面图表正确展示数据
  • 所有页面支持响应式布局
  • Web 界面可完成数据录入(运动/饮食/睡眠/体重)
  • 照片上传功能正常工作

数据管理

  • 可导出所有数据为 JSON
  • 可导出指定类型数据为 CSV
  • JSON 可重新导入
  • 自动备份在数据写入后执行
  • 可手动创建/恢复/清理备份

高级功能

  • 可设置定时提醒
  • 系统通知正常触发
  • 提醒可启用/禁用

文档

  • README 包含所有新功能的使用说明
  • 有清晰的截图或演示视频

🔗 相关文档


最后更新: 2026-01-18
维护者: Vitals 开发团队