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>
8898 lines
324 KiB
Python
8898 lines
324 KiB
Python
"""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="关闭">×</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="关闭">×</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()">×</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()">×</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()
|