mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-19 19:53:47 +08:00
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:
1 parent
7fcd06980f
commit
2dd32f1ee6
21 files changed
+8723
-165
No files matched your search
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, '&').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 `<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>
|
||||
Reference in new issue
Block a user