1036 lines
25 KiB
Markdown
1036 lines
25 KiB
Markdown
|
|
# 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'<a href="/admin" class="nav-link {"active" if active_page == "admin" else ""}">管理</a>'
|
|||
|
|
|
|||
|
|
# 在导航栏 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'''
|
|||
|
|
<a href="{href}" class="mobile-nav-item {active_class}">
|
|||
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
{icon}
|
|||
|
|
</svg>
|
|||
|
|
<span class="nav-label">{label}</span>
|
|||
|
|
</a>
|
|||
|
|
'''
|
|||
|
|
|
|||
|
|
# SVG 图标路径
|
|||
|
|
icons = {
|
|||
|
|
"home": '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
|||
|
|
"exercise": '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
|||
|
|
"meal": '<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/>',
|
|||
|
|
"sleep": '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
|||
|
|
"more": '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return f'''
|
|||
|
|
<nav class="mobile-nav safe-area-bottom">
|
|||
|
|
{nav_item("/", icons["home"], "首页", "home")}
|
|||
|
|
{nav_item("/exercise", icons["exercise"], "运动", "exercise")}
|
|||
|
|
{nav_item("/meal", icons["meal"], "饮食", "meal")}
|
|||
|
|
{nav_item("/sleep", icons["sleep"], "睡眠", "sleep")}
|
|||
|
|
<div class="mobile-nav-item more-trigger" onclick="toggleMoreMenu()">
|
|||
|
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
{icons["more"]}
|
|||
|
|
</svg>
|
|||
|
|
<span class="nav-label">更多</span>
|
|||
|
|
</div>
|
|||
|
|
</nav>
|
|||
|
|
|
|||
|
|
<div id="more-menu" class="more-menu hidden">
|
|||
|
|
<a href="/weight" class="more-menu-item">体重</a>
|
|||
|
|
<a href="/reading" class="more-menu-item">阅读</a>
|
|||
|
|
<a href="/report" class="more-menu-item">报告</a>
|
|||
|
|
<a href="/settings" class="more-menu-item">设置</a>
|
|||
|
|
{"<a href='/admin' class='more-menu-item'>管理</a>" if is_admin else ""}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
.mobile-nav {{
|
|||
|
|
position: fixed;
|
|||
|
|
bottom: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
height: 64px;
|
|||
|
|
background: white;
|
|||
|
|
border-top: 1px solid #E2E8F0;
|
|||
|
|
display: none;
|
|||
|
|
justify-content: space-around;
|
|||
|
|
align-items: center;
|
|||
|
|
z-index: 50;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.mobile-nav-item {{
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
min-width: 64px;
|
|||
|
|
min-height: 44px;
|
|||
|
|
color: #64748B;
|
|||
|
|
text-decoration: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.mobile-nav-item.active {{
|
|||
|
|
color: #3B82F6;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.nav-icon {{
|
|||
|
|
width: 24px;
|
|||
|
|
height: 24px;
|
|||
|
|
margin-bottom: 2px;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.nav-label {{
|
|||
|
|
font-size: 10px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.more-menu {{
|
|||
|
|
position: fixed;
|
|||
|
|
bottom: 72px;
|
|||
|
|
right: 16px;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|||
|
|
padding: 8px 0;
|
|||
|
|
z-index: 51;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.more-menu.hidden {{
|
|||
|
|
display: none;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.more-menu-item {{
|
|||
|
|
display: block;
|
|||
|
|
padding: 12px 24px;
|
|||
|
|
color: #1E293B;
|
|||
|
|
text-decoration: none;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
.more-menu-item:hover {{
|
|||
|
|
background: #F1F5F9;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
@media (max-width: 768px) {{
|
|||
|
|
.mobile-nav {{ display: flex; }}
|
|||
|
|
}}
|
|||
|
|
</style>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
function toggleMoreMenu() {{
|
|||
|
|
const menu = document.getElementById('more-menu');
|
|||
|
|
menu.classList.toggle('hidden');
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
// 点击其他地方关闭菜单
|
|||
|
|
document.addEventListener('click', function(e) {{
|
|||
|
|
if (!e.target.closest('.more-trigger') && !e.target.closest('.more-menu')) {{
|
|||
|
|
document.getElementById('more-menu').classList.add('hidden');
|
|||
|
|
}}
|
|||
|
|
}});
|
|||
|
|
</script>
|
|||
|
|
'''
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**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 导航正常工作
|
|||
|
|
- [ ] 真机测试通过
|