2140 lines
65 KiB
Markdown
2140 lines
65 KiB
Markdown
# 设置页面实现计划
|
||
|
||
> **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. **导航更新**
|
||
- 所有页面增加「设置」入口
|