Files
DDUp/docs/plans/2026-01-19-settings-page-implementation.md
2026-01-22 12:57:26 +08:00

65 KiB
Raw Blame History

设置页面实现计划

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.pyinit_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/weight
  • POST /api/exercise
  • POST /api/meal
  • POST /api/sleep
  • POST /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: 手动功能测试

  1. 启动应用:python -m uvicorn src.vitals.web.app:app --reload
  2. 访问 http://localhost:8000/settings
  3. 测试用户管理:
    • 创建新用户
    • 切换用户
    • 编辑用户名
    • 删除用户
  4. 测试体重录入
  5. 测试数据清除(先预览,再执行)
  6. 确认各页面导航正常

Step 3: 提交最终版本

git add .
git commit -m "feat: complete settings page implementation with user and system management"

完成总结

实现完成后,应用将具备:

  1. 用户管理

    • 创建/编辑/删除用户档案
    • 切换当前用户
    • 数据自动按用户隔离
  2. 系统管理

    • 体重快速录入
    • 数据清除(按时间/类型/全部)
    • 删除前预览和确认
  3. 导航更新

    • 所有页面增加「设置」入口