237 lines
7.3 KiB
Python
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)
|