Files

237 lines
7.3 KiB
Python

import os
import aiosqlite
from datetime import date, datetime, timedelta
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from contextlib import asynccontextmanager
import config
_ACTIONS = ["pushups", "squats", "crunches", "mountain", "plank"]
_ACTION_LABELS = {
"pushups": "俯卧撑",
"squats": "深蹲",
"crunches": "卷腹",
"mountain": "登山跑",
"plank": "平板支撑",
}
async def init_db():
async with aiosqlite.connect(config.DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS workouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
pushups INTEGER NOT NULL DEFAULT 0,
squats INTEGER NOT NULL DEFAULT 0,
crunches INTEGER NOT NULL DEFAULT 0,
mountain INTEGER NOT NULL DEFAULT 0,
plank INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS weights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
weight REAL NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.commit()
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title="Workout Five 五件套", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class WorkoutCreate(BaseModel):
date: str | None = None
pushups: int = 0
squats: int = 0
crunches: int = 0
mountain: int = 0
plank: int = 0
class WeightCreate(BaseModel):
date: str | None = None
weight: float
# ---------- Workouts ----------
@app.get("/api/workouts")
async def list_workouts():
async with aiosqlite.connect(config.DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM workouts ORDER BY date DESC"
)
return [dict(r) for r in await cursor.fetchall()]
@app.get("/api/workouts/today")
async def get_today():
async with aiosqlite.connect(config.DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM workouts WHERE date = ?",
(date.today().isoformat(),),
)
row = await cursor.fetchone()
return dict(row) if row else None
@app.post("/api/workouts")
async def upsert_workout(req: WorkoutCreate):
wdate = req.date or date.today().isoformat()
async with aiosqlite.connect(config.DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT id FROM workouts WHERE date = ?", (wdate,))
row = await cursor.fetchone()
if row:
await db.execute(
"""UPDATE workouts SET pushups=?, squats=?, crunches=?, mountain=?, plank=?
WHERE id=?""",
(req.pushups, req.squats, req.crunches, req.mountain, req.plank, row["id"]),
)
wid = row["id"]
else:
cursor = await db.execute(
"""INSERT INTO workouts (date, pushups, squats, crunches, mountain, plank)
VALUES (?, ?, ?, ?, ?, ?)""",
(wdate, req.pushups, req.squats, req.crunches, req.mountain, req.plank),
)
wid = cursor.lastrowid
await db.commit()
return {"id": wid, "ok": True}
@app.delete("/api/workouts/{wid}")
async def delete_workout(wid: int):
async with aiosqlite.connect(config.DB_PATH) as db:
cursor = await db.execute("DELETE FROM workouts WHERE id = ?", (wid,))
await db.commit()
if cursor.rowcount == 0:
raise HTTPException(404, "记录不存在")
return {"ok": True}
# ---------- Weights ----------
@app.get("/api/weights")
async def list_weights():
async with aiosqlite.connect(config.DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM weights ORDER BY date DESC")
return [dict(r) for r in await cursor.fetchall()]
@app.post("/api/weights")
async def upsert_weight(req: WeightCreate):
wdate = req.date or date.today().isoformat()
async with aiosqlite.connect(config.DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT id FROM weights WHERE date = ?", (wdate,))
row = await cursor.fetchone()
if row:
await db.execute("UPDATE weights SET weight=? WHERE id=?", (req.weight, row["id"]))
wid = row["id"]
else:
cursor = await db.execute(
"INSERT INTO weights (date, weight) VALUES (?, ?)", (wdate, req.weight)
)
wid = cursor.lastrowid
await db.commit()
return {"id": wid, "ok": True}
@app.delete("/api/weights/{wid}")
async def delete_weight(wid: int):
async with aiosqlite.connect(config.DB_PATH) as db:
cursor = await db.execute("DELETE FROM weights WHERE id = ?", (wid,))
await db.commit()
if cursor.rowcount == 0:
raise HTTPException(404, "记录不存在")
return {"ok": True}
# ---------- Stats ----------
@app.get("/api/stats")
async def get_stats():
async with aiosqlite.connect(config.DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM workouts ORDER BY date ASC")
rows = [dict(r) for r in await cursor.fetchall()]
wcursor = await db.execute("SELECT * FROM weights ORDER BY date ASC")
wrows = [dict(r) for r in await wcursor.fetchall()]
# 累计总量
totals = {a: sum(r[a] for r in rows) for a in _ACTIONS}
# 连续打卡天数(从今天往回数,或从最近一次往回数)
done_dates = {r["date"] for r in rows}
streak = 0
d = date.today()
# 今天没练就从昨天开始算
if d.isoformat() not in done_dates:
d = d - timedelta(days=1)
while d.isoformat() in done_dates:
streak += 1
d = d - timedelta(days=1)
# 本周(周一起)练了多少天
today = date.today()
monday = today - timedelta(days=today.weekday())
week_days = sum(
1 for r in rows if monday <= datetime.strptime(r["date"], "%Y-%m-%d").date() <= today
)
# 最近一次体重变化
weight_trend = None
if len(wrows) >= 2:
last = wrows[-1]["weight"]
prev = wrows[-2]["weight"]
weight_trend = {"latest": last, "prev": prev, "delta": round(last - prev, 1)}
return {
"totals": totals,
"streak": streak,
"week_days": week_days,
"total_days": len(rows),
"weight_trend": weight_trend,
"last_weight": wrows[-1]["weight"] if wrows else None,
}
# ---------- Static ----------
_static_dir = os.path.join(os.path.dirname(__file__), "static")
if os.path.isdir(_static_dir):
from fastapi.staticfiles import StaticFiles
app.mount("/", StaticFiles(directory=_static_dir, html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=config.PORT)