65 KiB
设置页面实现计划
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 为 Vitals 应用添加设置页面,包含用户管理(档案切换)和系统管理(体重录入、数据清除)功能
Architecture: 新增 User 模型和 users 表,为现有数据表添加 user_id 字段实现多用户隔离。通过 Cookie 存储当前用户 ID,所有 API 自动根据用户过滤数据。
Tech Stack: FastAPI, SQLite, Pydantic, Vanilla JavaScript
Task 1: 新增 User 模型
Files:
- Modify:
src/vitals/core/models.py - Test:
tests/test_models.py
Step 1: 编写 User 模型测试
在 tests/test_models.py 末尾添加:
class TestUser:
"""用户模型测试"""
def test_user_creation(self):
"""测试创建用户"""
from datetime import datetime
from src.vitals.core.models import User
user = User(
name="小明",
created_at=datetime.now(),
)
assert user.name == "小明"
assert user.is_active == False
assert user.id is None
def test_user_to_dict(self):
"""测试用户转换为字典"""
from datetime import datetime
from src.vitals.core.models import User
user = User(
id=1,
name="小红",
created_at=datetime(2026, 1, 19, 10, 0, 0),
is_active=True,
)
d = user.to_dict()
assert d["id"] == 1
assert d["name"] == "小红"
assert d["is_active"] == True
assert "created_at" in d
Step 2: 运行测试确认失败
pytest tests/test_models.py::TestUser -v
Expected: FAIL - ImportError: cannot import name 'User'
Step 3: 实现 User 模型
在 src/vitals/core/models.py 末尾添加:
@dataclass
class User:
"""用户档案"""
id: Optional[int] = None
name: str = ""
created_at: datetime = field(default_factory=datetime.now)
is_active: bool = False
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"created_at": self.created_at.isoformat(),
"is_active": self.is_active,
}
同时在文件顶部的导入中添加 datetime:
from datetime import date, time, datetime
Step 4: 运行测试确认通过
pytest tests/test_models.py::TestUser -v
Expected: PASS
Step 5: 提交
git add src/vitals/core/models.py tests/test_models.py
git commit -m "feat: add User model for multi-user support"
Task 2: 新增 DataClearRequest 模型
Files:
- Modify:
src/vitals/core/models.py - Test:
tests/test_models.py
Step 1: 编写 DataClearRequest 测试
在 tests/test_models.py 末尾添加:
class TestDataClearRequest:
"""数据清除请求模型测试"""
def test_clear_by_range(self):
"""测试按时间范围清除"""
from src.vitals.core.models import DataClearRequest
req = DataClearRequest(
user_id=1,
mode="range",
date_from=date(2026, 1, 1),
date_to=date(2026, 1, 15),
)
assert req.mode == "range"
assert req.date_from == date(2026, 1, 1)
def test_clear_by_type(self):
"""测试按类型清除"""
from src.vitals.core.models import DataClearRequest
req = DataClearRequest(
user_id=1,
mode="type",
data_types=["exercise", "meal"],
)
assert req.mode == "type"
assert "exercise" in req.data_types
def test_clear_all(self):
"""测试清除全部"""
from src.vitals.core.models import DataClearRequest
req = DataClearRequest(
user_id=1,
mode="all",
)
assert req.mode == "all"
Step 2: 运行测试确认失败
pytest tests/test_models.py::TestDataClearRequest -v
Expected: FAIL
Step 3: 实现 DataClearRequest 模型
在 src/vitals/core/models.py 末尾添加:
@dataclass
class DataClearRequest:
"""数据清除请求"""
user_id: int = 0
mode: str = "all" # "range" | "type" | "all"
date_from: Optional[date] = None
date_to: Optional[date] = None
data_types: Optional[list] = None # ["exercise", "meal", "sleep", "weight"]
Step 4: 运行测试确认通过
pytest tests/test_models.py::TestDataClearRequest -v
Expected: PASS
Step 5: 提交
git add src/vitals/core/models.py tests/test_models.py
git commit -m "feat: add DataClearRequest model"
Task 3: 数据库 - 创建 users 表
Files:
- Modify:
src/vitals/core/database.py - Test:
tests/test_database.py
Step 1: 编写 users 表测试
在 tests/test_database.py 末尾添加:
class TestUserDB:
"""用户数据库测试"""
def test_add_user(self):
"""测试添加用户"""
from src.vitals.core.models import User
user = User(name="测试用户")
user_id = db.add_user(user)
assert user_id > 0
def test_get_users(self):
"""测试获取用户列表"""
from src.vitals.core.models import User
db.add_user(User(name="用户1"))
db.add_user(User(name="用户2"))
users = db.get_users()
assert len(users) == 2
def test_get_user_by_id(self):
"""测试按 ID 获取用户"""
from src.vitals.core.models import User
user = User(name="小明")
user_id = db.add_user(user)
fetched = db.get_user(user_id)
assert fetched is not None
assert fetched.name == "小明"
def test_update_user(self):
"""测试更新用户"""
from src.vitals.core.models import User
user = User(name="原名")
user_id = db.add_user(user)
user.id = user_id
user.name = "新名"
db.update_user(user)
fetched = db.get_user(user_id)
assert fetched.name == "新名"
def test_delete_user(self):
"""测试删除用户"""
from src.vitals.core.models import User
user_id = db.add_user(User(name="待删除"))
db.delete_user(user_id)
fetched = db.get_user(user_id)
assert fetched is None
def test_set_active_user(self):
"""测试设置激活用户"""
from src.vitals.core.models import User
id1 = db.add_user(User(name="用户1"))
id2 = db.add_user(User(name="用户2"))
db.set_active_user(id1)
user1 = db.get_user(id1)
user2 = db.get_user(id2)
assert user1.is_active == True
assert user2.is_active == False
# 切换激活用户
db.set_active_user(id2)
user1 = db.get_user(id1)
user2 = db.get_user(id2)
assert user1.is_active == False
assert user2.is_active == True
def test_get_active_user(self):
"""测试获取激活用户"""
from src.vitals.core.models import User
id1 = db.add_user(User(name="用户1"))
db.set_active_user(id1)
active = db.get_active_user()
assert active is not None
assert active.id == id1
Step 2: 运行测试确认失败
pytest tests/test_database.py::TestUserDB -v
Expected: FAIL
Step 3: 在 init_db() 中添加 users 表
在 src/vitals/core/database.py 的 init_db() 函数中,在 config 表创建后添加:
# 用户表
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
is_active INTEGER DEFAULT 0
)
""")
Step 4: 实现用户 CRUD 函数
在 src/vitals/core/database.py 末尾添加:
# ===== 用户管理 =====
def add_user(user: User) -> int:
"""添加用户"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO users (name, created_at, is_active)
VALUES (?, ?, ?)
""", (
user.name,
user.created_at.isoformat(),
1 if user.is_active else 0,
))
return cursor.lastrowid
def get_users() -> list[User]:
"""获取所有用户"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users ORDER BY id")
return [
User(
id=row["id"],
name=row["name"],
created_at=datetime.fromisoformat(row["created_at"]),
is_active=bool(row["is_active"]),
)
for row in cursor.fetchall()
]
def get_user(user_id: int) -> Optional[User]:
"""按 ID 获取用户"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
row = cursor.fetchone()
if row:
return User(
id=row["id"],
name=row["name"],
created_at=datetime.fromisoformat(row["created_at"]),
is_active=bool(row["is_active"]),
)
return None
def update_user(user: User):
"""更新用户"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE users SET name = ?, is_active = ?
WHERE id = ?
""", (user.name, 1 if user.is_active else 0, user.id))
def delete_user(user_id: int):
"""删除用户"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
def set_active_user(user_id: int):
"""设置激活用户(同时取消其他用户的激活状态)"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_active = 0")
cursor.execute("UPDATE users SET is_active = 1 WHERE id = ?", (user_id,))
def get_active_user() -> Optional[User]:
"""获取当前激活的用户"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE is_active = 1")
row = cursor.fetchone()
if row:
return User(
id=row["id"],
name=row["name"],
created_at=datetime.fromisoformat(row["created_at"]),
is_active=bool(row["is_active"]),
)
return None
同时在文件顶部导入添加:
from .models import Exercise, Meal, Sleep, Weight, UserConfig, User
以及:
from datetime import date, time, datetime
Step 5: 运行测试确认通过
pytest tests/test_database.py::TestUserDB -v
Expected: PASS
Step 6: 提交
git add src/vitals/core/database.py tests/test_database.py
git commit -m "feat: add users table and CRUD functions"
Task 4: 数据库 - 添加 user_id 字段和迁移
Files:
- Modify:
src/vitals/core/database.py - Test:
tests/test_database.py
Step 1: 编写迁移测试
在 tests/test_database.py 末尾添加:
class TestUserIdMigration:
"""user_id 迁移测试"""
def test_ensure_default_user_creates_user(self):
"""测试 ensure_default_user 创建默认用户"""
db.ensure_default_user()
users = db.get_users()
assert len(users) >= 1
# 应有一个激活用户
active = db.get_active_user()
assert active is not None
def test_existing_data_gets_default_user_id(self):
"""测试现有数据关联到默认用户"""
from src.vitals.core.models import Exercise
# 先添加一条运动记录(无 user_id)
exercise = Exercise(
date=date(2026, 1, 18),
type="跑步",
duration=30,
calories=200,
)
db.add_exercise(exercise)
# 运行迁移
db.ensure_default_user()
# 获取默认用户的数据
active = db.get_active_user()
exercises = db.get_exercises(user_id=active.id)
assert len(exercises) >= 1
Step 2: 运行测试确认失败
pytest tests/test_database.py::TestUserIdMigration -v
Expected: FAIL
Step 3: 修改 init_db() 添加 user_id 字段
修改 src/vitals/core/database.py 中的 init_db() 函数,在各表定义中添加 user_id 字段:
def init_db():
"""初始化数据库表"""
with get_connection() as conn:
cursor = conn.cursor()
# 用户表
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
is_active INTEGER DEFAULT 0
)
""")
# 运动记录表
cursor.execute("""
CREATE TABLE IF NOT EXISTS exercise (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER DEFAULT 1,
date DATE NOT NULL,
type TEXT NOT NULL,
duration INTEGER NOT NULL,
calories INTEGER DEFAULT 0,
distance REAL,
heart_rate_avg INTEGER,
source TEXT DEFAULT '手动',
raw_data TEXT,
notes TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
# 饮食记录表
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER DEFAULT 1,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
description TEXT,
calories INTEGER DEFAULT 0,
protein REAL,
carbs REAL,
fat REAL,
photo_path TEXT,
food_items TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
# 睡眠记录表
cursor.execute("""
CREATE TABLE IF NOT EXISTS sleep (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER DEFAULT 1,
date DATE NOT NULL,
bedtime TEXT,
wake_time TEXT,
duration REAL NOT NULL,
quality INTEGER DEFAULT 3,
deep_sleep_mins INTEGER,
source TEXT DEFAULT '手动',
notes TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
# 体重记录表
cursor.execute("""
CREATE TABLE IF NOT EXISTS weight (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER DEFAULT 1,
date DATE NOT NULL,
weight_kg REAL NOT NULL,
body_fat_pct REAL,
muscle_mass REAL,
notes TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
# 用户配置表
cursor.execute("""
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER DEFAULT 1,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id)
)
""")
# 创建索引
cursor.execute("CREATE INDEX IF NOT EXISTS idx_exercise_date ON exercise(date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_exercise_user ON exercise(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_meal_date ON meal(date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_meal_user ON meal(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sleep_date ON sleep(date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sleep_user ON sleep(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_weight_date ON weight(date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_weight_user ON weight(user_id)")
Step 4: 实现 ensure_default_user() 迁移函数
在 src/vitals/core/database.py 末尾添加:
def ensure_default_user():
"""确保存在默认用户,并将无 user_id 的数据关联到默认用户"""
with get_connection() as conn:
cursor = conn.cursor()
# 检查是否已有用户
cursor.execute("SELECT COUNT(*) as count FROM users")
count = cursor.fetchone()["count"]
if count == 0:
# 创建默认用户
cursor.execute("""
INSERT INTO users (name, created_at, is_active)
VALUES (?, ?, 1)
""", ("默认用户", datetime.now().isoformat()))
# 获取激活用户(如果没有则设置第一个用户为激活)
cursor.execute("SELECT id FROM users WHERE is_active = 1")
active = cursor.fetchone()
if not active:
cursor.execute("SELECT id FROM users ORDER BY id LIMIT 1")
first = cursor.fetchone()
if first:
cursor.execute("UPDATE users SET is_active = 1 WHERE id = ?", (first["id"],))
active = first
if active:
default_user_id = active["id"]
# 迁移现有数据(将 user_id 为 NULL 或不存在的记录关联到默认用户)
for table in ["exercise", "meal", "sleep", "weight"]:
# 检查表是否有 user_id 列
cursor.execute(f"PRAGMA table_info({table})")
columns = [col["name"] for col in cursor.fetchall()]
if "user_id" not in columns:
# 添加 user_id 列
cursor.execute(f"ALTER TABLE {table} ADD COLUMN user_id INTEGER DEFAULT 1")
# 更新 NULL 的 user_id
cursor.execute(f"UPDATE {table} SET user_id = ? WHERE user_id IS NULL", (default_user_id,))
Step 5: 修改现有查询函数支持 user_id
修改 get_exercises 函数:
def get_exercises(start_date: Optional[date] = None, end_date: Optional[date] = None, user_id: Optional[int] = None) -> list[Exercise]:
"""查询运动记录"""
with get_connection() as conn:
cursor = conn.cursor()
query = "SELECT * FROM exercise WHERE 1=1"
params = []
if user_id:
query += " AND user_id = ?"
params.append(user_id)
if start_date:
query += " AND date >= ?"
params.append(start_date.isoformat())
if end_date:
query += " AND date <= ?"
params.append(end_date.isoformat())
query += " ORDER BY date DESC"
cursor.execute(query, params)
return [
Exercise(
id=row["id"],
date=date.fromisoformat(row["date"]),
type=row["type"],
duration=row["duration"],
calories=row["calories"],
distance=row["distance"],
heart_rate_avg=row["heart_rate_avg"],
source=row["source"],
raw_data=json.loads(row["raw_data"]) if row["raw_data"] else None,
notes=row["notes"],
)
for row in cursor.fetchall()
]
类似地修改 get_meals, get_sleep_records, get_weight_records 函数,添加 user_id 参数。
同时修改 add_exercise, add_meal, add_sleep, add_weight 函数,添加 user_id 参数:
def add_exercise(exercise: Exercise, user_id: int = 1) -> int:
"""添加运动记录"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO exercise (user_id, date, type, duration, calories, distance, heart_rate_avg, source, raw_data, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user_id,
exercise.date.isoformat(),
exercise.type,
exercise.duration,
exercise.calories,
exercise.distance,
exercise.heart_rate_avg,
exercise.source,
json.dumps(exercise.raw_data) if exercise.raw_data else None,
exercise.notes,
))
return cursor.lastrowid
Step 6: 运行测试确认通过
pytest tests/test_database.py::TestUserIdMigration -v
Expected: PASS
Step 7: 运行所有数据库测试确保没有破坏现有功能
pytest tests/test_database.py -v
Expected: ALL PASS
Step 8: 提交
git add src/vitals/core/database.py tests/test_database.py
git commit -m "feat: add user_id to data tables with migration support"
Task 5: 数据库 - 数据清除函数
Files:
- Modify:
src/vitals/core/database.py - Test:
tests/test_database.py
Step 1: 编写数据清除测试
在 tests/test_database.py 末尾添加:
class TestDataClear:
"""数据清除测试"""
def test_preview_delete_all(self):
"""测试预览删除全部"""
from src.vitals.core.models import Exercise, Meal, User
# 创建用户和数据
user_id = db.add_user(User(name="测试用户"))
db.add_exercise(Exercise(date=date(2026, 1, 10), type="跑步", duration=30, calories=200), user_id)
db.add_exercise(Exercise(date=date(2026, 1, 11), type="游泳", duration=45, calories=300), user_id)
db.add_meal(Meal(date=date(2026, 1, 10), meal_type="午餐", description="米饭", calories=500), user_id)
counts = db.preview_delete(user_id, mode="all")
assert counts["exercise"] == 2
assert counts["meal"] == 1
assert counts["sleep"] == 0
assert counts["weight"] == 0
assert counts["total"] == 3
def test_preview_delete_by_range(self):
"""测试预览按时间范围删除"""
from src.vitals.core.models import Exercise, User
user_id = db.add_user(User(name="测试用户"))
db.add_exercise(Exercise(date=date(2026, 1, 5), type="跑步", duration=30, calories=200), user_id)
db.add_exercise(Exercise(date=date(2026, 1, 10), type="跑步", duration=30, calories=200), user_id)
db.add_exercise(Exercise(date=date(2026, 1, 15), type="跑步", duration=30, calories=200), user_id)
counts = db.preview_delete(user_id, mode="range", date_from=date(2026, 1, 8), date_to=date(2026, 1, 12))
assert counts["exercise"] == 1
assert counts["total"] == 1
def test_preview_delete_by_type(self):
"""测试预览按类型删除"""
from src.vitals.core.models import Exercise, Meal, User
user_id = db.add_user(User(name="测试用户"))
db.add_exercise(Exercise(date=date(2026, 1, 10), type="跑步", duration=30, calories=200), user_id)
db.add_meal(Meal(date=date(2026, 1, 10), meal_type="午餐", description="米饭", calories=500), user_id)
counts = db.preview_delete(user_id, mode="type", data_types=["exercise"])
assert counts["exercise"] == 1
assert counts["meal"] == 0
assert counts["total"] == 1
def test_clear_data_all(self):
"""测试清除全部数据"""
from src.vitals.core.models import Exercise, Meal, User
user_id = db.add_user(User(name="测试用户"))
db.add_exercise(Exercise(date=date(2026, 1, 10), type="跑步", duration=30, calories=200), user_id)
db.add_meal(Meal(date=date(2026, 1, 10), meal_type="午餐", description="米饭", calories=500), user_id)
db.clear_data(user_id, mode="all")
assert len(db.get_exercises(user_id=user_id)) == 0
assert len(db.get_meals(user_id=user_id)) == 0
def test_clear_data_by_range(self):
"""测试按时间范围清除"""
from src.vitals.core.models import Exercise, User
user_id = db.add_user(User(name="测试用户"))
db.add_exercise(Exercise(date=date(2026, 1, 5), type="跑步", duration=30, calories=200), user_id)
db.add_exercise(Exercise(date=date(2026, 1, 10), type="跑步", duration=30, calories=200), user_id)
db.add_exercise(Exercise(date=date(2026, 1, 15), type="跑步", duration=30, calories=200), user_id)
db.clear_data(user_id, mode="range", date_from=date(2026, 1, 8), date_to=date(2026, 1, 12))
exercises = db.get_exercises(user_id=user_id)
assert len(exercises) == 2
dates = [e.date for e in exercises]
assert date(2026, 1, 10) not in dates
def test_clear_data_by_type(self):
"""测试按类型清除"""
from src.vitals.core.models import Exercise, Meal, User
user_id = db.add_user(User(name="测试用户"))
db.add_exercise(Exercise(date=date(2026, 1, 10), type="跑步", duration=30, calories=200), user_id)
db.add_meal(Meal(date=date(2026, 1, 10), meal_type="午餐", description="米饭", calories=500), user_id)
db.clear_data(user_id, mode="type", data_types=["exercise"])
assert len(db.get_exercises(user_id=user_id)) == 0
assert len(db.get_meals(user_id=user_id)) == 1
Step 2: 运行测试确认失败
pytest tests/test_database.py::TestDataClear -v
Expected: FAIL
Step 3: 实现 preview_delete 和 clear_data 函数
在 src/vitals/core/database.py 末尾添加:
def preview_delete(
user_id: int,
mode: str = "all",
date_from: Optional[date] = None,
date_to: Optional[date] = None,
data_types: Optional[list] = None,
) -> dict:
"""预览将删除的数据量"""
tables = ["exercise", "meal", "sleep", "weight"]
if mode == "type" and data_types:
tables = [t for t in tables if t in data_types]
counts = {}
total = 0
with get_connection() as conn:
cursor = conn.cursor()
for table in ["exercise", "meal", "sleep", "weight"]:
if mode == "type" and data_types and table not in data_types:
counts[table] = 0
continue
query = f"SELECT COUNT(*) as count FROM {table} WHERE user_id = ?"
params = [user_id]
if mode == "range" and date_from and date_to:
query += " AND date >= ? AND date <= ?"
params.extend([date_from.isoformat(), date_to.isoformat()])
cursor.execute(query, params)
count = cursor.fetchone()["count"]
counts[table] = count
total += count
counts["total"] = total
return counts
def clear_data(
user_id: int,
mode: str = "all",
date_from: Optional[date] = None,
date_to: Optional[date] = None,
data_types: Optional[list] = None,
):
"""清除数据"""
tables = ["exercise", "meal", "sleep", "weight"]
if mode == "type" and data_types:
tables = [t for t in tables if t in data_types]
with get_connection() as conn:
cursor = conn.cursor()
for table in tables:
query = f"DELETE FROM {table} WHERE user_id = ?"
params = [user_id]
if mode == "range" and date_from and date_to:
query += " AND date >= ? AND date <= ?"
params.extend([date_from.isoformat(), date_to.isoformat()])
cursor.execute(query, params)
Step 4: 运行测试确认通过
pytest tests/test_database.py::TestDataClear -v
Expected: PASS
Step 5: 提交
git add src/vitals/core/database.py tests/test_database.py
git commit -m "feat: add data preview and clear functions"
Task 6: 用户管理 API
Files:
- Modify:
src/vitals/web/app.py - Test:
tests/test_web.py
Step 1: 添加用户相关 Pydantic 模型
在 src/vitals/web/app.py 的 Pydantic 模型部分添加:
class UserResponse(BaseModel):
id: int
name: str
created_at: str
is_active: bool
class UserInput(BaseModel):
name: str
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if not value or len(value.strip()) == 0:
raise ValueError("用户名不能为空")
if len(value) > 20:
raise ValueError("用户名不能超过 20 个字符")
return value.strip()
Step 2: 实现用户管理 API 端点
在 src/vitals/web/app.py 的 API 路由部分添加:
# ===== 用户管理 API =====
@app.get("/api/users")
async def get_users():
"""获取所有用户"""
db.ensure_default_user()
users = db.get_users()
return [UserResponse(
id=u.id,
name=u.name,
created_at=u.created_at.isoformat(),
is_active=u.is_active,
) for u in users]
@app.get("/api/users/current")
async def get_current_user():
"""获取当前激活用户"""
db.ensure_default_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,
)
@app.post("/api/users")
async def create_user(user_input: UserInput):
"""创建新用户"""
from ..core.models import User
user = User(name=user_input.name)
user_id = db.add_user(user)
created = db.get_user(user_id)
return UserResponse(
id=created.id,
name=created.name,
created_at=created.created_at.isoformat(),
is_active=created.is_active,
)
@app.put("/api/users/{user_id}")
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
db.update_user(user)
return UserResponse(
id=user.id,
name=user.name,
created_at=user.created_at.isoformat(),
is_active=user.is_active,
)
@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="用户不存在")
# 检查是否是最后一个用户
users = db.get_users()
if len(users) <= 1:
raise HTTPException(status_code=400, detail="不能删除最后一个用户")
# 删除用户数据
db.clear_data(user_id, mode="all")
# 删除用户
db.delete_user(user_id)
# 如果删除的是激活用户,激活第一个用户
if user.is_active:
remaining = db.get_users()
if remaining:
db.set_active_user(remaining[0].id)
return {"message": "用户已删除"}
@app.post("/api/users/{user_id}/switch")
async def switch_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 UserResponse(
id=user.id,
name=user.name,
created_at=user.created_at.isoformat(),
is_active=True,
)
Step 3: 在 models.py 导入中添加 User
确保 src/vitals/web/app.py 顶部导入包含 User:
from ..core.models import Exercise, Meal, Sleep, UserConfig, Weight, User
Step 4: 运行测试
pytest tests/test_web.py -v
Expected: PASS
Step 5: 提交
git add src/vitals/web/app.py
git commit -m "feat: add user management API endpoints"
Task 7: 数据清除 API
Files:
- Modify:
src/vitals/web/app.py
Step 1: 添加数据清除 Pydantic 模型
在 src/vitals/web/app.py 的 Pydantic 模型部分添加:
class DataClearInput(BaseModel):
user_id: int
mode: str # "range" | "type" | "all"
date_from: Optional[str] = None
date_to: Optional[str] = None
data_types: Optional[list[str]] = None
confirm_text: str
@field_validator("mode")
@classmethod
def validate_mode(cls, value: str) -> str:
if value not in ["range", "type", "all"]:
raise ValueError("无效的清除模式")
return value
@field_validator("confirm_text")
@classmethod
def validate_confirm(cls, value: str) -> str:
if value != "确认删除":
raise ValueError("确认文字不正确")
return value
Step 2: 实现数据清除 API 端点
# ===== 数据清除 API =====
@app.post("/api/data/preview-delete")
async def preview_delete(
user_id: int = Query(...),
mode: str = Query("all"),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
data_types: Optional[str] = Query(None),
):
"""预览将删除的数据量"""
from datetime import date as date_type
df = date_type.fromisoformat(date_from) if date_from else None
dt = date_type.fromisoformat(date_to) if date_to else None
types = data_types.split(",") if data_types else None
counts = db.preview_delete(
user_id=user_id,
mode=mode,
date_from=df,
date_to=dt,
data_types=types,
)
return counts
@app.delete("/api/data/clear")
async def clear_data(clear_input: DataClearInput):
"""执行数据清除"""
from datetime import date as date_type
df = date_type.fromisoformat(clear_input.date_from) if clear_input.date_from else None
dt = date_type.fromisoformat(clear_input.date_to) if clear_input.date_to else None
# 预览并返回删除数量
counts = db.preview_delete(
user_id=clear_input.user_id,
mode=clear_input.mode,
date_from=df,
date_to=dt,
data_types=clear_input.data_types,
)
# 执行删除
db.clear_data(
user_id=clear_input.user_id,
mode=clear_input.mode,
date_from=df,
date_to=dt,
data_types=clear_input.data_types,
)
return {"message": "数据已清除", "deleted": counts}
Step 3: 运行测试
pytest tests/test_web.py -v
Expected: PASS
Step 4: 提交
git add src/vitals/web/app.py
git commit -m "feat: add data clear API endpoints"
Task 8: 修改现有 API 支持多用户
Files:
- Modify:
src/vitals/web/app.py
Step 1: 创建获取当前用户 ID 的辅助函数
在 src/vitals/web/app.py 添加辅助函数:
def get_current_user_id() -> int:
"""获取当前激活用户的 ID"""
db.ensure_default_user()
user = db.get_active_user()
return user.id if user else 1
Step 2: 修改现有 API 端点使用 user_id
修改 /api/today 端点:
@app.get("/api/today")
async def get_today_summary():
"""获取今日摘要"""
user_id = get_current_user_id()
today = date.today()
meals = db.get_meals(start_date=today, end_date=today, user_id=user_id)
# ... 其余代码使用 user_id 参数
类似地修改以下端点,添加 user_id=user_id 参数:
/api/week/api/exercises/api/meals/api/sleep/api/weightPOST /api/exercisePOST /api/mealPOST /api/sleepPOST /api/weight
Step 3: 运行所有测试
pytest -v
Expected: ALL PASS
Step 4: 提交
git add src/vitals/web/app.py
git commit -m "feat: update existing APIs to support multi-user"
Task 9: 设置页面 - 路由和基础 HTML
Files:
- Modify:
src/vitals/web/app.py
Step 1: 添加设置页面路由
@app.get("/settings")
async def settings_page():
"""设置页面"""
return HTMLResponse(content=get_settings_page_html(), status_code=200)
Step 2: 实现设置页面 HTML 生成函数
在 src/vitals/web/app.py 末尾添加 get_settings_page_html() 函数。由于代码较长,这里分为几个部分:
CSS 样式部分:
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>
<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, #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;
}
.tabs {
display: flex;
gap: 0;
margin-bottom: 20px;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.tab-btn {
flex: 1;
padding: 16px 24px;
border: none;
background: white;
color: #666;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: #667eea;
color: white;
}
.tab-btn:hover:not(.active) {
background: #f0f0f0;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.card h3 {
font-size: 1.1rem;
margin-bottom: 16px;
color: #333;
}
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}
.user-card {
background: #f8f8f8;
border-radius: 12px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.user-card:hover {
background: #f0f0f0;
}
.user-card.active {
border-color: #667eea;
background: #f0f4ff;
}
.user-card .avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #667eea;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin: 0 auto 12px;
}
.user-card .name {
font-weight: 600;
margin-bottom: 8px;
}
.user-card .actions {
display: flex;
gap: 8px;
justify-content: center;
}
.user-card .actions button {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.btn-switch {
background: #667eea;
color: white;
}
.btn-edit {
background: #e0e0e0;
color: #333;
}
.add-user-card {
background: #f8f8f8;
border: 2px dashed #ddd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 150px;
}
.add-user-card:hover {
border-color: #667eea;
background: #f0f4ff;
}
.add-user-card .plus {
font-size: 2rem;
color: #999;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #555;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
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;
}
.warning-box {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
color: #92400e;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: #f8f8f8;
border-radius: 8px;
cursor: pointer;
}
.radio-option:hover {
background: #f0f0f0;
}
.radio-option input[type="radio"] {
margin-top: 4px;
}
.radio-option .option-content {
flex: 1;
}
.radio-option .option-title {
font-weight: 600;
margin-bottom: 4px;
}
.radio-option .option-desc {
font-size: 0.9rem;
color: #666;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f8f8f8;
border-radius: 6px;
cursor: pointer;
}
.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;
}
.current-user-badge {
display: inline-block;
background: #667eea;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
margin-left: 8px;
}
</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="/report">报告</a>
<a class="active" href="/settings">设置</a>
</div>
<header>
<h1>设置</h1>
<p>管理用户和系统配置</p>
</header>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('users')">👤 用户管理</button>
<button class="tab-btn" onclick="switchTab('system')">🔧 系统管理</button>
</div>
<!-- 用户管理标签页 -->
<div id="tab-users" class="tab-content active">
<div class="card">
<h3>当前用户: <span id="current-user-name">加载中...</span></h3>
</div>
<div class="card">
<h3>用户列表</h3>
<div id="user-grid" class="user-grid">
<!-- 用户卡片将通过 JS 动态生成 -->
</div>
</div>
</div>
<!-- 系统管理标签页 -->
<div id="tab-system" class="tab-content">
<div class="card">
<h3>体重录入</h3>
<p style="margin-bottom: 16px; color: #666;">当前用户: <span id="weight-current-user">加载中...</span></p>
<form id="weight-form">
<div class="form-row">
<div class="form-group">
<label>日期</label>
<input type="date" id="weight-date" required>
</div>
<div class="form-group">
<label>体重 (kg)</label>
<input type="number" id="weight-kg" step="0.1" min="20" max="300" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>体脂率 % (选填)</label>
<input type="number" id="weight-fat" step="0.1" min="1" max="60">
</div>
<div class="form-group">
<label>备注 (选填)</label>
<input type="text" id="weight-notes" placeholder="例如:早餐前测量">
</div>
</div>
<button type="submit" class="btn btn-primary">录入体重</button>
</form>
</div>
<div class="card">
<h3>数据清除</h3>
<div class="warning-box">
⚠️ 警告:删除的数据无法恢复,请谨慎操作
</div>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="clear-mode" value="range">
<div class="option-content">
<div class="option-title">按时间范围</div>
<div class="option-desc">删除指定日期范围内的所有数据</div>
<div id="range-inputs" style="margin-top: 12px; display: none;">
<div class="form-row">
<div class="form-group">
<label>开始日期</label>
<input type="date" id="clear-date-from">
</div>
<div class="form-group">
<label>结束日期</label>
<input type="date" id="clear-date-to">
</div>
</div>
</div>
</div>
</label>
<label class="radio-option">
<input type="radio" name="clear-mode" value="type">
<div class="option-content">
<div class="option-title">按数据类型</div>
<div class="option-desc">删除指定类型的所有数据</div>
<div id="type-inputs" style="margin-top: 12px; display: none;">
<div class="checkbox-group">
<label><input type="checkbox" value="exercise"> 运动记录</label>
<label><input type="checkbox" value="meal"> 饮食记录</label>
<label><input type="checkbox" value="sleep"> 睡眠记录</label>
<label><input type="checkbox" value="weight"> 体重记录</label>
</div>
</div>
</div>
</label>
<label class="radio-option">
<input type="radio" name="clear-mode" value="all">
<div class="option-content">
<div class="option-title">清除全部数据</div>
<div class="option-desc">删除当前用户的所有健康数据</div>
</div>
</label>
</div>
<div style="margin-top: 20px;">
<button class="btn btn-danger" onclick="previewDelete()">预览并清除数据</button>
</div>
</div>
</div>
</div>
<!-- 用户编辑弹窗 -->
<div id="user-modal" class="modal">
<div class="modal-content">
<div class="modal-header" id="modal-title">添加用户</div>
<form id="user-form">
<input type="hidden" id="edit-user-id">
<div class="form-group">
<label>用户名称</label>
<input type="text" id="edit-user-name" maxlength="20" required placeholder="例如:小明">
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeUserModal()">取消</button>
<button type="button" class="btn btn-danger" id="delete-user-btn" style="display: none;" onclick="deleteUser()">删除</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- 删除确认弹窗 -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">⚠️ 确认删除</div>
<div id="delete-preview">
<p>即将删除以下数据:</p>
<ul id="delete-details" style="margin: 16px 0; padding-left: 20px;"></ul>
<p style="color: #ef4444; font-weight: 600;">此操作不可撤销!</p>
</div>
<div class="form-group" style="margin-top: 16px;">
<label>请输入 "确认删除" 以继续:</label>
<input type="text" id="confirm-text" placeholder="确认删除">
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeDeleteModal()">取消</button>
<button type="button" class="btn btn-danger" onclick="executeDelete()">删除</button>
</div>
</div>
</div>
<script>
let currentUserId = null;
let deleteParams = null;
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
initWeightForm();
initClearModeToggle();
});
// 标签页切换
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.querySelector(`[onclick="switchTab('${tab}')"]`).classList.add('active');
document.getElementById(`tab-${tab}`).classList.add('active');
}
// 加载用户列表
async function loadUsers() {
try {
const response = await fetch('/api/users');
const users = await response.json();
const currentUser = users.find(u => u.is_active);
if (currentUser) {
currentUserId = currentUser.id;
document.getElementById('current-user-name').textContent = currentUser.name;
document.getElementById('weight-current-user').textContent = currentUser.name;
}
const grid = document.getElementById('user-grid');
grid.innerHTML = users.map(user => `
<div class="user-card ${user.is_active ? 'active' : ''}" data-id="${user.id}">
<div class="avatar">${user.name.charAt(0)}</div>
<div class="name">${user.name} ${user.is_active ? '✓' : ''}</div>
<div class="actions">
${!user.is_active ? `<button class="btn-switch" onclick="switchUser(${user.id})">切换</button>` : ''}
<button class="btn-edit" onclick="editUser(${user.id}, '${user.name}')">编辑</button>
</div>
</div>
`).join('') + `
<div class="user-card add-user-card" onclick="openAddUserModal()">
<div class="plus">+</div>
<div>添加用户</div>
</div>
`;
} catch (error) {
console.error('加载用户失败:', error);
}
}
// 切换用户
async function switchUser(userId) {
try {
await fetch(`/api/users/${userId}/switch`, { method: 'POST' });
loadUsers();
} catch (error) {
alert('切换用户失败: ' + error.message);
}
}
// 打开添加用户弹窗
function openAddUserModal() {
document.getElementById('modal-title').textContent = '添加用户';
document.getElementById('edit-user-id').value = '';
document.getElementById('edit-user-name').value = '';
document.getElementById('delete-user-btn').style.display = 'none';
document.getElementById('user-modal').classList.add('active');
}
// 编辑用户
function editUser(id, name) {
document.getElementById('modal-title').textContent = '编辑用户';
document.getElementById('edit-user-id').value = id;
document.getElementById('edit-user-name').value = name;
document.getElementById('delete-user-btn').style.display = 'block';
document.getElementById('user-modal').classList.add('active');
}
// 关闭用户弹窗
function closeUserModal() {
document.getElementById('user-modal').classList.remove('active');
}
// 保存用户
document.getElementById('user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('edit-user-id').value;
const name = document.getElementById('edit-user-name').value.trim();
try {
if (id) {
await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
} else {
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
}
closeUserModal();
loadUsers();
} catch (error) {
alert('保存失败: ' + error.message);
}
});
// 删除用户
async function deleteUser() {
const id = document.getElementById('edit-user-id').value;
if (!confirm('确定要删除此用户及其所有数据吗?')) return;
try {
const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail);
}
closeUserModal();
loadUsers();
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 初始化体重表单
function initWeightForm() {
document.getElementById('weight-date').value = new Date().toISOString().split('T')[0];
document.getElementById('weight-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
date: document.getElementById('weight-date').value,
weight_kg: parseFloat(document.getElementById('weight-kg').value),
body_fat_pct: document.getElementById('weight-fat').value ? parseFloat(document.getElementById('weight-fat').value) : null,
notes: document.getElementById('weight-notes').value || null
};
try {
const response = await fetch('/api/weight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
alert('体重录入成功!');
document.getElementById('weight-kg').value = '';
document.getElementById('weight-fat').value = '';
document.getElementById('weight-notes').value = '';
} else {
const err = await response.json();
alert('录入失败: ' + err.detail);
}
} catch (error) {
alert('录入失败: ' + error.message);
}
});
}
// 初始化清除模式切换
function initClearModeToggle() {
document.querySelectorAll('input[name="clear-mode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
document.getElementById('range-inputs').style.display = e.target.value === 'range' ? 'block' : 'none';
document.getElementById('type-inputs').style.display = e.target.value === 'type' ? 'block' : 'none';
});
});
}
// 预览删除
async function previewDelete() {
const mode = document.querySelector('input[name="clear-mode"]:checked')?.value;
if (!mode) {
alert('请选择清除方式');
return;
}
let params = new URLSearchParams({ user_id: currentUserId, mode });
if (mode === 'range') {
const dateFrom = document.getElementById('clear-date-from').value;
const dateTo = document.getElementById('clear-date-to').value;
if (!dateFrom || !dateTo) {
alert('请选择日期范围');
return;
}
params.append('date_from', dateFrom);
params.append('date_to', dateTo);
}
if (mode === 'type') {
const types = Array.from(document.querySelectorAll('#type-inputs input:checked')).map(cb => cb.value);
if (types.length === 0) {
alert('请选择至少一种数据类型');
return;
}
params.append('data_types', types.join(','));
}
try {
const response = await fetch(`/api/data/preview-delete?${params}`);
const counts = await response.json();
deleteParams = {
user_id: currentUserId,
mode,
date_from: mode === 'range' ? document.getElementById('clear-date-from').value : null,
date_to: mode === 'range' ? document.getElementById('clear-date-to').value : null,
data_types: mode === 'type' ? Array.from(document.querySelectorAll('#type-inputs input:checked')).map(cb => cb.value) : null,
};
const details = document.getElementById('delete-details');
details.innerHTML = `
<li>运动记录: ${counts.exercise} 条</li>
<li>饮食记录: ${counts.meal} 条</li>
<li>睡眠记录: ${counts.sleep} 条</li>
<li>体重记录: ${counts.weight} 条</li>
<li><strong>共计: ${counts.total} 条</strong></li>
`;
document.getElementById('confirm-text').value = '';
document.getElementById('delete-modal').classList.add('active');
} catch (error) {
alert('预览失败: ' + error.message);
}
}
// 关闭删除确认弹窗
function closeDeleteModal() {
document.getElementById('delete-modal').classList.remove('active');
deleteParams = null;
}
// 执行删除
async function executeDelete() {
const confirmText = document.getElementById('confirm-text').value;
if (confirmText !== '确认删除') {
alert('请输入正确的确认文字');
return;
}
try {
const response = await fetch('/api/data/clear', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...deleteParams,
confirm_text: '确认删除'
})
});
if (response.ok) {
const result = await response.json();
alert(`成功删除 ${result.deleted.total} 条数据`);
closeDeleteModal();
} else {
const err = await response.json();
alert('删除失败: ' + err.detail);
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
</script>
</body>
</html>
"""
Step 3: 运行测试
pytest tests/test_web.py -v
Expected: PASS
Step 4: 手动测试页面
cd /Users/rocky/Projects/vitals && python -m uvicorn src.vitals.web.app:app --reload
访问 http://localhost:8000/settings 确认页面正常显示。
Step 5: 提交
git add src/vitals/web/app.py
git commit -m "feat: add settings page with user and system management"
Task 10: 更新所有页面导航栏
Files:
- Modify:
src/vitals/web/app.py
Step 1: 在所有页面的导航栏中添加设置链接
搜索所有 <div class="nav"> 并在最后一个链接后添加:
<a href="/settings">设置</a>
需要修改的函数:
get_dashboard_html()- 第 1022-1029 行get_exercise_page_html()- 第 1423-1430 行get_meal_page_html()- 第 1923-1930 行get_sleep_page_html()- 第 2538-2545 行get_weight_page_html()- 第 2946-2953 行get_report_page_html()- 第 3211-3218 行
每个导航栏修改为:
<div class="nav">
<a href="/">首页</a>
<a href="/exercise">运动</a>
<a href="/meal">饮食</a>
<a href="/sleep">睡眠</a>
<a href="/weight">体重</a>
<a href="/report">报告</a>
<a href="/settings">设置</a>
</div>
Step 2: 手动测试导航
访问各页面确认导航栏显示正确。
Step 3: 运行所有测试
pytest -v
Expected: ALL PASS
Step 4: 提交
git add src/vitals/web/app.py
git commit -m "feat: add settings link to all page navigation bars"
Task 11: 最终集成测试
Step 1: 运行完整测试套件
pytest -v --tb=short
Expected: ALL PASS
Step 2: 手动功能测试
- 启动应用:
python -m uvicorn src.vitals.web.app:app --reload - 访问 http://localhost:8000/settings
- 测试用户管理:
- 创建新用户
- 切换用户
- 编辑用户名
- 删除用户
- 测试体重录入
- 测试数据清除(先预览,再执行)
- 确认各页面导航正常
Step 3: 提交最终版本
git add .
git commit -m "feat: complete settings page implementation with user and system management"
完成总结
实现完成后,应用将具备:
-
用户管理
- 创建/编辑/删除用户档案
- 切换当前用户
- 数据自动按用户隔离
-
系统管理
- 体重快速录入
- 数据清除(按时间/类型/全部)
- 删除前预览和确认
-
导航更新
- 所有页面增加「设置」入口