feat: REST API with API keys, QR login, and web dashboard (v0.2.0)

- REST API (FastAPI): all data endpoints, sync (foreground/background),
  coverage stats, interactive docs at /docs
- API key system: exchange Xiaomi credentials for mif_sk_* keys
  (multi-account contexts, last-used tracking, prefix revocation);
  static key via MI_FITNESS_API_KEY, admin endpoints via MI_FITNESS_ADMIN_KEY
- QR code login: reverse-engineered Xiaomi universal QR flow
  (sid=xiaomiio), auto-issues API key after scan confirmation
- Built-in web dashboard (Flask): visual console for all endpoints,
  key management, QR login; reverse proxy with server-side key relay
- Standalone testpage/ console (python app.py, no install needed)
- Fix: pin mcp<2 (SDK 2.x removed Server.list_tools, broke startup)
- CI: install [all,dev] extras; docs: full technical documentation
  and QR login implementation guide
This commit is contained in:
binglua committed 2026-09-03 22:28:31 +08:00
1 parent 7fcd06980f
commit 2dd32f1ee6
21 files changed
+8723 -165

No files matched your search

+763
View File
@@ -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}
+51
View File
@@ -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)
+202
View File
@@ -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/<path> 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/<path:subpath>", 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)
File diff suppressed because it is too large. Load diff
@@ -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 = `
<div class="toast-icon">${icons[type] || 'ℹ️'}</div>
<div class="toast-content">
<div class="toast-title">${title}</div>
${desc ? `<div class="toast-desc">${desc}</div>` : ''}
</div>
`;
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 = `<div class="inspector-empty-state"><div class="spinner"></div><div>正在通过 Flask 代理请求后端...</div></div>`;
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 = `<div style="color:var(--status-error);padding:12px;">❌ 代理网络连接错误: ${err.message}<br><br>💡 请检查:<br>1. Python FastAPI 是否在 127.0.0.1:8321 运行<br>2. Flask 代理是否正常监听</div>`;
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 = `<div class="inspector-empty-state"><div class="inspector-empty-icon">📂</div><div>暂无返回数据</div></div>`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 `<span class="${cls}">${match}</span>`;
});
}
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 = `<div style="color:var(--accent-cyan);font-size:11px;margin-bottom:8px;">🔍 匹配到 ${lines.filter(l => l.toLowerCase().includes(q)).length} 行:</div>` + 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 = `<div class="inspector-empty-state"><p>所选时间段内无活动数据</p></div>`;
return;
}
let html = `
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>日期</th>
<th>步数</th>
<th>活动卡路里 (kcal)</th>
<th>距离 (km)</th>
</tr>
</thead>
<tbody>
`;
data.forEach(item => {
const distKm = item.distance ? (item.distance / 1000).toFixed(2) : '0.00';
html += `
<tr>
<td><strong>${item.date || '--'}</strong></td>
<td><span class="badge badge-success">👟 ${(item.steps || 0).toLocaleString()} 步</span></td>
<td>🔥 ${Math.round(item.active_calories || 0)} kcal</td>
<td>📍 ${distKm} km</td>
</tr>
`;
});
html += `</tbody></table></div>`;
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 = '<p class="panel-subtitle">无睡眠分段数据</p>';
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 = `
<div class="sleep-timeline-container">
<div class="sleep-timeline-bar">
<div class="sleep-segment deep" style="width:${(deep / total) * 100}%" data-tooltip="深睡: ${deep} 分钟"></div>
<div class="sleep-segment light" style="width:${(light / total) * 100}%" data-tooltip="浅睡: ${light} 分钟"></div>
<div class="sleep-segment rem" style="width:${(rem / total) * 100}%" data-tooltip="快速眼动 (REM): ${rem} 分钟"></div>
<div class="sleep-segment awake" style="width:${(awake / total) * 100}%" data-tooltip="清醒: ${awake} 分钟"></div>
</div>
<div class="sleep-legend">
<div class="legend-item"><span class="legend-dot deep"></span>深睡 ${deep}m (${Math.round((deep/total)*100)}%)</div>
<div class="legend-item"><span class="legend-dot light"></span>浅睡 ${light}m (${Math.round((light/total)*100)}%)</div>
<div class="legend-item"><span class="legend-dot rem"></span>REM ${rem}m (${Math.round((rem/total)*100)}%)</div>
<div class="legend-item"><span class="legend-dot awake"></span>清醒 ${awake}m</div>
</div>
</div>
`;
}
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 = '<div class="inspector-empty-state"><p>所选范围内无序列数据</p></div>';
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 += `
<div class="chart-bar-group">
<div class="chart-bar" style="height:${heightPercent}%">
<div class="chart-bar-tooltip">${item.date || item.timestamp || ''}: ${val.toLocaleString()}</div>
</div>
<div class="chart-bar-label">${dateLabel}</div>
</div>
`;
});
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 `
<div class="tag-pill ${isSelected ? 'selected' : ''}" onclick="toggleSyncTag('${t.id}')">
<span class="tag-check">✓</span>
<span>${t.name}</span>
</div>
`;
}).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 = '📱 <strong>已扫码!</strong> 请在手机上点击「确认登录」';
} else if (data.status === 'confirmed' && data.api_key) {
stopQrFlow();
$('qrStatusText').innerHTML = `✅ <strong>登录成功!</strong> 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 = '<p class="panel-subtitle">暂无已发放的 API Key 记录</p>';
return;
}
let html = `
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>标签</th>
<th>Key 前缀</th>
<th>User ID</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
`;
data.forEach(k => {
html += `
<tr>
<td><strong>${k.label || '默认'}</strong></td>
<td><code>${k.prefix || k.api_key_prefix || 'mif_sk_...'}</code></td>
<td>${k.user_id || '--'}</td>
<td>${k.created_at || '--'}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="revokeKeyByPrefix('${k.prefix || k.api_key_prefix}')">吊销</button>
</td>
</tr>
`;
});
html += '</tbody></table></div>';
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();
});
@@ -0,0 +1,650 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mi Fitness MCP | 健康数据中心与调试控制台</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="app-container">
<!-- Top Header -->
<header class="top-header">
<div class="header-brand">
<div class="brand-icon">⚡</div>
<div class="brand-titles">
<div class="brand-title">
<span>Mi Fitness Hub</span>
<span class="brand-badge">MCP CN</span>
</div>
<div class="brand-subtitle">小米运动健康数据服务 · Python Flask 驱动</div>
</div>
</div>
<div class="header-actions">
<!-- Live Backend Status -->
<div class="status-indicator-badge" id="backendStatusBadge" title="FastAPI 后端服务连接状态">
<div class="status-dot pulsing" id="backendStatusDot"></div>
<span id="backendStatusText">检测连接中...</span>
</div>
<!-- Global Date Range Pickers -->
<div class="form-row" style="margin: 0; gap: 6px;">
<input type="date" id="startDate" class="input-control" style="width: 130px; padding: 5px 8px; font-size: 12px;" title="开始日期">
<span style="color: var(--text-dim);">~</span>
<input type="date" id="endDate" class="input-control" style="width: 130px; padding: 5px 8px; font-size: 12px;" title="结束日期">
</div>
<!-- Global API Key Input -->
<div class="api-key-input-wrapper" title="X-API-Key(将作为请求头转发)">
<input type="password" id="apiKeyInput" placeholder="API Key (留空使用服务端默认)">
<button class="api-key-icon-btn" onclick="toggleApiKeyVisibility()" title="显示/隐藏 Key" id="keyVisBtn">👁️</button>
</div>
<!-- Quick Action Buttons -->
<button class="btn btn-primary btn-sm" onclick="openQrModal()">📱 扫码登录</button>
<button class="btn btn-secondary btn-sm" onclick="switchTab('sync')">🔄 数据同步</button>
<button class="btn btn-icon-only btn-sm" id="themeToggleBtn" onclick="toggleTheme()" title="切换浅色/深色主题">🌙</button>
</div>
</header>
<!-- Main Layout Grid -->
<div class="main-layout">
<!-- Left Sidebar Navigation -->
<aside class="app-sidebar">
<div>
<div class="nav-section-title">概览与系统</div>
<div class="nav-group">
<button class="nav-item active" data-tab="overview">
<span class="nav-icon">📊</span>
<span>仪表盘总览</span>
</button>
<button class="nav-item" data-tab="doctor">
<span class="nav-icon">🩺</span>
<span>连接与诊断</span>
</button>
</div>
</div>
<div>
<div class="nav-section-title">健康数据查询</div>
<div class="nav-group">
<button class="nav-item" data-tab="activity">
<span class="nav-icon">👟</span>
<span>每日活动</span>
<span class="nav-badge">Summary</span>
</button>
<button class="nav-item" data-tab="heartrate">
<span class="nav-icon">❤️</span>
<span>心率监测</span>
<span class="nav-badge">HR</span>
</button>
<button class="nav-item" data-tab="sleep">
<span class="nav-icon">💤</span>
<span>睡眠分析</span>
<span class="nav-badge">Sleep</span>
</button>
<button class="nav-item" data-tab="workouts">
<span class="nav-icon">🏃</span>
<span>运动记录</span>
<span class="nav-badge">Sports</span>
</button>
<button class="nav-item" data-tab="body">
<span class="nav-icon">⚖️</span>
<span>体成分与体重</span>
</button>
<button class="nav-item" data-tab="spo2">
<span class="nav-icon">🫁</span>
<span>血氧饱和度</span>
</button>
<button class="nav-item" data-tab="stress">
<span class="nav-icon">🧠</span>
<span>压力指数</span>
</button>
<button class="nav-item" data-tab="abnormal">
<span class="nav-icon">⚠️</span>
<span>异常心跳</span>
</button>
</div>
</div>
<div>
<div class="nav-section-title">数据聚合与同步</div>
<div class="nav-group">
<button class="nav-item" data-tab="series">
<span class="nav-icon">📈</span>
<span>指标序列分析</span>
</button>
<button class="nav-item" data-tab="sync">
<span class="nav-icon">🔄</span>
<span>云端同步中心</span>
</button>
</div>
</div>
<div>
<div class="nav-section-title">安全与授权</div>
<div class="nav-group">
<button class="nav-item" data-tab="keys">
<span class="nav-icon">🔑</span>
<span>API Key 管理</span>
</button>
</div>
</div>
</aside>
<!-- Center Workspace (Tabbed) -->
<main class="app-content">
<!-- Date Preset Filter Pills -->
<div class="date-preset-pills">
<span style="font-size: 11.5px; color: var(--text-dim); margin-right: 4px;">快捷范围:</span>
<button class="preset-pill" data-preset="today" id="preset-today">今天</button>
<button class="preset-pill active" data-preset="yesterday" id="preset-yesterday">昨天 ~ 今天</button>
<button class="preset-pill" data-preset="7d" id="preset-7d">近 7 天</button>
<button class="preset-pill" data-preset="30d" id="preset-30d">近 30 天</button>
<button class="preset-pill" data-preset="month" id="preset-month">本月</button>
</div>
<!-- 1. Overview Tab -->
<section class="tab-pane active" id="tab-overview">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">📊</span> 健康指标总览</div>
<div class="panel-subtitle">基于选定时间范围的最新健康数据概览</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="loadOverview()">🔄 刷新数据</button>
</div>
<div class="stat-cards-grid">
<div class="stat-card steps">
<div class="stat-card-header">
<span class="stat-card-title">今日总步数</span>
<div class="stat-card-icon">👟</div>
</div>
<div class="stat-card-value"><span id="statSteps">--</span> <span class="stat-card-unit">步</span></div>
<div class="stat-card-footer">
<span>目标 10,000 步</span>
<span class="stat-card-badge up">活动</span>
</div>
</div>
<div class="stat-card calories">
<div class="stat-card-header">
<span class="stat-card-title">活动消耗</span>
<div class="stat-card-icon">🔥</div>
</div>
<div class="stat-card-value"><span id="statCalories">--</span> <span class="stat-card-unit">kcal</span></div>
<div class="stat-card-footer">
<span>距离: <span id="statDistance">--</span> km</span>
<span class="stat-card-badge neutral">日常</span>
</div>
</div>
<div class="stat-card hr">
<div class="stat-card-header">
<span class="stat-card-title">静息心率</span>
<div class="stat-card-icon">❤️</div>
</div>
<div class="stat-card-value"><span id="statHr">--</span> <span class="stat-card-unit">bpm</span></div>
<div class="stat-card-footer">
<span>静息基准</span>
<span class="stat-card-badge up">正常</span>
</div>
</div>
<div class="stat-card sleep">
<div class="stat-card-header">
<span class="stat-card-title">最近睡眠</span>
<div class="stat-card-icon">💤</div>
</div>
<div class="stat-card-value"><span id="statSleep">--</span> <span class="stat-card-unit">小时</span></div>
<div class="stat-card-footer">
<span>包含小睡计算</span>
<span class="stat-card-badge neutral">良好</span>
</div>
</div>
<div class="stat-card spo2">
<div class="stat-card-header">
<span class="stat-card-title">血氧饱和度</span>
<div class="stat-card-icon">🫁</div>
</div>
<div class="stat-card-value"><span id="statSpo2">--</span></div>
<div class="stat-card-footer">
<span>>95% 正常范围</span>
<span class="stat-card-badge up">健康</span>
</div>
</div>
<div class="stat-card stress">
<div class="stat-card-header">
<span class="stat-card-title">压力水平</span>
<div class="stat-card-icon">🧠</div>
</div>
<div class="stat-card-value"><span id="statStress">--</span></div>
<div class="stat-card-footer">
<span>0-100 指数</span>
<span class="stat-card-badge neutral">适中</span>
</div>
</div>
</div>
<div class="glass-panel" style="margin-top: 10px;">
<div class="panel-title"><span class="title-icon">⚡</span> 快速操作与数据探测</div>
<div style="display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap;">
<button class="btn btn-outline" onclick="api('GET', '/api/status?deep=true')">深检服务连接</button>
<button class="btn btn-outline" onclick="api('GET', '/api/coverage')">检查数据覆盖率</button>
<button class="btn btn-outline" onclick="api('GET', '/')">FastAPI 服务元信息</button>
<button class="btn btn-primary" onclick="openQrModal()">📱 启动扫码登录</button>
</div>
</div>
</section>
<!-- 2. Daily Activity Tab -->
<section class="tab-pane" id="tab-activity">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">👟</span> 每日活动汇总 (/api/summary)</div>
<div class="panel-subtitle">步数、活动距离、日常活动卡路里消耗</div>
</div>
<button class="btn btn-primary" onclick="querySummary()">查询活动数据</button>
</div>
<div id="summaryVisualContainer" style="margin-top: 14px;">
<p class="panel-subtitle">点击上方按钮查询所选日期范围内的每日活动汇总...</p>
</div>
</div>
</section>
<!-- 3. Heart Rate Tab -->
<section class="tab-pane" id="tab-heartrate">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">❤️</span> 心率采样 (/api/heart-rate)</div>
<div class="panel-subtitle">逐分钟心率采样点、静息心率与极限心率统计</div>
</div>
<button class="btn btn-primary" onclick="queryHeartRate()">查询心率数据</button>
</div>
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">心率样本类型 (sample_type)</label>
<select id="hrType" class="select-control">
<option value="">全部类型 (All)</option>
<option value="passive">被动日常监测 (passive)</option>
<option value="active">运动活跃采样 (active)</option>
<option value="resting">静息心率 (resting)</option>
</select>
</div>
<div class="form-group sm">
<label class="form-label">返回上限 (limit)</label>
<input type="number" id="hrLimit" class="input-control" placeholder="默认 100">
</div>
</div>
</div>
</section>
<!-- 4. Sleep Tab -->
<section class="tab-pane" id="tab-sleep">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">💤</span> 睡眠分析与分段 (/api/sleep)</div>
<div class="panel-subtitle">入睡与清醒时间、深睡/浅睡/REM分期、零星小睡统计</div>
</div>
<button class="btn btn-primary" onclick="querySleep()">查询睡眠分析</button>
</div>
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">包含小睡 (include_naps)</label>
<select id="napsSelect" class="select-control">
<option value="true">包含零星小睡 (True)</option>
<option value="false">仅主睡眠阶段 (False)</option>
</select>
</div>
</div>
<div id="sleepTimelineWrapper" style="margin-top: 16px;"></div>
</div>
</section>
<!-- 5. Workouts Tab -->
<section class="tab-pane" id="tab-workouts">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">🏃</span> 运动训练记录 (/api/workouts)</div>
<div class="panel-subtitle">户外跑步、骑行、徒步等各项运动轨迹、心率区间与配速</div>
</div>
<button class="btn btn-primary" onclick="queryWorkouts()">查询运动记录</button>
</div>
</div>
</section>
<!-- 6. Body Measurements Tab -->
<section class="tab-pane" id="tab-body">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">⚖️</span> 体重与体成分 (/api/body-measurements)</div>
<div class="panel-subtitle">体脂率、BMI、肌肉量、骨量、内脏脂肪等(需体脂秤支持)</div>
</div>
<button class="btn btn-primary" onclick="queryBody()">查询体成分数据</button>
</div>
<label class="custom-checkbox">
<input type="checkbox" id="latestOnlyCheck">
<div class="checkbox-box">✓</div>
<span>仅返回最新一条记录 (latest_only=true)</span>
</label>
</div>
</section>
<!-- 7. SpO2 Tab -->
<section class="tab-pane" id="tab-spo2">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">🫁</span> 血氧监测 (/api/spo2)</div>
<div class="panel-subtitle">全天血氧饱和度连续采样与低血氧告警</div>
</div>
<button class="btn btn-primary" onclick="querySpo2()">查询血氧记录</button>
</div>
<div class="form-row" style="margin-top: 14px;">
<div class="form-group sm">
<label class="form-label">数量限制 (limit)</label>
<input type="number" id="spo2Limit" class="input-control" placeholder="如 50">
</div>
</div>
</div>
</section>
<!-- 8. Stress Tab -->
<section class="tab-pane" id="tab-stress">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">🧠</span> 压力监测 (/api/stress)</div>
<div class="panel-subtitle">全天压力值、压力等级分布与放松建议</div>
</div>
<button class="btn btn-primary" onclick="queryStress()">查询压力记录</button>
</div>
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">压力等级过滤 (level)</label>
<select id="stressLevel" class="select-control">
<option value="">全部等级 (All)</option>
<option value="low">低度压力 (Low)</option>
<option value="medium">中度压力 (Medium)</option>
<option value="high">高度压力 (High)</option>
</select>
</div>
<div class="form-group sm">
<label class="form-label">数量限制 (limit)</label>
<input type="number" id="stressLimit" class="input-control" placeholder="如 50">
</div>
</div>
</div>
</section>
<!-- 9. Abnormal Heart Beat Tab -->
<section class="tab-pane" id="tab-abnormal">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">⚠️</span> 异常心跳事件 (/api/abnormal-heart-beat)</div>
<div class="panel-subtitle">静息心率过高、过低或心律不齐事件记录</div>
</div>
<button class="btn btn-primary" onclick="queryAbnormalHr()">查询异常心跳</button>
</div>
</div>
</section>
<!-- 10. Metric Series Tab -->
<section class="tab-pane" id="tab-series">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">📈</span> 指标序列多维分析 (/api/metric-series)</div>
<div class="panel-subtitle">按天、周、月聚合分析健康走势</div>
</div>
<button class="btn btn-primary" onclick="queryMetricSeries()">生成分析序列</button>
</div>
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">指标类型 (metric)</label>
<select id="metricSelect" class="select-control">
<option value="steps">步数 (steps)</option>
<option value="distance_m">距离/米 (distance_m)</option>
<option value="active_kcal">活动卡路里 (active_kcal)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">时间粒度 (granularity)</label>
<select id="granularitySelect" class="select-control">
<option value="day">按天聚合 (Day)</option>
<option value="week">按周聚合 (Week)</option>
<option value="month">按月聚合 (Month)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">聚合算法 (aggregation)</label>
<select id="aggregationSelect" class="select-control">
<option value="sum">求和 (Sum)</option>
<option value="avg">平均值 (Avg)</option>
<option value="max">最大值 (Max)</option>
<option value="min">最小值 (Min)</option>
<option value="latest">取最新 (Latest)</option>
</select>
</div>
</div>
<!-- Visualization Canvas -->
<div class="chart-container" id="metricChartContainer" style="margin-top: 16px;">
<div class="inspector-empty-state"><p>选择上方指标并点击查询以查看走势图...</p></div>
</div>
</div>
</section>
<!-- 11. Sync Center Tab -->
<section class="tab-pane" id="tab-sync">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">🔄</span> 云端数据同步控制中心 (/api/sync)</div>
<div class="panel-subtitle">从小米运动健康云端拉取最新数据存入本地 SQLite</div>
</div>
</div>
<div style="margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<label class="form-label">选择要同步的数据类型 (多选):</label>
<div style="display: flex; gap: 8px;">
<button class="btn btn-outline btn-sm" onclick="selectAllSyncTags(true)">全选</button>
<button class="btn btn-outline btn-sm" onclick="selectAllSyncTags(false)">重置</button>
</div>
</div>
<div class="tag-pills-selector" id="syncTagPills"></div>
</div>
<div style="display: flex; gap: 12px; margin-top: 18px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="triggerSync(false)">🚀 前台同步(等待完成)</button>
<button class="btn btn-secondary" onclick="triggerSync(true)">⚡ 后台异步同步(立即返回)</button>
</div>
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-subtle);">
<label class="form-label">后台同步任务进度查询 (/api/sync/{id})</label>
<div class="form-row" style="margin-top: 6px;">
<div class="form-group">
<input type="text" id="syncIdInput" class="input-control" placeholder="输入后台同步返回的 sync_id (如 sync_abc123)">
</div>
<button class="btn btn-outline" onclick="pollSyncStatus()">查询任务状态</button>
</div>
</div>
</div>
</section>
<!-- 12. API Key Management Tab -->
<section class="tab-pane" id="tab-keys">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">🔑</span> API Key 密钥管理 (/api/auth/keys)</div>
<div class="panel-subtitle">使用小米账号凭据换取独立的 mif_sk_* 密钥,支持多账号与凭据隔离</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="listKeys()">刷新 Key 列表</button>
</div>
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">小米 User ID</label>
<input type="text" id="keyUserId" class="input-control" placeholder="小米数字 userId">
</div>
<div class="form-group">
<label class="form-label">小米 passToken</label>
<input type="password" id="keyPassToken" class="input-control" placeholder="小米登录 passToken">
</div>
<div class="form-group sm">
<label class="form-label">Key 标签</label>
<input type="text" id="keyLabel" class="input-control" placeholder="web-ui" value="web-console">
</div>
</div>
<button class="btn btn-primary" onclick="createKeyWithCredentials()" style="margin-top: 8px;">用凭据发放 API Key</button>
<div id="keysTableWrapper" style="margin-top: 20px;"></div>
</div>
</section>
<!-- 13. Doctor Tab -->
<section class="tab-pane" id="tab-doctor">
<div class="glass-panel">
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">🩺</span> 服务连通性与诊断报告</div>
<div class="panel-subtitle">测试适配器连通性、区域探测及本地 SQLite 数据库状态</div>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="api('GET', '/api/status?deep=true')">🩺 深度连通性探测 (Deep Check)</button>
<button class="btn btn-secondary" onclick="api('GET', '/api/status?deep=false')">⚡ 快速状态自检 (Fast Check)</button>
<button class="btn btn-outline" onclick="api('GET', '/api/coverage')">📊 检查本地数据覆盖范围</button>
</div>
</div>
</section>
</main>
<!-- Right Inspector & Developer Console -->
<aside class="app-inspector">
<div class="inspector-header">
<div class="inspector-title">
<span>💻</span> 实时控制台与响应检视
</div>
<div class="btn-group">
<button class="btn btn-secondary btn-sm" onclick="copyResponse()" title="复制 JSON">📋 复制</button>
<button class="btn btn-secondary btn-sm" onclick="copyCurl()" title="生成 cURL">cURL</button>
<button class="btn btn-secondary btn-sm" onclick="downloadJson()" title="导出 JSON">💾</button>
</div>
</div>
<div class="inspector-meta-bar">
<div class="meta-badges-group">
<span class="http-method-pill get" id="metaMethod">GET</span>
<span class="status-pill ok" id="metaStatus">READY</span>
<span class="latency-pill" id="metaLatency">-- ms</span>
</div>
<div style="font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px;" id="metaUrl">/</div>
</div>
<div class="inspector-actions-bar">
<input type="text" class="inspector-search-input" placeholder="在响应中过滤搜索..." oninput="filterInspectorJson(this.value)">
</div>
<div class="inspector-body" id="inspectorBody">
<div class="inspector-empty-state">
<div class="inspector-empty-icon">⚡</div>
<div>点击左侧功能发起 API 请求,返回的 JSON 将在此处进行语法高亮显示与检视。</div>
</div>
</div>
</aside>
</div>
</div>
<!-- QR Code Login Modal Overlay -->
<div class="modal-backdrop" id="qrModal">
<div class="modal-card">
<div class="modal-header">
<div class="modal-title">
<span>📱</span> 小米账号 App / 米家 扫码授权
</div>
<button class="modal-close-btn" onclick="closeQrModal()">✕</button>
</div>
<div class="modal-body">
<div class="qr-container">
<div class="qr-image-frame">
<img id="qrImage" alt="小米账号登录二维码" src="">
<div class="qr-scan-beam" id="qrScanBeam"></div>
</div>
<div style="width: 100%;">
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">
<span>二维码状态</span>
<span id="qrTimerText">有效期: 5:00</span>
</div>
<div class="qr-timer-bar">
<div class="qr-timer-progress" id="qrProgressBar"></div>
</div>
</div>
<div id="qrStatusText" style="text-align: center; font-size: 12.5px; color: var(--text-secondary); line-height: 1.5;">
请打开「小米运动健康」或「米家」App 扫码并在手机上点击确认
</div>
<div style="width: 100%; margin-top: 6px;">
<label class="form-label">登录 URL 备选通道 (在手机浏览器打开亦可):</label>
<div class="form-row" style="margin: 4px 0 0 0;">
<input type="text" id="qrLoginUrl" class="input-control" readonly style="font-size: 11px;">
<button class="btn btn-secondary btn-sm" onclick="copyLoginUrl()">复制</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="startQrFlow()">🔄 刷新二维码</button>
<button class="btn btn-secondary" onclick="closeQrModal()">关闭</button>
</div>
</div>
</div>
<!-- Floating Toast Notifications -->
<div class="toast-container" id="toastContainer"></div>
<!-- Scripts -->
<script src="/static/js/app.js"></script>
<script>
function toggleApiKeyVisibility() {
const input = $('apiKeyInput');
const btn = $('keyVisBtn');
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '🔒';
} else {
input.type = 'password';
btn.textContent = '👁️';
}
}
</script>
</body>
</html>