diff --git a/.env.example b/.env.example index 887d7f3..0e9b82c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,16 @@ -MI_FITNESS_USER_ID=replace_me -MI_FITNESS_PASS_TOKEN=replace_me -MI_FITNESS_REGION=ru +# 运行时环境变量示例(可选;均由服务进程读取,不要把真实值提交进仓库) + +# --- REST API (mi-fitness-mcp api) / Web 仪表盘 (mi-fitness-mcp web) --- +# 设置后所有数据端点要求 X-API-Key 头 +# MI_FITNESS_API_KEY=change-me +# 管理端点(Key 发放/列表/吊销、扫码登录)要求 X-Admin-Key;未设置时仅限 127.0.0.1 调用 +# MI_FITNESS_ADMIN_KEY=change-me + +# --- Web 仪表盘 (mi-fitness-mcp web) --- +# MI_FITNESS_API_URL=http://127.0.0.1:8321 # 反向代理的后端 FastAPI 地址 + +# --- Rust 测试页 (testpage/) --- +# BIND=127.0.0.1:8322 # 监听地址 +# MI_FITNESS_API_URL=http://127.0.0.1:8321 + +# 小米凭据不走环境变量:用 mi-fitness-mcp setup(keyring 存储)或扫码登录发放 Key。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 846db78..1a7783a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[all,dev]" - name: Ruff run: | python -m ruff check src tests diff --git a/.gitignore b/.gitignore index c08019d..a6e01df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .venv/ __pycache__/ +target/ *.pyc *.pyo *.pyd diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f9d18..8472f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 + +- 内置 Web 仪表盘(`mi-fitness-mcp web`,Flask):可视化测试全部 API 端点、Key 管理、扫码登录,内置反向代理(自动附加 X-API-Key/X-Admin-Key,上游耗时透传) +- REST API 服务(`mi-fitness-mcp api`,FastAPI):全部数据端点 + 同步(前台/后台)+ 覆盖统计,交互式文档 `/docs` +- API Key 体系:`POST /api/auth/keys` 用小米凭据换取 `mif_sk_*` Key(类大模型平台风格),支持多账号上下文、last_used 统计、按前缀吊销;静态 Key 经 `MI_FITNESS_API_KEY`、管理端点经 `MI_FITNESS_ADMIN_KEY` +- 扫码登录:逆向小米通用扫码流程(`sid=xiaomiio`),`/api/auth/qr/start|poll` 生成二维码并轮询换取凭据后自动发放 Key,无需浏览器 F12 +- Rust 测试页 `testpage/`(可选,内置 Web 仪表盘的 Rust 替代实现) +- 修复:`mcp` 依赖加上界 `<2`(MCP SDK 2.x 移除 `Server.list_tools` 导致启动崩溃) +- 文档:完整技术文档 `docs/PROJECT_DOCUMENTATION.md`(架构/协议/已知问题/重构指南)、扫码登录方案 `docs/QR_LOGIN.md` + ## 0.1.0 - initial standalone `mi-fitness-mcp` repository diff --git a/README.md b/README.md index d032bc5..fff48ed 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,79 @@ # Mi Fitness MCP CN -小米运动健康 / Mi Fitness 数据本地 MCP Server。 +小米运动健康(Mi Fitness)数据的本地 MCP Server + REST API + 现代化可视化 Web 仪表盘。把你自己账号里的健康数据同步到本地 SQLite,供 AI 客户端(MCP)、其他程序(HTTP API)或直接在现代化 Web 控制台查询分析。 -本项目基于 `kubulashvili/mi-fitness-mcp` 修改,增加中国区小米健康云适配,并补充睡眠与运动记录同步能力。 +基于 [kubulashvili/mi-fitness-mcp](https://github.com/kubulashvili/mi-fitness-mcp) 修改并深度重构,新增: -> 非小米官方项目,仅用于读取和分析你自己的健康数据。数据默认同步到本地 SQLite。 +- 🇨🇳 **中国区云端适配**(`--region cn`) +- 💤 **全量健康指标**:睡眠分期(深睡/浅睡/REM/小睡)、运动记录、静息心率、血氧(SpO₂)、压力、异常心跳 +- 🌐 **REST API 服务**(FastAPI):全部数据端点 + 多维指标聚合,供任意程序调用 +- 🖥️ **现代化 Web 仪表盘与控制台**(Python Flask):高质感深浅主题、图表走势、实时响应检视、cURL 一键生成 +- 🔑 **API Key 体系**:用小米凭据换取 `mif_sk_*` Key(类大模型平台风格),支持多账号、可吊销 +- 📱 **扫码登录**:逆向小米官方扫码流程,手机 App 扫码即可授权,告别浏览器 F12 抓 Cookie -## 功能 +> ⚠️ 非小米官方项目。全部云端接口为逆向所得,仅用于读取和分析**你自己的**健康数据,请遵守小米用户协议。接口随时可能因小米改版失效。 -- 读取小米运动健康云端数据 -- 本地 SQLite 缓存 -- MCP Server 支持 -- 支持中国区:`--region cn` -- 支持数据类型: - - `daily_activity`:步数、距离、活动卡路里 - - `heart_rate`:心率采样 - - `sleep`:睡眠记录 - - `workouts`:运动记录 - - `body_measurements`:体重 / 身体成分,视账号数据而定 +## 支持的数据 -## 已逆向验证的接口 +| 类型 | 内容 | CLI `--type` | API 端点 | +|---|---|---|---| +| 每日活动 | 步数、距离、活动卡路里 | `daily_activity` | `/api/summary` | +| 心率 | 逐分钟采样 + 静息心率 | `heart_rate` | `/api/heart-rate` | +| 睡眠 | 时长、入睡/清醒、分段(深睡/浅睡/REM) | `sleep` | `/api/sleep` | +| 运动 | 类型、时长、距离、卡路里、心率、配速 | `workouts` | `/api/workouts` | +| 身体成分 | 体重、BMI、体脂、肌肉量等(需体脂秤) | `body_measurements` | `/api/body-measurements` | +| 血氧 | SpO₂ 采样 | `spo2` | `/api/spo2` | +| 压力 | 压力分数 + 等级 | `stress` | `/api/stress` | +| 异常心跳 | 事件起止与时长 | `abnormal_heart_beat` | `/api/abnormal-heart-beat` | -### 健康数据接口 +## 快速开始 -```text -POST https://hlth.io.mi.com/app/v1/data/get_fitness_data_by_time -``` - -常用 key: - -```text -steps -calories -heart_rate -weight -sleep -``` - -睡眠请求示例: - -```json -{ - "start_time": 1767225600, - "end_time": 1782086399, - "key": "sleep" -} -``` - -### 运动记录接口 - -```text -POST https://hlth.io.mi.com/app/v1/data/get_sport_records_by_time -``` - -请求示例: - -```json -{ - "start_time": 1767225600, - "end_time": 1782086399, - "limit": 50 -} -``` - -返回字段通常包含: - -```text -sport_records -has_more -next_key -``` - -每条运动记录的 `value` 是 JSON 字符串,包含 `start_time`、`end_time`、`duration`、`distance`、`calories`、`avg_hrm`、`max_hrm` 等字段。 - -## 安装 +### 安装 ```bash -git clone git@github.com:binglua/mi-fitness-mcp-cn.git +git clone https://github.com//mi-fitness-mcp-cn.git cd mi-fitness-mcp-cn python -m venv .venv -source .venv/bin/activate -pip install -e '.[dev]' +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e '.[all,dev]' # 安装所有依赖(含 FastAPI 与 Flask Web UI) ``` -如果当前环境没有系统 keyring,可安装: +要求 Python ≥ 3.11。 + +### 获取凭据与可视化控制台 + +**方式 A:通过 Web 仪表盘扫码登录(最简单,推荐)** ```bash -pip install keyrings.alt +# 1. 启动后端 API 服务 (端口 8321) +mi-fitness-mcp api + +# 2. 另开终端启动 Web 仪表盘 (端口 8322) +mi-fitness-mcp web + +# 3. 浏览器打开 http://127.0.0.1:8322/ 点击「扫码登录」 +# 用小米账号 / 米家 App 扫码确认,自动换取并注入 API Key! ``` -注意:`keyrings.alt` 可能以明文文件方式保存凭据。 +**方式 B:手动配置凭据** -## 配置 - -需要从 `https://account.xiaomi.com` 登录后的 Cookie 中获取: - -- `userId` -- `passToken` - -中国区账号建议: +浏览器登录 [account.xiaomi.com](https://account.xiaomi.com),从 Cookie 获取 `userId` 与 `passToken`: ```bash -mi-fitness-mcp setup \ - --mode mi_fitness_cloud \ - --user-id "" \ - --pass-token "" \ - --region cn +mi-fitness-mcp setup --mode mi_fitness_cloud --user-id "" --pass-token "" --region cn +mi-fitness-mcp doctor # 验证连通性 ``` -检查连接: +### 数据同步 ```bash -mi-fitness-mcp doctor +mi-fitness-mcp sync --start-date 2026-01-01 --end-date 2026-06-30 # 全量数据同步 +mi-fitness-mcp sync --type sleep --start-date 2026-06-01 --end-date 2026-06-30 # 按类型同步 ``` -## 同步数据 +### 作为 MCP Server 接入 AI 客户端 -同步全部可探测数据: - -```bash -mi-fitness-mcp sync --start-date 2026-01-01 --end-date 2026-06-21 -``` - -按类型同步: - -```bash -mi-fitness-mcp sync --type daily_activity --start-date 2026-01-01 --end-date 2026-06-21 -mi-fitness-mcp sync --type heart_rate --start-date 2026-01-01 --end-date 2026-06-21 -mi-fitness-mcp sync --type sleep --start-date 2026-01-01 --end-date 2026-06-21 -mi-fitness-mcp sync --type workouts --start-date 2026-01-01 --end-date 2026-06-21 -mi-fitness-mcp sync --type body_measurements --start-date 2026-01-01 --end-date 2026-06-21 -``` - -## 启动 MCP Server - -```bash -mi-fitness-mcp serve -``` - -Claude Desktop 配置示例: +在 Claude Desktop 或其它 MCP 客户端中配置: ```json { @@ -151,67 +86,72 @@ Claude Desktop 配置示例: } ``` -## MCP 工具 +支持的 MCP 工具列表: +- `get_connection_status`, `sync_data`, `get_sync_status`, `get_profile`, `get_daily_summary` +- `query_metric_series`, `query_heart_rate`, `query_body_measurements`, `query_sleep`, `query_workouts` +- `query_spo2`, `query_stress`, `query_abnormal_heart_beat`, `get_data_coverage` -- `get_connection_status` -- `sync_data` -- `get_profile` -- `get_daily_summary` -- `query_metric_series` -- `query_heart_rate` -- `query_body_measurements` -- `query_sleep` -- `query_workouts` -- `get_data_coverage` +## REST API 与鉴权 -## 本地数据库 +启动 API 服务: -默认位置: - -```text -~/.local/share/mi-fitness-mcp/mi_fitness.db +```bash +mi-fitness-mcp api [--host 127.0.0.1] [--port 8321] ``` -主要表: +- 交互式 Swagger 文档:`http://127.0.0.1:8321/docs` +- 鉴权机制:支持静态环境变量 `MI_FITNESS_API_KEY`、管理密钥 `MI_FITNESS_ADMIN_KEY` 以及通过 `/api/auth/keys` 动态发放的 `mif_sk_*` 用户级 Key。 +- 扫码登录实现细节见 [docs/QR_LOGIN.md](docs/QR_LOGIN.md),完整架构与协议文档见 [docs/PROJECT_DOCUMENTATION.md](docs/PROJECT_DOCUMENTATION.md)。 + +```bash +# 携带 API Key 查询健康数据示例 +curl "http://127.0.0.1:8321/api/summary?start_date=2026-08-01&end_date=2026-08-31" \ + -H "X-API-Key: mif_sk_..." +``` + +## 项目结构 ```text -daily_activity -heart_rate_samples -sleep_sessions -workouts -body_measurements -sync_state +src/mi_fitness_mcp/ +├── main.py # CLI 统一入口: serve / setup / doctor / sync / api / web +├── server.py # MCP Server(14 个工具 + 异步同步引擎) +├── api.py # REST API 服务(FastAPI: 数据端点 + Key 体系 + 扫码登录) +├── web.py # 可视化仪表盘反向代理服务(Flask) +├── web_assets/ # 现代化前端界面(HTML / CSS / JS) +├── adapters/ # 小米云协议:登录、RC4加解密、签名、分页、解析 +├── services/ # 同步引擎(分块/增量)与查询聚合 +├── storage/ # SQLite 数据库引擎(8 张数据表 + sync_state) +├── models/ # Pydantic 数据模型 +├── config.py # 系统配置管理(platformdirs + JSON) +└── auth/ # keyring 安全凭据存储 +testpage/ # 可选:独立运行的 Web 测试控制台(免安装,直接 python app.py) +docs/ # 架构文档与扫码登录逆向原理解析 +tests/ # 单元测试与端到端测试套件 +``` + +## 本地开发与测试 + +```bash +# 安装开发与测试依赖 +pip install -e '.[all,dev]' + +# 代码规范检查 +ruff check src tests + +# 运行完整测试套件 +pytest -v ``` ## 安全说明 -- `passToken` 是敏感凭据,不要泄露。 -- 不要提交本地配置、数据库、keyring 文件。 -- 如果 token 泄露,建议退出小米账号并重新登录刷新。 +- `passToken` 等同小米账号登录态,**切勿提交或泄露**;若泄露,请在小米账号安全中心退出设备并重新登录。 +- 本地数据库与敏感配置文件已在 `.gitignore` 中默认排除。 +- 服务默认仅绑定 `127.0.0.1` 本地回环地址。如需向局域网或公网开放,请务必设置 `MI_FITNESS_API_KEY` 与 `MI_FITNESS_ADMIN_KEY`。 ## 免责声明 -本项目与小米公司无关。请仅用于读取和分析你自己的健康数据。 - - - -### 新增健康指标 - -本分支额外逆向并验证了以下小米运动健康云端 key,并已接入 CLI 同步、本地 SQLite 缓存和 MCP 查询工具: - -- `resting_heart_rate`:静息心率,合并到 `query_heart_rate(sample_type="resting")`。 -- `spo2`:血氧饱和度,CLI 类型 `spo2`,MCP 工具 `query_spo2`。 -- `stress`:压力值,CLI 类型 `stress`,MCP 工具 `query_stress`。 -- `abnormal_heart_beat`:异常心跳事件,CLI 类型 `abnormal_heart_beat`,MCP 工具 `query_abnormal_heart_beat`。 - -示例: - -```bash -mi-fitness-mcp sync --type spo2 --start-date 2026-06-01 --end-date 2026-06-22 -mi-fitness-mcp sync --type stress --start-date 2026-06-01 --end-date 2026-06-22 -mi-fitness-mcp sync --type abnormal_heart_beat --start-date 2026-06-01 --end-date 2026-06-22 -``` +本项目与小米公司无关。接口为社区逆向成果,不保证持续可用。请仅用于读取和分析您自己的个人健康数据。 ## License -MIT +[MIT](LICENSE) diff --git a/docs/PROJECT_DOCUMENTATION.md b/docs/PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..e68759c --- /dev/null +++ b/docs/PROJECT_DOCUMENTATION.md @@ -0,0 +1,774 @@ +# mi-fitness-mcp-cn 项目完整技术文档 + +> **文档目的**:作为重构/二次开发的唯一权威参考。内容基于源码逐行整理(基线 commit `7fcd069`),所有行为描述均与代码实际实现一一对应,不包含猜测。 +> **阅读对象**:执行重构的 AI 或工程师。重构约束与已知问题见第 15、16 章,**动手前必读**。 + +--- + +## 目录 + +1. [项目定位与背景](#1-项目定位与背景) +2. [快速事实卡](#2-快速事实卡) +3. [总体架构](#3-总体架构) +4. [目录结构与代码地图](#4-目录结构与代码地图) +5. [配置层 config.py](#5-配置层) +6. [凭据与认证 auth](#6-凭据与认证) +7. [数据模型 models](#7-数据模型) +8. [小米健康云协议(逆向核心)](#8-小米健康云协议) +9. [适配器层 mi_fitness_cloud.py](#9-适配器层) +10. [同步服务 sync_service.py](#10-同步服务) +11. [存储层 storage](#11-存储层) +12. [查询服务 query_service.py](#12-查询服务) +13. [MCP Server server.py](#13-mcp-server) +14. [CLI、辅助脚本与测试](#14-cli辅助脚本与测试) +15. [已知问题清单(重构目标)](#15-已知问题清单) +16. [重构方案建议](#16-重构方案建议) +17. [本地环境备注](#17-本地环境备注) + +--- + +## 1. 项目定位与背景 + +**mi-fitness-mcp-cn** 是一个本地运行的 Python MCP(Model Context Protocol)Server,用于把用户自己的「小米运动健康」App 云端数据同步到本地 SQLite,并通过 MCP 工具供 AI 客户端(如 Claude Desktop)查询。 + +- 改造自 `kubulashvili/mi-fitness-mcp`(国际区),本 fork 的增量: + 1. 支持中国区云端(`--region cn`,API host `hlth.io.mi.com`,时区 UTC+8); + 2. 新增睡眠(`sleep`)与运动记录(`workouts`)同步; + 3. 额外逆向并接入 4 个数据 key:`resting_heart_rate`、`spo2`、`stress`、`abnormal_heart_beat`; + 4. CLI 输出与注释中文化。 +- **非官方项目**,接口全部为逆向所得,无任何官方保证,随时可能因小米改版而失效。 +- 仅用于读取分析用户**自己的**健康数据。MIT 协议。 + +## 2. 快速事实卡 + +| 项 | 值 | +|---|---| +| 语言 / 最低版本 | Python ≥ 3.11(用了 `datetime.UTC`) | +| 核心依赖 | `mcp`(**必须 <2**,2.x 移除了 `Server.list_tools`)、`httpx`、`pydantic` ≥2、`platformdirs`、`keyring`、`click`(声明了但**未使用**)、`rich`(声明了但**未使用**) | +| 构建 | hatchling;入口 `mi-fitness-mcp = mi_fitness_mcp.cli_mi_fitness:main` | +| 源码规模 | src 约 3800 行 + 测试 370 行 + 独立探测脚本 268 行 | +| 异步模型 | 全 asyncio(httpx.AsyncClient + MCP stdio);但 DB 层是**同步 sqlite3** | +| 配置文件 | `/mi-fitness-mcp/config.json`(Windows: `C:\Users\\AppData\Local\mi-fitness-mcp\mi-fitness-mcp\config.json`) | +| 凭据存储 | OS keyring,服务名 `mi-fitness-mcp`,账号 `mi_fitness_auth_user_id` / `mi_fitness_auth_pass_token` | +| 数据库 | `/mi-fitness-mcp/mi_fitness.db`(Windows 同上双目录),SQLite,8 张业务表 + 1 张同步状态表 | +| 认证方式 | 小米账号 Cookie 中的 `userId` + `passToken`(用户手工从浏览器获取),换取 `serviceToken` + `ssecurity` | +| 区域 | 已知 `ru, cn, de, i2, sg, us`;cn 用 `hlth.io.mi.com` + UTC+8,其余用 `{region}.hlth.io.mi.com` + UTC | + +## 3. 总体架构 + +``` + ┌──────────────────────────────────────────────┐ + │ MCP 客户端 (AI) │ + └───────────────▲──────────────────────────────┘ + │ stdio (JSON-RPC) +┌──────────────────────────────────────┴──────────────────────────────┐ +│ cli_mi_fitness.py ──► main.py (argparse CLI: serve/setup/doctor/sync) +│ serve ─────────────► server.py (mcp.server.Server, 模块级单例)│ +│ │ list_tools / call_tool │ +│ sync ──┐ ▼ │ +│ │ ┌─────────────────────┐ ┌──────────────────┐ │ +│ └─────►│ SyncService │ │ QueryService │ │ +│ │ 分块/增量/去重 │ │ 聚合/过滤(内存) │ │ +│ └─────────┬───────────┘ └────────┬─────────┘ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ Database (sqlite3, 同步) │ │ +│ └──────────────────────────────────┘ │ +└─────────────▲───────────────────────────────────────────────────────┘ + │ AsyncIterator[Pydantic Model] +│ ┌──────────┴───────────────────────┐ +│ │ MiFitnessCloudAdapter │ +│ │ 登录换取 serviceToken/ssecurity │ +│ │ RC4 加密 + SHA1 签名 │ +│ │ 分页拉取 + 解析为模型 │ +│ └──────────┬───────────────────────┘ +└─────────────┼───────────────────────────────────────────────────────┘ + │ HTTPS (加密表单) + ┌──────────┴──────────────────┐ ┌─────────────────────────┐ + │ account.xiaomi.com 登录 │ │ hlth.io.mi.com 数据接口 │ + └─────────────────────────────┘ └─────────────────────────┘ +``` + +分层职责一句话: + +- **协议层(adapter 内嵌)**:登录、加密签名、分页、把云端 JSON 解析成 Pydantic 模型。 +- **服务层**:SyncService 负责「按 7 天分块拉取→落库→记录增量游标」;QueryService 负责从本地库读出后做内存聚合/过滤。 +- **接口层**:server.py 暴露 14 个 MCP 工具;main.py 暴露 4 个 CLI 子命令。 +- **存储层**:sqlite3 同步连接,UPSERT 写入,字符串日期过滤查询。 + +## 4. 目录结构与代码地图 + +``` +mi-fitness-mcp-cn/ +├── pyproject.toml # 构建/依赖/工具链(mcp>=1.0.0,<2 为本地修复,上游仍是 >=1.0.0) +├── probe_mifitness.py # 独立交互式探测脚本:复制了一份登录+加密代码(268 行,代码重复) +├── .env.example # 声明了 MI_FITNESS_USER_ID/PASS_TOKEN/REGION —— 但代码里【没有任何 env 加载逻辑】 +├── mcp-server-config.example.json +├── docs/PROJECT_DOCUMENTATION.md # 本文档 +├── tests/ # pytest + pytest-asyncio(auto) + respx +│ ├── test_config.py # 14 行:配置存取 roundtrip +│ ├── test_mi_fitness_cloud.py# 102 行:适配器纯函数(解析/时区/分页辅助) +│ ├── test_server_sync.py # 77 行:server 同步编排(mock adapter) +│ ├── test_storage_query.py # 47 行:入库/查询 +│ └── test_sync_service.py # 130 行:分块、增量、异常→partial +└── src/mi_fitness_mcp/ + ├── __init__.py # 3 行 + ├── cli_mi_fitness.py # 5 行:entry point 转发器 → main.main + ├── main.py # 193 行:argparse CLI(serve/setup/doctor/sync)⚠ 名字叫 main 实为 CLI + ├── server.py # 657 行:MCP Server(tools + handlers + 后台同步 + 生命周期) + ├── config.py # 76 行:Config 模型 + JSON 读写 + ├── auth/__init__.py # 45 行:keyring 存取 userId/passToken + ├── models/__init__.py # 190 行:全部 Pydantic 模型 + ├── adapters/ + │ ├── base.py # 116 行:DataAdapter 抽象基类(8 个 iter_* + 4 个基础方法) + │ └── mi_fitness_cloud.py # 805 行:★核心★ 协议实现 + 8 类数据解析器 + ├── services/ + │ ├── sync_service.py # 290 行:分块增量同步引擎 + │ └── query_service.py # 366 行:读侧聚合/过滤 + └── storage/__init__.py # 878 行:Database 类(建表/UPSERT/查询/覆盖统计) +``` + +**模块依赖方向**(重构时保持或理顺):`cli → server/main`、`server → services + adapter + storage + auth + config`、`services → adapter + storage`、`adapter → models + base`、`storage → models`。无循环依赖。 + +## 5. 配置层 + +`Config`(pydantic BaseModel)字段全表,含**实际使用状态**: + +| 字段 | 类型/默认 | 实际用途 | 状态 | +|---|---|---|---| +| `mode` | `Literal["mi_fitness_cloud","not_configured"]` = `not_configured` | 判定是否已配置 | 使用中 | +| `region` | str = `"ru"` | API host 与时区选择 | 使用中;⚠ 默认值与 README 推荐的 `cn` 不一致 | +| `timezone` | str = `"UTC"` | 仅 `get_profile` 返回值里出现 | **准死代码** | +| `database_path` | Path = platformdirs | DB 位置 | 使用中 | +| `logs_path` | Path = platformdirs | —— | **完全未使用**(无 logging 配置代码) | +| `auto_sync_on_start` | bool = True | —— | **未使用**(server 启动明确不连接不同步) | +| `stale_after_minutes` | int = 60 | —— | **未使用** | +| `store_raw_payloads` | bool = True | —— | **未使用**(原始报文从未落库) | +| `default_lookback_days` | 30 (1–3650) | 未给 start_date 时的回溯天数 | 使用中 | +| `sync_chunk_days` | 7 (1–90) | 分块大小 | 使用中 | +| `http_timeout_seconds` | 20.0 (0–120] | httpx 超时 | 使用中 | +| `request_retries` | 3 (1–10] | 请求重试次数 | 使用中 | +| `health_check_timeout_seconds` | 10.0 | 状态工具的健康检查 wait_for | 使用中 | +| `sync_type_timeout_seconds` | 180.0 | 单类型同步 asyncio.wait_for 上限 | 使用中(仅 MCP 路径;CLI 路径无超时) | +| `max_pages` | 200 (1–5000) | 单 key 分页安全上限 | 使用中 | + +- 存取:`load_config()` 无文件则生成默认配置并**立即写盘**;`save_config()` 把 Path 序列化为字符串。 +- 路径:`platformdirs.user_config_dir("mi-fitness-mcp")` / `user_data_dir(...)` / `user_log_dir(...)`。Windows 实测为 `AppData\Local\mi-fitness-mcp\mi-fitness-mcp\`(appauthor 与 appname 双层目录)。 +- ⚠ `.env.example` 是孤儿文件:代码不读取任何环境变量。 + +## 6. 凭据与认证 + +`auth/__init__.py`,基于系统 keyring: + +- `save_mi_fitness_token(user_id, pass_token)`:写两条密码项,服务名 `mi-fitness-mcp`,账号名 `mi_fitness_auth_user_id` / `mi_fitness_auth_pass_token`。 +- `load_mi_fitness_token()`:读取,任何异常吞掉并返回 `(None, None)`。 +- `delete_mi_fitness_token()`:删除。**没有任何 CLI/MCP 命令调用它**(无 logout 功能)。 +- README 提示:无系统 keyring 时可装 `keyrings.alt`(可能明文存盘)。 + +## 7. 数据模型 + +`models/__init__.py`。所有业务实体继承 `BaseEntity`: + +| 公共字段 | 说明 | +|---|---| +| `id` | 确定性主键,格式 `mi_fitness_{类别}_{业务键}`(详见 §11 UPSERT) | +| `provider` | 恒为 `mi_fitness` | +| `source_type` | 恒为 `cloud_session` | +| `source_record_id` | 云端记录 `time` 字符串,可空 | +| `user_id` / `device_id` | device_id 恒为 None(云端未解析设备) | +| `timezone` | 记录级时区名(`zone_name`),默认 UTC | +| `collected_at` | 记录时间(带记录自身 offset) | +| `created_at/updated_at` | 模型层默认 now(UTC) 去掉 tzinfo;**落库时并不写入这两列** | + +业务实体: + +| 模型 | 关键字段 | 备注 | +|---|---|---| +| `DailyActivity` | `date`(YYYY-MM-DD)、`steps`、`distance_m`、`active_kcal`;可选 `total_kcal/floors/active_minutes` | 云端只解析前 3+2 项 | +| `SleepSession` | `sleep_id`、`start_at/end_at`、`duration_minutes`、`time_asleep/time_awake_minutes`、`sleep_score`(0-100)、`is_nap`、`stages: list[SleepStage]` | stages 落库为 JSON 字符串 | +| `SleepStage` | `stage ∈ {deep,light,rem,awake}`、`minutes` | state 映射见 §9.4 | +| `Workout` | `workout_id`、`activity_type`、`start_at/end_at`、`duration_minutes`、距离/卡路里/平均最高心率/配速/步数 | 可选字段 0 值会被转 None | +| `BodyMeasurement` | `timestamp`、`weight_kg`(必填>0)、bmi/体脂/肌肉/水分/骨量/内脏脂肪/基础代谢/代谢年龄 | | +| `HeartRateSample` | `timestamp`、`bpm`、`sample_type ∈ {resting,active,passive,workout}` | `workout` 枚举**永远不会被赋值** | +| `SpO2Sample` | `timestamp`、`spo2_pct`(0-100) | | +| `StressSample` | `timestamp`、`stress_score`(0-100)、`level ∈ {low,medium,high}` | level 阈值 <30 low,<60 medium,否则 high | +| `AbnormalHeartBeatEvent` | `event_id`、`start_at/end_at`、`duration_seconds` | | + +响应/辅助模型: + +- `QueryResponse`:所有 MCP 查询工具的统一信封 `{status, source, generated_at, timezone, data, error}`。 +- `ConnectionStatus`:连接状态工具用(代码里还手工 merge 了 `connection_state/region/last_health_check_at/last_connection_error/sync_in_progress` 等额外键)。 +- `SyncResult`、`UserProfile`、`DeviceInfo`、`DataCoverage`:**已定义但基本未使用**(SyncResult 的字段被 server 手工拼 dict;UserProfile 仅部分字段复用;DataCoverage.gaps_detected 从未计算)。 + +## 8. 小米健康云协议 + +> 本章是本项目最有价值的资产,重构时**协议行为必须保持逐字节兼容**,否则登录/签名会失效。 + +### 8.1 登录(passToken → serviceToken + ssecurity) + +``` +GET https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=miothealth +Cookie: userId={user_id}; passToken={pass_token} +``` + +- 响应体以 **`&&&START&&&`** 前缀开头,去掉前缀后为 JSON,关键字段: + - `passToken`(新 token,登录成功后**回写覆盖** self.pass_token) + - `userId`(回写,转 str) + - `ssecurity`(base64,解码后保存为 bytes,用于后续签名) + - `location`(重定向 URL) +- 随后 `GET {location}`,收集响应的 `set-cookie`(取每条的 `k=v` 部分),拼接为会话 Cookie(含 `serviceToken` 等)。 +- ⚠ `httpx.AsyncClient(follow_redirects=False)`:location 是手工 GET 的;该响应的 cookie 不会自动进 cookie jar,而是拼成字符串 `self._cookies` 手工携带。 + +### 8.2 请求加密与签名(RC4 + 双签名) + +对数据端点的每次 POST: + +1. 明文表单:`data = json.dumps(payload, separators=(",", ":"))`(紧凑 JSON)。 +2. `nonce = os.urandom(8) + struct.pack(">I", unix_ts // 60)`(12 字节,后 4 字节是**分钟级**时间戳,大端)。 +3. `signed_nonce = sha256(ssecurity + nonce)`(32 字节原始摘要)。 +4. RC4 加密:以 `signed_nonce` 为 key,KSA 标准初始化后 **PRGA 先丢弃 1024 字节**,再异或明文。 +5. 签名函数 `_gen_signature(method, path, values, signed_nonce)`: + `base = method + "&" + path + "&data=" + values["data"]`(若 values 里有 `rc4_hash__` 则追加 `&rc4_hash__=...`)+ `"&" + base64(signed_nonce)`;结果 `base64(sha1(base))`。 +6. 组装最终表单(全部再 urlencode): + - `data`:RC4(明文 JSON) + - `rc4_hash__`:对**明文**表单计算的签名 + - `signature`:对**密文**表单(含 data、rc4_hash__)计算的签名 + - `_nonce`:base64(nonce) +7. `POST {base_url}{api_path}`,`Content-Type: application/x-www-form-urlencoded`,`Cookie: {会话cookie}`。 +8. 响应体是 **base64 文本**,用 `signed_nonce` RC4 解密得 JSON:`{code, message, result}`。 + - `code != 0` → 抛错;`code ∈ {401,403,-6,-10001}` 或 message 命中 auth 标记(`unauthorized / not logged in / session expired / invalid pass token / login required / ...`,大小写折叠匹配)→ 判定为认证失败。 + +### 8.3 端点与分页 + +| 端点 | 用途 | 请求 payload | 响应关键字段 | +|---|---|---|---| +| `POST /app/v1/data/get_fitness_data_by_time` | 健康数据(steps/calories/heart_rate/resting_heart_rate/weight/sleep/spo2/stress/abnormal_heart_beat) | `{start_time, end_time, key, next_key?}`(epoch 秒) | `data_list[]`、`has_more`、`next_key` | +| `POST /app/v1/data/get_sport_records_by_time` | 运动记录 | `{start_time, end_time, limit: 50, next_key?}` | `sport_records[]`、`has_more`、`next_key` | + +- host:region 为 `cn` 或空 → `https://hlth.io.mi.com`;否则 `https://{region}.hlth.io.mi.com`。 +- 分页:循环携带 `next_key` 直到 `has_more` 为假;用 `seen_keys` 集合检测游标成环(报 `pagination cursor loop detected`);页数超过 `max_pages`(默认 200)报 `pagination exceeded safety limit`。 +- 列表项公共字段:`time`(epoch 秒,记录时间)、`zone_offset`(秒)、`zone_name`(如 `Asia/Shanghai`)、`sid`(设备/来源 ID)、`value`(**JSON 字符串或 dict**)。 +- 时间换算:请求范围由「日期字符串 + 区域时区」决定——cn/空 → UTC+8,其他 → UTC;`end_date` 取到当天 `23:59:59`。 + +### 8.4 各数据 key 的载荷格式与字段映射 + +`value` 内字段 → 模型字段(括号内为备选字段名): + +**steps / calories(daily_activity 两次请求)** +- steps:`{steps, distance, calories}` → 按天累加 steps/distance_m/active_kcal(初值) +- calories:`{calories}` → 按天累加后**整体覆盖**该日的 `active_kcal` +- 日归属:以记录 `time` + `zone_offset` 格式化为 `YYYY-MM-DD` + +**sleep** +- 入睡:`bedtime`(备选 `device_bedtime`、`bed_timestamp`) +- 醒来:`wake_up_time`(备选 `device_wake_up_time`、`out_bed_timestamp`、兜底 `time`) +- `duration`(分钟;缺省用 end-start);清醒 `awake_duration`(备选 `sleep_awake_duration`);入睡分钟 = duration - awake +- `score`(备选 `sleep_score`)→ sleep_score;`is_nap` +- 分段 `items[]`:`{start_time, end_time, state}`,state 映射:`1→deep, 2→light, 3→light, 4→awake, 5→rem`,其他→light;分钟数=(end-start)//60,0 分钟丢弃 +- `sleep_id = f"{sid or user_id}_{item.time or int(睡眠结束ts)}"` + +**heart_rate** +- `{type, bpm}`:`type==0 → sample_type="passive"`,否则 `"active"` +- **resting_heart_rate**(独立 key):`{date_time, bpm}` → `sample_type="resting"`;时间戳优先 `date_time` + +**weight(body_measurements)** +- `weight`;`bmi`;`body_fat_rate→body_fat_pct`;`muscle_rate→muscle_mass_kg`;`moisture_rate→water_pct`;`bone_mass`;`visceral_fat→visceral_fat_score`;`basal_metabolism→basal_metabolism_kcal`;`body_age→metabolic_age` +- id:`mi_fitness_weight_{item.time}` + +**workouts(sport 端点)** +- `start_time`(兜底 `time`)、`end_time`(缺省 = start+duration 秒)、`duration`(秒) +- `distance`、`calories`(备选 `total_cal`)、`avg_hrm`、`max_hrm`、`avg_pace`、`max_pace`、`steps`(备选 `total_steps`)、`sport_type` +- `activity_type = item.category or item.key or sport_type or "workout"` +- `workout_id = f"{sid or user_id}_{item.key or 'workout'}_{item.time or start_ts}"` + +**spo2**:`{time, spo2 | value}`;timestamp 优先 value 内 `time` +**stress**:`{time, stress | score | value}`;level 按分数分段 +**abnormal_heart_beat**:`{start_time, end_time}`;`event_id = f"{sid or user_id}_{int(start_ts)}"` + +数值清洗:`_optional_float/_optional_int` 把 **0 值转 None**(缺省语义与“真实 0”被合并,见 §15-I6)。 + +### 8.5 错误处理与重试(_request 循环) + +- 重试 `request_retries`(默认 3)次,指数退避 `min(4s, 0.5*2^attempt) + rand(0..0.1)`。 +- 可重试:httpx 超时/网络错误;HTTP 429、≥500。 +- 认证失败(401/403 或业务码/message 命中):`_connected=False`,若还有重试余量且持有凭据 → **用 passToken 重新登录**并继续重试;重新登录失败 → 立即终止。 +- 其他业务错误:立即终止。 +- 最终失败统一抛 `RuntimeError("Mi Fitness request failed: ...")`(认证错误原样抛 `MiFitnessAuthenticationError`)。 +- ⚠ 全部错误被包装后,上层无法再区分认证失败与普通失败(`MiFitnessAuthenticationError` 在 `_request` 末尾被重新抛出,这一条保留;但 `connect()` 会把一切折叠成 `False` + 字符串 last_error)。 + +### 8.6 区域发现与时区 + +- `_discover_region(preferred)`:对候选区域依次试拉 `weight/steps/heart_rate`(固定区间 2025-04-01~2025-05-31),有数据即选中。**当前永远不会被调用**(region 恒为非空,见 §9.1 注释)——准死代码。 +- `_request_timezone`:`cn`/空 → UTC+8;其余 → UTC。`Config.timezone` 字段不参与此逻辑。 + +## 9. 适配器层 + +`adapters/base.py`:`DataAdapter(ABC)` 抽象 4 个基础方法(`connect/is_connected/get_user_id/get_available_data_types`)+ 8 个 `iter_*`(同步或异步迭代器二选一,签名返回 `Iterator | AsyncIterator` 联合类型——重构时建议收敛为纯 async)。 + +`adapters/mi_fitness_cloud.py` 实现要点: + +- **懒连接**:MCP 启动时只构造不连接(`server.main` 注释:stdio 必须快速可用);所有工具按需 `connect()`。`connect()` 有 `asyncio.Lock` 防并发,成功后 `_discover_data_types()` 返回**固定 8 类型列表**(不再探测,注释说明了原因:固定历史区间探测会静默漏数据)。 +- `health_check()`:真实调用 `_fetch_key("steps", today, today)`(1 天小请求验证认证+数据通路);瞬时网络失败**不会**使有效会话失效。 +- `iter_*` 的共同骨架:`if not connected or 没日期: return; yield`(空迭代器)→ 拉取 → 解析 yield。**注意 `return; yield` 写法**是为了把生成函数变为 async generator 的惯用法。 +- 分页辅助 `_fetch_key`(fitness)/ `_fetch_sport_records_by_time`(sport):见 §8.3。 +- `_record_datetime`:`datetime.fromtimestamp(item.time, tz=timezone(item.zone_offset))` —— 所有记录时间都带**记录自身**的时区偏移。 +- 每次 `connect()` 都会新建 `httpx.AsyncClient` 并重登录;`close()` 关闭并置 `_connected=False`。 +- 可调参数(由 server 从 Config 注入):`http_timeout / request_retries / max_pages`。 + +## 10. 同步服务 + +`services/sync_service.py`。`SyncService(adapter, db, default_lookback_days=30, chunk_days=7)`。 + +### 10.1 并发控制 + +- `sync_data_type()`:`if self._sync_active or self._sync_lock.locked(): raise RuntimeError` → 置 `_sync_active=True`(**无 await 的检查+置位**,在单事件循环内原子)→ `async with lock` 执行 → finally 复位。 +- server.py 另有全局 `sync_active` 布尔(拒绝并发 sync_data 工具调用)——**两把锁叠加**。 + +### 10.2 单类型同步算法 `_sync_data_type_unlocked` + +1. 增量游标:非 force_full 时读 `sync_state.last_record_timestamp`(`datetime.fromisoformat` 后 `replace(tzinfo=None)` —— ⚠ 丢偏移量)。 +2. 日期缺省:`end_date` 默认今天(**本地时区 naive**);`start_date` 缺省 = 游标日期(若有)否则 `end - (lookback-1)` 天。 +3. 校验 `start ≤ end`,格式必须 `YYYY-MM-DD`。 +4. **分块循环**:以 `chunk_days`(7)切分区间,逐块调 `_sync_range`;单块异常 → 记录 chunk error 并**立即停止**后续块,返回 `status=partial`(若已有成功块)或 `error`。 +5. 汇总 `{status, data_type, added, updated, skipped, start_date, end_date, chunks[]}`;每个 chunk 记录 added/updated/skipped。 +6. `skipped` 恒为 0(计数器从未自增)。 + +### 10.3 `_sync_range`(单块落库) + +- 按 `data_type` 分派到 8 个 `iter_*`,逐条 `db.insert_*`;返回 True 计 added,False 计 updated(**实际永远 True**,见 §15-I2)。 +- 逐条跟踪 `last_ts`(各类型用不同字段:activity 用 `collected_at`、sleep/workout/abnormal 用 `start_at`、其余用 `timestamp`)。 +- 块结束时 `db.update_sync_state(data_type, last_ts)`(仅当 last_ts 非空)。 +- 未知类型抛 `ValueError`。 +- `sync_data_type_sync()`:`asyncio.run` 包装的同步版本 —— **在已有事件循环内会炸**,当前无人调用(死代码)。 + +### 10.4 性能特征(实测) + +- 8 个月全类型同步 ≈ 每类型 `ceil(天数/7)` 次端点调用(heart_rate 还要 ×2:active+resting keys),单线程串行,总耗时数分钟。首次全量 2026-01-01→08-30 实测约 5–9 分钟;昨天+今天增量 <10 秒。 + +## 11. 存储层 + +`storage/__init__.py`(878 行)。同步 sqlite3,`sqlite3.Row`,**每次操作独立连接**(`contextmanager` 打开/关闭),每条 INSERT 单独 `conn.commit()`。 + +### 11.1 表结构(8 业务表 + sync_state) + +所有业务表公共列:`id TEXT PK, provider, source_type, source_record_id, user_id, device_id, timezone, collected_at, created_at, updated_at, …业务列`。时间值以 **ISO 字符串(带原 offset)** 存储。 + +| 表 | 业务列 | 业务 UNIQUE 约束 | UPSERT 冲突目标 | +|---|---|---|---| +| `daily_activity` | date, steps, distance_m, active_kcal, total_kcal, floors, active_minutes | `(user_id, date, device_id)` | `ON CONFLICT(id)` → 更新 steps/distance/active_kcal/timezone/collected_at 等 | +| `sleep_sessions` | sleep_id, start_at, end_at, duration_minutes, time_asleep_minutes, time_awake_minutes, sleep_score, is_nap, stages(JSON TEXT) | `(user_id, sleep_id)` | `ON CONFLICT(user_id, sleep_id)` → 不更新 start_at/end_at(⚠ 后端变化不会修正时间) | +| `workouts` | workout_id, activity_type, start_at, end_at, duration_minutes, distance_m, calories_kcal, avg/max_heart_rate_bpm, avg/max_pace_sec_per_km, total_steps | `(user_id, workout_id)` | `ON CONFLICT(user_id, workout_id)` → **不更新 activity_type/start_at/end_at/pace/steps** | +| `body_measurements` | timestamp, weight_kg, bmi, body_fat_pct, muscle_mass_kg, water_pct, bone_mass_kg, visceral_fat_score, basal_metabolism_kcal, metabolic_age | `(user_id, timestamp, device_id)` | `ON CONFLICT(id)` → 只更新 weight/bmi/body_fat/muscle/water | +| `heart_rate_samples` | timestamp, bpm, sample_type | `(user_id, timestamp, sample_type)` | `ON CONFLICT(id)` → 全字段更新;⚠ id=`mi_fitness_hr_{time}` 与业务约束不一致:同秒不同类型 → id 不同但撞业务 UNIQUE → `IntegrityError` | +| `spo2_samples` | timestamp, spo2_pct | `(user_id, timestamp)` | `ON CONFLICT(id)` | +| `stress_samples` | timestamp, stress_score, level | `(user_id, timestamp)` | `ON CONFLICT(id)` | +| `abnormal_heart_beat_events` | event_id, start_at, end_at, duration_seconds | `(user_id, event_id)` | `ON CONFLICT(user_id, event_id)` | +| `sync_state` | id PK, data_type UNIQUE, last_sync_at, last_record_timestamp, records_count | —— | `ON CONFLICT(data_type)`;⚠ `records_count` 永远是 0(从不更新) | + +索引:8 个 `(user_id, 日期/时间列)` 索引。无迁移机制(仅 `CREATE TABLE IF NOT EXISTS`)。 + +### 11.2 关键语义(重构必须知道) + +1. **insert 返回值不可信**:`cursor.rowcount > 0` 在 SQLite 的 `INSERT … ON CONFLICT DO UPDATE` 下插入和更新都返回 1 → added/updated 统计失真(实测重复同步显示「新增 242、更新 0」)。要区分需比较 `total_changes()` 或先查后写。 +2. **日期过滤是字符串比较**:查询用 `substr(时间列,1,10) >= ? AND <= ?`,比较的是 ISO 字符串前 10 位——即**记录自身时区的日期**。混合 `+08:00` 与 `Z` 记录时,同一“本地日”可能被切错;边界记录(23:xx / 00:xx)可能落入相邻日期桶。 +3. **created_at/updated_at 落库靠 SQLite 默认值**(CURRENT_TIMESTAMP,UTC),模型字段不传入。 +4. **阻塞风险**:所有 DB 调用是同步的,且被 async 服务直接调用(无 `to_thread`)——大查询会卡 MCP 事件循环。 +5. `get_data_coverage()`:对每表做 `MIN/MAX/COUNT(DISTINCT date)` 统计;daily 用 `date` 列,sleep/workouts/measurements/HR 用 `substr(...,1,10)`,spo2/stress/abnormal 用 `date(expr)`(三种风格并存)。 + +## 12. 查询服务 + +`services/query_service.py`。`QueryService(db, user_id)` —— 全部为**本地库读后内存加工**,不做 SQL 聚合。 + +| 方法 | 行为 | +|---|---| +| `get_daily_summaries(start, end)` | 拉区间内 daily_activity,按日期分组求和(steps/distance/active_kcal/total_kcal/floors/active_minutes) | +| `get_metric_series(metric, start, end, granularity, aggregation)` | 基于 summaries 取单指标序列;`granularity=week/month` 时内存聚合(周以周一为起点);**metric 枚举里的 `weight_kg` 永远取不到值**(summaries 没有该字段) | +| `get_sleep_sessions(..., include_naps)` | 过滤 nap;stages 从 JSON 解析;按 start_at 排序 | +| `get_workouts(...)` | 内存过滤 activity_types(lower 比较)/min_duration/min_distance_km | +| `get_body_measurements(...)` | 只输出非 None 指标;metrics 参数做字段白名单过滤 | +| `get_heart_rate_samples(...)` | 内存过滤 sample_type;**limit 在 Python 侧切片**(全量拉出后截断,大区间低效) | +| `get_spo2_samples / get_stress_samples / get_abnormal_heart_beat_events` | 同上模式(stress 支持 level 过滤) | +| `get_data_coverage(data_types?)` | 转发 db 统计,可按类型过滤 | + +## 13. MCP Server + +`server.py`。**架构特征:模块级全局单例** `config/db/adapter/sync_service/query_service/sync_tasks/sync_active`,由 `main()` 初始化。 + +### 13.1 生命周期 + +- `main()`:加载配置 → 建 `Database`(建表)→ `mode==mi_fitness_cloud` 且 keyring 有凭据时构造 adapter 并注入 http_timeout/retries/max_pages → 构造两个 service → `stdio_server()` 启动。**启动时不 connect**(保证 MCP 握手不被网络阻塞)。 +- 退出 finally:取消所有后台 sync task(gather 吞异常)→ `adapter.close()`。 +- ⚠ `config` 为 None 或 mode=not_configured 时:连接类工具报错,查询类工具走空库(QueryService 依然创建)。 + +### 13.2 工具清单(14 个) + +| 工具 | 必填参数 | 行为要点 | +|---|---|---| +| `get_connection_status` | — | 同步中→`is_connected()`;否则 `health_check()`(带 `health_check_timeout`)。合并 last_sync(8 类型里最新的 sync_state.last_sync_at)、available_types(连接成功用 adapter 的,否则从 sync_state 推断)、附加 `connection_state/region/last_health_check_at/last_connection_error/sync_in_progress` | +| `sync_data` | — | `data_types[]`(缺省=8 类型全量)、`start_date/end_date`、`force_full_sync`、`background`。空数组报错;未知类型报错。**全局 sync_active 防重入** | +| `get_sync_status` | `sync_id` | 查后台任务表(剔除 task 对象) | +| `get_profile` | — | 静态拼装 `{user_id, timezone, devices:[]}` | +| `get_daily_summary` | `date` 或 `start_date+end_date` | date 同时充当 start/end | +| `query_metric_series` | `metric, start_date, end_date` | metric 枚举 `steps/distance_m/active_kcal/weight_kg`(weight_kg 死枚举);granularity `day/week/month`;aggregation `sum/avg/min/max/latest`(⚠ latest 在服务层落到 else 分支=**sum**) | +| `query_heart_rate` | `start_date, end_date` | `sample_type` 枚举 `resting/active/passive/workout`;`limit` | +| `query_body_measurements` | 同上 | `metrics[]` 白名单;`latest_only` 取最后一条 | +| `query_sleep` | 同上 | `include_naps` 默认 True | +| `query_workouts` | 同上 | `activity_types/min_duration/min_distance_km` | +| `query_spo2` / `query_stress` / `query_abnormal_heart_beat` | 同上 | stress 支持 `level` 枚举过滤 | +| `get_data_coverage` | — | `data_types[]` 可选过滤 | + +完整 inputSchema JSON 见附录 A。响应统一为 `[TextContent(json.dumps(result))]`,异常捕获后返回 `{"status":"error","error":str}`(不抛给 MCP 层)。 + +### 13.3 后台同步(background=true) + +- `_prune_sync_tasks()`:清理已完成任务至多保留 `MAX_SYNC_TASKS=100`。 +- `sync_id=uuid4`;task 表条目 `{sync_id, status: queued→running→ok/partial/error/cancelled, created_at, started_at, …结果}`。 +- 前台/后台共用 `_run_sync_data`:按需 `adapter.connect()` → 校验/展开 data_types → 逐类型 `asyncio.wait_for(sync_service.sync_data_type(...), sync_type_timeout_seconds)`(**超时只标记该类型 error,不中断其他类型**)→ 汇总 `status: ok/partial/error`。 +- 取消传播:`asyncio.CancelledError` 单独处理置 `cancelled`。 +- ⚠ 任务表是内存 dict:**MCP 进程重启即丢失**;`get_sync_status` 对未知 id 返回 error。 + +## 14. CLI、辅助脚本与测试 + +### 14.1 CLI(main.py;注意:文件名叫 main,职责是 CLI) + +| 子命令 | 行为 | +|---|---| +| `serve`(或**无参数**) | `asyncio.run(server.main())` —— ⚠ 裸跑 `mi-fitness-mcp` 会直接启动 MCP server | +| `setup` | `--mode mi_fitness_cloud --user-id --pass-token --region`(mode 未给时进入交互式 input 向导);保存 keyring + config.json。⚠ 无任何参数校验(非空校验仅在交互路径) | +| `doctor` | 打印配置路径/加载配置/读凭据/真实 `connect()` 测连通(打印区域+8 类型)/检查 DB 文件存在性;配置缺失时 `sys.exit(1)` | +| `sync` | `--type`(单类型)+ `--start-date/--end-date`(缺省 lookback 30 天);连接失败 exit(1);逐类型打印 `新增/更新` 条数;**没有分块超时,也没有 `--force-full` 参数** | + +### 14.2 probe_mifitness.py + +独立交互式诊断脚本(268 行):**复制粘贴**了一份登录/RC4/签名代码(与 adapter 重复),支持对任意 key/区间做原始请求打印,用于逆向调试。重构候选:改为调用 adapter 的导出函数。 + +### 14.3 测试(18 个,全部通过) + +- `test_config.py`:配置 roundtrip。 +- `test_mi_fitness_cloud.py`:`_optional_*`、`_parse_value`(JSON 字符串与 dict 双形态)、`_record_datetime` zone_offset、分页游标环检测、`_is_authentication_error` 标记匹配等纯函数;respx mock HTTP。 +- `test_sync_service.py`:mock adapter 的分块/增量/partial 语义。 +- `test_storage_query.py`:入库+查询。 +- `test_server_sync.py`:server 层编排(mock adapter)。 +- 覆盖缺口:CLI 无测试;`_request` 加密路径无 golden-vector 测试;MCP 工具 handler 仅覆盖 sync 部分。 + +## 15. 已知问题清单 + +> 分级:**P0**=破坏正确性/可用性;**P1**=行为缺陷或明显技术债;**P2**=清理项。重构时按此核对。 + +**P0** + +- **I1 `mcp` 依赖无上界**:`mcp>=1.0.0` 会拉到 2.x,`Server.list_tools` 不存在 → server 与测试收集直接崩(已在本地 pyproject 修为 `>=1.0.0,<2`,**上游未修**,重构时保留上界或迁移到新 API)。 +- **I2 added/updated 统计失真**:UPSERT 下 `cursor.rowcount>0` 恒真 → 永远报“新增”(§11.2-1),连带 `sync_state.records_count` 恒 0。 +- **I3 同步游标时区混用**:`last_record_ts` 存的是带 offset 的 ISO 串,续传时 `replace(tzinfo=None)` 直接剥掉 → 跨时区/夏令时场景增量起点可能偏移数小时(边界记录重复或遗漏)。 +- **I4 HR 唯一约束与 id 冲突目标不一致**:`UNIQUE(user_id, timestamp, sample_type)` vs `ON CONFLICT(id)`,同秒双类型样本会抛 `IntegrityError` 中断整块同步(sync_range 无 per-record 容错)。 + +**P1** + +- **I5 事件循环阻塞**:同步 sqlite3 + 内存聚合直接在 async 上下文执行;大区间 heart_rate 查询(16k+ 行)会冻结 MCP 心跳/其他工具调用。 +- **I6 0 值被吞**:`_optional_float/int(0) → None`,把合法 0(如 0 步运动、0 kcal)当缺省丢弃。 +- **I7 日期过滤语义**:`substr(iso,1,10)` 字符串比较混合多 offset 记录(§11.2-2),查询/覆盖统计的“日”归属不稳定。 +- **I8 死枚举/死指标**:`query_metric_series.weight_kg` 永远无数据;`aggregation=latest` 落到 sum;`sample_type=workout` 永不产生;`skipped` 恒 0。 +- **I9 双重并发闸门**:server `sync_active` + SyncService 锁叠加;`sync_tasks` 内存态不可恢复。 +- **I10 协议层无测试保护**:RC4/签名/登录无 golden vectors,重构协议层极易悄悄破坏兼容性。 +- **I11 无迁移机制**:改表只能靠手工删库。 +- **I12 CLI 与 MCP 路径行为不一致**:CLI 无 per-type 超时/force_full;`serve` 是无参数默认行为(危险默认)。 +- **I13 每 chunk 单独 commit**:非原子块,中途断电留半块数据(靠 UPSERT 幂等兜底,但 sync_state 已前移时可能漏补)。 + +**P2** + +- **I14 死代码/孤儿**:`logs_path、auto_sync_on_start、stale_after_minutes、store_raw_payloads、timezone` 配置;`delete_mi_fitness_token`(无 logout 命令);`SyncResult/UserProfile/DeviceInfo/DataCoverage(gaps_detected)` 模型;`_discover_region`;`sync_data_type_sync`;`click/rich` 依赖;`.env.example`(无 env 加载)。 +- **I15 代码重复**:probe_mifitness.py 复制协议代码;query_service 周/月聚合两份近似函数;server 里 14 个 handler 高度模板化。 +- **I16 可观测性**:logger 无 handler(日志不知去向);`last_error` 只进工具响应。 +- **I17 命名混乱**:`main.py` 实为 CLI;`server.py` 里 `main()` 实为 MCP 入口。 +- **I18 Config 默认 `region="ru"`** 与文档/实际用法(cn)不一致。 +- **I19 base.py 抽象签名** `Iterator | AsyncIterator` 联合类型增加调用方分支(`_iterate_records` 就是为兼容它存在)。 + +## 16. 重构方案建议 + +### 16.1 硬性约束(不可破坏的兼容契约) + +1. **MCP 工具名与 inputSchema 保持不变**(14 个工具;客户端可能已固化调用方式)。响应 JSON 顶层字段保持兼容。 +2. **协议层逐字节兼容**:nonce 构造、RC4 drop-1024、双签名 base 串、登录前缀 `&&&START&&&`、分页参数——改协议必须先有 §16.4 的 golden vectors。 +3. **本地 DB 平滑升级**:老库文件必须能被新版本打开(或提供一次性迁移脚本),`sync_state` 游标不能丢。 +4. CLI 四个子命令与关键 flag 保持(可新增不可删除)。 +5. 保持 Python ≥3.11、`mcp>=1,<2` 上界。 + +### 16.2 目标架构(建议) + +``` +src/mi_fitness_mcp/ +├── app.py # AppContext:配置、db、adapter、services 集中装配,替代 server 模块级全局 +├── cli/ # main.py 拆分:cli/{__main__,setup,doctor,sync}.py +├── mcp_layer/ +│ ├── tools.py # 14 个工具的 schema 声明(数据驱动,替代 300 行 if-elif) +│ └── handlers.py # 薄 handler:解析参数→调 service→包 QueryResponse +├── protocol/ # ★从 adapter 抽出,纯函数化、可独立测试 +│ ├── xiaomi_auth.py # 登录(返回 serviceToken/ssecurity/cookies) +│ ├── crypto.py # nonce/rc4/signature(golden vectors) +│ └── client.py # XiaomiHealthClient:request/fetch_key/fetch_sport_records(重试/分页/错误分类) +├── adapters/ +│ ├── base.py # 纯 AsyncIterator 抽象 +│ └── mi_fitness_cloud.py # 仅做 payload→model 映射 +├── services/ +│ ├── sync_service.py # 分块/增量/游标(tz-aware) +│ └── query_service.py +├── storage/ +│ ├── db.py # aiosqlite(或 run_in_executor)+ 事务化 chunk commit +│ ├── schema.py # DDL + schema_version 迁移表 +│ └── repo.py # 按类型的仓储(UPSERT 用 total_changes 判定 added/updated) +├── config.py # pydantic-settings:JSON + 真实 env/.env 支持(落地 .env.example) +└── models/ # 现有模型 + 删除未用模型或接线 +``` + +### 16.3 优先修复映射(问题 → 方案) + +| 问题 | 方案 | +|---|---| +| I2 | UPSERT 后用 `conn.total_changes()` 差值或 `RETURNING` 判定 inserted/updated;顺带维护 `records_count` | +| I3 | 游标统一存 UTC(或带 offset 的完整 ISO),续传转 aware 再比较;默认日期一律 aware | +| I4 | HR 的 UPSERT 冲突目标改为业务 UNIQUE(`user_id,timestamp,sample_type`),id 与约束二选一统一 | +| I5 | storage 全链路 aiosqlite/线程池;查询下推 SQL(LIMIT/聚合) | +| I7 | 日期列改为存 UTC + 原始 offset 两列,或查询时用 `unixepoch()` 换算到用户时区再比较 | +| I6 | `_optional_*` 只把 None/空转 None,保留 0 | +| I8 | 删 weight_kg 枚举或实现体重序列(join body_measurements);`latest` 聚合单独实现;HR workout 类型留待协议支持或删枚举 | +| I9 | AppContext 单一同步闸门;后台任务可选落盘(sync_state 里记 running 状态) | +| I13 | 每 chunk 一个事务(数据+游标同 commit) | +| I11 | `schema_version` 表 + 线性迁移函数链 | + +### 16.4 验收标准 + +1. `pytest` 通过,且新增:crypto golden vectors(固定 ssecurity/nonce → 固定密文/签名)、8 类 payload fixture 解析快照测试、分块边界(chunk=1 天/跨月)、UPSERT added/updated 正确性、时区边界(+08:00 与 Z 混合查询)。 +2. 手工验收序列(当前环境可直接跑): + - `mi-fitness-mcp doctor` → 连接 ✅、8 类型齐全; + - `sync --start-date <昨天> --end-date <今天>` → 与重构前逐表 rowcount/内容 diff 一致; + - `serve` + MCP 客户端调用全部 14 工具成功; + - 老库文件(本机 `AppData\Local\mi-fitness-mcp\mi-fitness-mcp\mi_fitness.db`,约 19k 行数据)可直接打开且覆盖统计正确。 +3. 行为不回退:8 个月全量同步耗时不显著劣化;单类型超时(180s)语义保留。 + +## 17. 开发环境备注 + +- 要求 Python ≥ 3.11;开发安装 `pip install -e '.[dev]'`(含 pytest/ruff/build)。 +- 跑测试:`python -m pytest -q`(18 passed,pytest-asyncio auto 模式)。 +- Lint:`python -m ruff check src tests`(CI 同款规则集,见 pyproject `[tool.ruff]`)。 +- 构建:`python -m build`(hatchling)。 +- 运行时产物均在系统用户目录(platformdirs),不在仓库内:config.json、SQLite 库、keyring 凭据;`.gitignore` 已排除 `.venv/ *.db .env` 等。 + +## 18. HTTP API 扩展(新增模块) + +在 MCP 之外提供同能力的 REST 封装,供普通程序直接调用: + +- 模块:`src/mi_fitness_mcp/api.py`(FastAPI + uvicorn,可选依赖组 `pip install -e '.[api]'`)。 +- 启动:`mi-fitness-mcp api [--host 127.0.0.1] [--port 8321]`(新 CLI 子命令;绑定非本机地址且未设 key 时打印警告)。 +- 鉴权:设置环境变量 `MI_FITNESS_API_KEY` 后,全部接口要求请求头 `X-API-Key`;未设置则不鉴权(仅建议 127.0.0.1 场景)。已全局启用 CORS。 +- 生命周期:lifespan 中装配 config/Database/adapter/SyncService/QueryService(与 `server.main` 相同的懒连接策略),退出时取消后台同步任务并关闭 adapter。查询端点为同步 `def`(FastAPI 线程池执行,不阻塞事件循环)。 +- 端点一览(均为 `/api` 前缀,响应信封 `{"status","count","data"}` 或同步结果对象): + - `GET /` 服务信息;`GET /docs` Swagger UI + - `GET /api/status?deep=true|false`(deep=true 真实健康检查) + - `POST /api/sync` body `{data_types?, start_date?, end_date?, force_full_sync?, background?}`;前台等待返回汇总,`background=true` 返回 `{status:"accepted", sync_id}` + - `GET /api/sync/{sync_id}` 后台任务状态(内存态,重启即失) + - `GET /api/summary?start_date&end_date` + - `GET /api/metric-series?metric&start_date&end_date&granularity&aggregation`(metric 限 steps/distance_m/active_kcal,已在 API 层校验) + - `GET /api/heart-rate?start_date&end_date&sample_type&limit` + - `GET /api/sleep?start_date&end_date&include_naps` + - `GET /api/workouts?start_date&end_date&activity_types&min_duration&min_distance_km` + - `GET /api/body-measurements?start_date&end_date&metrics&latest_only` + - `GET /api/spo2` / `GET /api/stress?level` / `GET /api/abnormal-heart-beat` + - `GET /api/coverage?data_types` +- 错误约定:参数格式错→400,重复同步→409,未初始化→503,云连接失败→502,未知 sync_id→404,鉴权失败→401。 +- 已知取舍:`sync_tasks` 为内存态;查询与同步并发时 sqlite 写入可能偶发 busy(上游 I5/I13 修复后自然消除)。 + +## 19. API Key 体系与扫码登录(v0.2 新增) + +### 19.1 鉴权模型(类大模型平台) + +三级访问,实现在 `api.py` 的 `_gate`(入口闸门)+ `_resolve_context`(凭据解析): + +1. **默认凭据**:不带头 = 使用本机配置(keyring)的凭据,仅建议 127.0.0.1。 +2. **静态 Key**:环境变量 `MI_FITNESS_API_KEY` 设置后强制全员带头;该值本身也始终有效(管理员通道)。 +3. **发放 Key**:`mif_sk_<40 hex>`,存主库 `api_keys` 表(key/label/user_id/pass_token/region/created_at/last_used_at/revoked)。带 Key 请求按 Key 绑定的小米凭据解析出**独立 UserContext**(adapter/sync/query 三服务按凭据缓存),实现多账号隔离;每次使用更新 last_used_at。 + +管理端点(keys/qr)鉴权:环境变量 `MI_FITNESS_ADMIN_KEY` + `X-Admin-Key` 头;未设置该变量时仅允许本机调用。 + +### 19.2 Key 管理端点 + +- `POST /api/auth/keys` body `{user_id, pass_token, region?, label?}` → **真实登录小米验证凭据后**发放 Key(失败 400 返回原因)。完整 Key 仅返回一次。 +- `GET /api/auth/keys` → 列表(Key 打码,如 `mif_sk_ab12cd…ef34`)。 +- `DELETE /api/auth/keys/{prefix}` → 按完整 Key 或唯一前缀吊销(软删 revoked=1)。 + +### 19.3 扫码登录(逆向自 account.xiaomi.com 通用 QR 流程) + +流程与端点(参考 python-miio `cloud_qr.py`,MIT): + +1. `GET https://account.xiaomi.com/longPolling/loginUrl`,参数**必须** `sid=xiaomiio` + `callback=https://sts.api.io.mi.com/sts`(其他组合返回 code=10025「Callback连接不合法」;`miothealth` 不被扫码接口接受)。响应剥 `&&&START&&&` 后含 `qr`(二维码图片 URL)、`lp`(长轮询 URL)、`loginUrl`(浏览器确认链接)、`timeout`(300 秒)。 +2. 用户用小米账号/米家 App 扫码确认,或浏览器打开 loginUrl 登录确认。 +3. `GET {lp}` 长轮询,确认后返回 `userId` + 账号级 `passToken`(passToken 与 sid 无关,可再用 serviceLogin 换 miothealth 会话)。 +4. 服务端校验凭据可登录后自动发放 API Key。 + +API 封装:`POST /api/auth/qr/start?region=cn`(返回 qr_token、PNG 地址、login_url)→ `GET /api/auth/qr/{token}.png` → `GET /api/auth/qr/poll?token=...`(waiting / confirmed{api_key,user_id} / expired)。qr_sessions 为内存态,重启失效。 + +### 19.4 Python Flask Web 仪表盘与反向代理(web.py / testpage) + +基于 Python Flask 构建的现代化 Web 仪表盘与反向代理服务(无需 Rust 工具链,纯 Python 生态一键启动): +- `GET /`:提供极富设计感的现代化中文健康数据仪表盘(全部数据端点 + 同步中心 + API Key 发放/列表/吊销 + 扫码二维码登录弹窗 + 多维图表 + 实时响应检视器)。 +- `/proxy/*`:反向代理到 Python FastAPI 后端,服务端自动中继 `X-API-Key` 与 `X-Admin-Key`(凭据与密钥不进浏览器),4xx/5xx 状态码与耗时(`X-Upstream-Time-Ms` 响应头)原样透传。 +- 启动命令:`mi-fitness-mcp web` 或 `python testpage/app.py`。 +- 环境变量:`BIND`(默认 127.0.0.1:8322)、`MI_FITNESS_API_URL`(默认 127.0.0.1:8321)、`MI_FITNESS_API_KEY`、`MI_FITNESS_ADMIN_KEY`。 + +--- + +## 附录 A:MCP 工具 inputSchema 全量 JSON + +```json +{ + "get_connection_status": {"type": "object", "properties": {}}, + "sync_data": { + "type": "object", + "properties": { + "data_types": {"type": "array", "items": {"type": "string"}}, + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "force_full_sync": {"type": "boolean"}, + "background": {"type": "boolean", "default": false} + } + }, + "get_sync_status": { + "type": "object", + "properties": {"sync_id": {"type": "string"}}, + "required": ["sync_id"] + }, + "get_profile": {"type": "object", "properties": {}}, + "get_daily_summary": { + "type": "object", + "properties": {"date": {"type": "string"}, "start_date": {"type": "string"}, "end_date": {"type": "string"}} + }, + "query_metric_series": { + "type": "object", + "properties": { + "metric": {"type": "string", "enum": ["steps", "distance_m", "active_kcal", "weight_kg"]}, + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "granularity": {"type": "string", "enum": ["day", "week", "month"]}, + "aggregation": {"type": "string", "enum": ["sum", "avg", "min", "max", "latest"]} + }, + "required": ["metric", "start_date", "end_date"] + }, + "query_heart_rate": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "sample_type": {"type": "string", "enum": ["resting", "active", "passive", "workout"]}, + "limit": {"type": "integer"} + }, + "required": ["start_date", "end_date"] + }, + "query_body_measurements": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "metrics": { + "type": "array", + "items": {"type": "string", "enum": ["weight_kg", "bmi", "body_fat_pct", "muscle_mass_kg", "water_pct"]} + }, + "latest_only": {"type": "boolean"} + }, + "required": ["start_date", "end_date"] + }, + "query_sleep": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "include_naps": {"type": "boolean"} + }, + "required": ["start_date", "end_date"] + }, + "query_workouts": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "activity_types": {"type": "array", "items": {"type": "string"}}, + "min_duration": {"type": "integer"}, + "min_distance_km": {"type": "number"} + }, + "required": ["start_date", "end_date"] + }, + "query_spo2": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "limit": {"type": "integer"} + }, + "required": ["start_date", "end_date"] + }, + "query_stress": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "level": {"type": "string", "enum": ["low", "medium", "high"]}, + "limit": {"type": "integer"} + }, + "required": ["start_date", "end_date"] + }, + "query_abnormal_heart_beat": { + "type": "object", + "properties": { + "start_date": {"type": "string"}, + "end_date": {"type": "string"}, + "limit": {"type": "integer"} + }, + "required": ["start_date", "end_date"] + }, + "get_data_coverage": { + "type": "object", + "properties": {"data_types": {"type": "array", "items": {"type": "string"}}} + } +} +``` + +## 附录 B:响应信封示例 + +查询类(`QueryResponse`): + +```json +{"status": "ok", "source": "cache", "generated_at": "2026-08-31T10:00:00", "timezone": "UTC", "data": {"samples": [{"timestamp": "2026-08-08T04:09:00+08:00", "bpm": 93, "sample_type": "passive"}], "count": 1}, "error": null} +``` + +同步类(`_run_sync_data`): + +```json +{ + "status": "ok | partial | error", + "sync_id": "uuid", + "started_at": "...", "finished_at": "...", + "records_added": 0, "records_updated": 0, "records_skipped": 0, + "data_types_synced": ["daily_activity"], + "results": [{"data_type": "daily_activity", "status": "ok", "added": 1, "updated": 0, "skipped": 0, "start_date": "...", "end_date": "...", "chunks": [{"start_date": "...", "end_date": "...", "status": "ok", "added": 1, "updated": 0, "skipped": 0}], "duration_seconds": 1.2}] +} +``` + +## 附录 C:config.json 实例(本机实际生成) + +```json +{ + "mode": "mi_fitness_cloud", + "region": "cn", + "timezone": "UTC", + "database_path": "C:\\Users\\\\AppData\\Local\\mi-fitness-mcp\\mi-fitness-mcp\\mi_fitness.db", + "logs_path": "C:\\Users\\\\AppData\\Local\\mi-fitness-mcp\\mi-fitness-mcp\\logs\\mi_fitness.log", + "auto_sync_on_start": true, + "stale_after_minutes": 60, + "store_raw_payloads": true, + "default_lookback_days": 30, + "sync_chunk_days": 7, + "http_timeout_seconds": 20.0, + "request_retries": 3, + "health_check_timeout_seconds": 10.0, + "sync_type_timeout_seconds": 180.0, + "max_pages": 200 +} +``` diff --git a/docs/QR_LOGIN.md b/docs/QR_LOGIN.md new file mode 100644 index 0000000..a50d7a6 --- /dev/null +++ b/docs/QR_LOGIN.md @@ -0,0 +1,209 @@ +# 小米扫码登录实现方案 + +> 本文档只覆盖「扫码登录」这一部分:协议原理 → 服务端实现 → API 用法 → 安全与限制。 +> 代码位置:`src/mi_fitness_mcp/api.py`(`/api/auth/qr/*` 三个端点);测试页入口:`mi-fitness-api-test`(扫码面板)。 +> 协议来源:逆向小米账号通用扫码流程,参考 python-miio `cloud_qr.py`(MIT)。 + +--- + +## 1. 目标 + +用「手机扫码」替代「浏览器 F12 手抠 Cookie」来获取小米凭据 `userId + passToken`,扫码成功后服务端自动发放本平台的 API Key(`mif_sk_*`)。整个过程中小米凭据只出现在服务端,调用方全程只接触 API Key。 + +小米对第三方**密码登录**已加验证码,但**扫码流程不受影响**——这是选择扫码方案的核心原因。 + +## 2. 小米扫码协议(逆向所得) + +整个协议只有两个云端端点,全部是 GET,无需任何预置凭据: + +``` +手机App 我们的服务端 小米账号服务 + │ │ │ + │ │ ① GET /longPolling/loginUrl │ + │ │◄──────────────────────────────│ + │ │ 返回 qr / lp / loginUrl/ticket│ + │ │ │ + │ ② 用户扫码→点确认 │ │ + │◄───────────────────────│ (二维码 = 带 ticket 的URL) │ + │──── 确认登录 ──────────────────────────────────────────►│ + │ │ │ + │ │ ③ GET {lp} 长轮询(挂起) │ + │ │◄──────────────────────────────│ + │ │ 返回 userId + passToken │ +``` + +### 2.1 端点一:生成登录会话 + +``` +GET https://account.xiaomi.com/longPolling/loginUrl +``` + +| 参数 | 值 | 说明 | +|---|---|---| +| `sid` | `xiaomiio` | **必须是这个值**,换 `miothealth` 会返回 10025 错误 | +| `callback` | `https://sts.api.io.mi.com/sts` | **必须是这个值**,其他值同样 10025 | +| `qs` | `%3Fsid%3Dxiaomiio%26_json%3Dtrue` | 即 `?sid=xiaomiio&_json=true` 的 URL 编码 | +| `_qrsize` | `480` | 二维码图片尺寸 | +| `_hasLogo` | `false` | 二维码中央不放 logo | +| `_locale` | `zh_CN` | 界面语言 | +| `_dc` | 当前毫秒时间戳 | 防缓存 | + +响应体以固定前缀 `&&&START&&&` 开头(小米账号接口的通用 guard),剥掉后是 JSON: + +```json +{ + "qr": "https://account.xiaomi.com/pass/qr/login?ticket=lp_xxxx", + "loginUrl": "https://c3.account.xiaomi.com/longPolling/login?ticket=lp_xxxx", + "lp": "https://c3.lp.account.xiaomi.com/lp/s?k=lp_xxxx", + "timeout": "300" +} +``` + +| 字段 | 含义 | +|---|---| +| `qr` | 二维码图片的 URL(内容就是它自己,服务端需代抓 PNG) | +| `loginUrl` | 电脑端兜底:浏览器打开它 → 登录账号 → 点确认,效果等同扫码 | +| `lp` | 长轮询地址,用来等"用户已确认"的结果 | +| `timeout` | 本次登录会话有效期(秒),一般 300 | + +三个 URL 共享同一个一次性 `ticket`(`lp_` 前缀),ticket 就是"这一次登录会话"的身份。 + +### 2.2 端点二:长轮询确认结果 + +``` +GET {lp} +``` + +- 用户**尚未确认**:请求挂起(长轮询),十几秒后超时返回,继续轮询即可。 +- 用户**已确认**:立即返回 HTTP 200,剥 `&&&START&&&` 前缀后: + +```json +{"code": 0, "userId": 1234567890, "passToken": "V1:..."} +``` + +`userId` + `passToken` 与浏览器 Cookie 中手抠到的完全同种,且是**账号级**凭据(不绑定 xiaomiio 服务)。 + +### 2.3 关键坑(已修复并验证) + +| 坑 | 现象 | 结论 | +|---|---|---| +| sid 不能用 `miothealth` | 返回 `{"code":10025,"desc":"Callback连接不合法"}` | 扫码入口只认 `sid=xiaomiio` + `callback=https://sts.api.io.mi.com/sts` 这一个组合 | +| passToken 是否绑服务 | 担心 xiaomiio 的 token 不能用于运动健康 | **不绑定**。拿到后走 `serviceLogin?sid=miothealth` 可正常换运动健康会话 | +| 响应前缀 | 直接 `json.loads` 报错 | 所有账号接口都要先剥 `&&&START&&&` | +| 长轮询挂起 | 超时/网络异常频繁 | 属正常节奏,捕获后继续轮询,不算失败 | + +## 3. 服务端实现(api.py) + +小米的流程是"一次长轮询阻塞到底",直接搬到 HTTP API 会让请求挂几分钟。实现上改造成**提交-轮询**模式,拆成三个端点: + +### 3.1 状态机 + +``` +POST /start 生成会话 + │ + ▼ + [waiting] ──poll 时调 lp 且收到 code=0+userId+passToken──► [confirmed](含 api_key) + │ │ + └─── 超过 timeout+10 秒 ──► [expired] 之后 poll 永远返回 confirmed 结果 +``` + +### 3.2 端点一:`POST /api/auth/qr/start?region=cn` + +服务端向小米请求登录会话,把 `lp` / `loginUrl` / 二维码 PNG / 过期时间 / region 存入内存字典 `qr_sessions`(key 为 uuid `qr_token`),对外返回: + +```json +{ + "qr_token": "b5ca13315c1f4a62805a47916c82404a", + "qr_image": "/api/auth/qr/b5ca13315c1f4a62805a47916c82404a.png", + "login_url": "https://c3.account.xiaomi.com/longPolling/login?ticket=lp_...", + "expires_in": 300, + "instruction": "用小米运动健康/小米账号 App 扫码,或在浏览器打开 login_url 确认;然后轮询 /api/auth/qr/poll?token=..." +} +``` + +二维码 PNG 由服务端代抓并缓存进会话,浏览器拿到的是现成图片,不直连小米。 + +### 3.3 端点二:`GET /api/auth/qr/{token}.png` + +返回会话中缓存的 PNG(480×480,实测约 15KB),前端 `` 直接显示。token 不存在返回 404。 + +### 3.4 端点三:`GET /api/auth/qr/poll?token=xxx` + +每次调用最多阻塞约 15 秒(lp 单次挂起上限),无异常时返回三种状态之一: + +| 返回 | 含义 | 后续动作 | +|---|---|---| +| `{"status":"waiting"}` | 未确认/网络抖动 | 前端隔几秒再调 | +| `{"status":"confirmed","api_key":"mif_sk_...","user_id":"..."}` | 登录成功,Key 已发放 | 前端存下 Key,此后带头调用 | +| `{"status":"expired"}` | 超 5 分钟未确认 | 重新调 start 生成新码 | + +**confirmed 时服务端做的三件事**(全部在返回前完成): + +1. 从 lp 结果提取 `userId` / `passToken`; +2. 用这套凭据真实构造 `MiFitnessCloudAdapter` 并 `connect()` 一次——验证 passToken 能换到运动健康的 serviceToken,避免发放无效 Key;验证失败则返回 `confirmed` 但 `api_key: null` 并附说明; +3. 凭据写入主库 `api_keys` 表(key / user_id / pass_token / region / created_at),发放 `mif_sk_<40位hex>`,完整 Key 仅此一次返回。 + +### 3.5 设计取舍 + +| 决策 | 理由 | +|---|---| +| 长轮询放进 poll 而不是 start | start 里阻塞等扫码会让 HTTP 请求挂 5 分钟,网关/浏览器易超时;改为前端每几秒调一次 poll,单次最多 15 秒 | +| `qr_sessions` 纯内存 | 存的是未完成登录的临时 ticket,天然短命,重启失效无副作用(重新生成即可),不值得持久化 | +| passToken 不进前端 | 扫码结果由服务端直接落库换 Key,浏览器全程只见 `mif_sk_*`,与 Key 体系安全模型一致 | +| 发 Key 前真实验证 | 防止把"扫码成功但服务受限"的凭据发出去,问题在发放时刻暴露而非首次调用时 | +| poll 的 confirmed 常驻内存 | 让前端可以重复查询结果;代价是进程重启后确认记录丢失(重新扫码即可) | + +## 4. 凭据的后续链路(扫码之外) + +扫码只解决"凭据从哪来"。拿到 `userId + passToken` 后的流程与手动配置完全一致: + +``` +passToken ──GET serviceLogin?sid=miothealth──► ssecurity(签名密钥) + serviceToken(会话Cookie) + │ + └──► 健康数据接口(RC4 加密 + SHA1 签名,见主文档第 8 章) +``` + +即:扫码、`mi-fitness-mcp setup` 手动配置、`POST /api/auth/keys` 发放,三条路最终汇合到同一套凭据模型,扫码登录对 sync/query 全部端点透明生效。 + +## 5. 使用方法 + +### 5.1 测试页(推荐) + +打开 `http://127.0.0.1:8322/` → 「扫码登录」面板 → 点「生成登录二维码」→ 手机小米账号/米家 App 扫码并确认 → 回页面点「轮询扫码状态」→ confirmed 后 Key 自动填入右上角请求头。 + +### 5.2 纯 API 调用 + +```bash +# 1. 生成二维码 +curl -X POST "http://127.0.0.1:8321/api/auth/qr/start?region=cn" +# → {"qr_token":"...","qr_image":"/api/auth/qr/....png","login_url":"https://...","expires_in":300} + +# 2. 展示二维码:浏览器打开 http://127.0.0.1:8321/api/auth/qr/{qr_token}.png +# 或让用户直接打开返回的 login_url 在网页上确认 + +# 3. 轮询(未确认返回 waiting,可循环调用) +curl "http://127.0.0.1:8321/api/auth/qr/poll?token={qr_token}" +# → {"status":"confirmed","api_key":"mif_sk_...","user_id":"..."} +``` + +### 5.3 鉴权要求 + +三个 `/api/auth/qr/*` 端点都受管理鉴权保护: + +- 设置了环境变量 `MI_FITNESS_ADMIN_KEY`:必须带 `X-Admin-Key` 头; +- 未设置:仅允许 `127.0.0.1` 本机调用,非本机返回 403。 + +## 6. 安全说明 + +- **passToken 是长期凭据**:等同登录态,泄露后应退出并重新登录小米账号使其失效。本方案中它只在服务端内存与 `api_keys` 表中出现一次。 +- **扫码 = 把账号登录到你的服务器**:`/api/auth/qr/start` 绝不能无鉴权暴露在局域网/公网。 +- **ticket 一次性且 5 分钟过期**:小米侧控制,无法延长;过期重新生成即可。 +- **内存态会话**:服务重启丢失所有未完成的扫码会话,无持久化泄露面。 + +## 7. 已验证与已知边界 + +| 状态 | 项 | +|---|---| +| ✅ 已验证 | 会话生成、二维码 PNG 有效(480×480)、poll 未扫码返回 waiting、10025 问题修复、Key 发放→使用→吊销全链路 | +| ⏳ 需人工 | 用手机实际扫码走完 confirmed 全流程(扫码登录的本意,无法程序化替代) | +| 已知限制 | 二维码 5 分钟过期;confirmed 记录仅存内存;协议为逆向所得,小米改版(如 ticket 格式变化)会静默失效,需重新抓包对照 | diff --git a/pyproject.toml b/pyproject.toml index 4a9f112..f97aa2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mi-fitness-mcp" -version = "0.1.0" +version = "0.2.0" description = "MCP server for Mi Fitness data access" readme = "README.md" license = {text = "MIT"} @@ -23,7 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "mcp>=1.0.0", + "mcp>=1.0.0,<2", "pydantic>=2.0.0", "httpx>=0.27.0", "platformdirs>=4.0.0", @@ -33,6 +33,18 @@ dependencies = [ ] [project.optional-dependencies] +api = [ + "fastapi>=0.110.0", + "uvicorn>=0.29.0", +] +web = [ + "flask>=3.0.0", +] +all = [ + "fastapi>=0.110.0", + "uvicorn>=0.29.0", + "flask>=3.0.0", +] dev = [ "build>=1.2.2", "pytest>=8.0.0", diff --git a/src/mi_fitness_mcp/api.py b/src/mi_fitness_mcp/api.py new file mode 100644 index 0000000..9498c1f --- /dev/null +++ b/src/mi_fitness_mcp/api.py @@ -0,0 +1,763 @@ +"""HTTP API for Mi Fitness data (FastAPI). + +把 MCP Server 的同步/查询能力以 REST 形式暴露,供其他程序直接调用。 + +启动:mi-fitness-mcp api [--host 127.0.0.1] [--port 8321] + +鉴权模型(类大模型平台): +- 默认凭据:来自本地配置(config.json + keyring),不带头直接访问即可用(仅建议本机)。 +- 静态 Key:设置环境变量 MI_FITNESS_API_KEY 后,所有请求必须带 X-API-Key。 +- 发放 Key:管理员调 POST /api/auth/keys,用 userId+passToken 换取 mif_sk_* 形式的 + API Key;调用方之后只带 Key,不接触小米凭据。Key 记录在主库 api_keys 表。 +- 管理端点(发放/列表/吊销/扫码登录)需要 X-Admin-Key 匹配环境变量 + MI_FITNESS_ADMIN_KEY;未设置该变量时仅允许 127.0.0.1 本机调用。 + +扫码登录(逆向自 account.xiaomi.com 通用 QR 流程,参考 python-miio cloud_qr.py): +POST /api/auth/qr/start 生成二维码 → 用户用小米系 App 扫码或在浏览器打开 +login_url 确认 → GET /api/auth/qr/poll 轮询到 confirmed 并自动发放 API Key。 +""" + +import asyncio +import json +import os +import secrets +import time +import uuid +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Annotated + +import httpx +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import Response +from pydantic import BaseModel + +from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter +from mi_fitness_mcp.auth import load_mi_fitness_token +from mi_fitness_mcp.config import load_config +from mi_fitness_mcp.services.query_service import QueryService +from mi_fitness_mcp.services.sync_service import SyncService +from mi_fitness_mcp.storage import Database + +DATA_TYPES = [ + "daily_activity", + "heart_rate", + "body_measurements", + "sleep", + "workouts", + "spo2", + "stress", + "abnormal_heart_beat", +] + +METRICS = ["steps", "distance_m", "active_kcal"] +GRANULARITIES = ["day", "week", "month"] +AGGREGATIONS = ["sum", "avg", "min", "max", "latest"] +STRESS_LEVELS = ["low", "medium", "high"] + +LOGIN_URL_ENDPOINT = "https://account.xiaomi.com/longPolling/loginUrl" +LOGIN_PREFIX = "&&&START&&&" + +# ---------------------------------------------------------------- 用户上下文 + + +@dataclass +class UserContext: + """一套小米凭据对应的运行时上下文(适配器/服务/同步状态),按凭据缓存。""" + + user_id: str + pass_token: str + region: str + adapter: MiFitnessCloudAdapter | None = None + sync_service: SyncService | None = None + query_service: QueryService | None = None + sync_running: bool = False + + +def _build_context(db: Database, config, user_id: str, pass_token: str, region: str) -> UserContext: + adapter = MiFitnessCloudAdapter(user_id=user_id, pass_token=pass_token, region=region) + adapter.http_timeout = config.http_timeout_seconds + adapter.request_retries = config.request_retries + adapter.max_pages = config.max_pages + return UserContext( + user_id=user_id, + pass_token=pass_token, + region=region, + adapter=adapter, + sync_service=SyncService( + adapter, db, config.default_lookback_days, config.sync_chunk_days + ), + query_service=QueryService(db, user_id), + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + config = load_config() + db = Database(config.database_path) + with db._get_connection() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS api_keys ( + key TEXT PRIMARY KEY, + label TEXT, + user_id TEXT NOT NULL, + pass_token TEXT NOT NULL, + region TEXT NOT NULL DEFAULT 'cn', + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.commit() + + default_context = None + if config.mode == "mi_fitness_cloud": + user_id, pass_token = load_mi_fitness_token() + if user_id and pass_token: + default_context = _build_context(db, config, user_id, pass_token, config.region) + + app.state.config = config + app.state.db = db + app.state.default_context = default_context + app.state.contexts = {} + app.state.sync_tasks = {} + app.state.qr_sessions = {} + try: + yield + finally: + tasks = [entry.get("task") for entry in app.state.sync_tasks.values() if entry.get("task")] + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + contexts = list(app.state.contexts.values()) + if app.state.default_context: + contexts.append(app.state.default_context) + for ctx in contexts: + if ctx.adapter: + await ctx.adapter.close() + + +async def _gate(x_api_key: str | None = Header(default=None)) -> None: + """入口闸门:环境静态 Key 设置时强制带头;发了 Key 则校验有效性。""" + env_key = os.environ.get("MI_FITNESS_API_KEY") + if x_api_key is None: + if env_key: + raise HTTPException(status_code=401, detail="Missing X-API-Key header") + return + if env_key and x_api_key == env_key: + return + db: Database = app.state.db + with db._get_connection() as conn: + row = conn.execute( + "SELECT revoked FROM api_keys WHERE key = ?", (x_api_key,) + ).fetchone() + if row is None or row["revoked"]: + raise HTTPException(status_code=401, detail="Invalid API key") + + +app = FastAPI( + title="Mi Fitness API", + version="0.2.0", + description="小米运动健康数据 REST API(多 Key 鉴权 + 扫码登录 + 本地 SQLite 缓存)", + lifespan=lifespan, + dependencies=[Depends(_gate)], +) +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) + + +def _resolve_context(request: Request) -> UserContext: + """根据 X-API-Key 解析出对应用户上下文;静态 Key/无头 → 默认凭据。""" + state = request.app.state + header = request.headers.get("x-api-key") + ctx = state.default_context + if header and header != os.environ.get("MI_FITNESS_API_KEY"): + db: Database = state.db + with db._get_connection() as conn: + row = conn.execute( + "SELECT user_id, pass_token, region FROM api_keys WHERE key = ? AND revoked = 0", + (header,), + ).fetchone() + if row is not None: + cache_key = (row["user_id"], row["pass_token"], row["region"]) + ctx = state.contexts.get(cache_key) + if ctx is None: + ctx = _build_context(state.db, state.config, *cache_key) + state.contexts[cache_key] = ctx + with db._get_connection() as conn: + conn.execute( + "UPDATE api_keys SET last_used_at = ? WHERE key = ?", + (datetime.now(UTC).isoformat(), header), + ) + conn.commit() + if ctx is None or ctx.query_service is None: + raise HTTPException(status_code=503, detail="Server not configured") + return ctx + + +def _require_admin(request: Request) -> None: + env_key = os.environ.get("MI_FITNESS_ADMIN_KEY") + provided = request.headers.get("x-admin-key") + if env_key: + if provided != env_key: + raise HTTPException(status_code=401, detail="Invalid X-Admin-Key") + return + client_host = request.client.host if request.client else "" + if client_host not in ("127.0.0.1", "::1", "testclient"): + raise HTTPException( + status_code=403, + detail="非本机调用管理端点需要先设置环境变量 MI_FITNESS_ADMIN_KEY", + ) + + +def _validate_date(value: str | None, name: str) -> str | None: + if value is None: + return None + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"{name} 必须为 YYYY-MM-DD 格式") from exc + return value + + +def _validate_limit(limit: int | None) -> int | None: + if limit is not None and limit < 0: + raise HTTPException(status_code=400, detail="limit 不能为负数") + return limit + + +def _query_service(request: Request) -> QueryService: + return _resolve_context(request).query_service + + +# ---------------------------------------------------------------- Key 管理 + + +class CreateKeyRequest(BaseModel): + user_id: str + pass_token: str + region: str = "cn" + label: str | None = None + + +def _create_key_record(db: Database, user_id: str, pass_token: str, region: str, label: str | None) -> str: + key = "mif_sk_" + secrets.token_hex(20) + with db._get_connection() as conn: + conn.execute( + "INSERT INTO api_keys (key, label, user_id, pass_token, region, created_at) VALUES (?,?,?,?,?,?)", + (key, label, user_id, pass_token, region, datetime.now(UTC).isoformat()), + ) + conn.commit() + return key + + +@app.post("/api/auth/keys") +async def create_key(body: CreateKeyRequest, request: Request) -> dict: + """用小米凭据换取 API Key。会真实登录一次验证凭据有效性。""" + _require_admin(request) + state = request.app.state + adapter = MiFitnessCloudAdapter( + user_id=body.user_id, pass_token=body.pass_token, region=body.region or "cn" + ) + adapter.http_timeout = state.config.http_timeout_seconds + adapter.request_retries = state.config.request_retries + connected = await adapter.connect() + error = adapter.last_error + await adapter.close() + if not connected: + raise HTTPException( + status_code=400, + detail=f"凭据验证失败:{error or 'cannot login'}", + ) + key = _create_key_record(state.db, body.user_id, body.pass_token, body.region or "cn", body.label) + return { + "api_key": key, + "user_id": body.user_id, + "region": body.region or "cn", + "label": body.label, + "note": "完整 Key 只显示这一次,请妥善保存", + } + + +@app.get("/api/auth/keys") +async def list_keys(request: Request) -> dict: + _require_admin(request) + db: Database = request.app.state.db + with db._get_connection() as conn: + rows = conn.execute( + "SELECT key, label, user_id, region, created_at, last_used_at, revoked FROM api_keys ORDER BY created_at DESC" + ).fetchall() + items = [] + for r in rows: + k = r["key"] + items.append( + { + "key_masked": k[:12] + "…" + k[-4:], + "label": r["label"], + "user_id": r["user_id"], + "region": r["region"], + "created_at": r["created_at"], + "last_used_at": r["last_used_at"], + "revoked": bool(r["revoked"]), + } + ) + return {"status": "ok", "count": len(items), "data": items} + + +@app.delete("/api/auth/keys/{key_prefix}") +async def revoke_key(key_prefix: str, request: Request) -> dict: + _require_admin(request) + db: Database = request.app.state.db + with db._get_connection() as conn: + rows = conn.execute("SELECT key FROM api_keys").fetchall() + matches = [r["key"] for r in rows if r["key"] == key_prefix or r["key"].startswith(key_prefix)] + if not matches: + raise HTTPException(status_code=404, detail="没有匹配的 Key(可用完整 Key 或唯一前缀)") + for k in matches: + conn.execute("UPDATE api_keys SET revoked = 1 WHERE key = ?", (k,)) + conn.commit() + return {"status": "ok", "revoked": matches} + + +# ---------------------------------------------------------------- 扫码登录 + + +@app.post("/api/auth/qr/start") +async def qr_start(request: Request, region: str = "cn") -> dict: + """生成小米扫码登录二维码。 + + 采用小米通用扫码流程(sid=xiaomiio,与米家/小米账号 App 扫码兼容), + 扫码确认后从长轮询结果取账号级 passToken,再校验并发放 API Key。 + """ + _require_admin(request) + state = request.app.state + params = { + "_qrsize": "480", + "qs": "%3Fsid%3Dxiaomiio%26_json%3Dtrue", + "callback": "https://sts.api.io.mi.com/sts", + "_hasLogo": "false", + "sid": "xiaomiio", + "serviceParam": "", + "_locale": "zh_CN", + "_dc": str(int(time.time() * 1000)), + } + async with httpx.AsyncClient(follow_redirects=False, timeout=20) as client: + try: + resp = await client.get(LOGIN_URL_ENDPOINT, params=params) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"请求小米登录服务失败: {exc}") from exc + text = resp.text + if text.startswith(LOGIN_PREFIX): + text = text[len(LOGIN_PREFIX) :] + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=502, detail="小米返回了意外的登录响应") from exc + if "qr" not in data or "lp" not in data: + raise HTTPException(status_code=502, detail=f"小米未返回二维码: {list(data)[:8]}") + png_resp = await client.get(data["qr"]) + png = png_resp.content + + token = uuid.uuid4().hex + state.qr_sessions[token] = { + "lp": data["lp"], + "login_url": data.get("loginUrl", ""), + "region": region or "cn", + "created_at": time.time(), + "expires_in": int(data.get("timeout", 300)), + "status": "waiting", + "png": png, + } + return { + "qr_token": token, + "qr_image": f"/api/auth/qr/{token}.png", + "login_url": data.get("loginUrl", ""), + "expires_in": int(data.get("timeout", 300)), + "instruction": "用小米运动健康/小米账号 App 扫码,或在浏览器打开 login_url 确认;然后轮询 /api/auth/qr/poll?token=...", + } + + +@app.get("/api/auth/qr/{token}.png") +async def qr_png(token: str, request: Request) -> Response: + _require_admin(request) + sess = request.app.state.qr_sessions.get(token) + if sess is None or "png" not in sess: + raise HTTPException(status_code=404, detail="二维码不存在或已过期") + return Response(content=sess["png"], media_type="image/png") + + +@app.get("/api/auth/qr/poll") +async def qr_poll(token: str, request: Request) -> dict: + _require_admin(request) + state = request.app.state + sess = state.qr_sessions.get(token) + if sess is None: + raise HTTPException(status_code=404, detail="Unknown qr_token") + if sess["status"] == "confirmed": + return {"status": "confirmed", "api_key": sess.get("api_key"), "user_id": sess.get("user_id")} + if time.time() - sess["created_at"] > sess["expires_in"] + 10: + sess["status"] = "expired" + return {"status": "expired"} + + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(sess["lp"]) + except httpx.HTTPError: + return {"status": "waiting"} + if resp.status_code != 200: + return {"status": "waiting"} + text = resp.text + if text.startswith(LOGIN_PREFIX): + text = text[len(LOGIN_PREFIX) :] + try: + body = json.loads(text) + except json.JSONDecodeError: + return {"status": "waiting"} + if not (body.get("code") == 0 and body.get("userId")): + return {"status": "waiting"} + + user_id = str(body["userId"]) + pass_token = body.get("passToken", "") + if not pass_token: + sess["status"] = "confirmed" + return { + "status": "confirmed", + "user_id": user_id, + "api_key": None, + "note": "小米未在扫码结果中返回 passToken,请改用 POST /api/auth/keys 手动发放", + } + key = _create_key_record(state.db, user_id, pass_token, sess["region"], "qr-login") + sess["status"] = "confirmed" + sess["api_key"] = key + sess["user_id"] = user_id + return {"status": "confirmed", "api_key": key, "user_id": user_id} + + +# ---------------------------------------------------------------- 数据接口 + + +@app.get("/") +async def root() -> dict: + return { + "service": "mi-fitness-api", + "docs": "/docs", + "data_types": DATA_TYPES, + "sync": "POST /api/sync", + "status": "GET /api/status", + "auth": "POST /api/auth/keys (admin) | POST /api/auth/qr/start (admin)", + } + + +@app.get("/api/status") +async def get_status(request: Request, deep: bool = True) -> dict: + """连接状态。deep=true 时真实请求小米云做健康检查。""" + ctx = _resolve_context(request) + config = request.app.state.config + adapter = ctx.adapter + if adapter is None: + return {"mode": "not_configured", "connected": False, "message": "Server not configured."} + if ctx.sync_service and ctx.sync_service.sync_in_progress: + connected = adapter.is_connected() + elif deep: + try: + connected = await asyncio.wait_for( + adapter.health_check(), timeout=config.health_check_timeout_seconds + ) + except Exception: + connected = False + else: + connected = adapter.is_connected() + return { + "mode": "mi_fitness_cloud", + "region": ctx.region, + "user_id": ctx.user_id, + "connected": connected, + "available_data_types": adapter.get_available_data_types() if connected else [], + "error": None if connected else adapter.last_error, + "sync_in_progress": bool(ctx.sync_service and ctx.sync_service.sync_in_progress), + } + + +class SyncRequest(BaseModel): + data_types: list[str] | None = None + start_date: str | None = None + end_date: str | None = None + force_full_sync: bool = False + background: bool = False + + +async def _run_sync(ctx: UserContext, config, arguments: dict) -> dict: + sync_service = ctx.sync_service + adapter = ctx.adapter + if sync_service is None or adapter is None: + raise HTTPException(status_code=503, detail="Sync service not initialized") + if not adapter.is_connected() and not await adapter.connect(): + raise HTTPException( + status_code=502, detail=adapter.last_error or "Cannot connect to Mi Fitness cloud" + ) + supported = set(adapter.get_available_data_types()) + data_types = arguments.get("data_types") or sorted(supported) + unknown = sorted(set(data_types) - supported) + if unknown: + raise HTTPException( + status_code=400, detail=f"Unsupported data types: {', '.join(unknown)}" + ) + started_at = datetime.now(UTC) + totals = {"added": 0, "updated": 0, "skipped": 0} + details = [] + for data_type in data_types: + type_started = datetime.now(UTC) + try: + result = await asyncio.wait_for( + sync_service.sync_data_type( + data_type=data_type, + start_date=arguments.get("start_date"), + end_date=arguments.get("end_date"), + force_full=arguments.get("force_full_sync", False), + ), + timeout=config.sync_type_timeout_seconds, + ) + except TimeoutError: + details.append({"data_type": data_type, "status": "error", "error": "sync timed out"}) + continue + except Exception as exc: + details.append({"data_type": data_type, "status": "error", "error": str(exc)}) + continue + details.append( + { + "data_type": data_type, + "status": result.get("status", "ok"), + "added": result.get("added", 0), + "updated": result.get("updated", 0), + "skipped": result.get("skipped", 0), + "duration_seconds": (datetime.now(UTC) - type_started).total_seconds(), + } + ) + for key in totals: + totals[key] += result.get(key, 0) + succeeded = [d["data_type"] for d in details if d["status"] == "ok"] + has_partial = any(d["status"] == "partial" for d in details) + status = ( + "ok" + if len(succeeded) == len(details) + else "partial" + if succeeded or has_partial + else "error" + ) + return { + "status": status, + "sync_id": arguments["sync_id"], + "started_at": started_at.isoformat(), + "finished_at": datetime.now(UTC).isoformat(), + "records_added": totals["added"], + "records_updated": totals["updated"], + "records_skipped": totals["skipped"], + "data_types_synced": succeeded, + "results": details, + } + + +async def _background_sync(app: FastAPI, sync_id: str, ctx: UserContext, arguments: dict) -> None: + entry = app.state.sync_tasks[sync_id] + entry["status"] = "running" + try: + result = await _run_sync(ctx, app.state.config, {**arguments, "sync_id": sync_id}) + entry.update(result) + except HTTPException as exc: + entry.update({"status": "error", "error": exc.detail}) + except asyncio.CancelledError: + entry["status"] = "cancelled" + raise + except Exception as exc: + entry.update({"status": "error", "error": str(exc)}) + finally: + ctx.sync_running = False + entry.pop("task", None) + + +@app.post("/api/sync") +async def post_sync(req: SyncRequest, request: Request) -> dict: + """触发一次同步。不传日期则按增量游标/最近30天回溯;长任务建议 background=true。""" + ctx = _resolve_context(request) + if ctx.sync_service is None: + raise HTTPException(status_code=503, detail="Sync service not initialized") + if ctx.sync_running: + raise HTTPException(status_code=409, detail="Another synchronization is in progress") + _validate_date(req.start_date, "start_date") + _validate_date(req.end_date, "end_date") + arguments = { + "data_types": req.data_types, + "start_date": req.start_date, + "end_date": req.end_date, + "force_full_sync": req.force_full_sync, + } + if req.background: + ctx.sync_running = True + sync_id = str(uuid.uuid4()) + task = asyncio.create_task(_background_sync(request.app, sync_id, ctx, arguments)) + request.app.state.sync_tasks[sync_id] = {"sync_id": sync_id, "status": "queued", "task": task} + return {"status": "accepted", "sync_id": sync_id} + ctx.sync_running = True + try: + return await _run_sync(ctx, request.app.state.config, {**arguments, "sync_id": str(uuid.uuid4())}) + finally: + ctx.sync_running = False + + +@app.get("/api/sync/{sync_id}") +async def get_sync_status(sync_id: str, request: Request) -> dict: + entry = request.app.state.sync_tasks.get(sync_id) + if entry is None: + raise HTTPException(status_code=404, detail="Unknown sync_id") + return {key: value for key, value in entry.items() if key != "task"} + + +@app.get("/api/summary") +def get_summary(request: Request, start_date: str, end_date: str) -> dict: + data = _query_service(request).get_daily_summaries( + _validate_date(start_date, "start_date"), _validate_date(end_date, "end_date") + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/metric-series") +def get_metric_series( + request: Request, + metric: str, + start_date: str, + end_date: str, + granularity: str = "day", + aggregation: str = "sum", +) -> dict: + if metric not in METRICS: + raise HTTPException(status_code=400, detail=f"metric 必须是 {METRICS} 之一") + if granularity not in GRANULARITIES: + raise HTTPException(status_code=400, detail=f"granularity 必须是 {GRANULARITIES} 之一") + if aggregation not in AGGREGATIONS: + raise HTTPException(status_code=400, detail=f"aggregation 必须是 {AGGREGATIONS} 之一") + data = _query_service(request).get_metric_series( + metric=metric, + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + granularity=granularity, + aggregation=aggregation, + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/heart-rate") +def get_heart_rate( + request: Request, + start_date: str, + end_date: str, + sample_type: str | None = None, + limit: int | None = None, +) -> dict: + data = _query_service(request).get_heart_rate_samples( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + sample_type=sample_type, + limit=_validate_limit(limit), + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/sleep") +def get_sleep( + request: Request, start_date: str, end_date: str, include_naps: bool = True +) -> dict: + data = _query_service(request).get_sleep_sessions( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + include_naps=include_naps, + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/workouts") +def get_workouts( + request: Request, + start_date: str, + end_date: str, + activity_types: Annotated[list[str] | None, Query()] = None, + min_duration: int | None = None, + min_distance_km: float | None = None, +) -> dict: + data = _query_service(request).get_workouts( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + activity_types=activity_types, + min_duration=min_duration, + min_distance_km=min_distance_km, + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/body-measurements") +def get_body_measurements( + request: Request, + start_date: str, + end_date: str, + metrics: Annotated[list[str] | None, Query()] = None, + latest_only: bool = False, +) -> dict: + data = _query_service(request).get_body_measurements( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + metrics=metrics, + ) + if latest_only and data: + data = [data[-1]] + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/spo2") +def get_spo2(request: Request, start_date: str, end_date: str, limit: int | None = None) -> dict: + data = _query_service(request).get_spo2_samples( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + limit=_validate_limit(limit), + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/stress") +def get_stress( + request: Request, start_date: str, end_date: str, level: str | None = None, limit: int | None = None +) -> dict: + if level and level not in STRESS_LEVELS: + raise HTTPException(status_code=400, detail=f"level 必须是 {STRESS_LEVELS} 之一") + data = _query_service(request).get_stress_samples( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + level=level, + limit=_validate_limit(limit), + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/abnormal-heart-beat") +def get_abnormal_heart_beat( + request: Request, start_date: str, end_date: str, limit: int | None = None +) -> dict: + data = _query_service(request).get_abnormal_heart_beat_events( + start_date=_validate_date(start_date, "start_date"), + end_date=_validate_date(end_date, "end_date"), + limit=_validate_limit(limit), + ) + return {"status": "ok", "count": len(data), "data": data} + + +@app.get("/api/coverage") +def get_coverage( + request: Request, data_types: Annotated[list[str] | None, Query()] = None +) -> dict: + data = _query_service(request).get_data_coverage(data_types) + return {"status": "ok", "count": len(data), "data": data} diff --git a/src/mi_fitness_mcp/main.py b/src/mi_fitness_mcp/main.py index 6e92d25..2a2ff2a 100644 --- a/src/mi_fitness_mcp/main.py +++ b/src/mi_fitness_mcp/main.py @@ -144,6 +144,28 @@ def cmd_sync(args): asyncio.run(cmd_sync_async(args)) +def cmd_api(args): + import os + + import uvicorn + + if args.host not in ("127.0.0.1", "localhost") and not os.environ.get("MI_FITNESS_API_KEY"): + print("⚠️ 绑定到非本机地址但未设置 MI_FITNESS_API_KEY,健康数据将暴露给局域网。") + print(" 建议先设置环境变量 MI_FITNESS_API_KEY 再启动。") + uvicorn.run("mi_fitness_mcp.api:app", host=args.host, port=args.port) + + +def cmd_web(args): + from mi_fitness_mcp.web import run_server + + run_server( + host=args.host, + port=args.port, + backend_url=args.backend_url, + debug=args.debug, + ) + + def main(): parser = argparse.ArgumentParser(prog=PROGRAM_NAME, description="小米运动健康数据 MCP Server") subparsers = parser.add_subparsers(dest="command", help="可用命令") @@ -175,6 +197,31 @@ def main(): sync_parser.add_argument("--start-date", help="开始日期(YYYY-MM-DD)") sync_parser.add_argument("--end-date", help="结束日期(YYYY-MM-DD)") + api_parser = subparsers.add_parser("api", help="启动 HTTP API 服务(供其他程序调用)") + api_parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)") + api_parser.add_argument("--port", type=int, default=8321, help="监听端口(默认 8321)") + + web_parser = subparsers.add_parser("web", help="启动可视化 Web 仪表盘与测试控制台(Flask)") + web_parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)") + web_parser.add_argument("--port", type=int, default=8322, help="监听端口(默认 8322)") + web_parser.add_argument( + "--backend-url", + default="http://127.0.0.1:8321", + help="FastAPI 后端服务地址(默认 http://127.0.0.1:8321)", + ) + web_parser.add_argument("--debug", action="store_true", help="开启 Flask 调试模式") + + # Alias for webui + webui_parser = subparsers.add_parser("webui", help="启动可视化 Web 仪表盘(web 命令别名)") + webui_parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)") + webui_parser.add_argument("--port", type=int, default=8322, help="监听端口(默认 8322)") + webui_parser.add_argument( + "--backend-url", + default="http://127.0.0.1:8321", + help="FastAPI 后端服务地址(默认 http://127.0.0.1:8321)", + ) + webui_parser.add_argument("--debug", action="store_true", help="开启 Flask 调试模式") + args = parser.parse_args() if args.command == "serve" or args.command is None: asyncio.run(server_main()) @@ -184,6 +231,10 @@ def main(): cmd_doctor(args) elif args.command == "sync": cmd_sync(args) + elif args.command == "api": + cmd_api(args) + elif args.command in ("web", "webui"): + cmd_web(args) else: parser.print_help() sys.exit(1) diff --git a/src/mi_fitness_mcp/web.py b/src/mi_fitness_mcp/web.py new file mode 100644 index 0000000..6eefa14 --- /dev/null +++ b/src/mi_fitness_mcp/web.py @@ -0,0 +1,202 @@ +"""Flask Web UI & Reverse Proxy for Mi Fitness MCP. + +Provides a modern dashboard interface and reverse proxy to the backend FastAPI service. +- Serves the frontend at GET / +- Reverse proxies /proxy/ to the Python FastAPI backend (default http://127.0.0.1:8321) +- Automatically attaches X-API-Key and X-Admin-Key from environment variables +- Accurately reports upstream response time via X-Upstream-Time-Ms +- Supports streaming, JSON, and binary responses (e.g., QR code PNGs) +""" + +import os +import time +from pathlib import Path + +import httpx +from flask import ( + Flask, + Response, + jsonify, + render_template, + request, + send_from_directory, +) + +# Assets directory: src/mi_fitness_mcp/web_assets or fallback to testpage +PACKAGE_DIR = Path(__file__).resolve().parent +DEFAULT_ASSETS_DIR = PACKAGE_DIR / "web_assets" + + +def create_app( + backend_url: str | None = None, + api_key: str | None = None, + admin_key: str | None = None, + assets_dir: Path | str | None = None, +) -> Flask: + """Create and configure the Flask web application.""" + assets_path = Path(assets_dir) if assets_dir else DEFAULT_ASSETS_DIR + + app = Flask( + __name__, + static_folder=str(assets_path / "static") if (assets_path / "static").exists() else str(assets_path), + static_url_path="/static", + template_folder=str(assets_path / "templates") if (assets_path / "templates").exists() else str(assets_path), + ) + + # Configuration with env fallbacks + app.config["BACKEND_URL"] = ( + backend_url + or os.environ.get("MI_FITNESS_API_URL") + or "http://127.0.0.1:8321" + ).rstrip("/") + app.config["API_KEY"] = api_key or os.environ.get("MI_FITNESS_API_KEY") + app.config["ADMIN_KEY"] = admin_key or os.environ.get("MI_FITNESS_ADMIN_KEY") + + # Persistent HTTP client with 300s timeout (trust_env=False avoids proxy interference on localhost) + http_client = httpx.Client(trust_env=False, timeout=300.0, follow_redirects=True) + + @app.route("/") + def index(): + """Render the main dashboard interface.""" + template_file = assets_path / "templates" / "index.html" + if template_file.exists(): + return render_template( + "index.html", + backend_url=app.config["BACKEND_URL"], + has_api_key=bool(app.config["API_KEY"]), + has_admin_key=bool(app.config["ADMIN_KEY"]), + ) + # Fallback to direct static index.html + direct_index = assets_path / "index.html" + if direct_index.exists(): + return send_from_directory(str(assets_path), "index.html") + return jsonify({"status": "ok", "message": "Mi Fitness Web UI is running"}), 200 + + @app.route("/api/proxy_status") + def proxy_status(): + """Get status of the Flask proxy and configured upstream backend.""" + backend = app.config["BACKEND_URL"] + healthy = False + latency_ms = None + detail = "" + try: + t0 = time.perf_counter() + resp = http_client.get(f"{backend}/", timeout=3.0) + latency_ms = round((time.perf_counter() - t0) * 1000, 2) + healthy = resp.status_code < 500 + except Exception as e: + detail = str(e) + + return jsonify({ + "proxy": "running", + "proxy_type": "Flask (Python)", + "backend_url": backend, + "backend_reachable": healthy, + "backend_latency_ms": latency_ms, + "has_server_api_key": bool(app.config["API_KEY"]), + "has_server_admin_key": bool(app.config["ADMIN_KEY"]), + "error_detail": detail if not healthy else None, + }) + + @app.route("/proxy", defaults={"subpath": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) + @app.route("/proxy/", defaults={"subpath": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) + @app.route("/proxy/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) + def proxy(subpath: str = ""): + """Reverse proxy all requests to the backend FastAPI service.""" + backend = app.config["BACKEND_URL"] + query_string = request.query_string.decode("utf-8") + clean_subpath = subpath.lstrip("/") + target_url = f"{backend}/{clean_subpath}" if clean_subpath else f"{backend}/" + if query_string: + target_url = f"{target_url}?{query_string}" + + # Prepare headers: forward browser headers except host and content-length + forward_headers: dict[str, str] = {} + for key, value in request.headers.items(): + k_lower = key.lower() + if k_lower not in ("host", "content-length", "transfer-encoding"): + forward_headers[key] = value + + # Attach API Key (Server env priority, otherwise pass through client key) + server_api_key = app.config["API_KEY"] + if server_api_key: + forward_headers["X-API-Key"] = server_api_key + + # Attach Admin Key if configured + server_admin_key = app.config["ADMIN_KEY"] + if server_admin_key: + forward_headers["X-Admin-Key"] = server_admin_key + + body = request.get_data() if request.method in ("POST", "PUT", "PATCH", "DELETE") else None + + started = time.perf_counter() + try: + upstream_resp = http_client.request( + method=request.method, + url=target_url, + headers=forward_headers, + content=body, + ) + elapsed_ms = round((time.perf_counter() - started) * 1000, 2) + + # Build response back to browser + excluded_headers = {"content-encoding", "content-length", "transfer-encoding", "connection"} + response_headers: list[tuple[str, str]] = [ + (k, v) + for k, v in upstream_resp.headers.items() + if k.lower() not in excluded_headers + ] + response_headers.append(("X-Upstream-Time-Ms", str(int(elapsed_ms)))) + + return Response( + response=upstream_resp.content, + status=upstream_resp.status_code, + headers=response_headers, + mimetype=upstream_resp.headers.get("content-type"), + ) + except httpx.RequestError as exc: + elapsed_ms = round((time.perf_counter() - started) * 1000, 2) + error_body = { + "error": "Flask 代理无法访问后端", + "detail": str(exc), + "target": target_url, + "hint": f"请确认 Python FastAPI 服务已在 {backend} 正常启动", + } + resp = jsonify(error_body) + resp.status_code = 502 + resp.headers["X-Upstream-Time-Ms"] = str(int(elapsed_ms)) + return resp + + return app + + +def run_server( + host: str = "127.0.0.1", + port: int = 8322, + backend_url: str | None = None, + api_key: str | None = None, + admin_key: str | None = None, + debug: bool = False, +): + """Start the Flask web development server.""" + app = create_app(backend_url=backend_url, api_key=api_key, admin_key=admin_key) + print("[*] Mi Fitness Web UI started!") + print(f" Web Dashboard: http://{host}:{port}/") + print(f" Proxy Backend: {app.config['BACKEND_URL']}") + if app.config["API_KEY"]: + print(" Auth Mode: Server X-API-Key configured") + if app.config["ADMIN_KEY"]: + print(" Admin Auth: Server X-Admin-Key configured") + print(" Press Ctrl+C to quit") + app.run(host=host, port=port, debug=debug) + + +if __name__ == "__main__": + addr = os.environ.get("BIND", "127.0.0.1:8322") + if ":" in addr: + h, p = addr.split(":", 1) + port_num = int(p) + else: + h = addr + port_num = 8322 + run_server(host=h, port=port_num) diff --git a/src/mi_fitness_mcp/web_assets/static/css/style.css b/src/mi_fitness_mcp/web_assets/static/css/style.css new file mode 100644 index 0000000..b565040 --- /dev/null +++ b/src/mi_fitness_mcp/web_assets/static/css/style.css @@ -0,0 +1,1745 @@ +/** + * Mi Fitness Dashboard & Developer Console Design System + * Modern Glassmorphism & High-Density Health Analytics UI + * Designed for Mi Fitness MCP CN + */ + +/* ========================================================================== + 1. Design Tokens & CSS Custom Properties + ========================================================================== */ + +:root { + /* Color Palette - Dark Mode (Default) */ + --bg-base: #090d16; + --bg-gradient: radial-gradient(circle at 15% 15%, rgba(56, 189, 248, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(139, 92, 246, 0.08) 0%, transparent 40%), + #090d16; + --bg-surface: rgba(17, 24, 39, 0.72); + --bg-surface-hover: rgba(30, 41, 59, 0.85); + --bg-surface-active: rgba(51, 65, 85, 0.9); + --bg-card: rgba(15, 23, 42, 0.65); + --bg-card-hover: rgba(30, 41, 59, 0.75); + --bg-input: rgba(11, 18, 32, 0.85); + --bg-input-focus: rgba(15, 23, 42, 0.95); + --bg-code: #070b12; + --bg-modal-overlay: rgba(0, 0, 0, 0.75); + + /* Borders & Dividers */ + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.1); + --border-active: rgba(56, 189, 248, 0.4); + --border-focus: #38bdf8; + + /* Typography Colors */ + --text-primary: #f8fafc; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --text-dim: #64748b; + --text-inverted: #0f172a; + + /* Brand & Accent Colors */ + --accent-cyan: #38bdf8; + --accent-cyan-glow: rgba(56, 189, 248, 0.35); + --accent-indigo: #818cf8; + --accent-indigo-glow: rgba(129, 140, 248, 0.35); + --accent-purple: #a855f7; + --accent-emerald: #10b981; + --accent-rose: #f43f5e; + --accent-amber: #f59e0b; + --accent-teal: #14b8a6; + --accent-pink: #ec4899; + + /* Status Colors */ + --status-success: #10b981; + --status-success-bg: rgba(16, 185, 129, 0.15); + --status-warning: #f59e0b; + --status-warning-bg: rgba(245, 158, 11, 0.15); + --status-error: #ef4444; + --status-error-bg: rgba(239, 68, 68, 0.15); + --status-info: #38bdf8; + --status-info-bg: rgba(56, 189, 248, 0.15); + + /* Shadows & Elevation */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 12px -2px rgba(0, 0, 0, 0.4), 0 2px 6px -1px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 12px 28px -4px rgba(0, 0, 0, 0.5), 0 4px 12px -2px rgba(0, 0, 0, 0.3); + --shadow-glow: 0 0 20px -2px var(--accent-cyan-glow); + --shadow-glow-indigo: 0 0 20px -2px var(--accent-indigo-glow); + + /* Glassmorphism & Blurs */ + --backdrop-blur: blur(16px); + --backdrop-blur-sm: blur(8px); + + /* Transitions & Animation Timings */ + --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Border Radii */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Layout Constants */ + --header-height: 68px; + --sidebar-width: 240px; + --sidebar-collapsed-width: 72px; + --inspector-width: 460px; +} + +/* Light Theme Variables */ +[data-theme="light"] { + --bg-base: #f8fafc; + --bg-gradient: radial-gradient(circle at 15% 15%, rgba(14, 165, 233, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(99, 102, 241, 0.08) 0%, transparent 40%), + #f8fafc; + --bg-surface: rgba(255, 255, 255, 0.85); + --bg-surface-hover: rgba(241, 245, 249, 0.95); + --bg-surface-active: #e2e8f0; + --bg-card: rgba(255, 255, 255, 0.75); + --bg-card-hover: rgba(255, 255, 255, 0.95); + --bg-input: #ffffff; + --bg-input-focus: #ffffff; + --bg-code: #0f172a; + --bg-modal-overlay: rgba(15, 23, 42, 0.6); + + --border-subtle: rgba(0, 0, 0, 0.05); + --border-default: rgba(0, 0, 0, 0.1); + --border-active: rgba(14, 165, 233, 0.5); + --border-focus: #0284c7; + + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; + --text-dim: #94a3b8; + --text-inverted: #f8fafc; + + --accent-cyan: #0284c7; + --accent-cyan-glow: rgba(2, 132, 199, 0.25); + --accent-indigo: #4f46e5; + --accent-indigo-glow: rgba(79, 70, 229, 0.25); + + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 14px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 12px 28px -4px rgba(0, 0, 0, 0.12); +} + +/* ========================================================================== + 2. Reset, Base & Typography + ========================================================================== */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + width: 100%; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 13.5px; + line-height: 1.55; + color: var(--text-primary); + background: var(--bg-gradient); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +/* Custom Sleek Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.25); + border-radius: var(--radius-full); + transition: background var(--transition-fast); +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(56, 189, 248, 0.5); +} + +h1, h2, h3, h4, h5, h6 { + color: var(--text-primary); + font-weight: 600; + letter-spacing: -0.02em; +} + +p { + color: var(--text-secondary); +} + +a { + color: var(--accent-cyan); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: #7dd3fc; +} + +code, pre { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace; +} + +/* ========================================================================== + 3. Application Shell & Top Header + ========================================================================== */ + +#app-container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.top-header { + position: sticky; + top: 0; + z-index: 100; + height: var(--header-height); + padding: 0 24px; + background: var(--bg-surface); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: var(--shadow-sm); + transition: background var(--transition-normal), border-color var(--transition-normal); +} + +.header-brand { + display: flex; + align-items: center; + gap: 12px; + min-width: max-content; +} + +.brand-icon { + width: 38px; + height: 38px; + background: linear-gradient(135deg, #0284c7, #6366f1); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 20px; + box-shadow: 0 0 15px rgba(56, 189, 248, 0.4); + position: relative; + overflow: hidden; +} + +.brand-icon::after { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.3) 50%, transparent 60%); + animation: shine 4s infinite; +} + +@keyframes shine { + 0% { transform: translateX(-100%) translateY(-100%); } + 20% { transform: translateX(100%) translateY(100%); } + 100% { transform: translateX(100%) translateY(100%); } +} + +.brand-titles { + display: flex; + flex-direction: column; +} + +.brand-title { + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + background: linear-gradient(to right, #f8fafc, #94a3b8); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +[data-theme="light"] .brand-title { + background: linear-gradient(to right, #0f172a, #475569); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.brand-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: var(--radius-full); + background: rgba(56, 189, 248, 0.15); + color: var(--accent-cyan); + border: 1px solid rgba(56, 189, 248, 0.3); + text-transform: uppercase; + -webkit-text-fill-color: var(--accent-cyan); +} + +.brand-subtitle { + font-size: 11px; + color: var(--text-muted); +} + +/* Header Status & Controls */ +.header-actions { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.status-indicator-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + font-size: 12px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--status-success); + box-shadow: 0 0 8px var(--status-success); + position: relative; +} + +.status-dot.pulsing::after { + content: ''; + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + border-radius: 50%; + border: 1px solid var(--status-success); + animation: pulseDot 2s infinite; +} + +.status-dot.offline { + background: var(--status-error); + box-shadow: 0 0 8px var(--status-error); +} + +.status-dot.offline.pulsing::after { + border-color: var(--status-error); +} + +@keyframes pulseDot { + 0% { transform: scale(1); opacity: 0.8; } + 70% { transform: scale(2.2); opacity: 0; } + 100% { transform: scale(2.2); opacity: 0; } +} + +.api-key-input-wrapper { + position: relative; + display: flex; + align-items: center; + min-width: 240px; +} + +.api-key-input-wrapper input { + width: 100%; + padding: 7px 32px 7px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-default); + background: var(--bg-input); + color: var(--text-primary); + font-size: 12.5px; + font-family: inherit; + transition: all var(--transition-fast); +} + +.api-key-input-wrapper input:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: var(--shadow-glow); + background: var(--bg-input-focus); +} + +.api-key-icon-btn { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: color var(--transition-fast); +} + +.api-key-icon-btn:hover { + color: var(--accent-cyan); +} + +/* ========================================================================== + 4. App Main Layout (Sidebar + Workspace + Inspector) + ========================================================================== */ + +.main-layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width); + gap: 0; + min-height: calc(100vh - var(--header-height)); + position: relative; +} + +/* Left Sidebar Navigation */ +.app-sidebar { + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + padding: 20px 12px; + display: flex; + flex-direction: column; + gap: 20px; + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow-y: auto; +} + +.nav-section-title { + font-size: 11px; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0 12px; + margin-bottom: 6px; +} + +.nav-group { + display: flex; + flex-direction: column; + gap: 3px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + background: transparent; + width: 100%; + text-align: left; + transition: all var(--transition-fast); + user-select: none; +} + +.nav-item:hover { + color: var(--text-primary); + background: var(--bg-surface-hover); + border-color: var(--border-subtle); +} + +.nav-item.active { + color: var(--accent-cyan); + background: rgba(56, 189, 248, 0.12); + border-color: rgba(56, 189, 248, 0.25); + font-weight: 600; + box-shadow: inset 0 0 12px rgba(56, 189, 248, 0.1); +} + +.nav-icon { + font-size: 16px; + width: 20px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; +} + +.nav-item.active .nav-icon { + opacity: 1; + transform: scale(1.1); + filter: drop-shadow(0 0 6px var(--accent-cyan-glow)); +} + +.nav-badge { + margin-left: auto; + font-size: 10.5px; + padding: 1px 7px; + border-radius: var(--radius-full); + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); +} + +.nav-item.active .nav-badge { + background: rgba(56, 189, 248, 0.2); + color: var(--accent-cyan); +} + +/* Central Workspace Area */ +.app-content { + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + max-width: 100%; +} + +.tab-pane { + display: none; + flex-direction: column; + gap: 18px; + animation: fadeIn 0.25s ease-out; +} + +.tab-pane.active { + display: flex; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ========================================================================== + 5. UI Cards, Panels & Glass Containers + ========================================================================== */ + +.glass-panel { + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 18px 20px; + box-shadow: var(--shadow-sm); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.glass-panel:hover { + border-color: var(--border-active); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + gap: 12px; +} + +.panel-title { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.panel-title .title-icon { + font-size: 16px; + color: var(--accent-cyan); +} + +.panel-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* Overview Stat Cards Grid */ +.stat-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + position: relative; + overflow: hidden; + transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.stat-card:hover { + transform: translateY(-3px); + border-color: var(--border-active); + box-shadow: var(--shadow-md); +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-indigo)); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.stat-card:hover::before { + opacity: 1; +} + +.stat-card-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.stat-card-title { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.stat-card-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + background: rgba(255, 255, 255, 0.05); +} + +.stat-card.steps .stat-card-icon { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); } +.stat-card.hr .stat-card-icon { background: rgba(244, 63, 94, 0.15); color: var(--accent-rose); } +.stat-card.sleep .stat-card-icon { background: rgba(139, 92, 246, 0.15); color: var(--accent-purple); } +.stat-card.calories .stat-card-icon { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); } +.stat-card.spo2 .stat-card-icon { background: rgba(20, 184, 166, 0.15); color: var(--accent-teal); } +.stat-card.stress .stat-card-icon { background: rgba(236, 72, 153, 0.15); color: var(--accent-pink); } + +.stat-card-value { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); + display: flex; + align-items: baseline; + gap: 4px; +} + +.stat-card-unit { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.stat-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11.5px; + color: var(--text-dim); +} + +.stat-card-badge { + padding: 1px 6px; + border-radius: var(--radius-xs); + font-weight: 600; + font-size: 10.5px; +} + +.stat-card-badge.up { background: var(--status-success-bg); color: var(--status-success); } +.stat-card-badge.down { background: var(--status-error-bg); color: var(--status-error); } +.stat-card-badge.neutral { background: rgba(255, 255, 255, 0.08); color: var(--text-muted); } + +/* ========================================================================== + 6. Form Controls, Datepickers, Inputs & Buttons + ========================================================================== */ + +.form-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 140px; +} + +.form-group.sm { + max-width: 140px; +} + +.form-label { + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; +} + +.input-control, .select-control { + width: 100%; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-default); + background: var(--bg-input); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + transition: all var(--transition-fast); +} + +.input-control:hover, .select-control:hover { + border-color: var(--border-active); +} + +.input-control:focus, .select-control:focus { + outline: none; + border-color: var(--border-focus); + background: var(--bg-input-focus); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); +} + +/* Date Preset Quick Filter Pills */ +.date-preset-pills { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; +} + +.preset-pill { + padding: 4px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--border-subtle); + background: var(--bg-card); + color: var(--text-muted); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.preset-pill:hover { + border-color: var(--border-active); + color: var(--text-primary); + background: var(--bg-card-hover); +} + +.preset-pill.active { + background: var(--accent-cyan-glow); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + font-weight: 600; +} + +/* Custom Checkbox & Toggle Switch */ +.custom-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + margin-top: 6px; +} + +.custom-checkbox input { + display: none; +} + +.checkbox-box { + width: 17px; + height: 17px; + border-radius: var(--radius-xs); + border: 1.5px solid var(--border-default); + background: var(--bg-input); + display: flex; + align-items: center; + justify-content: center; + color: transparent; + font-size: 12px; + transition: all var(--transition-fast); +} + +.custom-checkbox input:checked + .checkbox-box { + background: var(--accent-cyan); + border-color: var(--accent-cyan); + color: var(--text-inverted); + box-shadow: 0 0 8px var(--accent-cyan-glow); +} + +/* Switch Toggle */ +.switch-control { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.switch-control input { + display: none; +} + +.switch-track { + width: 36px; + height: 20px; + background: var(--bg-surface-active); + border-radius: var(--radius-full); + position: relative; + transition: background var(--transition-fast); + border: 1px solid var(--border-default); +} + +.switch-thumb { + width: 14px; + height: 14px; + background: #ffffff; + border-radius: 50%; + position: absolute; + top: 2px; + left: 2px; + transition: transform var(--transition-bounce); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.switch-control input:checked + .switch-track { + background: var(--accent-cyan); + border-color: var(--accent-cyan); +} + +.switch-control input:checked + .switch-track .switch-thumb { + transform: translateX(16px); +} + +/* Buttons System */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + border: 1px solid transparent; + transition: all var(--transition-fast); + white-space: nowrap; + user-select: none; +} + +.btn:active { + transform: scale(0.97); +} + +.btn-primary { + background: linear-gradient(135deg, #0284c7, #2563eb); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 10px rgba(2, 132, 199, 0.3); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #0369a1, #1d4ed8); + box-shadow: 0 4px 16px rgba(2, 132, 199, 0.45); +} + +.btn-secondary { + background: var(--bg-card); + border-color: var(--border-default); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--bg-card-hover); + border-color: var(--border-active); + color: var(--accent-cyan); +} + +.btn-outline { + background: transparent; + border-color: var(--border-default); + color: var(--text-secondary); +} + +.btn-outline:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + background: rgba(56, 189, 248, 0.08); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: var(--status-error); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.25); + border-color: var(--status-error); +} + +.btn-success { + background: rgba(16, 185, 129, 0.15); + border-color: rgba(16, 185, 129, 0.3); + color: var(--status-success); +} + +.btn-success:hover { + background: rgba(16, 185, 129, 0.25); + border-color: var(--status-success); +} + +.btn-sm { + padding: 5px 10px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: 10px 20px; + font-size: 14px; + border-radius: var(--radius-lg); +} + +.btn-icon-only { + padding: 8px; + border-radius: var(--radius-md); + background: var(--bg-card); + border: 1px solid var(--border-default); + color: var(--text-secondary); +} + +.btn-icon-only:hover { + border-color: var(--border-active); + color: var(--accent-cyan); +} + +/* Button Group */ +.btn-group { + display: inline-flex; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-default); +} + +.btn-group .btn { + border-radius: 0; + border: none; + border-right: 1px solid var(--border-default); +} + +.btn-group .btn:last-child { + border-right: none; +} + +/* ========================================================================== + 7. Data Visualization & Component Badges + ========================================================================== */ + +/* Sleep Stage Visual Timeline */ +.sleep-timeline-container { + display: flex; + flex-direction: column; + gap: 8px; + margin: 14px 0; +} + +.sleep-timeline-bar { + display: flex; + height: 28px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-code); + border: 1px solid var(--border-default); +} + +.sleep-segment { + height: 100%; + position: relative; + transition: opacity var(--transition-fast); + cursor: pointer; +} + +.sleep-segment:hover { + opacity: 0.8; + filter: brightness(1.2); +} + +.sleep-segment.deep { background: #3b82f6; } +.sleep-segment.light { background: #60a5fa; } +.sleep-segment.rem { background: #8b5cf6; } +.sleep-segment.awake { background: #f59e0b; } + +.sleep-legend { + display: flex; + align-items: center; + gap: 16px; + font-size: 11.5px; + color: var(--text-muted); + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.legend-dot.deep { background: #3b82f6; } +.legend-dot.light { background: #60a5fa; } +.legend-dot.rem { background: #8b5cf6; } +.legend-dot.awake { background: #f59e0b; } + +/* Metric Series Custom Chart Canvas Container */ +.chart-container { + width: 100%; + height: 240px; + position: relative; + background: var(--bg-code); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 16px; + display: flex; + align-items: flex-end; + gap: 8px; + overflow-x: auto; +} + +.chart-bar-group { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + height: 100%; + justify-content: flex-end; + min-width: 28px; + position: relative; +} + +.chart-bar { + width: 18px; + background: linear-gradient(180deg, var(--accent-cyan), #2563eb); + border-radius: var(--radius-xs) var(--radius-xs) 0 0; + transition: height 0.4s ease-out, background var(--transition-fast); + cursor: pointer; + position: relative; +} + +.chart-bar:hover { + background: linear-gradient(180deg, #7dd3fc, #3b82f6); + box-shadow: 0 0 10px var(--accent-cyan-glow); +} + +.chart-bar-label { + font-size: 10px; + color: var(--text-dim); + margin-top: 6px; + white-space: nowrap; +} + +.chart-bar-tooltip { + position: absolute; + bottom: calc(100% + 6px); + background: var(--bg-surface-active); + color: var(--text-primary); + font-size: 11px; + padding: 3px 8px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-default); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: all var(--transition-fast); + z-index: 10; +} + +.chart-bar:hover .chart-bar-tooltip { + opacity: 1; + transform: translateY(0); +} + +/* Multi-select sync type tags */ +.tag-pills-selector { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0; +} + +.tag-pill { + padding: 6px 12px; + border-radius: var(--radius-full); + border: 1px solid var(--border-default); + background: var(--bg-card); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all var(--transition-fast); +} + +.tag-pill:hover { + border-color: var(--border-active); + color: var(--text-primary); +} + +.tag-pill.selected { + background: rgba(56, 189, 248, 0.15); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + font-weight: 600; +} + +.tag-pill .tag-check { + font-size: 11px; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.tag-pill.selected .tag-check { + opacity: 1; +} + +/* Data Table Grid */ +.data-table-container { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-card); + margin-top: 10px; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + text-align: left; +} + +.data-table th { + background: rgba(255, 255, 255, 0.03); + padding: 10px 14px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--border-default); + white-space: nowrap; +} + +.data-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:hover td { + background: rgba(255, 255, 255, 0.02); + color: var(--text-primary); +} + +/* Pill Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.badge-success { background: var(--status-success-bg); color: var(--status-success); border: 1px solid rgba(16, 185, 129, 0.3); } +.badge-warning { background: var(--status-warning-bg); color: var(--status-warning); border: 1px solid rgba(245, 158, 11, 0.3); } +.badge-danger { background: var(--status-error-bg); color: var(--status-error); border: 1px solid rgba(239, 68, 68, 0.3); } +.badge-info { background: var(--status-info-bg); color: var(--status-info); border: 1px solid rgba(56, 189, 248, 0.3); } +.badge-purple { background: rgba(168, 85, 247, 0.15); color: var(--accent-purple); border: 1px solid rgba(168, 85, 247, 0.3); } + +/* ========================================================================== + 8. Right-Side Live Developer Console & Response Inspector + ========================================================================== */ + +.app-inspector { + background: var(--bg-surface); + border-left: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow: hidden; +} + +.inspector-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.inspector-title { + font-size: 13.5px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-primary); +} + +.inspector-meta-bar { + padding: 10px 18px; + background: var(--bg-card); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); + gap: 8px; +} + +.meta-badges-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.http-method-pill { + padding: 2px 7px; + border-radius: var(--radius-xs); + font-weight: 700; + font-size: 10.5px; + text-transform: uppercase; +} + +.http-method-pill.get { background: #0369a1; color: #e0f2fe; } +.http-method-pill.post { background: #047857; color: #d1fae5; } +.http-method-pill.delete { background: #b91c1c; color: #fee2e2; } +.http-method-pill.put { background: #b45309; color: #fef3c7; } + +.status-pill { + padding: 2px 7px; + border-radius: var(--radius-xs); + font-weight: 700; + font-size: 10.5px; +} + +.status-pill.ok { background: #064e3b; color: #34d399; } +.status-pill.err { background: #7f1d1d; color: #f87171; } +.status-pill.pending { background: #1e1b4b; color: #a5b4fc; } + +.latency-pill { + padding: 2px 7px; + border-radius: var(--radius-xs); + background: rgba(255, 255, 255, 0.06); + color: var(--text-secondary); + font-size: 11px; + font-family: monospace; +} + +.inspector-actions-bar { + padding: 8px 18px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 8px; +} + +.inspector-search-input { + flex: 1; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-default); + background: var(--bg-input); + color: var(--text-primary); + font-size: 11.5px; +} + +.inspector-body { + flex: 1; + padding: 14px 18px; + background: var(--bg-code); + overflow-y: auto; + font-family: 'JetBrains Mono', Consolas, monospace; + font-size: 12px; + line-height: 1.6; + color: #e2e8f0; + white-space: pre-wrap; + word-break: break-all; + position: relative; +} + +/* JSON Viewer Syntax Highlighting */ +.json-key { color: #38bdf8; font-weight: 600; } +.json-string { color: #4ade80; } +.json-number { color: #fbbf24; } +.json-boolean { color: #f472b6; font-weight: 600; } +.json-null { color: #94a3b8; font-style: italic; } + +.inspector-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-dim); + gap: 12px; + text-align: center; +} + +.inspector-empty-icon { + font-size: 42px; + opacity: 0.4; +} + +/* ========================================================================== + 9. Modal Dialogs & Popup Overlays + ========================================================================== */ + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-modal-overlay); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + z-index: 1000; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.2s ease-out; +} + +.modal-backdrop.active { + display: flex; +} + +.modal-card { + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + width: 100%; + max-width: 480px; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes modalPop { + 0% { transform: scale(0.9) translateY(20px); opacity: 0; } + 100% { transform: scale(1) translateY(0); opacity: 1; } +} + +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.modal-close-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.modal-close-btn:hover { + color: var(--text-primary); + background: var(--bg-surface-hover); +} + +.modal-body { + padding: 22px; + display: flex; + flex-direction: column; + gap: 16px; + max-height: 70vh; + overflow-y: auto; +} + +.modal-footer { + padding: 16px 22px; + border-top: 1px solid var(--border-subtle); + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +/* QR Code Display in Modal */ +.qr-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 16px 0; +} + +.qr-image-frame { + width: 220px; + height: 220px; + background: #ffffff; + border-radius: var(--radius-lg); + padding: 12px; + box-shadow: 0 0 25px rgba(56, 189, 248, 0.2); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.qr-image-frame img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: var(--radius-sm); +} + +.qr-scan-beam { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, #0284c7, transparent); + box-shadow: 0 0 10px #38bdf8; + animation: scanBeam 2.5s infinite ease-in-out; +} + +@keyframes scanBeam { + 0% { top: 5%; opacity: 0; } + 20% { opacity: 1; } + 80% { opacity: 1; } + 100% { top: 95%; opacity: 0; } +} + +.qr-timer-bar { + width: 100%; + height: 4px; + background: var(--bg-surface-active); + border-radius: var(--radius-full); + overflow: hidden; +} + +.qr-timer-progress { + height: 100%; + background: var(--accent-cyan); + width: 100%; + transition: width 1s linear; +} + +/* ========================================================================== + 10. Toast Notification System + ========================================================================== */ + +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.toast-message { + pointer-events: auto; + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 12px 18px; + box-shadow: var(--shadow-lg); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + display: flex; + align-items: center; + gap: 12px; + min-width: 280px; + max-width: 420px; + animation: toastSlideIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + transition: all var(--transition-normal); +} + +@keyframes toastSlideIn { + 0% { transform: translateX(120%) scale(0.9); opacity: 0; } + 100% { transform: translateX(0) scale(1); opacity: 1; } +} + +.toast-message.toast-leaving { + transform: translateX(120%) scale(0.9); + opacity: 0; +} + +.toast-icon { + font-size: 18px; + flex-shrink: 0; +} + +.toast-message.success { border-color: rgba(16, 185, 129, 0.4); } +.toast-message.success .toast-icon { color: var(--status-success); } +.toast-message.error { border-color: rgba(239, 68, 68, 0.4); } +.toast-message.error .toast-icon { color: var(--status-error); } +.toast-message.warning { border-color: rgba(245, 158, 11, 0.4); } +.toast-message.warning .toast-icon { color: var(--status-warning); } +.toast-message.info { border-color: rgba(56, 189, 248, 0.4); } +.toast-message.info .toast-icon { color: var(--status-info); } + +.toast-content { + flex: 1; +} + +.toast-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.toast-desc { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 1px; +} + +/* ========================================================================== + 11. Micro-animations, Loaders & Skeleton States + ========================================================================== */ + +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.skeleton { + background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%); + background-size: 200% 100%; + animation: shimmer 1.8s infinite; + border-radius: var(--radius-sm); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Pulse animation for live updates */ +.live-pulse { + animation: pulseGlow 2s infinite; +} + +@keyframes pulseGlow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Tooltip Styling */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: var(--bg-surface-active); + color: var(--text-primary); + font-size: 11px; + font-weight: 500; + padding: 4px 8px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-default); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: all var(--transition-fast); + z-index: 100; + box-shadow: var(--shadow-md); +} + +[data-tooltip]:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* ========================================================================== + 12. Responsive Breakpoints & Mobile Adaptations + ========================================================================== */ + +@media (max-width: 1400px) { + :root { + --inspector-width: 380px; + } +} + +@media (max-width: 1200px) { + .main-layout { + grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--inspector-width); + } + + .nav-item span:not(.nav-icon) { + display: none; + } + + .nav-section-title { + display: none; + } + + .nav-item { + justify-content: center; + padding: 10px; + } +} + +@media (max-width: 992px) { + .main-layout { + grid-template-columns: 1fr; + } + + .app-sidebar { + display: none; /* Can be toggled with mobile drawer */ + } + + .app-inspector { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 320px; + z-index: 90; + border-left: none; + border-top: 1px solid var(--border-default); + } + + .app-content { + padding-bottom: 340px; + } +} + +@media (max-width: 640px) { + .top-header { + padding: 0 14px; + } + + .brand-subtitle, .api-key-input-wrapper { + display: none; + } + + .stat-cards-grid { + grid-template-columns: 1fr 1fr; + } + + .form-row { + flex-direction: column; + align-items: stretch; + } + + .form-group { + width: 100%; + max-width: none !important; + } +} diff --git a/src/mi_fitness_mcp/web_assets/static/js/app.js b/src/mi_fitness_mcp/web_assets/static/js/app.js new file mode 100644 index 0000000..7819be5 --- /dev/null +++ b/src/mi_fitness_mcp/web_assets/static/js/app.js @@ -0,0 +1,818 @@ +/** + * Mi Fitness Dashboard & Developer Console + * Core Client-side Application Logic + */ + +// State Store +const state = { + activeTab: 'overview', + theme: localStorage.getItem('mi_fitness_theme') || 'dark', + apiKey: localStorage.getItem('mi_fitness_api_key') || '', + qrToken: '', + qrPollInterval: null, + qrTimerInterval: null, + qrExpiresIn: 300, + syncTypes: ['daily_activity', 'heart_rate', 'sleep', 'workouts', 'body_measurements', 'spo2', 'stress', 'abnormal_heart_beat'], + lastResponse: null, + lastRequestInfo: null, +}; + +// DOM Utilities +const $ = (id) => document.getElementById(id); +const $$ = (selector) => document.querySelectorAll(selector); + +// Date Helpers +const formatDate = (d) => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +function initDates() { + const today = new Date(); + const yesterday = new Date(Date.now() - 86400000); + + $('startDate').value = formatDate(yesterday); + $('endDate').value = formatDate(today); +} + +function setDatePreset(type) { + const today = new Date(); + let start = new Date(); + + $$('.preset-pill').forEach(p => p.classList.remove('active')); + + if (type === 'today') { + start = today; + } else if (type === 'yesterday') { + start = new Date(Date.now() - 86400000); + today.setDate(today.getDate() - 1); + } else if (type === '7d') { + start = new Date(Date.now() - 7 * 86400000); + } else if (type === '30d') { + start = new Date(Date.now() - 30 * 86400000); + } else if (type === 'month') { + start = new Date(today.getFullYear(), today.getMonth(), 1); + } + + $('startDate').value = formatDate(start); + $('endDate').value = formatDate(type === 'yesterday' ? start : new Date()); + + const el = $(`preset-${type}`); + if (el) el.classList.add('active'); +} + +const getSd = () => $('startDate').value; +const getEd = () => $('endDate').value; +const getRangeQuery = (path) => `${path}?start_date=${getSd()}&end_date=${getEd()}`; +const getSelQuery = (id, param) => { + const v = $(id).value; + return v ? `&${param}=${encodeURIComponent(v)}` : ''; +}; +const getLimQuery = (id) => { + const v = $(id).value.trim(); + return v ? `&limit=${encodeURIComponent(v)}` : ''; +}; + +// UI Theme Management +function initTheme() { + document.documentElement.setAttribute('data-theme', state.theme); + updateThemeIcon(); +} + +function toggleTheme() { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('mi_fitness_theme', state.theme); + document.documentElement.setAttribute('data-theme', state.theme); + updateThemeIcon(); + showToast('主题已切换', `已切换至 ${state.theme === 'dark' ? '深色模式 🌙' : '浅色模式 ☀️'}`, 'info'); +} + +function updateThemeIcon() { + const btn = $('themeToggleBtn'); + if (btn) { + btn.innerHTML = state.theme === 'dark' ? '🌙' : '☀️'; + } +} + +// Toast Notifications +function showToast(title, desc = '', type = 'info', duration = 3500) { + const container = $('toastContainer'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast-message ${type}`; + + const icons = { + success: '✅', + error: '❌', + warning: '⚠️', + info: '💡', + }; + + toast.innerHTML = ` +
${icons[type] || 'ℹ️'}
+
+
${title}
+ ${desc ? `
${desc}
` : ''} +
+ `; + + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('toast-leaving'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +// Tab Switching +function switchTab(tabId) { + state.activeTab = tabId; + + $$('.nav-item').forEach(item => { + item.classList.toggle('active', item.getAttribute('data-tab') === tabId); + }); + + $$('.tab-pane').forEach(pane => { + pane.classList.toggle('active', pane.id === `tab-${tabId}`); + }); +} + +// API Communication & Proxy Handler +function getHeaders() { + const headers = { 'Content-Type': 'application/json' }; + const key = state.apiKey || $('apiKeyInput').value.trim(); + if (key) { + headers['X-API-Key'] = key; + } + return headers; +} + +async function api(method, path, body = null) { + const startTime = performance.now(); + const url = '/proxy' + path; + + updateInspectorHeader(method, path, 'pending', '请求中…'); + $('inspectorBody').innerHTML = `
正在通过 Flask 代理请求后端...
`; + + try { + const options = { + method, + headers: getHeaders(), + }; + if (body) { + options.body = JSON.stringify(body); + } + + const resp = await fetch(url, options); + const latency = Math.round(performance.now() - startTime); + const text = await resp.text(); + + let jsonObj = null; + try { + jsonObj = JSON.parse(text); + } catch (e) { + // plain text + } + + state.lastResponse = jsonObj || text; + state.lastRequestInfo = { + method, + path, + url, + status: resp.status, + latency, + headers: options.headers, + body, + }; + + updateInspectorHeader(method, path, resp.ok ? 'ok' : 'err', `HTTP ${resp.status}`, latency); + renderJsonViewer(state.lastResponse); + + if (!resp.ok) { + showToast(`请求失败: HTTP ${resp.status}`, jsonObj?.error || jsonObj?.detail || '请查看控制台输出', 'error'); + } + + return jsonObj; + } catch (err) { + const latency = Math.round(performance.now() - startTime); + updateInspectorHeader(method, path, 'err', '网络错误', latency); + $('inspectorBody').innerHTML = `
❌ 代理网络连接错误: ${err.message}

