保存 NAS 健康系统 UI7 代码与本机维护基线

This commit is contained in:
weiyuqing committed 2026-10-17 13:29:12 +08:00
1 parent 1767b4921a
commit 8546ba23ff
80 files changed
+4984 -381

No files matched your search

+112 -113
View File
@@ -6,7 +6,6 @@ import logging
import os
import random
import struct
from collections import defaultdict
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta, timezone
from typing import Any
@@ -315,10 +314,14 @@ class MiFitnessCloudAdapter(DataAdapter):
payload["next_key"] = next_key
result = await self._request(base_url, "/app/v1/data/get_fitness_data_by_time", payload)
items.extend(result.get("data_list", []))
if not result.get("has_more") or not result.get("next_key"):
if not isinstance(result.get("data_list"), list):
raise ValueError("Invalid Mi Fitness data response")
items.extend(result["data_list"])
if not result.get("has_more"):
break
candidate = str(result.get("next_key"))
candidate = result.get("next_key")
if not isinstance(candidate, str) or not candidate:
raise ValueError("Invalid Mi Fitness pagination cursor")
if candidate in seen_keys:
raise RuntimeError("Mi Fitness pagination cursor loop detected")
seen_keys.add(candidate)
@@ -402,10 +405,14 @@ class MiFitnessCloudAdapter(DataAdapter):
result = await self._request(
base_url, "/app/v1/data/get_sport_records_by_time", payload
)
items.extend(result.get("sport_records", []))
if not result.get("has_more") or not result.get("next_key"):
if not isinstance(result.get("sport_records"), list):
raise ValueError("Invalid Mi Fitness sport response")
items.extend(result["sport_records"])
if not result.get("has_more"):
break
candidate = str(result.get("next_key"))
candidate = result.get("next_key")
if not isinstance(candidate, str) or not candidate:
raise ValueError("Invalid Mi Fitness sport pagination cursor")
if candidate in seen_keys:
raise RuntimeError("Mi Fitness sport pagination cursor loop detected")
seen_keys.add(candidate)
@@ -447,61 +454,68 @@ class MiFitnessCloudAdapter(DataAdapter):
return
yield
records = await self._fetch_key("steps", start_date, end_date)
daily: dict[str, dict[str, Any]] = defaultdict(
lambda: {
"steps": 0,
"distance_m": 0.0,
"active_kcal": 0.0,
"timezone": "UTC",
"collected_at": None,
}
)
for item in records:
payload = self._parse_value(item)
collected_at = self._record_datetime(item)
date_str = collected_at.strftime("%Y-%m-%d")
daily[date_str]["steps"] += int(payload.get("steps", 0))
daily[date_str]["distance_m"] += float(payload.get("distance", 0))
daily[date_str]["active_kcal"] += float(payload.get("calories", 0))
daily[date_str]["timezone"] = item.get("zone_name") or daily[date_str]["timezone"]
if (
daily[date_str]["collected_at"] is None
or collected_at > daily[date_str]["collected_at"]
):
daily[date_str]["collected_at"] = collected_at
# 多来源分钟明细有重叠,使用小米已融合的日报,不自行累加或猜测去重。
reports: dict[str, dict[str, tuple[tuple[int, int], dict]]] = {}
tz = self._request_timezone()
for key in ("steps", "calories"):
by_date: dict[str, tuple[tuple[int, int], dict]] = {}
for item in await self._fetch_daily_reports(key, start_date, end_date):
date_str = datetime.fromtimestamp(int(item["time"]), tz=tz).date().isoformat()
if not start_date <= date_str <= end_date:
continue
rank = (int(item.get("update_time", 0)), int(item.get("watermark", 0)))
existing = by_date.get(date_str)
if existing is None or rank > existing[0]:
by_date[date_str] = (rank, item)
elif rank == existing[0] and self._parse_value(item) != self._parse_value(existing[1]):
raise ValueError(f"Conflicting daily reports for {key} on {date_str}")
reports[key] = by_date
calorie_records = await self._fetch_key("calories", start_date, end_date)
calorie_totals: dict[str, float] = defaultdict(float)
for item in calorie_records:
payload = self._parse_value(item)
collected_at = self._record_datetime(item)
date_str = collected_at.strftime("%Y-%m-%d")
calorie_totals[date_str] += float(payload.get("calories", 0))
daily[date_str]["timezone"] = item.get("zone_name") or daily[date_str]["timezone"]
if (
daily[date_str]["collected_at"] is None
or collected_at > daily[date_str]["collected_at"]
):
daily[date_str]["collected_at"] = collected_at
for date_str, total in calorie_totals.items():
daily[date_str]["active_kcal"] = total
for date_str, values in sorted(daily.items()):
for date_str in sorted(set(reports["steps"]) | set(reports["calories"])):
if date_str not in reports["steps"] or date_str not in reports["calories"]:
raise ValueError(f"Incomplete daily report on {date_str}")
step_item = reports["steps"][date_str][1]
step_payload = self._parse_value(step_item)
calorie_payload = self._parse_value(reports["calories"][date_str][1])
if not {"steps", "distance"} <= step_payload.keys() or "calories" not in calorie_payload:
raise ValueError(f"Incomplete daily report values on {date_str}")
yield DailyActivity(
id=f"mi_fitness_activity_{date_str}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=f"daily_report:{date_str}",
user_id=self.user_id or "unknown",
timezone=str(values["timezone"]),
collected_at=values["collected_at"],
timezone="Asia/Shanghai" if self.region in ("", "cn") else "UTC",
collected_at=datetime.fromtimestamp(int(step_item["time"]), tz=tz),
date=date_str,
steps=int(values["steps"]),
distance_m=float(values["distance_m"]),
active_kcal=float(values["active_kcal"]),
steps=int(step_payload["steps"]),
distance_m=float(step_payload["distance"]),
active_kcal=float(calorie_payload["calories"]),
)
async def _fetch_daily_reports(self, key: str, start_date: str, end_date: str) -> list[dict]:
base_url = "https://hlth.io.mi.com" if self.region in ("", "cn") else f"https://{self.region}.hlth.io.mi.com"
start_time, end_time = self._date_range_to_timestamps(start_date, end_date)
records: list[dict] = []
next_key = None
seen_keys: set[str] = set()
for _ in range(self.max_pages):
arguments = {"key": key, "tag": "daily_report", "start_time": start_time, "end_time": end_time, "limit": 100}
if next_key is not None:
arguments["next_key"] = next_key
result = await self._request(base_url, "/app/v1/data/get_aggregated_fitness_data_by_time", arguments)
if not isinstance(result.get("data_list"), list):
raise ValueError("Invalid daily report response")
records.extend(result["data_list"])
if not result.get("has_more"):
return records
candidate = result.get("next_key")
if not isinstance(candidate, str) or not candidate or candidate in seen_keys:
raise ValueError("Invalid daily report pagination cursor")
seen_keys.add(candidate)
next_key = candidate
raise RuntimeError("Daily report pagination exceeded safety limit")
async def iter_sleep_sessions(
self,
start_date: str | None = None,
@@ -511,68 +525,53 @@ class MiFitnessCloudAdapter(DataAdapter):
return
yield
records = await self._fetch_key("sleep", start_date, end_date)
for item in records:
payload = self._parse_value(item)
zone_offset = int(item.get("zone_offset", 0) or 0)
sleep_start = (
payload.get("bedtime")
or payload.get("device_bedtime")
or payload.get("bed_timestamp")
)
sleep_end = (
payload.get("wake_up_time")
or payload.get("device_wake_up_time")
or payload.get("out_bed_timestamp")
or item.get("time")
)
if not sleep_start or not sleep_end:
tz = self._request_timezone()
reports: dict[str, tuple[tuple[int, int], dict]] = {}
for item in await self._fetch_daily_reports("sleep", start_date, end_date):
day = datetime.fromtimestamp(int(item["time"]), tz=tz).date().isoformat()
if not start_date <= day <= end_date:
continue
rank = (int(item.get("update_time", 0)), int(item.get("watermark", 0)))
existing = reports.get(day)
if existing is None or rank > existing[0]:
reports[day] = (rank, item)
elif rank == existing[0] and self._parse_value(item) != self._parse_value(existing[1]):
raise ValueError(f"Conflicting sleep daily reports on {day}")
start_at = self._timestamp_to_datetime(sleep_start, zone_offset)
end_at = self._timestamp_to_datetime(sleep_end, zone_offset)
duration_minutes = int(
payload.get("duration") or max(0, (int(sleep_end) - int(sleep_start)) // 60)
)
awake_minutes = int(
payload.get("awake_duration") or payload.get("sleep_awake_duration") or 0
)
asleep_minutes = max(0, duration_minutes - awake_minutes)
stages: list[SleepStage] = []
for segment in payload.get("items", []) or []:
try:
seg_start = int(segment.get("start_time", 0))
seg_end = int(segment.get("end_time", 0))
minutes = max(0, (seg_end - seg_start) // 60)
if minutes:
stages.append(
SleepStage(
stage=self._sleep_stage_name(segment.get("state")), minutes=minutes
)
)
except Exception:
continue
sleep_id = f"{item.get('sid', self.user_id)}_{item.get('time', int(sleep_end))}"
yield SleepSession(
id=f"mi_fitness_sleep_{sleep_id}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
sleep_id=sleep_id,
start_at=start_at,
end_at=end_at,
duration_minutes=duration_minutes,
time_asleep_minutes=asleep_minutes,
time_awake_minutes=awake_minutes,
sleep_score=self._optional_int(payload.get("score") or payload.get("sleep_score")),
is_nap=bool(payload.get("is_nap", False)),
stages=stages,
)
for day, (_, item) in sorted(reports.items()):
payload = self._parse_value(item)
segments = payload.get("segment_details")
if not isinstance(segments, list):
raise ValueError(f"Incomplete sleep daily report on {day}")
if sum(int(segment["duration"]) for segment in segments) != int(payload["total_duration"]):
raise ValueError(f"Sleep daily report duration mismatch on {day}")
for index, segment in enumerate(sorted(segments, key=lambda s: int(s["bedtime"]))):
# 日报 duration 已是实际睡眠分钟,不能再次减清醒时间。
duration = int(segment["duration"])
sleep_id = f"daily_report_{day}_{index}"
stages = [
SleepStage(stage=stage, minutes=int(segment[key]))
for stage, key in (("deep", "sleep_deep_duration"), ("light", "sleep_light_duration"), ("rem", "sleep_rem_duration"))
if key in segment
]
yield SleepSession(
id=f"mi_fitness_sleep_{sleep_id}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=f"daily_report:{day}",
user_id=self.user_id or "unknown",
timezone="Asia/Shanghai" if self.region in ("", "cn") else "UTC",
collected_at=datetime.fromtimestamp(int(item.get("update_time") or item["time"]), tz=tz),
sleep_id=sleep_id,
start_at=datetime.fromtimestamp(int(segment["bedtime"]), tz=tz),
end_at=datetime.fromtimestamp(int(segment["wake_up_time"]), tz=tz),
duration_minutes=duration,
time_asleep_minutes=duration,
time_awake_minutes=int(segment.get("sleep_awake_duration", 0)),
sleep_score=self._optional_int(payload.get("sleep_score")) if len(segments) == 1 else None,
is_nap=bool(segment.get("is_nap", False)),
stages=stages,
)
async def iter_workouts(
self,
+80 -51
View File
@@ -21,6 +21,7 @@ import asyncio
import json
import os
import secrets
import threading
import time
import uuid
from contextlib import asynccontextmanager
@@ -85,6 +86,7 @@ class UserContext:
def _build_context(db: Database, config, user_id: str, pass_token: str, region: str) -> UserContext:
_assert_data_owner(db, user_id, region)
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
@@ -145,6 +147,8 @@ async def lifespan(app: FastAPI):
app.state.db = db
app.state.default_context = default_context
app.state.contexts = {}
app.state.key_contexts = {}
app.state.key_context_lock = threading.RLock()
app.state.sync_tasks = {}
app.state.qr_sessions = {}
try:
@@ -243,22 +247,33 @@ def _resolve_context(request: Request) -> UserContext:
"SELECT user_id, pass_token, region FROM api_keys WHERE key = ? AND revoked = 0",
(header,),
).fetchone()
if row is None:
state.key_contexts.pop(header, None)
raise HTTPException(status_code=401, detail="Invalid or revoked API key")
if row is not None:
# passToken 优先从系统 keyring 读取;为空则回退 DB 列(无 keyring 环境的降级存储)
pass_token = load_api_key_secret(header) or row["pass_token"]
if not pass_token:
raise HTTPException(status_code=401, detail="API key credentials unavailable")
cache_key = (row["user_id"], 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()
with state.key_context_lock:
# 每次仍检查数据库吊销状态;短期复用上下文,避免每个请求重复执行昂贵的解密。
now = time.monotonic()
identity = (row["user_id"], row["region"], row["pass_token"])
cached = state.key_contexts.get(header)
if cached and cached["expires"] > now and cached["identity"] == identity:
return cached["context"]
# passToken 优先从系统 keyring 读取;为空则回退 DB 列(无 keyring 环境的降级存储)
pass_token = load_api_key_secret(header) or row["pass_token"]
if not pass_token:
raise HTTPException(status_code=401, detail="API key credentials unavailable")
cache_key = (row["user_id"], 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
state.key_contexts[header] = {"expires": now + 60, "identity": identity, "context": 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
@@ -302,6 +317,13 @@ def _query_service(request: Request) -> QueryService:
# ---------------------------------------------------------------- Key 管理
def _assert_data_owner(db: Database, user_id: str, region: str, *, connection=None) -> None:
try:
db.assert_single_owner(user_id, region, _connection=connection)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
class CreateKeyRequest(BaseModel):
user_id: str
pass_token: str
@@ -312,8 +334,10 @@ class CreateKeyRequest(BaseModel):
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)
# passToken 存系统 keyring(加密);keyring 不可用才降级写 DB 明文列
stored = store_api_key_secret(key, pass_token)
with db._get_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
_assert_data_owner(db, user_id, region, connection=conn)
stored = store_api_key_secret(key, pass_token)
conn.execute(
"INSERT INTO api_keys (key, label, user_id, pass_token, region, created_at) VALUES (?,?,?,?,?,?)",
(key, label, user_id, "" if stored else pass_token, region, datetime.now(UTC).isoformat()),
@@ -384,6 +408,8 @@ async def revoke_key(key_prefix: str, request: Request) -> dict:
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 或唯一前缀)")
if len(matches) != 1:
raise HTTPException(status_code=409, detail="前缀不唯一,请提供更长的唯一前缀")
for k in matches:
conn.execute("UPDATE api_keys SET revoked = 1 WHERE key = ?", (k,))
delete_api_key_secret(k)
@@ -465,44 +491,45 @@ async def qr_poll(token: str, request: Request) -> dict:
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"}
async with sess.setdefault("poll_lock", asyncio.Lock()):
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"}
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:
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"
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}
sess["api_key"] = key
sess["user_id"] = user_id
return {"status": "confirmed", "api_key": key, "user_id": user_id}
# ---------------------------------------------------------------- 数据接口
@@ -561,6 +588,8 @@ class SyncRequest(BaseModel):
async def _run_sync(ctx: UserContext, config, arguments: dict) -> dict:
sync_service = ctx.sync_service
adapter = ctx.adapter
if sync_service is not None:
_assert_data_owner(sync_service.db, ctx.user_id, ctx.region)
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():
+1 -1
View File
@@ -46,7 +46,7 @@ class SleepSession(BaseEntity):
sleep_id: str = Field(description="Unique sleep session ID")
start_at: datetime = Field(description="Sleep start time")
end_at: datetime = Field(description="Sleep end time")
duration_minutes: int = Field(ge=0, description="Total duration")
duration_minutes: int = Field(ge=0, description="Total sleep duration excluding awake minutes")
time_asleep_minutes: int = Field(ge=0, description="Actual sleep time")
time_awake_minutes: int = Field(ge=0, description="Time awake during sleep")
sleep_score: int | None = Field(None, ge=0, le=100, description="Sleep quality score")
+2 -1
View File
@@ -306,7 +306,8 @@ async def _handle_get_connection_status() -> dict:
"stress",
"abnormal_heart_beat",
]:
state = db.get_sync_state(data_type)
owner_scope = {"user_id": adapter.user_id} if getattr(adapter, "user_id", None) else {}
state = db.get_sync_state(data_type, **owner_scope)
if state and state.get("last_sync_at"):
available_types.append(data_type)
sync_time = datetime.fromisoformat(state["last_sync_at"])
+6 -2
View File
@@ -89,7 +89,7 @@ class QueryService:
"""Aggregate daily series by week."""
weeks = {}
for item in series:
for item in sorted(series, key=lambda record: record["date"]):
date = datetime.strptime(item["date"], "%Y-%m-%d")
# 计算周起始日(周一)
week_start = date - timedelta(days=date.weekday())
@@ -109,6 +109,8 @@ class QueryService:
value = min(values)
elif aggregation == "max":
value = max(values)
elif aggregation == "latest":
value = values[-1]
else:
value = sum(values)
@@ -124,7 +126,7 @@ class QueryService:
"""Aggregate daily series by month."""
months = {}
for item in series:
for item in sorted(series, key=lambda record: record["date"]):
date = datetime.strptime(item["date"], "%Y-%m-%d")
month_key = date.strftime("%Y-%m")
@@ -142,6 +144,8 @@ class QueryService:
value = min(values)
elif aggregation == "max":
value = max(values)
elif aggregation == "latest":
value = values[-1]
else:
value = sum(values)
+38 -21
View File
@@ -3,7 +3,7 @@
import asyncio
import logging
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any
from mi_fitness_mcp.adapters.base import DataAdapter
@@ -32,6 +32,7 @@ class SyncService:
self.db = db
self.default_lookback_days = default_lookback_days
self.chunk_days = chunk_days
self._owner_scope = {"user_id": adapter.user_id} if getattr(adapter, "user_id", None) else {}
self._sync_lock = asyncio.Lock()
self._sync_active = False
@@ -48,6 +49,21 @@ class SyncService:
for record in records:
yield record
async def _persist_records(self, data_type: str, records: Any):
"""限制每批 200 条,并在线程中提交,避免磁盘等待堵住 HTTP 请求。"""
batch = []
async for record in self._iterate_records(records):
batch.append(record)
if len(batch) >= 200:
results = await asyncio.to_thread(self.db.insert_records_batch, data_type, batch)
for item, created in zip(batch, results, strict=True):
yield item, created
batch = []
if batch:
results = await asyncio.to_thread(self.db.insert_records_batch, data_type, batch)
for item, created in zip(batch, results, strict=True):
yield item, created
async def sync_data_type(
self,
data_type: str,
@@ -92,12 +108,13 @@ class SyncService:
# 获取上次同步状态,用于增量同步
last_record_ts = None
if not force_full:
state = self.db.get_sync_state(data_type)
state = self.db.get_sync_state(data_type, **self._owner_scope)
if state and state.get("last_record_timestamp"):
last_record_ts = datetime.fromisoformat(state["last_record_timestamp"])
zone = timezone(timedelta(hours=8)) if getattr(self.adapter, "region", None) in ("", "cn") else None
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
end_date = datetime.now(zone).strftime("%Y-%m-%d")
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
start_dt = datetime.strptime(start_date, "%Y-%m-%d") if start_date else None
@@ -105,7 +122,7 @@ class SyncService:
raise ValueError("Dates must use YYYY-MM-DD format") from exc
if start_dt is None:
start_dt = (
last_record_ts.replace(tzinfo=None)
datetime.combine((last_record_ts.astimezone(zone) if zone and last_record_ts.tzinfo else last_record_ts).date(), datetime.min.time())
if last_record_ts
else end_dt - timedelta(days=self.default_lookback_days - 1)
)
@@ -177,8 +194,8 @@ class SyncService:
# 按数据类型执行同步
if data_type == "daily_activity":
records = self.adapter.iter_daily_activity(start_date, end_date)
async for activity in self._iterate_records(records):
if self.db.insert_daily_activity(activity):
async for activity, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -187,8 +204,8 @@ class SyncService:
elif data_type == "sleep":
records = self.adapter.iter_sleep_sessions(start_date, end_date)
async for sleep in self._iterate_records(records):
if self.db.insert_sleep_session(sleep):
async for sleep, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -197,8 +214,8 @@ class SyncService:
elif data_type == "workouts":
records = self.adapter.iter_workouts(start_date, end_date)
async for workout in self._iterate_records(records):
if self.db.insert_workout(workout):
async for workout, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -207,8 +224,8 @@ class SyncService:
elif data_type == "body_measurements":
records = self.adapter.iter_body_measurements(start_date, end_date)
async for measurement in self._iterate_records(records):
if self.db.insert_body_measurement(measurement):
async for measurement, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -217,8 +234,8 @@ class SyncService:
elif data_type == "heart_rate":
records = self.adapter.iter_heart_rate(start_date, end_date)
async for sample in self._iterate_records(records):
if self.db.insert_heart_rate_sample(sample):
async for sample, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -227,8 +244,8 @@ class SyncService:
elif data_type == "spo2":
records = self.adapter.iter_spo2(start_date, end_date)
async for sample in self._iterate_records(records):
if self.db.insert_spo2_sample(sample):
async for sample, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -237,8 +254,8 @@ class SyncService:
elif data_type == "stress":
records = self.adapter.iter_stress(start_date, end_date)
async for sample in self._iterate_records(records):
if self.db.insert_stress_sample(sample):
async for sample, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -247,8 +264,8 @@ class SyncService:
elif data_type == "abnormal_heart_beat":
records = self.adapter.iter_abnormal_heart_beat(start_date, end_date)
async for event in self._iterate_records(records):
if self.db.insert_abnormal_heart_beat_event(event):
async for event, created in self._persist_records(data_type, records):
if created:
added += 1
else:
updated += 1
@@ -260,7 +277,7 @@ class SyncService:
# 更新同步状态
if last_ts:
self.db.update_sync_state(data_type, last_ts)
await asyncio.to_thread(self.db.update_sync_state, data_type, last_ts, **self._owner_scope)
logger.info(
f"Synced {data_type}: {added} added, {updated} updated, "
+131 -37
View File
@@ -2,7 +2,7 @@
import json
import sqlite3
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -275,13 +275,68 @@ class Database:
finally:
conn.close()
def insert_daily_activity(self, activity: DailyActivity) -> bool:
def insert_records_batch(self, data_type: str, records: list) -> list[bool]:
"""在一个事务中写入一批记录,失败时整批回滚;复用已有 UPSERT。"""
methods = {
"daily_activity": self.insert_daily_activity,
"sleep": self.insert_sleep_session,
"workouts": self.insert_workout,
"body_measurements": self.insert_body_measurement,
"heart_rate": self.insert_heart_rate_sample,
"spo2": self.insert_spo2_sample,
"stress": self.insert_stress_sample,
"abnormal_heart_beat": self.insert_abnormal_heart_beat_event,
}
if data_type not in methods:
raise ValueError("Unsupported batch data type")
if not records:
return []
with self._get_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
owners = {record.user_id for record in records}
if len(owners) != 1:
raise ValueError("一批健康记录只能属于一个账号")
self.assert_single_owner(next(iter(owners)), _connection=conn)
result = [methods[data_type](record, _connection=conn) for record in records]
conn.commit()
return result
def assert_single_owner(self, user_id: str, region: str | None = None, *, _connection=None) -> None:
"""本部署的健康库固定绑定一个账号,历史已吊销授权仍参与校验。"""
with (self._get_connection() if _connection is None else nullcontext(_connection)) as conn:
for table in ("daily_activity", "sleep_sessions", "workouts", "body_measurements",
"heart_rate_samples", "spo2_samples", "stress_samples", "abnormal_heart_beat_events"):
# user_id 为索引前缀且不可为空,只需检查两端,避免每批扫描整个历史库。
first = conn.execute(f"SELECT user_id FROM {table} ORDER BY user_id LIMIT 1").fetchone()
if first:
last = conn.execute(f"SELECT user_id FROM {table} ORDER BY user_id DESC LIMIT 1").fetchone()
if first[0] != user_id or last[0] != user_id:
raise ValueError("健康数据库已绑定其他账号,拒绝混写;请使用原账号")
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='api_keys'").fetchone():
if conn.execute("SELECT 1 FROM api_keys WHERE user_id != ? LIMIT 1", (user_id,)).fetchone():
raise ValueError("健康数据库已绑定其他账号,拒绝混写;请使用原账号")
if region is not None and conn.execute(
"SELECT 1 FROM api_keys WHERE region != ? LIMIT 1", (region,)
).fetchone():
raise ValueError("健康数据库已绑定其他地区,拒绝更改数据来源")
@contextmanager
def _write_connection(self, connection, user_id):
if connection is not None:
yield connection
else:
with self._get_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
self.assert_single_owner(user_id, _connection=conn)
yield conn
def insert_daily_activity(self, activity: DailyActivity, *, _connection: sqlite3.Connection | None = None) -> bool:
"""Insert or update daily activity record.
Returns:
True if record was inserted, False if updated
"""
with self._get_connection() as conn:
with self._write_connection(_connection, activity.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO daily_activity (
@@ -318,12 +373,13 @@ class Database:
activity.active_minutes,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_sleep_session(self, sleep: SleepSession) -> bool:
def insert_sleep_session(self, sleep: SleepSession, *, _connection: sqlite3.Connection | None = None) -> bool:
"""Insert or update sleep session record."""
with self._get_connection() as conn:
with self._write_connection(_connection, sleep.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO sleep_sessions (
@@ -332,6 +388,12 @@ class Database:
time_asleep_minutes, time_awake_minutes, sleep_score, is_nap, stages
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, sleep_id) DO UPDATE SET
source_record_id = excluded.source_record_id,
timezone = excluded.timezone,
collected_at = excluded.collected_at,
start_at = excluded.start_at,
end_at = excluded.end_at,
is_nap = excluded.is_nap,
duration_minutes = excluded.duration_minutes,
time_asleep_minutes = excluded.time_asleep_minutes,
time_awake_minutes = excluded.time_awake_minutes,
@@ -359,12 +421,13 @@ class Database:
json.dumps([s.model_dump() for s in sleep.stages]),
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_workout(self, workout: Workout) -> bool:
def insert_workout(self, workout: Workout, *, _connection: sqlite3.Connection | None = None) -> bool:
"""Insert or update workout record."""
with self._get_connection() as conn:
with self._write_connection(_connection, workout.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO workouts (
@@ -374,11 +437,21 @@ class Database:
max_heart_rate_bpm, avg_pace_sec_per_km, max_pace_sec_per_km, total_steps
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, workout_id) DO UPDATE SET
source_record_id = excluded.source_record_id,
device_id = excluded.device_id,
timezone = excluded.timezone,
collected_at = excluded.collected_at,
activity_type = excluded.activity_type,
start_at = excluded.start_at,
end_at = excluded.end_at,
duration_minutes = excluded.duration_minutes,
distance_m = excluded.distance_m,
calories_kcal = excluded.calories_kcal,
avg_heart_rate_bpm = excluded.avg_heart_rate_bpm,
max_heart_rate_bpm = excluded.max_heart_rate_bpm,
avg_pace_sec_per_km = excluded.avg_pace_sec_per_km,
max_pace_sec_per_km = excluded.max_pace_sec_per_km,
total_steps = excluded.total_steps,
updated_at = CURRENT_TIMESTAMP
""",
(
@@ -404,12 +477,13 @@ class Database:
workout.total_steps,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_body_measurement(self, measurement: BodyMeasurement) -> bool:
def insert_body_measurement(self, measurement: BodyMeasurement, *, _connection: sqlite3.Connection | None = None) -> bool:
"""Insert or update body measurement record."""
with self._get_connection() as conn:
with self._write_connection(_connection, measurement.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO body_measurements (
@@ -424,6 +498,10 @@ class Database:
body_fat_pct = excluded.body_fat_pct,
muscle_mass_kg = excluded.muscle_mass_kg,
water_pct = excluded.water_pct,
bone_mass_kg = excluded.bone_mass_kg,
visceral_fat_score = excluded.visceral_fat_score,
basal_metabolism_kcal = excluded.basal_metabolism_kcal,
metabolic_age = excluded.metabolic_age,
updated_at = CURRENT_TIMESTAMP
""",
(
@@ -447,11 +525,12 @@ class Database:
measurement.metabolic_age,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_heart_rate_sample(self, sample: HeartRateSample) -> bool:
with self._get_connection() as conn:
def insert_heart_rate_sample(self, sample: HeartRateSample, *, _connection: sqlite3.Connection | None = None) -> bool:
with self._write_connection(_connection, sample.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO heart_rate_samples (
@@ -480,11 +559,12 @@ class Database:
sample.sample_type,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_spo2_sample(self, sample: SpO2Sample) -> bool:
with self._get_connection() as conn:
def insert_spo2_sample(self, sample: SpO2Sample, *, _connection: sqlite3.Connection | None = None) -> bool:
with self._write_connection(_connection, sample.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO spo2_samples (
@@ -511,11 +591,12 @@ class Database:
sample.spo2_pct,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_stress_sample(self, sample: StressSample) -> bool:
with self._get_connection() as conn:
def insert_stress_sample(self, sample: StressSample, *, _connection: sqlite3.Connection | None = None) -> bool:
with self._write_connection(_connection, sample.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO stress_samples (
@@ -544,11 +625,12 @@ class Database:
sample.level,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def insert_abnormal_heart_beat_event(self, event: AbnormalHeartBeatEvent) -> bool:
with self._get_connection() as conn:
def insert_abnormal_heart_beat_event(self, event: AbnormalHeartBeatEvent, *, _connection: sqlite3.Connection | None = None) -> bool:
with self._write_connection(_connection, event.user_id) as conn:
cursor = conn.execute(
"""
INSERT INTO abnormal_heart_beat_events (
@@ -578,10 +660,11 @@ class Database:
event.duration_seconds,
),
)
conn.commit()
if _connection is None:
conn.commit()
return cursor.rowcount > 0
def update_sync_state(self, data_type: str, last_record_ts: datetime | None = None) -> None:
def update_sync_state(self, data_type: str, last_record_ts: datetime | None = None, *, user_id: str | None = None) -> None:
"""Update sync state for a data type."""
with self._get_connection() as conn:
conn.execute(
@@ -592,16 +675,17 @@ class Database:
last_sync_at = CURRENT_TIMESTAMP,
last_record_timestamp = excluded.last_record_timestamp
""",
(data_type, last_record_ts.isoformat() if last_record_ts else None),
(json.dumps([user_id, data_type], separators=(",", ":")) if user_id else data_type,
last_record_ts.isoformat() if last_record_ts else None),
)
conn.commit()
def get_sync_state(self, data_type: str) -> dict[str, Any] | None:
def get_sync_state(self, data_type: str, *, user_id: str | None = None) -> dict[str, Any] | None:
"""Get sync state for a data type."""
with self._get_connection() as conn:
row = conn.execute(
"SELECT * FROM sync_state WHERE data_type = ?",
(data_type,),
(json.dumps([user_id, data_type], separators=(",", ":")) if user_id else data_type,),
).fetchone()
return dict(row) if row else None
@@ -635,12 +719,22 @@ class Database:
"""
SELECT * FROM sleep_sessions
WHERE user_id = ?
AND substr(start_at, 1, 10) >= ? AND substr(start_at, 1, 10) <= ?
AND substr(end_at, 1, 10) >= ? AND substr(end_at, 1, 10) <= ?
ORDER BY start_at
""",
(user_id, start_date, end_date),
).fetchall()
return [dict(row) for row in rows]
records = [dict(row) for row in rows]
# 原始会话仍保留;同日已有融合日报时,只呈现其最新版本。
latest = {}
for record in records:
if (record.get("source_record_id") or "").startswith("daily_report:"):
day = record["end_at"][:10]
latest[day] = max(latest.get(day, ""), record.get("collected_at") or "")
return [record for record in records if record["end_at"][:10] not in latest or (
(record.get("source_record_id") or "").startswith("daily_report:")
and (record.get("collected_at") or "") == latest[record["end_at"][:10]]
)]
def query_workouts(
self,
@@ -781,9 +875,9 @@ class Database:
row = conn.execute(
"""
SELECT
MIN(substr(start_at, 1, 10)) as first_date,
MAX(substr(start_at, 1, 10)) as last_date,
COUNT(DISTINCT substr(start_at, 1, 10)) as days_with_data
MIN(substr(end_at, 1, 10)) as first_date,
MAX(substr(end_at, 1, 10)) as last_date,
COUNT(DISTINCT substr(end_at, 1, 10)) as days_with_data
FROM sleep_sessions
WHERE user_id = ?
""",
@@ -864,9 +958,9 @@ class Database:
row = conn.execute(
f"""
SELECT
MIN(date({date_expr})) as first_date,
MAX(date({date_expr})) as last_date,
COUNT(DISTINCT date({date_expr})) as days_with_data
MIN(substr({date_expr}, 1, 10)) as first_date,
MAX(substr({date_expr}, 1, 10)) as last_date,
COUNT(DISTINCT substr({date_expr}, 1, 10)) as days_with_data
FROM {table_name}
WHERE user_id = ?
""",
+33 -1
View File
@@ -9,6 +9,7 @@ Provides a modern dashboard interface and reverse proxy to the backend FastAPI s
"""
import os
import secrets
import time
from pathlib import Path
@@ -53,6 +54,12 @@ def create_app(
).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")
app.config["WEB_PASSWORD"] = (
os.environ.get("MI_FITNESS_WEB_PASSWORD")
or app.config["ADMIN_KEY"]
or app.config["API_KEY"]
)
app.config["WEB_ORIGIN"] = os.environ.get("MI_FITNESS_WEB_ORIGIN", "").rstrip("/")
# 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)
@@ -62,8 +69,27 @@ def create_app(
# Host 头白名单(防 DNS 重绑定):默认仅本机域名,环境变量可覆盖
if not host_allowed(request.host, default_allowed_hosts()):
return jsonify({"detail": "Host not allowed"}), 403
password = app.config["WEB_PASSWORD"]
if password:
auth = request.authorization
if not (
auth and auth.type == "basic" and auth.username == "admin"
and secrets.compare_digest((auth.password or "").encode("utf-8"), password.encode("utf-8"))
):
return Response("请使用网页访问密码登录。", status=401,
headers={"WWW-Authenticate": 'Basic realm="Mi Fitness Hub", charset="UTF-8"'},
content_type="text/plain; charset=utf-8")
# 浏览器会自动发送 Basic 凭据,仍需拒绝其他网站借用此身份调用代理。
origin = request.headers.get("Origin")
if origin and origin not in {request.host_url.rstrip("/"), app.config["WEB_ORIGIN"]}:
return jsonify({"detail": "Cross-origin request denied"}), 403
return None
@app.after_request
def _prevent_health_cache(response):
response.headers["Cache-Control"] = "no-store"
return response
@app.route("/")
def index():
"""Render the main dashboard interface."""
@@ -123,7 +149,7 @@ def create_app(
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"):
if k_lower not in ("host", "content-length", "transfer-encoding", "authorization"):
forward_headers[key] = value
# Attach API Key (Server env priority, otherwise pass through client key)
@@ -135,6 +161,10 @@ def create_app(
server_admin_key = app.config["ADMIN_KEY"]
if server_admin_key:
forward_headers["X-Admin-Key"] = server_admin_key
if clean_subpath.startswith("api/auth/"):
# 管理接口独立校验管理员身份,不能被浏览器残留的已吊销查询 Key 阻断。
forward_headers = {key: value for key, value in forward_headers.items()
if key.lower() != "x-api-key"}
body = request.get_data() if request.method in ("POST", "PUT", "PATCH", "DELETE") else None
@@ -176,6 +206,8 @@ def create_app(
resp.headers["X-Upstream-Time-Ms"] = str(int(elapsed_ms))
return resp
from mi_fitness_mcp.workday_calendar import register_calendar
register_calendar(app, assets_path)
return app
@@ -0,0 +1,175 @@
{
"$schema": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json",
"$id": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/2025.json",
"year": 2025,
"papers": [
"https://www.gov.cn/zhengce/zhengceku/202411/content_6986383.htm"
],
"days": [
{
"name": "元旦",
"date": "2025-01-01",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-01-26",
"isOffDay": false
},
{
"name": "春节",
"date": "2025-01-28",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-01-29",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-01-30",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-01-31",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-02-01",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-02-02",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-02-03",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-02-04",
"isOffDay": true
},
{
"name": "春节",
"date": "2025-02-08",
"isOffDay": false
},
{
"name": "清明节",
"date": "2025-04-04",
"isOffDay": true
},
{
"name": "清明节",
"date": "2025-04-05",
"isOffDay": true
},
{
"name": "清明节",
"date": "2025-04-06",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2025-04-27",
"isOffDay": false
},
{
"name": "劳动节",
"date": "2025-05-01",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2025-05-02",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2025-05-03",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2025-05-04",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2025-05-05",
"isOffDay": true
},
{
"name": "端午节",
"date": "2025-05-31",
"isOffDay": true
},
{
"name": "端午节",
"date": "2025-06-01",
"isOffDay": true
},
{
"name": "端午节",
"date": "2025-06-02",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-09-28",
"isOffDay": false
},
{
"name": "国庆节、中秋节",
"date": "2025-10-01",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-02",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-03",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-04",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-05",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-06",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-07",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-08",
"isOffDay": true
},
{
"name": "国庆节、中秋节",
"date": "2025-10-11",
"isOffDay": false
}
]
}
@@ -0,0 +1,205 @@
{
"$schema": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json",
"$id": "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/2026.json",
"year": 2026,
"papers": [
"https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"
],
"days": [
{
"name": "元旦",
"date": "2026-01-01",
"isOffDay": true
},
{
"name": "元旦",
"date": "2026-01-02",
"isOffDay": true
},
{
"name": "元旦",
"date": "2026-01-03",
"isOffDay": true
},
{
"name": "元旦",
"date": "2026-01-04",
"isOffDay": false
},
{
"name": "春节",
"date": "2026-02-14",
"isOffDay": false
},
{
"name": "春节",
"date": "2026-02-15",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-16",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-17",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-18",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-19",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-20",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-21",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-22",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-23",
"isOffDay": true
},
{
"name": "春节",
"date": "2026-02-28",
"isOffDay": false
},
{
"name": "清明节",
"date": "2026-04-04",
"isOffDay": true
},
{
"name": "清明节",
"date": "2026-04-05",
"isOffDay": true
},
{
"name": "清明节",
"date": "2026-04-06",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2026-05-01",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2026-05-02",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2026-05-03",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2026-05-04",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2026-05-05",
"isOffDay": true
},
{
"name": "劳动节",
"date": "2026-05-09",
"isOffDay": false
},
{
"name": "端午节",
"date": "2026-06-19",
"isOffDay": true
},
{
"name": "端午节",
"date": "2026-06-20",
"isOffDay": true
},
{
"name": "端午节",
"date": "2026-06-21",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-09-20",
"isOffDay": false
},
{
"name": "中秋节",
"date": "2026-09-25",
"isOffDay": true
},
{
"name": "中秋节",
"date": "2026-09-26",
"isOffDay": true
},
{
"name": "中秋节",
"date": "2026-09-27",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-01",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-02",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-03",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-04",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-05",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-06",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-07",
"isOffDay": true
},
{
"name": "国庆节",
"date": "2026-10-10",
"isOffDay": false
}
]
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 NateScarlet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,58 @@
/* 各健康查询页共用:概览、趋势、分页明细。 */
.main-layout { grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--inspector-width); }
.main-layout.inspector-collapsed { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
.app-inspector[hidden] { display: none !important; }
.app-content, .health-results { min-width: 0; }
.date-preset-pills { flex-wrap: wrap; }
.health-results { margin-top: 18px; }
.health-query-tab > .glass-panel:first-child { background: none; border: 0; box-shadow: none; padding: 0; }
.health-query-tab > .glass-panel:first-child .form-row { margin-top: 8px !important; }
.health-query-tab > .glass-panel:first-child .form-group { max-width: 360px; }
.health-query-tab > .glass-panel:first-child .panel-title { font-size: 20px; }
.health-query-tab > .glass-panel:first-child .panel-subtitle { display: none; }
.health-range { color: var(--text-muted); font-size: 12px; margin: 0 0 14px; }
.health-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.health-card { border: 1px solid var(--border-default); background: var(--bg-card); border-radius: 12px; padding: 18px; min-width: 0; }
.health-card > div:first-child { font-size: 12px; color: var(--text-muted); }
.health-card strong { display: block; font-size: 23px; margin: 12px 0 8px; line-height: 1.3; overflow-wrap: anywhere; }
.health-card strong small { font-size: 12px; color: var(--text-muted); font-weight: 400; }
.health-caption { font-size: 12px; color: var(--text-muted); line-height: 1.7; }
.health-results .glass-panel { margin-bottom: 16px; }
.health-results h3 { margin: 0; font-size: 15px; }
.health-trend svg { display: block; width: 100%; height: auto; max-height: 235px; margin: 10px 0 4px; }
.health-trend svg text { fill: var(--text-muted); font: 11px system-ui, sans-serif; }
.health-grid { stroke: var(--border-default); stroke-dasharray: 4 5; }
.health-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--text-secondary); }
.health-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; }
.health-empty { padding: 32px 16px; color: var(--text-muted); text-align: center; font-size: 13px; line-height: 1.8; }
.health-details .data-table-container { overflow-x: auto; margin-top: 14px; }
.health-details td, .health-details th { white-space: nowrap; }
.health-pagination { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; font-size: 12px; color: var(--text-muted); }
.health-pagination > div { display: flex; align-items: center; gap: 12px; }
.health-pagination button:disabled { opacity: .45; cursor: default; }
@media (max-width: 1200px) and (min-width: 993px) {
.main-layout, .main-layout.inspector-collapsed { grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr); }
.main-layout:not(.inspector-collapsed) { grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr) 340px; }
.main-layout:not(.inspector-collapsed) .health-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 992px) {
.main-layout, .main-layout.inspector-collapsed { grid-template-columns: minmax(0, 1fr); }
.app-inspector:not([hidden]) { display: flex; position: fixed; inset: var(--header-height) 0 0 auto; width: min(92vw, 480px); height: auto; z-index: 50; background: var(--bg-base); }
.main-layout.inspector-collapsed .app-content { padding-bottom: 24px; }
.health-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.health-card { padding: 12px; }
.health-card strong { font-size: 18px; }
.health-pagination { flex-wrap: wrap; }
.health-results .panel-header { gap: 12px; flex-wrap: wrap; }
}
/* 参考线不影响纵轴;越界参考值在图表上方标注。 */
.health-reference-label { fill: var(--bg-input); }
.health-reference-overflow { display: flex; justify-content: flex-start; align-items: center; gap: 8px; flex-wrap: wrap; margin: 8px 0 2px; }
.health-reference-badge { border: 1px solid currentColor; border-radius: 4px; padding: 2px 6px; font-size: 11px; }
.health-pressure-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 6px 0; font-size: 12px; color: var(--text-secondary); }
.health-pressure-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; }
.health-calendar-legend { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 16px; margin: 2px 0 4px; font-size: 12px; color: var(--text-secondary); }
.health-calendar-legend i { display: inline-block; width: 12px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
@@ -67,8 +67,8 @@
--shadow-glow-indigo: 0 0 20px -2px var(--accent-indigo-glow);
/* Glassmorphism & Blurs */
--backdrop-blur: blur(16px);
--backdrop-blur-sm: blur(8px);
--backdrop-blur: none;
--backdrop-blur-sm: none;
/* Transitions & Animation Timings */
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
@@ -1329,6 +1329,8 @@ code, pre {
.inspector-body {
flex: 1;
min-height: 0;
contain: layout paint;
padding: 14px 18px;
background: var(--bg-code);
overflow-y: auto;
@@ -1341,6 +1343,12 @@ code, pre {
position: relative;
}
.inspector-preview-notice {
white-space: normal;
color: var(--text-muted);
margin-bottom: 12px;
}
/* JSON Viewer Syntax Highlighting */
.json-key { color: #38bdf8; font-weight: 600; }
.json-string { color: #4ade80; }
+164 -73
View File
@@ -15,6 +15,11 @@ const state = {
syncTypes: ['daily_activity', 'heart_rate', 'sleep', 'workouts', 'body_measurements', 'spo2', 'stress', 'abnormal_heart_beat'],
lastResponse: null,
lastRequestInfo: null,
overviewRequestId: 0,
inspectorRequestId: 0,
healthRequestId: 0,
healthViews: {},
healthController: null,
};
// DOM Utilities
@@ -46,21 +51,21 @@ function setDatePreset(type) {
if (type === 'today') {
start = today;
} else if (type === 'yesterday') {
start = new Date(Date.now() - 86400000);
today.setDate(today.getDate() - 1);
start.setDate(today.getDate() - 1);
} else if (type === '7d') {
start = new Date(Date.now() - 7 * 86400000);
start.setDate(today.getDate() - 6);
} else if (type === '30d') {
start = new Date(Date.now() - 30 * 86400000);
start.setDate(today.getDate() - 29);
} else if (type === 'month') {
start = new Date(today.getFullYear(), today.getMonth(), 1);
}
$('startDate').value = formatDate(start);
$('endDate').value = formatDate(type === 'yesterday' ? start : new Date());
$('endDate').value = formatDate(today);
const el = $(`preset-${type}`);
if (el) el.classList.add('active');
return reloadActiveView();
}
const getSd = () => $('startDate').value;
@@ -129,6 +134,8 @@ function showToast(title, desc = '', type = 'info', duration = 3500) {
// Tab Switching
function switchTab(tabId) {
invalidateHealthQuery();
clearInspector();
state.activeTab = tabId;
$$('.nav-item').forEach(item => {
@@ -138,6 +145,7 @@ function switchTab(tabId) {
$$('.tab-pane').forEach(pane => {
pane.classList.toggle('active', pane.id === `tab-${tabId}`);
});
return reloadActiveView();
}
// API Communication & Proxy Handler
@@ -150,17 +158,24 @@ function getHeaders() {
return headers;
}
async function api(method, path, body = null) {
async function api(method, path, body = null, view = {}) {
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>`;
const inspectorRequestId = view.inspect !== false && (!view.isCurrent || view.isCurrent())
? ++state.inspectorRequestId : null;
const canInspect = () => inspectorRequestId !== null && inspectorRequestId === state.inspectorRequestId
&& (!view.isCurrent || view.isCurrent());
if (canInspect()) {
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(),
...(view.signal ? {signal: view.signal} : {}),
};
if (body) {
options.body = JSON.stringify(body);
@@ -177,30 +192,35 @@ async function api(method, path, body = null) {
// plain text
}
state.lastResponse = jsonObj || text;
state.lastRequestInfo = {
method,
path,
url,
status: resp.status,
latency,
headers: options.headers,
body,
};
if (canInspect()) {
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);
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');
if (canInspect()) showToast(`请求失败: HTTP ${resp.status}`, jsonObj?.error || jsonObj?.detail || '请查看控制台输出', 'error');
return null;
}
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');
if (canInspect()) {
updateInspectorHeader(method, path, 'err', '网络错误', latency);
$('inspectorBody').textContent = `代理网络连接错误: ${err.message}`;
showToast('代理连接失败', '无法连接到后端服务,请确认端口与服务状态', 'error');
}
return null;
}
}
@@ -225,7 +245,15 @@ function renderJsonViewer(data) {
}
const jsonStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
$('inspectorBody').innerHTML = highlightJson(jsonStr);
renderInspectorPreview(jsonStr);
}
function renderInspectorPreview(text, prefix = '') {
// 只限制页面预览;完整响应仍用于统计、复制及导出。
const preview = text.slice(0, 16000).split('\n').slice(0, 200).join('\n');
const notice = preview.length < text.length
? '<div class="inspector-preview-notice">大响应预览:最多显示 200 行;复制和导出保留完整响应。</div>' : '';
$('inspectorBody').innerHTML = prefix + notice + highlightJson(preview);
}
function highlightJson(json) {
@@ -260,7 +288,7 @@ function filterInspectorJson(query) {
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 || '未找到匹配项');
renderInspectorPreview(filtered || '未找到匹配项', `<div style="color:var(--accent-cyan);font-size:11px;margin-bottom:8px;">🔍 匹配到 ${lines.filter(l => l.toLowerCase().includes(q)).length} 行:</div>`);
}
// Copy & Export Tools
@@ -310,54 +338,98 @@ function downloadJson() {
}
// 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');
function responseRows(response) {
if (Array.isArray(response)) return response;
return response?.status === 'ok' && Array.isArray(response.data) ? response.data : [];
}
const [summary, hr, sleep, spo2, stress] = await Promise.all([
function latestRecord(rows, field) {
return rows.reduce((latest, row) => !latest || String(row[field] || '') > String(latest[field] || '') ? row : latest, null);
}
function displayNumber(value, format = String) {
return typeof value === 'number' && Number.isFinite(value) ? format(value) : '--';
}
function numericValues(rows, field) {
return rows.map(row => row[field]).filter(value => typeof value === 'number' && Number.isFinite(value));
}
function aggregateValue(rows, field, average = false) {
const values = numericValues(rows, field);
return values.length ? values.reduce((sum, value) => sum + value, 0) / (average ? values.length : 1) : null;
}
function sleepDays(rows) {
const days = new Map();
rows.forEach(row => {
const stamp = Date.parse(row.end_at);
if (!Number.isFinite(stamp) || !Number.isFinite(row.duration_minutes)) return;
const day = new Date(stamp + 8 * 3600000).toISOString().slice(0, 10);
days.set(day, (days.get(day) || 0) + row.duration_minutes);
});
return days;
}
async function loadOverview() {
const requestId = ++state.overviewRequestId;
showToast('正在加载仪表盘概览…', '', 'info', 1500);
['statSteps', 'statCalories', 'statDistance', 'statHr', 'statSleep', 'statSpo2', 'statStress'].forEach(id => {
$(id).textContent = '--';
});
const currentView = {isCurrent: () => requestId === state.overviewRequestId && state.activeTab === 'overview'};
const summaryPromise = api('GET', getRangeQuery('/api/summary'), null, currentView);
const hrPromise = api('GET', getRangeQuery('/api/heart-rate') + '&sample_type=resting', null, {inspect:false});
const sleepPromise = api('GET', getRangeQuery('/api/sleep') + '&include_naps=true', null, {inspect:false});
const spo2Promise = api('GET', getRangeQuery('/api/spo2'), null, {inspect:false});
const stressPromise = api('GET', getRangeQuery('/api/stress'), null, {inspect:false});
const responses = await Promise.all([
summaryPromise, hrPromise, sleepPromise, spo2Promise, stressPromise
]);
if (requestId !== state.overviewRequestId) return;
const [summary, hr, sleep, spo2, stress] = responses.map(responseRows);
$('overviewRange').textContent = `${getSd()} 至 ${getEd()};缺失日期不按 0 计入均值`;
$('statActivityCoverage').textContent = `已记录 ${new Set(summary.map(row => row.date)).size} 天`;
const days = sleepDays(sleep);
$('statSleepCoverage').textContent = `记录 ${days.size} 天 · 含小睡`;
$('statHrCoverage').textContent = `样本 ${numericValues(hr, 'bpm').length} 条`;
$('statSpo2Coverage').textContent = `样本 ${numericValues(spo2, 'spo2_pct').length} 条`;
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';
$('statSteps').textContent = displayNumber(aggregateValue(summary, 'steps'), value => value.toLocaleString());
$('statCalories').textContent = displayNumber(aggregateValue(summary, 'active_kcal'), Math.round);
$('statDistance').textContent = displayNumber(aggregateValue(summary, 'distance_m'), value => (value / 1000).toFixed(2));
}
if (hr && Array.isArray(hr) && hr.length > 0) {
$('statHr').textContent = hr[0].bpm || '--';
$('statHr').textContent = displayNumber(aggregateValue(hr, 'bpm', true), value => String(Math.round(value * 10) / 10));
}
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;
const minutes = days.size ? [...days.values()].reduce((sum, value) => sum + value, 0) / days.size : null;
$('statSleep').textContent = displayNumber(minutes, value => (value / 60).toFixed(1));
}
if (spo2 && Array.isArray(spo2) && spo2.length > 0) {
$('statSpo2').textContent = spo2[0].spo2_value ? `${spo2[0].spo2_value}%` : '--';
$('statSpo2').textContent = displayNumber(aggregateValue(spo2, 'spo2_pct', true), value => `${Math.round(value * 10) / 10}%`);
}
if (stress && Array.isArray(stress) && stress.length > 0) {
$('statStress').textContent = stress[0].stress_value || '--';
$('statStress').textContent = displayNumber(aggregateValue(stress, 'stress_score', true), value => String(Math.round(value * 10) / 10));
}
showToast('仪表盘已刷新', '最新数据已加载', 'success');
const failed = responses.some(response => !response || (!Array.isArray(response) && response.status !== 'ok'));
showToast(failed ? '部分数据未加载' : '仪表盘已刷新', failed ? '请查看接口错误;未用 0 替代失败数据' : '已按所选日期汇总', failed ? 'warning' : 'success');
}
// Health Data Queries
async function querySummary() {
const data = await api('GET', getRangeQuery('/api/summary'));
renderSummaryVisual(data);
return loadHealthView('activity');
}
function renderSummaryVisual(data) {
data = responseRows(data);
const container = $('summaryVisualContainer');
if (!container) return;
if (!data || !Array.isArray(data) || data.length === 0) {
@@ -380,12 +452,12 @@ function renderSummaryVisual(data) {
`;
data.forEach(item => {
const distKm = item.distance ? (item.distance / 1000).toFixed(2) : '0.00';
const distKm = displayNumber(item.distance_m, value => (value / 1000).toFixed(2));
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>🔥 ${displayNumber(item.active_kcal, Math.round)} kcal</td>
<td>📍 ${distKm} km</td>
</tr>
`;
@@ -396,17 +468,15 @@ function renderSummaryVisual(data) {
}
async function queryHeartRate() {
const q = getRangeQuery('/api/heart-rate') + getSelQuery('hrType', 'sample_type') + getLimQuery('hrLimit');
await api('GET', q);
return loadHealthView('heartrate');
}
async function querySleep() {
const q = getRangeQuery('/api/sleep') + '&include_naps=' + $('napsSelect').value;
const data = await api('GET', q);
renderSleepTimeline(data);
return loadHealthView('sleep');
}
function renderSleepTimeline(data) {
data = responseRows(data);
const container = $('sleepTimelineWrapper');
if (!container) return;
if (!data || !Array.isArray(data) || data.length === 0) {
@@ -414,11 +484,16 @@ function renderSleepTimeline(data) {
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 latest = latestRecord(data, 'end_at');
if (!Array.isArray(latest.stages) || latest.stages.length === 0) {
container.innerHTML = '<p class="panel-subtitle">该记录没有睡眠阶段明细</p>';
return;
}
const stageMinutes = name => latest.stages.reduce((sum, stage) => sum + (stage.stage === name && Number.isFinite(stage.minutes) ? stage.minutes : 0), 0);
const deep = stageMinutes('deep');
const light = stageMinutes('light');
const rem = stageMinutes('rem');
const awake = Number.isFinite(latest.time_awake_minutes) ? latest.time_awake_minutes : stageMinutes('awake');
const total = deep + light + rem + awake || 1;
container.innerHTML = `
@@ -440,24 +515,23 @@ function renderSleepTimeline(data) {
}
async function queryWorkouts() {
await api('GET', getRangeQuery('/api/workouts'));
return loadHealthView('workouts');
}
async function queryBody() {
const isLatest = $('latestOnlyCheck').checked;
await api('GET', getRangeQuery('/api/body-measurements') + '&latest_only=' + isLatest);
return loadHealthView('body');
}
async function querySpo2() {
await api('GET', getRangeQuery('/api/spo2') + getLimQuery('spo2Limit'));
return loadHealthView('spo2');
}
async function queryStress() {
await api('GET', getRangeQuery('/api/stress') + getSelQuery('stressLevel', 'level') + getLimQuery('stressLimit'));
return loadHealthView('stress');
}
async function queryAbnormalHr() {
await api('GET', getRangeQuery('/api/abnormal-heart-beat'));
return loadHealthView('abnormal');
}
// Metric Series Analytics
@@ -471,6 +545,7 @@ async function queryMetricSeries() {
}
function renderMetricChart(data, metricName) {
data = responseRows(data);
const container = $('metricChartContainer');
if (!container) return;
if (!data || !Array.isArray(data) || data.length === 0) {
@@ -563,8 +638,10 @@ async function triggerSync(background = false) {
if (background && res && res.sync_id) {
$('syncIdInput').value = res.sync_id;
showToast('已获取同步任务 ID', res.sync_id, 'success');
} else if (!background && res) {
} else if (!background && res?.status === 'ok') {
showToast('前台同步完成', '健康数据已更新至本地 SQLite', 'success');
} else if (res) {
showToast(res.status === 'partial' ? '同步部分完成' : '同步未成功', '请查看各数据类型的返回状态', res.status === 'partial' ? 'warning' : 'error');
}
}
@@ -713,6 +790,7 @@ async function listKeys() {
}
function renderKeysTable(data) {
data = responseRows(data);
const container = $('keysTableWrapper');
if (!container) return;
if (!data || !Array.isArray(data) || data.length === 0) {
@@ -736,14 +814,18 @@ function renderKeysTable(data) {
`;
data.forEach(k => {
const masked = String(k.key_masked || '');
const prefix = masked.split('…')[0];
const canRevoke = !k.revoked && /^mif_sk_[0-9a-f]+$/.test(prefix);
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
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><strong>${escape(k.label || '默认')}</strong></td>
<td><code>${escape(masked || '--')}</code></td>
<td>${escape(k.user_id || '--')}</td>
<td>${escape(k.created_at || '--')}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="revokeKeyByPrefix('${k.prefix || k.api_key_prefix}')">吊销</button>
${canRevoke ? `<button class="btn btn-danger btn-sm" onclick="revokeKeyByPrefix('${prefix}')">吊销</button>` : (k.revoked ? '已吊销' : '--')}
</td>
</tr>
`;
@@ -763,7 +845,8 @@ async function revokeKeyByPrefix(prefix) {
}
if (!confirm(`确定要吊销前缀为 ${prefix} 的 API Key 吗?`)) return;
await api('DELETE', `/api/auth/keys/${encodeURIComponent(prefix)}`);
const result = await api('DELETE', `/api/auth/keys/${encodeURIComponent(prefix)}`);
if (result?.status !== 'ok') return;
showToast('Key 吊销指令已发送', '', 'info');
listKeys();
}
@@ -791,11 +874,19 @@ async function checkSystemHealth() {
// Initialization on DOM Ready
document.addEventListener('DOMContentLoaded', () => {
initTheme();
initHealthViews();
initDates();
initApiKeyInput();
renderSyncPills();
checkSystemHealth();
setInterval(checkSystemHealth, 15000);
['startDate', 'endDate'].forEach(id => {
$(id).addEventListener('change', () => {
$$('.preset-pill').forEach(pill => pill.classList.remove('active'));
clearInspector();
reloadActiveView();
});
});
// Bind preset pills
$$('.preset-pill').forEach(pill => {
@@ -0,0 +1,114 @@
/* 查询页交互;沿用现有请求代理、日期和授权。 */
function invalidateHealthQuery(){
++state.healthRequestId;
if(state.healthController)state.healthController.abort();
state.healthController=null;
}
function clearInspector(){
++state.inspectorRequestId;
state.lastResponse=null;state.lastRequestInfo=null;
$('metaUrl').textContent='尚未查询';$('metaStatus').textContent='READY';$('metaLatency').textContent='—';
$('inspectorBody').innerHTML='<div class="inspector-empty-state">当前页面尚未查询,旧页面的响应已清除。</div>';
}
function setInspectorCollapsed(collapsed){
$('mainLayout').classList.toggle('inspector-collapsed',collapsed);
$('inspectorPanel').hidden=collapsed;
$('inspectorToggle').textContent=collapsed?'展开控制台':'收起控制台';
$('inspectorToggle').setAttribute('aria-expanded',String(!collapsed));
localStorage.setItem('mi_fitness_inspector_collapsed',String(collapsed));
}
function toggleInspector(){setInspectorCollapsed(!$('inspectorPanel').hidden);}
function healthPath(kind){
const endpoints={activity:'/api/summary',heartrate:'/api/heart-rate',sleep:'/api/sleep',workouts:'/api/workouts',body:'/api/body-measurements',spo2:'/api/spo2',stress:'/api/stress',abnormal:'/api/abnormal-heart-beat'};
let query=getRangeQuery(endpoints[kind]);
// 接口按源日期筛选;时间戳类型向两侧各取一天,再按北京时间精确归属。
if(kind!=='activity'&&getSd()&&getEd()){
const shift=(date,n)=>new Date(Date.parse(date)+n*86400000).toISOString().slice(0,10);
query=endpoints[kind]+'?start_date='+shift(getSd(),-1)+'&end_date='+shift(getEd(),1);
}
if(kind==='heartrate')query+=getSelQuery('hrType','sample_type');
if(kind==='sleep')query+='&include_naps='+$('napsSelect').value;
if(kind==='body')query+='&latest_only=false';
return query;
}
function healthSelection(kind){
const id={heartrate:'hrLimit',spo2:'spo2Limit',stress:'stressLimit'}[kind];
const text=id?$(id).value.trim():'';
return {limit:text?Number(text):null,latestOnly:kind==='body'&&$('latestOnlyCheck').checked,pressureLevel:kind==='stress'?$('stressLevel').value:'',note:healthScopeNote(kind)};
}
function healthScopeNote(kind){
const notes=[];
const limit={heartrate:'hrLimit',spo2:'spo2Limit',stress:'stressLimit'}[kind];
if(limit&&$(limit).value.trim())notes.push('仅显示最近 '+$(limit).value.trim()+' 条;统计仅针对筛选结果');
if(kind==='body'&&$('latestOnlyCheck').checked)notes.push('仅返回最新一条');
if(kind==='sleep')notes.push($('napsSelect').value==='true'?'包含小睡':'不含小睡');
if(kind==='heartrate'&&$('hrType').value)notes.push('采样类型 '+$('hrType').value);
if(kind==='stress'&&$('stressLevel').value)notes.push('压力等级 '+({relaxed:'放松 1–25',mild:'轻度 26–50',moderate:'中度 51–80',severe:'重度 81–100'}[$('stressLevel').value]||$('stressLevel').value));
return notes.join(';');
}
function reloadActiveView(){
if(state.activeTab==='overview')return loadOverview();
if(HealthViews.kinds.includes(state.activeTab))return loadHealthView(state.activeTab);
if(state.activeTab==='series')return queryMetricSeries();
}
async function loadHealthView(kind){
invalidateHealthQuery();
const id=state.healthRequestId, start=getSd(),end=getEd();
const container=$('healthView-'+kind);
state.healthViews[kind]=null;
clearInspector();
if(!start||!end||start>end){container.innerHTML='<div class="glass-panel health-empty">请选择有效的开始和结束日期。</div>';return;}
const selection=healthSelection(kind);
if(selection.limit!==null&&(!Number.isInteger(selection.limit)||selection.limit<1)){container.innerHTML='<div class="glass-panel health-empty">数量限制需要填写正整数,或留空显示全部记录。</div>';return;}
const path=healthPath(kind),selectionKey=JSON.stringify(selection);
container.innerHTML='<div class="glass-panel health-empty">正在查询健康记录…</div>';
const controller=new AbortController();state.healthController=controller;
const calendarRequest=typeof fetch==='function'?fetch('/api/calendar?start='+start.slice(0,4)+'&end='+end.slice(0,4),{signal:controller.signal})
.then(response=>response.ok?response.json():null).then(value=>value?.years||{}).catch(()=>({})):Promise.resolve({});
const isCurrent=()=>id===state.healthRequestId&&state.activeTab===kind&&getSd()===start&&getEd()===end&&healthPath(kind)===path&&JSON.stringify(healthSelection(kind))===selectionKey;
const result=await api('GET',path,null,{isCurrent,signal:controller.signal});
if(!isCurrent())return;
if(!result||result.status!=='ok'||!Array.isArray(result.data)){
container.innerHTML='<div class="glass-panel health-empty" role="alert">查询失败,未显示旧数据。请重试或展开控制台查看原因。</div>';return;
}
try{
const model=HealthViews.build(kind,result.data,{start,end,...selection});
state.healthViews[kind]={model,page:1};
container.innerHTML=HealthViews.render(model,1);
// 日历晚到只更新当前查询,健康记录不等待外部日历下载。
calendarRequest.then(calendar=>{
if(!isCurrent()||state.healthViews[kind]?.model!==model)return;
model.range.calendar=calendar;
const page=state.healthViews[kind].page;
container.innerHTML=HealthViews.render(model,page);
});
}catch(error){
container.innerHTML='<div class="glass-panel health-empty" role="alert">数据展示失败,请检查日期范围或展开控制台查看原始记录。</div>';
}
}
function healthPage(kind,page){
const saved=state.healthViews[kind];if(!saved)return;
saved.page=Math.min(Math.max(1,Math.ceil(saved.model.records.length/10)),Math.max(1,page));
$('healthView-'+kind).querySelector('.health-table-page').innerHTML=HealthViews.detail(saved.model,saved.page);
}
function exportHealthRecords(kind){
const saved=state.healthViews[kind];if(!saved)return;
const blob=new Blob(['\uFEFF',HealthViews.csv(saved.model)],{type:'text/csv;charset=utf-8'});
const url=URL.createObjectURL(blob),link=document.createElement('a');
link.href=url;link.download=`${saved.model.title}_${saved.model.range.start}_${saved.model.range.end}.csv`;
document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function initHealthViews(){
setInspectorCollapsed(localStorage.getItem('mi_fitness_inspector_collapsed')!=='false');
for(const kind of HealthViews.kinds){
$('healthView-'+kind).addEventListener('click',event=>{
const button=event.target.closest('button');if(!button)return;
if(button.hasAttribute('data-health-page'))healthPage(kind,Number(button.dataset.healthPage));
if(button.hasAttribute('data-health-export'))exportHealthRecords(kind);
});
}
for(const id of ['hrType','hrLimit','napsSelect','latestOnlyCheck','spo2Limit','stressLevel','stressLimit']){
$(id).addEventListener('change',()=>reloadActiveView());
}
}
@@ -0,0 +1,196 @@
/* 健康数据展示:统计全部返回记录,图表和表格限制渲染数量。 */
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
else root.HealthViews=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const kinds=['activity','heartrate','sleep','workouts','body','spo2','stress','abnormal'];
const finite=n=>typeof n==='number'&&Number.isFinite(n);
const num=(v,p=1)=>finite(v)?v.toLocaleString('zh-CN',{maximumFractionDigits:p}):'—';
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const sum=(rows,key)=>{const a=rows.map(r=>r[key]).filter(finite);return a.length?a.reduce((s,x)=>s+x,0):null;};
const avg=(rows,key)=>{const a=rows.map(r=>r[key]).filter(finite);return a.length?a.reduce((s,x)=>s+x,0)/a.length:null;};
const extrema=(rows,key,maximum)=>{let v=null;for(const r of rows)if(finite(r[key]))v=v===null?r[key]:(maximum?Math.max(v,r[key]):Math.min(v,r[key]));return v;};
function day(value){
if(/^\d{4}-\d{2}-\d{2}$/.test(value||''))return value;
const t=Date.parse(value);return Number.isFinite(t)?new Date(t+28800000).toISOString().slice(0,10):'';
}
function stamp(value){const t=Date.parse(value);return Number.isFinite(t)?new Date(t+28800000).toISOString().slice(0,19).replace('T',' '):'—';}
const minutes=v=>finite(v)?`${Math.floor(Math.round(v)/60)}小时${Math.round(v)%60}分`:'—';
const asleep=r=>finite(r.time_asleep_minutes)?r.time_asleep_minutes:r.duration_minutes;
const stage=(r,s)=>{const a=(Array.isArray(r.stages)?r.stages:[]).filter(x=>x.stage===s&&finite(x.minutes));return a.length?a.reduce((n,x)=>n+x.minutes,0):null;};
const card=(label,raw,unit='',note='',format=num)=>({label,raw,value:format(raw),unit,note});
const colors=['#3186cb','#90c9f0','#ac92e7','#94a3b8'];
const pressureLevels=[
{key:'relaxed',label:'放松',min:1,max:25,color:'#22a06b'},
{key:'mild',label:'轻度',min:26,max:50,color:'#d9a20b'},
{key:'moderate',label:'中度',min:51,max:80,color:'#e78325'},
{key:'severe',label:'重度',min:81,max:100,color:'#db5267'},
];
function pressureBand(value){
if(!finite(value)||value<1||value>100)return {key:'unclassified',label:'未分类'};
return pressureLevels.find(level=>value<=level.max);
}
function calendarDay(date,calendar={}){
const year=calendar?.[date.slice(0,4)];
const known=year&&Array.isArray(year.days)&&year.days.length&&Array.isArray(year.papers)&&year.papers.length;
const override=known?year.days.find(row=>row.date===date):null;
const weekday=new Date(date+'T00:00:00Z').getUTCDay();
return {off:override?override.isOffDay:weekday===0||weekday===6,provisional:!known};
}
function calendarLegend(m){
if(!m.records.length)return '';
if(m.stride>1)return '<div class="health-caption">多日汇总不标注单日工作属性;缩小到 120 天以内可查看每日标记。</div>';
const missing=[...new Set(m.points.filter(p=>calendarDay(p.date,m.range.calendar).provisional).map(p=>p.date.slice(0,4)))];
return `<div class="health-calendar-legend"><span><i style="background:#e78325"></i>工作日</span><span><i style="background:#22a06b"></i>非工作日</span></div><div class="health-caption">${missing.length?esc(missing.join('、'))+' 年调休安排未更新,暂按周一至周五/周末区分。':'按中国大陆节假日及调休安排标注。'}</div>`;
}
const config={
activity:{title:'每日活动',date:'date',field:'steps',unit:'步',trend:'每日步数',mode:'sum',columns:[['日期','date'],['步数(步)','steps'],['距离(km)','distance_m',v=>finite(v)?num(v/1000,2):'—'],['活动热量(kcal)','active_kcal'],['总热量(kcal)','total_kcal'],['活动时长(分钟)','active_minutes']]},
heartrate:{title:'心率监测',date:'timestamp',field:'bpm',unit:'bpm',trend:'每日心率均值',mode:'mean',columns:[['记录时间','timestamp',stamp],['心率(bpm)','bpm'],['采样类型','sample_type',v=>({resting:'静息',passive:'被动采样',active:'运动',manual:'手动'}[v]||v||'未标注')]]},
sleep:{title:'睡眠分析',date:'end_at',field:'time_asleep_minutes',unit:'小时',trend:'每日睡眠时长',mode:'sleep',columns:[['醒来日期','end_at',day],['入睡时间','start_at',stamp],['醒来时间','end_at',stamp],['实睡时长','_asleep',minutes],['深睡','_deep',minutes],['浅睡','_light',minutes],['REM','_rem',minutes],['清醒(分钟)','time_awake_minutes'],['小睡','is_nap',v=>v?'是':'否'],['评分','sleep_score']]},
workouts:{title:'运动记录',date:'start_at',field:'duration_minutes',unit:'分钟',trend:'每日运动时长',mode:'sum',columns:[['开始时间','start_at',stamp],['运动类型','activity_type'],['时长(分钟)','duration_minutes'],['距离(km)','distance_m',v=>finite(v)?num(v/1000,2):'—'],['热量(kcal)','calories_kcal'],['平均心率(bpm)','avg_heart_rate_bpm'],['最高心率(bpm)','max_heart_rate_bpm']]},
body:{title:'体成分与体重',date:'timestamp',field:'weight_kg',unit:'kg',trend:'每日最近一次体重',mode:'latest',columns:[['测量时间','timestamp',stamp],['体重(kg)','weight_kg'],['BMI','bmi'],['体脂率(%)','body_fat_pct'],['肌肉量(kg,源字段)','muscle_mass_kg'],['水分(%)','water_pct'],['骨量(kg)','bone_mass_kg'],['内脏脂肪等级','visceral_fat_score'],['基础代谢(kcal/日)','basal_metabolism_kcal'],['代谢年龄(岁)','metabolic_age']]},
spo2:{title:'血氧饱和度',date:'timestamp',field:'spo2_pct',unit:'%',trend:'每日血氧均值',mode:'mean',columns:[['记录时间','timestamp',stamp],['血氧(%)','spo2_pct'],['日期','timestamp',day]]},
stress:{title:'压力指数',date:'timestamp',field:'stress_score',unit:'指数',trend:'每日压力均值',mode:'mean',columns:[['记录时间','timestamp',stamp],['压力指数','stress_score'],['等级','_pressureBand']]},
abnormal:{title:'异常心跳',date:'start_at',field:'duration_seconds',unit:'次',trend:'每日已保存事件数',mode:'count',columns:[['开始时间','start_at',stamp],['结束时间','end_at',stamp],['持续时长(秒)','duration_seconds']]},
};
function build(kind,records,range){
const c=config[kind];if(!c)throw new Error('不支持的查询类型');
let rows=records.filter(r=>{const key=day(r[c.date]);return key&&key>=range.start&&key<=range.end;})
.sort((a,b)=>Date.parse(b[c.date])-Date.parse(a[c.date]));
if(kind==='stress'&&range.pressureLevel)rows=rows.filter(r=>pressureBand(r.stress_score).key===range.pressureLevel);
if(range.latestOnly)rows=rows.slice(0,1);
if(range.limit)rows=rows.slice(0,range.limit);
const groups=new Map();
for(const r of rows){const key=day(r[c.date]);if(key&&key>=range.start&&key<=range.end){if(!groups.has(key))groups.set(key,[]);groups.get(key).push(r);}}
const coverage=groups.size, latest=rows[0]||{};
let cards,legend=[c.trend];
if(kind==='activity')cards=[card('期间总步数',sum(rows,'steps'),'步'),card('活动消耗',sum(rows,'active_kcal'),'kcal'),card('累计距离',sum(rows,'distance_m')===null?null:sum(rows,'distance_m')/1000,'km'),card('记录覆盖',coverage,'天')];
else if(kind==='sleep'){
const daily=[...groups.values()].map(g=>({minutes:sum(g.map(r=>({value:asleep(r)})),'value'),deep:sum(g.map(r=>({value:stage(r,'deep')})),'value')}));
cards=[card('日均实睡',avg(daily,'minutes'),'','仅计算有记录的日期,含筛选后小睡',minutes),card('记录覆盖',coverage,'天'),card('日均深睡',avg(daily,'deep'),'','仅计算有深睡记录的日期',minutes),card('小睡记录',rows.filter(r=>r.is_nap).length,'次')];
legend=['深睡','浅睡','REM','未分期'];
}else if(kind==='heartrate'){
cards=[card('被动心率均值',avg(rows.filter(r=>r.sample_type==='passive'),'bpm'),'bpm'),card('静息心率均值',avg(rows.filter(r=>r.sample_type==='resting'),'bpm'),'bpm'),card('最低心率',extrema(rows,'bpm',false),'bpm'),card('最高心率',extrema(rows,'bpm',true),'bpm')];
legend=['被动采样','静息','其他类型'];
}else if(kind==='workouts')cards=[card('运动次数',rows.length,'次'),card('运动时长',sum(rows,'duration_minutes'),'分钟'),card('累计距离',sum(rows,'distance_m')===null?null:sum(rows,'distance_m')/1000,'km'),card('运动热量',sum(rows,'calories_kcal'),'kcal')];
else if(kind==='body')cards=[card('最近体重',latest.weight_kg,'kg'),card('最近 BMI',latest.bmi),card('最近体脂率',latest.body_fat_pct,'%'),card('测量记录',rows.length,'次',rows.length<2?'不足两次测量,无法判断体重变化趋势':'')];
else if(kind==='abnormal')cards=[card('已保存事件',rows.length,'次'),card('累计时长',sum(rows,'duration_seconds'),'秒'),card('有记录日期',coverage,'天'),card('最近事件',null,'','',()=>rows.length?stamp(latest.start_at):'未保存')];
else cards=[card(`平均${kind==='spo2'?'血氧':'压力'}`,avg(rows,c.field),c.unit),card('最低值',extrema(rows,c.field,false),c.unit),card('最高值',extrema(rows,c.field,true),c.unit),card('记录覆盖',coverage,'天')];
const span=Math.floor((Date.parse(range.end)-Date.parse(range.start))/86400000)+1;
if(!Number.isFinite(span)||span<1||span>3660)throw new Error('请选择有效日期范围(最多3660天)');
const stride=Math.ceil(span/120), points=[];
for(let i=0;i<span;i+=stride){
const date=new Date(Date.parse(range.start)+i*86400000).toISOString().slice(0,10);
const g=[];for(let j=i;j<Math.min(i+stride,span);j++){const key=new Date(Date.parse(range.start)+j*86400000).toISOString().slice(0,10);for(const r of groups.get(key)||[])g.push(r);}
let values;
if(kind==='sleep'){
const total=sum(g.map(r=>({v:asleep(r)})),'v');
const stages=['deep','light','rem'].map(s=>sum(g.map(r=>({v:stage(r,s)})),'v'));
const phaseSum=stages.reduce((s,x)=>s+(x??0),0);
values=total===null?[null,null,null,null]:phaseSum>total?[null,null,null,total/60]:[...stages.map(x=>x===null?null:x/60),Math.max(0,total-phaseSum)/60];
}else if(kind==='heartrate')values=[avg(g.filter(r=>r.sample_type==='passive'),'bpm'),avg(g.filter(r=>r.sample_type==='resting'),'bpm'),avg(g.filter(r=>!['passive','resting'].includes(r.sample_type)),'bpm')];
else if(c.mode==='latest'){
const last=g.reduce((best,r)=>finite(r[c.field])&&(!best||Date.parse(r[c.date])>Date.parse(best[c.date]))?r:best,null);
values=[last?.[c.field]??null];
}
else values=[!g.length?null:c.mode==='count'?g.length:c.mode==='sum'?sum(g,c.field):avg(g,c.field)];
points.push({date,values,missing:values.every(x=>x===null)});
}
const labels=Object.fromEntries(c.columns.map(([label,key])=>[key,label]));
const cell=(r,key)=>key==='_asleep'?asleep(r):key==='_deep'?stage(r,'deep'):key==='_light'?stage(r,'light'):key==='_rem'?stage(r,'rem'):key==='_pressureBand'?pressureBand(r.stress_score).label:r[key];
const references=[];
const reference=(key,label,value,color)=>{if(finite(value))references.push({key,label,value,color});};
let referenceNote='';
if(kind==='activity'){
reference('average','日均',avg([...groups.values()].map(g=>({value:sum(g,'steps')})),'value'),'#d97706');
reference('fixed','参考',7000,'#22a06b');referenceNote='平均步数按有步数记录的日期计算。';
}else if(kind==='heartrate'){
reference('average','总平均',avg(rows,'bpm'),'#d97706');
reference('resting-average','静息平均',avg(rows.filter(r=>r.sample_type==='resting'),'bpm'),'#9467cf');
reference('fixed','参考',170,'#db5267');referenceNote='总平均包含全部筛选样本,静息平均仅计算静息记录。';
}else if(kind==='sleep'){
reference('average','日均实睡',cards[0].raw===null?null:cards[0].raw/60,'#d97706');
reference('fixed','参考',8,'#22a06b');referenceNote='平均线按有实睡记录的醒来日计算,包含筛选后的小睡。';
}else if(kind==='body'){
reference('average','平均',avg(rows,'weight_kg'),'#d97706');reference('fixed','参考',65,'#22a06b');
referenceNote='平均线使用全部筛选体重测量记录;柱状图保留每日最近一次测量。';
}else if(kind==='spo2'){
reference('average','平均',avg(rows,'spo2_pct'),'#d97706');referenceNote='平均线按全部筛选血氧样本计算。';
}else if(kind==='stress'){
reference('average','平均',avg(rows,'stress_score'),'#d97706');
pressureLevels.slice(0,3).forEach(level=>reference('stress-'+level.max,level.label+'上界',level.max,level.color));
referenceNote='平均线按全部筛选压力样本计算;0 或超出 1–100 的数值保留为未分类。';
}
return {kind,...c,cards,legend,points,records:rows,range:{...range},coverage,stride,labels,cell,references,referenceNote};
}
function referenceLayout(m){
const stacked=m.kind==='sleep',min=({body:40,spo2:80,heartrate:40})[m.kind]??0;let dataMax=min;
for(const point of m.points)dataMax=Math.max(dataMax,stacked?point.values.reduce((s,v)=>s+(v??0),0):Math.max(...point.values.map(v=>v??0)));
const span=dataMax>min?dataMax-min:min>0?10:1;
const power=10**Math.floor(Math.log10(span));
const rounded=[1,1.2,1.5,2,2.5,3,4,5,6,8,10].find(n=>n>=span/power)||10;
const max=min+rounded*power;
const inside=m.references.filter(r=>r.value>=min&&r.value<=max).map(r=>({...r,lineY:172-(r.value-min)/(max-min)*158})).sort((a,b)=>a.lineY-b.lineY);
inside.forEach((r,i)=>{r.labelY=Math.max(25,Math.min(164,r.lineY-5),i?inside[i-1].labelY+19:25);});
if(inside.length&&inside.at(-1).labelY>164){const shift=inside.at(-1).labelY-164;inside.forEach(r=>r.labelY-=shift);}
return {min,max,inside,outside:m.references.filter(r=>r.value>max||r.value<min)};
}
function referenceHeader(m){
const layout=referenceLayout(m),outside=m.records.length?layout.outside:[];
if(!outside.length)return '';
return `<div class="health-reference-overflow">${outside.map(r=>`<span class="health-reference-badge" style="color:${r.color}" title="超出当前纵轴,仅在上方标注">${esc(r.label)} ${esc(num(r.value))}${esc(m.unit)} ${r.value<layout.min?'↓':'↑'}</span>`).join('')}<span class="health-caption">超出当前纵轴</span></div>`;
}
function pressureLegend(m){
return m.kind==='stress'?`<div class="health-pressure-legend">${pressureLevels.map(level=>`<span><i style="background:${level.color}"></i>${level.label} ${level.min}–${level.max}</span>`).join('')}</div>`:'';
}
function chart(m){
if(!m.records.length)return '<div class="health-empty">NAS 在所选期间未保存此类记录。未保存不表示正常或没有发生。</div>';
const height=158, step=880/m.points.length, stacked=m.kind==='sleep';
const layout=referenceLayout(m),max=layout.max,min=layout.min;let content='',below=false;
for(let i=0;i<=4;i++){const y=14+height-i*height/4;content+=`<line x1="58" y1="${y}" x2="948" y2="${y}" class="health-grid"/><text x="48" y="${y+4}" text-anchor="end">${esc(num(min+(max-min)*i/4))}</text>`;}
m.points.forEach((p,i)=>{
const x=62+i*step,width=Math.min(step*.65,24);let base=14+height;
if(p.missing)content+=`<text x="${x+step/2}" y="${base-4}" text-anchor="middle" class="health-missing">·</text>`;
p.values.forEach((v,j)=>{if(v===null)return;const h=(stacked?v:Math.max(min,v)-min)/(max-min)*height;
const bx=stacked?x+(step-width)/2:x+(step-width)/2+j*width/p.values.length;
const bw=stacked?width:width/p.values.length;
const y=stacked?base-h:14+height-h;
content+=`<rect x="${bx}" y="${y}" width="${bw}" height="${h}" fill="${colors[j]}"><title>${esc(p.date)} ${esc(m.legend[j])}:${esc(num(v,2))} ${esc(m.unit)}</title></rect>`;
if(v<min){below=true;content+=`<text x="${bx+bw/2}" y="172" text-anchor="middle"><title>${esc(p.date)} ${esc(num(v,2))}${esc(m.unit)},低于纵轴下限 ${min}</title>↓</text>`;}
if(stacked)base=y;
});
if(i===0||i===m.points.length-1||i%Math.max(1,Math.ceil(m.points.length/7))===0)content+=`<text x="${x+step/2}" y="194" text-anchor="middle">${esc(p.date.slice(5))}</text>`;
if(m.stride===1){
const classification=calendarDay(p.date,m.range.calendar),cx=x+step/2,half=Math.min(4,step*.3);
content+=`<line class="health-calendar-mark" data-date="${p.date}" x1="${cx-half}" x2="${cx+half}" y1="207" y2="207" stroke="${classification.off?'#22a06b':'#e78325'}" stroke-width="2.5" stroke-linecap="round"><title>${p.date} ${classification.off?'非工作日':'工作日'}${classification.provisional?'(暂按周末判断,调休安排未更新)':''}</title></line>`;
}
});
for(const r of layout.inside){
const label=`${r.label} ${num(r.value)}${m.unit}`;
const width=Math.min(225,[...label].reduce((n,c)=>n+(c.charCodeAt(0)>255?11:6.5),12));
content+=`<line class="health-reference" data-reference-key="${r.key}" data-reference-value="${r.value}" x1="58" y1="${r.lineY}" x2="948" y2="${r.lineY}" stroke="${r.color}" stroke-width="1.2" stroke-dasharray="5 4"/>`;
content+=`<path d="M65 ${r.lineY} L65 ${r.labelY-5}" stroke="${r.color}" fill="none"/><rect class="health-reference-label" x="66" y="${r.labelY-12}" width="${width}" height="17" rx="3" stroke="${r.color}" stroke-width="0.5"/><text x="71" y="${r.labelY}" style="fill:${r.color}">${esc(label)}</text>`;
}
return `<svg viewBox="0 0 960 220" role="img" aria-label="${esc(m.trend)}(${esc(m.unit)})">${content}</svg>${calendarLegend(m)}${below?'<div class="health-caption">↓ 表示低于纵轴下限;明细和统计保留原值。</div>':''}`;
}
function detail(m,page=1){
const pages=Math.max(1,Math.ceil(m.records.length/10));page=Math.min(pages,Math.max(1,page));
const slice=m.records.slice((page-1)*10,page*10);
return `<div class="data-table-container"><table class="data-table"><thead><tr>${m.columns.map(([label])=>`<th>${esc(label)}</th>`).join('')}</tr></thead><tbody>${slice.map(r=>`<tr>${m.columns.map(([,key,format])=>{const value=m.cell(r,key);return `<td>${esc(format?format(value):finite(value)?num(value,2):value)}</td>`;}).join('')}</tr>`).join('')}</tbody></table></div>${!slice.length?'<div class="health-empty">NAS 在所选期间未保存此类记录</div>':''}<div class="health-pagination"><span>共 ${m.records.length.toLocaleString('zh-CN')} 条 · 每页 10 条</span><div><button class="btn btn-outline btn-sm" data-health-page="${page-1}" ${page===1?'disabled':''}>上一页</button><span> ${page} / ${pages} </span><button class="btn btn-outline btn-sm" data-health-page="${page+1}" ${page===pages?'disabled':''}>下一页</button></div></div>`;
}
function render(m,page=1){
const title=`${m.range.start} 至 ${m.range.end} · 北京时间`;
return `<div class="health-range">${esc(title)}${m.range.note?` · ${esc(m.range.note)}`:''}</div><div class="health-cards">${m.cards.map(c=>`<div class="health-card"><div>${esc(c.label)}</div><strong>${esc(c.value)} <small>${esc(c.unit)}</small></strong><div class="health-caption">${esc(c.note)}</div></div>`).join('')}</div><section class="glass-panel health-trend"><div class="panel-header"><h3>${esc(m.trend)}(${esc(m.unit)})</h3><div class="health-legend">${m.legend.map((v,i)=>`<span><i style="background:${colors[i]}"></i>${esc(v)}</span>`).join('')}</div></div>${referenceHeader(m)}${chart(m)}${pressureLegend(m)}<div class="health-caption">${esc(m.referenceNote)}缺失记录留空,不补 0;悬停可查看数值。${m.stride>1?`每柱汇总 ${m.stride} 天,明细保留全部记录。`:''}${m.kind==='sleep'?'按醒来日期汇总,实睡不含清醒。阶段数据不完整或不一致时显示为未分期。':''}</div></section><section class="glass-panel health-details"><div class="panel-header"><h3>${esc(m.title)}明细</h3><button class="btn btn-outline btn-sm" data-health-export>导出全部查询结果</button></div><div class="health-table-page">${detail(m,page)}</div></section>`;
}
function csv(m){
// 导出完整接口字段;防止电子表格把源文本作为公式执行。
const keys=[...new Set(m.records.flatMap(r=>Object.keys(r)))];
if(m.kind==='stress')keys.push('_pressureBand');
const quote=v=>{let s=v===null||v===undefined?'':typeof v==='object'?JSON.stringify(v):String(v);if(typeof v==='string'&&/^[\s]*[=+@-]/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';};
const header=k=>m.kind==='stress'&&k==='level'?'源等级(原值)':k==='_pressureBand'?'压力等级(当前分界)':m.labels[k]||k;
return [keys.map(k=>quote(header(k))).join(','),...m.records.map(r=>keys.map(k=>quote(k==='_pressureBand'?m.cell(r,k):r[k])).join(','))].join('\r\n');
}
return {kinds,build,render,detail,csv,day,stamp,pressureBand,referenceLayout,calendarDay};
});
@@ -4,7 +4,8 @@
<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="stylesheet" href="/static/css/style.css?v=20261017-ui7">
<link rel="stylesheet" href="/static/css/health.css?v=20261017-ui7">
</head>
<body>
<div id="app-container">
@@ -49,7 +50,7 @@
</header>
<!-- Main Layout Grid -->
<div class="main-layout">
<div class="main-layout inspector-collapsed" id="mainLayout">
<!-- Left Sidebar Navigation -->
<aside class="app-sidebar">
<div>
@@ -143,6 +144,7 @@
<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>
<button class="btn btn-outline btn-sm" id="inspectorToggle" onclick="toggleInspector()" aria-expanded="false" aria-controls="inspectorPanel" style="margin-left:auto">展开控制台</button>
</div>
<!-- 1. Overview Tab -->
@@ -150,7 +152,7 @@
<div class="panel-header">
<div>
<div class="panel-title"><span class="title-icon">📊</span> 健康指标总览</div>
<div class="panel-subtitle">基于选定时间范围的最新健康数据概览</div>
<div class="panel-subtitle" id="overviewRange">按所选日期汇总;均值仅计算有记录的数据</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="loadOverview()">🔄 刷新数据</button>
</div>
@@ -158,73 +160,73 @@
<div class="stat-cards-grid">
<div class="stat-card steps">
<div class="stat-card-header">
<span class="stat-card-title">今日总步数</span>
<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>
<span id="statActivityCoverage">--</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>
<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>
<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>
<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>
<span id="statHrCoverage">--</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>
<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>
<span id="statSleepCoverage">--</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>
<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>
<span id="statSpo2Coverage">--</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>
<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>
<span class="stat-card-badge neutral">均值</span>
</div>
</div>
</div>
@@ -241,27 +243,28 @@
</section>
<!-- 2. Daily Activity Tab -->
<section class="tab-pane" id="tab-activity">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">👟</span> 每日活动</div>
<div class="panel-subtitle">步数、活动距离、日常活动卡路里消耗</div>
</div>
<button class="btn btn-primary" onclick="querySummary()">查询活动数据</button>
</div>
<div id="summaryVisualContainer" style="margin-top: 14px;">
<div hidden id="summaryVisualContainer" style="margin-top: 14px;">
<p class="panel-subtitle">点击上方按钮查询所选日期范围内的每日活动汇总...</p>
</div>
</div>
<div class="health-results" id="healthView-activity" aria-live="polite"></div>
</section>
<!-- 3. Heart Rate Tab -->
<section class="tab-pane" id="tab-heartrate">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">❤️</span> 心率监测</div>
<div class="panel-subtitle">逐分钟心率采样点、静息心率与极限心率统计</div>
</div>
<button class="btn btn-primary" onclick="queryHeartRate()">查询心率数据</button>
@@ -269,7 +272,7 @@
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">心率样本类型 (sample_type)</label>
<label class="form-label">采样类型</label>
<select id="hrType" class="select-control">
<option value="">全部类型 (All)</option>
<option value="passive">被动日常监测 (passive)</option>
@@ -278,19 +281,20 @@
</select>
</div>
<div class="form-group sm">
<label class="form-label">返回上限 (limit)</label>
<input type="number" id="hrLimit" class="input-control" placeholder="默认 100">
<label class="form-label">仅显示最近条数</label>
<input type="number" id="hrLimit" class="input-control" placeholder="留空显示全部">
</div>
</div>
</div>
<div class="health-results" id="healthView-heartrate" aria-live="polite"></div>
</section>
<!-- 4. Sleep Tab -->
<section class="tab-pane" id="tab-sleep">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">💤</span> 睡眠分析</div>
<div class="panel-subtitle">入睡与清醒时间、深睡/浅睡/REM分期、零星小睡统计</div>
</div>
<button class="btn btn-primary" onclick="querySleep()">查询睡眠分析</button>
@@ -298,37 +302,39 @@
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">包含小睡 (include_naps)</label>
<label class="form-label">睡眠范围</label>
<select id="napsSelect" class="select-control">
<option value="true">包含零星小睡 (True)</option>
<option value="false">仅主睡眠阶段 (False)</option>
<option value="true">包含小睡</option>
<option value="false">不含小睡</option>
</select>
</div>
</div>
<div id="sleepTimelineWrapper" style="margin-top: 16px;"></div>
<div hidden id="sleepTimelineWrapper" style="margin-top: 16px;"></div>
</div>
<div class="health-results" id="healthView-sleep" aria-live="polite"></div>
</section>
<!-- 5. Workouts Tab -->
<section class="tab-pane" id="tab-workouts">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">🏃</span> 运动记录</div>
<div class="panel-subtitle">户外跑步、骑行、徒步等各项运动轨迹、心率区间与配速</div>
</div>
<button class="btn btn-primary" onclick="queryWorkouts()">查询运动记录</button>
</div>
</div>
<div class="health-results" id="healthView-workouts" aria-live="polite"></div>
</section>
<!-- 6. Body Measurements Tab -->
<section class="tab-pane" id="tab-body">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">⚖️</span> 体成分与体重</div>
<div class="panel-subtitle">体脂率、BMI、肌肉量、骨量、内脏脂肪等(需体脂秤支持)</div>
</div>
<button class="btn btn-primary" onclick="queryBody()">查询体成分数据</button>
@@ -337,17 +343,18 @@
<label class="custom-checkbox">
<input type="checkbox" id="latestOnlyCheck">
<div class="checkbox-box">✓</div>
<span>仅返回最新一条记录 (latest_only=true)</span>
<span>仅显示最新一条记录</span>
</label>
</div>
<div class="health-results" id="healthView-body" aria-live="polite"></div>
</section>
<!-- 7. SpO2 Tab -->
<section class="tab-pane" id="tab-spo2">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">🫁</span> 血氧饱和度</div>
<div class="panel-subtitle">全天血氧饱和度连续采样与低血氧告警</div>
</div>
<button class="btn btn-primary" onclick="querySpo2()">查询血氧记录</button>
@@ -355,19 +362,20 @@
<div class="form-row" style="margin-top: 14px;">
<div class="form-group sm">
<label class="form-label">数量限制 (limit)</label>
<label class="form-label">仅显示最近条数</label>
<input type="number" id="spo2Limit" class="input-control" placeholder="如 50">
</div>
</div>
</div>
<div class="health-results" id="healthView-spo2" aria-live="polite"></div>
</section>
<!-- 8. Stress Tab -->
<section class="tab-pane" id="tab-stress">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">🧠</span> 压力指数</div>
<div class="panel-subtitle">全天压力值、压力等级分布与放松建议</div>
</div>
<button class="btn btn-primary" onclick="queryStress()">查询压力记录</button>
@@ -375,33 +383,36 @@
<div class="form-row" style="margin-top: 14px;">
<div class="form-group">
<label class="form-label">压力等级过滤 (level)</label>
<label class="form-label">压力等级</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>
<option value="relaxed">放松(1–25)</option>
<option value="mild">轻度(26–50)</option>
<option value="moderate">中度(51–80)</option>
<option value="severe">重度(81–100)</option>
</select>
</div>
<div class="form-group sm">
<label class="form-label">数量限制 (limit)</label>
<label class="form-label">仅显示最近条数</label>
<input type="number" id="stressLimit" class="input-control" placeholder="如 50">
</div>
</div>
</div>
<div class="health-results" id="healthView-stress" aria-live="polite"></div>
</section>
<!-- 9. Abnormal Heart Beat Tab -->
<section class="tab-pane" id="tab-abnormal">
<section class="tab-pane health-query-tab" 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-title"><span class="title-icon">⚠️</span> 异常心跳</div>
<div class="panel-subtitle">静息心率过高、过低或心律不齐事件记录</div>
</div>
<button class="btn btn-primary" onclick="queryAbnormalHr()">查询异常心跳</button>
</div>
</div>
<div class="health-results" id="healthView-abnormal" aria-live="polite"></div>
</section>
<!-- 10. Metric Series Tab -->
@@ -542,12 +553,13 @@
</main>
<!-- Right Inspector & Developer Console -->
<aside class="app-inspector">
<aside class="app-inspector" id="inspectorPanel" hidden>
<div class="inspector-header">
<div class="inspector-title">
<span>💻</span> 实时控制台与响应检视
</div>
<div class="btn-group">
<button class="btn btn-secondary btn-sm" onclick="setInspectorCollapsed(true)" title="收起控制台">收起</button>
<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>
@@ -629,7 +641,9 @@
<div class="toast-container" id="toastContainer"></div>
<!-- Scripts -->
<script src="/static/js/app.js"></script>
<script src="/static/js/health-views.js?v=20261017-ui7"></script>
<script src="/static/js/health-ui.js?v=20261017-ui7"></script>
<script src="/static/js/app.js?v=20261017-ui7"></script>
<script>
function toggleApiKeyVisibility() {
const input = $('apiKeyInput');
+134
View File
@@ -0,0 +1,134 @@
"""公开节假日日历:本地优先、异步更新,不发送健康记录或登录凭据。"""
import datetime as dt
import json
import os
import threading
import time
import urllib.parse
from pathlib import Path
SOURCE = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json'
MAX_BYTES = 65536
def validate_calendar(value, year):
if not isinstance(value, dict) or type(value.get('year')) is not int or value['year'] != year:
raise ValueError('日历年份不符')
papers, days = value.get('papers'), value.get('days')
if not isinstance(papers, list) or not papers or not all(
isinstance(p, str) and urllib.parse.urlparse(p).scheme == 'https'
and (urllib.parse.urlparse(p).hostname or '').endswith('.gov.cn') for p in papers
):
raise ValueError('缺少官方日历来源')
if not isinstance(days, list) or not 1 <= len(days) <= 120:
raise ValueError('日历尚未发布或记录无效')
seen = set()
for row in days:
if not isinstance(row, dict) or type(row.get('isOffDay')) is not bool:
raise ValueError('无效日期分类')
if not isinstance(row.get('date'), str):
raise ValueError('无效日期')
date = dt.date.fromisoformat(row['date'])
if date.year != year or date.isoformat() != row['date'] or date in seen:
raise ValueError('无效或重复日期')
seen.add(date)
return {'year': year, 'papers': papers, 'days': [
{'date': r['date'], 'isOffDay': r['isOffDay']} for r in days
]}
def download_calendar(year):
# 固定公开地址、TLS 验证、无重定向/凭据,限制下载体积和等待时间。
import httpx
with httpx.Client(trust_env=False, timeout=3, follow_redirects=False) as client:
with client.stream('GET', SOURCE.format(year=year)) as response:
response.raise_for_status()
body = bytearray()
for chunk in response.iter_bytes():
body.extend(chunk)
if len(body) > MAX_BYTES:
raise ValueError('日历文件过大')
return json.loads(body)
class CalendarStore:
def __init__(self, directory, bundled, fetcher=download_calendar):
self.directory, self.bundled = Path(directory), Path(bundled)
self.fetcher = fetcher
self.lock = threading.Lock()
self.values, self.attempted, self.pending = {}, {}, set()
self.running = False
def get(self, year):
with self.lock:
if year in self.values:
return self.values[year]
for path in [self.directory / f'{year}.json', self.bundled / f'{year}.json']:
try:
if path.stat().st_size > MAX_BYTES:
continue
value = validate_calendar(json.loads(path.read_text(encoding='utf-8')), year)
self.values[year] = value
return value
except (OSError, ValueError, TypeError, KeyError):
continue
return None
def refresh(self, year):
try:
value = validate_calendar(self.fetcher(year), year)
self.directory.mkdir(parents=True, exist_ok=True)
path = self.directory / f'{year}.json'
temporary = path.with_suffix('.json.tmp')
temporary.write_text(json.dumps(value, ensure_ascii=False) + '\n', encoding='utf-8')
os.replace(temporary, path)
with self.lock:
self.values[year] = value
except (OSError, ValueError, TypeError, KeyError):
pass
except Exception:
# 下载超时/连接失败保持旧缓存,不能阻塞健康查询或覆盖已知日历。
pass
def schedule(self, years):
current = dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).year
with self.lock:
for year in years:
if 2004 <= year <= current + 1 and time.monotonic() - self.attempted.get(year, -86400) >= 86400:
self.pending.add(year)
self.attempted[year] = time.monotonic()
if self.pending and not self.running:
self.running = True
threading.Thread(target=self._drain, daemon=True, name='holiday-calendar').start()
def _drain(self):
while True:
with self.lock:
if not self.pending:
self.running = False
return
year = min(self.pending)
self.pending.remove(year)
self.refresh(year)
def register_calendar(app, assets_path):
from flask import jsonify, request
store = CalendarStore(os.environ.get('MI_FITNESS_CALENDAR_DIR', '/tmp/health-calendar'),
assets_path / 'static' / 'calendars')
app.extensions['workday_calendar'] = store
@app.get('/api/calendar')
def calendar():
try:
start, end = int(request.args['start']), int(request.args['end'])
if not (2004 <= start <= end <= 2100 and end - start <= 10):
raise ValueError()
except (KeyError, ValueError):
return jsonify({'error': '无效日历年份范围'}), 400
years = list(range(start, end + 1))
values = {str(year): store.get(year) for year in years}
# 按需每日检查,同时预取来年,浏览器只访问本机网页服务。
current = dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).year
store.schedule(set(years) | {current, current + 1})
return jsonify({'years': {key: value for key, value in values.items() if value is not None}})