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

2301 lines
66 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-4Web 数据录入功能
**优先级**: ⭐⭐⭐⭐⭐
**预计工作量**: 6-8 小时
**依赖**: P1-1P1-2P1-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
<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>
```
**模态框表单**
```html
<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. 照片上传(饮食表单)**
```html
<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`**
```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
<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 配置命令
```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 <file>` 可恢复数据库
- [ ] `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
<?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 中添加快捷设置命令
```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 开发团队