💡 请检查:
1. Python FastAPI 是否在 127.0.0.1:8321 运行
2. Flask 代理是否正常监听
`; + showToast('代理连接失败', '无法连接到后端服务,请确认端口与服务状态', 'error'); + return null; + } +} + +// Inspector View Rendering +function updateInspectorHeader(method, path, statusType, statusText, latency = 0) { + const methodClass = method.toLowerCase(); + $('metaMethod').className = `http-method-pill ${methodClass}`; + $('metaMethod').textContent = method; + + $('metaStatus').className = `status-pill ${statusType}`; + $('metaStatus').textContent = statusText; + + $('metaLatency').textContent = latency > 0 ? `${latency} ms` : '...'; + $('metaUrl').textContent = path; +} + +function renderJsonViewer(data) { + if (!data) { + $('inspectorBody').innerHTML = `
📂
暂无返回数据
`; + return; + } + + const jsonStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + $('inspectorBody').innerHTML = highlightJson(jsonStr); +} + +function highlightJson(json) { + if (typeof json !== 'string') { + json = JSON.stringify(json, null, 2); + } + json = json.replace(/&/g, '&').replace(//g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { + let cls = 'json-number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'json-key'; + } else { + cls = 'json-string'; + } + } else if (/true|false/.test(match)) { + cls = 'json-boolean'; + } else if (/null/.test(match)) { + cls = 'json-null'; + } + return `${match}`; + }); +} + +function filterInspectorJson(query) { + if (!state.lastResponse) return; + const q = query.trim().toLowerCase(); + if (!q) { + renderJsonViewer(state.lastResponse); + return; + } + const str = JSON.stringify(state.lastResponse, null, 2); + const lines = str.split('\n'); + const filtered = lines.filter(line => line.toLowerCase().includes(q)).join('\n'); + $('inspectorBody').innerHTML = `
🔍 匹配到 ${lines.filter(l => l.toLowerCase().includes(q)).length} 行:
` + highlightJson(filtered || '未找到匹配项'); +} + +// Copy & Export Tools +function copyResponse() { + if (!state.lastResponse) { + showToast('无可复制数据', '', 'warning'); + return; + } + const text = typeof state.lastResponse === 'string' ? state.lastResponse : JSON.stringify(state.lastResponse, null, 2); + navigator.clipboard.writeText(text).then(() => { + showToast('复制成功', '响应 JSON 已存入剪贴板', 'success'); + }); +} + +function copyCurl() { + if (!state.lastRequestInfo) { + showToast('无可用请求', '请先发起一次 API 请求', 'warning'); + return; + } + const { method, path, headers, body } = state.lastRequestInfo; + let curl = `curl -X ${method} "http://127.0.0.1:8321${path}"`; + for (const [k, v] of Object.entries(headers)) { + curl += ` \\\n -H "${k}: ${v}"`; + } + if (body) { + curl += ` \\\n -d '${JSON.stringify(body)}'`; + } + navigator.clipboard.writeText(curl).then(() => { + showToast('cURL 命令已复制', '可在终端直接执行该请求', 'success'); + }); +} + +function downloadJson() { + if (!state.lastResponse) { + showToast('无可下载数据', '', 'warning'); + return; + } + const text = typeof state.lastResponse === 'string' ? state.lastResponse : JSON.stringify(state.lastResponse, null, 2); + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `mi-fitness-response-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast('下载完成', 'JSON 文件已保存', 'success'); +} + +// Overview Dashboard Loader +async function loadOverview() { + showToast('正在加载仪表盘概览…', '', 'info', 1500); + const summaryPromise = api('GET', getRangeQuery('/api/summary')); + const hrPromise = api('GET', getRangeQuery('/api/heart-rate') + '&sample_type=resting&limit=1'); + const sleepPromise = api('GET', getRangeQuery('/api/sleep') + '&include_naps=true'); + const spo2Promise = api('GET', getRangeQuery('/api/spo2') + '&limit=1'); + const stressPromise = api('GET', getRangeQuery('/api/stress') + '&limit=1'); + + const [summary, hr, sleep, spo2, stress] = await Promise.all([ + summaryPromise, hrPromise, sleepPromise, spo2Promise, stressPromise + ]); + + if (summary && Array.isArray(summary) && summary.length > 0) { + const latest = summary[summary.length - 1]; + $('statSteps').textContent = latest.steps ? latest.steps.toLocaleString() : '0'; + $('statCalories').textContent = latest.active_calories ? Math.round(latest.active_calories) : '0'; + $('statDistance').textContent = latest.distance ? (latest.distance / 1000).toFixed(2) : '0'; + } + + if (hr && Array.isArray(hr) && hr.length > 0) { + $('statHr').textContent = hr[0].bpm || '--'; + } + + if (sleep && Array.isArray(sleep) && sleep.length > 0) { + const lastSleep = sleep[sleep.length - 1]; + const totalMinutes = lastSleep.duration_minutes || (lastSleep.end_time - lastSleep.start_time) / 60; + const hours = (totalMinutes / 60).toFixed(1); + $('statSleep').textContent = isNaN(hours) ? '--' : hours; + } + + if (spo2 && Array.isArray(spo2) && spo2.length > 0) { + $('statSpo2').textContent = spo2[0].spo2_value ? `${spo2[0].spo2_value}%` : '--'; + } + + if (stress && Array.isArray(stress) && stress.length > 0) { + $('statStress').textContent = stress[0].stress_value || '--'; + } + + showToast('仪表盘已刷新', '最新数据已加载', 'success'); +} + +// Health Data Queries +async function querySummary() { + const data = await api('GET', getRangeQuery('/api/summary')); + renderSummaryVisual(data); +} + +function renderSummaryVisual(data) { + const container = $('summaryVisualContainer'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = `

