保存 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

@@ -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');