Files
DDUp/src/vitals/web/app.py
liweiliang0905@gmail.com 0f11e8ad56 feat: 用户配置隔离与食物智能识别
1. Config 表用户隔离
   - 添加 user_id 字段,复合主键 (user_id, key)
   - 现有数据归属 ID=1 用户
   - 所有 get_config/save_config 调用传入 user_id

2. 食物文字智能识别
   - 本地数据库优先匹配(快速)
   - 识别失败时自动调用通义千问 AI(准确)
   - 有配置 API Key 才调用,否则返回本地结果

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:32:56 +08:00

8898 lines
324 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FastAPI Web 仪表盘"""
import os
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Optional
from fastapi import Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
from uuid import uuid4
from ..core import database as db
from ..core.models import Exercise, Meal, Sleep, UserConfig, Weight, User, Reading, Invite
from ..core.auth import hash_password, verify_password, create_token, decode_token, generate_invite_code, get_token_expire_seconds
# 初始化数据库
db.init_db()
db.migrate_auth_fields() # 迁移认证字段
db.migrate_config_add_user_id() # 迁移 config 表添加 user_id
app = FastAPI(
title="Vitals 健康管理",
description="本地优先的综合健康管理应用",
version="0.1.0",
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 认证中间件
class AuthMiddleware(BaseHTTPMiddleware):
"""服务端认证中间件 - 未登录用户重定向到登录页"""
# 不需要认证的路径前缀
PUBLIC_PREFIXES = (
"/api/auth/login",
"/api/auth/register",
"/login",
"/register",
"/static",
"/photos",
"/docs",
"/openapi.json",
"/favicon.ico",
)
async def dispatch(self, request: Request, call_next):
path = request.url.path
# 检查是否为公开路径
if any(path.startswith(prefix) for prefix in self.PUBLIC_PREFIXES):
return await call_next(request)
# 从 Cookie 获取 token
token = request.cookies.get("auth_token")
# API 请求也支持 Header 认证(向后兼容)
if not token and path.startswith("/api/"):
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
# 验证 token
if token:
payload = decode_token(token)
if payload:
# token 有效,继续处理
return await call_next(request)
# 未认证
if path.startswith("/api/"):
# API 请求返回 401
return JSONResponse(
status_code=401,
content={"detail": "未登录或登录已过期"}
)
else:
# 页面请求重定向到登录页
redirect_url = f"/login?redirect={path}" if path != "/" else "/login"
return RedirectResponse(url=redirect_url, status_code=302)
app.add_middleware(AuthMiddleware)
# 静态文件
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
# 食物照片静态文件
photos_dir = Path.home() / ".vitals" / "photos"
if photos_dir.exists():
app.mount("/photos", StaticFiles(directory=photos_dir), name="photos")
# ===== Pydantic 模型 =====
class ExerciseResponse(BaseModel):
id: Optional[int]
date: str
type: str
duration: int
calories: int
distance: Optional[float]
heart_rate_avg: Optional[int]
source: str
notes: Optional[str]
class MealResponse(BaseModel):
id: Optional[int]
date: str
meal_type: str
description: str
calories: int
protein: Optional[float]
carbs: Optional[float]
fat: Optional[float]
photo_path: Optional[str]
class SleepResponse(BaseModel):
id: Optional[int]
date: str
bedtime: Optional[str]
wake_time: Optional[str]
duration: float
quality: int
deep_sleep_mins: Optional[int]
source: str
notes: Optional[str]
class WeightResponse(BaseModel):
id: Optional[int]
date: str
weight_kg: float
body_fat_pct: Optional[float]
muscle_mass: Optional[float]
notes: Optional[str]
class ConfigResponse(BaseModel):
age: Optional[int]
gender: Optional[str]
height: Optional[float]
weight: Optional[float]
activity_level: str
goal: str
bmr: Optional[int]
tdee: Optional[int]
class TodaySummary(BaseModel):
date: str
calories_intake: int
calories_burned: int
calories_balance: int
exercise_count: int
exercise_duration: int
sleep_duration: Optional[float]
sleep_quality: Optional[int]
weight: Optional[float]
tdee: Optional[int]
meals: list[MealResponse]
exercises: list[ExerciseResponse]
class WeekSummary(BaseModel):
start_date: str
end_date: str
total_exercise_count: int
total_exercise_duration: int
total_calories_burned: int
avg_calories_intake: int
avg_sleep_duration: float
avg_sleep_quality: float
weight_start: Optional[float]
weight_end: Optional[float]
weight_change: Optional[float]
daily_stats: list[dict]
# ===== Web 数据录入模型 =====
class ExerciseInput(BaseModel):
date: str
type: str
duration: int
calories: Optional[int] = None
distance: Optional[float] = None
heart_rate_avg: Optional[int] = None
notes: Optional[str] = None
@field_validator("duration")
@classmethod
def validate_duration(cls, value: int) -> int:
if value <= 0 or value > 1440:
raise ValueError("时长必须在 1-1440 分钟之间")
return value
class SleepInput(BaseModel):
date: str
bedtime: Optional[str] = None
wake_time: Optional[str] = None
duration: float
quality: int
notes: Optional[str] = None
@field_validator("quality")
@classmethod
def validate_quality(cls, value: int) -> int:
if value < 1 or value > 5:
raise ValueError("质量评分必须在 1-5 之间")
return value
@field_validator("duration")
@classmethod
def validate_sleep_duration(cls, value: float) -> float:
if value <= 0 or value > 24:
raise ValueError("睡眠时长必须在 0-24 小时之间")
return value
class WeightInput(BaseModel):
date: str
weight_kg: float
body_fat_pct: Optional[float] = None
muscle_mass: Optional[float] = None
notes: Optional[str] = None
@field_validator("weight_kg")
@classmethod
def validate_weight(cls, value: float) -> float:
if value < 20 or value > 300:
raise ValueError("体重必须在 20-300 kg 之间")
return value
class UserResponse(BaseModel):
id: int
name: str
created_at: str
is_active: bool
gender: Optional[str] = None
height_cm: Optional[float] = None
weight_kg: Optional[float] = None
age: Optional[int] = None
bmi: Optional[float] = None
bmi_status: Optional[str] = None
class UserInput(BaseModel):
name: str
gender: Optional[str] = None
height_cm: Optional[float] = None
weight_kg: Optional[float] = None
age: Optional[int] = None
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if not value or len(value.strip()) == 0:
raise ValueError("用户名不能为空")
if len(value) > 50:
raise ValueError("用户名不能超过 50 个字符")
return value.strip()
@field_validator("gender")
@classmethod
def validate_gender(cls, value: Optional[str]) -> Optional[str]:
if value is not None and value not in ["male", "female"]:
raise ValueError("性别必须是 'male''female'")
return value
@field_validator("height_cm")
@classmethod
def validate_height(cls, value: Optional[float]) -> Optional[float]:
if value is not None and (value < 50 or value > 300):
raise ValueError("身高必须在 50-300 cm 之间")
return value
@field_validator("weight_kg")
@classmethod
def validate_weight(cls, value: Optional[float]) -> Optional[float]:
if value is not None and (value < 20 or value > 500):
raise ValueError("体重必须在 20-500 kg 之间")
return value
@field_validator("age")
@classmethod
def validate_age(cls, value: Optional[int]) -> Optional[int]:
if value is not None and (value < 1 or value > 150):
raise ValueError("年龄必须在 1-150 之间")
return value
class DataClearInput(BaseModel):
user_id: int
mode: str # "all" | "range" | "type"
date_from: Optional[str] = None
date_to: Optional[str] = None
data_types: Optional[list[str]] = None
@field_validator("mode")
@classmethod
def validate_mode(cls, value: str) -> str:
if value not in ["all", "range", "type"]:
raise ValueError("mode 必须是 'all', 'range''type'")
return value
class ReadingInput(BaseModel):
title: str
author: Optional[str] = None
cover_url: Optional[str] = None
duration: int
mood: Optional[str] = None
notes: Optional[str] = None
date: Optional[str] = None
@field_validator("title")
@classmethod
def validate_title(cls, value: str) -> str:
if not value or len(value.strip()) == 0:
raise ValueError("书名不能为空")
return value.strip()
@field_validator("duration")
@classmethod
def validate_duration(cls, value: int) -> int:
if value < 1 or value > 1440:
raise ValueError("阅读时长必须在 1-1440 分钟之间")
return value
@field_validator("mood")
@classmethod
def validate_mood(cls, value: Optional[str]) -> Optional[str]:
valid_moods = ["😄", "😊", "😐", "😔", "😢"]
if value and value not in valid_moods:
raise ValueError("心情必须是 😄😊😐😔😢 之一")
return value
class ReadingResponse(BaseModel):
id: int
user_id: int
date: str
title: str
author: Optional[str]
cover_url: Optional[str]
duration: int
mood: Optional[str]
notes: Optional[str]
# ===== 认证相关模型 =====
class LoginInput(BaseModel):
username: str
password: str
remember_me: bool = False
class RegisterInput(BaseModel):
username: str
password: str
invite_code: str
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
if not value or len(value.strip()) == 0:
raise ValueError("用户名不能为空")
if len(value) > 50:
raise ValueError("用户名不能超过 50 个字符")
return value.strip()
@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
if len(value) < 6:
raise ValueError("密码长度至少 6 位")
return value
class TokenResponse(BaseModel):
token: str
user: "AuthUserResponse"
class AuthUserResponse(BaseModel):
id: int
name: str
is_admin: bool
is_disabled: bool
class InviteResponse(BaseModel):
id: int
code: str
created_by: int
used_by: Optional[int]
created_at: str
expires_at: Optional[str]
is_used: bool
is_expired: bool
class InviteInput(BaseModel):
expires_days: Optional[int] = None # 可选的过期天数
# ===== 认证辅助函数 =====
# 不需要认证的路径
PUBLIC_PATHS = {
"/api/auth/login",
"/api/auth/register",
"/login",
"/register",
"/static",
"/photos",
"/docs",
"/openapi.json",
}
def get_token_from_header(authorization: Optional[str] = Header(None)) -> Optional[str]:
"""从 Header 提取 Token"""
if not authorization:
return None
if authorization.startswith("Bearer "):
return authorization[7:]
return None
def get_token_from_request(
request: Request,
authorization: Optional[str] = Header(None),
auth_token: Optional[str] = Cookie(None)
) -> Optional[str]:
"""从 Cookie 或 Header 中获取 Token优先 Cookie"""
# 优先从 Cookie 获取
if auth_token:
return auth_token
# 其次从 Header 获取(向后兼容)
return get_token_from_header(authorization)
def get_current_user(
request: Request,
authorization: Optional[str] = Header(None),
auth_token: Optional[str] = Cookie(None)
) -> Optional[User]:
"""获取当前登录用户(可选,返回 None 表示未登录)"""
token = get_token_from_request(request, authorization, auth_token)
if not token:
return None
payload = decode_token(token)
if not payload:
return None
user = db.get_user(payload["user_id"])
if not user or user.is_disabled:
return None
return user
def require_user(
request: Request,
authorization: Optional[str] = Header(None),
auth_token: Optional[str] = Cookie(None)
) -> User:
"""要求用户登录(必须认证)"""
user = get_current_user(request, authorization, auth_token)
if not user:
raise HTTPException(status_code=401, detail="未登录或登录已过期")
return user
def require_admin(
request: Request,
authorization: Optional[str] = Header(None),
auth_token: Optional[str] = Cookie(None)
) -> User:
"""要求管理员权限"""
user = require_user(request, authorization, auth_token)
if not user.is_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
def get_current_user_id(
request: Request,
authorization: Optional[str] = Header(None),
auth_token: Optional[str] = Cookie(None)
) -> int:
"""获取当前用户 ID兼容模式未登录时使用活跃用户"""
user = get_current_user(request, authorization, auth_token)
if user:
return user.id
# 兼容模式:未登录时使用当前活跃用户
db.ensure_default_user()
active_user = db.get_active_user()
return active_user.id if active_user else 1
# ===== API 路由 =====
# ===== 认证 API =====
@app.post("/api/auth/login")
async def login(data: LoginInput):
"""用户登录"""
user = db.get_user_by_name(data.username)
if not user:
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.password_hash:
raise HTTPException(status_code=401, detail="用户未设置密码,请联系管理员")
if not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if user.is_disabled:
raise HTTPException(status_code=403, detail="账户已被禁用")
# 创建 token根据 remember_me 设置不同的过期时间)
token = create_token(user.id, user.name, user.is_admin, data.remember_me)
# 创建响应并设置 Cookie
response = JSONResponse(content={
"token": token,
"user": {
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
"is_disabled": user.is_disabled,
}
})
# 设置 HTTPOnly Cookie
max_age = get_token_expire_seconds(data.remember_me) if data.remember_me else None
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=False, # HTTP 环境,生产环境应设为 True
samesite="lax",
max_age=max_age, # None 表示会话 Cookie
path="/", # 确保所有路径都能使用此 Cookie
)
return response
@app.post("/api/auth/register")
async def register(data: RegisterInput):
"""用户注册(需要邀请码)"""
# 验证邀请码
invite = db.get_invite_by_code(data.invite_code)
if not invite:
raise HTTPException(status_code=400, detail="邀请码无效")
if invite.is_used:
raise HTTPException(status_code=400, detail="邀请码已被使用")
if invite.is_expired:
raise HTTPException(status_code=400, detail="邀请码已过期")
# 检查用户名是否已存在
existing = db.get_user_by_name(data.username)
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
# 创建用户
password_hash = hash_password(data.password)
new_user = User(
name=data.username,
password_hash=password_hash,
is_active=False,
is_admin=False,
is_disabled=False,
)
user_id = db.add_user(new_user)
# 标记邀请码已使用
db.mark_invite_used(invite.id, user_id)
# 获取创建的用户
user = db.get_user(user_id)
# 生成 token 并创建响应
token = create_token(user.id, user.name, user.is_admin)
response = JSONResponse(content={
"token": token,
"user": {
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
"is_disabled": user.is_disabled,
}
})
# 设置 Cookie
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=get_token_expire_seconds(False), # 注册默认 1 天
path="/",
)
return response
@app.post("/api/auth/logout")
async def logout(response: Response):
"""用户登出"""
response.delete_cookie(key="auth_token")
return {"message": "登出成功"}
@app.get("/api/auth/me", response_model=AuthUserResponse)
async def get_me(user: User = Depends(require_user)):
"""获取当前登录用户信息"""
return AuthUserResponse(
id=user.id,
name=user.name,
is_admin=user.is_admin,
is_disabled=user.is_disabled,
)
# ===== 管理员 API =====
@app.get("/api/admin/users")
async def admin_get_users(admin: User = Depends(require_admin)):
"""获取所有用户(管理员)"""
users = db.get_users()
return [
{
"id": u.id,
"name": u.name,
"email": u.email,
"is_admin": u.is_admin,
"is_disabled": u.is_disabled,
"created_at": u.created_at.isoformat(),
}
for u in users
]
@app.post("/api/admin/users/{user_id}/disable")
async def admin_disable_user(user_id: int, admin: User = Depends(require_admin)):
"""禁用用户(管理员)"""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="不能禁用自己")
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.is_disabled = True
db.update_user(user)
return {"message": "用户已禁用"}
@app.post("/api/admin/users/{user_id}/enable")
async def admin_enable_user(user_id: int, admin: User = Depends(require_admin)):
"""启用用户(管理员)"""
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.is_disabled = False
db.update_user(user)
return {"message": "用户已启用"}
@app.delete("/api/admin/users/{user_id}")
async def admin_delete_user(user_id: int, admin: User = Depends(require_admin)):
"""删除用户(管理员)"""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="不能删除自己")
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 删除用户数据
db.clear_data(user_id, mode="all")
# 删除用户
db.delete_user(user_id)
return {"message": "用户已删除"}
@app.get("/api/admin/invites", response_model=list[InviteResponse])
async def admin_get_invites(admin: User = Depends(require_admin)):
"""获取邀请码列表(管理员)"""
invites = db.get_invites()
return [
InviteResponse(
id=inv.id,
code=inv.code,
created_by=inv.created_by,
used_by=inv.used_by,
created_at=inv.created_at.isoformat(),
expires_at=inv.expires_at.isoformat() if inv.expires_at else None,
is_used=inv.is_used,
is_expired=inv.is_expired,
)
for inv in invites
]
@app.post("/api/admin/invites", response_model=InviteResponse)
async def admin_create_invite(data: InviteInput, admin: User = Depends(require_admin)):
"""生成邀请码(管理员)"""
code = generate_invite_code()
expires_at = None
if data.expires_days:
expires_at = datetime.now() + timedelta(days=data.expires_days)
invite = Invite(
code=code,
created_by=admin.id,
expires_at=expires_at,
)
invite_id = db.add_invite(invite)
created_invite = db.get_invite_by_code(code)
return InviteResponse(
id=created_invite.id,
code=created_invite.code,
created_by=created_invite.created_by,
used_by=created_invite.used_by,
created_at=created_invite.created_at.isoformat(),
expires_at=created_invite.expires_at.isoformat() if created_invite.expires_at else None,
is_used=created_invite.is_used,
is_expired=created_invite.is_expired,
)
@app.delete("/api/admin/invites/{invite_id}")
async def admin_delete_invite(invite_id: int, admin: User = Depends(require_admin)):
"""删除邀请码(管理员)"""
db.delete_invite(invite_id)
return {"message": "邀请码已删除"}
# ===== API 密钥管理(管理员) =====
@app.get("/api/admin/api-keys")
async def admin_get_api_keys(admin: User = Depends(require_admin)):
"""获取所有 API Keys 状态(掩码显示)"""
return db.get_all_api_keys(masked=True)
@app.get("/api/admin/api-keys/{provider}")
async def admin_get_api_key(provider: str, admin: User = Depends(require_admin)):
"""获取指定 API Key 完整值"""
if provider not in db.API_KEY_ENV_MAP:
raise HTTPException(status_code=400, detail=f"未知的 provider: {provider}")
value = db.get_api_key(provider)
return {
"provider": provider,
"name": db.API_KEY_NAMES.get(provider, provider),
"value": value,
}
class ApiKeyInput(BaseModel):
value: str
@app.put("/api/admin/api-keys/{provider}")
async def admin_set_api_key(provider: str, data: ApiKeyInput, admin: User = Depends(require_admin)):
"""设置/更新 API Key"""
if provider not in db.API_KEY_ENV_MAP:
raise HTTPException(status_code=400, detail=f"未知的 provider: {provider}")
db.set_api_key(provider, data.value)
return {"message": "API Key 已保存", "provider": provider}
@app.delete("/api/admin/api-keys/{provider}")
async def admin_delete_api_key(provider: str, admin: User = Depends(require_admin)):
"""删除 API Key回退到环境变量"""
if provider not in db.API_KEY_ENV_MAP:
raise HTTPException(status_code=400, detail=f"未知的 provider: {provider}")
db.delete_api_key(provider)
return {"message": "API Key 已删除,将使用环境变量配置", "provider": provider}
# ===== 页面路由 =====
@app.get("/login")
async def login_page():
"""登录页面"""
return HTMLResponse(content=get_login_page_html(), status_code=200)
@app.get("/register")
async def register_page():
"""注册页面"""
return HTMLResponse(content=get_register_page_html(), status_code=200)
@app.get("/admin")
async def admin_page():
"""管理后台页面"""
return HTMLResponse(content=get_admin_page_html(), status_code=200)
@app.get("/")
async def root():
"""首页"""
return HTMLResponse(content=get_dashboard_html(), status_code=200)
@app.get("/exercise")
async def exercise_page():
"""运动页面"""
return HTMLResponse(content=get_exercise_page_html(), status_code=200)
@app.get("/meal")
async def meal_page():
"""饮食页面"""
return HTMLResponse(content=get_meal_page_html(), status_code=200)
@app.get("/report")
async def report_page():
"""报告页面"""
return HTMLResponse(content=get_report_page_html(), status_code=200)
@app.get("/api/report/week")
async def download_week_report(format: str = Query(default="pdf")):
"""生成并下载周报"""
from ..core.report import export_report, generate_weekly_report
report = generate_weekly_report()
return _export_report_file(report, format, "weekly", export_report)
@app.get("/api/report/month")
async def download_month_report(
year: Optional[int] = Query(default=None),
month: Optional[int] = Query(default=None),
format: str = Query(default="pdf"),
):
"""生成并下载月报"""
from ..core.report import export_report, generate_monthly_report
report = generate_monthly_report(year, month)
return _export_report_file(report, format, "monthly", export_report)
@app.get("/sleep")
async def sleep_page():
"""睡眠页面"""
return HTMLResponse(content=get_sleep_page_html(), status_code=200)
@app.get("/weight")
async def weight_page():
"""体重页面"""
return HTMLResponse(content=get_weight_page_html(), status_code=200)
@app.get("/settings")
async def settings_page():
"""设置页面"""
return HTMLResponse(content=get_settings_page_html(), status_code=200)
@app.get("/api/config", response_model=ConfigResponse)
async def get_config():
"""获取用户配置"""
active_user = db.get_active_user()
user_id = active_user.id if active_user else 1
config = db.get_config(user_id)
return ConfigResponse(
age=config.age,
gender=config.gender,
height=config.height,
weight=config.weight,
activity_level=config.activity_level,
goal=config.goal,
bmr=config.bmr,
tdee=config.tdee,
)
@app.get("/api/today", response_model=TodaySummary)
async def get_today_summary():
"""获取今日概览"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
today = date.today()
config = db.get_config(active_user.id)
# 获取今日数据
exercises = db.get_exercises(start_date=today, end_date=today, user_id=active_user.id)
meals = db.get_meals(start_date=today, end_date=today, user_id=active_user.id)
sleep_records = db.get_sleep_records(start_date=today, end_date=today, user_id=active_user.id)
weight_records = db.get_weight_records(start_date=today, end_date=today, user_id=active_user.id)
# 计算卡路里
calories_intake = sum(m.calories for m in meals)
calories_burned = sum(e.calories for e in exercises)
# 基础代谢 + 运动消耗
tdee = config.tdee or 0
total_burned = tdee + calories_burned
calories_balance = calories_intake - total_burned
# 睡眠数据
sleep = sleep_records[0] if sleep_records else None
# 最新体重
latest_weight = db.get_latest_weight()
return TodaySummary(
date=today.isoformat(),
calories_intake=calories_intake,
calories_burned=calories_burned,
calories_balance=calories_balance,
exercise_count=len(exercises),
exercise_duration=sum(e.duration for e in exercises),
sleep_duration=sleep.duration if sleep else None,
sleep_quality=sleep.quality if sleep else None,
weight=latest_weight.weight_kg if latest_weight else None,
tdee=config.tdee,
meals=[
MealResponse(
id=m.id,
date=m.date.isoformat(),
meal_type=m.meal_type,
description=m.description,
calories=m.calories,
protein=m.protein,
carbs=m.carbs,
fat=m.fat,
photo_path=m.photo_path,
)
for m in meals
],
exercises=[
ExerciseResponse(
id=e.id,
date=e.date.isoformat(),
type=e.type,
duration=e.duration,
calories=e.calories,
distance=e.distance,
heart_rate_avg=e.heart_rate_avg,
source=e.source,
notes=e.notes,
)
for e in exercises
],
)
@app.get("/api/week", response_model=WeekSummary)
async def get_week_summary():
"""获取本周汇总"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
today = date.today()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
# 获取本周数据
exercises = db.get_exercises(start_date=start_of_week, end_date=end_of_week, user_id=active_user.id)
meals = db.get_meals(start_date=start_of_week, end_date=end_of_week, user_id=active_user.id)
sleep_records = db.get_sleep_records(start_date=start_of_week, end_date=end_of_week, user_id=active_user.id)
weight_records = db.get_weight_records(start_date=start_of_week, end_date=end_of_week, user_id=active_user.id)
# 计算统计数据
total_exercise_duration = sum(e.duration for e in exercises)
total_calories_burned = sum(e.calories for e in exercises)
total_calories_intake = sum(m.calories for m in meals)
# 计算平均值
days_with_meals = len(set(m.date for m in meals)) or 1
avg_calories_intake = total_calories_intake // days_with_meals
avg_sleep_duration = (
sum(s.duration for s in sleep_records) / len(sleep_records)
if sleep_records
else 0
)
avg_sleep_quality = (
sum(s.quality for s in sleep_records) / len(sleep_records)
if sleep_records
else 0
)
# 体重变化
weight_records_sorted = sorted(weight_records, key=lambda w: w.date)
weight_start = weight_records_sorted[0].weight_kg if weight_records_sorted else None
weight_end = weight_records_sorted[-1].weight_kg if weight_records_sorted else None
weight_change = (
round(weight_end - weight_start, 2)
if weight_start and weight_end
else None
)
# 每日统计
daily_stats = []
for i in range(7):
day = start_of_week + timedelta(days=i)
day_exercises = [e for e in exercises if e.date == day]
day_meals = [m for m in meals if m.date == day]
day_sleep = next((s for s in sleep_records if s.date == day), None)
daily_stats.append({
"date": day.isoformat(),
"weekday": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][i],
"exercise_duration": sum(e.duration for e in day_exercises),
"calories_intake": sum(m.calories for m in day_meals),
"calories_burned": sum(e.calories for e in day_exercises),
"sleep_duration": day_sleep.duration if day_sleep else None,
})
return WeekSummary(
start_date=start_of_week.isoformat(),
end_date=end_of_week.isoformat(),
total_exercise_count=len(exercises),
total_exercise_duration=total_exercise_duration,
total_calories_burned=total_calories_burned,
avg_calories_intake=avg_calories_intake,
avg_sleep_duration=round(avg_sleep_duration, 1),
avg_sleep_quality=round(avg_sleep_quality, 1),
weight_start=weight_start,
weight_end=weight_end,
weight_change=weight_change,
daily_stats=daily_stats,
)
@app.get("/api/exercises", response_model=list[ExerciseResponse])
async def get_exercises(
days: int = Query(default=30, ge=1, le=365, description="查询天数"),
):
"""获取运动记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
end_date = date.today()
start_date = end_date - timedelta(days=days)
exercises = db.get_exercises(start_date=start_date, end_date=end_date, user_id=active_user.id)
return [
ExerciseResponse(
id=e.id,
date=e.date.isoformat(),
type=e.type,
duration=e.duration,
calories=e.calories,
distance=e.distance,
heart_rate_avg=e.heart_rate_avg,
source=e.source,
notes=e.notes,
)
for e in exercises
]
@app.get("/api/exercises/stats")
async def get_exercise_stats(
days: int = Query(default=30, ge=1, le=365, description="统计天数"),
):
"""获取运动统计数据"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
today = date.today()
period_start = today - timedelta(days=days - 1)
month_start = date(today.year, today.month, 1)
month_exercises = db.get_exercises(start_date=month_start, end_date=today, user_id=active_user.id)
month_count = len(month_exercises)
month_duration = sum(e.duration for e in month_exercises)
month_calories = sum(e.calories for e in month_exercises)
month_type_counts: dict[str, int] = {}
for e in month_exercises:
month_type_counts[e.type] = month_type_counts.get(e.type, 0) + 1
top_type = max(month_type_counts, key=month_type_counts.get) if month_type_counts else None
period_exercises = db.get_exercises(start_date=period_start, end_date=today)
type_counts: dict[str, int] = {}
type_calories: dict[str, int] = {}
for e in period_exercises:
type_counts[e.type] = type_counts.get(e.type, 0) + 1
type_calories[e.type] = type_calories.get(e.type, 0) + e.calories
daily_stats = []
for i in range(days):
day = period_start + timedelta(days=i)
day_exercises = [e for e in period_exercises if e.date == day]
daily_stats.append({
"date": day.isoformat(),
"duration": sum(e.duration for e in day_exercises),
"calories": sum(e.calories for e in day_exercises),
})
return {
"month": {
"count": month_count,
"duration": month_duration,
"calories": month_calories,
"top_type": top_type,
},
"period": {
"days": days,
"type_counts": type_counts,
"type_calories": type_calories,
},
"daily_stats": daily_stats,
}
@app.get("/api/meals", response_model=list[MealResponse])
async def get_meals(
days: int = Query(default=30, ge=1, le=365, description="查询天数"),
):
"""获取饮食记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
end_date = date.today()
start_date = end_date - timedelta(days=days)
meals = db.get_meals(start_date=start_date, end_date=end_date, user_id=active_user.id)
return [
MealResponse(
id=m.id,
date=m.date.isoformat(),
meal_type=m.meal_type,
description=m.description,
calories=m.calories,
protein=m.protein,
carbs=m.carbs,
fat=m.fat,
photo_path=m.photo_path,
)
for m in meals
]
@app.post("/api/exercise")
async def add_exercise_api(data: ExerciseInput):
"""添加运动记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
try:
record_date = date.fromisoformat(data.date)
except ValueError as exc:
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
from ..core.calories import estimate_exercise_calories
config = db.get_config(active_user.id)
weight_kg = config.weight or 70
calories = data.calories if data.calories is not None else estimate_exercise_calories(
data.type, data.duration, weight_kg
)
exercise = Exercise(
date=record_date,
type=data.type,
duration=data.duration,
calories=calories,
distance=data.distance,
heart_rate_avg=data.heart_rate_avg,
notes=data.notes,
source="web",
)
record_id = db.add_exercise(exercise, user_id=active_user.id)
return {"success": True, "id": record_id}
@app.delete("/api/exercise/{exercise_id}")
async def delete_exercise_api(exercise_id: int):
"""删除运动记录"""
try:
db.delete_exercise(exercise_id)
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/meal")
async def add_meal_api(
date_str: str = Form(...),
meal_type: str = Form(...),
description: str = Form(""),
calories: Optional[str] = Form(None),
protein: Optional[str] = Form(None),
carbs: Optional[str] = Form(None),
fat: Optional[str] = Form(None),
photo: Optional[UploadFile] = File(None),
):
"""添加饮食记录(支持照片上传)"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
def _parse_optional_int(value: Optional[str], field_name: str) -> Optional[int]:
if value is None:
return None
value = value.strip()
if value == "":
return None
try:
return int(value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"{field_name} 必须是整数") from exc
def _parse_optional_float(value: Optional[str], field_name: str) -> Optional[float]:
if value is None:
return None
value = value.strip()
if value == "":
return None
try:
return float(value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"{field_name} 必须是数字") from exc
try:
record_date = date.fromisoformat(date_str)
except ValueError as exc:
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
photo_path = None
if photo:
photos_root = Path.home() / ".vitals" / "photos" / date_str[:7]
photos_root.mkdir(parents=True, exist_ok=True)
suffix = Path(photo.filename or "").suffix.lower() or ".jpg"
safe_type = meal_type.replace(" ", "_")
timestamp = datetime.now().strftime("%H%M%S")
file_path = photos_root / f"{date_str}_{safe_type}_{timestamp}{suffix}"
content = await photo.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(status_code=400, detail="照片不能超过 5MB")
file_path.write_bytes(content)
photo_path = str(file_path)
calories_i = _parse_optional_int(calories, "卡路里")
protein_f = _parse_optional_float(protein, "蛋白质")
carbs_f = _parse_optional_float(carbs, "碳水")
fat_f = _parse_optional_float(fat, "脂肪")
if calories_i is None and description:
from ..core.calories import estimate_meal_calories
result = estimate_meal_calories(description)
calories_i = result["total_calories"]
protein_f = result["total_protein"]
carbs_f = result["total_carbs"]
fat_f = result["total_fat"]
else:
calories_i = calories_i or 0
meal = Meal(
date=record_date,
meal_type=meal_type,
description=description,
calories=calories_i,
protein=protein_f,
carbs=carbs_f,
fat=fat_f,
photo_path=photo_path,
food_items=None,
)
record_id = db.add_meal(meal, user_id=active_user.id)
return {"success": True, "id": record_id, "calories": calories_i}
@app.delete("/api/meal/{meal_id}")
async def delete_meal_api(meal_id: int):
"""删除饮食记录"""
try:
db.delete_meal(meal_id)
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/meal/recognize")
async def recognize_food(
text: Optional[str] = Form(None),
image: Optional[UploadFile] = File(None),
provider: str = Form("qwen"),
):
"""
智能食物识别
支持两种输入方式:
- text: 文字描述,如 "今天吃了一碗米饭、两个鸡蛋"
- image: 食物图片
provider: "qwen" | "deepseek" | "claude" | "local"
"""
if not text and not image:
raise HTTPException(status_code=400, detail="请提供文字描述或上传图片")
try:
if image:
# 图片识别
import tempfile
from ..vision.analyzer import get_analyzer
# 保存临时文件
suffix = Path(image.filename or "").suffix.lower() or ".jpg"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await image.read()
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片不能超过 10MB")
tmp.write(content)
tmp_path = Path(tmp.name)
try:
analyzer = get_analyzer(provider=provider)
if hasattr(analyzer, "analyze_image"):
result = analyzer.analyze_image(tmp_path)
else:
result = analyzer.analyze(tmp_path)
finally:
tmp_path.unlink(missing_ok=True)
else:
# 文字识别
if provider == "qwen":
from ..vision.providers.qwen import get_qwen_analyzer
analyzer = get_qwen_analyzer()
result = analyzer.analyze_text(text)
elif provider == "deepseek":
from ..vision.providers.deepseek import get_deepseek_analyzer
analyzer = get_deepseek_analyzer()
result = analyzer.analyze_text(text)
else:
# Claude 和 local 只用本地估算
from ..core.calories import estimate_meal_calories
result = estimate_meal_calories(text)
result["description"] = text
result["provider"] = provider
return {
"success": True,
"description": result.get("description", ""),
"total_calories": result.get("total_calories", 0),
"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", provider),
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}") from e
@app.post("/api/sleep")
async def add_sleep_api(data: SleepInput):
"""添加睡眠记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
try:
record_date = date.fromisoformat(data.date)
except ValueError as exc:
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
bedtime = time.fromisoformat(data.bedtime) if data.bedtime else None
wake_time = time.fromisoformat(data.wake_time) if data.wake_time else None
sleep = Sleep(
date=record_date,
bedtime=bedtime,
wake_time=wake_time,
duration=data.duration,
quality=data.quality,
notes=data.notes,
source="web",
)
record_id = db.add_sleep(sleep, user_id=active_user.id)
return {"success": True, "id": record_id}
@app.delete("/api/sleep/{sleep_id}")
async def delete_sleep_api(sleep_id: int):
"""删除睡眠记录"""
try:
db.delete_sleep(sleep_id)
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/weight")
async def add_weight_api(data: WeightInput):
"""添加体重记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
try:
record_date = date.fromisoformat(data.date)
except ValueError as exc:
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
weight = Weight(
date=record_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, user_id=active_user.id)
return {"success": True, "id": record_id}
@app.delete("/api/weight/{weight_id}")
async def delete_weight_api(weight_id: int):
"""删除体重记录"""
try:
db.delete_weight(weight_id)
return {"success": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/meals/nutrition")
async def get_meal_nutrition_stats(
days: int = Query(default=30, ge=1, le=365, description="统计天数"),
):
"""获取饮食营养统计"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
today = date.today()
start_date = today - timedelta(days=days - 1)
meals = db.get_meals(start_date=start_date, end_date=today, user_id=active_user.id)
total_calories = sum(m.calories for m in meals)
total_protein = sum(m.protein or 0 for m in meals)
total_carbs = sum(m.carbs or 0 for m in meals)
total_fat = sum(m.fat or 0 for m in meals)
meal_type_counts: dict[str, int] = {}
meal_type_calories: dict[str, int] = {}
for m in meals:
meal_type_counts[m.meal_type] = meal_type_counts.get(m.meal_type, 0) + 1
meal_type_calories[m.meal_type] = meal_type_calories.get(m.meal_type, 0) + m.calories
recent_days = 7 if days >= 7 else days
recent_start = today - timedelta(days=recent_days - 1)
daily_stats = []
for i in range(recent_days):
day = recent_start + timedelta(days=i)
day_meals = [m for m in meals if m.date == day]
daily_stats.append({
"date": day.isoformat(),
"calories": sum(m.calories for m in day_meals),
"protein": sum(m.protein or 0 for m in day_meals),
"carbs": sum(m.carbs or 0 for m in day_meals),
"fat": sum(m.fat or 0 for m in day_meals),
})
return {
"days": days,
"total_calories": total_calories,
"macros": {
"protein": round(total_protein, 1),
"carbs": round(total_carbs, 1),
"fat": round(total_fat, 1),
},
"meal_types": {
"counts": meal_type_counts,
"calories": meal_type_calories,
},
"daily_stats": daily_stats,
}
@app.get("/api/sleep", response_model=list[SleepResponse])
async def get_sleep_records(
days: int = Query(default=30, ge=1, le=365, description="查询天数"),
):
"""获取睡眠记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
end_date = date.today()
start_date = end_date - timedelta(days=days)
records = db.get_sleep_records(start_date=start_date, end_date=end_date, user_id=active_user.id)
return [
SleepResponse(
id=s.id,
date=s.date.isoformat(),
bedtime=s.bedtime.isoformat() if s.bedtime else None,
wake_time=s.wake_time.isoformat() if s.wake_time else None,
duration=s.duration,
quality=s.quality,
deep_sleep_mins=s.deep_sleep_mins,
source=s.source,
notes=s.notes,
)
for s in records
]
@app.get("/api/weight", response_model=list[WeightResponse])
async def get_weight_records(
days: int = Query(default=90, ge=1, le=365, description="查询天数"),
):
"""获取体重记录"""
# 获取激活用户
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="没有激活的用户")
end_date = date.today()
start_date = end_date - timedelta(days=days)
records = db.get_weight_records(start_date=start_date, end_date=end_date, user_id=active_user.id)
return [
WeightResponse(
id=w.id,
date=w.date.isoformat(),
weight_kg=w.weight_kg,
body_fat_pct=w.body_fat_pct,
muscle_mass=w.muscle_mass,
notes=w.notes,
)
for w in records
]
@app.get("/api/weight/goal")
async def get_weight_goal():
"""获取目标体重(基于用户配置推断)"""
active_user = db.get_active_user()
user_id = active_user.id if active_user else 1
config = db.get_config(user_id)
if not config.weight:
return {"goal_weight": None}
if config.goal == "lose":
goal_weight = round(config.weight * 0.95, 1)
elif config.goal == "gain":
goal_weight = round(config.weight * 1.05, 1)
else:
goal_weight = round(config.weight, 1)
return {"goal_weight": goal_weight}
# ===== 阅读 API =====
@app.get("/reading")
async def reading_page():
"""阅读页面"""
return HTMLResponse(content=get_reading_page_html(), status_code=200)
@app.get("/api/reading", response_model=list[ReadingResponse])
async def get_readings(days: int = 30):
"""获取阅读记录"""
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="请先设置活跃用户")
readings = db.get_readings(user_id=active_user.id, days=days)
return [
ReadingResponse(
id=r.id,
user_id=r.user_id,
date=r.date.isoformat(),
title=r.title,
author=r.author,
cover_url=r.cover_url,
duration=r.duration,
mood=r.mood,
notes=r.notes,
)
for r in readings
]
@app.post("/api/reading", response_model=ReadingResponse)
async def add_reading(reading_input: ReadingInput):
"""添加阅读记录"""
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="请先设置活跃用户")
from datetime import date as dt_date
reading_date = dt_date.fromisoformat(reading_input.date) if reading_input.date else dt_date.today()
reading = Reading(
date=reading_date,
title=reading_input.title,
author=reading_input.author,
cover_url=reading_input.cover_url,
duration=reading_input.duration,
mood=reading_input.mood,
notes=reading_input.notes,
)
reading_id = db.add_reading(reading, user_id=active_user.id)
created = db.get_reading(reading_id)
return ReadingResponse(
id=created.id,
user_id=created.user_id,
date=created.date.isoformat(),
title=created.title,
author=created.author,
cover_url=created.cover_url,
duration=created.duration,
mood=created.mood,
notes=created.notes,
)
@app.delete("/api/reading/{reading_id}")
async def delete_reading(reading_id: int):
"""删除阅读记录"""
reading = db.get_reading(reading_id)
if not reading:
raise HTTPException(status_code=404, detail="阅读记录不存在")
db.delete_reading(reading_id)
return {"message": "阅读记录已删除"}
@app.get("/api/reading/today")
async def get_today_reading():
"""获取今日阅读摘要"""
active_user = db.get_active_user()
if not active_user:
return {"duration": 0, "book": None, "mood": None}
return db.get_today_reading(user_id=active_user.id)
@app.get("/api/reading/stats")
async def get_reading_stats(days: int = 30):
"""获取阅读统计"""
active_user = db.get_active_user()
if not active_user:
raise HTTPException(status_code=400, detail="请先设置活跃用户")
return db.get_reading_stats(user_id=active_user.id, days=days)
@app.get("/api/books/search")
async def search_books(q: str):
"""搜索书籍OpenLibrary API"""
import urllib.request
import urllib.parse
import json
import ssl
if not q or len(q.strip()) < 2:
return {"books": []}
try:
encoded_q = urllib.parse.quote(q)
url = f"https://openlibrary.org/search.json?q={encoded_q}&limit=5"
# 创建不验证 SSL 的上下文
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers={"User-Agent": "Vitals/1.0"})
with urllib.request.urlopen(req, timeout=5, context=ssl_context) as response:
data = json.loads(response.read().decode())
books = []
for doc in data.get("docs", [])[:5]:
cover_id = doc.get("cover_i")
cover_url = f"https://covers.openlibrary.org/b/id/{cover_id}-M.jpg" if cover_id else None
books.append({
"title": doc.get("title", ""),
"author": doc.get("author_name", [""])[0] if doc.get("author_name") else None,
"cover_url": cover_url,
"isbn": doc.get("isbn", [""])[0] if doc.get("isbn") else None,
})
return {"books": books}
except Exception as e:
return {"books": [], "error": str(e)}
# ===== 用户管理 API =====
@app.get("/api/users", response_model=list[UserResponse])
async def get_users(current_user: User = Depends(require_user)):
"""获取用户列表(非管理员仅返回自己)"""
if current_user.is_admin:
users = db.get_users()
else:
users = [db.get_user(current_user.id)]
return [
UserResponse(
id=u.id,
name=u.name,
created_at=u.created_at.isoformat(),
is_active=u.is_active,
gender=u.gender,
height_cm=u.height_cm,
weight_kg=u.weight_kg,
age=u.age,
bmi=u.bmi,
bmi_status=u.bmi_status,
)
for u in users if u
]
@app.get("/api/users/active", response_model=UserResponse)
async def get_active_user():
"""获取当前激活的用户"""
user = db.get_active_user()
if not user:
raise HTTPException(status_code=404, detail="没有激活的用户")
return UserResponse(
id=user.id,
name=user.name,
created_at=user.created_at.isoformat(),
is_active=user.is_active,
gender=user.gender,
height_cm=user.height_cm,
weight_kg=user.weight_kg,
age=user.age,
bmi=user.bmi,
bmi_status=user.bmi_status,
)
@app.get("/api/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, current_user: User = Depends(require_user)):
"""获取指定用户(非管理员只能查自己)"""
if not current_user.is_admin and user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看其他用户")
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return UserResponse(
id=user.id,
name=user.name,
created_at=user.created_at.isoformat(),
is_active=user.is_active,
gender=user.gender,
height_cm=user.height_cm,
weight_kg=user.weight_kg,
age=user.age,
bmi=user.bmi,
bmi_status=user.bmi_status,
)
@app.post("/api/users", response_model=UserResponse)
async def create_user(user_input: UserInput):
"""创建新用户"""
user = User(
name=user_input.name,
gender=user_input.gender,
height_cm=user_input.height_cm,
weight_kg=user_input.weight_kg,
age=user_input.age,
)
user_id = db.add_user(user)
created_user = db.get_user(user_id)
return UserResponse(
id=created_user.id,
name=created_user.name,
created_at=created_user.created_at.isoformat(),
is_active=created_user.is_active,
gender=created_user.gender,
height_cm=created_user.height_cm,
weight_kg=created_user.weight_kg,
age=created_user.age,
bmi=created_user.bmi,
bmi_status=created_user.bmi_status,
)
@app.put("/api/users/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_input: UserInput):
"""更新用户信息"""
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.name = user_input.name
user.gender = user_input.gender
user.height_cm = user_input.height_cm
user.weight_kg = user_input.weight_kg
user.age = user_input.age
db.update_user(user)
updated_user = db.get_user(user_id)
return UserResponse(
id=updated_user.id,
name=updated_user.name,
created_at=updated_user.created_at.isoformat(),
is_active=updated_user.is_active,
gender=updated_user.gender,
height_cm=updated_user.height_cm,
weight_kg=updated_user.weight_kg,
age=updated_user.age,
bmi=updated_user.bmi,
bmi_status=updated_user.bmi_status,
)
@app.delete("/api/users/{user_id}")
async def delete_user(user_id: int):
"""删除用户"""
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.is_active:
raise HTTPException(status_code=400, detail="无法删除激活中的用户,请先切换到其他用户")
db.delete_user(user_id)
return {"message": "用户已删除"}
@app.post("/api/users/{user_id}/activate")
async def activate_user(user_id: int):
"""设置激活用户"""
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
db.set_active_user(user_id)
return {"message": f"已切换到用户: {user.name}"}
# ===== 数据清除 API =====
@app.post("/api/data/preview-delete")
async def preview_delete_data(request: DataClearInput):
"""预览将要删除的数据量"""
# 验证用户存在
user = db.get_user(request.user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 转换日期字符串
date_from = date.fromisoformat(request.date_from) if request.date_from else None
date_to = date.fromisoformat(request.date_to) if request.date_to else None
# 获取预览数据
counts = db.preview_delete(
user_id=request.user_id,
mode=request.mode,
date_from=date_from,
date_to=date_to,
data_types=request.data_types,
)
return counts
@app.post("/api/data/clear")
async def clear_data(request: DataClearInput):
"""清除数据"""
# 验证用户存在
user = db.get_user(request.user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 转换日期字符串
date_from = date.fromisoformat(request.date_from) if request.date_from else None
date_to = date.fromisoformat(request.date_to) if request.date_to else None
# 执行清除
db.clear_data(
user_id=request.user_id,
mode=request.mode,
date_from=date_from,
date_to=date_to,
data_types=request.data_types,
)
return {"message": "数据已清除"}
# ===== 移动端 H5 通用样式和组件 =====
def get_common_mobile_styles() -> str:
"""生成移动端通用 CSS 样式"""
return """
/* 移动端基础 */
* {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
/* 响应式断点 */
@media (max-width: 768px) {
html { font-size: 14px; }
.container {
padding: 0 16px;
max-width: 100%;
}
/* 隐藏桌面导航 */
.desktop-nav, .nav { display: none !important; }
/* 显示移动端底部导航 */
.mobile-nav { display: flex !important; }
/* 内容区域留出底部导航空间 */
.main-content, body {
padding-bottom: 80px;
}
}
@media (min-width: 769px) {
.mobile-nav { display: none !important; }
.desktop-nav, .nav { display: flex; }
}
/* 触摸目标 */
.touch-target {
min-height: 44px;
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
}
/* 安全区域 */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
"""
def get_mobile_nav_html(active_page: str = "", is_admin: bool = False) -> str:
"""生成移动端底部导航栏"""
def nav_item(href: str, icon: str, label: str, page: str) -> str:
active_class = "active" if active_page == page else ""
return f'''
<a href="{href}" class="mobile-nav-item {active_class}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{icon}
</svg>
<span class="nav-label">{label}</span>
</a>
'''
# SVG 图标路径
icons = {
"home": '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
"exercise": '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
"meal": '<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/>',
"sleep": '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
"more": '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
}
admin_link = f'<a href="/admin" class="more-menu-item">管理</a>' if is_admin else ""
return f'''
<nav class="mobile-nav safe-area-bottom">
{nav_item("/", icons["home"], "首页", "home")}
{nav_item("/exercise", icons["exercise"], "运动", "exercise")}
{nav_item("/meal", icons["meal"], "饮食", "meal")}
{nav_item("/sleep", icons["sleep"], "睡眠", "sleep")}
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{icons["more"]}
</svg>
<span class="nav-label">更多</span>
</div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
{admin_link}
</div>
<style>
.mobile-nav {{
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: white;
border-top: 1px solid #E2E8F0;
display: none;
justify-content: space-around;
align-items: center;
z-index: 50;
}}
.mobile-nav-item {{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 64px;
min-height: 44px;
color: #64748B;
text-decoration: none;
cursor: pointer;
}}
.mobile-nav-item.active {{
color: #3B82F6;
}}
.nav-icon {{
width: 24px;
height: 24px;
margin-bottom: 2px;
}}
.nav-label {{
font-size: 10px;
font-weight: 500;
}}
.more-menu {{
position: fixed;
bottom: 72px;
right: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
padding: 8px 0;
z-index: 51;
}}
.more-menu.hidden {{
display: none;
}}
.more-menu-item {{
display: block;
padding: 12px 24px;
color: #1E293B;
text-decoration: none;
}}
.more-menu-item:hover {{
background: #F1F5F9;
}}
@media (max-width: 768px) {{
.mobile-nav {{ display: flex; }}
}}
</style>
<script>
function toggleMoreMenu() {{
const menu = document.getElementById('more-menu');
menu.classList.toggle('hidden');
}}
// 点击其他地方关闭菜单
document.addEventListener('click', function(e) {{
if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) {{
document.getElementById('more-menu').classList.add('hidden');
}}
}});
</script>
'''
# ===== 认证页面 HTML =====
def get_login_page_html() -> str:
"""生成登录页面 HTML - Neumorphism 设计风格"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Vitals 健康管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&family=Raleway:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Raleway', -apple-system, BlinkMacSystemFont, sans-serif;
background: #ECFEFF;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: #ECFEFF;
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow:
-10px -10px 30px rgba(255, 255, 255, 0.8),
10px 10px 30px rgba(0, 0, 0, 0.1);
}
.logo {
text-align: center;
margin-bottom: 36px;
}
.logo-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #0891B2, #22D3EE);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow:
-4px -4px 10px rgba(255, 255, 255, 0.8),
4px 4px 10px rgba(0, 0, 0, 0.1);
}
.logo-icon svg {
width: 36px;
height: 36px;
color: white;
}
.logo h1 {
font-family: 'Lora', serif;
font-size: 2rem;
font-weight: 600;
color: #164E63;
margin-bottom: 8px;
}
.logo p {
color: #0891B2;
font-size: 0.95rem;
font-weight: 400;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 10px;
font-weight: 500;
color: #164E63;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 16px 20px;
border: none;
border-radius: 12px;
font-size: 1rem;
font-family: 'Raleway', sans-serif;
background: #ECFEFF;
color: #164E63;
box-shadow:
inset 3px 3px 8px rgba(0, 0, 0, 0.08),
inset -3px -3px 8px rgba(255, 255, 255, 0.9);
transition: box-shadow 0.2s ease;
}
.form-group input:focus {
outline: none;
box-shadow:
inset 3px 3px 8px rgba(0, 0, 0, 0.08),
inset -3px -3px 8px rgba(255, 255, 255, 0.9),
0 0 0 3px rgba(8, 145, 178, 0.2);
}
.form-group input::placeholder {
color: #94A3B8;
}
.remember-row {
display: flex;
align-items: center;
margin-bottom: 28px;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-wrapper input[type="checkbox"] {
display: none;
}
.checkbox-custom {
width: 22px;
height: 22px;
border-radius: 6px;
background: #ECFEFF;
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.08),
inset -2px -2px 5px rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
transition: all 0.2s ease;
}
.checkbox-wrapper input[type="checkbox"]:checked + .checkbox-custom {
background: linear-gradient(135deg, #0891B2, #22D3EE);
box-shadow:
-2px -2px 5px rgba(255, 255, 255, 0.8),
2px 2px 5px rgba(0, 0, 0, 0.1);
}
.checkbox-custom svg {
width: 14px;
height: 14px;
color: white;
opacity: 0;
transition: opacity 0.2s ease;
}
.checkbox-wrapper input[type="checkbox"]:checked + .checkbox-custom svg {
opacity: 1;
}
.checkbox-label {
color: #164E63;
font-size: 0.9rem;
user-select: none;
}
.btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #059669, #10B981);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
font-family: 'Raleway', sans-serif;
cursor: pointer;
box-shadow:
-4px -4px 10px rgba(255, 255, 255, 0.8),
4px 4px 10px rgba(0, 0, 0, 0.15);
transition: all 0.15s ease;
}
.btn:hover {
box-shadow:
-2px -2px 5px rgba(255, 255, 255, 0.8),
2px 2px 5px rgba(0, 0, 0, 0.15);
}
.btn:active {
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.2),
inset -2px -2px 5px rgba(255, 255, 255, 0.1);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn.success {
background: linear-gradient(135deg, #059669, #10B981);
}
.error-msg {
background: #FEE2E2;
color: #DC2626;
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 24px;
display: none;
font-size: 0.9rem;
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.05),
inset -2px -2px 5px rgba(255, 255, 255, 0.5);
}
.divider {
display: flex;
align-items: center;
margin: 28px 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, rgba(8, 145, 178, 0.3), transparent);
}
.divider span {
padding: 0 16px;
color: #94A3B8;
font-size: 0.85rem;
}
.links {
text-align: center;
}
.links p {
color: #64748B;
font-size: 0.9rem;
}
.links a {
color: #0891B2;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.links a:hover {
color: #164E63;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
padding: 16px;
}
.login-card {
width: 100%;
max-width: none;
box-shadow: none;
padding: 32px 20px;
}
.form-group input {
height: 48px;
font-size: 16px; /* 防止 iOS 缩放 */
padding: 14px 16px;
}
.btn {
height: 48px;
font-size: 16px;
}
.logo h1 {
font-size: 1.75rem;
}
}
@media (max-width: 380px) {
.login-card {
padding: 24px 16px;
}
}
/* 减少动画 */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
</style>
</head>
<body>
<div class="login-card">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<h1>Vitals</h1>
<p>健康管理,从这里开始</p>
</div>
<div id="error" class="error-msg"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" placeholder="请输入用户名" required autofocus>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" placeholder="请输入密码" required>
</div>
<div class="remember-row">
<label class="checkbox-wrapper">
<input type="checkbox" id="rememberMe" name="rememberMe">
<span class="checkbox-custom">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
<span class="checkbox-label">记住我30天内免登录</span>
</label>
</div>
<button type="submit" class="btn" id="submitBtn">登录</button>
</form>
<div class="divider"><span>或</span></div>
<div class="links">
<p>没有账号?<a href="/register">立即注册</a></p>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const error = document.getElementById('error');
btn.disabled = true;
btn.textContent = '登录中...';
error.style.display = 'none';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
remember_me: document.getElementById('rememberMe').checked,
})
});
const data = await response.json();
if (response.ok) {
// 保存 token 和用户信息
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
// 显示成功状态
btn.textContent = '✓ 登录成功';
btn.classList.add('success');
// 跳转(管理员跳转到管理页面)
setTimeout(() => {
const params = new URLSearchParams(window.location.search);
let redirect = params.get('redirect');
// 如果没有指定重定向,管理员默认跳转到 /admin
if (!redirect) {
redirect = data.user.is_admin ? '/admin' : '/';
}
window.location.href = redirect;
}, 500);
} else {
error.textContent = data.detail || '登录失败';
error.style.display = 'block';
btn.disabled = false;
btn.textContent = '登录';
}
} catch (err) {
error.textContent = '网络错误,请稍后重试';
error.style.display = 'block';
btn.disabled = false;
btn.textContent = '登录';
}
});
</script>
</body>
</html>
"""
def get_register_page_html() -> str:
"""生成注册页面 HTML - Neumorphism 设计风格"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册 - Vitals 健康管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&family=Raleway:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Raleway', -apple-system, BlinkMacSystemFont, sans-serif;
background: #ECFEFF;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-card {
background: #ECFEFF;
border-radius: 24px;
padding: 40px;
width: 100%;
max-width: 420px;
box-shadow:
-10px -10px 30px rgba(255, 255, 255, 0.8),
10px 10px 30px rgba(0, 0, 0, 0.1);
}
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, #0891B2, #22D3EE);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
box-shadow:
-4px -4px 10px rgba(255, 255, 255, 0.8),
4px 4px 10px rgba(0, 0, 0, 0.1);
}
.logo-icon svg {
width: 32px;
height: 32px;
color: white;
}
.logo h1 {
font-family: 'Lora', serif;
font-size: 1.8rem;
font-weight: 600;
color: #164E63;
margin-bottom: 6px;
}
.logo p {
color: #0891B2;
font-size: 0.9rem;
font-weight: 400;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #164E63;
font-size: 0.85rem;
}
.form-group input {
width: 100%;
padding: 14px 18px;
border: none;
border-radius: 12px;
font-size: 0.95rem;
font-family: 'Raleway', sans-serif;
background: #ECFEFF;
color: #164E63;
box-shadow:
inset 3px 3px 8px rgba(0, 0, 0, 0.08),
inset -3px -3px 8px rgba(255, 255, 255, 0.9);
transition: box-shadow 0.2s ease;
}
.form-group input:focus {
outline: none;
box-shadow:
inset 3px 3px 8px rgba(0, 0, 0, 0.08),
inset -3px -3px 8px rgba(255, 255, 255, 0.9),
0 0 0 3px rgba(8, 145, 178, 0.2);
}
.form-group input::placeholder {
color: #94A3B8;
}
.form-group .hint {
font-size: 0.8rem;
color: #64748B;
margin-top: 8px;
padding-left: 4px;
}
.btn {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #059669, #10B981);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
font-family: 'Raleway', sans-serif;
cursor: pointer;
box-shadow:
-4px -4px 10px rgba(255, 255, 255, 0.8),
4px 4px 10px rgba(0, 0, 0, 0.15);
transition: all 0.15s ease;
margin-top: 8px;
}
.btn:hover {
box-shadow:
-2px -2px 5px rgba(255, 255, 255, 0.8),
2px 2px 5px rgba(0, 0, 0, 0.15);
}
.btn:active {
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.2),
inset -2px -2px 5px rgba(255, 255, 255, 0.1);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-msg {
background: #FEE2E2;
color: #DC2626;
padding: 12px 14px;
border-radius: 12px;
margin-bottom: 20px;
display: none;
font-size: 0.85rem;
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.05),
inset -2px -2px 5px rgba(255, 255, 255, 0.5);
}
.divider {
display: flex;
align-items: center;
margin: 24px 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, rgba(8, 145, 178, 0.3), transparent);
}
.divider span {
padding: 0 16px;
color: #94A3B8;
font-size: 0.85rem;
}
.links {
text-align: center;
}
.links p {
color: #64748B;
font-size: 0.9rem;
}
.links a {
color: #0891B2;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.links a:hover {
color: #164E63;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
padding: 16px;
}
.register-card {
width: 100%;
max-width: none;
box-shadow: none;
padding: 32px 20px;
}
.form-group input {
height: 48px;
font-size: 16px; /* 防止 iOS 缩放 */
padding: 14px 16px;
}
.btn {
height: 48px;
font-size: 16px;
}
.logo h1 {
font-size: 1.5rem;
}
}
@media (max-width: 380px) {
.register-card {
padding: 24px 16px;
}
}
/* 减少动画 */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
</style>
</head>
<body>
<div class="register-card">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<h1>Vitals</h1>
<p>创建账号,开始健康管理</p>
</div>
<div id="error" class="error-msg"></div>
<form id="registerForm">
<div class="form-group">
<label for="inviteCode">邀请码</label>
<input type="text" id="inviteCode" name="inviteCode" placeholder="请输入邀请码" required>
<p class="hint">需要邀请码才能注册,请联系管理员获取</p>
</div>
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" placeholder="2-50 个字符" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" placeholder="至少 6 位" required>
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="再次输入密码" required>
</div>
<button type="submit" class="btn" id="submitBtn">注册</button>
</form>
<div class="divider"><span>或</span></div>
<div class="links">
<p>已有账号?<a href="/login">立即登录</a></p>
</div>
</div>
<script>
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const error = document.getElementById('error');
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
error.textContent = '两次输入的密码不一致';
error.style.display = 'block';
return;
}
if (password.length < 6) {
error.textContent = '密码至少需要 6 位';
error.style.display = 'block';
return;
}
btn.disabled = true;
btn.textContent = '注册中...';
error.style.display = 'none';
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
username: document.getElementById('username').value,
password: password,
invite_code: document.getElementById('inviteCode').value,
})
});
const data = await response.json();
if (response.ok) {
// 保存 token 和用户信息
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
// 显示成功
btn.textContent = '✓ 注册成功';
setTimeout(() => {
window.location.href = '/';
}, 500);
} else {
error.textContent = data.detail || '注册失败';
error.style.display = 'block';
btn.disabled = false;
btn.textContent = '注册';
}
} catch (err) {
error.textContent = '网络错误,请稍后重试';
error.style.display = 'block';
btn.disabled = false;
btn.textContent = '注册';
}
});
</script>
</body>
</html>
"""
def get_admin_page_html() -> str:
"""生成管理后台页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - Vitals 健康管理</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a {
color: #666;
text-decoration: none;
font-weight: 600;
}
.nav a.active { color: #667eea; }
.nav a:hover { color: #667eea; }
.nav .spacer { flex: 1; }
.nav .user-info { color: #888; font-size: 0.9rem; }
.nav .logout-btn {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.9rem;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 20px;
text-align: center;
margin-bottom: 30px;
border-radius: 12px;
}
header h1 { font-size: 2rem; margin-bottom: 10px; }
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.card h2 {
font-size: 1.2rem;
margin-bottom: 16px;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
}
table {
width: 100%;
border-collapse: collapse;
}
table th, table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
table th {
background: #f8f8f8;
font-weight: 600;
color: #666;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover { background: #5a6fd6; }
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover { background: #dc2626; }
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover { background: #059669; }
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.status-active { background: #d1fae5; color: #059669; }
.status-disabled { background: #fee2e2; color: #dc2626; }
.status-used { background: #e0e7ff; color: #4f46e5; }
.status-unused { background: #fef3c7; color: #d97706; }
.status-expired { background: #f3f4f6; color: #6b7280; }
.invite-code {
font-family: monospace;
background: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
}
.no-data {
text-align: center;
padding: 40px;
color: #888;
}
.loading {
text-align: center;
padding: 40px;
color: #888;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active { display: flex; }
.modal-content {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
}
.modal-header {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 16px;
}
.modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.form-group input, .form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
.unauthorized {
text-align: center;
padding: 60px 20px;
}
.unauthorized h2 {
color: #ef4444;
margin-bottom: 16px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px)); }
.container { padding: 12px; }
header { padding: 20px 16px; margin-bottom: 16px; }
header h1 { font-size: 1.5rem; }
.card { padding: 16px; margin-bottom: 16px; }
.card h2 { font-size: 1.1rem; flex-direction: column; align-items: flex-start; gap: 12px; }
/* 表格改为卡片列表 */
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { background: #f8f9fa; border-radius: 8px; padding: 12px; margin-bottom: 12px; border: 1px solid #e2e8f0; }
td { padding: 8px 0; border: none; display: flex; justify-content: space-between; align-items: center; }
td:before { content: attr(data-label); font-weight: 600; color: #64748b; font-size: 0.85rem; }
/* 按钮触摸目标 */
.btn { min-height: 44px; padding: 12px 16px; font-size: 1rem; }
.btn-sm { min-height: 40px; padding: 10px 14px; }
/* 表单输入框 */
.form-group input, .form-group select { font-size: 16px; min-height: 48px; padding: 14px 16px; }
/* 模态框 */
.modal-content { margin: 12px; padding: 20px; }
.modal-footer { flex-direction: column; gap: 10px; }
.modal-footer .btn { width: 100%; }
/* 操作按钮组 */
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.action-buttons .btn { flex: 1; min-width: 80px; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
<a href="/admin" id="admin-nav-link" class="active">管理</a>
<div class="spacer"></div>
<span class="user-info" id="userInfo"></span>
<button class="logout-btn" onclick="logout()">退出</button>
</div>
<div id="unauthorizedView" class="unauthorized" style="display: none;">
<h2>无权访问</h2>
<p>此页面仅限管理员访问</p>
<p style="margin-top: 20px;"><a href="/">返回首页</a></p>
</div>
<div id="adminContent" style="display: none;">
<header>
<h1>管理后台</h1>
<p>用户管理与邀请码管理</p>
</header>
<div class="card">
<h2>
用户管理
<span id="userCount" style="font-weight: normal; font-size: 0.9rem; color: #888;"></span>
</h2>
<div id="usersLoading" class="loading">加载中...</div>
<table id="usersTable" style="display: none;">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>状态</th>
<th>管理员</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
<div class="card">
<h2>
邀请码管理
<button class="btn btn-primary btn-sm" onclick="showCreateInviteModal()">生成邀请码</button>
</h2>
<div id="invitesLoading" class="loading">加载中...</div>
<table id="invitesTable" style="display: none;">
<thead>
<tr>
<th>邀请码</th>
<th>状态</th>
<th>创建者</th>
<th>使用者</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="invitesBody"></tbody>
</table>
<div id="invitesEmpty" class="no-data" style="display: none;">暂无邀请码</div>
</div>
</div>
</div>
<!-- 创建邀请码弹窗 -->
<div id="createInviteModal" class="modal">
<div class="modal-content">
<div class="modal-header">生成邀请码</div>
<div class="form-group">
<label>过期时间(可选)</label>
<select id="expiresDays">
<option value="">永不过期</option>
<option value="1">1 天</option>
<option value="7">7 天</option>
<option value="30">30 天</option>
</select>
</div>
<div class="modal-footer">
<button class="btn" onclick="hideCreateInviteModal()">取消</button>
<button class="btn btn-primary" onclick="createInvite()">生成</button>
</div>
</div>
</div>
<!-- 邀请码显示弹窗 -->
<div id="showInviteModal" class="modal">
<div class="modal-content">
<div class="modal-header">邀请码已生成</div>
<p style="margin-bottom: 16px;">请复制并发送给需要注册的用户:</p>
<div style="text-align: center; padding: 20px; background: #f3f4f6; border-radius: 8px;">
<span id="newInviteCode" class="invite-code" style="font-size: 1.5rem;"></span>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="copyInviteCode()">复制</button>
<button class="btn" onclick="hideShowInviteModal()">关闭</button>
</div>
</div>
</div>
<script>
let currentUser = null;
let users = [];
// 初始化
document.addEventListener('DOMContentLoaded', () => {
checkAuth();
});
function checkAuth() {
const userStr = localStorage.getItem('user');
if (!userStr) {
// 没有用户信息,尝试从 API 获取
fetch('/api/auth/me', { credentials: 'same-origin' })
.then(res => {
if (!res.ok) {
window.location.href = '/login';
return;
}
return res.json();
})
.then(user => {
if (user) {
localStorage.setItem('user', JSON.stringify(user));
currentUser = user;
initAdminPage();
}
})
.catch(() => window.location.href = '/login');
return;
}
try {
currentUser = JSON.parse(userStr);
initAdminPage();
} catch (e) {
window.location.href = '/login';
}
}
function initAdminPage() {
document.getElementById('userInfo').textContent = currentUser.name;
if (!currentUser.is_admin) {
// 隐藏导航栏中的管理链接
const adminNavLink = document.getElementById('admin-nav-link');
if (adminNavLink) adminNavLink.style.display = 'none';
document.getElementById('unauthorizedView').style.display = 'block';
return;
}
document.getElementById('adminContent').style.display = 'block';
loadUsers();
loadInvites();
}
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (e) {
// 忽略错误
}
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
function getToken() {
return localStorage.getItem('token');
}
async function apiRequest(url, options = {}) {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
credentials: 'same-origin',
headers
});
if (response.status === 401) {
logout();
return null;
}
return response;
}
async function loadUsers() {
const response = await apiRequest('/api/admin/users');
if (!response) return;
users = await response.json();
document.getElementById('userCount').textContent = `共 ${users.length} 个用户`;
document.getElementById('usersLoading').style.display = 'none';
document.getElementById('usersTable').style.display = 'table';
const tbody = document.getElementById('usersBody');
tbody.innerHTML = users.map(user => `
<tr>
<td data-label="ID">${user.id}</td>
<td data-label="用户名">${user.name}</td>
<td data-label="状态">
<span class="status-badge ${user.is_disabled ? 'status-disabled' : 'status-active'}">
${user.is_disabled ? '已禁用' : '正常'}
</span>
</td>
<td data-label="管理员">${user.is_admin ? '' : ''}</td>
<td data-label="创建时间">${user.created_at.split('T')[0]}</td>
<td data-label="操作">
${user.id !== currentUser.id ? `
<div class="action-buttons">
${user.is_disabled
? `<button class="btn btn-success btn-sm" onclick="enableUser(${user.id})">启用</button>`
: `<button class="btn btn-danger btn-sm" onclick="disableUser(${user.id})">禁用</button>`
}
<button class="btn btn-danger btn-sm" onclick="deleteUser(${user.id}, '${user.name}')">删除</button>
</div>
` : '<span style="color:#888">当前用户</span>'}
</td>
</tr>
`).join('');
}
async function disableUser(userId) {
if (!confirm('确定要禁用此用户吗?')) return;
await apiRequest(`/api/admin/users/${userId}/disable`, { method: 'POST' });
loadUsers();
}
async function enableUser(userId) {
await apiRequest(`/api/admin/users/${userId}/enable`, { method: 'POST' });
loadUsers();
}
async function deleteUser(userId, username) {
if (!confirm(`确定要删除用户 "${username}" 吗?\\n此操作将删除该用户的所有数据且无法恢复`)) return;
await apiRequest(`/api/admin/users/${userId}`, { method: 'DELETE' });
loadUsers();
}
async function loadInvites() {
const response = await apiRequest('/api/admin/invites');
if (!response) return;
const invites = await response.json();
document.getElementById('invitesLoading').style.display = 'none';
if (invites.length === 0) {
document.getElementById('invitesEmpty').style.display = 'block';
return;
}
document.getElementById('invitesTable').style.display = 'table';
const tbody = document.getElementById('invitesBody');
tbody.innerHTML = invites.map(invite => {
let status = '未使用';
let statusClass = 'status-unused';
if (invite.is_used) {
status = '已使用';
statusClass = 'status-used';
} else if (invite.is_expired) {
status = '已过期';
statusClass = 'status-expired';
}
const creator = users.find(u => u.id === invite.created_by);
const usedBy = invite.used_by ? users.find(u => u.id === invite.used_by) : null;
return `
<tr>
<td data-label="邀请码"><span class="invite-code">${invite.code}</span></td>
<td data-label="状态"><span class="status-badge ${statusClass}">${status}</span></td>
<td data-label="创建者">${creator ? creator.name : invite.created_by}</td>
<td data-label="使用者">${usedBy ? usedBy.name : '-'}</td>
<td data-label="创建时间">${invite.created_at.split('T')[0]}</td>
<td data-label="操作">
${!invite.is_used ? `<button class="btn btn-danger btn-sm" onclick="deleteInvite(${invite.id})">删除</button>` : '-'}
</td>
</tr>
`;
}).join('');
}
function showCreateInviteModal() {
document.getElementById('createInviteModal').classList.add('active');
}
function hideCreateInviteModal() {
document.getElementById('createInviteModal').classList.remove('active');
}
async function createInvite() {
const expiresDays = document.getElementById('expiresDays').value;
const body = expiresDays ? { expires_days: parseInt(expiresDays) } : {};
const response = await apiRequest('/api/admin/invites', {
method: 'POST',
body: JSON.stringify(body),
});
if (!response) return;
const invite = await response.json();
hideCreateInviteModal();
document.getElementById('newInviteCode').textContent = invite.code;
document.getElementById('showInviteModal').classList.add('active');
loadInvites();
}
function hideShowInviteModal() {
document.getElementById('showInviteModal').classList.remove('active');
}
function copyInviteCode() {
const code = document.getElementById('newInviteCode').textContent;
navigator.clipboard.writeText(code);
alert('邀请码已复制到剪贴板');
}
async function deleteInvite(inviteId) {
if (!confirm('确定要删除此邀请码吗?')) return;
await apiRequest(`/api/admin/invites/${inviteId}`, { method: 'DELETE' });
loadInvites();
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" style="color:#667eea;font-weight:600;">管理</a>
</div>
<style>
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: calc(64px + env(safe-area-inset-bottom, 0px)); padding-bottom: env(safe-area-inset-bottom, 0px); background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: flex-start; padding-top: 8px; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.mobile-nav-item.active { color: #667eea; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: calc(72px + env(safe-area-inset-bottom, 0px)); right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 14px 24px; color: #1E293B; text-decoration: none; min-height: 44px; }
.more-menu-item:hover, .more-menu-item:active { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_dashboard_html() -> str:
"""生成仪表盘 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 健康管理</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a {
color: #666;
text-decoration: none;
font-weight: 600;
}
.nav a.active {
color: #667eea;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav {
display: none !important;
}
body {
padding-bottom: 80px;
}
.container {
padding: 12px;
}
header {
padding: 20px 16px;
margin-bottom: 16px;
}
header h1 {
font-size: 1.5rem;
}
.grid {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.card {
padding: 14px;
}
.card .value {
font-size: 1.5rem;
}
.chart-container {
padding: 14px;
height: auto;
}
.chart-container canvas {
max-height: 180px;
}
.meals-list li {
flex-wrap: wrap;
gap: 4px;
}
.meal-desc {
width: 100%;
margin: 4px 0;
order: 3;
}
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 20px;
text-align: center;
margin-bottom: 30px;
border-radius: 12px;
}
header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
header p {
opacity: 0.9;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card h3 {
font-size: 0.9rem;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card .value {
font-size: 2rem;
font-weight: 700;
color: #333;
}
.card .unit {
font-size: 1rem;
color: #666;
margin-left: 4px;
}
.card .change {
font-size: 0.85rem;
margin-top: 8px;
}
.positive { color: #10b981; }
.negative { color: #ef4444; }
.neutral { color: #6b7280; }
.chart-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.chart-container h3 {
margin-bottom: 20px;
color: #333;
}
.meals-list {
list-style: none;
}
.meals-list li {
padding: 12px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.meals-list li:last-child {
border-bottom: none;
}
.meal-type {
font-weight: 600;
color: #667eea;
min-width: 50px;
}
.meal-desc {
flex: 1;
margin: 0 15px;
color: #666;
}
.meal-cal {
font-weight: 600;
color: #333;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.no-data {
text-align: center;
padding: 40px;
color: #999;
}
footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a class="active" href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</div>
<header>
<h1>Vitals 健康管理</h1>
<p id="today-date"></p>
</header>
<div class="grid" id="stats-grid">
<div class="card">
<h3>今日摄入</h3>
<div class="value" id="calories-intake">--<span class="unit">卡</span></div>
</div>
<div class="card">
<h3>今日消耗</h3>
<div class="value" id="calories-burned">--<span class="unit">卡</span></div>
<div class="change neutral" id="calories-balance"></div>
</div>
<div class="card">
<h3>运动时长</h3>
<div class="value" id="exercise-duration">--<span class="unit">分钟</span></div>
</div>
<div class="card">
<h3>睡眠时长</h3>
<div class="value" id="sleep-duration">--<span class="unit">小时</span></div>
</div>
<div class="card">
<h3>今日阅读</h3>
<div class="value" id="reading-duration">--<span class="unit">分钟</span></div>
<div class="change neutral" id="reading-book"></div>
</div>
</div>
<div class="chart-container">
<h3>本周卡路里趋势</h3>
<canvas id="calories-chart" height="100"></canvas>
</div>
<div class="grid">
<div class="card">
<h3>今日饮食</h3>
<ul class="meals-list" id="meals-list">
<li class="no-data">暂无记录</li>
</ul>
</div>
<div class="card">
<h3>体重趋势 (近30天)</h3>
<canvas id="weight-chart" height="200"></canvas>
</div>
</div>
<footer>
Vitals v0.1.0 - 本地优先的健康管理
</footer>
</div>
<script>
// 格式化日期
const today = new Date();
document.getElementById('today-date').textContent =
today.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
});
// 加载今日数据
async function loadTodayData() {
try {
const res = await fetch('/api/today');
const data = await res.json();
document.getElementById('calories-intake').innerHTML =
`${data.calories_intake}<span class="unit">卡</span>`;
const totalBurned = (data.tdee || 0) + data.calories_burned;
document.getElementById('calories-burned').innerHTML =
`${totalBurned}<span class="unit">卡</span>`;
const balance = data.calories_balance;
const balanceEl = document.getElementById('calories-balance');
if (balance < 0) {
balanceEl.className = 'change positive';
balanceEl.textContent = `热量缺口 ${Math.abs(balance)} 卡`;
} else if (balance > 0) {
balanceEl.className = 'change negative';
balanceEl.textContent = `热量盈余 ${balance} 卡`;
} else {
balanceEl.className = 'change neutral';
balanceEl.textContent = '收支平衡';
}
document.getElementById('exercise-duration').innerHTML =
`${data.exercise_duration}<span class="unit">分钟</span>`;
if (data.sleep_duration) {
document.getElementById('sleep-duration').innerHTML =
`${data.sleep_duration.toFixed(1)}<span class="unit">小时</span>`;
}
// 加载今日阅读数据
try {
const readingRes = await fetch('/api/reading/today');
const readingData = await readingRes.json();
document.getElementById('reading-duration').innerHTML =
`${readingData.duration}<span class="unit">分钟</span>`;
const readingBook = document.getElementById('reading-book');
if (readingData.book) {
readingBook.textContent = `${readingData.mood || ''} ${readingData.book}`;
} else {
readingBook.textContent = '';
}
} catch (e) {
console.log('加载阅读数据失败');
}
// 更新饮食列表
const mealsList = document.getElementById('meals-list');
if (data.meals.length > 0) {
mealsList.innerHTML = data.meals.map(m => `
<li>
<span class="meal-type">${m.meal_type}</span>
<span class="meal-desc">${m.description}</span>
<span class="meal-cal">${m.calories} 卡</span>
</li>
`).join('');
}
} catch (error) {
console.error('加载今日数据失败:', error);
}
}
// 加载周数据并绘制图表
async function loadWeekData() {
try {
const res = await fetch('/api/week');
const data = await res.json();
const ctx = document.getElementById('calories-chart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: data.daily_stats.map(d => d.weekday),
datasets: [
{
label: '摄入',
data: data.daily_stats.map(d => d.calories_intake),
backgroundColor: 'rgba(102, 126, 234, 0.8)',
},
{
label: '消耗',
data: data.daily_stats.map(d => d.calories_burned),
backgroundColor: 'rgba(16, 185, 129, 0.8)',
}
]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'top',
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (error) {
console.error('加载周数据失败:', error);
}
}
// 加载体重数据
async function loadWeightData() {
try {
const res = await fetch('/api/weight?days=30');
const data = await res.json();
if (data.length === 0) return;
// 按日期排序
data.sort((a, b) => a.date.localeCompare(b.date));
const ctx = document.getElementById('weight-chart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => d.date.slice(5)),
datasets: [{
label: '体重 (kg)',
data: data.map(d => d.weight_kg),
borderColor: 'rgba(118, 75, 162, 1)',
backgroundColor: 'rgba(118, 75, 162, 0.1)',
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: false
}
}
}
});
} catch (error) {
console.error('加载体重数据失败:', error);
}
}
// 初始化
loadTodayData();
loadWeekData();
loadWeightData();
// 检查管理员状态并显示管理入口
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.is_admin) {
const adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.style.display = 'block';
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item active">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span class="nav-label">首页</span>
</a>
<a href="/exercise" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
<span class="nav-label">运动</span>
</a>
<a href="/meal" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/>
</svg>
<span class="nav-label">饮食</span>
</a>
<a href="/sleep" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<span class="nav-label">睡眠</span>
</a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
</svg>
<span class="nav-label">更多</span>
</div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<style>
.mobile-nav {
position: fixed; bottom: 0; left: 0; right: 0; height: 64px;
background: white; border-top: 1px solid #E2E8F0;
display: none; justify-content: space-around; align-items: center; z-index: 50;
}
.mobile-nav-item {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer;
}
.mobile-nav-item.active { color: #667eea; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu {
position: fixed; bottom: 72px; right: 16px; background: white;
border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51;
}
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 12px 24px; color: #1E293B; text-decoration: none; }
.more-menu-item:hover { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() {
document.getElementById('more-menu').classList.toggle('hidden');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) {
document.getElementById('more-menu').classList.add('hidden');
}
});
</script>
</body>
</html>
"""
def get_exercise_page_html() -> str:
"""生成运动页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 运动</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a {
color: #666;
text-decoration: none;
font-weight: 600;
}
.nav a.active {
color: #667eea;
}
header {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
padding: 24px 20px;
text-align: center;
margin-bottom: 24px;
border-radius: 12px;
}
header h1 {
font-size: 1.8rem;
margin-bottom: 6px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.card {
background: white;
border-radius: 12px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card h3 {
font-size: 0.85rem;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.card .value {
font-size: 1.8rem;
font-weight: 700;
color: #333;
}
.card .unit {
font-size: 0.9rem;
color: #666;
margin-left: 4px;
}
.chart-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.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.06); }
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background: rgba(0,0,0,0.35);
}
.modal-content {
background: white;
margin: 8% auto;
padding: 20px;
border-radius: 12px;
width: 92%;
max-width: 520px;
box-shadow: 0 12px 32px rgba(0,0,0,0.2);
}
.modal-content h2 { margin-bottom: 12px; }
.modal-content label { display: block; margin-top: 12px; color: #555; }
.modal-content input,
.modal-content select,
.modal-content textarea {
width: 100%;
padding: 10px;
margin-top: 6px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.modal-content button {
margin-top: 16px;
width: 100%;
padding: 10px 12px;
background: #667eea;
border: none;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.close {
float: right;
font-size: 22px;
cursor: pointer;
color: #999;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
color: #666;
font-weight: 600;
}
.empty {
text-align: center;
padding: 30px;
color: #999;
}
.delete-btn {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
transition: background 0.2s;
}
.delete-btn:hover {
background: #fef2f2;
}
/* 必填标记 */
.required {
color: #ef4444;
margin-left: 2px;
}
/* Focus 样式 */
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
}
.fab:focus {
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.4), 0 4px 12px rgba(0,0,0,0.3);
}
/* Close 按钮改进 */
.close {
float: right;
font-size: 22px;
cursor: pointer;
color: #999;
background: none;
border: none;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.close:hover {
background: #f3f4f6;
color: #333;
}
/* 按钮 loading 状态 */
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* 表格响应式 */
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 导航 hover */
.nav a {
transition: color 0.2s;
}
.nav a:hover {
color: #667eea;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: 80px; }
.container { padding: 12px; }
header { padding: 18px 14px; margin-bottom: 16px; }
header h1 { font-size: 1.4rem; }
.grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.card { padding: 12px; }
.card .value { font-size: 1.4rem; }
.fab { bottom: 84px; right: 16px; width: 52px; height: 52px; }
.modal-content { margin: 5% auto; width: 95%; padding: 16px; }
/* 表格转卡片 */
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { background: white; margin-bottom: 12px; padding: 12px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
td { padding: 6px 0; border: none; display: flex; justify-content: space-between; }
td:before { content: attr(data-label); font-weight: 600; color: #666; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/">首页</a>
<a class="active" href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</div>
<header>
<h1>运动概览</h1>
<p>本月运动统计与趋势</p>
</header>
<div class="grid">
<div class="card">
<h3>本月运动次数</h3>
<div class="value" id="month-count">--</div>
</div>
<div class="card">
<h3>本月总时长</h3>
<div class="value" id="month-duration">--<span class="unit">分钟</span></div>
</div>
<div class="card">
<h3>本月消耗卡路里</h3>
<div class="value" id="month-calories">--<span class="unit">卡</span></div>
</div>
<div class="card">
<h3>最常运动类型</h3>
<div class="value" id="top-type">--</div>
</div>
</div>
<div class="chart-container">
<h3>近 30 天运动时长趋势</h3>
<canvas id="duration-chart" height="120"></canvas>
</div>
<div class="grid">
<div class="chart-container">
<h3>运动类型分布</h3>
<canvas id="type-chart" height="200"></canvas>
</div>
<div class="chart-container">
<h3>卡路里消耗分布</h3>
<canvas id="calorie-chart" height="200"></canvas>
</div>
</div>
<div class="chart-container">
<h3>最近 50 条运动记录</h3>
<table id="exercise-table">
<thead>
<tr>
<th>日期</th>
<th>类型</th>
<th>时长</th>
<th>卡路里</th>
<th>距离</th>
<th>操作</th>
</tr>
</thead>
<tbody id="exercise-body">
<tr><td class="empty" colspan="6">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<button class="fab" onclick="openAddModal()" aria-label="添加运动记录">+</button>
<div id="addModal" class="modal" onclick="if(event.target===this)closeAddModal()">
<div class="modal-content" role="dialog" aria-labelledby="modal-title-exercise">
<button type="button" class="close" onclick="closeAddModal()" aria-label="关闭">&times;</button>
<h2 id="modal-title-exercise">添加运动记录</h2>
<form id="addExerciseForm">
<label for="exercise-date">日期 <span class="required">*</span></label>
<input type="date" id="exercise-date" name="date" required>
<label for="exercise-type">运动类型 <span class="required">*</span></label>
<select id="exercise-type" name="type" required>
<option>跑步</option>
<option>游泳</option>
<option>骑行</option>
<option>力量训练</option>
<option>健走</option>
<option>瑜伽</option>
<option>足球</option>
<option>其他</option>
</select>
<label for="exercise-duration">时长(分钟) <span class="required">*</span></label>
<input type="number" id="exercise-duration" name="duration" min="1" max="1440" inputmode="numeric" required>
<label for="exercise-distance">距离(公里,可选)</label>
<input type="number" id="exercise-distance" name="distance" step="0.1" min="0" inputmode="decimal">
<label for="exercise-calories">卡路里(可选,留空自动估算)</label>
<input type="number" id="exercise-calories" name="calories" min="0" inputmode="numeric">
<label for="exercise-heart-rate">平均心率(可选)</label>
<input type="number" id="exercise-heart-rate" name="heart_rate_avg" min="30" max="220" inputmode="numeric">
<label for="exercise-notes">备注</label>
<textarea id="exercise-notes" name="notes" rows="3"></textarea>
<button type="submit" id="exercise-submit-btn">保存</button>
</form>
</div>
</div>
<script>
// Modal 控制
function openAddModal() {
const modal = document.getElementById('addModal');
modal.style.display = 'block';
document.getElementById('exercise-date').value = new Date().toISOString().split('T')[0];
document.body.style.overflow = 'hidden';
}
function closeAddModal() {
document.getElementById('addModal').style.display = 'none';
document.body.style.overflow = '';
}
// ESC 键关闭 Modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAddModal();
});
async function loadExerciseStats() {
try {
const res = await fetch('/api/exercises/stats?days=30');
const data = await res.json();
document.getElementById('month-count').textContent = data.month.count || 0;
document.getElementById('month-duration').innerHTML =
`${data.month.duration || 0}<span class="unit">分钟</span>`;
document.getElementById('month-calories').innerHTML =
`${data.month.calories || 0}<span class="unit">卡</span>`;
document.getElementById('top-type').textContent = data.month.top_type || '暂无';
const labels = data.daily_stats.map(d => d.date.slice(5));
const durations = data.daily_stats.map(d => d.duration);
const durationCtx = document.getElementById('duration-chart').getContext('2d');
new Chart(durationCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '运动时长(分钟)',
data: durations,
borderColor: 'rgba(17, 153, 142, 1)',
backgroundColor: 'rgba(17, 153, 142, 0.15)',
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true }
}
}
});
const typeLabels = Object.keys(data.period.type_counts || {});
const typeValues = Object.values(data.period.type_counts || {});
const typeCtx = document.getElementById('type-chart').getContext('2d');
new Chart(typeCtx, {
type: 'bar',
data: {
labels: typeLabels.length ? typeLabels : ['暂无'],
datasets: [{
label: '次数',
data: typeValues.length ? typeValues : [0],
backgroundColor: 'rgba(102, 126, 234, 0.8)',
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true }
}
}
});
const calorieLabels = Object.keys(data.period.type_calories || {});
const calorieValues = Object.values(data.period.type_calories || {});
const calorieCtx = document.getElementById('calorie-chart').getContext('2d');
new Chart(calorieCtx, {
type: 'doughnut',
data: {
labels: calorieLabels.length ? calorieLabels : ['暂无'],
datasets: [{
data: calorieValues.length ? calorieValues : [0],
backgroundColor: [
'rgba(17, 153, 142, 0.8)',
'rgba(102, 126, 234, 0.8)',
'rgba(118, 75, 162, 0.8)',
'rgba(16, 185, 129, 0.8)',
'rgba(239, 68, 68, 0.8)'
]
}]
},
options: {
responsive: true
}
});
} catch (error) {
console.error('加载统计失败:', error);
}
}
async function loadExerciseList() {
try {
const res = await fetch('/api/exercises?days=30');
const data = await res.json();
const body = document.getElementById('exercise-body');
if (!data.length) {
body.innerHTML = '<tr><td class="empty" colspan="6">暂无记录</td></tr>';
return;
}
const rows = data.slice(0, 50).map(item => {
const distance = item.distance ? `${item.distance.toFixed(2)} km` : '-';
return `
<tr data-id="${item.id}">
<td>${item.date}</td>
<td>${item.type}</td>
<td>${item.duration} 分钟</td>
<td>${item.calories} 卡</td>
<td>${distance}</td>
<td><button class="delete-btn" onclick="deleteExercise(${item.id})">删除</button></td>
</tr>
`;
}).join('');
body.innerHTML = rows;
} catch (error) {
const body = document.getElementById('exercise-body');
body.innerHTML = '<tr><td class="empty" colspan="6">加载失败</td></tr>';
}
}
async function deleteExercise(id) {
if (!confirm('确定要删除这条运动记录吗?')) return;
try {
const res = await fetch(`/api/exercise/${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
location.reload();
} else {
alert('删除失败:' + (result.error || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
async function submitExerciseForm(event) {
event.preventDefault();
const btn = document.getElementById('exercise-submit-btn');
const originalText = btn.textContent;
// Loading 状态
btn.disabled = true;
btn.textContent = '保存中...';
const formData = new FormData(event.target);
const payload = Object.fromEntries(formData.entries());
if (payload.distance === '') delete payload.distance;
if (payload.calories === '') delete payload.calories;
if (payload.heart_rate_avg === '') delete payload.heart_rate_avg;
try {
const res = await fetch('/api/exercise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await res.json();
if (result.success) {
closeAddModal();
location.reload();
} else {
alert('添加失败');
}
} catch (error) {
alert('添加失败:' + error.message);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
document.getElementById('addExerciseForm').addEventListener('submit', submitExerciseForm);
loadExerciseStats();
loadExerciseList();
// 检查管理员状态
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.is_admin) {
const adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.style.display = 'block';
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item active"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<style>
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: 64px; background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: center; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; }
.mobile-nav-item.active { color: #11998e; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: 72px; right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 12px 24px; color: #1E293B; text-decoration: none; }
.more-menu-item:hover { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_meal_page_html() -> str:
"""生成饮食页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 饮食</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a {
color: #666;
text-decoration: none;
font-weight: 600;
}
.nav a.active {
color: #667eea;
}
header {
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
color: #3a2f05;
padding: 24px 20px;
text-align: center;
margin-bottom: 24px;
border-radius: 12px;
}
header h1 {
font-size: 1.8rem;
margin-bottom: 6px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.card {
background: white;
border-radius: 12px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card h3 {
font-size: 0.85rem;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.card .value {
font-size: 1.8rem;
font-weight: 700;
color: #333;
}
.card .unit {
font-size: 0.9rem;
color: #666;
margin-left: 4px;
}
.chart-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-top: 12px;
}
.calendar .day {
background: #f8f9fa;
border-radius: 8px;
padding: 10px;
min-height: 70px;
font-size: 0.85rem;
}
.calendar .day .date {
font-weight: 700;
color: #555;
}
.calendar .day .cal {
margin-top: 6px;
font-weight: 600;
}
.calendar .day.low { background: #ecfdf3; }
.calendar .day.high { background: #fef2f2; }
.calendar .empty { background: transparent; }
.weekday {
text-align: center;
font-weight: 600;
color: #666;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
color: #666;
font-weight: 600;
}
.empty {
text-align: center;
padding: 30px;
color: #999;
}
.delete-btn {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
transition: background 0.2s;
}
.delete-btn:hover {
background: #fef2f2;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.photo-grid img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 10px;
}
.fab {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #f7971e, #ffd200);
color: #3a2f05;
font-size: 24px;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
cursor: pointer;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background: rgba(0,0,0,0.35);
}
.modal-content {
background: white;
margin: 8% auto;
padding: 20px;
border-radius: 12px;
width: 92%;
max-width: 560px;
box-shadow: 0 12px 32px rgba(0,0,0,0.2);
}
.modal-content h2 { margin-bottom: 12px; }
.modal-content label { display: block; margin-top: 12px; color: #555; }
.modal-content input,
.modal-content select,
.modal-content textarea {
width: 100%;
padding: 10px;
margin-top: 6px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.modal-content button {
margin-top: 16px;
width: 100%;
padding: 10px 12px;
background: #f59e0b;
border: none;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.close {
float: right;
font-size: 22px;
cursor: pointer;
color: #999;
background: none;
border: none;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.close:hover {
background: #f3f4f6;
color: #333;
}
/* 必填标记 */
.required { color: #ef4444; margin-left: 2px; }
/* Focus 样式 */
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
}
button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.3);
}
button:disabled { opacity: 0.7; cursor: not-allowed; }
.nav a { transition: color 0.2s; }
.nav a:hover { color: #f59e0b; }
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: 80px; }
.container { padding: 12px; }
header { padding: 18px 14px; margin-bottom: 16px; }
header h1 { font-size: 1.4rem; }
.grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.card { padding: 12px; }
.card .value { font-size: 1.4rem; }
.fab { bottom: 84px; right: 16px; width: 52px; height: 52px; }
.modal-content { margin: 5% auto; width: 95%; padding: 16px; }
.calendar { grid-template-columns: repeat(7, 1fr); gap: 4px; }
.calendar .day { min-height: 50px; padding: 6px; font-size: 0.75rem; }
.photo-grid { grid-template-columns: repeat(2, 1fr); }
.photo-grid img { height: 100px; }
/* 表格转卡片 */
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { background: white; margin-bottom: 12px; padding: 12px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
td { padding: 6px 0; border: none; display: flex; justify-content: space-between; }
td:before { content: attr(data-label); font-weight: 600; color: #666; }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a class="active" href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</div>
<header>
<h1>饮食概览</h1>
<p>卡路里与营养摄入趋势</p>
</header>
<div class="grid">
<div class="card">
<h3>近 30 天摄入</h3>
<div class="value" id="total-calories">--<span class="unit">卡</span></div>
</div>
<div class="card">
<h3>平均每日摄入</h3>
<div class="value" id="avg-calories">--<span class="unit">卡</span></div>
</div>
<div class="card">
<h3>蛋白质总量</h3>
<div class="value" id="total-protein">--<span class="unit">g</span></div>
</div>
<div class="card">
<h3>碳水/脂肪总量</h3>
<div class="value" id="total-carb-fat">--</div>
</div>
</div>
<div class="chart-container">
<h3>本月饮食日历</h3>
<div class="calendar" id="calendar">
<div class="empty">加载中...</div>
</div>
</div>
<div class="grid">
<div class="chart-container">
<h3>营养成分分布</h3>
<canvas id="macro-chart" height="200"></canvas>
</div>
<div class="chart-container">
<h3>餐次分布(卡路里)</h3>
<canvas id="mealtype-chart" height="200"></canvas>
</div>
</div>
<div class="chart-container">
<h3>近 7 天摄入趋势</h3>
<canvas id="intake-chart" height="120"></canvas>
</div>
<div class="chart-container">
<h3>食物照片墙</h3>
<div class="photo-grid" id="photo-grid">
<div class="empty">暂无照片</div>
</div>
</div>
<div class="chart-container">
<h3>最近 50 条饮食记录</h3>
<table>
<thead>
<tr>
<th>日期</th>
<th>餐次</th>
<th>食物</th>
<th>卡路里</th>
<th>蛋白质</th>
<th>操作</th>
</tr>
</thead>
<tbody id="meal-body">
<tr><td class="empty" colspan="6">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<button class="fab" onclick="openMealModal()" aria-label="添加饮食记录">+</button>
<div id="mealModal" class="modal" onclick="if(event.target===this)closeMealModal()">
<div class="modal-content" role="dialog" aria-labelledby="modal-title-meal">
<button type="button" class="close" onclick="closeMealModal()" aria-label="关闭">&times;</button>
<h2 id="modal-title-meal">添加饮食记录</h2>
<form id="addMealForm" enctype="multipart/form-data">
<label for="meal-date">日期 <span class="required">*</span></label>
<input type="date" id="meal-date" name="date_str" required>
<label for="meal-type">餐次 <span class="required">*</span></label>
<select id="meal-type" name="meal_type" required>
<option>早餐</option>
<option>午餐</option>
<option>晚餐</option>
<option>加餐</option>
</select>
<label for="meal-description">食物描述</label>
<textarea id="meal-description" name="description" rows="3"></textarea>
<label for="meal-calories">卡路里(可选,留空自动估算)</label>
<input type="number" id="meal-calories" name="calories" min="0" inputmode="numeric">
<label>蛋白质/碳水/脂肪(可选)</label>
<div class="grid">
<input type="number" id="meal-protein" name="protein" step="0.1" placeholder="蛋白质 g" inputmode="decimal" aria-label="蛋白质(克)">
<input type="number" id="meal-carbs" name="carbs" step="0.1" placeholder="碳水 g" inputmode="decimal" aria-label="碳水(克)">
<input type="number" id="meal-fat" name="fat" step="0.1" placeholder="脂肪 g" inputmode="decimal" aria-label="脂肪(克)">
</div>
<label for="photoInput">食物照片(可选,<5MB</label>
<input type="file" id="photoInput" name="photo" accept="image/*" onchange="onPhotoSelected()">
<button type="button" id="recognizeBtn" onclick="recognizeFood()" style="margin-top:8px; background:#667eea; display:none;">AI识别食物</button>
<div id="recognizeStatus" role="status" style="margin-top:8px; padding:8px; border-radius:6px; display:none;"></div>
<button type="submit" id="meal-submit-btn">保存</button>
</form>
</div>
</div>
<script>
function openMealModal() {
document.getElementById('mealModal').style.display = 'block';
document.body.style.overflow = 'hidden';
// 默认填今天
const dateEl = document.getElementById('meal-date');
if (dateEl && !dateEl.value) {
dateEl.value = new Date().toISOString().split('T')[0];
}
}
function closeMealModal() {
document.getElementById('mealModal').style.display = 'none';
document.body.style.overflow = '';
// 重置识别状态
document.getElementById('recognizeBtn').style.display = 'none';
document.getElementById('recognizeStatus').style.display = 'none';
}
// ESC 键关闭 Modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMealModal();
});
function onPhotoSelected() {
const photoInput = document.getElementById('photoInput');
const recognizeBtn = document.getElementById('recognizeBtn');
if (photoInput.files && photoInput.files.length > 0) {
recognizeBtn.style.display = 'block';
} else {
recognizeBtn.style.display = 'none';
}
}
async function recognizeFood() {
const photoInput = document.getElementById('photoInput');
const statusDiv = document.getElementById('recognizeStatus');
const recognizeBtn = document.getElementById('recognizeBtn');
if (!photoInput.files || photoInput.files.length === 0) {
alert('请先选择照片');
return;
}
const file = photoInput.files[0];
if (file.size > 10 * 1024 * 1024) {
alert('图片不能超过 10MB');
return;
}
// 显示识别中状态
statusDiv.style.display = 'block';
statusDiv.style.background = '#e3f2fd';
statusDiv.style.color = '#1976d2';
statusDiv.innerHTML = '🤖 正在识别食物,请稍候...';
recognizeBtn.disabled = true;
try {
const formData = new FormData();
formData.append('image', file);
formData.append('provider', 'qwen');
const res = await fetch('/api/meal/recognize', {
method: 'POST',
body: formData,
});
const result = await res.json();
if (res.ok && result.success) {
// 显示成功状态
statusDiv.style.background = '#e8f5e9';
statusDiv.style.color = '#2e7d32';
statusDiv.innerHTML = `✅ 识别成功!<br>食物:${result.description}<br>卡路里:${result.total_calories} 卡`;
// 自动填充表单
const form = document.getElementById('addMealForm');
form.querySelector('[name="description"]').value = result.description || '';
form.querySelector('[name="calories"]').value = result.total_calories || '';
if (result.total_protein) {
form.querySelector('[name="protein"]').value = result.total_protein.toFixed(1);
}
if (result.total_carbs) {
form.querySelector('[name="carbs"]').value = result.total_carbs.toFixed(1);
}
if (result.total_fat) {
form.querySelector('[name="fat"]').value = result.total_fat.toFixed(1);
}
} else {
// 显示错误状态
statusDiv.style.background = '#ffebee';
statusDiv.style.color = '#c62828';
statusDiv.innerHTML = `❌ 识别失败:${result.detail || '未知错误'}`;
}
} catch (error) {
// 显示错误状态
statusDiv.style.background = '#ffebee';
statusDiv.style.color = '#c62828';
statusDiv.innerHTML = `❌ 识别失败:${error.message}`;
} finally {
recognizeBtn.disabled = false;
}
}
function formatPhotoUrl(path) {
if (!path) return null;
const marker = '/.vitals/photos/';
const idx = path.indexOf(marker);
if (idx >= 0) {
return '/photos/' + path.substring(idx + marker.length);
}
return path;
}
function renderCalendar(mealMap, tdee) {
const calendar = document.getElementById('calendar');
calendar.innerHTML = '';
const weekLabels = ['','','','','','',''];
weekLabels.forEach(label => {
const el = document.createElement('div');
el.className = 'weekday';
el.textContent = label;
calendar.appendChild(el);
});
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startWeekday = (first.getDay() + 6) % 7; // 周一=0
for (let i = 0; i < startWeekday; i++) {
const empty = document.createElement('div');
empty.className = 'day empty';
calendar.appendChild(empty);
}
for (let d = 1; d <= last.getDate(); d++) {
const dateKey = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const calories = mealMap[dateKey] || 0;
const day = document.createElement('div');
day.className = 'day';
if (tdee) {
if (calories > tdee) day.classList.add('high');
else if (calories > 0) day.classList.add('low');
}
day.innerHTML = `<div class="date">${d}</div><div class="cal">${calories ? calories + '' : ''}</div>`;
calendar.appendChild(day);
}
}
async function loadMealStats() {
try {
const [nutritionRes, configRes, mealsRes] = await Promise.all([
fetch('/api/meals/nutrition?days=30'),
fetch('/api/config'),
fetch('/api/meals?days=60'),
]);
const nutrition = await nutritionRes.json();
const config = await configRes.json();
const meals = await mealsRes.json();
document.getElementById('total-calories').innerHTML =
`${nutrition.total_calories}<span class="unit">卡</span>`;
const avg = Math.round(nutrition.total_calories / Math.max(1, nutrition.days));
document.getElementById('avg-calories').innerHTML =
`${avg}<span class="unit">卡</span>`;
document.getElementById('total-protein').innerHTML =
`${nutrition.macros.protein}<span class="unit">g</span>`;
document.getElementById('total-carb-fat').innerHTML =
`${nutrition.macros.carbs}g / ${nutrition.macros.fat}g`;
const macroCtx = document.getElementById('macro-chart').getContext('2d');
new Chart(macroCtx, {
type: 'doughnut',
data: {
labels: ['蛋白质', '碳水', '脂肪'],
datasets: [{
data: [nutrition.macros.protein, nutrition.macros.carbs, nutrition.macros.fat],
backgroundColor: ['#10b981', '#f59e0b', '#ef4444'],
}]
},
options: { responsive: true }
});
const typeLabels = Object.keys(nutrition.meal_types.calories || {});
const typeValues = Object.values(nutrition.meal_types.calories || {});
const mealtypeCtx = document.getElementById('mealtype-chart').getContext('2d');
new Chart(mealtypeCtx, {
type: 'bar',
data: {
labels: typeLabels.length ? typeLabels : ['暂无'],
datasets: [{
data: typeValues.length ? typeValues : [0],
backgroundColor: 'rgba(247, 151, 30, 0.8)',
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}
});
const intakeLabels = nutrition.daily_stats.map(d => d.date.slice(5));
const intakeValues = nutrition.daily_stats.map(d => d.calories);
const intakeCtx = document.getElementById('intake-chart').getContext('2d');
new Chart(intakeCtx, {
type: 'line',
data: {
labels: intakeLabels,
datasets: [{
label: '摄入卡路里',
data: intakeValues,
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.2)',
fill: true,
tension: 0.3,
}]
},
options: { responsive: true, scales: { y: { beginAtZero: true } } }
});
const mealMap = {};
meals.forEach(m => {
mealMap[m.date] = (mealMap[m.date] || 0) + m.calories;
});
renderCalendar(mealMap, config.tdee || 0);
const photoGrid = document.getElementById('photo-grid');
const photoMeals = meals.filter(m => m.photo_path);
if (photoMeals.length) {
photoGrid.innerHTML = photoMeals.slice(0, 12).map(m => {
const url = formatPhotoUrl(m.photo_path);
if (!url) return '';
return `<img src="${url}" alt="${m.description || 'meal'}">`;
}).join('');
} else {
photoGrid.innerHTML = '<div class="empty">暂无照片</div>';
}
const body = document.getElementById('meal-body');
if (!meals.length) {
body.innerHTML = '<tr><td class="empty" colspan="6">暂无记录</td></tr>';
} else {
const rows = meals.slice(0, 50).map(m => {
const protein = m.protein ? `${m.protein} g` : '-';
return `
<tr data-id="${m.id}">
<td>${m.date}</td>
<td>${m.meal_type}</td>
<td>${m.description || '-'}</td>
<td>${m.calories} 卡</td>
<td>${protein}</td>
<td><button class="delete-btn" onclick="deleteMeal(${m.id})">删除</button></td>
</tr>
`;
}).join('');
body.innerHTML = rows;
}
} catch (error) {
console.error('加载饮食数据失败:', error);
const calendar = document.getElementById('calendar');
if (calendar) {
calendar.innerHTML = '<div class="empty">加载失败</div>';
}
const body = document.getElementById('meal-body');
if (body) {
body.innerHTML = '<tr><td class="empty" colspan="6">加载失败</td></tr>';
}
}
}
async function deleteMeal(id) {
if (!confirm('确定要删除这条饮食记录吗?')) return;
try {
const res = await fetch(`/api/meal/${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
location.reload();
} else {
alert('删除失败:' + (result.error || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
async function submitMealForm(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// 兼容性兜底:显式 set 必填字段,避免出现 422 missing
const dateStr = (form.querySelector('[name="date_str"]')?.value || '').trim();
const mealType = (form.querySelector('[name="meal_type"]')?.value || '').trim();
if (!dateStr || !mealType) {
alert('请填写日期与餐次');
return;
}
formData.set('date_str', dateStr);
formData.set('meal_type', mealType);
// 删除空数值字段,避免后端解析 "" 为 int/float 失败
['calories', 'protein', 'carbs', 'fat'].forEach((key) => {
const v = formData.get(key);
if (v === '') {
formData.delete(key);
}
});
const photo = formData.get('photo');
if (photo && photo.size > 5 * 1024 * 1024) {
alert('照片不能超过 5MB');
return;
}
try {
const res = await fetch('/api/meal', {
method: 'POST',
body: formData,
});
const result = await res.json().catch(() => ({}));
if (res.ok && result.success) {
closeMealModal();
location.reload();
} else {
const detail = result.detail ? JSON.stringify(result.detail) : (result.error || '未知错误');
alert('添加失败:' + detail);
}
} catch (error) {
alert('添加失败:' + error.message);
}
}
document.getElementById('addMealForm').addEventListener('submit', submitMealForm);
loadMealStats();
// 检查管理员状态
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.is_admin) {
const adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.style.display = 'block';
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item active"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<style>
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: 64px; background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: center; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; }
.mobile-nav-item.active { color: #f59e0b; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: 72px; right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 12px 24px; color: #1E293B; text-decoration: none; }
.more-menu-item:hover { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_sleep_page_html() -> str:
"""生成睡眠页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 睡眠</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a {
color: #666;
text-decoration: none;
font-weight: 600;
}
.nav a.active { color: #667eea; }
header {
background: linear-gradient(135deg, #5ee7df 0%, #b490ca 100%);
color: #2b1f33;
padding: 24px 20px;
text-align: center;
margin-bottom: 24px;
border-radius: 12px;
}
header h1 { font-size: 1.8rem; margin-bottom: 6px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.card {
background: white;
border-radius: 12px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card h3 {
font-size: 0.85rem;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.card .value {
font-size: 1.8rem;
font-weight: 700;
color: #333;
}
.card .unit { font-size: 0.9rem; color: #666; margin-left: 4px; }
.chart-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.fab {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #5ee7df, #b490ca);
color: #2b1f33;
font-size: 24px;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
cursor: pointer;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background: rgba(0,0,0,0.35);
}
.modal-content {
background: white;
margin: 8% auto;
padding: 20px;
border-radius: 12px;
width: 92%;
max-width: 520px;
box-shadow: 0 12px 32px rgba(0,0,0,0.2);
}
.modal-content h2 { margin-bottom: 12px; }
.modal-content label { display: block; margin-top: 12px; color: #555; }
.modal-content input,
.modal-content select,
.modal-content textarea {
width: 100%;
padding: 10px;
margin-top: 6px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.modal-content button {
margin-top: 16px;
width: 100%;
padding: 10px 12px;
background: #7c3aed;
border: none;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.close {
float: right;
font-size: 22px;
cursor: pointer;
color: #999;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
color: #666;
font-weight: 600;
}
.empty {
text-align: center;
padding: 30px;
color: #999;
}
.delete-btn {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
transition: background 0.2s;
}
.delete-btn:hover {
background: #fef2f2;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: 80px; }
.container { padding: 12px; }
header { padding: 18px 14px; margin-bottom: 16px; }
header h1 { font-size: 1.4rem; }
.grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.card { padding: 12px; }
.card .value { font-size: 1.4rem; }
.fab { bottom: 84px; right: 16px; width: 52px; height: 52px; }
.modal-content { margin: 5% auto; width: 95%; padding: 16px; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { background: white; margin-bottom: 12px; padding: 12px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
td { padding: 6px 0; border: none; display: flex; justify-content: space-between; }
td:before { content: attr(data-label); font-weight: 600; color: #666; }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a class="active" href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</div>
<header>
<h1>睡眠概览</h1>
<p>睡眠时长与质量趋势</p>
</header>
<div class="grid">
<div class="card">
<h3>近 30 天平均时长</h3>
<div class="value" id="avg-duration">--<span class="unit">小时</span></div>
</div>
<div class="card">
<h3>近 30 天平均质量</h3>
<div class="value" id="avg-quality">--</div>
</div>
<div class="card">
<h3>最短/最长睡眠</h3>
<div class="value" id="range-duration">--</div>
</div>
<div class="card">
<h3>有记录天数</h3>
<div class="value" id="sleep-days">--</div>
</div>
</div>
<div class="chart-container">
<h3>近 30 天睡眠时长趋势</h3>
<canvas id="duration-chart" height="120"></canvas>
</div>
<div class="grid">
<div class="chart-container">
<h3>睡眠质量趋势</h3>
<canvas id="quality-chart" height="200"></canvas>
</div>
<div class="chart-container">
<h3>入睡/起床时间(小时)</h3>
<canvas id="bedtime-chart" height="200"></canvas>
</div>
</div>
<div class="chart-container">
<h3>最近 30 条睡眠记录</h3>
<table>
<thead>
<tr>
<th>日期</th>
<th>时长</th>
<th>质量</th>
<th>入睡</th>
<th>起床</th>
<th>操作</th>
</tr>
</thead>
<tbody id="sleep-body">
<tr><td class="empty" colspan="6">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<button class="fab" onclick="openSleepModal()">+</button>
<div id="sleepModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeSleepModal()">&times;</span>
<h2>添加睡眠记录</h2>
<form id="addSleepForm">
<label>日期</label>
<input type="date" name="date" required>
<label>入睡时间(可选)</label>
<input type="time" name="bedtime">
<label>起床时间(可选)</label>
<input type="time" name="wake_time">
<label>睡眠时长(小时)</label>
<input type="number" name="duration" step="0.1" min="0" max="24" required>
<label>睡眠质量1-5</label>
<select name="quality" required>
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<label>备注</label>
<textarea name="notes" rows="3"></textarea>
<button type="submit">保存</button>
</form>
</div>
</div>
<script>
function openSleepModal() {
document.getElementById('sleepModal').style.display = 'block';
}
function closeSleepModal() {
document.getElementById('sleepModal').style.display = 'none';
}
function timeToHour(timeStr) {
if (!timeStr) return null;
const parts = timeStr.split(':');
if (parts.length < 2) return null;
const hour = parseInt(parts[0], 10);
const min = parseInt(parts[1], 10);
return hour + min / 60;
}
async function loadSleepData() {
try {
const res = await fetch('/api/sleep?days=30');
const records = await res.json();
if (!records.length) {
document.getElementById('sleep-body').innerHTML =
'<tr><td class="empty" colspan="6">暂无记录</td></tr>';
return;
}
const durations = records.map(r => r.duration);
const qualities = records.map(r => r.quality);
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
const avgQuality = qualities.reduce((a, b) => a + b, 0) / qualities.length;
const minDuration = Math.min(...durations);
const maxDuration = Math.max(...durations);
document.getElementById('avg-duration').innerHTML =
`${avgDuration.toFixed(1)}<span class="unit">小时</span>`;
document.getElementById('avg-quality').textContent = `${avgQuality.toFixed(1)}/5`;
document.getElementById('range-duration').textContent =
`${minDuration.toFixed(1)} - ${maxDuration.toFixed(1)} h`;
document.getElementById('sleep-days').textContent = records.length;
const sorted = records.slice().reverse();
const labels = sorted.map(r => r.date.slice(5));
const durationData = sorted.map(r => r.duration);
const qualityData = sorted.map(r => r.quality);
const durationCtx = document.getElementById('duration-chart').getContext('2d');
new Chart(durationCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '睡眠时长(小时)',
data: durationData,
borderColor: '#6d28d9',
backgroundColor: 'rgba(109, 40, 217, 0.2)',
fill: true,
tension: 0.3,
}]
},
options: { responsive: true, scales: { y: { beginAtZero: true } } }
});
const qualityCtx = document.getElementById('quality-chart').getContext('2d');
new Chart(qualityCtx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '质量评分',
data: qualityData,
backgroundColor: 'rgba(124, 58, 237, 0.8)',
}]
},
options: { responsive: true, scales: { y: { beginAtZero: true, max: 5 } } }
});
const bedtimeCtx = document.getElementById('bedtime-chart').getContext('2d');
const bedtimeData = sorted.map(r => timeToHour(r.bedtime));
const wakeData = sorted.map(r => timeToHour(r.wake_time));
new Chart(bedtimeCtx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '入睡时间',
data: bedtimeData,
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14, 165, 233, 0.2)',
fill: false,
tension: 0.3,
},
{
label: '起床时间',
data: wakeData,
borderColor: '#22c55e',
backgroundColor: 'rgba(34, 197, 94, 0.2)',
fill: false,
tension: 0.3,
}
]
},
options: { responsive: true, scales: { y: { beginAtZero: true, max: 24 } } }
});
const body = document.getElementById('sleep-body');
const rows = records.slice(0, 30).map(r => {
return `
<tr data-id="${r.id}">
<td>${r.date}</td>
<td>${r.duration} 小时</td>
<td>${r.quality}/5</td>
<td>${r.bedtime || '-'}</td>
<td>${r.wake_time || '-'}</td>
<td><button class="delete-btn" onclick="deleteSleep(${r.id})">删除</button></td>
</tr>
`;
}).join('');
body.innerHTML = rows;
} catch (error) {
const body = document.getElementById('sleep-body');
body.innerHTML = '<tr><td class="empty" colspan="6">加载失败</td></tr>';
}
}
async function deleteSleep(id) {
if (!confirm('确定要删除这条睡眠记录吗?')) return;
try {
const res = await fetch(`/api/sleep/${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
location.reload();
} else {
alert('删除失败:' + (result.error || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
async function submitSleepForm(event) {
event.preventDefault();
const formData = new FormData(event.target);
const payload = Object.fromEntries(formData.entries());
if (payload.bedtime === '') delete payload.bedtime;
if (payload.wake_time === '') delete payload.wake_time;
if (payload.notes === '') delete payload.notes;
try {
const res = await fetch('/api/sleep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await res.json();
if (result.success) {
closeSleepModal();
location.reload();
} else {
alert('添加失败');
}
} catch (error) {
alert('添加失败:' + error.message);
}
}
document.getElementById('addSleepForm').addEventListener('submit', submitSleepForm);
loadSleepData();
// 检查管理员状态
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.is_admin) {
const adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.style.display = 'block';
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item active"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<style>
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: 64px; background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: center; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; }
.mobile-nav-item.active { color: #7c3aed; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: 72px; right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 12px 24px; color: #1E293B; text-decoration: none; }
.more-menu-item:hover { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_weight_page_html() -> str:
"""生成体重页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 体重</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a { color: #666; text-decoration: none; font-weight: 600; }
.nav a.active { color: #667eea; }
header {
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
color: #1a3b4b;
padding: 24px 20px;
text-align: center;
margin-bottom: 24px;
border-radius: 12px;
}
header h1 { font-size: 1.8rem; margin-bottom: 6px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.card {
background: white;
border-radius: 12px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card h3 {
font-size: 0.85rem;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.card .value {
font-size: 1.8rem;
font-weight: 700;
color: #333;
}
.card .unit { font-size: 0.9rem; color: #666; margin-left: 4px; }
.chart-container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.fab {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #84fab0, #8fd3f4);
color: #1a3b4b;
font-size: 24px;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
cursor: pointer;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background: rgba(0,0,0,0.35);
}
.modal-content {
background: white;
margin: 8% auto;
padding: 20px;
border-radius: 12px;
width: 92%;
max-width: 520px;
box-shadow: 0 12px 32px rgba(0,0,0,0.2);
}
.modal-content h2 { margin-bottom: 12px; }
.modal-content label { display: block; margin-top: 12px; color: #555; }
.modal-content input,
.modal-content textarea {
width: 100%;
padding: 10px;
margin-top: 6px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.modal-content button {
margin-top: 16px;
width: 100%;
padding: 10px 12px;
background: #0ea5e9;
border: none;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.close {
float: right;
font-size: 22px;
cursor: pointer;
color: #999;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
color: #666;
font-weight: 600;
}
.empty {
text-align: center;
padding: 30px;
color: #999;
}
.delete-btn {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
transition: background 0.2s;
}
.delete-btn:hover {
background: #fef2f2;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: 80px; }
.container { padding: 12px; }
header { padding: 18px 14px; margin-bottom: 16px; }
header h1 { font-size: 1.4rem; }
.grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.card { padding: 12px; }
.card .value { font-size: 1.4rem; }
.fab { bottom: 84px; right: 16px; width: 52px; height: 52px; }
.modal-content { margin: 5% auto; width: 95%; padding: 16px; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { background: white; margin-bottom: 12px; padding: 12px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
td { padding: 6px 0; border: none; display: flex; justify-content: space-between; }
td:before { content: attr(data-label); font-weight: 600; color: #666; }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a class="active" href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</div>
<header>
<h1>体重趋势</h1>
<p>体重与体脂变化</p>
</header>
<div class="grid">
<div class="card">
<h3>当前体重</h3>
<div class="value" id="current-weight">--<span class="unit">kg</span></div>
</div>
<div class="card">
<h3>体重变化</h3>
<div class="value" id="weight-change">--</div>
</div>
<div class="card">
<h3>BMI</h3>
<div class="value" id="bmi-value">--</div>
</div>
<div class="card">
<h3>目标体重</h3>
<div class="value" id="goal-weight">--</div>
</div>
</div>
<div class="chart-container">
<h3>体重曲线(近 90 天)</h3>
<canvas id="weight-chart" height="120"></canvas>
</div>
<div class="chart-container">
<h3>体脂率曲线(近 90 天)</h3>
<canvas id="fat-chart" height="120"></canvas>
</div>
<div class="chart-container">
<h3>最近 30 条体重记录</h3>
<table>
<thead>
<tr>
<th>日期</th>
<th>体重</th>
<th>体脂率</th>
<th>肌肉量</th>
<th>操作</th>
</tr>
</thead>
<tbody id="weight-body">
<tr><td class="empty" colspan="5">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<button class="fab" onclick="openWeightModal()">+</button>
<div id="weightModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeWeightModal()">&times;</span>
<h2>添加体重记录</h2>
<form id="addWeightForm">
<label>日期</label>
<input type="date" name="date" required>
<label>体重kg</label>
<input type="number" name="weight_kg" step="0.1" min="20" max="300" required>
<label>体脂率(可选)</label>
<input type="number" name="body_fat_pct" step="0.1" min="0" max="60">
<label>肌肉量(可选)</label>
<input type="number" name="muscle_mass" step="0.1" min="0" max="100">
<label>备注</label>
<textarea name="notes" rows="3"></textarea>
<button type="submit">保存</button>
</form>
</div>
</div>
<script>
function openWeightModal() {
document.getElementById('weightModal').style.display = 'block';
}
function closeWeightModal() {
document.getElementById('weightModal').style.display = 'none';
}
async function loadWeightData() {
try {
const [weightRes, configRes, goalRes] = await Promise.all([
fetch('/api/weight?days=90'),
fetch('/api/config'),
fetch('/api/weight/goal'),
]);
const weights = await weightRes.json();
const config = await configRes.json();
const goal = await goalRes.json();
const body = document.getElementById('weight-body');
if (!weights.length) {
body.innerHTML = '<tr><td class="empty" colspan="5">暂无记录</td></tr>';
return;
}
const sorted = weights.slice().sort((a, b) => a.date.localeCompare(b.date));
const labels = sorted.map(r => r.date.slice(5));
const weightValues = sorted.map(r => r.weight_kg);
const fatValues = sorted.map(r => r.body_fat_pct || null);
const current = sorted[sorted.length - 1];
const start = sorted[0];
const change = current.weight_kg - start.weight_kg;
document.getElementById('current-weight').innerHTML =
`${current.weight_kg.toFixed(1)}<span class="unit">kg</span>`;
document.getElementById('weight-change').textContent =
`${change >= 0 ? '+' : ''}${change.toFixed(1)} kg`;
if (config.height) {
const heightM = config.height / 100;
const bmi = current.weight_kg / (heightM * heightM);
document.getElementById('bmi-value').textContent = bmi.toFixed(1);
}
if (goal.goal_weight) {
document.getElementById('goal-weight').textContent = `${goal.goal_weight.toFixed(1)} kg`;
} else {
document.getElementById('goal-weight').textContent = '未设置';
}
const weightCtx = document.getElementById('weight-chart').getContext('2d');
new Chart(weightCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '体重 (kg)',
data: weightValues,
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14, 165, 233, 0.2)',
fill: true,
tension: 0.3,
}]
},
options: { responsive: true, scales: { y: { beginAtZero: false } } }
});
const fatCtx = document.getElementById('fat-chart').getContext('2d');
new Chart(fatCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '体脂率 (%)',
data: fatValues,
borderColor: '#f97316',
backgroundColor: 'rgba(249, 115, 22, 0.2)',
fill: true,
tension: 0.3,
}]
},
options: { responsive: true, scales: { y: { beginAtZero: false } } }
});
// Populate table (show newest first)
const rows = weights.slice(0, 30).map(r => {
const fat = r.body_fat_pct ? `${r.body_fat_pct}%` : '-';
const muscle = r.muscle_mass ? `${r.muscle_mass} kg` : '-';
return `
<tr data-id="${r.id}">
<td>${r.date}</td>
<td>${r.weight_kg} kg</td>
<td>${fat}</td>
<td>${muscle}</td>
<td><button class="delete-btn" onclick="deleteWeight(${r.id})">删除</button></td>
</tr>
`;
}).join('');
body.innerHTML = rows;
} catch (error) {
console.error('加载体重数据失败:', error);
const body = document.getElementById('weight-body');
body.innerHTML = '<tr><td class="empty" colspan="5">加载失败</td></tr>';
}
}
async function deleteWeight(id) {
if (!confirm('确定要删除这条体重记录吗?')) return;
try {
const res = await fetch(`/api/weight/${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
location.reload();
} else {
alert('删除失败:' + (result.error || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
async function submitWeightForm(event) {
event.preventDefault();
const formData = new FormData(event.target);
const payload = Object.fromEntries(formData.entries());
if (payload.body_fat_pct === '') delete payload.body_fat_pct;
if (payload.muscle_mass === '') delete payload.muscle_mass;
if (payload.notes === '') delete payload.notes;
try {
const res = await fetch('/api/weight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await res.json();
if (result.success) {
closeWeightModal();
location.reload();
} else {
alert('添加失败');
}
} catch (error) {
alert('添加失败:' + error.message);
}
}
document.getElementById('addWeightForm').addEventListener('submit', submitWeightForm);
loadWeightData();
// 检查管理员状态
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.is_admin) {
const adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.style.display = 'block';
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item" style="color:#0ea5e9;font-weight:600;">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<style>
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: 64px; background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: center; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; }
.mobile-nav-item.active { color: #0ea5e9; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: 72px; right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 12px 24px; color: #1E293B; text-decoration: none; }
.more-menu-item:hover { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_report_page_html() -> str:
"""生成报告页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a { color: #666; text-decoration: none; font-weight: 600; }
.nav a.active { color: #667eea; }
header {
background: linear-gradient(135deg, #9d50bb 0%, #6e48aa 100%);
color: white;
padding: 24px 20px;
text-align: center;
margin-bottom: 24px;
border-radius: 12px;
}
header h1 { font-size: 1.8rem; margin-bottom: 6px; }
.card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
.actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.btn {
display: inline-block;
text-align: center;
padding: 10px 12px;
background: #6e48aa;
color: white;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
}
code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 6px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px)); }
.container { padding: 12px; }
header { padding: 20px 16px; margin-bottom: 16px; }
header h1 { font-size: 1.5rem; }
.card { padding: 16px; margin-bottom: 12px; }
.actions { grid-template-columns: 1fr 1fr; gap: 10px; }
.btn {
min-height: 48px;
padding: 14px 16px;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
}
@media (max-width: 400px) {
.actions { grid-template-columns: 1fr; }
}
/* 移动端底部导航样式 */
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: 64px; background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: center; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; }
.mobile-nav-item.active { color: #9d50bb; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: 72px; right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 12px 24px; color: #1E293B; text-decoration: none; }
.more-menu-item:hover { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a class="active" href="/report">报告</a>
<a href="/settings">设置</a>
</div>
<header>
<h1>健康报告</h1>
<p>周报 / 月报导出与查看</p>
</header>
<div class="card">
<h3>使用说明</h3>
<p>报告支持一键生成并下载 PDF/PNG</p>
<p><code>周报 PDF/PNG</code> 与 <code>月报 PDF/PNG</code></p>
</div>
<div class="card">
<h3>快速操作</h3>
<div class="actions">
<a class="btn" href="/" style="background:#5b21b6;">返回首页</a>
<a class="btn" href="/exercise">查看运动</a>
<a class="btn" href="/meal">查看饮食</a>
<a class="btn" href="/sleep">查看睡眠</a>
<a class="btn" href="/weight">查看体重</a>
</div>
</div>
<div class="card">
<h3>一键生成与下载</h3>
<div class="actions">
<a class="btn" href="#" onclick="downloadReport('week','pdf');return false;">周报 PDF</a>
<a class="btn" href="#" onclick="downloadReport('week','png');return false;">周报 PNG</a>
<a class="btn" href="#" onclick="downloadReport('month','pdf');return false;">月报 PDF</a>
<a class="btn" href="#" onclick="downloadReport('month','png');return false;">月报 PNG</a>
</div>
</div>
</div>
<script>
async function downloadReport(type, format) {
const url = type === 'week'
? `/api/report/week?format=${format}`
: `/api/report/month?format=${format}`;
try {
const res = await fetch(url);
if (!res.ok) {
const text = await res.text();
alert('生成失败:' + text);
return;
}
const blob = await res.blob();
const fileName = `${type}-report.${format}`;
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
alert('下载失败:' + error.message);
}
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item" style="color:#9d50bb;font-weight:600;">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_reading_page_html() -> str:
"""生成阅读页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 阅读</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@600;700&family=Raleway:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Raleway', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #F8FAFC;
color: #1E293B;
line-height: 1.6;
}
h1, h2, h3 { font-family: 'Lora', serif; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.nav {
display: flex; gap: 16px; align-items: center;
padding: 12px 18px; background: white; border-radius: 12px;
margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);
flex-wrap: wrap;
}
.nav a {
color: #64748B; text-decoration: none; font-weight: 600;
transition: color 200ms; cursor: pointer;
}
.nav a:hover, .nav a.active { color: #3B82F6; }
header {
background: linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%);
color: white; padding: 30px 20px; text-align: center;
margin-bottom: 30px; border-radius: 12px;
}
header h1 { font-size: 2rem; margin-bottom: 10px; }
header p { opacity: 0.95; font-size: 1rem; }
.section {
background: white; border-radius: 12px; padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 30px;
}
.section h2 {
font-size: 1.5rem; color: #1E293B; margin-bottom: 20px;
padding-bottom: 15px; border-bottom: 2px solid #E2E8F0;
}
.stats-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
margin-bottom: 30px;
}
@media (max-width: 768px) { .stats-grid { grid-template-columns: 1fr; } }
.chart-container {
background: #F8FAFC; border-radius: 8px; padding: 20px;
min-height: 200px;
}
.chart-title { font-weight: 600; color: #475569; margin-bottom: 15px; }
.book-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px; margin-top: 20px;
}
.book-card {
background: #F8FAFC; border-radius: 8px; padding: 12px;
text-align: center; cursor: pointer; transition: all 200ms;
border: 2px solid transparent;
}
.book-card:hover { border-color: #3B82F6; transform: translateY(-2px); }
.book-cover {
width: 100%; height: 160px; object-fit: cover; border-radius: 4px;
background: #E2E8F0; margin-bottom: 10px;
}
.book-cover.placeholder {
display: flex; align-items: center; justify-content: center;
font-size: 3rem; color: #94A3B8;
}
.book-title {
font-weight: 600; font-size: 0.9rem; color: #1E293B;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.book-duration { font-size: 0.8rem; color: #64748B; margin-top: 4px; }
.btn {
padding: 12px 24px; border: none; border-radius: 8px;
font-size: 1rem; font-weight: 600; cursor: pointer;
transition: all 200ms; font-family: 'Raleway', sans-serif;
}
.btn:active { transform: scale(0.98); }
.btn-cta { background: #F97316; color: white; }
.btn-cta:hover { background: #EA580C; }
.btn-primary { background: #3B82F6; color: white; }
.btn-primary:hover { background: #2563EB; }
.btn-secondary { background: #E2E8F0; color: #475569; }
.btn-secondary:hover { background: #CBD5E1; }
.btn-danger { background: #EF4444; color: white; }
.btn-danger:hover { background: #DC2626; }
.modal {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 1000;
align-items: center; justify-content: center; padding: 20px;
}
.modal.active { display: flex; }
.modal-content {
background: white; border-radius: 12px; padding: 30px;
max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto;
}
.modal-header { margin-bottom: 20px; }
.modal-header h3 { font-size: 1.3rem; color: #1E293B; }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; font-weight: 600; color: #475569; margin-bottom: 8px; }
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 12px 16px; border: 2px solid #E2E8F0;
border-radius: 8px; font-size: 1rem; font-family: 'Raleway', sans-serif;
transition: border-color 200ms;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: #3B82F6;
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
@media (max-width: 500px) { .form-row { grid-template-columns: 1fr; } }
.mood-selector { display: flex; gap: 10px; justify-content: center; }
.mood-option {
font-size: 2rem; cursor: pointer; padding: 8px;
border-radius: 8px; transition: all 200ms; opacity: 0.5;
}
.mood-option:hover { opacity: 0.8; transform: scale(1.1); }
.mood-option.selected { opacity: 1; background: #EFF6FF; transform: scale(1.2); }
.search-results {
max-height: 200px; overflow-y: auto; border: 1px solid #E2E8F0;
border-radius: 8px; margin-top: 8px; display: none;
}
.search-results.active { display: block; }
.search-item {
display: flex; align-items: center; gap: 12px; padding: 10px;
cursor: pointer; transition: background 200ms;
}
.search-item:hover { background: #F1F5F9; }
.search-item img { width: 40px; height: 60px; object-fit: cover; border-radius: 4px; }
.search-item-info { flex: 1; }
.search-item-title { font-weight: 600; font-size: 0.9rem; }
.search-item-author { font-size: 0.8rem; color: #64748B; }
.selected-cover {
width: 80px; height: 120px; object-fit: cover; border-radius: 4px;
margin: 10px 0; background: #E2E8F0;
}
.duration-presets { display: flex; gap: 8px; margin-top: 8px; }
.duration-preset {
padding: 6px 12px; background: #F1F5F9; border-radius: 6px;
font-size: 0.85rem; cursor: pointer; transition: all 200ms;
}
.duration-preset:hover { background: #E2E8F0; }
.alert {
padding: 12px 20px; border-radius: 8px; margin-bottom: 15px;
font-weight: 500; opacity: 0; transition: opacity 300ms;
}
.alert.active { opacity: 1; }
.alert-success { background: #DCFCE7; color: #166534; }
.alert-error { background: #FEE2E2; color: #DC2626; }
.bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 120px; }
.bar {
flex: 1; background: linear-gradient(to top, #3B82F6, #60A5FA);
border-radius: 4px 4px 0 0; min-height: 4px; transition: height 300ms;
}
.bar-label { text-align: center; font-size: 0.7rem; color: #64748B; margin-top: 4px; }
.pie-chart { display: flex; align-items: center; gap: 20px; }
.pie-legend { display: flex; flex-direction: column; gap: 8px; }
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; }
.legend-color { width: 16px; height: 16px; border-radius: 4px; }
.empty-state { text-align: center; padding: 40px; color: #94A3B8; }
.book-history { display: none; margin-top: 15px; padding: 15px; background: #F8FAFC; border-radius: 8px; }
.book-history.active { display: block; }
.history-item {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid #E2E8F0;
}
.history-item:last-child { border-bottom: none; }
/* 移动端适配 */
@media (max-width: 768px) {
.nav { display: none !important; }
body { padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px)); }
.container { padding: 12px; }
header { padding: 20px 16px; margin-bottom: 20px; }
header h1 { font-size: 1.5rem; }
.section { padding: 16px; margin-bottom: 20px; }
.section h2 { font-size: 1.25rem; margin-bottom: 16px; }
.book-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
.book-cover { height: 140px; }
.modal-content { padding: 20px; margin: 12px; max-height: calc(100vh - 24px); }
/* 表单输入框增大 - 防止 iOS 缩放 */
.form-group input, .form-group select, .form-group textarea {
font-size: 16px !important;
min-height: 48px;
padding: 14px 16px;
}
.form-group textarea { min-height: 80px; }
/* 按钮触摸目标 44px */
.btn {
min-height: 48px;
padding: 14px 20px;
font-size: 1rem;
}
.modal-footer { flex-direction: column; gap: 10px; }
.modal-footer .btn { width: 100%; }
/* 时长预设按钮 */
.duration-presets { flex-wrap: wrap; }
.duration-preset {
min-height: 44px;
min-width: 60px;
display: flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
}
/* 心情选择器 */
.mood-option {
min-width: 48px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
/* 图表容器 */
.chart-container { padding: 16px; min-height: 160px; }
.pie-chart { flex-direction: column; gap: 16px; align-items: center; }
}
@media (max-width: 400px) {
.book-grid { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<nav class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading" class="active">阅读</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</nav>
<header>
<h1>我的阅读</h1>
<p id="summary-text">加载中...</p>
</header>
<div id="alert-container"></div>
<!-- 统计图表 -->
<div class="section">
<h2>阅读统计</h2>
<div class="stats-grid">
<div class="chart-container">
<div class="chart-title">近30天阅读时长</div>
<div id="duration-chart" class="bar-chart"></div>
<div id="duration-labels" style="display: flex; justify-content: space-between; margin-top: 8px;">
</div>
</div>
<div class="chart-container">
<div class="chart-title">心情分布</div>
<div id="mood-chart" class="pie-chart">
<canvas id="mood-canvas" width="120" height="120"></canvas>
<div id="mood-legend" class="pie-legend"></div>
</div>
</div>
</div>
</div>
<!-- 我的书库 -->
<div class="section">
<h2>我的书库</h2>
<div id="book-grid" class="book-grid">
<div class="empty-state">加载中...</div>
</div>
<div style="margin-top: 30px; text-align: center;">
<button class="btn btn-cta" onclick="openAddModal()">+ 记录阅读</button>
</div>
</div>
</div>
<!-- 添加阅读模态框 -->
<div id="add-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>记录阅读</h3>
</div>
<form id="add-form">
<div class="form-group">
<label for="book-title">书名 *</label>
<input type="text" id="book-title" placeholder="输入书名搜索封面" autocomplete="off">
<div id="search-results" class="search-results"></div>
<img id="selected-cover" class="selected-cover" style="display: none;">
<input type="hidden" id="book-author">
<input type="hidden" id="book-cover">
</div>
<div class="form-row">
<div class="form-group">
<label for="duration">阅读时长 (分钟) *</label>
<input type="number" id="duration" min="1" max="1440" value="30">
<div class="duration-presets">
<span class="duration-preset" onclick="setDuration(15)">15分</span>
<span class="duration-preset" onclick="setDuration(30)">30分</span>
<span class="duration-preset" onclick="setDuration(45)">45分</span>
<span class="duration-preset" onclick="setDuration(60)">1小时</span>
</div>
</div>
<div class="form-group">
<label for="reading-date">阅读日期</label>
<input type="date" id="reading-date">
</div>
</div>
<div class="form-group">
<label>阅读心情</label>
<div class="mood-selector">
<span class="mood-option" data-mood="😄" onclick="selectMood(this)">😄</span>
<span class="mood-option" data-mood="😊" onclick="selectMood(this)">😊</span>
<span class="mood-option" data-mood="😐" onclick="selectMood(this)">😐</span>
<span class="mood-option" data-mood="😔" onclick="selectMood(this)">😔</span>
<span class="mood-option" data-mood="😢" onclick="selectMood(this)">😢</span>
</div>
<input type="hidden" id="selected-mood">
</div>
<div class="form-group">
<label for="notes">读后感 (选填)</label>
<textarea id="notes" rows="3" placeholder="记录你的阅读感受..."></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeAddModal()">取消</button>
<button type="submit" class="btn btn-cta">保存</button>
</div>
</form>
</div>
</div>
<!-- 书籍详情模态框 -->
<div id="book-detail-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="detail-title">书籍详情</h3>
</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<img id="detail-cover" class="selected-cover" style="display: block;">
<div>
<p><strong>作者:</strong><span id="detail-author">-</span></p>
<p><strong>总阅读时长:</strong><span id="detail-duration">-</span></p>
<p><strong>阅读次数:</strong><span id="detail-count">-</span></p>
</div>
</div>
<h4 style="margin-bottom: 10px;">阅读记录</h4>
<div id="detail-history"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeDetailModal()">关闭</button>
</div>
</div>
</div>
<script>
let searchTimeout = null;
let selectedMood = null;
let statsData = null;
// 初始化日期
document.getElementById('reading-date').value = new Date().toISOString().split('T')[0];
// 显示提示
function showAlert(message, type = 'success') {
const container = document.getElementById('alert-container');
const alert = document.createElement('div');
alert.className = `alert alert-${type} active`;
alert.textContent = message;
container.appendChild(alert);
setTimeout(() => {
alert.classList.remove('active');
setTimeout(() => alert.remove(), 300);
}, 3000);
}
// 加载统计数据
async function loadStats() {
try {
const response = await fetch('/api/reading/stats?days=30');
statsData = await response.json();
// 更新摘要文本
const hours = (statsData.total_duration / 60).toFixed(1);
const bookCount = statsData.books.length;
document.getElementById('summary-text').textContent =
`本月已读 ${hours} 小时 · ${bookCount} 本书`;
// 绘制时长图表
renderDurationChart(statsData.daily_duration);
// 绘制心情分布
renderMoodChart(statsData.mood_distribution);
// 渲染书库
renderBooks(statsData.books);
} catch (error) {
console.error('加载统计失败:', error);
}
}
// 渲染时长柱状图
function renderDurationChart(dailyData) {
const container = document.getElementById('duration-chart');
const labels = document.getElementById('duration-labels');
if (Object.keys(dailyData).length === 0) {
container.innerHTML = '<div class="empty-state">暂无数据</div>';
return;
}
const dates = [];
const today = new Date();
for (let i = 29; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
dates.push(d.toISOString().split('T')[0]);
}
const values = dates.map(d => dailyData[d] || 0);
const maxVal = Math.max(...values, 1);
container.innerHTML = values.map((v, i) =>
`<div class="bar" style="height: ${(v / maxVal) * 100}%;" title="${dates[i]}: ${v}分钟"></div>`
).join('');
labels.innerHTML = `<span>30天前</span><span>今天</span>`;
}
// 渲染心情饼图
function renderMoodChart(moodData) {
const canvas = document.getElementById('mood-canvas');
const legend = document.getElementById('mood-legend');
const ctx = canvas.getContext('2d');
const moods = Object.keys(moodData);
if (moods.length === 0) {
legend.innerHTML = '<div class="empty-state">暂无数据</div>';
return;
}
const colors = {
'😄': '#22C55E',
'😊': '#3B82F6',
'😐': '#F59E0B',
'😔': '#8B5CF6',
'😢': '#EF4444'
};
const total = Object.values(moodData).reduce((a, b) => a + b, 0);
let startAngle = -Math.PI / 2;
ctx.clearRect(0, 0, 120, 120);
moods.forEach(mood => {
const value = moodData[mood];
const sliceAngle = (value / total) * 2 * Math.PI;
ctx.beginPath();
ctx.moveTo(60, 60);
ctx.arc(60, 60, 50, startAngle, startAngle + sliceAngle);
ctx.fillStyle = colors[mood] || '#94A3B8';
ctx.fill();
startAngle += sliceAngle;
});
legend.innerHTML = moods.map(mood =>
`<div class="legend-item">
<div class="legend-color" style="background: ${colors[mood] || '#94A3B8'}"></div>
<span>${mood} ${moodData[mood]}次</span>
</div>`
).join('');
}
// 渲染书库
function renderBooks(books) {
const container = document.getElementById('book-grid');
if (books.length === 0) {
container.innerHTML = '<div class="empty-state">还没有阅读记录,点击下方按钮开始记录吧!</div>';
return;
}
container.innerHTML = books.map(book => `
<div class="book-card" onclick="showBookDetail('${encodeURIComponent(book.title)}')">
${book.cover_url
? `<img class="book-cover" src="${book.cover_url}" alt="${book.title}">`
: `<div class="book-cover placeholder">📚</div>`
}
<div class="book-title" title="${book.title}">${book.title}</div>
<div class="book-duration">${(book.total_duration / 60).toFixed(1)}小时</div>
</div>
`).join('');
}
// 显示书籍详情
async function showBookDetail(encodedTitle) {
const title = decodeURIComponent(encodedTitle);
const book = statsData.books.find(b => b.title === title);
if (!book) return;
document.getElementById('detail-title').textContent = book.title;
document.getElementById('detail-author').textContent = book.author || '-';
document.getElementById('detail-duration').textContent = (book.total_duration / 60).toFixed(1) + '小时';
document.getElementById('detail-count').textContent = book.reading_count + '';
const coverImg = document.getElementById('detail-cover');
if (book.cover_url) {
coverImg.src = book.cover_url;
coverImg.style.display = 'block';
} else {
coverImg.style.display = 'none';
}
// 加载该书的阅读历史
try {
const response = await fetch('/api/reading?days=365');
const readings = await response.json();
const bookReadings = readings.filter(r => r.title === title);
document.getElementById('detail-history').innerHTML = bookReadings.map(r => `
<div class="history-item">
<div>
<span>${r.date}</span>
<span style="margin-left: 10px;">${r.duration}分钟</span>
<span style="margin-left: 10px;">${r.mood || ''}</span>
</div>
<button class="btn btn-danger" style="padding: 4px 10px; font-size: 0.8rem;"
onclick="deleteReading(${r.id})">删除</button>
</div>
`).join('');
} catch (error) {
document.getElementById('detail-history').innerHTML = '<div class="empty-state">加载失败</div>';
}
document.getElementById('book-detail-modal').classList.add('active');
}
function closeDetailModal() {
document.getElementById('book-detail-modal').classList.remove('active');
}
// 删除阅读记录
async function deleteReading(id) {
if (!confirm('确定删除这条阅读记录吗?')) return;
try {
const response = await fetch(`/api/reading/${id}`, { method: 'DELETE' });
if (response.ok) {
showAlert('已删除');
closeDetailModal();
loadStats();
} else {
showAlert('删除失败', 'error');
}
} catch (error) {
showAlert('删除失败', 'error');
}
}
// 搜索书籍
async function searchBooks(query) {
if (query.length < 2) {
document.getElementById('search-results').classList.remove('active');
return;
}
try {
const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
const container = document.getElementById('search-results');
if (data.books && data.books.length > 0) {
container.innerHTML = data.books.map(book => `
<div class="search-item" onclick="selectBook('${encodeURIComponent(JSON.stringify(book))}')">
${book.cover_url
? `<img src="${book.cover_url}" alt="${book.title}">`
: `<div style="width:40px;height:60px;background:#E2E8F0;border-radius:4px;"></div>`
}
<div class="search-item-info">
<div class="search-item-title">${book.title}</div>
<div class="search-item-author">${book.author || '未知作者'}</div>
</div>
</div>
`).join('');
container.classList.add('active');
} else {
container.innerHTML = '<div style="padding: 15px; text-align: center; color: #94A3B8;">未找到结果</div>';
container.classList.add('active');
}
} catch (error) {
console.error('搜索失败:', error);
}
}
// 选择书籍
function selectBook(encodedBook) {
const book = JSON.parse(decodeURIComponent(encodedBook));
document.getElementById('book-title').value = book.title;
document.getElementById('book-author').value = book.author || '';
document.getElementById('book-cover').value = book.cover_url || '';
const coverImg = document.getElementById('selected-cover');
if (book.cover_url) {
coverImg.src = book.cover_url;
coverImg.style.display = 'block';
} else {
coverImg.style.display = 'none';
}
document.getElementById('search-results').classList.remove('active');
}
// 监听书名输入
document.getElementById('book-title').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => searchBooks(e.target.value), 500);
});
// 设置时长
function setDuration(mins) {
document.getElementById('duration').value = mins;
}
// 选择心情
function selectMood(element) {
document.querySelectorAll('.mood-option').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
selectedMood = element.dataset.mood;
document.getElementById('selected-mood').value = selectedMood;
}
// 打开添加模态框
function openAddModal() {
document.getElementById('add-modal').classList.add('active');
document.getElementById('book-title').focus();
}
function closeAddModal() {
document.getElementById('add-modal').classList.remove('active');
document.getElementById('add-form').reset();
document.getElementById('selected-cover').style.display = 'none';
document.getElementById('search-results').classList.remove('active');
document.querySelectorAll('.mood-option').forEach(el => el.classList.remove('selected'));
selectedMood = null;
document.getElementById('reading-date').value = new Date().toISOString().split('T')[0];
}
// 提交表单
document.getElementById('add-form').addEventListener('submit', async (e) => {
e.preventDefault();
const title = document.getElementById('book-title').value.trim();
const duration = parseInt(document.getElementById('duration').value);
if (!title) {
showAlert('请输入书名', 'error');
return;
}
if (!duration || duration < 1) {
showAlert('请输入有效的阅读时长', 'error');
return;
}
const data = {
title: title,
author: document.getElementById('book-author').value || null,
cover_url: document.getElementById('book-cover').value || null,
duration: duration,
mood: selectedMood,
notes: document.getElementById('notes').value || null,
date: document.getElementById('reading-date').value,
};
try {
const response = await fetch('/api/reading', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
showAlert('阅读记录已保存');
closeAddModal();
loadStats();
} else {
const error = await response.json();
showAlert(error.detail || '保存失败', 'error');
}
} catch (error) {
showAlert('保存失败', 'error');
}
});
// 页面加载
document.addEventListener('DOMContentLoaded', loadStats);
// 检查管理员状态
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.is_admin) {
const adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.style.display = 'block';
}
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="nav-label">首页</span></a>
<a href="/exercise" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><span class="nav-label">运动</span></a>
<a href="/meal" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg><span class="nav-label">饮食</span></a>
<a href="/sleep" class="mobile-nav-item"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg><span class="nav-label">睡眠</span></a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg><span class="nav-label">更多</span></div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item" style="color:#3B82F6;font-weight:600;">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item">设置</a>
<a href="/admin" class="more-menu-item" id="admin-link" style="display:none">管理</a>
</div>
<style>
.mobile-nav { position: fixed; bottom: 0; left: 0; right: 0; height: calc(64px + env(safe-area-inset-bottom, 0px)); padding-bottom: env(safe-area-inset-bottom, 0px); background: white; border-top: 1px solid #E2E8F0; display: none; justify-content: space-around; align-items: flex-start; padding-top: 8px; z-index: 50; }
.mobile-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 64px; min-height: 44px; color: #64748B; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.mobile-nav-item.active { color: #3B82F6; }
.nav-icon { width: 24px; height: 24px; margin-bottom: 2px; }
.nav-label { font-size: 10px; font-weight: 500; }
.more-menu { position: fixed; bottom: calc(72px + env(safe-area-inset-bottom, 0px)); right: 16px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 8px 0; z-index: 51; }
.more-menu.hidden { display: none; }
.more-menu-item { display: block; padding: 14px 24px; color: #1E293B; text-decoration: none; min-height: 44px; }
.more-menu-item:hover, .more-menu-item:active { background: #F1F5F9; }
@media (max-width: 768px) { .mobile-nav { display: flex; } }
</style>
<script>
function toggleMoreMenu() { document.getElementById('more-menu').classList.toggle('hidden'); }
document.addEventListener('click', function(e) { if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) { document.getElementById('more-menu').classList.add('hidden'); } });
</script>
</body>
</html>
"""
def get_settings_page_html() -> str:
"""生成设置页面 HTML"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vitals 设置</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@600;700&family=Raleway:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Raleway', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #F8FAFC;
color: #1E293B;
line-height: 1.6;
}
h1, h2, h3 {
font-family: 'Lora', serif;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 18px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.nav a {
color: #64748B;
text-decoration: none;
font-weight: 600;
transition: color 200ms;
cursor: pointer;
}
.nav a:hover {
color: #3B82F6;
}
.nav a.active {
color: #3B82F6;
}
@media (max-width: 768px) {
.nav {
flex-wrap: wrap;
gap: 10px;
}
}
header {
background: linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%);
color: white;
padding: 30px 20px;
text-align: center;
margin-bottom: 30px;
border-radius: 12px;
}
header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
header p {
opacity: 0.95;
font-size: 1rem;
}
.section {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 30px;
}
.section h2 {
font-size: 1.5rem;
color: #1E293B;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #E2E8F0;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 16px;
border: 2px solid #E2E8F0;
border-radius: 8px;
font-size: 1rem;
font-family: 'Raleway', sans-serif;
transition: border-color 200ms;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3B82F6;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
font-family: 'Raleway', sans-serif;
cursor: pointer;
transition: all 200ms;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: #3B82F6;
color: white;
}
.btn-primary:hover {
background: #2563EB;
}
.btn-cta {
background: #F97316;
color: white;
}
.btn-cta:hover {
background: #EA580C;
}
.btn-danger {
background: #EF4444;
color: white;
}
.btn-danger:hover {
background: #DC2626;
}
.btn-secondary {
background: #E2E8F0;
color: #475569;
}
.btn-secondary:hover {
background: #CBD5E1;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
.bmi-display {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #F1F5F9;
border-radius: 8px;
font-size: 1.2rem;
font-weight: 700;
}
#bmi-value {
color: #1E293B;
}
.bmi-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.bmi-status.underweight {
background: #DBEAFE;
color: #1E40AF;
}
.bmi-status.normal {
background: #DCFCE7;
color: #166534;
}
.bmi-status.overweight {
background: #FEF3C7;
color: #B45309;
}
.bmi-status.obese {
background: #FEE2E2;
color: #DC2626;
}
.user-list {
display: grid;
gap: 15px;
margin-top: 20px;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border: 2px solid #E2E8F0;
border-radius: 8px;
transition: all 200ms;
cursor: pointer;
}
.user-card:hover {
border-color: #CBD5E1;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
.user-card.active {
border-color: #3B82F6;
background: #EFF6FF;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 700;
font-size: 1.1rem;
color: #1E293B;
}
.user-meta {
font-size: 0.85rem;
color: #64748B;
margin-top: 4px;
}
.user-badge {
display: inline-block;
padding: 4px 12px;
background: #3B82F6;
color: white;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
margin-left: 10px;
}
.user-actions {
display: flex;
gap: 10px;
}
.btn-small {
padding: 8px 16px;
font-size: 0.9rem;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
margin-bottom: 20px;
}
.modal-header h3 {
font-size: 1.5rem;
color: #1E293B;
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.alert {
padding: 16px 20px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.alert.active {
display: block;
}
.alert-success {
background: #D1FAE5;
color: #065F46;
border: 1px solid #10B981;
}
.alert-error {
background: #FEE2E2;
color: #991B1B;
border: 1px solid #EF4444;
}
.alert-info {
background: #DBEAFE;
color: #1E40AF;
border: 1px solid #3B82F6;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.radio-option {
display: flex;
align-items: center;
padding: 12px;
border: 2px solid #E2E8F0;
border-radius: 8px;
cursor: pointer;
transition: all 200ms;
}
.radio-option:hover {
border-color: #3B82F6;
background: #F8FAFC;
}
.radio-option input[type="radio"] {
margin-right: 10px;
width: 20px;
height: 20px;
cursor: pointer;
}
.radio-option label {
cursor: pointer;
font-weight: 500;
margin: 0;
}
.range-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 15px;
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 15px;
}
.checkbox-option {
display: flex;
align-items: center;
}
.checkbox-option input[type="checkbox"] {
margin-right: 8px;
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-option label {
cursor: pointer;
font-weight: 500;
margin: 0;
}
.preview-box {
background: #F8FAFC;
border: 2px solid #E2E8F0;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.preview-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #E2E8F0;
}
.preview-item:last-child {
border-bottom: none;
}
.preview-label {
font-weight: 600;
color: #475569;
}
.preview-value {
font-weight: 700;
color: #1E293B;
}
.preview-total {
margin-top: 10px;
padding-top: 10px;
border-top: 2px solid #CBD5E1;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-left: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.container {
padding: 15px;
padding-bottom: 100px;
}
.section {
padding: 20px;
}
.range-inputs {
grid-template-columns: 1fr;
}
.user-card {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.user-actions {
width: 100%;
justify-content: flex-start;
}
/* 隐藏桌面导航 */
.nav {
display: none;
}
/* 表单输入框加大 */
.form-group input,
.form-group select {
height: 48px;
font-size: 16px;
}
/* 按钮符合触摸标准 */
.btn {
min-height: 44px;
padding: 12px 20px;
}
.btn-small {
min-height: 44px;
padding: 10px 16px;
}
/* 模态框适配 */
.modal-content {
width: 95%;
max-height: 90vh;
margin: 20px;
}
header {
padding: 20px 16px;
margin-bottom: 20px;
}
header h1 {
font-size: 1.5rem;
}
}
/* 移动端底部导航 */
.mobile-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: white;
border-top: 1px solid #E2E8F0;
display: none;
justify-content: space-around;
align-items: center;
z-index: 50;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 64px;
min-height: 44px;
color: #64748B;
text-decoration: none;
cursor: pointer;
}
.mobile-nav-item.active {
color: #3B82F6;
}
.nav-icon {
width: 24px;
height: 24px;
margin-bottom: 2px;
}
.nav-label {
font-size: 10px;
font-weight: 500;
}
.more-menu {
position: fixed;
bottom: 72px;
right: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
padding: 8px 0;
z-index: 51;
}
.more-menu.hidden {
display: none;
}
.more-menu-item {
display: block;
padding: 12px 24px;
color: #1E293B;
text-decoration: none;
}
.more-menu-item:hover {
background: #F1F5F9;
}
@media (max-width: 768px) {
.mobile-nav { display: flex; }
}
</style>
</head>
<body>
<div class="container">
<div class="nav desktop-nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/reading">阅读</a>
<a href="/report">报告</a>
<a class="active" href="/settings">设置</a>
</div>
<header>
<h1>系统设置</h1>
<p>管理用户档案和数据</p>
</header>
<div id="alert-container"></div>
<!-- 个人信息 -->
<div class="section">
<h2>个人信息</h2>
<p style="color: #64748B; margin-bottom: 20px;">编辑当前激活用户的基本信息BMI 将根据身高和体重自动计算</p>
<form id="profile-form">
<div class="form-row">
<div class="form-group">
<label for="profile-name">姓名 *</label>
<input type="text" id="profile-name" name="name" required placeholder="请输入姓名" maxlength="50">
</div>
<div class="form-group">
<label for="profile-gender">性别</label>
<select id="profile-gender" name="gender">
<option value="">请选择</option>
<option value="male">男</option>
<option value="female">女</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="profile-age">年龄</label>
<input type="number" id="profile-age" name="age" min="1" max="150" placeholder="请输入年龄">
</div>
<div class="form-group">
<label for="profile-height">身高 (cm)</label>
<input type="number" id="profile-height" name="height_cm" min="50" max="300" step="0.1" placeholder="请输入身高">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="profile-weight">体重 (kg)</label>
<input type="number" id="profile-weight" name="weight_kg" min="20" max="500" step="0.1" placeholder="请输入体重">
</div>
<div class="form-group">
<label>BMI (自动计算)</label>
<div class="bmi-display">
<span id="bmi-value">--</span>
<span id="bmi-status" class="bmi-status"></span>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<button type="submit" class="btn btn-primary">保存个人信息</button>
</div>
</form>
</div>
<!-- 用户管理 -->
<div class="section">
<h2>用户管理</h2>
<div id="user-list" class="user-list">
<div style="text-align: center; padding: 40px; color: #94A3B8;">
加载中...
</div>
</div>
<div style="margin-top: 30px;">
<button class="btn btn-cta" onclick="openCreateUserModal()">
+ 创建新用户
</button>
</div>
</div>
<!-- 数据管理 -->
<div class="section">
<h2>数据管理</h2>
<form id="clear-form">
<div class="form-group">
<label>清除模式</label>
<div class="radio-group">
<div class="radio-option">
<input type="radio" id="mode-all" name="mode" value="all" checked>
<label for="mode-all">清除全部数据</label>
</div>
<div class="radio-option">
<input type="radio" id="mode-range" name="mode" value="range">
<label for="mode-range">按时间范围清除</label>
</div>
<div class="radio-option">
<input type="radio" id="mode-type" name="mode" value="type">
<label for="mode-type">按数据类型清除</label>
</div>
</div>
</div>
<div id="range-section" class="form-group" style="display: none;">
<label>时间范围</label>
<div class="range-inputs">
<div>
<input type="date" id="date-from" name="date_from">
</div>
<div>
<input type="date" id="date-to" name="date_to">
</div>
</div>
</div>
<div id="type-section" class="form-group" style="display: none;">
<label>数据类型</label>
<div class="checkbox-group">
<div class="checkbox-option">
<input type="checkbox" id="type-exercise" value="exercise">
<label for="type-exercise">运动</label>
</div>
<div class="checkbox-option">
<input type="checkbox" id="type-meal" value="meal">
<label for="type-meal">饮食</label>
</div>
<div class="checkbox-option">
<input type="checkbox" id="type-sleep" value="sleep">
<label for="type-sleep">睡眠</label>
</div>
<div class="checkbox-option">
<input type="checkbox" id="type-weight" value="weight">
<label for="type-weight">体重</label>
</div>
</div>
</div>
<div id="preview-section" style="display: none;">
<div class="preview-box">
<h3 style="margin-bottom: 15px; color: #475569;">预览删除数据</h3>
<div class="preview-item">
<span class="preview-label">运动记录</span>
<span class="preview-value" id="preview-exercise">0</span>
</div>
<div class="preview-item">
<span class="preview-label">饮食记录</span>
<span class="preview-value" id="preview-meal">0</span>
</div>
<div class="preview-item">
<span class="preview-label">睡眠记录</span>
<span class="preview-value" id="preview-sleep">0</span>
</div>
<div class="preview-item">
<span class="preview-label">体重记录</span>
<span class="preview-value" id="preview-weight">0</span>
</div>
<div class="preview-item preview-total">
<span class="preview-label">总计</span>
<span class="preview-value" id="preview-total">0</span>
</div>
</div>
</div>
<div style="display: flex; gap: 15px; margin-top: 20px;">
<button type="button" class="btn btn-primary" onclick="previewDelete()">
预览删除
</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()" id="delete-btn" disabled>
执行删除
</button>
</div>
</form>
</div>
<!-- API 密钥配置(仅管理员可见) -->
<div class="section" id="api-keys-section" style="display: none;">
<h2>API 密钥配置</h2>
<p style="color: #64748B; margin-bottom: 20px;">配置 AI 服务的 API 密钥,用于食物图片识别功能</p>
<div id="api-keys-list">
<div style="text-align: center; padding: 40px; color: #94A3B8;">
加载中...
</div>
</div>
</div>
<!-- 账户管理 -->
<div class="section">
<h2>账户</h2>
<p style="color: #64748B; margin-bottom: 20px;">当前登录账户管理</p>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 20px; background: #F8FAFC; border-radius: 8px; margin-bottom: 20px;">
<div>
<div style="font-weight: 600; color: #1E293B; font-size: 1.1rem;" id="account-name">--</div>
<div style="color: #64748B; font-size: 0.9rem; margin-top: 4px;" id="account-role">普通用户</div>
</div>
<div id="account-badge" style="display: none; padding: 4px 12px; background: #3B82F6; color: white; border-radius: 12px; font-size: 0.75rem; font-weight: 700;">
管理员
</div>
</div>
<!-- 管理后台入口(仅管理员可见) -->
<a href="/admin" id="admin-entry" class="btn btn-primary" style="width: 100%; display: none; text-align: center; text-decoration: none; margin-bottom: 12px;">
进入管理后台
</a>
<button type="button" class="btn btn-danger" onclick="logoutAccount()" style="width: 100%;">
退出登录
</button>
</div>
<!-- 创建用户模态框 -->
<div id="create-user-modal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3>创建新用户</h3>
</div>
<form id="create-user-form">
<div class="form-group">
<label for="user-name">姓名 *</label>
<input type="text" id="user-name" name="name" required placeholder="请输入姓名" maxlength="50">
</div>
<div class="form-row">
<div class="form-group">
<label for="new-user-gender">性别</label>
<select id="new-user-gender" name="gender">
<option value="">请选择</option>
<option value="male">男</option>
<option value="female">女</option>
</select>
</div>
<div class="form-group">
<label for="new-user-age">年龄</label>
<input type="number" id="new-user-age" name="age" min="1" max="150" placeholder="年龄">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="new-user-height">身高 (cm)</label>
<input type="number" id="new-user-height" name="height_cm" min="50" max="300" step="0.1" placeholder="身高">
</div>
<div class="form-group">
<label for="new-user-weight">体重 (kg)</label>
<input type="number" id="new-user-weight" name="weight_kg" min="20" max="500" step="0.1" placeholder="体重">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeCreateUserModal()">取消</button>
<button type="submit" class="btn btn-cta">创建</button>
</div>
</form>
</div>
</div>
<!-- 确认删除模态框 -->
<div id="confirm-delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>确认删除</h3>
</div>
<p style="margin-bottom: 20px; color: #475569;">
此操作将永久删除 <strong id="confirm-total">0</strong> 条记录,无法恢复。确定要继续吗?
</p>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeConfirmModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="executeDelete()">确认删除</button>
</div>
</div>
</div>
</div>
<script>
let activeUser = null;
let previewData = null;
// 计算 BMI
function calculateBMI() {
const height = parseFloat(document.getElementById('profile-height').value);
const weight = parseFloat(document.getElementById('profile-weight').value);
const bmiValue = document.getElementById('bmi-value');
const bmiStatus = document.getElementById('bmi-status');
if (height && weight && height > 0) {
const heightM = height / 100;
const bmi = weight / (heightM * heightM);
bmiValue.textContent = bmi.toFixed(1);
let status = '';
let statusClass = '';
if (bmi < 18.5) {
status = '偏瘦';
statusClass = 'underweight';
} else if (bmi < 24) {
status = '正常';
statusClass = 'normal';
} else if (bmi < 28) {
status = '偏胖';
statusClass = 'overweight';
} else {
status = '肥胖';
statusClass = 'obese';
}
bmiStatus.textContent = status;
bmiStatus.className = 'bmi-status ' + statusClass;
} else {
bmiValue.textContent = '--';
bmiStatus.textContent = '';
bmiStatus.className = 'bmi-status';
}
}
// 加载个人信息
async function loadProfile() {
try {
const response = await fetch('/api/users/active');
if (response.ok) {
const user = await response.json();
activeUser = user;
document.getElementById('profile-name').value = user.name || '';
document.getElementById('profile-gender').value = user.gender || '';
document.getElementById('profile-age').value = user.age || '';
document.getElementById('profile-height').value = user.height_cm || '';
document.getElementById('profile-weight').value = user.weight_kg || '';
calculateBMI();
}
} catch (error) {
console.error('加载个人信息失败:', error);
}
}
// 保存个人信息
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!activeUser) {
showAlert('没有激活的用户', 'error');
return;
}
const name = document.getElementById('profile-name').value.trim();
const gender = document.getElementById('profile-gender').value || null;
const age = document.getElementById('profile-age').value ? parseInt(document.getElementById('profile-age').value) : null;
const height_cm = document.getElementById('profile-height').value ? parseFloat(document.getElementById('profile-height').value) : null;
const weight_kg = document.getElementById('profile-weight').value ? parseFloat(document.getElementById('profile-weight').value) : null;
if (!name) {
showAlert('姓名不能为空', 'error');
return;
}
try {
const response = await fetch(`/api/users/${activeUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, gender, height_cm, weight_kg, age })
});
if (response.ok) {
showAlert('个人信息已保存', 'success');
loadProfile();
loadUsers();
} else {
const error = await response.json();
showAlert(error.detail || '保存失败', 'error');
}
} catch (error) {
console.error('保存个人信息失败:', error);
showAlert('保存失败', 'error');
}
});
// 显示提示
function showAlert(message, type = 'success') {
const container = document.getElementById('alert-container');
const alert = document.createElement('div');
alert.className = `alert alert-${type} active`;
alert.textContent = message;
container.appendChild(alert);
setTimeout(() => {
alert.classList.remove('active');
setTimeout(() => alert.remove(), 300);
}, 3000);
}
// 加载用户列表
async function loadUsers() {
try {
const response = await fetch('/api/users');
const users = await response.json();
const activeResponse = await fetch('/api/users/active');
const active = await activeResponse.json();
activeUser = active;
const container = document.getElementById('user-list');
if (users.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 40px; color: #94A3B8;">暂无用户</div>';
return;
}
container.innerHTML = users.map(user => `
<div class="user-card ${user.is_active ? 'active' : ''}">
<div class="user-info">
<div class="user-name">
${user.name}
${user.is_active ? '<span class="user-badge">激活</span>' : ''}
</div>
<div class="user-meta">创建时间: ${new Date(user.created_at).toLocaleDateString('zh-CN')}</div>
</div>
<div class="user-actions">
${!user.is_active ? `<button class="btn btn-primary btn-small" onclick="activateUser(${user.id})">激活</button>` : ''}
${!user.is_active ? `<button class="btn btn-danger btn-small" onclick="deleteUser(${user.id}, '${user.name}')">删除</button>` : ''}
</div>
</div>
`).join('');
} catch (error) {
console.error('加载用户失败:', error);
showAlert('加载用户失败', 'error');
}
}
// 创建用户模态框
function openCreateUserModal() {
document.getElementById('create-user-modal').classList.add('active');
document.getElementById('user-name').focus();
}
function closeCreateUserModal() {
document.getElementById('create-user-modal').classList.remove('active');
document.getElementById('create-user-form').reset();
}
// 创建用户
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('user-name').value.trim();
const gender = document.getElementById('new-user-gender').value || null;
const age = document.getElementById('new-user-age').value ? parseInt(document.getElementById('new-user-age').value) : null;
const height_cm = document.getElementById('new-user-height').value ? parseFloat(document.getElementById('new-user-height').value) : null;
const weight_kg = document.getElementById('new-user-weight').value ? parseFloat(document.getElementById('new-user-weight').value) : null;
if (!name) {
showAlert('姓名不能为空', 'error');
return;
}
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, gender, height_cm, weight_kg, age })
});
if (response.ok) {
showAlert('用户创建成功', 'success');
closeCreateUserModal();
loadUsers();
} else {
const error = await response.json();
showAlert(error.detail || '创建失败', 'error');
}
} catch (error) {
console.error('创建用户失败:', error);
showAlert('创建用户失败', 'error');
}
});
// 激活用户
async function activateUser(userId) {
try {
const response = await fetch(`/api/users/${userId}/activate`, {
method: 'POST'
});
if (response.ok) {
showAlert('用户已激活', 'success');
loadUsers();
} else {
const error = await response.json();
showAlert(error.detail || '激活失败', 'error');
}
} catch (error) {
console.error('激活用户失败:', error);
showAlert('激活用户失败', 'error');
}
}
// 删除用户
async function deleteUser(userId, userName) {
if (!confirm(`确定要删除用户 "${userName}" 吗?此操作将同时删除该用户的所有数据。`)) {
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE'
});
if (response.ok) {
showAlert('用户已删除', 'success');
loadUsers();
} else {
const error = await response.json();
showAlert(error.detail || '删除失败', 'error');
}
} catch (error) {
console.error('删除用户失败:', error);
showAlert('删除用户失败', 'error');
}
}
// 模式切换
document.querySelectorAll('input[name="mode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const mode = e.target.value;
document.getElementById('range-section').style.display = mode === 'range' ? 'block' : 'none';
document.getElementById('type-section').style.display = mode === 'type' ? 'block' : 'none';
document.getElementById('preview-section').style.display = 'none';
document.getElementById('delete-btn').disabled = true;
});
});
// 预览删除
async function previewDelete() {
if (!activeUser) {
showAlert('没有激活的用户', 'error');
return;
}
const mode = document.querySelector('input[name="mode"]:checked').value;
const data = { user_id: activeUser.id, mode };
if (mode === 'range') {
const dateFrom = document.getElementById('date-from').value;
const dateTo = document.getElementById('date-to').value;
if (!dateFrom || !dateTo) {
showAlert('请选择时间范围', 'error');
return;
}
data.date_from = dateFrom;
data.date_to = dateTo;
}
if (mode === 'type') {
const types = [];
document.querySelectorAll('#type-section input[type="checkbox"]:checked').forEach(cb => {
types.push(cb.value);
});
if (types.length === 0) {
showAlert('请至少选择一种数据类型', 'error');
return;
}
data.data_types = types;
}
try {
const response = await fetch('/api/data/preview-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
previewData = await response.json();
document.getElementById('preview-exercise').textContent = previewData.exercise;
document.getElementById('preview-meal').textContent = previewData.meal;
document.getElementById('preview-sleep').textContent = previewData.sleep;
document.getElementById('preview-weight').textContent = previewData.weight;
document.getElementById('preview-total').textContent = previewData.total;
document.getElementById('preview-section').style.display = 'block';
document.getElementById('delete-btn').disabled = previewData.total === 0;
if (previewData.total === 0) {
showAlert('没有符合条件的数据', 'info');
}
} else {
const error = await response.json();
showAlert(error.detail || '预览失败', 'error');
}
} catch (error) {
console.error('预览删除失败:', error);
showAlert('预览删除失败', 'error');
}
}
// 确认删除
function confirmDelete() {
if (!previewData || previewData.total === 0) {
return;
}
document.getElementById('confirm-total').textContent = previewData.total;
document.getElementById('confirm-delete-modal').classList.add('active');
}
function closeConfirmModal() {
document.getElementById('confirm-delete-modal').classList.remove('active');
}
// 执行删除
async function executeDelete() {
if (!activeUser || !previewData) {
return;
}
const mode = document.querySelector('input[name="mode"]:checked').value;
const data = { user_id: activeUser.id, mode };
if (mode === 'range') {
data.date_from = document.getElementById('date-from').value;
data.date_to = document.getElementById('date-to').value;
}
if (mode === 'type') {
const types = [];
document.querySelectorAll('#type-section input[type="checkbox"]:checked').forEach(cb => {
types.push(cb.value);
});
data.data_types = types;
}
try {
const response = await fetch('/api/data/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
showAlert('数据已清除', 'success');
closeConfirmModal();
document.getElementById('preview-section').style.display = 'none';
document.getElementById('delete-btn').disabled = true;
document.getElementById('clear-form').reset();
previewData = null;
} else {
const error = await response.json();
showAlert(error.detail || '删除失败', 'error');
}
} catch (error) {
console.error('删除数据失败:', error);
showAlert('删除数据失败', 'error');
}
}
// 点击模态框外部关闭
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('active');
}
});
});
// 加载账户信息
async function loadAccountInfo() {
try {
const response = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (response.ok) {
const user = await response.json();
document.getElementById('account-name').textContent = user.name;
document.getElementById('account-role').textContent = user.is_admin ? '管理员账户' : '普通用户';
if (user.is_admin) {
document.getElementById('account-badge').style.display = 'block';
// 显示管理后台入口
document.getElementById('admin-entry').style.display = 'block';
// 显示 API 密钥配置区域
document.getElementById('api-keys-section').style.display = 'block';
loadApiKeys();
}
// 保存到 localStorage
localStorage.setItem('user', JSON.stringify(user));
} else {
// 如果获取失败,尝试从 localStorage 获取
const cachedUser = localStorage.getItem('user');
if (cachedUser) {
const user = JSON.parse(cachedUser);
document.getElementById('account-name').textContent = user.name;
document.getElementById('account-role').textContent = user.is_admin ? '管理员账户' : '普通用户';
if (user.is_admin) {
document.getElementById('account-badge').style.display = 'block';
document.getElementById('admin-entry').style.display = 'block';
document.getElementById('api-keys-section').style.display = 'block';
loadApiKeys();
}
}
}
} catch (error) {
console.error('加载账户信息失败:', error);
// 尝试从 localStorage 获取
const cachedUser = localStorage.getItem('user');
if (cachedUser) {
const user = JSON.parse(cachedUser);
document.getElementById('account-name').textContent = user.name;
document.getElementById('account-role').textContent = user.is_admin ? '管理员账户' : '普通用户';
if (user.is_admin) {
document.getElementById('account-badge').style.display = 'block';
document.getElementById('admin-entry').style.display = 'block';
document.getElementById('api-keys-section').style.display = 'block';
loadApiKeys();
}
}
}
}
// API 密钥管理
async function loadApiKeys() {
try {
const response = await fetch('/api/admin/api-keys', { credentials: 'same-origin' });
if (response.ok) {
const keys = await response.json();
renderApiKeys(keys);
} else {
document.getElementById('api-keys-list').innerHTML = '<div style="color: #EF4444; padding: 20px;">加载失败,请刷新重试</div>';
}
} catch (error) {
console.error('加载 API 密钥失败:', error);
document.getElementById('api-keys-list').innerHTML = '<div style="color: #EF4444; padding: 20px;">加载失败,请刷新重试</div>';
}
}
function renderApiKeys(keys) {
const container = document.getElementById('api-keys-list');
const providers = Object.keys(keys);
container.innerHTML = providers.map(provider => {
const key = keys[provider];
const hasValue = key.value !== null;
const sourceLabel = key.source === 'database' ? '数据库' : (key.source === 'env' ? '环境变量' : '未配置');
const sourceColor = key.source === 'database' ? '#10B981' : (key.source === 'env' ? '#F59E0B' : '#94A3B8');
return `
<div class="api-key-item" style="padding: 20px; border: 2px solid #E2E8F0; border-radius: 8px; margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<div style="font-weight: 600; color: #1E293B;">${key.name}</div>
<div style="font-size: 0.85rem; color: ${sourceColor}; margin-top: 4px;">
来源: ${sourceLabel}
</div>
</div>
<div style="display: flex; gap: 8px;">
${hasValue ? `<button class="btn btn-secondary btn-small" onclick="toggleApiKeyVisibility('${provider}')">显示</button>` : ''}
${key.source === 'database' ? `<button class="btn btn-danger btn-small" onclick="deleteApiKey('${provider}')">删除</button>` : ''}
</div>
</div>
<div style="display: flex; gap: 10px;">
<input type="password" id="api-key-${provider}" class="api-key-input"
value="${hasValue ? key.value : ''}"
placeholder="${hasValue ? '' : '未配置,请输入 API Key'}"
style="flex: 1; padding: 10px 14px; border: 2px solid #E2E8F0; border-radius: 6px; font-family: monospace;">
<button class="btn btn-primary btn-small" onclick="saveApiKey('${provider}')">保存</button>
</div>
</div>
`;
}).join('');
}
async function toggleApiKeyVisibility(provider) {
const input = document.getElementById(`api-key-${provider}`);
if (input.type === 'password') {
// 获取完整值
try {
const response = await fetch(`/api/admin/api-keys/${provider}`, { credentials: 'same-origin' });
if (response.ok) {
const data = await response.json();
input.value = data.value || '';
input.type = 'text';
// 更新按钮文字
event.target.textContent = '隐藏';
}
} catch (error) {
console.error('获取 API 密钥失败:', error);
showAlert('获取失败', 'error');
}
} else {
input.type = 'password';
event.target.textContent = '显示';
// 重新加载掩码值
loadApiKeys();
}
}
async function saveApiKey(provider) {
const input = document.getElementById(`api-key-${provider}`);
const value = input.value.trim();
if (!value) {
showAlert('请输入 API Key', 'error');
return;
}
try {
const response = await fetch(`/api/admin/api-keys/${provider}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ value })
});
if (response.ok) {
showAlert('API Key 已保存', 'success');
loadApiKeys();
} else {
const error = await response.json();
showAlert(error.detail || '保存失败', 'error');
}
} catch (error) {
console.error('保存 API 密钥失败:', error);
showAlert('保存失败', 'error');
}
}
async function deleteApiKey(provider) {
if (!confirm('确定要删除此 API Key 吗?删除后将回退到使用环境变量配置。')) {
return;
}
try {
const response = await fetch(`/api/admin/api-keys/${provider}`, {
method: 'DELETE',
credentials: 'same-origin'
});
if (response.ok) {
showAlert('API Key 已删除', 'success');
loadApiKeys();
} else {
const error = await response.json();
showAlert(error.detail || '删除失败', 'error');
}
} catch (error) {
console.error('删除 API 密钥失败:', error);
showAlert('删除失败', 'error');
}
}
// 退出登录
async function logoutAccount() {
if (!confirm('确定要退出登录吗?')) {
return;
}
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (error) {
console.error('登出失败:', error);
}
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
// 页面加载
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
loadProfile();
loadAccountInfo();
// BMI 实时计算
document.getElementById('profile-height').addEventListener('input', calculateBMI);
document.getElementById('profile-weight').addEventListener('input', calculateBMI);
});
// 移动端更多菜单
function toggleMoreMenu() {
const menu = document.getElementById('more-menu');
menu.classList.toggle('hidden');
}
// 点击其他地方关闭菜单
document.addEventListener('click', function(e) {
if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) {
const menu = document.getElementById('more-menu');
if (menu) menu.classList.add('hidden');
}
});
</script>
<!-- 移动端底部导航 -->
<nav class="mobile-nav">
<a href="/" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span class="nav-label">首页</span>
</a>
<a href="/exercise" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
<span class="nav-label">运动</span>
</a>
<a href="/meal" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/>
</svg>
<span class="nav-label">饮食</span>
</a>
<a href="/sleep" class="mobile-nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<span class="nav-label">睡眠</span>
</a>
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
</svg>
<span class="nav-label">更多</span>
</div>
</nav>
<div id="more-menu" class="more-menu hidden">
<a href="/weight" class="more-menu-item">体重</a>
<a href="/reading" class="more-menu-item">阅读</a>
<a href="/report" class="more-menu-item">报告</a>
<a href="/settings" class="more-menu-item" style="color: #3B82F6; font-weight: 600;">设置</a>
</div>
</body>
</html>
"""
def _export_report_file(report, format: str, prefix: str, exporter):
"""导出报告并返回下载响应"""
fmt = format.lower().strip(".")
if fmt not in {"pdf", "png"}:
raise HTTPException(status_code=400, detail="不支持的格式")
reports_dir = Path.home() / ".vitals" / "reports"
reports_dir.mkdir(parents=True, exist_ok=True)
filename = f"{prefix}_{uuid4().hex}.{fmt}"
output_path = reports_dir / filename
ok = exporter(report, output_path)
if not ok:
raise HTTPException(status_code=400, detail="导出失败")
media_types = {
"pdf": "application/pdf",
"png": "image/png",
}
return FileResponse(
output_path,
media_type=media_types[fmt],
filename=filename,
)
def run_server(host: str = "0.0.0.0", port: int = 8080):
"""启动 Web 服务器"""
import uvicorn
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
run_server()