所选时间段内无活动数据

`; + return; + } + + let html = ` +
+ + + + + + + + + + + `; + + data.forEach(item => { + const distKm = item.distance ? (item.distance / 1000).toFixed(2) : '0.00'; + html += ` + + + + + + + `; + }); + + html += `
日期步数活动卡路里 (kcal)距离 (km)
${item.date || '--'}👟 ${(item.steps || 0).toLocaleString()} 步🔥 ${Math.round(item.active_calories || 0)} kcal📍 ${distKm} km
`; + container.innerHTML = html; +} + +async function queryHeartRate() { + const q = getRangeQuery('/api/heart-rate') + getSelQuery('hrType', 'sample_type') + getLimQuery('hrLimit'); + await api('GET', q); +} + +async function querySleep() { + const q = getRangeQuery('/api/sleep') + '&include_naps=' + $('napsSelect').value; + const data = await api('GET', q); + renderSleepTimeline(data); +} + +function renderSleepTimeline(data) { + const container = $('sleepTimelineWrapper'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = '

无睡眠分段数据

'; + return; + } + + const latest = data[data.length - 1]; + const deep = latest.deep_sleep_minutes || 0; + const light = latest.light_sleep_minutes || 0; + const rem = latest.rem_sleep_minutes || 0; + const awake = latest.awake_minutes || 0; + const total = deep + light + rem + awake || 1; + + container.innerHTML = ` +
+
+
+
+
+
+
+
+
深睡 ${deep}m (${Math.round((deep/total)*100)}%)
+
浅睡 ${light}m (${Math.round((light/total)*100)}%)
+
REM ${rem}m (${Math.round((rem/total)*100)}%)
+
清醒 ${awake}m
+
+
+ `; +} + +async function queryWorkouts() { + await api('GET', getRangeQuery('/api/workouts')); +} + +async function queryBody() { + const isLatest = $('latestOnlyCheck').checked; + await api('GET', getRangeQuery('/api/body-measurements') + '&latest_only=' + isLatest); +} + +async function querySpo2() { + await api('GET', getRangeQuery('/api/spo2') + getLimQuery('spo2Limit')); +} + +async function queryStress() { + await api('GET', getRangeQuery('/api/stress') + getSelQuery('stressLevel', 'level') + getLimQuery('stressLimit')); +} + +async function queryAbnormalHr() { + await api('GET', getRangeQuery('/api/abnormal-heart-beat')); +} + +// Metric Series Analytics +async function queryMetricSeries() { + const metric = $('metricSelect').value; + const gran = $('granularitySelect').value; + const agg = $('aggregationSelect').value; + const q = `${getRangeQuery('/api/metric-series')}&metric=${metric}&granularity=${gran}&aggregation=${agg}`; + const data = await api('GET', q); + renderMetricChart(data, metric); +} + +function renderMetricChart(data, metricName) { + const container = $('metricChartContainer'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = '

所选范围内无序列数据

'; + return; + } + + const maxVal = Math.max(...data.map(d => d.value || 0), 1); + let barsHtml = ''; + + data.forEach(item => { + const val = item.value || 0; + const heightPercent = Math.max(Math.round((val / maxVal) * 100), 5); + const dateLabel = item.timestamp ? item.timestamp.split('T')[0].slice(5) : (item.date || ''); + + barsHtml += ` +
+
+
${item.date || item.timestamp || ''}: ${val.toLocaleString()}
+
+
${dateLabel}
+
+ `; + }); + + container.innerHTML = barsHtml; +} + +// Sync Center Control +function toggleSyncTag(tag) { + const idx = state.syncTypes.indexOf(tag); + if (idx > -1) { + if (state.syncTypes.length > 1) { + state.syncTypes.splice(idx, 1); + } else { + showToast('至少保留一种同步类型', '', 'warning'); + return; + } + } else { + state.syncTypes.push(tag); + } + renderSyncPills(); +} + +function selectAllSyncTags(all = true) { + if (all) { + state.syncTypes = ['daily_activity', 'heart_rate', 'sleep', 'workouts', 'body_measurements', 'spo2', 'stress', 'abnormal_heart_beat']; + } else { + state.syncTypes = ['daily_activity']; + } + renderSyncPills(); +} + +function renderSyncPills() { + const container = $('syncTagPills'); + if (!container) return; + const allTypes = [ + { id: 'daily_activity', name: '每日活动' }, + { id: 'heart_rate', name: '心率' }, + { id: 'sleep', name: '睡眠' }, + { id: 'workouts', name: '运动' }, + { id: 'body_measurements', name: '体成分' }, + { id: 'spo2', name: '血氧' }, + { id: 'stress', name: '压力' }, + { id: 'abnormal_heart_beat', name: '异常心跳' }, + ]; + + container.innerHTML = allTypes.map(t => { + const isSelected = state.syncTypes.includes(t.id); + return ` +
+ ✓ + ${t.name} +
+ `; + }).join(''); +} + +async function triggerSync(background = false) { + const body = { + start_date: getSd(), + end_date: getEd(), + background, + data_types: state.syncTypes, + }; + + showToast(background ? '已触发后台异步同步' : '正在执行前台同步…', '请耐心等待云端响应', 'info'); + const res = await api('POST', '/api/sync', body); + + if (background && res && res.sync_id) { + $('syncIdInput').value = res.sync_id; + showToast('已获取同步任务 ID', res.sync_id, 'success'); + } else if (!background && res) { + showToast('前台同步完成', '健康数据已更新至本地 SQLite', 'success'); + } +} + +async function pollSyncStatus() { + const syncId = $('syncIdInput').value.trim(); + if (!syncId) { + showToast('请输入同步任务 ID', '', 'warning'); + return; + } + await api('GET', `/api/sync/${encodeURIComponent(syncId)}`); +} + +// QR Code Authentication Modal & Auto Polling +function openQrModal() { + $('qrModal').classList.add('active'); + startQrFlow(); +} + +function closeQrModal() { + $('qrModal').classList.remove('active'); + stopQrFlow(); +} + +async function startQrFlow() { + stopQrFlow(); + $('qrStatusText').textContent = '正在向小米账号服务器生成登录二维码...'; + $('qrScanBeam').style.display = 'none'; + + const res = await api('POST', '/api/auth/qr/start?region=cn'); + if (res && res.qr_token) { + state.qrToken = res.qr_token; + state.qrExpiresIn = res.expires_in || 300; + + $('qrImage').src = `/proxy/api/auth/qr/${res.qr_token}.png`; + $('qrLoginUrl').value = res.login_url || ''; + $('qrScanBeam').style.display = 'block'; + $('qrStatusText').textContent = '请打开小米运动健康/米家/小米账号 App 扫码并在手机上点击确认'; + + startQrTimer(); + startQrPolling(); + } else { + $('qrStatusText').textContent = '二维码生成失败,请确认后端连接正常'; + } +} + +function startQrTimer() { + const total = state.qrExpiresIn; + let remaining = total; + + state.qrTimerInterval = setInterval(() => { + remaining--; + const mins = Math.floor(remaining / 60); + const secs = String(remaining % 60).padStart(2, '0'); + $('qrTimerText').textContent = `有效期剩余: ${mins}:${secs}`; + $('qrProgressBar').style.width = `${(remaining / total) * 100}%`; + + if (remaining <= 0) { + stopQrFlow(); + $('qrStatusText').textContent = '二维码已过期,请点击「刷新二维码」重试'; + } + }, 1000); +} + +function startQrPolling() { + state.qrPollInterval = setInterval(async () => { + if (!state.qrToken) return; + try { + const res = await fetch(`/proxy/api/auth/qr/poll?token=${state.qrToken}`, { + headers: getHeaders(), + }); + const data = await res.json(); + + if (data.status === 'scanned') { + $('qrStatusText').innerHTML = '📱 已扫码! 请在手机上点击「确认登录」'; + } else if (data.status === 'confirmed' && data.api_key) { + stopQrFlow(); + $('qrStatusText').innerHTML = `✅ 登录成功! API Key 已自动保存`; + saveApiKey(data.api_key); + showToast('扫码授权成功', `API Key: ${data.api_key.slice(0, 16)}…`, 'success'); + setTimeout(() => closeQrModal(), 1800); + } + } catch (e) { + // ignore transient poll error + } + }, 2000); +} + +function stopQrFlow() { + if (state.qrPollInterval) clearInterval(state.qrPollInterval); + if (state.qrTimerInterval) clearInterval(state.qrTimerInterval); + state.qrPollInterval = null; + state.qrTimerInterval = null; +} + +function copyLoginUrl() { + const url = $('qrLoginUrl').value; + if (!url) return; + navigator.clipboard.writeText(url).then(() => { + showToast('登录链接已复制', '也可在手机浏览器打开该链接登录确认', 'success'); + }); +} + +// API Key Management +function saveApiKey(key) { + state.apiKey = key.trim(); + localStorage.setItem('mi_fitness_api_key', state.apiKey); + $('apiKeyInput').value = state.apiKey; +} + +function initApiKeyInput() { + const input = $('apiKeyInput'); + input.value = state.apiKey; + input.addEventListener('change', (e) => { + saveApiKey(e.target.value); + showToast('API Key 已更新', '', 'info'); + }); +} + +async function createKeyWithCredentials() { + const userId = $('keyUserId').value.trim(); + const passToken = $('keyPassToken').value.trim(); + const label = $('keyLabel').value.trim() || 'web-console'; + + if (!userId || !passToken) { + showToast('请填写 user_id 和 passToken', '', 'warning'); + return; + } + + const res = await api('POST', '/api/auth/keys', { + user_id: userId, + pass_token: passToken, + region: 'cn', + label, + }); + + if (res && res.api_key) { + saveApiKey(res.api_key); + showToast('API Key 发放成功', '已自动填入全局请求头', 'success'); + listKeys(); + } +} + +async function listKeys() { + const data = await api('GET', '/api/auth/keys'); + renderKeysTable(data); +} + +function renderKeysTable(data) { + const container = $('keysTableWrapper'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = '

暂无已发放的 API Key 记录

'; + return; + } + + let html = ` +
+ + + + + + + + + + + + `; + + data.forEach(k => { + html += ` + + + + + + + + `; + }); + + html += '
标签Key 前缀User ID创建时间操作
${k.label || '默认'}${k.prefix || k.api_key_prefix || 'mif_sk_...'}${k.user_id || '--'}${k.created_at || '--'} + +
'; + container.innerHTML = html; +} + +async function revokeKeyByPrefix(prefix) { + if (!prefix) { + prefix = $('revokePrefixInput').value.trim(); + } + if (!prefix) { + showToast('请提供要吊销的 Key 前缀', '', 'warning'); + return; + } + if (!confirm(`确定要吊销前缀为 ${prefix} 的 API Key 吗?`)) return; + + await api('DELETE', `/api/auth/keys/${encodeURIComponent(prefix)}`); + showToast('Key 吊销指令已发送', '', 'info'); + listKeys(); +} + +// System Connection & Health Check +async function checkSystemHealth() { + const statusEl = $('backendStatusText'); + const dotEl = $('backendStatusDot'); + try { + const res = await fetch('/api/proxy_status'); + const data = await res.json(); + if (data.backend_reachable) { + dotEl.className = 'status-dot pulsing'; + statusEl.textContent = `FastAPI: 在线 (${data.backend_latency_ms}ms)`; + } else { + dotEl.className = 'status-dot offline pulsing'; + statusEl.textContent = 'FastAPI: 离线'; + } + } catch (e) { + dotEl.className = 'status-dot offline pulsing'; + statusEl.textContent = '代理服务异常'; + } +} + +// Initialization on DOM Ready +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + initDates(); + initApiKeyInput(); + renderSyncPills(); + checkSystemHealth(); + setInterval(checkSystemHealth, 15000); + + // Bind preset pills + $$('.preset-pill').forEach(pill => { + pill.addEventListener('click', (e) => { + const type = e.target.getAttribute('data-preset'); + if (type) setDatePreset(type); + }); + }); + + // Tab Navigation + $$('.nav-item').forEach(item => { + item.addEventListener('click', () => { + const tab = item.getAttribute('data-tab'); + if (tab) switchTab(tab); + }); + }); + + // Auto load overview stats + loadOverview(); +}); diff --git a/src/mi_fitness_mcp/web_assets/templates/index.html b/src/mi_fitness_mcp/web_assets/templates/index.html new file mode 100644 index 0000000..aa7852f --- /dev/null +++ b/src/mi_fitness_mcp/web_assets/templates/index.html @@ -0,0 +1,650 @@ + + + + + + Mi Fitness MCP | 健康数据中心与调试控制台 + + + + + + +
+ +
+
+
⚡
+
+
+ Mi Fitness Hub + MCP CN +
+
小米运动健康数据服务 · Python Flask 驱动
+
+
+ +
+ +
+
+ 检测连接中... +
+ + +
+ + ~ + +
+ + +
+ + +
+ + + + + +
+
+ + +
+ + + + +
+ +
+ 快捷范围: + + + + + +
+ + +
+
+
+
📊 健康指标总览
+
基于选定时间范围的最新健康数据概览
+
+ +
+ +
+
+
+ 今日总步数 +
👟
+
+
-- 步
+ +
+ +
+
+ 活动消耗 +
🔥
+
+
-- kcal
+ +
+ +
+
+ 静息心率 +
❤️
+
+
-- bpm
+ +
+ +
+
+ 最近睡眠 +
💤
+
+
-- 小时
+ +
+ +
+
+ 血氧饱和度 +
🫁
+
+
--
+ +
+ +
+
+ 压力水平 +
🧠
+
+
--
+ +
+
+ +
+
⚡ 快速操作与数据探测
+
+ + + + +
+
+
+ + +
+
+
+
+
👟 每日活动汇总 (/api/summary)
+
步数、活动距离、日常活动卡路里消耗
+
+ +
+
+

点击上方按钮查询所选日期范围内的每日活动汇总...

+
+
+
+ + +
+
+
+
+
❤️ 心率采样 (/api/heart-rate)
+
逐分钟心率采样点、静息心率与极限心率统计
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
💤 睡眠分析与分段 (/api/sleep)
+
入睡与清醒时间、深睡/浅睡/REM分期、零星小睡统计
+
+ +
+ +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+
🏃 运动训练记录 (/api/workouts)
+
户外跑步、骑行、徒步等各项运动轨迹、心率区间与配速
+
+ +
+
+
+ + +
+
+
+
+
⚖️ 体重与体成分 (/api/body-measurements)
+
体脂率、BMI、肌肉量、骨量、内脏脂肪等(需体脂秤支持)
+
+ +
+ + +
+
+ + +
+
+
+
+
🫁 血氧监测 (/api/spo2)
+
全天血氧饱和度连续采样与低血氧告警
+
+ +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+
🧠 压力监测 (/api/stress)
+
全天压力值、压力等级分布与放松建议
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
⚠️ 异常心跳事件 (/api/abnormal-heart-beat)
+
静息心率过高、过低或心律不齐事件记录
+
+ +
+
+
+ + +
+
+
+
+
📈 指标序列多维分析 (/api/metric-series)
+
按天、周、月聚合分析健康走势
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

选择上方指标并点击查询以查看走势图...

+
+
+
+ + +
+
+
+
+
🔄 云端数据同步控制中心 (/api/sync)
+
从小米运动健康云端拉取最新数据存入本地 SQLite
+
+
+ +
+
+ +
+ + +
+
+ +
+
+ +
+ + +
+ +
+ +
+
+ +
+ +
+
+
+
+ + +
+
+
+
+
🔑 API Key 密钥管理 (/api/auth/keys)
+
使用小米账号凭据换取独立的 mif_sk_* 密钥,支持多账号与凭据隔离
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+
+
🩺 服务连通性与诊断报告
+
测试适配器连通性、区域探测及本地 SQLite 数据库状态
+
+
+ +
+ + + +
+
+
+
+ + + +
+
+ + + + + +
+ + + + + + diff --git a/testpage/.gitignore b/testpage/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/testpage/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/testpage/README.md b/testpage/README.md new file mode 100644 index 0000000..dae3269 --- /dev/null +++ b/testpage/README.md @@ -0,0 +1,34 @@ +# Mi Fitness Web UI & API 调试控制台(Python Flask) + +现代化的小米运动健康数据可视化面板与反向代理控制台(基于 Python Flask),提供开箱即用的 Web 界面,方便直观查看健康数据、触发同步、管理 API Key 及扫码登录授权。 + +- 📊 **现代化仪表盘**:涵盖每日活动、心率采样、睡眠分期、运动训练、体重体成分、血氧、压力与异常心跳。 +- 📈 **多维指标序列分析**:支持步数、距离、卡路里按天/周/月进行 sum/avg/max/min 聚合与图表走势。 +- 📱 **扫码登录**:内嵌小米官方 App / 米家扫码登录流程,自动轮询并一键换取 API Key。 +- 🔑 **API Key 体系**:多账号凭据发放、列表查询与前缀吊销。 +- 💻 **开发者控制台**:实时响应检视、JSON 语法高亮/过滤、耗时统计与一键生成 cURL 命令。 +- ⚡ **无缝代理**:自动反向代理 `/proxy/*` 到后端 FastAPI(默认 `http://127.0.0.1:8321`),并自动中继服务端安全 Key(`X-API-Key` / `X-Admin-Key`)。 + +## 快速启动 + +### 方式 1:通过主项目 CLI + +```bash +mi-fitness-mcp web [--host 127.0.0.1] [--port 8322] [--backend-url http://127.0.0.1:8321] +``` + +### 方式 2:直接运行脚本 + +```bash +python testpage/app.py +# 浏览器打开 http://127.0.0.1:8322/ +``` + +## 环境变量 + +| 变量名 | 默认值 | 说明 | +|---|---|---| +| `BIND` | `127.0.0.1:8322` | Web 界面监听地址与端口 | +| `MI_FITNESS_API_URL` | `http://127.0.0.1:8321` | FastAPI 后端服务地址 | +| `MI_FITNESS_API_KEY` | 无 | 设置后代理自动附加 `X-API-Key`(密钥不进浏览器) | +| `MI_FITNESS_ADMIN_KEY` | 无 | 设置后代理自动附加 `X-Admin-Key`(管理端点鉴权) | diff --git a/testpage/app.py b/testpage/app.py new file mode 100644 index 0000000..81e9c4f --- /dev/null +++ b/testpage/app.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Mi Fitness Web UI & Testing Proxy (Flask). + +Usage: + python app.py + python app.py --host 127.0.0.1 --port 8322 --backend-url http://127.0.0.1:8321 +""" + +import argparse +import os +import sys +from pathlib import Path + +# Add repo src to sys.path so it works without install +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "src")) + +from mi_fitness_mcp.web import run_server + + +def main(): + parser = argparse.ArgumentParser(description="Mi Fitness Web UI & API Test Proxy (Flask)") + parser.add_argument( + "--host", + default=os.environ.get("BIND_HOST", "127.0.0.1"), + help="Host address to bind (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=int(os.environ.get("PORT", "8322")), + help="Port to listen on (default: 8322)", + ) + parser.add_argument( + "--backend-url", + default=os.environ.get("MI_FITNESS_API_URL", "http://127.0.0.1:8321"), + help="Backend FastAPI URL (default: http://127.0.0.1:8321)", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Run in Flask debug mode", + ) + + args = parser.parse_args() + run_server( + host=args.host, + port=args.port, + backend_url=args.backend_url, + debug=args.debug, + ) + + +if __name__ == "__main__": + main() diff --git a/testpage/static/css/style.css b/testpage/static/css/style.css new file mode 100644 index 0000000..b565040 --- /dev/null +++ b/testpage/static/css/style.css @@ -0,0 +1,1745 @@ +/** + * Mi Fitness Dashboard & Developer Console Design System + * Modern Glassmorphism & High-Density Health Analytics UI + * Designed for Mi Fitness MCP CN + */ + +/* ========================================================================== + 1. Design Tokens & CSS Custom Properties + ========================================================================== */ + +:root { + /* Color Palette - Dark Mode (Default) */ + --bg-base: #090d16; + --bg-gradient: radial-gradient(circle at 15% 15%, rgba(56, 189, 248, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(139, 92, 246, 0.08) 0%, transparent 40%), + #090d16; + --bg-surface: rgba(17, 24, 39, 0.72); + --bg-surface-hover: rgba(30, 41, 59, 0.85); + --bg-surface-active: rgba(51, 65, 85, 0.9); + --bg-card: rgba(15, 23, 42, 0.65); + --bg-card-hover: rgba(30, 41, 59, 0.75); + --bg-input: rgba(11, 18, 32, 0.85); + --bg-input-focus: rgba(15, 23, 42, 0.95); + --bg-code: #070b12; + --bg-modal-overlay: rgba(0, 0, 0, 0.75); + + /* Borders & Dividers */ + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.1); + --border-active: rgba(56, 189, 248, 0.4); + --border-focus: #38bdf8; + + /* Typography Colors */ + --text-primary: #f8fafc; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --text-dim: #64748b; + --text-inverted: #0f172a; + + /* Brand & Accent Colors */ + --accent-cyan: #38bdf8; + --accent-cyan-glow: rgba(56, 189, 248, 0.35); + --accent-indigo: #818cf8; + --accent-indigo-glow: rgba(129, 140, 248, 0.35); + --accent-purple: #a855f7; + --accent-emerald: #10b981; + --accent-rose: #f43f5e; + --accent-amber: #f59e0b; + --accent-teal: #14b8a6; + --accent-pink: #ec4899; + + /* Status Colors */ + --status-success: #10b981; + --status-success-bg: rgba(16, 185, 129, 0.15); + --status-warning: #f59e0b; + --status-warning-bg: rgba(245, 158, 11, 0.15); + --status-error: #ef4444; + --status-error-bg: rgba(239, 68, 68, 0.15); + --status-info: #38bdf8; + --status-info-bg: rgba(56, 189, 248, 0.15); + + /* Shadows & Elevation */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 12px -2px rgba(0, 0, 0, 0.4), 0 2px 6px -1px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 12px 28px -4px rgba(0, 0, 0, 0.5), 0 4px 12px -2px rgba(0, 0, 0, 0.3); + --shadow-glow: 0 0 20px -2px var(--accent-cyan-glow); + --shadow-glow-indigo: 0 0 20px -2px var(--accent-indigo-glow); + + /* Glassmorphism & Blurs */ + --backdrop-blur: blur(16px); + --backdrop-blur-sm: blur(8px); + + /* Transitions & Animation Timings */ + --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Border Radii */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Layout Constants */ + --header-height: 68px; + --sidebar-width: 240px; + --sidebar-collapsed-width: 72px; + --inspector-width: 460px; +} + +/* Light Theme Variables */ +[data-theme="light"] { + --bg-base: #f8fafc; + --bg-gradient: radial-gradient(circle at 15% 15%, rgba(14, 165, 233, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(99, 102, 241, 0.08) 0%, transparent 40%), + #f8fafc; + --bg-surface: rgba(255, 255, 255, 0.85); + --bg-surface-hover: rgba(241, 245, 249, 0.95); + --bg-surface-active: #e2e8f0; + --bg-card: rgba(255, 255, 255, 0.75); + --bg-card-hover: rgba(255, 255, 255, 0.95); + --bg-input: #ffffff; + --bg-input-focus: #ffffff; + --bg-code: #0f172a; + --bg-modal-overlay: rgba(15, 23, 42, 0.6); + + --border-subtle: rgba(0, 0, 0, 0.05); + --border-default: rgba(0, 0, 0, 0.1); + --border-active: rgba(14, 165, 233, 0.5); + --border-focus: #0284c7; + + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; + --text-dim: #94a3b8; + --text-inverted: #f8fafc; + + --accent-cyan: #0284c7; + --accent-cyan-glow: rgba(2, 132, 199, 0.25); + --accent-indigo: #4f46e5; + --accent-indigo-glow: rgba(79, 70, 229, 0.25); + + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 14px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 12px 28px -4px rgba(0, 0, 0, 0.12); +} + +/* ========================================================================== + 2. Reset, Base & Typography + ========================================================================== */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + width: 100%; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 13.5px; + line-height: 1.55; + color: var(--text-primary); + background: var(--bg-gradient); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +/* Custom Sleek Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.25); + border-radius: var(--radius-full); + transition: background var(--transition-fast); +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(56, 189, 248, 0.5); +} + +h1, h2, h3, h4, h5, h6 { + color: var(--text-primary); + font-weight: 600; + letter-spacing: -0.02em; +} + +p { + color: var(--text-secondary); +} + +a { + color: var(--accent-cyan); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: #7dd3fc; +} + +code, pre { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace; +} + +/* ========================================================================== + 3. Application Shell & Top Header + ========================================================================== */ + +#app-container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.top-header { + position: sticky; + top: 0; + z-index: 100; + height: var(--header-height); + padding: 0 24px; + background: var(--bg-surface); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: var(--shadow-sm); + transition: background var(--transition-normal), border-color var(--transition-normal); +} + +.header-brand { + display: flex; + align-items: center; + gap: 12px; + min-width: max-content; +} + +.brand-icon { + width: 38px; + height: 38px; + background: linear-gradient(135deg, #0284c7, #6366f1); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 20px; + box-shadow: 0 0 15px rgba(56, 189, 248, 0.4); + position: relative; + overflow: hidden; +} + +.brand-icon::after { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.3) 50%, transparent 60%); + animation: shine 4s infinite; +} + +@keyframes shine { + 0% { transform: translateX(-100%) translateY(-100%); } + 20% { transform: translateX(100%) translateY(100%); } + 100% { transform: translateX(100%) translateY(100%); } +} + +.brand-titles { + display: flex; + flex-direction: column; +} + +.brand-title { + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + background: linear-gradient(to right, #f8fafc, #94a3b8); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +[data-theme="light"] .brand-title { + background: linear-gradient(to right, #0f172a, #475569); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.brand-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: var(--radius-full); + background: rgba(56, 189, 248, 0.15); + color: var(--accent-cyan); + border: 1px solid rgba(56, 189, 248, 0.3); + text-transform: uppercase; + -webkit-text-fill-color: var(--accent-cyan); +} + +.brand-subtitle { + font-size: 11px; + color: var(--text-muted); +} + +/* Header Status & Controls */ +.header-actions { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.status-indicator-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-full); + font-size: 12px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--status-success); + box-shadow: 0 0 8px var(--status-success); + position: relative; +} + +.status-dot.pulsing::after { + content: ''; + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + border-radius: 50%; + border: 1px solid var(--status-success); + animation: pulseDot 2s infinite; +} + +.status-dot.offline { + background: var(--status-error); + box-shadow: 0 0 8px var(--status-error); +} + +.status-dot.offline.pulsing::after { + border-color: var(--status-error); +} + +@keyframes pulseDot { + 0% { transform: scale(1); opacity: 0.8; } + 70% { transform: scale(2.2); opacity: 0; } + 100% { transform: scale(2.2); opacity: 0; } +} + +.api-key-input-wrapper { + position: relative; + display: flex; + align-items: center; + min-width: 240px; +} + +.api-key-input-wrapper input { + width: 100%; + padding: 7px 32px 7px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-default); + background: var(--bg-input); + color: var(--text-primary); + font-size: 12.5px; + font-family: inherit; + transition: all var(--transition-fast); +} + +.api-key-input-wrapper input:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: var(--shadow-glow); + background: var(--bg-input-focus); +} + +.api-key-icon-btn { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: color var(--transition-fast); +} + +.api-key-icon-btn:hover { + color: var(--accent-cyan); +} + +/* ========================================================================== + 4. App Main Layout (Sidebar + Workspace + Inspector) + ========================================================================== */ + +.main-layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width); + gap: 0; + min-height: calc(100vh - var(--header-height)); + position: relative; +} + +/* Left Sidebar Navigation */ +.app-sidebar { + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + padding: 20px 12px; + display: flex; + flex-direction: column; + gap: 20px; + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow-y: auto; +} + +.nav-section-title { + font-size: 11px; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0 12px; + margin-bottom: 6px; +} + +.nav-group { + display: flex; + flex-direction: column; + gap: 3px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + background: transparent; + width: 100%; + text-align: left; + transition: all var(--transition-fast); + user-select: none; +} + +.nav-item:hover { + color: var(--text-primary); + background: var(--bg-surface-hover); + border-color: var(--border-subtle); +} + +.nav-item.active { + color: var(--accent-cyan); + background: rgba(56, 189, 248, 0.12); + border-color: rgba(56, 189, 248, 0.25); + font-weight: 600; + box-shadow: inset 0 0 12px rgba(56, 189, 248, 0.1); +} + +.nav-icon { + font-size: 16px; + width: 20px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; +} + +.nav-item.active .nav-icon { + opacity: 1; + transform: scale(1.1); + filter: drop-shadow(0 0 6px var(--accent-cyan-glow)); +} + +.nav-badge { + margin-left: auto; + font-size: 10.5px; + padding: 1px 7px; + border-radius: var(--radius-full); + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); +} + +.nav-item.active .nav-badge { + background: rgba(56, 189, 248, 0.2); + color: var(--accent-cyan); +} + +/* Central Workspace Area */ +.app-content { + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + max-width: 100%; +} + +.tab-pane { + display: none; + flex-direction: column; + gap: 18px; + animation: fadeIn 0.25s ease-out; +} + +.tab-pane.active { + display: flex; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ========================================================================== + 5. UI Cards, Panels & Glass Containers + ========================================================================== */ + +.glass-panel { + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 18px 20px; + box-shadow: var(--shadow-sm); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.glass-panel:hover { + border-color: var(--border-active); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + gap: 12px; +} + +.panel-title { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.panel-title .title-icon { + font-size: 16px; + color: var(--accent-cyan); +} + +.panel-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* Overview Stat Cards Grid */ +.stat-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + position: relative; + overflow: hidden; + transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.stat-card:hover { + transform: translateY(-3px); + border-color: var(--border-active); + box-shadow: var(--shadow-md); +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-indigo)); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.stat-card:hover::before { + opacity: 1; +} + +.stat-card-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.stat-card-title { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.stat-card-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + background: rgba(255, 255, 255, 0.05); +} + +.stat-card.steps .stat-card-icon { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); } +.stat-card.hr .stat-card-icon { background: rgba(244, 63, 94, 0.15); color: var(--accent-rose); } +.stat-card.sleep .stat-card-icon { background: rgba(139, 92, 246, 0.15); color: var(--accent-purple); } +.stat-card.calories .stat-card-icon { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); } +.stat-card.spo2 .stat-card-icon { background: rgba(20, 184, 166, 0.15); color: var(--accent-teal); } +.stat-card.stress .stat-card-icon { background: rgba(236, 72, 153, 0.15); color: var(--accent-pink); } + +.stat-card-value { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); + display: flex; + align-items: baseline; + gap: 4px; +} + +.stat-card-unit { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.stat-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11.5px; + color: var(--text-dim); +} + +.stat-card-badge { + padding: 1px 6px; + border-radius: var(--radius-xs); + font-weight: 600; + font-size: 10.5px; +} + +.stat-card-badge.up { background: var(--status-success-bg); color: var(--status-success); } +.stat-card-badge.down { background: var(--status-error-bg); color: var(--status-error); } +.stat-card-badge.neutral { background: rgba(255, 255, 255, 0.08); color: var(--text-muted); } + +/* ========================================================================== + 6. Form Controls, Datepickers, Inputs & Buttons + ========================================================================== */ + +.form-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 140px; +} + +.form-group.sm { + max-width: 140px; +} + +.form-label { + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; +} + +.input-control, .select-control { + width: 100%; + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-default); + background: var(--bg-input); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + transition: all var(--transition-fast); +} + +.input-control:hover, .select-control:hover { + border-color: var(--border-active); +} + +.input-control:focus, .select-control:focus { + outline: none; + border-color: var(--border-focus); + background: var(--bg-input-focus); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); +} + +/* Date Preset Quick Filter Pills */ +.date-preset-pills { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; +} + +.preset-pill { + padding: 4px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--border-subtle); + background: var(--bg-card); + color: var(--text-muted); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.preset-pill:hover { + border-color: var(--border-active); + color: var(--text-primary); + background: var(--bg-card-hover); +} + +.preset-pill.active { + background: var(--accent-cyan-glow); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + font-weight: 600; +} + +/* Custom Checkbox & Toggle Switch */ +.custom-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + margin-top: 6px; +} + +.custom-checkbox input { + display: none; +} + +.checkbox-box { + width: 17px; + height: 17px; + border-radius: var(--radius-xs); + border: 1.5px solid var(--border-default); + background: var(--bg-input); + display: flex; + align-items: center; + justify-content: center; + color: transparent; + font-size: 12px; + transition: all var(--transition-fast); +} + +.custom-checkbox input:checked + .checkbox-box { + background: var(--accent-cyan); + border-color: var(--accent-cyan); + color: var(--text-inverted); + box-shadow: 0 0 8px var(--accent-cyan-glow); +} + +/* Switch Toggle */ +.switch-control { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.switch-control input { + display: none; +} + +.switch-track { + width: 36px; + height: 20px; + background: var(--bg-surface-active); + border-radius: var(--radius-full); + position: relative; + transition: background var(--transition-fast); + border: 1px solid var(--border-default); +} + +.switch-thumb { + width: 14px; + height: 14px; + background: #ffffff; + border-radius: 50%; + position: absolute; + top: 2px; + left: 2px; + transition: transform var(--transition-bounce); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.switch-control input:checked + .switch-track { + background: var(--accent-cyan); + border-color: var(--accent-cyan); +} + +.switch-control input:checked + .switch-track .switch-thumb { + transform: translateX(16px); +} + +/* Buttons System */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + border: 1px solid transparent; + transition: all var(--transition-fast); + white-space: nowrap; + user-select: none; +} + +.btn:active { + transform: scale(0.97); +} + +.btn-primary { + background: linear-gradient(135deg, #0284c7, #2563eb); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.15); + box-shadow: 0 2px 10px rgba(2, 132, 199, 0.3); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #0369a1, #1d4ed8); + box-shadow: 0 4px 16px rgba(2, 132, 199, 0.45); +} + +.btn-secondary { + background: var(--bg-card); + border-color: var(--border-default); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--bg-card-hover); + border-color: var(--border-active); + color: var(--accent-cyan); +} + +.btn-outline { + background: transparent; + border-color: var(--border-default); + color: var(--text-secondary); +} + +.btn-outline:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + background: rgba(56, 189, 248, 0.08); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: var(--status-error); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.25); + border-color: var(--status-error); +} + +.btn-success { + background: rgba(16, 185, 129, 0.15); + border-color: rgba(16, 185, 129, 0.3); + color: var(--status-success); +} + +.btn-success:hover { + background: rgba(16, 185, 129, 0.25); + border-color: var(--status-success); +} + +.btn-sm { + padding: 5px 10px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: 10px 20px; + font-size: 14px; + border-radius: var(--radius-lg); +} + +.btn-icon-only { + padding: 8px; + border-radius: var(--radius-md); + background: var(--bg-card); + border: 1px solid var(--border-default); + color: var(--text-secondary); +} + +.btn-icon-only:hover { + border-color: var(--border-active); + color: var(--accent-cyan); +} + +/* Button Group */ +.btn-group { + display: inline-flex; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-default); +} + +.btn-group .btn { + border-radius: 0; + border: none; + border-right: 1px solid var(--border-default); +} + +.btn-group .btn:last-child { + border-right: none; +} + +/* ========================================================================== + 7. Data Visualization & Component Badges + ========================================================================== */ + +/* Sleep Stage Visual Timeline */ +.sleep-timeline-container { + display: flex; + flex-direction: column; + gap: 8px; + margin: 14px 0; +} + +.sleep-timeline-bar { + display: flex; + height: 28px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-code); + border: 1px solid var(--border-default); +} + +.sleep-segment { + height: 100%; + position: relative; + transition: opacity var(--transition-fast); + cursor: pointer; +} + +.sleep-segment:hover { + opacity: 0.8; + filter: brightness(1.2); +} + +.sleep-segment.deep { background: #3b82f6; } +.sleep-segment.light { background: #60a5fa; } +.sleep-segment.rem { background: #8b5cf6; } +.sleep-segment.awake { background: #f59e0b; } + +.sleep-legend { + display: flex; + align-items: center; + gap: 16px; + font-size: 11.5px; + color: var(--text-muted); + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.legend-dot.deep { background: #3b82f6; } +.legend-dot.light { background: #60a5fa; } +.legend-dot.rem { background: #8b5cf6; } +.legend-dot.awake { background: #f59e0b; } + +/* Metric Series Custom Chart Canvas Container */ +.chart-container { + width: 100%; + height: 240px; + position: relative; + background: var(--bg-code); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 16px; + display: flex; + align-items: flex-end; + gap: 8px; + overflow-x: auto; +} + +.chart-bar-group { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + height: 100%; + justify-content: flex-end; + min-width: 28px; + position: relative; +} + +.chart-bar { + width: 18px; + background: linear-gradient(180deg, var(--accent-cyan), #2563eb); + border-radius: var(--radius-xs) var(--radius-xs) 0 0; + transition: height 0.4s ease-out, background var(--transition-fast); + cursor: pointer; + position: relative; +} + +.chart-bar:hover { + background: linear-gradient(180deg, #7dd3fc, #3b82f6); + box-shadow: 0 0 10px var(--accent-cyan-glow); +} + +.chart-bar-label { + font-size: 10px; + color: var(--text-dim); + margin-top: 6px; + white-space: nowrap; +} + +.chart-bar-tooltip { + position: absolute; + bottom: calc(100% + 6px); + background: var(--bg-surface-active); + color: var(--text-primary); + font-size: 11px; + padding: 3px 8px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-default); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: all var(--transition-fast); + z-index: 10; +} + +.chart-bar:hover .chart-bar-tooltip { + opacity: 1; + transform: translateY(0); +} + +/* Multi-select sync type tags */ +.tag-pills-selector { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0; +} + +.tag-pill { + padding: 6px 12px; + border-radius: var(--radius-full); + border: 1px solid var(--border-default); + background: var(--bg-card); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all var(--transition-fast); +} + +.tag-pill:hover { + border-color: var(--border-active); + color: var(--text-primary); +} + +.tag-pill.selected { + background: rgba(56, 189, 248, 0.15); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + font-weight: 600; +} + +.tag-pill .tag-check { + font-size: 11px; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.tag-pill.selected .tag-check { + opacity: 1; +} + +/* Data Table Grid */ +.data-table-container { + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-card); + margin-top: 10px; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + text-align: left; +} + +.data-table th { + background: rgba(255, 255, 255, 0.03); + padding: 10px 14px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--border-default); + white-space: nowrap; +} + +.data-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:hover td { + background: rgba(255, 255, 255, 0.02); + color: var(--text-primary); +} + +/* Pill Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.badge-success { background: var(--status-success-bg); color: var(--status-success); border: 1px solid rgba(16, 185, 129, 0.3); } +.badge-warning { background: var(--status-warning-bg); color: var(--status-warning); border: 1px solid rgba(245, 158, 11, 0.3); } +.badge-danger { background: var(--status-error-bg); color: var(--status-error); border: 1px solid rgba(239, 68, 68, 0.3); } +.badge-info { background: var(--status-info-bg); color: var(--status-info); border: 1px solid rgba(56, 189, 248, 0.3); } +.badge-purple { background: rgba(168, 85, 247, 0.15); color: var(--accent-purple); border: 1px solid rgba(168, 85, 247, 0.3); } + +/* ========================================================================== + 8. Right-Side Live Developer Console & Response Inspector + ========================================================================== */ + +.app-inspector { + background: var(--bg-surface); + border-left: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow: hidden; +} + +.inspector-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.inspector-title { + font-size: 13.5px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-primary); +} + +.inspector-meta-bar { + padding: 10px 18px; + background: var(--bg-card); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); + gap: 8px; +} + +.meta-badges-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.http-method-pill { + padding: 2px 7px; + border-radius: var(--radius-xs); + font-weight: 700; + font-size: 10.5px; + text-transform: uppercase; +} + +.http-method-pill.get { background: #0369a1; color: #e0f2fe; } +.http-method-pill.post { background: #047857; color: #d1fae5; } +.http-method-pill.delete { background: #b91c1c; color: #fee2e2; } +.http-method-pill.put { background: #b45309; color: #fef3c7; } + +.status-pill { + padding: 2px 7px; + border-radius: var(--radius-xs); + font-weight: 700; + font-size: 10.5px; +} + +.status-pill.ok { background: #064e3b; color: #34d399; } +.status-pill.err { background: #7f1d1d; color: #f87171; } +.status-pill.pending { background: #1e1b4b; color: #a5b4fc; } + +.latency-pill { + padding: 2px 7px; + border-radius: var(--radius-xs); + background: rgba(255, 255, 255, 0.06); + color: var(--text-secondary); + font-size: 11px; + font-family: monospace; +} + +.inspector-actions-bar { + padding: 8px 18px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 8px; +} + +.inspector-search-input { + flex: 1; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-default); + background: var(--bg-input); + color: var(--text-primary); + font-size: 11.5px; +} + +.inspector-body { + flex: 1; + padding: 14px 18px; + background: var(--bg-code); + overflow-y: auto; + font-family: 'JetBrains Mono', Consolas, monospace; + font-size: 12px; + line-height: 1.6; + color: #e2e8f0; + white-space: pre-wrap; + word-break: break-all; + position: relative; +} + +/* JSON Viewer Syntax Highlighting */ +.json-key { color: #38bdf8; font-weight: 600; } +.json-string { color: #4ade80; } +.json-number { color: #fbbf24; } +.json-boolean { color: #f472b6; font-weight: 600; } +.json-null { color: #94a3b8; font-style: italic; } + +.inspector-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-dim); + gap: 12px; + text-align: center; +} + +.inspector-empty-icon { + font-size: 42px; + opacity: 0.4; +} + +/* ========================================================================== + 9. Modal Dialogs & Popup Overlays + ========================================================================== */ + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-modal-overlay); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + z-index: 1000; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.2s ease-out; +} + +.modal-backdrop.active { + display: flex; +} + +.modal-card { + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + width: 100%; + max-width: 480px; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes modalPop { + 0% { transform: scale(0.9) translateY(20px); opacity: 0; } + 100% { transform: scale(1) translateY(0); opacity: 1; } +} + +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.modal-close-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.modal-close-btn:hover { + color: var(--text-primary); + background: var(--bg-surface-hover); +} + +.modal-body { + padding: 22px; + display: flex; + flex-direction: column; + gap: 16px; + max-height: 70vh; + overflow-y: auto; +} + +.modal-footer { + padding: 16px 22px; + border-top: 1px solid var(--border-subtle); + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +/* QR Code Display in Modal */ +.qr-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 16px 0; +} + +.qr-image-frame { + width: 220px; + height: 220px; + background: #ffffff; + border-radius: var(--radius-lg); + padding: 12px; + box-shadow: 0 0 25px rgba(56, 189, 248, 0.2); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.qr-image-frame img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: var(--radius-sm); +} + +.qr-scan-beam { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, #0284c7, transparent); + box-shadow: 0 0 10px #38bdf8; + animation: scanBeam 2.5s infinite ease-in-out; +} + +@keyframes scanBeam { + 0% { top: 5%; opacity: 0; } + 20% { opacity: 1; } + 80% { opacity: 1; } + 100% { top: 95%; opacity: 0; } +} + +.qr-timer-bar { + width: 100%; + height: 4px; + background: var(--bg-surface-active); + border-radius: var(--radius-full); + overflow: hidden; +} + +.qr-timer-progress { + height: 100%; + background: var(--accent-cyan); + width: 100%; + transition: width 1s linear; +} + +/* ========================================================================== + 10. Toast Notification System + ========================================================================== */ + +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.toast-message { + pointer-events: auto; + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 12px 18px; + box-shadow: var(--shadow-lg); + backdrop-filter: var(--backdrop-blur); + -webkit-backdrop-filter: var(--backdrop-blur); + display: flex; + align-items: center; + gap: 12px; + min-width: 280px; + max-width: 420px; + animation: toastSlideIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + transition: all var(--transition-normal); +} + +@keyframes toastSlideIn { + 0% { transform: translateX(120%) scale(0.9); opacity: 0; } + 100% { transform: translateX(0) scale(1); opacity: 1; } +} + +.toast-message.toast-leaving { + transform: translateX(120%) scale(0.9); + opacity: 0; +} + +.toast-icon { + font-size: 18px; + flex-shrink: 0; +} + +.toast-message.success { border-color: rgba(16, 185, 129, 0.4); } +.toast-message.success .toast-icon { color: var(--status-success); } +.toast-message.error { border-color: rgba(239, 68, 68, 0.4); } +.toast-message.error .toast-icon { color: var(--status-error); } +.toast-message.warning { border-color: rgba(245, 158, 11, 0.4); } +.toast-message.warning .toast-icon { color: var(--status-warning); } +.toast-message.info { border-color: rgba(56, 189, 248, 0.4); } +.toast-message.info .toast-icon { color: var(--status-info); } + +.toast-content { + flex: 1; +} + +.toast-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.toast-desc { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 1px; +} + +/* ========================================================================== + 11. Micro-animations, Loaders & Skeleton States + ========================================================================== */ + +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.skeleton { + background: linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%); + background-size: 200% 100%; + animation: shimmer 1.8s infinite; + border-radius: var(--radius-sm); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Pulse animation for live updates */ +.live-pulse { + animation: pulseGlow 2s infinite; +} + +@keyframes pulseGlow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Tooltip Styling */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: var(--bg-surface-active); + color: var(--text-primary); + font-size: 11px; + font-weight: 500; + padding: 4px 8px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-default); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: all var(--transition-fast); + z-index: 100; + box-shadow: var(--shadow-md); +} + +[data-tooltip]:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* ========================================================================== + 12. Responsive Breakpoints & Mobile Adaptations + ========================================================================== */ + +@media (max-width: 1400px) { + :root { + --inspector-width: 380px; + } +} + +@media (max-width: 1200px) { + .main-layout { + grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--inspector-width); + } + + .nav-item span:not(.nav-icon) { + display: none; + } + + .nav-section-title { + display: none; + } + + .nav-item { + justify-content: center; + padding: 10px; + } +} + +@media (max-width: 992px) { + .main-layout { + grid-template-columns: 1fr; + } + + .app-sidebar { + display: none; /* Can be toggled with mobile drawer */ + } + + .app-inspector { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 320px; + z-index: 90; + border-left: none; + border-top: 1px solid var(--border-default); + } + + .app-content { + padding-bottom: 340px; + } +} + +@media (max-width: 640px) { + .top-header { + padding: 0 14px; + } + + .brand-subtitle, .api-key-input-wrapper { + display: none; + } + + .stat-cards-grid { + grid-template-columns: 1fr 1fr; + } + + .form-row { + flex-direction: column; + align-items: stretch; + } + + .form-group { + width: 100%; + max-width: none !important; + } +} diff --git a/testpage/static/js/app.js b/testpage/static/js/app.js new file mode 100644 index 0000000..7819be5 --- /dev/null +++ b/testpage/static/js/app.js @@ -0,0 +1,818 @@ +/** + * Mi Fitness Dashboard & Developer Console + * Core Client-side Application Logic + */ + +// State Store +const state = { + activeTab: 'overview', + theme: localStorage.getItem('mi_fitness_theme') || 'dark', + apiKey: localStorage.getItem('mi_fitness_api_key') || '', + qrToken: '', + qrPollInterval: null, + qrTimerInterval: null, + qrExpiresIn: 300, + syncTypes: ['daily_activity', 'heart_rate', 'sleep', 'workouts', 'body_measurements', 'spo2', 'stress', 'abnormal_heart_beat'], + lastResponse: null, + lastRequestInfo: null, +}; + +// DOM Utilities +const $ = (id) => document.getElementById(id); +const $$ = (selector) => document.querySelectorAll(selector); + +// Date Helpers +const formatDate = (d) => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +function initDates() { + const today = new Date(); + const yesterday = new Date(Date.now() - 86400000); + + $('startDate').value = formatDate(yesterday); + $('endDate').value = formatDate(today); +} + +function setDatePreset(type) { + const today = new Date(); + let start = new Date(); + + $$('.preset-pill').forEach(p => p.classList.remove('active')); + + if (type === 'today') { + start = today; + } else if (type === 'yesterday') { + start = new Date(Date.now() - 86400000); + today.setDate(today.getDate() - 1); + } else if (type === '7d') { + start = new Date(Date.now() - 7 * 86400000); + } else if (type === '30d') { + start = new Date(Date.now() - 30 * 86400000); + } else if (type === 'month') { + start = new Date(today.getFullYear(), today.getMonth(), 1); + } + + $('startDate').value = formatDate(start); + $('endDate').value = formatDate(type === 'yesterday' ? start : new Date()); + + const el = $(`preset-${type}`); + if (el) el.classList.add('active'); +} + +const getSd = () => $('startDate').value; +const getEd = () => $('endDate').value; +const getRangeQuery = (path) => `${path}?start_date=${getSd()}&end_date=${getEd()}`; +const getSelQuery = (id, param) => { + const v = $(id).value; + return v ? `&${param}=${encodeURIComponent(v)}` : ''; +}; +const getLimQuery = (id) => { + const v = $(id).value.trim(); + return v ? `&limit=${encodeURIComponent(v)}` : ''; +}; + +// UI Theme Management +function initTheme() { + document.documentElement.setAttribute('data-theme', state.theme); + updateThemeIcon(); +} + +function toggleTheme() { + state.theme = state.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('mi_fitness_theme', state.theme); + document.documentElement.setAttribute('data-theme', state.theme); + updateThemeIcon(); + showToast('主题已切换', `已切换至 ${state.theme === 'dark' ? '深色模式 🌙' : '浅色模式 ☀️'}`, 'info'); +} + +function updateThemeIcon() { + const btn = $('themeToggleBtn'); + if (btn) { + btn.innerHTML = state.theme === 'dark' ? '🌙' : '☀️'; + } +} + +// Toast Notifications +function showToast(title, desc = '', type = 'info', duration = 3500) { + const container = $('toastContainer'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast-message ${type}`; + + const icons = { + success: '✅', + error: '❌', + warning: '⚠️', + info: '💡', + }; + + toast.innerHTML = ` +
${icons[type] || 'ℹ️'}
+
+
${title}
+ ${desc ? `
${desc}
` : ''} +
+ `; + + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('toast-leaving'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +// Tab Switching +function switchTab(tabId) { + state.activeTab = tabId; + + $$('.nav-item').forEach(item => { + item.classList.toggle('active', item.getAttribute('data-tab') === tabId); + }); + + $$('.tab-pane').forEach(pane => { + pane.classList.toggle('active', pane.id === `tab-${tabId}`); + }); +} + +// API Communication & Proxy Handler +function getHeaders() { + const headers = { 'Content-Type': 'application/json' }; + const key = state.apiKey || $('apiKeyInput').value.trim(); + if (key) { + headers['X-API-Key'] = key; + } + return headers; +} + +async function api(method, path, body = null) { + const startTime = performance.now(); + const url = '/proxy' + path; + + updateInspectorHeader(method, path, 'pending', '请求中…'); + $('inspectorBody').innerHTML = `
正在通过 Flask 代理请求后端...
`; + + try { + const options = { + method, + headers: getHeaders(), + }; + if (body) { + options.body = JSON.stringify(body); + } + + const resp = await fetch(url, options); + const latency = Math.round(performance.now() - startTime); + const text = await resp.text(); + + let jsonObj = null; + try { + jsonObj = JSON.parse(text); + } catch (e) { + // plain text + } + + state.lastResponse = jsonObj || text; + state.lastRequestInfo = { + method, + path, + url, + status: resp.status, + latency, + headers: options.headers, + body, + }; + + updateInspectorHeader(method, path, resp.ok ? 'ok' : 'err', `HTTP ${resp.status}`, latency); + renderJsonViewer(state.lastResponse); + + if (!resp.ok) { + showToast(`请求失败: HTTP ${resp.status}`, jsonObj?.error || jsonObj?.detail || '请查看控制台输出', 'error'); + } + + return jsonObj; + } catch (err) { + const latency = Math.round(performance.now() - startTime); + updateInspectorHeader(method, path, 'err', '网络错误', latency); + $('inspectorBody').innerHTML = `
❌ 代理网络连接错误: ${err.message}

💡 请检查:
1. Python FastAPI 是否在 127.0.0.1:8321 运行
2. Flask 代理是否正常监听
`; + showToast('代理连接失败', '无法连接到后端服务,请确认端口与服务状态', 'error'); + return null; + } +} + +// Inspector View Rendering +function updateInspectorHeader(method, path, statusType, statusText, latency = 0) { + const methodClass = method.toLowerCase(); + $('metaMethod').className = `http-method-pill ${methodClass}`; + $('metaMethod').textContent = method; + + $('metaStatus').className = `status-pill ${statusType}`; + $('metaStatus').textContent = statusText; + + $('metaLatency').textContent = latency > 0 ? `${latency} ms` : '...'; + $('metaUrl').textContent = path; +} + +function renderJsonViewer(data) { + if (!data) { + $('inspectorBody').innerHTML = `
📂
暂无返回数据
`; + return; + } + + const jsonStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + $('inspectorBody').innerHTML = highlightJson(jsonStr); +} + +function highlightJson(json) { + if (typeof json !== 'string') { + json = JSON.stringify(json, null, 2); + } + json = json.replace(/&/g, '&').replace(//g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { + let cls = 'json-number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'json-key'; + } else { + cls = 'json-string'; + } + } else if (/true|false/.test(match)) { + cls = 'json-boolean'; + } else if (/null/.test(match)) { + cls = 'json-null'; + } + return `${match}`; + }); +} + +function filterInspectorJson(query) { + if (!state.lastResponse) return; + const q = query.trim().toLowerCase(); + if (!q) { + renderJsonViewer(state.lastResponse); + return; + } + const str = JSON.stringify(state.lastResponse, null, 2); + const lines = str.split('\n'); + const filtered = lines.filter(line => line.toLowerCase().includes(q)).join('\n'); + $('inspectorBody').innerHTML = `
🔍 匹配到 ${lines.filter(l => l.toLowerCase().includes(q)).length} 行:
` + highlightJson(filtered || '未找到匹配项'); +} + +// Copy & Export Tools +function copyResponse() { + if (!state.lastResponse) { + showToast('无可复制数据', '', 'warning'); + return; + } + const text = typeof state.lastResponse === 'string' ? state.lastResponse : JSON.stringify(state.lastResponse, null, 2); + navigator.clipboard.writeText(text).then(() => { + showToast('复制成功', '响应 JSON 已存入剪贴板', 'success'); + }); +} + +function copyCurl() { + if (!state.lastRequestInfo) { + showToast('无可用请求', '请先发起一次 API 请求', 'warning'); + return; + } + const { method, path, headers, body } = state.lastRequestInfo; + let curl = `curl -X ${method} "http://127.0.0.1:8321${path}"`; + for (const [k, v] of Object.entries(headers)) { + curl += ` \\\n -H "${k}: ${v}"`; + } + if (body) { + curl += ` \\\n -d '${JSON.stringify(body)}'`; + } + navigator.clipboard.writeText(curl).then(() => { + showToast('cURL 命令已复制', '可在终端直接执行该请求', 'success'); + }); +} + +function downloadJson() { + if (!state.lastResponse) { + showToast('无可下载数据', '', 'warning'); + return; + } + const text = typeof state.lastResponse === 'string' ? state.lastResponse : JSON.stringify(state.lastResponse, null, 2); + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `mi-fitness-response-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast('下载完成', 'JSON 文件已保存', 'success'); +} + +// Overview Dashboard Loader +async function loadOverview() { + showToast('正在加载仪表盘概览…', '', 'info', 1500); + const summaryPromise = api('GET', getRangeQuery('/api/summary')); + const hrPromise = api('GET', getRangeQuery('/api/heart-rate') + '&sample_type=resting&limit=1'); + const sleepPromise = api('GET', getRangeQuery('/api/sleep') + '&include_naps=true'); + const spo2Promise = api('GET', getRangeQuery('/api/spo2') + '&limit=1'); + const stressPromise = api('GET', getRangeQuery('/api/stress') + '&limit=1'); + + const [summary, hr, sleep, spo2, stress] = await Promise.all([ + summaryPromise, hrPromise, sleepPromise, spo2Promise, stressPromise + ]); + + if (summary && Array.isArray(summary) && summary.length > 0) { + const latest = summary[summary.length - 1]; + $('statSteps').textContent = latest.steps ? latest.steps.toLocaleString() : '0'; + $('statCalories').textContent = latest.active_calories ? Math.round(latest.active_calories) : '0'; + $('statDistance').textContent = latest.distance ? (latest.distance / 1000).toFixed(2) : '0'; + } + + if (hr && Array.isArray(hr) && hr.length > 0) { + $('statHr').textContent = hr[0].bpm || '--'; + } + + if (sleep && Array.isArray(sleep) && sleep.length > 0) { + const lastSleep = sleep[sleep.length - 1]; + const totalMinutes = lastSleep.duration_minutes || (lastSleep.end_time - lastSleep.start_time) / 60; + const hours = (totalMinutes / 60).toFixed(1); + $('statSleep').textContent = isNaN(hours) ? '--' : hours; + } + + if (spo2 && Array.isArray(spo2) && spo2.length > 0) { + $('statSpo2').textContent = spo2[0].spo2_value ? `${spo2[0].spo2_value}%` : '--'; + } + + if (stress && Array.isArray(stress) && stress.length > 0) { + $('statStress').textContent = stress[0].stress_value || '--'; + } + + showToast('仪表盘已刷新', '最新数据已加载', 'success'); +} + +// Health Data Queries +async function querySummary() { + const data = await api('GET', getRangeQuery('/api/summary')); + renderSummaryVisual(data); +} + +function renderSummaryVisual(data) { + const container = $('summaryVisualContainer'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = `

