2026-01-22 12:57:26 +08:00
|
|
|
|
"""数据导出/导入模块(JSON/CSV)"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import csv
|
|
|
|
|
|
import json
|
|
|
|
|
|
from dataclasses import asdict, is_dataclass
|
|
|
|
|
|
from datetime import date, time
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Literal, Optional
|
|
|
|
|
|
|
|
|
|
|
|
from . import database as db
|
|
|
|
|
|
from .models import Exercise, Meal, Sleep, UserConfig, Weight
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ExportType = Literal["exercise", "meal", "sleep", "weight"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_default(value):
|
|
|
|
|
|
"""json.dumps 默认序列化器:处理 date/time/dataclass"""
|
|
|
|
|
|
if isinstance(value, (date, time)):
|
|
|
|
|
|
return value.isoformat()
|
|
|
|
|
|
if is_dataclass(value):
|
|
|
|
|
|
return asdict(value)
|
|
|
|
|
|
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-24 11:32:17 +08:00
|
|
|
|
def export_all_data_json(output_path: Path, user_id: int = 1) -> None:
|
2026-01-22 12:57:26 +08:00
|
|
|
|
"""导出所有数据为 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()],
|
2026-01-24 11:32:17 +08:00
|
|
|
|
"config": db.get_config(user_id).to_dict(),
|
2026-01-22 12:57:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
output_path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_to_csv(
|
|
|
|
|
|
data_type: ExportType,
|
|
|
|
|
|
output_path: Path,
|
|
|
|
|
|
start_date: Optional[date] = None,
|
|
|
|
|
|
end_date: Optional[date] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""导出指定类型数据为 CSV(字段与导入器兼容)"""
|
|
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
if data_type == "exercise":
|
|
|
|
|
|
records = db.get_exercises(start_date=start_date, end_date=end_date)
|
|
|
|
|
|
fieldnames = ["date", "type", "duration", "calories", "distance", "heart_rate_avg", "notes"]
|
|
|
|
|
|
rows = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": r.date.isoformat(),
|
|
|
|
|
|
"type": r.type,
|
|
|
|
|
|
"duration": r.duration,
|
|
|
|
|
|
"calories": r.calories,
|
|
|
|
|
|
"distance": r.distance or "",
|
|
|
|
|
|
"heart_rate_avg": r.heart_rate_avg or "",
|
|
|
|
|
|
"notes": r.notes or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
]
|
|
|
|
|
|
elif data_type == "meal":
|
|
|
|
|
|
records = db.get_meals(start_date=start_date, end_date=end_date)
|
|
|
|
|
|
fieldnames = ["date", "meal_type", "description", "calories", "protein", "carbs", "fat"]
|
|
|
|
|
|
rows = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": r.date.isoformat(),
|
|
|
|
|
|
"meal_type": r.meal_type,
|
|
|
|
|
|
"description": r.description,
|
|
|
|
|
|
"calories": r.calories,
|
|
|
|
|
|
"protein": r.protein or "",
|
|
|
|
|
|
"carbs": r.carbs or "",
|
|
|
|
|
|
"fat": r.fat or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
]
|
|
|
|
|
|
elif data_type == "sleep":
|
|
|
|
|
|
records = db.get_sleep_records(start_date=start_date, end_date=end_date)
|
|
|
|
|
|
fieldnames = ["date", "bedtime", "wake_time", "duration", "quality", "deep_sleep_mins", "notes"]
|
|
|
|
|
|
rows = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": r.date.isoformat(),
|
|
|
|
|
|
"bedtime": r.bedtime.isoformat() if r.bedtime else "",
|
|
|
|
|
|
"wake_time": r.wake_time.isoformat() if r.wake_time else "",
|
|
|
|
|
|
"duration": r.duration,
|
|
|
|
|
|
"quality": r.quality,
|
|
|
|
|
|
"deep_sleep_mins": r.deep_sleep_mins or "",
|
|
|
|
|
|
"notes": r.notes or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
]
|
|
|
|
|
|
elif data_type == "weight":
|
|
|
|
|
|
records = db.get_weight_records(start_date=start_date, end_date=end_date)
|
|
|
|
|
|
fieldnames = ["date", "weight_kg", "body_fat_pct", "muscle_mass", "notes"]
|
|
|
|
|
|
rows = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": r.date.isoformat(),
|
|
|
|
|
|
"weight_kg": r.weight_kg,
|
|
|
|
|
|
"body_fat_pct": r.body_fat_pct or "",
|
|
|
|
|
|
"muscle_mass": r.muscle_mass or "",
|
|
|
|
|
|
"notes": r.notes or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in records
|
|
|
|
|
|
]
|
|
|
|
|
|
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 row in rows:
|
|
|
|
|
|
writer.writerow(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-24 11:32:17 +08:00
|
|
|
|
def import_from_json(input_path: Path, user_id: int = 1) -> dict[str, int]:
|
2026-01-22 12:57:26 +08:00
|
|
|
|
"""从 JSON 导入数据(最小实现:覆盖性导入,不做去重)"""
|
|
|
|
|
|
data = json.loads(input_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
stats = {"exercise": 0, "meal": 0, "sleep": 0, "weight": 0}
|
|
|
|
|
|
|
|
|
|
|
|
# config(可选)
|
|
|
|
|
|
config = data.get("config")
|
|
|
|
|
|
if isinstance(config, dict):
|
|
|
|
|
|
# 仅持久化可写字段
|
|
|
|
|
|
db.save_config(
|
2026-01-24 11:32:17 +08:00
|
|
|
|
user_id,
|
2026-01-22 12:57:26 +08:00
|
|
|
|
UserConfig(
|
|
|
|
|
|
age=config.get("age"),
|
|
|
|
|
|
gender=config.get("gender"),
|
|
|
|
|
|
height=config.get("height"),
|
|
|
|
|
|
weight=config.get("weight"),
|
|
|
|
|
|
activity_level=config.get("activity_level", "moderate"),
|
|
|
|
|
|
goal=config.get("goal", "maintain"),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# exercises
|
|
|
|
|
|
for item in data.get("exercises", []):
|
|
|
|
|
|
try:
|
|
|
|
|
|
ex = Exercise(
|
|
|
|
|
|
date=date.fromisoformat(item["date"]),
|
|
|
|
|
|
type=item.get("type", "其他"),
|
|
|
|
|
|
duration=int(item.get("duration", 0)),
|
|
|
|
|
|
calories=int(item.get("calories", 0)),
|
|
|
|
|
|
distance=float(item["distance"]) if item.get("distance") not in (None, "") else None,
|
|
|
|
|
|
heart_rate_avg=int(item["heart_rate_avg"])
|
|
|
|
|
|
if item.get("heart_rate_avg") not in (None, "")
|
|
|
|
|
|
else None,
|
|
|
|
|
|
source=item.get("source", "手动"),
|
|
|
|
|
|
notes=item.get("notes"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_exercise(ex)
|
|
|
|
|
|
stats["exercise"] += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# meals
|
|
|
|
|
|
for item in data.get("meals", []):
|
|
|
|
|
|
try:
|
|
|
|
|
|
meal = Meal(
|
|
|
|
|
|
date=date.fromisoformat(item["date"]),
|
|
|
|
|
|
meal_type=item.get("meal_type", "午餐"),
|
|
|
|
|
|
description=item.get("description", ""),
|
|
|
|
|
|
calories=int(item.get("calories", 0)),
|
|
|
|
|
|
protein=float(item["protein"]) if item.get("protein") not in (None, "") else None,
|
|
|
|
|
|
carbs=float(item["carbs"]) if item.get("carbs") not in (None, "") else None,
|
|
|
|
|
|
fat=float(item["fat"]) if item.get("fat") not in (None, "") else None,
|
|
|
|
|
|
photo_path=item.get("photo_path"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_meal(meal)
|
|
|
|
|
|
stats["meal"] += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# sleep
|
|
|
|
|
|
for item in data.get("sleep", []):
|
|
|
|
|
|
try:
|
|
|
|
|
|
s = Sleep(
|
|
|
|
|
|
date=date.fromisoformat(item["date"]),
|
|
|
|
|
|
bedtime=time.fromisoformat(item["bedtime"]) if item.get("bedtime") else None,
|
|
|
|
|
|
wake_time=time.fromisoformat(item["wake_time"]) if item.get("wake_time") else None,
|
|
|
|
|
|
duration=float(item.get("duration", 0)),
|
|
|
|
|
|
quality=int(item.get("quality", 3)),
|
|
|
|
|
|
deep_sleep_mins=int(item["deep_sleep_mins"])
|
|
|
|
|
|
if item.get("deep_sleep_mins") not in (None, "")
|
|
|
|
|
|
else None,
|
|
|
|
|
|
source=item.get("source", "手动"),
|
|
|
|
|
|
notes=item.get("notes"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_sleep(s)
|
|
|
|
|
|
stats["sleep"] += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# weight
|
|
|
|
|
|
for item in data.get("weight", []):
|
|
|
|
|
|
try:
|
|
|
|
|
|
w = Weight(
|
|
|
|
|
|
date=date.fromisoformat(item["date"]),
|
|
|
|
|
|
weight_kg=float(item.get("weight_kg", 0)),
|
|
|
|
|
|
body_fat_pct=float(item["body_fat_pct"])
|
|
|
|
|
|
if item.get("body_fat_pct") not in (None, "")
|
|
|
|
|
|
else None,
|
|
|
|
|
|
muscle_mass=float(item["muscle_mass"])
|
|
|
|
|
|
if item.get("muscle_mass") not in (None, "")
|
|
|
|
|
|
else None,
|
|
|
|
|
|
notes=item.get("notes"),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_weight(w)
|
|
|
|
|
|
stats["weight"] += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|