66 KiB
Vitals 开发任务清单
生成日期: 2026-01-18
基于: 2025-01-17-vitals-design.md
📋 任务概览
本清单包含 Vitals 项目的后续开发任务,按优先级排序,每项任务包含具体的实现步骤和验收标准。
总工作量估算:约 26-36 小时(1-1.5 周全职开发)
🎯 P1 - Web 功能增强(最高优先级)
目标: 完善 Web 仪表盘,从单一首页扩展为完整的健康管理平台
任务 P1-1:添加运动页面
优先级: ⭐⭐⭐⭐⭐
预计工作量: 4-6 小时
依赖: 无
实现步骤
1. 后端路由(src/vitals/web/app.py)
@app.get("/exercise")
async def exercise_page():
"""运动页面"""
return HTMLResponse(content=get_exercise_page_html())
# API 已存在,可能需要增强
@app.get("/api/exercises/stats")
async def get_exercise_stats(days: int = 30):
"""获取运动统计数据"""
# 按类型分组
# 时长趋势
# 卡路里分布
2. 前端页面实现
在 app.py 新增 get_exercise_page_html() 函数,或创建 src/vitals/web/templates/exercise.html
页面包含以下模块:
- 顶部导航栏:首页、运动、饮食、睡眠、体重、报告
- 统计卡片:
- 本月运动次数
- 本月总时长
- 本月消耗卡路里
- 最常运动类型
- 图表区域:
- 运动时长趋势(Chart.js 折线图,近 30 天)
- 按类型分组柱状图(跑步/游泳/骑行...)
- 卡路里消耗饼图
- 记录列表:
- 表格展示最近 50 条运动记录
- 支持按日期/类型筛选
- 快速添加按钮:浮动按钮 "+" 打开添加表单(P1-4 实现)
3. 响应式设计
- 桌面端:双列布局
- 移动端:单列堆叠
- 使用 CSS Grid/Flexbox
验收标准
- 访问
http://localhost:8080/exercise可正常显示 - 图表正确展示近 30 天数据
- 筛选器可按日期范围和类型过滤
- 在手机端(宽度 <768px)布局正常
- 数据为空时显示友好的空状态提示
任务 P1-2:添加饮食页面
优先级: ⭐⭐⭐⭐⭐
预计工作量: 4-6 小时
依赖: 无
实现步骤
1. 后端路由(src/vitals/web/app.py)
@app.get("/meal")
async def meal_page():
"""饮食页面"""
return HTMLResponse(content=get_meal_page_html())
@app.get("/api/meals/nutrition")
async def get_nutrition_stats(days: int = 30):
"""获取营养统计"""
# 蛋白质/碳水/脂肪总量
# 按餐次分组
2. 前端页面实现
页面包含以下模块:
- 日历视图:
- 月历展示每日摄入卡路里
- 点击日期查看详细餐次
- 颜色编码(低于 TDEE 绿色,超出红色)
- 营养统计:
- 蛋白质/碳水/脂肪饼图
- 近 7 天营养摄入趋势
- 餐次分布:
- 柱状图:早餐/午餐/晚餐/加餐各占比
- 照片墙(如有照片):
- Grid 布局展示食物照片缩略图
- 点击放大查看
- 记录列表:
- 按日期分组显示
- 展示食物描述、营养成分
3. 照片静态文件访问
如果 Meal 记录中有 photo_path,需要配置静态文件访问:
# 在 app.py 中
photos_dir = Path.home() / ".vitals" / "photos"
if photos_dir.exists():
app.mount("/photos", StaticFiles(directory=photos_dir), name="photos")
验收标准
- 日历视图可正常展示当月数据
- 点击日期可查看该日所有餐次
- 营养成分饼图准确显示比例
- 照片可正常加载和预览(如有)
- 空状态提示友好
任务 P1-3:添加睡眠和体重页面
优先级: ⭐⭐⭐⭐
预计工作量: 3-4 小时
依赖: 无
实现步骤
1. 睡眠页面(/sleep)
后端路由:
@app.get("/sleep")
async def sleep_page():
"""睡眠页面"""
return HTMLResponse(content=get_sleep_page_html())
前端模块:
- 睡眠时长趋势折线图(近 30 天)
- 质量评分热力图(7x4 周视图)
- 入睡时间分布散点图
- 平均睡眠时长/质量统计卡片
- 最佳/最差睡眠记录
2. 体重页面(/weight)
后端路由:
@app.get("/weight")
async def weight_page():
"""体重页面"""
return HTMLResponse(content=get_weight_page_html())
@app.get("/api/weight/goal")
async def get_weight_goal():
"""获取目标体重(从用户配置)"""
前端模块:
- 体重曲线(Chart.js,支持缩放)
- 体脂率曲线(双 Y 轴)
- 目标线(用户配置中的目标体重)
- BMI 计算器
- 统计卡片:当前体重、起始体重、变化量、距目标
- 日期范围选择器(7天/30天/90天/全部)
验收标准
- 睡眠页面图表准确展示数据
- 体重页面支持日期范围切换
- 目标线正确显示(如用户配置了目标)
- BMI 计算准确(BMI = 体重kg / (身高m)²)
- 数据点可悬停查看详细信息
任务 P1-4:Web 数据录入功能
优先级: ⭐⭐⭐⭐⭐
预计工作量: 6-8 小时
依赖: P1-1、P1-2、P1-3
实现步骤
1. 后端 POST API(src/vitals/web/app.py)
from pydantic import BaseModel, validator
from fastapi import File, UploadFile
# Pydantic 输入模型
class ExerciseInput(BaseModel):
date: str # YYYY-MM-DD
type: str
duration: int
calories: Optional[int] = None
distance: Optional[float] = None
heart_rate_avg: Optional[int] = None
notes: Optional[str] = None
@validator('duration')
def validate_duration(cls, v):
if v <= 0 or v > 1440:
raise ValueError('时长必须在 1-1440 分钟之间')
return v
class MealInput(BaseModel):
date: str
meal_type: str
description: str
calories: Optional[int] = None
protein: Optional[float] = None
carbs: Optional[float] = None
fat: Optional[float] = None
class SleepInput(BaseModel):
date: str
bedtime: Optional[str] = None # HH:MM
wake_time: Optional[str] = None
duration: float
quality: int
notes: Optional[str] = None
@validator('quality')
def validate_quality(cls, v):
if v < 1 or v > 5:
raise ValueError('质量评分必须在 1-5 之间')
return v
class WeightInput(BaseModel):
date: str
weight_kg: float
body_fat_pct: Optional[float] = None
muscle_mass: Optional[float] = None
notes: Optional[str] = None
@validator('weight_kg')
def validate_weight(cls, v):
if v < 20 or v > 300:
raise ValueError('体重必须在 20-300 kg 之间')
return v
# POST 路由
@app.post("/api/exercise")
async def add_exercise_api(data: ExerciseInput):
"""添加运动记录"""
exercise = Exercise(
date=date.fromisoformat(data.date),
type=data.type,
duration=data.duration,
calories=data.calories or estimate_exercise_calories(data.type, data.duration),
distance=data.distance,
heart_rate_avg=data.heart_rate_avg,
notes=data.notes,
source="web",
)
record_id = db.add_exercise(exercise)
return {"success": True, "id": record_id}
@app.post("/api/meal")
async def add_meal_api(
data: MealInput,
photo: Optional[UploadFile] = File(None)
):
"""添加饮食记录(支持照片上传)"""
photo_path = None
if photo:
# 保存照片
photos_dir = Path.home() / ".vitals" / "photos" / data.date[:7] # YYYY-MM
photos_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名
timestamp = datetime.now().strftime("%H%M%S")
photo_path = photos_dir / f"{data.date}_{data.meal_type}_{timestamp}.jpg"
# 保存文件
with open(photo_path, "wb") as f:
f.write(await photo.read())
# 如果没有提供卡路里,尝试估算
if data.calories is None and data.description:
from ..core.calories import estimate_meal_calories
result = estimate_meal_calories(data.description)
calories = result["total_calories"]
protein = result["total_protein"]
carbs = result["total_carbs"]
fat = result["total_fat"]
else:
calories = data.calories or 0
protein = data.protein
carbs = data.carbs
fat = data.fat
meal = Meal(
date=date.fromisoformat(data.date),
meal_type=data.meal_type,
description=data.description,
calories=calories,
protein=protein,
carbs=carbs,
fat=fat,
photo_path=str(photo_path) if photo_path else None,
)
record_id = db.add_meal(meal)
return {"success": True, "id": record_id, "calories": calories}
@app.post("/api/sleep")
async def add_sleep_api(data: SleepInput):
"""添加睡眠记录"""
sleep = Sleep(
date=date.fromisoformat(data.date),
bedtime=time.fromisoformat(data.bedtime) if data.bedtime else None,
wake_time=time.fromisoformat(data.wake_time) if data.wake_time else None,
duration=data.duration,
quality=data.quality,
notes=data.notes,
source="web",
)
record_id = db.add_sleep(sleep)
return {"success": True, "id": record_id}
@app.post("/api/weight")
async def add_weight_api(data: WeightInput):
"""添加体重记录"""
weight = Weight(
date=date.fromisoformat(data.date),
weight_kg=data.weight_kg,
body_fat_pct=data.body_fat_pct,
muscle_mass=data.muscle_mass,
notes=data.notes,
)
record_id = db.add_weight(weight)
return {"success": True, "id": record_id}
2. 前端表单实现
设计方案:使用模态框(Modal)
在各页面添加浮动按钮:
<button class="fab" onclick="openAddModal()">+</button>
<style>
.fab {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
font-size: 24px;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
cursor: pointer;
transition: transform 0.2s;
}
.fab:hover {
transform: scale(1.1);
}
</style>
模态框表单:
<div id="addModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">×</span>
<h2>添加运动记录</h2>
<form id="addExerciseForm">
<label>日期</label>
<input type="date" name="date" required>
<label>运动类型</label>
<select name="type" required>
<option>跑步</option>
<option>游泳</option>
<option>骑行</option>
<option>力量训练</option>
<option>其他</option>
</select>
<label>时长(分钟)</label>
<input type="number" name="duration" min="1" max="1440" required>
<label>距离(公里,可选)</label>
<input type="number" name="distance" step="0.1" min="0">
<label>卡路里(可选,留空自动估算)</label>
<input type="number" name="calories" min="0">
<label>备注</label>
<textarea name="notes" rows="3"></textarea>
<button type="submit">保存</button>
</form>
</div>
</div>
<script>
async function submitForm(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/exercise', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert('添加成功!');
closeModal();
location.reload(); // 刷新页面
} else {
alert('添加失败:' + result.error);
}
} catch (error) {
alert('添加失败:' + error.message);
}
}
document.getElementById('addExerciseForm').addEventListener('submit', submitForm);
</script>
3. 照片上传(饮食表单)
<form id="addMealForm" enctype="multipart/form-data">
<!-- ...其他字段... -->
<label>食物照片(可选)</label>
<input type="file" name="photo" accept="image/*">
<p class="hint">支持 JPG/PNG,最大 5MB</p>
<button type="submit">保存</button>
</form>
<script>
async function submitMealForm(event) {
event.preventDefault();
const formData = new FormData(event.target);
// 文件大小校验
const photo = formData.get('photo');
if (photo && photo.size > 5 * 1024 * 1024) {
alert('照片不能超过 5MB');
return;
}
try {
const response = await fetch('/api/meal', {
method: 'POST',
body: formData // 直接发送 FormData,浏览器自动设置 Content-Type
});
const result = await response.json();
if (result.success) {
alert(`添加成功!估算热量: ${result.calories} 卡`);
closeModal();
location.reload();
}
} catch (error) {
alert('添加失败:' + error.message);
}
}
</script>
验收标准
- 所有 4 个 POST API 可正常工作
- 表单校验正确(必填项、数值范围)
- 照片上传成功后可在饮食页面查看
- 饮食表单留空卡路里时自动估算
- 提交成功后页面刷新并显示新记录
- 错误提示友好(网络错误、校验失败)
- 表单样式与页面整体风格一致
任务 P1-5:DeepSeek 智能食物识别
优先级: ⭐⭐⭐⭐⭐
预计工作量: 6-8 小时
依赖: P1-2 饮食页面
功能概述
在饮食记录时,用户可通过以下两种方式智能识别食物并自动估算卡路里:
- 上传照片:DeepSeek Vision 识别图片中的食物
- 文字描述:DeepSeek 解析自然语言描述(如"一碗米饭加红烧肉")
系统自动调用 DeepSeek API,返回食物列表和营养估算,用户确认后保存。
实现步骤
1. 创建 DeepSeek 适配器(src/vitals/vision/providers/deepseek.py)
"""DeepSeek Vision API 适配器"""
import base64
import os
from pathlib import Path
from typing import Optional
import httpx
from ...core.calories import estimate_meal_calories
class DeepSeekVisionAnalyzer:
"""DeepSeek Vision 食物识别分析器"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY")
self.base_url = "https://api.deepseek.com/v1"
def analyze_image(self, image_path: Path) -> dict:
"""
分析食物图片
返回:
{
"description": "米饭、红烧排骨、西兰花",
"total_calories": 680,
"items": [...]
}
"""
if not self.api_key:
raise ValueError("需要设置 DEEPSEEK_API_KEY 环境变量")
# 读取并编码图片
image_data = self._encode_image(image_path)
media_type = self._get_media_type(image_path)
# 调用 DeepSeek Vision API
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": "deepseek-chat", # 使用支持 vision 的模型
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{image_data}"
}
},
{
"type": "text",
"text": """请分析这张食物图片,识别出所有食物。
要求:
1. 列出所有可识别的食物,用中文名称
2. 估计每种食物的大致份量(如:一碗米饭、100g红烧肉)
3. 按照 "食物1+食物2+食物3" 的格式返回
例如返回格式:一碗米饭+红烧排骨+西兰花
只返回食物列表,不需要其他解释。"""
}
]
}
],
"temperature": 0.3,
}
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
result = response.json()
# 解析响应
description = result["choices"][0]["message"]["content"].strip()
# 使用卡路里计算模块估算营养成分
nutrition = estimate_meal_calories(description)
nutrition["description"] = description
nutrition["provider"] = "deepseek"
return nutrition
def analyze_text(self, text_description: str) -> dict:
"""
分析文字描述的食物
输入: "今天吃了一碗米饭、两个鸡蛋还有一杯牛奶"
返回: 结构化的食物列表和营养估算
"""
if not self.api_key:
raise ValueError("需要设置 DEEPSEEK_API_KEY 环境变量")
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": "deepseek-chat",
"messages": [
{
"role": "system",
"content": """你是专业的营养分析助手。用户会告诉你吃了什么,你需要:
1. 识别所有食物
2. 标准化食物名称(如"面"→"面条","肉"→"猪肉")
3. 提取数量(如"两个"、"一碗"、"100g")
4. 按照 "食物1+食物2+食物3" 格式返回
示例:
用户输入:"今天吃了一碗米饭、两个鸡蛋还有一杯牛奶"
你返回:"一碗米饭+两个鸡蛋+一杯牛奶"
只返回标准化后的食物列表,不需要其他解释。"""
},
{
"role": "user",
"content": text_description
}
],
"temperature": 0.3,
}
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
result = response.json()
# 解析响应
standardized = result["choices"][0]["message"]["content"].strip()
# 使用卡路里计算模块估算营养成分
nutrition = estimate_meal_calories(standardized)
nutrition["description"] = standardized
nutrition["provider"] = "deepseek"
nutrition["original_input"] = text_description
return nutrition
def _encode_image(self, image_path: Path) -> str:
"""将图片编码为 base64"""
with open(image_path, "rb") as f:
return base64.standard_b64encode(f.read()).decode("utf-8")
def _get_media_type(self, image_path: Path) -> str:
"""获取图片的 MIME 类型"""
suffix = image_path.suffix.lower()
media_types = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
return media_types.get(suffix, "image/jpeg")
def get_deepseek_analyzer(api_key: Optional[str] = None) -> DeepSeekVisionAnalyzer:
"""获取 DeepSeek 分析器"""
return DeepSeekVisionAnalyzer(api_key)
2. 更新分析器工厂(src/vitals/vision/analyzer.py)
在现有的 get_analyzer() 函数中增加 DeepSeek 选项:
def get_analyzer(
provider: str = "deepseek", # 默认使用 DeepSeek
api_key: Optional[str] = None
) -> FoodAnalyzer:
"""
获取食物分析器
provider: "deepseek" | "claude" | "local"
"""
if provider == "deepseek":
from .providers.deepseek import get_deepseek_analyzer
return get_deepseek_analyzer(api_key)
elif provider == "claude":
return ClaudeFoodAnalyzer(api_key)
else:
return LocalFoodAnalyzer()
3. 后端 API 增强(src/vitals/web/app.py)
新增智能识别接口:
from pydantic import BaseModel
class FoodRecognitionRequest(BaseModel):
"""食物识别请求"""
text: Optional[str] = None # 文字描述
image_base64: Optional[str] = None # 图片 base64(前端传)
provider: str = "deepseek" # 默认 DeepSeek
@app.post("/api/meal/recognize")
async def recognize_food(
text: Optional[str] = Form(None),
image: Optional[UploadFile] = File(None),
provider: str = Form("deepseek"),
):
"""
智能识别食物(支持照片或文字)
返回: {
"success": true,
"description": "一碗米饭+红烧排骨",
"total_calories": 680,
"items": [...],
"provider": "deepseek"
}
"""
try:
from ..vision.analyzer import get_analyzer
# 获取 API Key(优先环境变量,备选配置文件)
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
# 从配置文件读取(可选实现)
config_path = Path.home() / ".vitals" / "config.json"
if config_path.exists():
import json
config = json.loads(config_path.read_text())
api_key = config.get("deepseek_api_key")
if not api_key:
raise HTTPException(
status_code=400,
detail="未配置 DEEPSEEK_API_KEY,请先设置:vitals config set-api-key --deepseek YOUR_KEY"
)
analyzer = get_analyzer(provider=provider, api_key=api_key)
# 图片识别
if image:
# 保存临时文件
temp_dir = Path.home() / ".vitals" / "temp"
temp_dir.mkdir(parents=True, exist_ok=True)
temp_path = temp_dir / f"temp_{datetime.now().strftime('%Y%m%d%H%M%S')}.jpg"
content = await image.read()
temp_path.write_bytes(content)
try:
result = analyzer.analyze_image(temp_path)
finally:
temp_path.unlink(missing_ok=True) # 清理临时文件
return {
"success": True,
"description": result["description"],
"total_calories": result["total_calories"],
"total_protein": result.get("total_protein", 0),
"total_carbs": result.get("total_carbs", 0),
"total_fat": result.get("total_fat", 0),
"items": result.get("items", []),
"provider": provider,
}
# 文字识别
elif text:
if hasattr(analyzer, 'analyze_text'):
result = analyzer.analyze_text(text)
else:
# Fallback: 直接用 estimate_meal_calories
from ..core.calories import estimate_meal_calories
result = estimate_meal_calories(text)
result["description"] = text
result["provider"] = "local"
return {
"success": True,
"description": result["description"],
"total_calories": result["total_calories"],
"total_protein": result.get("total_protein", 0),
"total_carbs": result.get("total_carbs", 0),
"total_fat": result.get("total_fat", 0),
"items": result.get("items", []),
"provider": result.get("provider", "local"),
}
else:
raise HTTPException(status_code=400, detail="请提供照片或文字描述")
except Exception as e:
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
4. 前端表单改进(饮食页面)
在 /meal 的添加表单中增加"智能识别"功能:
<form id="addMealForm" enctype="multipart/form-data">
<label>日期</label>
<input type="date" name="date_str" required>
<label>餐次</label>
<select name="meal_type" required>
<option>早餐</option>
<option>午餐</option>
<option>晚餐</option>
<option>加餐</option>
</select>
<!-- 智能识别区域 -->
<div class="recognition-section">
<h4>智能识别(可选)</h4>
<div class="tabs">
<button type="button" class="tab active" onclick="switchTab('photo')">📷 拍照识别</button>
<button type="button" class="tab" onclick="switchTab('text')">✏️ 文字识别</button>
</div>
<div id="photoTab" class="tab-content active">
<label>上传食物照片</label>
<input type="file" id="recognizePhoto" accept="image/*">
<button type="button" onclick="recognizeFromPhoto()">🔍 识别食物</button>
</div>
<div id="textTab" class="tab-content" style="display:none;">
<label>描述吃了什么</label>
<textarea id="recognizeText" rows="3" placeholder="例如:今天午餐吃了一碗米饭、红烧肉和炒青菜"></textarea>
<button type="button" onclick="recognizeFromText()">🔍 识别食物</button>
</div>
<div id="recognitionResult" class="recognition-result" style="display:none;">
<h5>识别结果</h5>
<p class="description"></p>
<p class="calories"></p>
<ul class="items-list"></ul>
<button type="button" onclick="applyRecognition()">✓ 使用此结果</button>
</div>
</div>
<hr>
<!-- 手动输入区域 -->
<label>食物描述</label>
<textarea name="description" rows="3" placeholder="也可手动输入"></textarea>
<label>卡路里(可选,留空自动估算)</label>
<input type="number" name="calories" min="0">
<label>蛋白质/碳水/脂肪(可选)</label>
<div class="grid">
<input type="number" name="protein" step="0.1" placeholder="蛋白质 g">
<input type="number" name="carbs" step="0.1" placeholder="碳水 g">
<input type="number" name="fat" step="0.1" placeholder="脂肪 g">
</div>
<label>食物照片(可选,<5MB)</label>
<input type="file" name="photo" accept="image/*">
<button type="submit">保存</button>
</form>
<style>
.recognition-section {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.recognition-section h4 {
margin-bottom: 12px;
color: #333;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.tab {
padding: 8px 16px;
border: none;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.tab.active {
background: #667eea;
color: white;
}
.tab-content {
padding: 12px;
background: white;
border-radius: 6px;
}
.recognition-result {
margin-top: 16px;
padding: 12px;
background: #e0f2fe;
border-radius: 6px;
border-left: 4px solid #0284c7;
}
.recognition-result h5 {
margin-bottom: 8px;
color: #0369a1;
}
.recognition-result .description {
font-weight: 600;
margin-bottom: 4px;
}
.recognition-result .calories {
color: #10b981;
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 8px;
}
.recognition-result .items-list {
list-style: none;
font-size: 0.9rem;
color: #666;
margin-bottom: 12px;
}
.recognition-result .items-list li {
padding: 4px 0;
}
</style>
<script>
let lastRecognitionResult = null;
function switchTab(tab) {
// 切换 tab 样式
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
// 切换内容
document.getElementById('photoTab').style.display = tab === 'photo' ? 'block' : 'none';
document.getElementById('textTab').style.display = tab === 'text' ? 'block' : 'none';
}
async function recognizeFromPhoto() {
const fileInput = document.getElementById('recognizePhoto');
const file = fileInput.files[0];
if (!file) {
alert('请先选择照片');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('照片不能超过 5MB');
return;
}
const formData = new FormData();
formData.append('image', file);
formData.append('provider', 'deepseek');
try {
showLoading('识别中,请稍候...');
const response = await fetch('/api/meal/recognize', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '识别失败');
}
const result = await response.json();
displayRecognitionResult(result);
} catch (error) {
alert('识别失败:' + error.message);
} finally {
hideLoading();
}
}
async function recognizeFromText() {
const text = document.getElementById('recognizeText').value.trim();
if (!text) {
alert('请输入食物描述');
return;
}
const formData = new FormData();
formData.append('text', text);
formData.append('provider', 'deepseek');
try {
showLoading('分析中,请稍候...');
const response = await fetch('/api/meal/recognize', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '识别失败');
}
const result = await response.json();
displayRecognitionResult(result);
} catch (error) {
alert('识别失败:' + error.message);
} finally {
hideLoading();
}
}
function displayRecognitionResult(result) {
lastRecognitionResult = result;
const resultDiv = document.getElementById('recognitionResult');
resultDiv.style.display = 'block';
resultDiv.querySelector('.description').textContent = result.description;
resultDiv.querySelector('.calories').textContent = `总热量: ${result.total_calories} 卡`;
const itemsList = resultDiv.querySelector('.items-list');
if (result.items && result.items.length) {
itemsList.innerHTML = result.items.map(item =>
`<li>${item.name}: ${item.calories} 卡 (蛋白质 ${item.protein}g, 碳水 ${item.carbs}g, 脂肪 ${item.fat}g)</li>`
).join('');
} else {
itemsList.innerHTML = '';
}
}
function applyRecognition() {
if (!lastRecognitionResult) return;
// 填充表单
document.querySelector('textarea[name="description"]').value = lastRecognitionResult.description;
document.querySelector('input[name="calories"]').value = lastRecognitionResult.total_calories;
document.querySelector('input[name="protein"]').value = lastRecognitionResult.total_protein;
document.querySelector('input[name="carbs"]').value = lastRecognitionResult.total_carbs;
document.querySelector('input[name="fat"]').value = lastRecognitionResult.total_fat;
alert('已自动填入识别结果,请检查后提交');
document.getElementById('recognitionResult').style.display = 'none';
}
function showLoading(message) {
// 简单实现,可以用更好的 loading 组件
const loading = document.createElement('div');
loading.id = 'loadingOverlay';
loading.innerHTML = `<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;color:white;font-size:1.2rem;z-index:9999;">${message}</div>`;
document.body.appendChild(loading);
}
function hideLoading() {
document.getElementById('loadingOverlay')?.remove();
}
</script>
5. CLI 配置管理(src/vitals/cli.py)
增加 API Key 配置命令:
@config_app.command("set-api-key")
def config_set_api_key(
deepseek: Optional[str] = typer.Option(None, "--deepseek", help="DeepSeek API Key"),
claude: Optional[str] = typer.Option(None, "--claude", help="Claude API Key"),
):
"""设置 AI 服务 API Key"""
import json
config_path = Path.home() / ".vitals" / "config.json"
# 读取现有配置
if config_path.exists():
config = json.loads(config_path.read_text())
else:
config = {}
# 更新
if deepseek:
config["deepseek_api_key"] = deepseek
console.print("[green]✓[/green] DeepSeek API Key 已保存")
if claude:
config["claude_api_key"] = claude
console.print("[green]✓[/green] Claude API Key 已保存")
# 保存
config_path.write_text(json.dumps(config, indent=2))
console.print(f" 配置文件: {config_path}")
@config_app.command("show-api-keys")
def config_show_api_keys():
"""显示已配置的 API Keys(仅显示前缀)"""
import json
config_path = Path.home() / ".vitals" / "config.json"
if not config_path.exists():
console.print("暂无 API Key 配置")
return
config = json.loads(config_path.read_text())
table = Table(title="API Keys")
table.add_column("服务", style="cyan")
table.add_column("状态", style="green")
for service in ["deepseek_api_key", "claude_api_key"]:
key = config.get(service)
if key:
masked = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
table.add_row(service.replace("_api_key", ""), f"✓ 已配置 ({masked})")
else:
table.add_row(service.replace("_api_key", ""), "✗ 未配置")
console.print(table)
6. 依赖更新(pyproject.toml)
在 dependencies 中添加:
dependencies = [
# ...existing...
"httpx>=0.27.0", # DeepSeek API 调用
]
7. 测试(tests/test_deepseek.py)
"""DeepSeek 食物识别测试"""
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from src.vitals.vision.providers.deepseek import DeepSeekVisionAnalyzer
class TestDeepSeekAnalyzer:
"""DeepSeek 分析器测试"""
def test_requires_api_key(self):
"""测试需要 API Key"""
analyzer = DeepSeekVisionAnalyzer(api_key=None)
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
analyzer.analyze_text("米饭")
@patch('httpx.Client.post')
def test_analyze_text(self, mock_post):
"""测试文字识别"""
# Mock API 响应
mock_response = Mock()
mock_response.json.return_value = {
"choices": [{
"message": {
"content": "一碗米饭+两个鸡蛋"
}
}]
}
mock_post.return_value = mock_response
analyzer = DeepSeekVisionAnalyzer(api_key="test-key")
result = analyzer.analyze_text("今天吃了一碗米饭和两个鸡蛋")
assert result["description"] is not None
assert result["total_calories"] > 0
assert result["provider"] == "deepseek"
@patch('httpx.Client.post')
def test_analyze_image(self, mock_post, tmp_path):
"""测试图片识别"""
# 创建测试图片
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b"fake image data")
# Mock API 响应
mock_response = Mock()
mock_response.json.return_value = {
"choices": [{
"message": {
"content": "米饭+红烧肉+青菜"
}
}]
}
mock_post.return_value = mock_response
analyzer = DeepSeekVisionAnalyzer(api_key="test-key")
result = analyzer.analyze_image(test_image)
assert result["description"] is not None
assert result["total_calories"] > 0
使用流程
配置 API Key:
# 方式 1: 环境变量(推荐)
export DEEPSEEK_API_KEY="sk-..."
# 方式 2: CLI 配置
vitals config set-api-key --deepseek sk-...
# 查看配置
vitals config show-api-keys
Web 界面使用:
- 访问
/meal页面,点击右下角 "+" - 选择"📷 拍照识别"或"✏️ 文字识别"
- 上传照片或输入文字描述
- 点击"🔍 识别食物"
- 系统调用 DeepSeek API 返回结果
- 确认后点"✓ 使用此结果"自动填入表单
- 可手动调整后提交保存
验收标准
- DeepSeek Vision 适配器实现完成
/api/meal/recognize接口可正常工作- 饮食表单新增"智能识别"区域(照片/文字切换)
- 照片识别成功返回食物列表和卡路里
- 文字识别成功解析自然语言描述
- 识别结果可一键填入表单
- API Key 可通过 CLI 配置和查看
- 未配置 API Key 时有友好提示
- 测试覆盖主要流程(使用 mock)
技术细节
DeepSeek API 文档: https://platform.deepseek.com/api-docs/
计费:
- DeepSeek-V3 (文字): ¥1/M tokens (输入), ¥2/M tokens (输出)
- DeepSeek-Vision: 约 ¥0.5-1/次(图片)
优化建议:
- 缓存识别结果(同图片/文字不重复调用)
- 添加识别历史(用户可复用之前识别的食物)
- 支持用户修正识别结果(加入个人食物库)
📦 P2 - 数据管理功能(第二优先级)
目标: 增强数据的可维护性和可移植性
任务 P2-1:数据导出功能
优先级: ⭐⭐⭐
预计工作量: 3-4 小时
依赖: 无
实现步骤
1. 创建导出模块(src/vitals/core/export.py)
"""数据导出模块"""
import csv
import json
from datetime import date
from pathlib import Path
from typing import Literal, Optional
from . import database as db
def export_all_data_json(output_path: Path) -> None:
"""导出所有数据为 JSON"""
data = {
"version": "1.0",
"export_date": date.today().isoformat(),
"exercises": [e.to_dict() for e in db.get_exercises()],
"meals": [m.to_dict() for m in db.get_meals()],
"sleep": [s.to_dict() for s in db.get_sleep_records()],
"weight": [w.to_dict() for w in db.get_weight_records()],
"config": db.get_config().to_dict(),
}
output_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
def export_to_csv(
data_type: Literal["exercise", "meal", "sleep", "weight"],
output_path: Path,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
) -> None:
"""导出指定类型数据为 CSV"""
if data_type == "exercise":
records = db.get_exercises(start_date, end_date)
fieldnames = ["date", "type", "duration", "calories", "distance",
"heart_rate_avg", "source", "notes"]
elif data_type == "meal":
records = db.get_meals(start_date, end_date)
fieldnames = ["date", "meal_type", "description", "calories",
"protein", "carbs", "fat", "photo_path"]
elif data_type == "sleep":
records = db.get_sleep_records(start_date, end_date)
fieldnames = ["date", "bedtime", "wake_time", "duration",
"quality", "deep_sleep_mins", "source", "notes"]
elif data_type == "weight":
records = db.get_weight_records(start_date, end_date)
fieldnames = ["date", "weight_kg", "body_fat_pct", "muscle_mass", "notes"]
else:
raise ValueError(f"不支持的数据类型: {data_type}")
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for record in records:
data = record.to_dict()
# 只保留 CSV 需要的字段
row = {k: data.get(k, "") for k in fieldnames}
writer.writerow(row)
def import_from_json(input_path: Path) -> dict:
"""从 JSON 导入数据"""
data = json.loads(input_path.read_text(encoding="utf-8"))
stats = {
"exercise": 0,
"meal": 0,
"sleep": 0,
"weight": 0,
}
# 导入运动记录
for item in data.get("exercises", []):
from .models import Exercise
exercise = Exercise(**item)
db.add_exercise(exercise)
stats["exercise"] += 1
# 导入饮食记录
for item in data.get("meals", []):
from .models import Meal
meal = Meal(**item)
db.add_meal(meal)
stats["meal"] += 1
# 导入睡眠记录
for item in data.get("sleep", []):
from .models import Sleep
sleep = Sleep(**item)
db.add_sleep(sleep)
stats["sleep"] += 1
# 导入体重记录
for item in data.get("weight", []):
from .models import Weight
weight = Weight(**item)
db.add_weight(weight)
stats["weight"] += 1
return stats
2. CLI 接入(src/vitals/cli.py)
# 新增子命令组
export_app = typer.Typer(help="数据导出")
app.add_typer(export_app, name="export")
@export_app.command("json")
def export_json(
output: Path = typer.Argument(..., help="输出文件路径"),
):
"""导出所有数据为 JSON"""
from .core.export import export_all_data_json
try:
export_all_data_json(output)
console.print(f"[green]✓[/green] 数据已导出: {output}")
except Exception as e:
console.print(f"[red]✗[/red] 导出失败: {e}")
raise typer.Exit(1)
@export_app.command("csv")
def export_csv(
data_type: str = typer.Option(..., "--type", "-t", help="数据类型 (exercise/meal/sleep/weight)"),
output: Path = typer.Argument(..., help="输出文件路径"),
start_date: Optional[str] = typer.Option(None, "--start", help="起始日期 (YYYY-MM-DD)"),
end_date: Optional[str] = typer.Option(None, "--end", help="结束日期 (YYYY-MM-DD)"),
):
"""导出指定类型数据为 CSV"""
from .core.export import export_to_csv
start = date.fromisoformat(start_date) if start_date else None
end = date.fromisoformat(end_date) if end_date else None
try:
export_to_csv(data_type, output, start, end)
console.print(f"[green]✓[/green] {data_type} 数据已导出: {output}")
except Exception as e:
console.print(f"[red]✗[/red] 导出失败: {e}")
raise typer.Exit(1)
@export_app.command("import-json")
def import_json_cmd(
input_file: Path = typer.Argument(..., help="JSON 文件路径"),
):
"""从 JSON 导入数据"""
from .core.export import import_from_json
if not input_file.exists():
console.print(f"[red]✗[/red] 文件不存在: {input_file}")
raise typer.Exit(1)
try:
stats = import_from_json(input_file)
console.print(f"[green]✓[/green] 数据导入完成:")
console.print(f" 运动记录: {stats['exercise']} 条")
console.print(f" 饮食记录: {stats['meal']} 条")
console.print(f" 睡眠记录: {stats['sleep']} 条")
console.print(f" 体重记录: {stats['weight']} 条")
except Exception as e:
console.print(f"[red]✗[/red] 导入失败: {e}")
raise typer.Exit(1)
3. 测试(tests/test_export.py)
"""数据导出测试"""
from datetime import date
from pathlib import Path
import pytest
from src.vitals.core import database as db
from src.vitals.core.models import Exercise, Weight
from src.vitals.core.export import (
export_all_data_json,
export_to_csv,
import_from_json,
)
def test_export_all_data_json(tmp_path):
"""测试导出所有数据为 JSON"""
# 添加测试数据
db.add_exercise(Exercise(date=date(2026, 1, 18), type="跑步", duration=30, calories=240))
db.add_weight(Weight(date=date(2026, 1, 18), weight_kg=72.5))
output_path = tmp_path / "export.json"
export_all_data_json(output_path)
assert output_path.exists()
data = json.loads(output_path.read_text())
assert "exercises" in data
assert "weight" in data
assert len(data["exercises"]) > 0
def test_export_to_csv(tmp_path):
"""测试导出 CSV"""
db.add_exercise(Exercise(date=date(2026, 1, 18), type="跑步", duration=30, calories=240))
output_path = tmp_path / "exercise.csv"
export_to_csv("exercise", output_path)
assert output_path.exists()
content = output_path.read_text()
assert "date,type,duration" in content
def test_import_from_json(tmp_path):
"""测试从 JSON 导入"""
# 先导出
db.add_exercise(Exercise(date=date(2026, 1, 18), type="跑步", duration=30, calories=240))
json_path = tmp_path / "export.json"
export_all_data_json(json_path)
# 清空数据库
# (测试环境自动隔离,这里仅验证导入逻辑)
# 重新导入
stats = import_from_json(json_path)
assert stats["exercise"] > 0
验收标准
vitals export json ~/backup.json成功导出vitals export csv --type exercise ~/exercise.csv成功导出- JSON 文件可被
import-json重新导入 - CSV 格式与
vitals import csv兼容 - 测试覆盖导出和导入的往返一致性
任务 P2-2:自动备份数据库
优先级: ⭐⭐⭐
预计工作量: 2-3 小时
依赖: 无
实现步骤
1. 创建备份模块(src/vitals/core/backup.py)
"""数据库备份模块"""
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from . import database as db
def get_backup_dir() -> Path:
"""获取备份目录"""
backup_dir = Path.home() / ".vitals" / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
return backup_dir
def backup_database(backup_dir: Optional[Path] = None) -> Path:
"""
备份数据库
返回: 备份文件路径
"""
if not backup_dir:
backup_dir = get_backup_dir()
db_path = db.get_db_path()
if not db_path.exists():
raise FileNotFoundError("数据库文件不存在")
# 生成备份文件名(包含时间戳)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = backup_dir / f"vitals_{timestamp}.db"
# 复制数据库文件
shutil.copy2(db_path, backup_path)
# 清理旧备份
cleanup_old_backups(backup_dir, keep_days=7)
return backup_path
def cleanup_old_backups(backup_dir: Path, keep_days: int = 7) -> int:
"""
清理旧备份(保留最近 N 天)
返回: 删除的文件数量
"""
cutoff_date = datetime.now() - timedelta(days=keep_days)
deleted = 0
for backup_file in backup_dir.glob("vitals_*.db"):
try:
# 从文件名提取日期
timestamp_str = backup_file.stem.replace("vitals_", "")
file_date = datetime.strptime(timestamp_str[:8], "%Y%m%d")
if file_date < cutoff_date:
backup_file.unlink()
deleted += 1
except (ValueError, IndexError):
# 文件名格式不对,跳过
continue
return deleted
def list_backups(backup_dir: Optional[Path] = None) -> list[dict]:
"""
列出所有备份
返回: [{"path": Path, "date": datetime, "size": int}, ...]
"""
if not backup_dir:
backup_dir = get_backup_dir()
backups = []
for backup_file in sorted(backup_dir.glob("vitals_*.db"), reverse=True):
try:
timestamp_str = backup_file.stem.replace("vitals_", "")
file_date = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
size = backup_file.stat().st_size
backups.append({
"path": backup_file,
"date": file_date,
"size": size,
})
except (ValueError, IndexError):
continue
return backups
def restore_database(backup_path: Path) -> bool:
"""
恢复数据库
注意:会覆盖当前数据库!
"""
if not backup_path.exists():
raise FileNotFoundError(f"备份文件不存在: {backup_path}")
# 校验备份文件
if backup_path.stat().st_size == 0:
raise ValueError("备份文件为空")
db_path = db.get_db_path()
# 先备份当前数据库
if db_path.exists():
current_backup = db_path.parent / f"vitals_before_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
shutil.copy2(db_path, current_backup)
# 恢复备份
shutil.copy2(backup_path, db_path)
return True
2. CLI 接入(src/vitals/cli.py)
# 新增子命令组
backup_app = typer.Typer(help="数据备份")
app.add_typer(backup_app, name="backup")
@backup_app.command("create")
def backup_create():
"""创建数据库备份"""
from .core.backup import backup_database
try:
backup_path = backup_database()
size_mb = backup_path.stat().st_size / 1024 / 1024
console.print(f"[green]✓[/green] 备份已创建: {backup_path}")
console.print(f" 大小: {size_mb:.2f} MB")
except Exception as e:
console.print(f"[red]✗[/red] 备份失败: {e}")
raise typer.Exit(1)
@backup_app.command("list")
def backup_list():
"""列出所有备份"""
from .core.backup import list_backups
backups = list_backups()
if not backups:
console.print("暂无备份")
return
table = Table(title="数据库备份")
table.add_column("日期时间", style="cyan")
table.add_column("大小", style="green")
table.add_column("路径", style="yellow")
for backup in backups:
size_mb = backup["size"] / 1024 / 1024
table.add_row(
backup["date"].strftime("%Y-%m-%d %H:%M:%S"),
f"{size_mb:.2f} MB",
str(backup["path"]),
)
console.print(table)
@backup_app.command("restore")
def backup_restore(
backup_file: Path = typer.Argument(..., help="备份文件路径"),
force: bool = typer.Option(False, "--force", "-f", help="跳过确认"),
):
"""恢复数据库(会覆盖当前数据库!)"""
from .core.backup import restore_database
if not force:
confirm = typer.confirm("⚠️ 恢复操作会覆盖当前数据库,是否继续?")
if not confirm:
console.print("已取消")
raise typer.Exit(0)
try:
restore_database(backup_file)
console.print(f"[green]✓[/green] 数据库已恢复: {backup_file}")
except Exception as e:
console.print(f"[red]✗[/red] 恢复失败: {e}")
raise typer.Exit(1)
@backup_app.command("cleanup")
def backup_cleanup(
days: int = typer.Option(7, "--days", "-d", help="保留最近多少天的备份"),
):
"""清理旧备份"""
from .core.backup import cleanup_old_backups, get_backup_dir
deleted = cleanup_old_backups(get_backup_dir(), keep_days=days)
console.print(f"[green]✓[/green] 已清理 {deleted} 个旧备份")
3. 自动备份策略
在 cli.py 的主回调函数中添加自动备份:
@app.callback()
def main(
ctx: typer.Context,
):
"""Vitals - 本地优先的健康管理应用"""
db.init_db()
# 自动备份(仅在数据写入命令后执行)
# 检查是否是写入命令
write_commands = ["log", "import"]
if ctx.invoked_subcommand in write_commands:
# 注册退出时备份
import atexit
from .core.backup import backup_database, get_backup_dir
def auto_backup():
try:
# 检查今天是否已备份
backups = list(get_backup_dir().glob(f"vitals_{date.today().strftime('%Y%m%d')}_*.db"))
if not backups:
backup_database()
except Exception:
pass # 静默失败,不影响主流程
atexit.register(auto_backup)
4. 测试(tests/test_backup.py)
"""数据库备份测试"""
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from src.vitals.core.backup import (
backup_database,
cleanup_old_backups,
list_backups,
restore_database,
)
def test_backup_database(tmp_path):
"""测试备份数据库"""
backup_path = backup_database(tmp_path)
assert backup_path.exists()
assert backup_path.name.startswith("vitals_")
assert backup_path.suffix == ".db"
def test_cleanup_old_backups(tmp_path):
"""测试清理旧备份"""
# 创建模拟备份文件
old_date = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
new_date = datetime.now().strftime("%Y%m%d")
old_backup = tmp_path / f"vitals_{old_date}_120000.db"
new_backup = tmp_path / f"vitals_{new_date}_120000.db"
old_backup.write_text("old")
new_backup.write_text("new")
# 清理 7 天前的备份
deleted = cleanup_old_backups(tmp_path, keep_days=7)
assert deleted == 1
assert not old_backup.exists()
assert new_backup.exists()
def test_list_backups(tmp_path):
"""测试列出备份"""
# 创建测试备份
backup_path = backup_database(tmp_path)
backups = list_backups(tmp_path)
assert len(backups) > 0
assert backups[0]["path"] == backup_path
def test_restore_database(tmp_path, test_db):
"""测试恢复数据库"""
# 先备份
backup_path = backup_database(tmp_path)
# 模拟修改数据库
# ...
# 恢复
result = restore_database(backup_path)
assert result is True
验收标准
vitals backup create成功创建备份vitals backup list正确显示所有备份vitals backup restore <file>可恢复数据库vitals backup cleanup --days 7正确清理旧备份- 自动备份在数据写入后执行(每天首次)
- 恢复前会自动备份当前数据库
- 测试覆盖所有备份和恢复场景
🎁 P3 - 高级功能(第三优先级)
目标: 锦上添花,提升用户粘性
任务 P3-1:定时提醒功能
优先级: ⭐⭐
预计工作量: 4-5 小时
依赖: 无
实现步骤
1. 创建提醒模块(src/vitals/core/reminder.py)
"""定时提醒模块"""
import json
import subprocess
import sys
from datetime import datetime, time
from pathlib import Path
from typing import Literal, Optional
def get_reminder_config_path() -> Path:
"""获取提醒配置文件路径"""
config_dir = Path.home() / ".vitals"
config_dir.mkdir(exist_ok=True)
return config_dir / "reminders.json"
def load_reminder_config() -> dict:
"""加载提醒配置"""
config_path = get_reminder_config_path()
if not config_path.exists():
return {"reminders": []}
return json.loads(config_path.read_text())
def save_reminder_config(config: dict):
"""保存提醒配置"""
config_path = get_reminder_config_path()
config_path.write_text(json.dumps(config, indent=2))
def add_reminder(
reminder_type: Literal["weight", "sleep", "exercise", "meal"],
reminder_time: str, # HH:MM
) -> bool:
"""添加提醒"""
config = load_reminder_config()
# 检查是否已存在
for reminder in config["reminders"]:
if reminder["type"] == reminder_type:
reminder["time"] = reminder_time
reminder["enabled"] = True
save_reminder_config(config)
return True
# 新增
config["reminders"].append({
"type": reminder_type,
"time": reminder_time,
"enabled": True,
})
save_reminder_config(config)
return True
def remove_reminder(reminder_type: str) -> bool:
"""删除提醒"""
config = load_reminder_config()
original_len = len(config["reminders"])
config["reminders"] = [
r for r in config["reminders"]
if r["type"] != reminder_type
]
if len(config["reminders"]) < original_len:
save_reminder_config(config)
return True
return False
def disable_reminder(reminder_type: str) -> bool:
"""禁用提醒"""
config = load_reminder_config()
for reminder in config["reminders"]:
if reminder["type"] == reminder_type:
reminder["enabled"] = False
save_reminder_config(config)
return True
return False
def enable_reminder(reminder_type: str) -> bool:
"""启用提醒"""
config = load_reminder_config()
for reminder in config["reminders"]:
if reminder["type"] == reminder_type:
reminder["enabled"] = True
save_reminder_config(config)
return True
return False
def send_notification(title: str, message: str, sound: bool = True):
"""发送系统通知"""
try:
if sys.platform == "darwin":
# macOS
script = f'display notification "{message}" with title "{title}"'
if sound:
script += ' sound name "default"'
subprocess.run(["osascript", "-e", script], check=True)
elif sys.platform == "linux":
# Linux (需要安装 libnotify-bin)
subprocess.run(["notify-send", title, message], check=True)
elif sys.platform == "win32":
# Windows (使用 plyer 库)
try:
from plyer import notification
notification.notify(
title=title,
message=message,
app_name="Vitals",
timeout=10,
)
except ImportError:
print(f"{title}: {message}")
else:
print(f"{title}: {message}")
except Exception as e:
print(f"通知发送失败: {e}")
def check_reminders():
"""检查是否需要提醒(由定时任务调用)"""
config = load_reminder_config()
now = datetime.now()
current_time = now.strftime("%H:%M")
for reminder in config.get("reminders", []):
if not reminder.get("enabled", True):
continue
if reminder["time"] == current_time:
reminder_type = reminder["type"]
# 根据类型定制提醒内容
messages = {
"weight": "早安!该称体重了 ⚖️",
"sleep": "该记录今天的睡眠了 😴",
"exercise": "记得记录今天的运动哦 🏃",
"meal": "别忘了记录这餐吃了什么 🍽️",
}
send_notification(
"Vitals 提醒",
messages.get(reminder_type, f"该记录{reminder_type}了!")
)
2. CLI 接入(src/vitals/cli.py)
# 新增子命令组
remind_app = typer.Typer(help="定时提醒")
app.add_typer(remind_app, name="remind")
@remind_app.command("set")
def remind_set(
reminder_type: str = typer.Argument(..., help="提醒类型 (weight/sleep/exercise/meal)"),
reminder_time: str = typer.Option(..., "--time", "-t", help="提醒时间 (HH:MM)"),
):
"""设置提醒"""
from .core.reminder import add_reminder
try:
add_reminder(reminder_type, reminder_time)
console.print(f"[green]✓[/green] 已设置提醒: {reminder_type} 每天 {reminder_time}")
except Exception as e:
console.print(f"[red]✗[/red] 设置失败: {e}")
raise typer.Exit(1)
@remind_app.command("list")
def remind_list():
"""列出所有提醒"""
from .core.reminder import load_reminder_config
config = load_reminder_config()
reminders = config.get("reminders", [])
if not reminders:
console.print("暂无提醒")
return
table = Table(title="定时提醒")
table.add_column("类型", style="cyan")
table.add_column("时间", style="green")
table.add_column("状态", style="yellow")
for reminder in reminders:
status = "✓ 启用" if reminder.get("enabled", True) else "✗ 禁用"
table.add_row(
reminder["type"],
reminder["time"],
status,
)
console.print(table)
@remind_app.command("disable")
def remind_disable(
reminder_type: str = typer.Argument(..., help="提醒类型"),
):
"""禁用提醒"""
from .core.reminder import disable_reminder
if disable_reminder(reminder_type):
console.print(f"[green]✓[/green] 已禁用 {reminder_type} 提醒")
else:
console.print(f"[yellow]⚠[/yellow] 未找到该提醒")
@remind_app.command("enable")
def remind_enable(
reminder_type: str = typer.Argument(..., help="提醒类型"),
):
"""启用提醒"""
from .core.reminder import enable_reminder
if enable_reminder(reminder_type):
console.print(f"[green]✓[/green] 已启用 {reminder_type} 提醒")
else:
console.print(f"[yellow]⚠[/yellow] 未找到该提醒")
@remind_app.command("remove")
def remind_remove(
reminder_type: str = typer.Argument(..., help="提醒类型"),
):
"""删除提醒"""
from .core.reminder import remove_reminder
if remove_reminder(reminder_type):
console.print(f"[green]✓[/green] 已删除 {reminder_type} 提醒")
else:
console.print(f"[yellow]⚠[/yellow] 未找到该提醒")
@remind_app.command("test")
def remind_test():
"""测试系统通知"""
from .core.reminder import send_notification
send_notification("Vitals 测试", "如果你看到这条消息,说明通知功能正常工作!")
console.print("[green]✓[/green] 测试通知已发送")
3. 设置定时任务(macOS launchd)
创建 ~/.vitals/setup_reminders.sh:
#!/bin/bash
# Vitals 提醒设置脚本(macOS)
PLIST_PATH="$HOME/Library/LaunchAgents/com.vitals.reminder.plist"
cat > "$PLIST_PATH" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.vitals.reminder</string>
<key>ProgramArguments</key>
<array>
<string>$(which python3)</string>
<string>-c</string>
<string>from vitals.core.reminder import check_reminders; check_reminders()</string>
</array>
<key>StartInterval</key>
<integer>60</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/.vitals/reminder.log</string>
<key>StandardErrorPath</key>
<string>$HOME/.vitals/reminder_error.log</string>
</dict>
</plist>
EOF
# 加载定时任务
launchctl unload "$PLIST_PATH" 2>/dev/null
launchctl load "$PLIST_PATH"
echo "✓ 提醒定时任务已设置"
echo " 日志位置: ~/.vitals/reminder.log"
在 CLI 中添加快捷设置命令:
@remind_app.command("setup")
def remind_setup():
"""设置定时任务(macOS)"""
if sys.platform != "darwin":
console.print("[yellow]⚠[/yellow] 此功能目前仅支持 macOS")
return
script_path = Path.home() / ".vitals" / "setup_reminders.sh"
# 创建脚本
script_path.write_text("""#!/bin/bash
# ... (脚本内容)
""")
script_path.chmod(0o755)
# 执行脚本
import subprocess
subprocess.run([str(script_path)], check=True)
console.print("[green]✓[/green] 定时任务已设置")
console.print(" 使用 'vitals remind set' 配置具体提醒时间")
验收标准
vitals remind set weight --time 07:00设置成功vitals remind list正确显示所有提醒vitals remind test可收到系统通知vitals remind setup在 macOS 上设置定时任务- 通知在设定时间准时触发
- 配置持久化(重启后仍有效)
- 支持启用/禁用单个提醒
📅 开发计划建议
Week 1: Web 增强
| 天数 | 任务 | 预计产出 |
|---|---|---|
| Day 1-2 | P1-1 运动页面 + P1-2 饮食页面 | 两个新页面上线 |
| Day 3 | P1-3 睡眠/体重页面 | 两个新页面上线 |
| Day 4-6 | P1-4 Web 数据录入 | 所有表单可用 |
| Day 7 | 集成测试 + Bug 修复 | Web 功能完整可用 |
Week 2: 数据管理 + 打磨
| 天数 | 任务 | 预计产出 |
|---|---|---|
| Day 8 | P2-1 数据导出 | 导出/导入功能 |
| Day 9 | P2-2 自动备份 | 备份/恢复功能 |
| Day 10 | P3-1 定时提醒(可选) | 提醒功能 |
| Day 11-12 | 文档更新 + 演示视频 | README + DEMO |
✅ 总体验收清单
完成所有任务后,应达到以下标准:
Web 功能
- 首页、运动、饮食、睡眠、体重、报告页面全部可访问
- 所有页面图表正确展示数据
- 所有页面支持响应式布局
- Web 界面可完成数据录入(运动/饮食/睡眠/体重)
- 照片上传功能正常工作
数据管理
- 可导出所有数据为 JSON
- 可导出指定类型数据为 CSV
- JSON 可重新导入
- 自动备份在数据写入后执行
- 可手动创建/恢复/清理备份
高级功能
- 可设置定时提醒
- 系统通知正常触发
- 提醒可启用/禁用
文档
- README 包含所有新功能的使用说明
- 有清晰的截图或演示视频
🔗 相关文档
最后更新: 2026-01-18
维护者: Vitals 开发团队