diff --git a/docs/plans/2026-01-23-implementation-plan.md b/docs/plans/2026-01-23-implementation-plan.md new file mode 100644 index 0000000..b475c4a --- /dev/null +++ b/docs/plans/2026-01-23-implementation-plan.md @@ -0,0 +1,1035 @@ +# Vitals H5/MySQL/权限增强 实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将 Vitals 应用改造为 H5 移动端友好、MySQL 数据库、增强用户权限隔离 + +**Architecture:** +- 数据库层:mysql-connector-python 替换 sqlite3,保持裸 SQL 查询结构 +- API 层:添加用户权限过滤,非管理员仅能访问自己数据 +- UI 层:响应式 CSS 适配移动端,底部 Tab 导航 + +**Tech Stack:** Python 3.10+, FastAPI, mysql-connector-python, 原生 CSS 媒体查询 + +--- + +## 阶段 1: MySQL 数据库迁移 + +### Task 1.1: 添加 mysql-connector-python 依赖 + +**Files:** +- Modify: `pyproject.toml` + +**Step 1: 添加依赖** + +在 `pyproject.toml` 的 `dependencies` 列表中添加: + +```toml +"mysql-connector-python>=8.0.0", +``` + +**Step 2: 安装依赖** + +Run: `pip install -e .` +Expected: Successfully installed mysql-connector-python + +**Step 3: Commit** + +```bash +git add pyproject.toml +git commit -m "deps: 添加 mysql-connector-python 依赖" +``` + +--- + +### Task 1.2: 重构 database.py - 连接管理 + +**Files:** +- Modify: `src/vitals/core/database.py:1-40` + +**Step 1: 添加 MySQL 导入和配置** + +将文件开头改为: + +```python +"""数据库操作 - 支持 MySQL""" + +import json +import os +from contextlib import contextmanager +from datetime import date, time, datetime, timedelta +from pathlib import Path +from typing import Optional + +import mysql.connector +from mysql.connector import pooling + +from .models import Exercise, Meal, Sleep, Weight, UserConfig, User, Reading, Invite + + +# 数据库连接池(全局) +_connection_pool = None + + +def get_mysql_config() -> dict: + """获取 MySQL 配置""" + return { + "host": os.environ.get("MYSQL_HOST", "localhost"), + "port": int(os.environ.get("MYSQL_PORT", "3306")), + "user": os.environ.get("MYSQL_USER", "vitals"), + "password": os.environ.get("MYSQL_PASSWORD", ""), + "database": os.environ.get("MYSQL_DATABASE", "vitals"), + } + + +def init_connection_pool(): + """初始化数据库连接池""" + global _connection_pool + if _connection_pool is None: + config = get_mysql_config() + _connection_pool = pooling.MySQLConnectionPool( + pool_name="vitals_pool", + pool_size=5, + pool_reset_session=True, + **config + ) + return _connection_pool + + +@contextmanager +def get_connection(): + """获取数据库连接""" + pool = init_connection_pool() + conn = pool.get_connection() + try: + cursor = conn.cursor(dictionary=True) + yield conn, cursor + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cursor.close() + conn.close() +``` + +**Step 2: 验证语法** + +Run: `python -c "from vitals.core.database import get_connection; print('OK')"` +Expected: 语法正确(连接会失败因为还没有 MySQL 服务器,这是预期的) + +**Step 3: Commit** + +```bash +git add src/vitals/core/database.py +git commit -m "refactor: database.py 添加 MySQL 连接池管理" +``` + +--- + +### Task 1.3: 重构 database.py - 建表语句 + +**Files:** +- Modify: `src/vitals/core/database.py` - `init_db()` 函数 + +**Step 1: 改写 init_db 函数** + +```python +def init_db(): + """初始化数据库表""" + with get_connection() as (conn, cursor): + # 运动记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS exercise ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT DEFAULT 1, + date DATE NOT NULL, + type VARCHAR(100) NOT NULL, + duration INT NOT NULL, + calories INT DEFAULT 0, + distance FLOAT, + heart_rate_avg INT, + source VARCHAR(50) DEFAULT '手动', + raw_data TEXT, + notes TEXT, + INDEX idx_exercise_date (date) + ) + """) + + # 饮食记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS meal ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT DEFAULT 1, + date DATE NOT NULL, + meal_type VARCHAR(50) NOT NULL, + description TEXT, + calories INT DEFAULT 0, + protein FLOAT, + carbs FLOAT, + fat FLOAT, + photo_path VARCHAR(500), + food_items TEXT, + INDEX idx_meal_date (date) + ) + """) + + # 睡眠记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sleep ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT DEFAULT 1, + date DATE NOT NULL, + bedtime VARCHAR(20), + wake_time VARCHAR(20), + duration FLOAT NOT NULL, + quality INT DEFAULT 3, + deep_sleep_mins INT, + source VARCHAR(50) DEFAULT '手动', + notes TEXT, + INDEX idx_sleep_date (date) + ) + """) + + # 体重记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS weight ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT DEFAULT 1, + date DATE NOT NULL, + weight_kg FLOAT NOT NULL, + body_fat_pct FLOAT, + muscle_mass FLOAT, + notes TEXT, + INDEX idx_weight_date (date) + ) + """) + + # 用户配置表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS config ( + `key` VARCHAR(100) PRIMARY KEY, + value TEXT + ) + """) + + # 用户表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL UNIQUE, + created_at DATETIME NOT NULL, + is_active TINYINT DEFAULT 0, + gender VARCHAR(10), + height_cm FLOAT, + weight_kg FLOAT, + age INT, + password_hash VARCHAR(255), + email VARCHAR(255), + is_admin TINYINT DEFAULT 0, + is_disabled TINYINT DEFAULT 0 + ) + """) + + # 邀请码表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS invites ( + id INT PRIMARY KEY AUTO_INCREMENT, + code VARCHAR(100) NOT NULL UNIQUE, + created_by INT NOT NULL, + used_by INT, + created_at DATETIME NOT NULL, + expires_at DATETIME + ) + """) + + # 阅读记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS reading ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT DEFAULT 1, + date DATE NOT NULL, + title VARCHAR(500) NOT NULL, + author VARCHAR(255), + cover_url VARCHAR(1000), + duration INT DEFAULT 0, + mood VARCHAR(50), + notes TEXT, + INDEX idx_reading_date (date) + ) + """) +``` + +**Step 2: Commit** + +```bash +git add src/vitals/core/database.py +git commit -m "refactor: init_db 改为 MySQL 建表语句" +``` + +--- + +### Task 1.4: 重构 database.py - 所有查询占位符 + +**Files:** +- Modify: `src/vitals/core/database.py` - 所有 CRUD 函数 + +**Step 1: 批量替换占位符** + +将所有 SQL 查询中的 `?` 替换为 `%s`。 + +主要改动点(示例): + +```python +# 改前 +cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + +# 改后 +cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) +``` + +**Step 2: 修改所有函数的连接获取方式** + +将所有函数中的: +```python +with get_connection() as conn: + cursor = conn.cursor() +``` + +改为: +```python +with get_connection() as (conn, cursor): +``` + +**Step 3: 修改 row 访问方式** + +MySQL 返回 dictionary,直接用 `row["field"]` 访问(与现有代码兼容)。 + +移除所有 `conn.row_factory = sqlite3.Row` 相关代码。 + +**Step 4: 修改 lastrowid 获取** + +```python +# MySQL 方式 +cursor.execute(...) +return cursor.lastrowid +``` + +**Step 5: Commit** + +```bash +git add src/vitals/core/database.py +git commit -m "refactor: 所有 SQL 查询改为 MySQL 语法" +``` + +--- + +### Task 1.5: 移除 SQLite 特有代码 + +**Files:** +- Modify: `src/vitals/core/database.py` + +**Step 1: 移除 SQLite 导入和函数** + +删除: +- `import sqlite3` +- `get_db_path()` 函数 +- `PRAGMA` 相关代码 +- `migrate_auth_fields()` 中的 SQLite 特有逻辑 + +**Step 2: 简化 migrate_auth_fields** + +```python +def migrate_auth_fields(): + """迁移:为现有 users 表添加认证字段(MySQL 版本)""" + with get_connection() as (conn, cursor): + # 检查列是否存在,MySQL 方式 + cursor.execute(""" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' + """) + columns = [row["COLUMN_NAME"] for row in cursor.fetchall()] + + if "password_hash" not in columns: + cursor.execute("ALTER TABLE users ADD COLUMN password_hash VARCHAR(255)") + if "email" not in columns: + cursor.execute("ALTER TABLE users ADD COLUMN email VARCHAR(255)") + if "is_admin" not in columns: + cursor.execute("ALTER TABLE users ADD COLUMN is_admin TINYINT DEFAULT 0") + if "is_disabled" not in columns: + cursor.execute("ALTER TABLE users ADD COLUMN is_disabled TINYINT DEFAULT 0") +``` + +**Step 3: Commit** + +```bash +git add src/vitals/core/database.py +git commit -m "refactor: 移除 SQLite 特有代码,完成 MySQL 迁移" +``` + +--- + +### Task 1.6: 创建数据迁移脚本 + +**Files:** +- Create: `scripts/migrate_sqlite_to_mysql.py` + +**Step 1: 编写迁移脚本** + +```python +#!/usr/bin/env python3 +"""SQLite 到 MySQL 数据迁移脚本""" + +import sqlite3 +import os +from pathlib import Path + +import mysql.connector + + +def get_sqlite_path() -> Path: + """获取 SQLite 数据库路径""" + env_path = os.environ.get("VITALS_DB_PATH") + if env_path: + return Path(env_path) + return Path.home() / ".vitals" / "vitals.db" + + +def migrate(): + # SQLite 连接 + sqlite_path = get_sqlite_path() + if not sqlite_path.exists(): + print(f"SQLite 数据库不存在: {sqlite_path}") + return + + sqlite_conn = sqlite3.connect(sqlite_path) + sqlite_conn.row_factory = sqlite3.Row + sqlite_cursor = sqlite_conn.cursor() + + # MySQL 连接 + mysql_conn = mysql.connector.connect( + host=os.environ.get("MYSQL_HOST", "localhost"), + port=int(os.environ.get("MYSQL_PORT", "3306")), + user=os.environ.get("MYSQL_USER", "vitals"), + password=os.environ.get("MYSQL_PASSWORD", ""), + database=os.environ.get("MYSQL_DATABASE", "vitals"), + ) + mysql_cursor = mysql_conn.cursor() + + tables = ["users", "exercise", "meal", "sleep", "weight", "reading", "invites", "config"] + + for table in tables: + print(f"迁移表: {table}") + try: + sqlite_cursor.execute(f"SELECT * FROM {table}") + rows = sqlite_cursor.fetchall() + + if not rows: + print(f" - 无数据") + continue + + columns = [desc[0] for desc in sqlite_cursor.description] + placeholders = ", ".join(["%s"] * len(columns)) + column_names = ", ".join([f"`{c}`" for c in columns]) + + insert_sql = f"INSERT INTO {table} ({column_names}) VALUES ({placeholders})" + + for row in rows: + values = tuple(row) + try: + mysql_cursor.execute(insert_sql, values) + except mysql.connector.IntegrityError as e: + print(f" - 跳过重复记录: {e}") + + mysql_conn.commit() + print(f" - 迁移 {len(rows)} 条记录") + + except sqlite3.OperationalError as e: + print(f" - 表不存在或错误: {e}") + + sqlite_conn.close() + mysql_conn.close() + print("迁移完成!") + + +if __name__ == "__main__": + migrate() +``` + +**Step 2: Commit** + +```bash +git add scripts/migrate_sqlite_to_mysql.py +git commit -m "feat: 添加 SQLite 到 MySQL 数据迁移脚本" +``` + +--- + +### Task 1.7: 更新 Docker 配置 + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `.env.example` + +**Step 1: 更新 docker-compose.yml** + +添加 MySQL 服务: + +```yaml +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} + MYSQL_DATABASE: ${MYSQL_DATABASE:-vitals} + MYSQL_USER: ${MYSQL_USER:-vitals} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-vitalspassword} + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + vitals: + # ... 现有配置 ... + depends_on: + mysql: + condition: service_healthy + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-vitals} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-vitalspassword} + MYSQL_DATABASE: ${MYSQL_DATABASE:-vitals} + +volumes: + mysql_data: +``` + +**Step 2: 更新 .env.example** + +```bash +# MySQL 配置 +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=vitals +MYSQL_PASSWORD=your_password_here +MYSQL_DATABASE=vitals +MYSQL_ROOT_PASSWORD=rootpassword +``` + +**Step 3: Commit** + +```bash +git add docker-compose.yml .env.example +git commit -m "config: 添加 MySQL Docker 配置" +``` + +--- + +## 阶段 2: 用户权限增强 + +### Task 2.1: 修改 GET /api/users 接口 + +**Files:** +- Modify: `src/vitals/web/app.py:1699-1717` + +**Step 1: 添加权限过滤** + +```python +@app.get("/api/users", response_model=list[UserResponse]) +async def get_users(current_user: User = Depends(get_current_user)): + """获取用户列表(非管理员仅返回自己)""" + if current_user.is_admin: + users = db.get_users() + else: + users = [db.get_user(current_user.id)] + + return [ + UserResponse( + id=u.id, + name=u.name, + created_at=u.created_at.isoformat(), + is_active=u.is_active, + gender=u.gender, + height_cm=u.height_cm, + weight_kg=u.weight_kg, + age=u.age, + bmi=u.bmi, + bmi_status=u.bmi_status, + ) + for u in users if u + ] +``` + +**Step 2: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "feat: GET /api/users 非管理员仅返回自己" +``` + +--- + +### Task 2.2: 修改 GET /api/users/{user_id} 接口 + +**Files:** +- Modify: `src/vitals/web/app.py:1740-1758` + +**Step 1: 添加权限检查** + +```python +@app.get("/api/users/{user_id}", response_model=UserResponse) +async def get_user(user_id: int, current_user: User = Depends(get_current_user)): + """获取指定用户(非管理员只能查自己)""" + if not current_user.is_admin and user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权查看其他用户") + + user = db.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + return UserResponse( + id=user.id, + name=user.name, + created_at=user.created_at.isoformat(), + is_active=user.is_active, + gender=user.gender, + height_cm=user.height_cm, + weight_kg=user.weight_kg, + age=user.age, + bmi=user.bmi, + bmi_status=user.bmi_status, + ) +``` + +**Step 2: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "feat: GET /api/users/{id} 非管理员只能查自己" +``` + +--- + +### Task 2.3: 隐藏非管理员的管理入口 + +**Files:** +- Modify: `src/vitals/web/app.py` - 导航栏 HTML 生成函数 + +**Step 1: 找到导航栏生成代码** + +搜索 `get_navbar_html` 或类似函数,修改管理链接的显示条件。 + +**Step 2: 添加 is_admin 参数** + +在导航栏生成函数中,仅当 `is_admin=True` 时显示管理链接: + +```python +def get_navbar_html(active_page: str = "", is_admin: bool = False) -> str: + # ... 现有代码 ... + + admin_link = "" + if is_admin: + admin_link = f'管理' + + # 在导航栏 HTML 中使用 admin_link +``` + +**Step 3: 更新所有调用处** + +在所有页面渲染时传入当前用户的 `is_admin` 状态。 + +**Step 4: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "feat: 非管理员隐藏导航栏管理入口" +``` + +--- + +## 阶段 3: H5 移动端适配 + +### Task 3.1: 添加全局移动端基础样式 + +**Files:** +- Modify: `src/vitals/web/app.py` - 通用 CSS 函数 + +**Step 1: 创建移动端基础 CSS** + +在 `get_common_styles()` 或类似函数中添加: + +```css +/* 移动端基础 */ +* { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +/* 响应式断点 */ +@media (max-width: 768px) { + html { font-size: 14px; } + + .container { + padding: 0 16px; + max-width: 100%; + } + + /* 隐藏桌面导航 */ + .desktop-nav { display: none; } + + /* 显示移动端底部导航 */ + .mobile-nav { display: flex; } + + /* 内容区域留出底部导航空间 */ + .main-content { + padding-bottom: 80px; + } +} + +@media (min-width: 769px) { + .mobile-nav { display: none; } + .desktop-nav { display: flex; } +} + +/* 触摸目标 */ +.touch-target { + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +/* 安全区域 */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0); +} +``` + +**Step 2: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "style: 添加移动端基础 CSS 样式" +``` + +--- + +### Task 3.2: 创建底部 Tab 导航组件 + +**Files:** +- Modify: `src/vitals/web/app.py` + +**Step 1: 创建底部导航 HTML 函数** + +```python +def get_mobile_nav_html(active_page: str = "", is_admin: bool = False) -> str: + """生成移动端底部导航栏""" + + def nav_item(href: str, icon: str, label: str, page: str) -> str: + active_class = "active" if active_page == page else "" + return f''' + + + {icon} + + {label} + + ''' + + # SVG 图标路径 + icons = { + "home": '', + "exercise": '', + "meal": '', + "sleep": '', + "more": '', + } + + return f''' + + + + + + + + ''' +``` + +**Step 2: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "feat: 创建移动端底部 Tab 导航组件" +``` + +--- + +### Task 3.3: 适配登录/注册页面 + +**Files:** +- Modify: `src/vitals/web/app.py` - `get_login_page_html()`, `get_register_page_html()` + +**Step 1: 添加移动端适配样式** + +在登录页面 CSS 中添加: + +```css +@media (max-width: 768px) { + .login-container { + min-height: 100vh; + padding: 24px 16px; + display: flex; + flex-direction: column; + justify-content: center; + } + + .login-card { + width: 100%; + max-width: none; + box-shadow: none; + padding: 24px 16px; + } + + .login-input { + height: 48px; + font-size: 16px; /* 防止 iOS 缩放 */ + } + + .login-button { + height: 48px; + font-size: 16px; + } +} +``` + +**Step 2: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "style: 登录/注册页面移动端适配" +``` + +--- + +### Task 3.4: 适配首页 Dashboard + +**Files:** +- Modify: `src/vitals/web/app.py` - 首页 HTML 生成 + +**Step 1: 添加移动端适配样式** + +```css +@media (max-width: 768px) { + .dashboard-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .stat-card { + padding: 16px; + } + + .stat-value { + font-size: 28px; + } + + .chart-container { + height: 200px; + } +} +``` + +**Step 2: 在页面中添加底部导航** + +**Step 3: Commit** + +```bash +git add src/vitals/web/app.py +git commit -m "style: 首页 Dashboard 移动端适配" +``` + +--- + +### Task 3.5-3.10: 适配其余页面 + +按相同模式适配以下页面: +- Task 3.5: 运动页面 (`/exercise`) +- Task 3.6: 饮食页面 (`/meal`) +- Task 3.7: 睡眠页面 (`/sleep`) +- Task 3.8: 体重页面 (`/weight`) +- Task 3.9: 阅读页面 (`/reading`) +- Task 3.10: 设置页面 (`/settings`) +- Task 3.11: 报告页面 (`/report`) +- Task 3.12: 管理页面 (`/admin`) + +每个页面的适配要点: +1. 表格改为卡片列表 +2. 表单输入框加大 +3. 按钮符合 44px 触摸标准 +4. 添加底部导航 +5. 提交 commit + +--- + +## 阶段 4: 测试与验证 + +### Task 4.1: 本地 MySQL 测试 + +**Step 1: 启动 MySQL** + +Run: `docker-compose up mysql -d` + +**Step 2: 初始化数据库** + +Run: `python -c "from vitals.core.database import init_db; init_db()"` + +**Step 3: 运行应用** + +Run: `python -m vitals.web.app` + +**Step 4: 验证功能** + +- 登录/注册 +- 数据 CRUD +- 权限隔离 + +--- + +### Task 4.2: 移动端真机测试 + +**Step 1: 获取本地 IP** + +Run: `ifconfig | grep "inet " | grep -v 127.0.0.1` + +**Step 2: 手机浏览器访问** + +访问 `http://<本地IP>:8080` + +**Step 3: 测试清单** + +- [ ] 底部导航显示正常 +- [ ] 表单输入无缩放问题 +- [ ] 按钮点击区域足够 +- [ ] 页面无横向滚动 + +--- + +## 完成标准 + +- [ ] MySQL 数据库正常连接和操作 +- [ ] 非管理员仅能看到自己的用户信息 +- [ ] 所有 11 个页面移动端适配完成 +- [ ] 底部 Tab 导航正常工作 +- [ ] 真机测试通过