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

2140 lines
65 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 设置页面实现计划
> **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` 末尾添加:
```python
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: 运行测试确认失败**
```bash
pytest tests/test_models.py::TestUser -v
```
Expected: FAIL - `ImportError: cannot import name 'User'`
**Step 3: 实现 User 模型**
`src/vitals/core/models.py` 末尾添加:
```python
@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`
```python
from datetime import date, time, datetime
```
**Step 4: 运行测试确认通过**
```bash
pytest tests/test_models.py::TestUser -v
```
Expected: PASS
**Step 5: 提交**
```bash
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` 末尾添加:
```python
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: 运行测试确认失败**
```bash
pytest tests/test_models.py::TestDataClearRequest -v
```
Expected: FAIL
**Step 3: 实现 DataClearRequest 模型**
`src/vitals/core/models.py` 末尾添加:
```python
@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: 运行测试确认通过**
```bash
pytest tests/test_models.py::TestDataClearRequest -v
```
Expected: PASS
**Step 5: 提交**
```bash
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` 末尾添加:
```python
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: 运行测试确认失败**
```bash
pytest tests/test_database.py::TestUserDB -v
```
Expected: FAIL
**Step 3: 在 init_db() 中添加 users 表**
`src/vitals/core/database.py``init_db()` 函数中,在 `config` 表创建后添加:
```python
# 用户表
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` 末尾添加:
```python
# ===== 用户管理 =====
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
```
同时在文件顶部导入添加:
```python
from .models import Exercise, Meal, Sleep, Weight, UserConfig, User
```
以及:
```python
from datetime import date, time, datetime
```
**Step 5: 运行测试确认通过**
```bash
pytest tests/test_database.py::TestUserDB -v
```
Expected: PASS
**Step 6: 提交**
```bash
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` 末尾添加:
```python
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: 运行测试确认失败**
```bash
pytest tests/test_database.py::TestUserIdMigration -v
```
Expected: FAIL
**Step 3: 修改 init_db() 添加 user_id 字段**
修改 `src/vitals/core/database.py` 中的 `init_db()` 函数,在各表定义中添加 `user_id` 字段:
```python
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` 末尾添加:
```python
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` 函数:
```python
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` 参数:
```python
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: 运行测试确认通过**
```bash
pytest tests/test_database.py::TestUserIdMigration -v
```
Expected: PASS
**Step 7: 运行所有数据库测试确保没有破坏现有功能**
```bash
pytest tests/test_database.py -v
```
Expected: ALL PASS
**Step 8: 提交**
```bash
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` 末尾添加:
```python
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: 运行测试确认失败**
```bash
pytest tests/test_database.py::TestDataClear -v
```
Expected: FAIL
**Step 3: 实现 preview_delete 和 clear_data 函数**
`src/vitals/core/database.py` 末尾添加:
```python
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: 运行测试确认通过**
```bash
pytest tests/test_database.py::TestDataClear -v
```
Expected: PASS
**Step 5: 提交**
```bash
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 模型部分添加:
```python
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 路由部分添加:
```python
# ===== 用户管理 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
```python
from ..core.models import Exercise, Meal, Sleep, UserConfig, Weight, User
```
**Step 4: 运行测试**
```bash
pytest tests/test_web.py -v
```
Expected: PASS
**Step 5: 提交**
```bash
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 模型部分添加:
```python
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 端点**
```python
# ===== 数据清除 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: 运行测试**
```bash
pytest tests/test_web.py -v
```
Expected: PASS
**Step 4: 提交**
```bash
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` 添加辅助函数:
```python
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` 端点:
```python
@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: 运行所有测试**
```bash
pytest -v
```
Expected: ALL PASS
**Step 4: 提交**
```bash
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: 添加设置页面路由**
```python
@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 样式部分:**
```python
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: 运行测试**
```bash
pytest tests/test_web.py -v
```
Expected: PASS
**Step 4: 手动测试页面**
```bash
cd /Users/rocky/Projects/vitals && python -m uvicorn src.vitals.web.app:app --reload
```
访问 http://localhost:8000/settings 确认页面正常显示。
**Step 5: 提交**
```bash
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">` 并在最后一个链接后添加:
```html
<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 行
每个导航栏修改为:
```html
<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: 运行所有测试**
```bash
pytest -v
```
Expected: ALL PASS
**Step 4: 提交**
```bash
git add src/vitals/web/app.py
git commit -m "feat: add settings link to all page navigation bars"
```
---
## Task 11: 最终集成测试
**Step 1: 运行完整测试套件**
```bash
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: 提交最终版本**
```bash
git add .
git commit -m "feat: complete settings page implementation with user and system management"
```
---
## 完成总结
实现完成后,应用将具备:
1. **用户管理**
- 创建/编辑/删除用户档案
- 切换当前用户
- 数据自动按用户隔离
2. **系统管理**
- 体重快速录入
- 数据清除(按时间/类型/全部)
- 删除前预览和确认
3. **导航更新**
- 所有页面增加「设置」入口