所选时间段内无活动数据

`; + return; + } + + let html = ` +
+ + + + + + + + + + + `; + + data.forEach(item => { + const distKm = item.distance ? (item.distance / 1000).toFixed(2) : '0.00'; + html += ` + + + + + + + `; + }); + + html += `
日期步数活动卡路里 (kcal)距离 (km)
${item.date || '--'}👟 ${(item.steps || 0).toLocaleString()} 步🔥 ${Math.round(item.active_calories || 0)} kcal📍 ${distKm} km
`; + container.innerHTML = html; +} + +async function queryHeartRate() { + const q = getRangeQuery('/api/heart-rate') + getSelQuery('hrType', 'sample_type') + getLimQuery('hrLimit'); + await api('GET', q); +} + +async function querySleep() { + const q = getRangeQuery('/api/sleep') + '&include_naps=' + $('napsSelect').value; + const data = await api('GET', q); + renderSleepTimeline(data); +} + +function renderSleepTimeline(data) { + const container = $('sleepTimelineWrapper'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = '

无睡眠分段数据

'; + return; + } + + const latest = data[data.length - 1]; + const deep = latest.deep_sleep_minutes || 0; + const light = latest.light_sleep_minutes || 0; + const rem = latest.rem_sleep_minutes || 0; + const awake = latest.awake_minutes || 0; + const total = deep + light + rem + awake || 1; + + container.innerHTML = ` +
+
+
+
+
+
+
+
+
深睡 ${deep}m (${Math.round((deep/total)*100)}%)
+
浅睡 ${light}m (${Math.round((light/total)*100)}%)
+
REM ${rem}m (${Math.round((rem/total)*100)}%)
+
清醒 ${awake}m
+
+
+ `; +} + +async function queryWorkouts() { + await api('GET', getRangeQuery('/api/workouts')); +} + +async function queryBody() { + const isLatest = $('latestOnlyCheck').checked; + await api('GET', getRangeQuery('/api/body-measurements') + '&latest_only=' + isLatest); +} + +async function querySpo2() { + await api('GET', getRangeQuery('/api/spo2') + getLimQuery('spo2Limit')); +} + +async function queryStress() { + await api('GET', getRangeQuery('/api/stress') + getSelQuery('stressLevel', 'level') + getLimQuery('stressLimit')); +} + +async function queryAbnormalHr() { + await api('GET', getRangeQuery('/api/abnormal-heart-beat')); +} + +// Metric Series Analytics +async function queryMetricSeries() { + const metric = $('metricSelect').value; + const gran = $('granularitySelect').value; + const agg = $('aggregationSelect').value; + const q = `${getRangeQuery('/api/metric-series')}&metric=${metric}&granularity=${gran}&aggregation=${agg}`; + const data = await api('GET', q); + renderMetricChart(data, metric); +} + +function renderMetricChart(data, metricName) { + const container = $('metricChartContainer'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = '

所选范围内无序列数据

'; + return; + } + + const maxVal = Math.max(...data.map(d => d.value || 0), 1); + let barsHtml = ''; + + data.forEach(item => { + const val = item.value || 0; + const heightPercent = Math.max(Math.round((val / maxVal) * 100), 5); + const dateLabel = item.timestamp ? item.timestamp.split('T')[0].slice(5) : (item.date || ''); + + barsHtml += ` +
+
+
${item.date || item.timestamp || ''}: ${val.toLocaleString()}
+
+
${dateLabel}
+
+ `; + }); + + container.innerHTML = barsHtml; +} + +// Sync Center Control +function toggleSyncTag(tag) { + const idx = state.syncTypes.indexOf(tag); + if (idx > -1) { + if (state.syncTypes.length > 1) { + state.syncTypes.splice(idx, 1); + } else { + showToast('至少保留一种同步类型', '', 'warning'); + return; + } + } else { + state.syncTypes.push(tag); + } + renderSyncPills(); +} + +function selectAllSyncTags(all = true) { + if (all) { + state.syncTypes = ['daily_activity', 'heart_rate', 'sleep', 'workouts', 'body_measurements', 'spo2', 'stress', 'abnormal_heart_beat']; + } else { + state.syncTypes = ['daily_activity']; + } + renderSyncPills(); +} + +function renderSyncPills() { + const container = $('syncTagPills'); + if (!container) return; + const allTypes = [ + { id: 'daily_activity', name: '每日活动' }, + { id: 'heart_rate', name: '心率' }, + { id: 'sleep', name: '睡眠' }, + { id: 'workouts', name: '运动' }, + { id: 'body_measurements', name: '体成分' }, + { id: 'spo2', name: '血氧' }, + { id: 'stress', name: '压力' }, + { id: 'abnormal_heart_beat', name: '异常心跳' }, + ]; + + container.innerHTML = allTypes.map(t => { + const isSelected = state.syncTypes.includes(t.id); + return ` +
+ ✓ + ${t.name} +
+ `; + }).join(''); +} + +async function triggerSync(background = false) { + const body = { + start_date: getSd(), + end_date: getEd(), + background, + data_types: state.syncTypes, + }; + + showToast(background ? '已触发后台异步同步' : '正在执行前台同步…', '请耐心等待云端响应', 'info'); + const res = await api('POST', '/api/sync', body); + + if (background && res && res.sync_id) { + $('syncIdInput').value = res.sync_id; + showToast('已获取同步任务 ID', res.sync_id, 'success'); + } else if (!background && res) { + showToast('前台同步完成', '健康数据已更新至本地 SQLite', 'success'); + } +} + +async function pollSyncStatus() { + const syncId = $('syncIdInput').value.trim(); + if (!syncId) { + showToast('请输入同步任务 ID', '', 'warning'); + return; + } + await api('GET', `/api/sync/${encodeURIComponent(syncId)}`); +} + +// QR Code Authentication Modal & Auto Polling +function openQrModal() { + $('qrModal').classList.add('active'); + startQrFlow(); +} + +function closeQrModal() { + $('qrModal').classList.remove('active'); + stopQrFlow(); +} + +async function startQrFlow() { + stopQrFlow(); + $('qrStatusText').textContent = '正在向小米账号服务器生成登录二维码...'; + $('qrScanBeam').style.display = 'none'; + + const res = await api('POST', '/api/auth/qr/start?region=cn'); + if (res && res.qr_token) { + state.qrToken = res.qr_token; + state.qrExpiresIn = res.expires_in || 300; + + $('qrImage').src = `/proxy/api/auth/qr/${res.qr_token}.png`; + $('qrLoginUrl').value = res.login_url || ''; + $('qrScanBeam').style.display = 'block'; + $('qrStatusText').textContent = '请打开小米运动健康/米家/小米账号 App 扫码并在手机上点击确认'; + + startQrTimer(); + startQrPolling(); + } else { + $('qrStatusText').textContent = '二维码生成失败,请确认后端连接正常'; + } +} + +function startQrTimer() { + const total = state.qrExpiresIn; + let remaining = total; + + state.qrTimerInterval = setInterval(() => { + remaining--; + const mins = Math.floor(remaining / 60); + const secs = String(remaining % 60).padStart(2, '0'); + $('qrTimerText').textContent = `有效期剩余: ${mins}:${secs}`; + $('qrProgressBar').style.width = `${(remaining / total) * 100}%`; + + if (remaining <= 0) { + stopQrFlow(); + $('qrStatusText').textContent = '二维码已过期,请点击「刷新二维码」重试'; + } + }, 1000); +} + +function startQrPolling() { + state.qrPollInterval = setInterval(async () => { + if (!state.qrToken) return; + try { + const res = await fetch(`/proxy/api/auth/qr/poll?token=${state.qrToken}`, { + headers: getHeaders(), + }); + const data = await res.json(); + + if (data.status === 'scanned') { + $('qrStatusText').innerHTML = '📱 已扫码! 请在手机上点击「确认登录」'; + } else if (data.status === 'confirmed' && data.api_key) { + stopQrFlow(); + $('qrStatusText').innerHTML = `✅ 登录成功! API Key 已自动保存`; + saveApiKey(data.api_key); + showToast('扫码授权成功', `API Key: ${data.api_key.slice(0, 16)}…`, 'success'); + setTimeout(() => closeQrModal(), 1800); + } + } catch (e) { + // ignore transient poll error + } + }, 2000); +} + +function stopQrFlow() { + if (state.qrPollInterval) clearInterval(state.qrPollInterval); + if (state.qrTimerInterval) clearInterval(state.qrTimerInterval); + state.qrPollInterval = null; + state.qrTimerInterval = null; +} + +function copyLoginUrl() { + const url = $('qrLoginUrl').value; + if (!url) return; + navigator.clipboard.writeText(url).then(() => { + showToast('登录链接已复制', '也可在手机浏览器打开该链接登录确认', 'success'); + }); +} + +// API Key Management +function saveApiKey(key) { + state.apiKey = key.trim(); + localStorage.setItem('mi_fitness_api_key', state.apiKey); + $('apiKeyInput').value = state.apiKey; +} + +function initApiKeyInput() { + const input = $('apiKeyInput'); + input.value = state.apiKey; + input.addEventListener('change', (e) => { + saveApiKey(e.target.value); + showToast('API Key 已更新', '', 'info'); + }); +} + +async function createKeyWithCredentials() { + const userId = $('keyUserId').value.trim(); + const passToken = $('keyPassToken').value.trim(); + const label = $('keyLabel').value.trim() || 'web-console'; + + if (!userId || !passToken) { + showToast('请填写 user_id 和 passToken', '', 'warning'); + return; + } + + const res = await api('POST', '/api/auth/keys', { + user_id: userId, + pass_token: passToken, + region: 'cn', + label, + }); + + if (res && res.api_key) { + saveApiKey(res.api_key); + showToast('API Key 发放成功', '已自动填入全局请求头', 'success'); + listKeys(); + } +} + +async function listKeys() { + const data = await api('GET', '/api/auth/keys'); + renderKeysTable(data); +} + +function renderKeysTable(data) { + const container = $('keysTableWrapper'); + if (!container) return; + if (!data || !Array.isArray(data) || data.length === 0) { + container.innerHTML = '

暂无已发放的 API Key 记录

'; + return; + } + + let html = ` +
+ + + + + + + + + + + + `; + + data.forEach(k => { + html += ` + + + + + + + + `; + }); + + html += '
标签Key 前缀User ID创建时间操作
${k.label || '默认'}${k.prefix || k.api_key_prefix || 'mif_sk_...'}${k.user_id || '--'}${k.created_at || '--'} + +
'; + container.innerHTML = html; +} + +async function revokeKeyByPrefix(prefix) { + if (!prefix) { + prefix = $('revokePrefixInput').value.trim(); + } + if (!prefix) { + showToast('请提供要吊销的 Key 前缀', '', 'warning'); + return; + } + if (!confirm(`确定要吊销前缀为 ${prefix} 的 API Key 吗?`)) return; + + await api('DELETE', `/api/auth/keys/${encodeURIComponent(prefix)}`); + showToast('Key 吊销指令已发送', '', 'info'); + listKeys(); +} + +// System Connection & Health Check +async function checkSystemHealth() { + const statusEl = $('backendStatusText'); + const dotEl = $('backendStatusDot'); + try { + const res = await fetch('/api/proxy_status'); + const data = await res.json(); + if (data.backend_reachable) { + dotEl.className = 'status-dot pulsing'; + statusEl.textContent = `FastAPI: 在线 (${data.backend_latency_ms}ms)`; + } else { + dotEl.className = 'status-dot offline pulsing'; + statusEl.textContent = 'FastAPI: 离线'; + } + } catch (e) { + dotEl.className = 'status-dot offline pulsing'; + statusEl.textContent = '代理服务异常'; + } +} + +// Initialization on DOM Ready +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + initDates(); + initApiKeyInput(); + renderSyncPills(); + checkSystemHealth(); + setInterval(checkSystemHealth, 15000); + + // Bind preset pills + $$('.preset-pill').forEach(pill => { + pill.addEventListener('click', (e) => { + const type = e.target.getAttribute('data-preset'); + if (type) setDatePreset(type); + }); + }); + + // Tab Navigation + $$('.nav-item').forEach(item => { + item.addEventListener('click', () => { + const tab = item.getAttribute('data-tab'); + if (tab) switchTab(tab); + }); + }); + + // Auto load overview stats + loadOverview(); +}); diff --git a/testpage/templates/index.html b/testpage/templates/index.html new file mode 100644 index 0000000..aa7852f --- /dev/null +++ b/testpage/templates/index.html @@ -0,0 +1,650 @@ + + + + + + Mi Fitness MCP | 健康数据中心与调试控制台 + + + + + + +
+ +
+
+
⚡
+
+
+ Mi Fitness Hub + MCP CN +
+
小米运动健康数据服务 · Python Flask 驱动
+
+
+ +
+ +
+
+ 检测连接中... +
+ + +
+ + ~ + +
+ + +
+ + +
+ + + + + +
+
+ + +
+ + + + +
+ +
+ 快捷范围: + + + + + +
+ + +
+
+
+
📊 健康指标总览
+
基于选定时间范围的最新健康数据概览
+
+ +
+ +
+
+
+ 今日总步数 +
👟
+
+
-- 步
+ +
+ +
+
+ 活动消耗 +
🔥
+
+
-- kcal
+ +
+ +
+
+ 静息心率 +
❤️
+
+
-- bpm
+ +
+ +
+
+ 最近睡眠 +
💤
+
+
-- 小时
+ +
+ +
+
+ 血氧饱和度 +
🫁
+
+
--
+ +
+ +
+
+ 压力水平 +
🧠
+
+
--
+ +
+
+ +
+
⚡ 快速操作与数据探测
+
+ + + + +
+
+
+ + +
+
+
+
+
👟 每日活动汇总 (/api/summary)
+
步数、活动距离、日常活动卡路里消耗
+
+ +
+
+

点击上方按钮查询所选日期范围内的每日活动汇总...

+
+
+
+ + +
+
+
+
+
❤️ 心率采样 (/api/heart-rate)
+
逐分钟心率采样点、静息心率与极限心率统计
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
💤 睡眠分析与分段 (/api/sleep)
+
入睡与清醒时间、深睡/浅睡/REM分期、零星小睡统计
+
+ +
+ +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+
🏃 运动训练记录 (/api/workouts)
+
户外跑步、骑行、徒步等各项运动轨迹、心率区间与配速
+
+ +
+
+
+ + +
+
+
+
+
⚖️ 体重与体成分 (/api/body-measurements)
+
体脂率、BMI、肌肉量、骨量、内脏脂肪等(需体脂秤支持)
+
+ +
+ + +
+
+ + +
+
+
+
+
🫁 血氧监测 (/api/spo2)
+
全天血氧饱和度连续采样与低血氧告警
+
+ +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+
🧠 压力监测 (/api/stress)
+
全天压力值、压力等级分布与放松建议
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
⚠️ 异常心跳事件 (/api/abnormal-heart-beat)
+
静息心率过高、过低或心律不齐事件记录
+
+ +
+
+
+ + +
+
+
+
+
📈 指标序列多维分析 (/api/metric-series)
+
按天、周、月聚合分析健康走势
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

选择上方指标并点击查询以查看走势图...

+
+
+
+ + +
+
+
+
+
🔄 云端数据同步控制中心 (/api/sync)
+
从小米运动健康云端拉取最新数据存入本地 SQLite
+
+
+ +
+
+ +
+ + +
+
+ +
+
+ +
+ + +
+ +
+ +
+
+ +
+ +
+
+
+
+ + +
+
+
+
+
🔑 API Key 密钥管理 (/api/auth/keys)
+
使用小米账号凭据换取独立的 mif_sk_* 密钥,支持多账号与凭据隔离
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+
+
🩺 服务连通性与诊断报告
+
测试适配器连通性、区域探测及本地 SQLite 数据库状态
+
+
+ +
+ + + +
+
+
+
+ + + +
+
+ + + + + +
+ + + + + + diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..2542d72 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,66 @@ +"""Tests for Flask Web UI and reverse proxy.""" + +import httpx +import respx +from httpx import Response + +from mi_fitness_mcp.web import create_app + + +def test_index_route(): + app = create_app() + app.config["TESTING"] = True + client = app.test_client() + + response = client.get("/") + assert response.status_code == 200 + assert "Mi Fitness" in response.text or "Mi Fitness Web UI" in response.text + + +def test_proxy_status_route(): + app = create_app(backend_url="http://127.0.0.1:8321") + app.config["TESTING"] = True + client = app.test_client() + + response = client.get("/api/proxy_status") + assert response.status_code == 200 + data = response.get_json() + assert data["proxy"] == "running" + assert data["proxy_type"] == "Flask (Python)" + assert data["backend_url"] == "http://127.0.0.1:8321" + + +@respx.mock +def test_proxy_forwarding(): + backend_url = "http://test-backend:8321" + respx.get(f"{backend_url}/api/summary").mock( + return_value=Response(200, json=[{"date": "2026-08-30", "steps": 8500}]) + ) + + app = create_app(backend_url=backend_url, api_key="mif_sk_secret_test") + app.config["TESTING"] = True + client = app.test_client() + + resp = client.get("/proxy/api/summary?start_date=2026-08-30&end_date=2026-08-31") + assert resp.status_code == 200 + assert "X-Upstream-Time-Ms" in resp.headers + data = resp.get_json() + assert len(data) == 1 + assert data[0]["steps"] == 8500 + + +@respx.mock +def test_proxy_upstream_error_502(): + backend_url = "http://non-existent-backend:8321" + respx.get(f"{backend_url}/api/status").mock( + side_effect=httpx.ConnectError("Connection refused") + ) + app = create_app(backend_url=backend_url) + app.config["TESTING"] = True + client = app.test_client() + + resp = client.get("/proxy/api/status") + assert resp.status_code == 502 + data = resp.get_json() + assert "error" in data + assert "Flask 代理无法访问后端" in data["error"]