mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-19 11:43:47 +08:00
保存 NAS 健康系统 UI7 代码与本机维护基线
This commit is contained in:
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; }
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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');
|
||||
|
||||
Reference in new issue
Block a user