# Vitals 开发任务清单 > 生成日期: 2026-01-18 > 基于: [2025-01-17-vitals-design.md](./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`)** ```python @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`)** ```python @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`,需要配置静态文件访问: ```python # 在 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`)** **后端路由**: ```python @app.get("/sleep") async def sleep_page(): """睡眠页面""" return HTMLResponse(content=get_sleep_page_html()) ``` **前端模块**: - 睡眠时长趋势折线图(近 30 天) - 质量评分热力图(7x4 周视图) - 入睡时间分布散点图 - 平均睡眠时长/质量统计卡片 - 最佳/最差睡眠记录 **2. 体重页面(`/weight`)** **后端路由**: ```python @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-4:Web 数据录入功能 **优先级**: ⭐⭐⭐⭐⭐ **预计工作量**: 6-8 小时 **依赖**: P1-1、P1-2、P1-3 #### 实现步骤 **1. 后端 POST API(`src/vitals/web/app.py`)** ```python 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) 在各页面添加浮动按钮: ```html ``` **模态框表单**: ```html ``` **3. 照片上传(饮食表单)** ```html

支持 JPG/PNG,最大 5MB

``` #### 验收标准 - [ ] 所有 4 个 POST API 可正常工作 - [ ] 表单校验正确(必填项、数值范围) - [ ] 照片上传成功后可在饮食页面查看 - [ ] 饮食表单留空卡路里时自动估算 - [ ] 提交成功后页面刷新并显示新记录 - [ ] 错误提示友好(网络错误、校验失败) - [ ] 表单样式与页面整体风格一致 --- ### 任务 P1-5:DeepSeek 智能食物识别 **优先级**: ⭐⭐⭐⭐⭐ **预计工作量**: 6-8 小时 **依赖**: P1-2 饮食页面 #### 功能概述 在饮食记录时,用户可通过以下两种方式智能识别食物并自动估算卡路里: 1. **上传照片**:DeepSeek Vision 识别图片中的食物 2. **文字描述**:DeepSeek 解析自然语言描述(如"一碗米饭加红烧肉") 系统自动调用 DeepSeek API,返回食物列表和营养估算,用户确认后保存。 #### 实现步骤 **1. 创建 DeepSeek 适配器(`src/vitals/vision/providers/deepseek.py`)** ```python """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 选项: ```python 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`)** 新增智能识别接口: ```python 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` 的添加表单中增加"智能识别"功能: ```html

智能识别(可选)


``` **5. CLI 配置管理(`src/vitals/cli.py`)** 增加 API Key 配置命令: ```python @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 中添加: ```toml dependencies = [ # ...existing... "httpx>=0.27.0", # DeepSeek API 调用 ] ``` **7. 测试(`tests/test_deepseek.py`)** ```python """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**: ```bash # 方式 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`)** ```python """数据导出模块""" 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`)** ```python # 新增子命令组 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`)** ```python """数据导出测试""" 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`)** ```python """数据库备份模块""" 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`)** ```python # 新增子命令组 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` 的主回调函数中添加自动备份: ```python @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`)** ```python """数据库备份测试""" 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 ` 可恢复数据库 - [ ] `vitals backup cleanup --days 7` 正确清理旧备份 - [ ] 自动备份在数据写入后执行(每天首次) - [ ] 恢复前会自动备份当前数据库 - [ ] 测试覆盖所有备份和恢复场景 --- ## 🎁 P3 - 高级功能(第三优先级) > **目标**: 锦上添花,提升用户粘性 ### 任务 P3-1:定时提醒功能 **优先级**: ⭐⭐ **预计工作量**: 4-5 小时 **依赖**: 无 #### 实现步骤 **1. 创建提醒模块(`src/vitals/core/reminder.py`)** ```python """定时提醒模块""" 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`)** ```python # 新增子命令组 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`: ```bash #!/bin/bash # Vitals 提醒设置脚本(macOS) PLIST_PATH="$HOME/Library/LaunchAgents/com.vitals.reminder.plist" cat > "$PLIST_PATH" << EOF Label com.vitals.reminder ProgramArguments $(which python3) -c from vitals.core.reminder import check_reminders; check_reminders() StartInterval 60 RunAtLoad StandardOutPath $HOME/.vitals/reminder.log StandardErrorPath $HOME/.vitals/reminder_error.log EOF # 加载定时任务 launchctl unload "$PLIST_PATH" 2>/dev/null launchctl load "$PLIST_PATH" echo "✓ 提醒定时任务已设置" echo " 日志位置: ~/.vitals/reminder.log" ``` 在 CLI 中添加快捷设置命令: ```python @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 包含所有新功能的使用说明 - [ ] 有清晰的截图或演示视频 --- ## 🔗 相关文档 - [设计文档](./2025-01-17-vitals-design.md) - [项目 README](../../README.md) - [测试文档](../../tests/README.md)(待创建) --- **最后更新**: 2026-01-18 **维护者**: Vitals 开发团队