2301 lines
66 KiB
Markdown
2301 lines
66 KiB
Markdown
# 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
|
||
<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()">×</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-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
|
||
<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 开发团队
|