五件套徒手健身打卡应用:FastAPI+Vue3+PWA
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user